simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

PlayFab:Unityゲーム内課金システムPayPal編

個人ゲーム制作でも何百万人にも膨れ上がるユーザーを平気で捌ける順応性のあるバックエンドサービスを短期無料で作れる時代になりました。
Amazon の GameSparks, Google の Firebase, Microsoft の PlayFab, 富士通クラウドテクノロジーズのmobile backend など様々なところから mobile backend as a service (mBaaS)というものが、ウチが一番だ、これから成長するんだと宣伝している時代です。

中でも、ゲーム内に登場するアイテムを実際のお金(USDや日本円)で購入するシステムを提供している Microsoft の PlayFab を、今回は日本語の紹介が少ない中、実際に動かして解説していきたいと思います。

初見さんで PlayFab を Unity で使えるようにする手順が気になる方はこちらを一読してなぞってみましょう。
qiita.com
要約すると PlayFab のアカウントとゲームタイトルを作成して
PlayFab SDK 使うための Unity アセットをインポート後
タイトルIDをエディタ拡張で記入すれば
アカウント作成 or ログインのクライアント API 呼び出しが成功することを確認できます。(慣れてくると数十分の手作業)

もっと具体的には次の一行を Start に置くだけの作業

    public void Start()
    {
        PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
        { CustomId = "GettingStartedGuide", CreateAccount = true },
        OnLoginSuccess, OnLoginFailure);
    }

以降は、上記のカスタムIDにリンクしたプレイヤーとしてゲームの API を叩くことを意識しておいてください。
(何このログイン、キモ!?と思う方は前の記事の「プレイヤー認証」を読んでね)

PlayFab の他に支払い処理をするアドオンとの連携が必須

Microsoft Store, Google PlayApp Store などの配信サービスにアップロードせず、Windows 用のゲームとして審査いらずで課金システムを設けることができます。(そんな手段も取れます)
自身の Web サイトからゲーム本体をダウンロードできるような仕組みにしても良いですね。
ただしクレジットカード情報や実際のお金の移動はデリケートな処理です。そもそもカード会社が我々を信用しない…
そこで PlayFab のアプリ内課金を支援する3rdパーティ製のアドオンにその大事な情報を任せて、
金銭授受の結果だけをゲームに反映させることができる機能が PlayFab にはあります。(もちろん金の移動先はあなたの銀行口座です。)

早くそのやり方を確認したい!という方、細かい手順はすべて次の公式ドキュメントにまとめられています。(省略せず、以下は具体的な手順を日本語で解説してます。)
Non-Receipt Payment Processing
アドオンは PayPal や Steam, Amazon などいくつかありますが、中でもめちゃめちゃ簡単なのが Steam とのこと
でも Steam の開発者登録とアプリ登録って数週間は待つことになるので、本記事はすぐに確認できる PayPal のビジネスアカウントを使って動作確認してみました。
(ゲーム内処理は Steam とほとんど同じです、容易に切り替えも可能)

本当に3rdParty製のアドオンとの連携が必要なんですか?PlayFab単体でどうにかできませんか?→無理です。何かしらの 3rdParty 製のサービスで振り込み先の銀行口座を登録してください。(次が情報ソース)
community.playfab.com

PayPal のビジネスアカウントを作る

初めての人は次のリンクから
PayPal(ペイパル) - かんたん&安全なオンライン決済サービス
にサインアップ&ログインします。

アカウントの種類は2つありますが、今回の課金システムを試すなら受け取りも可能なビジネスアカウントにしましょう。

f:id:simplestar_tech:20190816213711p:plain
あなたが作りたいのはビジネスアカウントですよね

基本的に個人での登録であること、本名と住所を記入して、本人確認のフローを通せばビジネスアカウントの登録手順で詰まることはありません。
長い間パーソナルアカウントだった私は、アカウントの種類変更のアップグレードボタンを押したら、個人情報の確認いらずでビジネスアカウントに秒で昇格しました…

f:id:simplestar_tech:20190816213935p:plain
なんか、証明書類用意する前に振り込み可能なビジネスアカウントできちゃった

個人情報の入力を行ってビジネスアカウントができたなら、PlayFab の Add-onタブにて PayPal のインストールを行います。

f:id:simplestar_tech:20190816093749p:plain
Add-On タブにて PayPal をインストール

記入するのはマーチャントIDだけ、これは PayPal のアカウント情報として確認可能(この情報は公開しちゃいけない)
もう一つの必須項目の return link ってなんでしょう?(世界中から同じ質問が湧いています)
Questions on PayPal payments - Playfab Community
何のことはなく、支払いが完了したときに「お買い上げありがとうございます!」とユーザに示すあなた独自の Web ページの url を指定するとのことでした。(紛らわしい!)
可能ならそのページから、Unity ゲームにフォーカスを戻せたら良いですね。

現在は次の状態

f:id:simplestar_tech:20190816102412p:plain
PayPal ページをアクティブ化

PayPal のビジネスアカウントが必要なのは理解できたと思います。
そのビジネスアカウントのときに現れる歯車マークの設定ボタンから、サードパーティのアクセスを管理する項目「アカウント設定」が初めて選べるようになります。
ここは PlayFab の Add-on の説明にあるとおり、API を 3rd Party ここでいう PlayFab を信頼して処理を代行させる許可を与えるため API 利用者を探す欄に "billing_api1.playfab.com" を記入して
たくさんの許可項目があるなか一つ process Express Checkout payments に該当する「エクスプレス チェックアウトを使って支払いを処理します。」にだけ許可チェックを入れて登録を完了させます。

あと、この先もしこのビジネスアカウントで、支払いをしようとログインしようとしたら、「支払い先とは別のアカウントにしてよ」とメッセージが出てログインに失敗しますので
支払われる様子を最後まで試したい人は、別のメールアドレスでパーソナルアカウントを作っておくと良いでしょう。

ゲーム内の「カタログ」に現実のお金「RM」単位の金額を設定する

これは PlayFab の決まりごとなのですが、セキュアにアイテムの購入・販売をするための仕組みとして、必ず PlayFab ではカタログと呼ばれる価格設定されたアイテムを複数内包する、まさにカタログを定義して利用することになります。
ここで、PayPal を使った購入対象としてカタログとアイテムを指定する時、カタログにてアイテムに価格設定として RM (実際のお金、アメリカドルの0.01つまり1セント1RMという)単位を指定しておかないと PayPal の支払いオプションが入手できないエラーが発生します。
ので、ちゃんとカタログを作るときに RM で、そうですね 1000 RM とすれば 10 ドルのアイテムとして購入することが試せるようになります。

PlayFab のカタログの作り方、理解は次の公式ドキュメントを読むだけでバッチリでした。
カタログ - PlayFab | Microsoft Docs

材料は揃ったのでコードの動作確認をする

先に紹介済みですが、コード例と意味は次の公式ドキュメントが全てを漏れなく解説済みです。
Non-Receipt Payment Processing

これに沿って動かしたときの絵を見てみましょう。
実際にテストに使ったコードがこちら

using PlayFab;
using PlayFab.ClientModels;
using System.Collections.Generic;
using UnityEngine;

public class PlayFabSample : MonoBehaviour
{
    public void Start()
    {
        PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
        { CustomId = "GettingStartedGuide", CreateAccount = true },
        OnLoginSuccess, OnLoginFailure);
    }

    private void OnLoginSuccess(LoginResult result)
    {
        Debug.Log("Congratulations, you made your first successful API call!");
    }

    private void OnLoginFailure(PlayFabError error)
    {
        Debug.LogWarning("Something went wrong with your first API call.  :(");
        Debug.LogError("Here's some debug information:");
        Debug.LogError(error.GenerateErrorReport());
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            PlayFabClientAPI.StartPurchase(new StartPurchaseRequest()
            {
                CatalogVersion = "main",
                Items = new List<ItemPurchaseRequest>() {
                    new ItemPurchaseRequest() {
                        ItemId = "apple",
                        Quantity = 1,
                        Annotation = "Purchased via in-game store"
                    }
                }
            }, OnSuccessOfStartPurchase, 
            error =>
            {
                // Handle error
            });
        }
    }

    void OnSuccessOfStartPurchase(StartPurchaseResult r)
    {
        Debug.Log($"result.OrderId = {r.OrderId}");
        PlayFabClientAPI.PayForPurchase(new PayForPurchaseRequest()
        {
            OrderId = r.OrderId,
            ProviderName = "PayPal",
            Currency = "RM"
        }, result => {
            // Handle success
            Debug.Log($"result.PurchaseConfirmationPageURL = {result.PurchaseConfirmationPageURL}");
            Application.OpenURL(result.PurchaseConfirmationPageURL);
            // 何らかの待機処理を入れて、ここで「お買い上げありがとうございました」の return url からゲームにフォーカスが戻ってくるまで待機する
            OnSuccessOfPayForPurchase(result);
        }, error => {
            // Handle error
        });
    }

    void OnSuccessOfPayForPurchase(PayForPurchaseResult r)
    {
        PlayFabClientAPI.ConfirmPurchase(new ConfirmPurchaseRequest()
        {
            OrderId = r.OrderId
        }, result => {
            // Handle success
            Debug.Log($"result.Items[0].DisplayName = {result.Items[0].DisplayName}");
        }, error => {
            // Handle error
        });;
    }
}

このコードが最後まで成功するまで色々エラーを見てきました。

まず、カタログが無いと、不正なアイテム情報の指定だ、カタログ作れと怒られたり
正しいアイテムを指定しても、それには価格設定が無いから購入対象外だの
PayPal での購入処理をキャンセルしたら ConfirmPurchase の結果を取得してみるとエラー処理に入り
FailedByPaymentProvider で FinalizeTransaction failed, Ack=FailureExpress Checkout PayerID is missing. The PayerID value is invalid. と出るわ
支払いを逃げたからかな?

そこで、支払い用の別の PayPal アカウントを作って実際に 10 USD のアイテムを支払ってみた。
確かに支払われている様子。

f:id:simplestar_tech:20190816231203j:plain
ゲーム内操作で表示されたWebページを承認すると、自身のビジネスアカウントに振り込まれる

この時、ConfirmPurchase は実行せずとも、 StartPurchase で指定したアイテムと個数が Player のインベントリのデータベースに記録されていることを確認できました。

f:id:simplestar_tech:20190816231853j:plain
インベントリに購入したアイテムが自動で追加されている様子
カタログの仕組みを利用すれば、課金完了と同時にアイテムは自動でプレイヤーの手に渡ってくれる親切設計です。

ConfirmPurchase は、あくまで、ちゃんと購入が完了できましたよ、あなたの買ったアイテムはこれこれですよと表示するために行うものでしょう。
(クライアントからアイテム追加のイベントはキックできないので…)

USD 表示のものを登録した銀行口座に出金してみたが…処理が数分では終わらず、どうも数日かかる模様

f:id:simplestar_tech:20190816232221p:plain
PayPal を使うたびに手数料がかかります。
PayPal の決済手数料はここに書かれている通り
決済手数料|ビジネス向け-PayPal(ペイパル)

ゲーム内通貨を現実のお金で購入してもらい、ゲーム内のお金のやりとりは PlayFab が用意している購入のフローを実行するというのが PlayFab のデザインのようです。
ゲーム内の店舗でのアイテム購入の説明はこちらを参照します。
ストアのクイックスタート - PlayFab | Microsoft Docs

その他、プレイヤー間でアイテムや金銭のトレードを行うようにも API が設計されています。
取り引き - PlayFab | Microsoft Docs
これを利用すれば、不正が行われること無く、活発にゲーム内でプレイヤー同士の交易が行われそうです。

まとめ

バックエンドサービスの群雄割拠時代の PlayFab の位置付け
Unity から PlayFab を1プレイヤーとして利用する手順
3rd パーティ製のアプリ内購入システムを利用しなければならないこと
PayPal と PlayFab との連携手順
実際の購入フローの実演と、登録した口座に振り込まれることの確認
ゲーム内仮想通貨を利用した購入と交易のデザインがあること
を順に確認してきました。

ずっとこういう実際のお金が動くゲームシステムってどうやって作るんだろうと、スキル Zero の状態でしたが
これでゼロが1になった感じです。
まだゲームコンテンツをこれから作っていく段階ですし、そもそもまだそういうお金が実際に動くゲームにするか決めていませんが
選択肢として PlayFab を Unity に導入すれば、課金システムを容易に組み込めることがわかって、個人的に満足です。

みなさんのバックエンド開発・サービス選びの参考になれば幸いです。
ここまで読んで頂きありがとうございました。

PlayFab:UnityのバックエンドとしてのAWS入門

不正を許さないため、オンラインゲームは基本的に現実のお金でのアイテムの購入、ゲーム内の仮想通貨やアイテムの交換はクライアント側では行わずにサーバー側で行います。
そうした不正を許さないバックエンド処理に使う api とデータベースをほぼ無料で使わせてくれるMicrosoft がサポートしている PlayFab をご存知ですか?

「え!まじ!?」と思う入門者が最初に読むべきドキュメントはこちら
docs.microsoft.com

プレイヤー認証

ゲーム開始直後にプレイヤーアカウントを作成するためにIDとパスワードやメール確認などがあると、せっかく遊びに来てくれたお客さんの大半がゲームに触れることなく二度と立ち寄らないことになります。
そんな現実を重く見たPlayFab はアカウント作成のフローを非常に意識していて、最初はデバイス固有の値としてユーザーを見分けるカスタムIDにリンクして、これでユーザーをしばらく特定してゲームを進行させるデザインをとっています。

プレイヤーを作成するのは最初のカスタムIDによるアクセス時で、それ以降はサーバー側でカスタムIDとリンクされた、別のプレイヤーID x 2 を参照する形で全てのクライアントAPIが実行されます。
ゲームを提供する我々をスタジオと見立てて、それぞれ
・マスタープレイヤーID
・タイトルプレイヤーID
をそれぞれスタジオに一意に、タイトル(ゲームごと)に一意に決定して管理します。

戦略的には、後々そのユーザーがミドル・ヘビーユーザーになってくれたときにアカウント連携して、カスタムIDからリンクを解けば、偶然カスタムIDを発見した第三者にアカウントを乗っ取られることもなく
複数デバイスを持つユーザーを一意に見分け、また、スタジオ内の複数のタイトル間をまたぐプレイヤー情報を管理してタイトル間の統計を取ったりと、考え込まれた枠組みでプレイヤーによるクライアント API 実行を実現できます。

「やるじゃん!PlayFab! そこんとこもっと詳しく!」と思った方だけこちらを読むべし→ログインの基本とベストプラクティス - PlayFab | Microsoft Docs

マスターデータ

近年のゲームの面白さはパラメータの調整によって実現されています。
具体的には、プレイヤーの武器の攻撃力、攻撃回数、防御力・ライフ、敵の防御力・ライフ・攻撃力、アイテムの配置・量など(ガチャの確率・配分もかな?)
そういったゲームタイトル固有の静的にデザインされたデータをマスターデータとゲーム業界の人たちは呼ぶようです。(開発者会議とかで)
PlayFab ではそれをタイトルデータとして、テキスト形式だったり、バイナリファイル形式にして、コンテンツ配信ネットワークCDN)を使った非常に効率的な配布方法を提供してくれています。
ゲームマネージャー画面で、開発者がファイルやテキストをアップロードし、クライアントの API から取得するというスタイルでデータの授受を行います。(より具体的にはダウンロード用の一時的なurlを取得してあとは HTTP GET で好きなタイミングでダウンロードしな!って感じだそうです)
詳しくはこちら→コンテンツ配信ネットワークのクイックスタート - PlayFab | Microsoft Docs

ここでとても自然な欲求が生まれてくると思います。
そうだ、みんなで同じデータを共有し、それが動的に切り替わるようなデータも用意したいです!
残念ながら…そんな機能は PlayFab にはありません!
世界中から期待されていますが、将来的な展望として掲げていて未だ実現できていない状況(2019年8月現在)→情報ソース CloudScript Question - Playfab Community

外部サービスとの連携

無いならあきらめて PlayFab 以外で全ユーザーから読み書きするデータストアを外部サービスとして用意しよう。
これしか道がないと PlayFab の中の人も言っているので、ここは Microsoft Azure じゃなくて Amazon Web Services を使います。

手順の概要はこう
1.カスタム CloudScript を作る→具体的な手順 CloudScript のクイックスタート - PlayFab | Microsoft Docs
2.HTTP リクエストを CloudScript 内で同期呼び出しする→要するにこう

以上、url (Amazon API Gateway)の作り方、その先で呼ばれる Lambda の作り方、動き方などは…

四日間ほど Amazon の公式ドキュメントを何十ページと読んで、覚えて、理解していく苦行を続けたけど、欲しかった内容は上記の4ページ + 1 記事だけでした。
これで認証ありAPIを世界に公開しつつ、PlayFab の Cloud Script だけが認証キーを知っている形で、クライアントに一切の秘匿情報を与えずに全プレイヤーが読み書きするデータストアを提供できるようになりました。

なにはともあれ、以下の目標達成!

Unity:VRMのMToonマテリアルをLightweightRPのShaderGraphで、その3で公開

# はじめに

内容は玄人向けですが、はじめての方にもわかりやすく説明します。

  • Unity とは、ゲーム開発するときに、共通で必要になるものをだいたい用意してくれるツールです。(基本無料です。)
  • VRM とは、Virtual Reality における3Dモデルデータを扱うためのファイルフォーマットです。(世界に先駆けて日本が作っているので応援してる)
  • MToon とは、VRM の標準の Toon シェーダーです。(イラスト調の見た目を作る陰影計算ロジック)
  • UniversalRP とは、本記事まで LightweightRP と呼んでましたが、Unity がこれからメインとして扱うシェーダを書く場所
  • ShaderGraph とは、UniversalRP のシェーダーをノード(要素)とエッジ(連結線)で描くデザイナ脳で作れる Unity 内のツール

# 公開しました

細かい事抜きにして、動くコードを公開しました。

github.com

README の手順に従って LightweightRP でも描画確認できた方は続けてお読みください。

置き換えたファイルが何しているかをイメージするための資料を以下に書き留めていきます。

# 差分をとって PBR シェーダーとの差異を確認する

今回移動してもらったこちらの C# ファイルは、シェーダーコードを構築する仕事を行います。
Packages\com.unity.render-pipelines.lightweight@5.16.1\Editor\ShaderGraph\LightWeightToonSubShader.cs

ベースとなる実装は最初から存在する物理ベースレンダリング(PBR)の実装です。
差分を Visual Studio Code のコマンドパレットの差分チェックなどで見てみてください。
基本的な部分は全部一致していて、Shade カラーとテクスチャ、Outline の幅の情報を渡す口だけが増えていることを確認できます。
ほんと、差異はこれだけです。

f:id:simplestar_tech:20190721101843p:plain
差分を取ると、スロットが追加されているだけ

シェーダーコードを構築するのが先程紹介した SubShader.cs ファイルの仕事ですが、これをもう少し具体的に解説すると
ある .template テキストファイルをベースに、文字列置換しながらシェーダーコードを完成させます。

具体的にどの .template ファイルを利用しているかはこちらのコードから確認できます。

f:id:simplestar_tech:20190721102148p:plain
.template ファイルのテキスト読み込み部分

ここまで理解が進めば、あとは .template で陰影計算ロジックを書いているんだろうと予想がつくでしょう。そのとおりです。

こちらも、PBR のものをベースに作成しています。
差分をとってみるととても納得できると思います。

差分を見るのは次のファイルです。
Packages\com.unity.render-pipelines.lightweight@5.16.1\Editor\ShaderGraph\lightweightToonForwardPass.template

f:id:simplestar_tech:20190721102618p:plain
.template を Toon(左)と PBR (右)で比較したときの様子

f:id:simplestar_tech:20190721102803p:plain
.template を Toon(左)と PBR (右)で比較したときの様子(色決定部分)

差異をもう少し注意深く追うと、Toon の方は outline pass と書かれたもう一つ、とても似たレンダリングパスの記述が確認できると思います。
こちらです。

//
// outline pass
// 

Pass
{
	// Material options generated by graph
${Tags}
${Blending}
Cull Front
ZTest Less
${ZWrite}

Unity シェーダーでこの記述は、ポリゴンの裏面を、表面よりちょっとだけ奥に配置するようにして正しい奥行きで描画という意味になります。(イメージできますか?)
もう一つ重要なのは、スクリーン座標における頂点位置の拡張です。(位置を法線方向にずらす処理)

        o.clipPos = TransformOutlineToHClipScreenSpace(v.vertex.xyz, v.normal.xyz, vd.OutlineWidth);

脳内でイメージを作って読んでいた方はこれで納得できると思いますが、この処理により、輪郭線が表現されています。

f:id:simplestar_tech:20190721103827p:plain
顔の輪郭線が表現されたときの様子

# 肝となるシェーダーロジック

ここまでの解説を要約すると PBR シェーダーをベースに、影色と輪郭線の太さパラメータを利用することと輪郭線を描くパスが追加されたというものでした。
気になるのは要所で出てきた次の2つの関数ですね。

  • LightweightFragmentToon
  • TransformOutlineToHClipScreenSpace

実装は次のファイルに書かれています。

Packages\com.unity.render-pipelines.lightweight@5.16.1\ShaderLibrary\Lighting.hlsl

コメントで // for Toon Shading と書かれている行より上は置き換える前のファイルとまったく同じコードです。手入れていません。
その下に続く関数については License 表記にあるとおり、こちらは
LiliumToonGraph をベースに Fragment 関数を簡略化したものです。
github.com
ToonyIntensity の計算はオリジナルの MToon と全く同じロジックを使いました。
github.com

あとは Normal と SphereAdd (MToon 特有のモデル周辺が黄色く光る表現)は次のとおり Shader Graph で表現しました。
こちらは simplestar の完全オリジナル版

f:id:simplestar_tech:20190721111531p:plain
MToon の SphereAdd の座標計算をグラフ表現してます

# カスタム Master Node

次のクラスが上記の Toon Master という名前のノードを定義しています。直感で理解します。
Packages\com.unity.shadergraph@5.16.1\Editor\Data\MasterNodes\ToonMasterNode.cs

# まとめ

解説は以上になります。
なんかすぐに Universal RP 対応が入りそうなのですぐに古い情報になってしまいそうですが
勘所としておさえ、新しい環境にも容易に移植できる技術者が増えることを期待します。

ここまで読んでくださりありがとうございました。

Unity:VRMのMToonマテリアルをLWRPのShaderGraphで、その2

こちらの記事の続きです。
simplestar-tech.hatenablog.com

Unity Shader Graph で VRM の MToon 表現を LWRP で実現してみます。
カスタム Master Node の作り方まで確認できたので、具体的な Toon シェーディングコードを
GitHub - you-ri/LiliumToonGraph: ShaderGraph が使える ToonShader (LWRP)
こちらの実装から写経・理解してみます。

Lighting.hlsl に追加するべきは、まずは LightingToon 関数で、これが4つの引数をとります。
そこでさらに独自関数が呼ばれていました。DirectToonBDRF コレの定義を追います。
実装は Minimalist CookTorrance BRDF とのこと

一旦これで Toon の関数が揃いました。

変更前と後で見た目を比較してみます。

f:id:simplestar_tech:20190715132515p:plain
PRB そのままの絵
f:id:simplestar_tech:20190715132840p:plain
LightingToon に置き換えた Shader の絵(実際はクックトランスシェーダー)

次は GlobalIlluminationToon に GlobalIllumination を置き換えます

それは GlossyEnvironmentReflectionToon, EnvironmentToon で構成されていたので、それらも定義します。
適用した後の絵がこちら

f:id:simplestar_tech:20190715133718p:plain
GlobalIlluminationToon の適用結果

続いて LightingToonyBased を導入します。

f:id:simplestar_tech:20190715134936p:plain
ToonyBased を入れたのですが、変わりました?なんか暗いですね。

コレで最後かな? ToonyIntensity を導入します。

f:id:simplestar_tech:20190715140602p:plain
ToonyIntensity を導入した結果

暗に型変換するエラーがいくつかありましたので解決しました。
例えば戻り値が half3 の関数なのに、利用時に half として扱うとか

あれ、アウトラインはどうやっているんだろう?

lightweightToonExtraPasses.template ファイルの差異を見たが、得なし
lightweightToonForwardPass.template ファイルの差異を見た

以下の変数定義が追加されている

		float ToonyLighting = 1;

		float3 Shade = float3(0.25, 0.25, 0.25);
		float ShadeShift = 0.5;
		float ShadeToony = 1;

ほか LightweightFragmentToon に必要とされる引数の数が変わったので、hlsl の方を修正する

また Pass が一つ追加されていて、こちらはアウトラインの追加となっていた。

一通り見てからのコンパイル

うわ、エラー 2つ
一つ潰して 3つに

OutlineWidth というものを外部から利用している模様

ここで ToonMasterNode クラスの方を修正することにした
以下のパラメータを追加

        public const string ShadeSlotName = "Shade";
        public const string ShadeShiftSlotName = "ShadeShift";
        public const string ShadeToonySlotName = "ShadeToony";
        public const string OutlineWidthSlotName = "OutlineWidth";
        public const string ToonyLightingSlotName = "ToonyLighting";

他にもスロットを追加したところ、Master Node のスロットだけ増えました。

f:id:simplestar_tech:20190715151909p:plain
Master Node のスロットだけ増えた。依然としてエラー

最後に直すべきは…class LightWeightToonSubShader : IToonSubShaderですね。
記述の足りていない OutlineWidth などを追加していきます。

            PixelShaderSlots = new List<int>
            {
                ToonMasterNode.AlbedoSlotId,
                ToonMasterNode.NormalSlotId,
                ToonMasterNode.EmissionSlotId,
                ToonMasterNode.MetallicSlotId,
                ToonMasterNode.SmoothnessSlotId,
                ToonMasterNode.OcclusionSlotId,
                ToonMasterNode.AlphaSlotId,
                ToonMasterNode.AlphaThresholdSlotId,
                ToonMasterNode.ShadeSlotId,
                ToonMasterNode.ShadeShiftSlotId,
                ToonMasterNode.ShadeToonySlotId,
                ToonMasterNode.ToonyLightingSlotId
            },
            VertexShaderSlots = new List<int>()
            {
                ToonMasterNode.PositionSlotId,
                ToonMasterNode.OutlineWidthSlotId
            }

コレなんだろう
subShader.Append("CustomEditor \"UnityEditor.ShaderGraph.ToonMasterGUI\"");
後で足さないとな…

ひとまずシェーダーエラーを片付けたら絵が出ました。

f:id:simplestar_tech:20190715155040p:plain
outline 全部入りのディフォルト値の絵

試験的に UnityChan に当てるとこんな感じ

f:id:simplestar_tech:20190715161553p:plain
上記のディフォルト値のシェーダを適用

思った絵にならないな

f:id:simplestar_tech:20190715165850p:plain
ライティングの設定に問題?

あ、Normal Sampler を Type Default にしたままだった、そりゃ法線がおかしくなるかな
修正後がこちら

f:id:simplestar_tech:20190715173241p:plain
最後まで読むとできるようになる絵

f:id:simplestar_tech:20190715173407p:plain
カスタムノードから生やした Shader Graph はこちらです。

LWRP で MainColor, ShadeColor を指定できる Toon マテリアル表現ができるようになりました。
あとはこのシェーダを使って UniVRM で生成したキャラクターを確認できればよいのかな?

続く

ユニティちゃんライセンス

この作品はユニティちゃんライセンス条項の元に提供されています

Unity:VRMのMToonマテリアルをLWRPのShaderGraphで、その1

自分だけの3Dキャラクターをあらゆるゲームタイトルに登場させるというのは、ゲームが始まった頃からの全人類の悲願でした(二回目)
長い間、キャラクターエディットという、ゲーム開始時のアバター選びという機能の提供が続いていましたが、ついに一つ作れば、どのゲームにも入れる時代となりました。(なろうとしています)
https://vrm.dev/images/vrm/vrm_app.png
画像は 「VRM」って何?どんなことができる? - VRM より

Unity で LWRP の時代が到来しようとしているところ
VRM の Unity インポートまわりは MToon マテリアルを使用していて
そのまま使うとシェーダーエラーになります
simplestar-tech.hatenablog.com
簡易対応は絵を破壊してしまうので、本対応が待たれていますが…あれから三ヶ月
対応にはもう少し時間がかかりそうという雰囲気を感じたので、資料をもとに再現する Shader Graph を作って、作り方を記録します。

まずは Unity の LWRPについて理解しましょう。
simplestar-tech.hatenablog.com

…何が、Unity 最高!だよ(最高だけど)

Shader Graph について理解はこちら
simplestar-tech.hatenablog.com

…いったい、何に勝つんだか…

そして、MToon について理解を深めます。
github.com

具体的にはこちらを読み解きました
https://niconare.nicovideo.jp/watch/kn3485

ここで、これから作りたいものが形になり始めます

# 草案

パラメータ全ては移動できそうにないですが、いくつか代表的なものを引っ越ししたいと思います。

  • Lit & Alpha
  • Shade

のカラーとテクスチャの両方は必須で引っ越しするとして

輪郭線の太さとカラー
幅制御テクスチャは可能なら調べて対応してみたい

Shadeing Shift
Shading Toony
は考えどころ、Shader Graph についてもっと深い理解が要求されますね…

リムライトテクスチャは、機能を探して対応してあげたいが

そんなところですね。

Shader Graph で Toon レンダリング

まずは数万人の開発需要と数億人のユーザー需要がある中
新しい Shader システムに Toon が存在しているかどうかを確認してみましょう。

まさに MToon をリファレンスして LWRP で Toon 表現を行うカスタムマスターノードの例が見つかりました。
github.com

まずは Shader Graph でカスタムマスターノードを用意する方法について確認してみましょう。

# Shader Graph 入門

まずはこちらを読みます。
Home · you-ri/LiliumToonGraph Wiki · GitHub

なるほど、Packages フォルダ以下へ Cache 下にあった Shader Graph コード一式を移動し、勝手に初期化される問題を回避し
まずは CreatePBRShaderGraph.cs を真似て、CreateToonShaderGraph.cs を作る

using System.IO;
using UnityEditor.ProjectWindowCallback;

namespace UnityEditor.ShaderGraph
{
    class CreateToonShaderGraph : EndNameEditAction
    {
        [MenuItem("Assets/Create/Shader/Toon Graph", false, 208)]
        public static void CreateMaterialGraph()
        {
            ProjectWindowUtil.StartNameEditingIfProjectWindowExists(0, CreateInstance<CreateToonShaderGraph>(),
                string.Format("New Shader Graph.{0}", ShaderGraphImporter.Extension), null, null);
        }

        public override void Action(int instanceId, string pathName, string resourceFile)
        {
            var graph = new GraphData();
            graph.AddNode(new PBRMasterNode()); // ←ここをカスタムノードクラスに置き換える!!
            graph.path = "Shader Graphs";
            File.WriteAllText(pathName, EditorJsonUtility.ToJson(graph));
            AssetDatabase.Refresh();
        }
    }
}

まずはこんな感じの作業で、UI に Toon Graph の項目を作り、既存の PBRMasterNode が作られるところまで確認できました。

f:id:simplestar_tech:20190715030756j:plain
Toon Graph がメニューに追加されていることを確認

コメントで強調したとおり、PBRMasterNode クラスを真似て、ToonMasterNode クラスを作ってみます。

PBR を一括でドキュメント内 Toon リプレースしてみました。
IToonSubShader, ToonSettingsView クラスの2つを用意しろと怒られますね。

元の PBRのインタフェースと View クラスの方もリネームして用意しましょう。

IPBRSubShader は ISubShader しか継承してなかったので、元の IPBRSubShader を利用するように戻しました。
ToonSettingsView はクラス名だけ変えた引数を受け付けるだけなので、作る必要はあるけど、内部のロジックは PBRSettingsView.cs クラスをそのまま持ってきました。

ここをカスタムノードクラスに置き換える!!のコメントを ToonMasterNode に置き換えて
一旦ここでコンパイルが通る状態

試験的に Toon Graph を作ってみると…
あれ、エラーに…仕方なく IToonSubShader を IPBRSubShader に似せて作って再チャレンジ

f:id:simplestar_tech:20190715093106p:plain
ノットコンパチのエラーが出るけど、カスタムマスターノードとして UI 操作ができる状態に

ToonSubShader を用意する

参考にしているプロジェクトでは LightWeightToonSubShader : IToonSubShader クラスを用意している
このインタフェースが実装されると、Shader Graph で有効になるのかな?

Unity オリジナルを参照するにはここまで Shader Graph の中を見てきましたが、ここからは LightWeightRP の Package の中を見ていくことになります。
そこには確かに
LightWeightPBRSubShader.cs
lightweightPBRForwardPass.template
lightweightPBRExtraPasses.template
がありました。

仕方ないので、LightWeightRP もカスタムする目的で Library\PackageCache から com.unity.render-pipelines.lightweight@5.16.1 を Packages フォルダへ移動します。

上記3つのファイル名や中身について、あらゆる PBR を Toon に置き換えたものを同フォルダへ配置してみましょう。

こんな感じ

f:id:simplestar_tech:20190715102406p:plain
LWRP の Pacage 配下へ Toon リネーム系を配置

Shader Graph を Toon から作ってみると、ちょっとだけエラーメッセージが変わりました。

f:id:simplestar_tech:20190715103056p:plain
変わったエラー

該当箇所について周辺を読んでみます。
おそらく LightweightFragmentPBR 関数がどこか hlsl として記述されていて、これをインクルードして使えるようになっていて
Toon に置き換えたものがどこにも無いのだと思われる

grep で探すとヒット

ShaderLibrary/Lighting.hlsl

///////////////////////////////////////////////////////////////////////////////
//                      Fragment Functions                                   //
//       Used by ShaderGraph and others builtin renderers                    //
///////////////////////////////////////////////////////////////////////////////
half4 LightweightFragmentPBR(InputData inputData, half3 albedo, half metallic, half3 specular,
    half smoothness, half occlusion, half3 emission, half alpha)
{
    BRDFData brdfData;

そこに関数を複製して Toon サフィックス付けて配置するとどうでしょう?(以下のように endif の手前に挿入した)

}

half4 LightweightFragmentToon(InputData inputData, half3 albedo, half metallic, half3 specular,
    half smoothness, half occlusion, half3 emission, half alpha)
{
// 省略
}
#endif

これで Toon Graph を作成したら、うまくいきました。

f:id:simplestar_tech:20190715105715p:plain
カスタム Master Node で正しく LWRP シェーディングできている様子

f:id:simplestar_tech:20190715105937p:plain
見た目は PBR そのままです。そっくりそのままですもんね…

続いて Toon カスタムしていきます。
長くなったので2つに分けます。

続き
simplestar-tech.hatenablog.com

CubeWalkGame昼と夜

LWRP アセットがお気に入りの今日このごろ
ゲーム内に時間を設定して、これに対して昼と夜の見た目にプロシージャルに変化する様子を確認してみたいと思います。

あ、できちゃった

www.youtube.com

詳細はツィートにあり

CubeWalkGameリファクタリングでわかりやすく

今回は CubeWalkGame シリーズの最終回ということで
カメラを DOTween でぐるりと軌道を描くように動かして様子を見ます。
あとはコードを見直しながら、直したいところ直していきます。

最終的にフレームレートを落とさずに、無駄なメッシュが残ること無く、近傍からメッシュが作成され続ける仕組みができました。

f:id:simplestar_tech:20190706163650j:plain
farChuncRadius = 6 の絵

まずはコードを見てコメントする

高速化
一番効いたのが、周辺チャンクをまたぐ参照の計算を、一個内側の場合はスキップするように書いたこと

f:id:simplestar_tech:20190702235206p:plain
5ms → 2.2ms 7ms → 2.8ms まで短縮

チャンクメッシュ作成をスキップするものが事前にわかるなら
これからメッシュを作らなければならないメッシュも明らかなわけで
常に最大メッシュ作成数までタスクリストから取り出したら、それを entity マーカーフラグに設定すればいい

そうすればスパイクなくなる

だいたいそうだった

今は GC Collect のスパイクが気になる
明示的に GC を呼ぶと…遅すぎたのでキャンセル

あと、過去のメッシュが残る問題を確認した。
期待とことなる動きの理由は?

…先程入れた、既存の meshFilter があるときのエンティティ作成スキップが原因
これからメッシュを作らなければならないメッシュだけを System で処理するようにしたら、起きるべきイベントまでスキップされていた…
これはもとに戻した。

スキップするものはメッシュ作成にカウントしないようにしたが…今度はスキップ処理が数万件となりスパイクが発生…
そこで、スキップ数にも限度 100 を設定したところ、バランス良く計算量をバラけさせることができた。

今度はカメラが大きく移動したときに、期待と異なるくらい遠方のメッシュ結合が走るようになっていた
意味がわからない

意味がわかってきた。
現在の仕組みをおさらいしよう。

この世界の最小構成要素はプリズムです。
プリズムが2つでキューブとなり
キューブが 16 x 16 x 16 のチャンクを一つの結合メッシュオブジェクトとして表現しています。

世界データを永続化するためにチャンク単位で int 配列をファイル保存し、実行時にこれを非同期処理で読み込みます。
読み込むチャンクの順序は初期フレームとプレイヤーチャンクの更新のタイミングにて、内側から順に余白分も含めて
1結合チャンクのために 125 チャンクの読み込みを farChunkRadius * 2 + 1 の3乗回、farChunkRadius が 5 なので
1,331 回分の計画を立てます。(固定長配列に)

計画が立った段階で、非同期処理が開始され、読み込み済みのチャンク位置についてはスキップしながら高速にループが回ります。
125 チャンクの読み込みが完了したタイミングで、コアと周面のデータロード完了のイベントが発行されます。

メッシュ作成を行うクラスはこのデータロード完了イベントを待っていて
ハンドルすると、周辺チャンクメッシュ作成と、コアによるメッシュ結合を計画します。(キューに詰めます)

あとはメインスレッドで環境の最大スレッド数 - 1 個の数だけメッシュ作成ジョブを実行する目的で、キューの内容を消化します。
ここが 100 件以上スキップするか、最大スレッド数 - 1 以上のメッシュ作成になるようなら、キューからの取り出しをやめて処理を回すというもの

問題をみつけるようになったのは、この処理の途中でカメラが動き続けて、プレイヤーチャンクが頻繁に更新されるようになった時

非同期処理は同期処理のことなんか見ていないので
データロードをものすごい勢いでスキップしながら、コア周辺のロードも終わったよとイベントを発行します

メインスレッド側はプレイヤーチャンク移動を検出したら、一度非同期処理を終わらせようとフラグを立てますが
そのフラグを非同期読み込み処理が気づいた頃には、ずっと遠方のデータロード完了のイベントを多数発火するキューが作られてしまっているわけ

で、期待はプレイヤー周辺の未作成のチャンクが作られる様子を想像するのだけど
気づけば、ものすごく遠方のチャンクが作られる様子を目の当たりにする。

これを直すには…

ということを考えて、ひらめくのは
世代番号の利用

プレイヤーチャンクの更新イベントは あるクラスが発火し
コアと周辺データロード完了のイベントも同じクラスが担当しています

プレイヤーチャンク更新のたびにインクリメントされる uint インデックスを考えます。
コアと周辺データロードを実行するタスクのローカル変数にその世代番号を持たせ
イベントにも第何世代によるロードイベントなのかを伝えます。

メッシュ作成側には常に、あるクラスの最新の世代番号というものを渡しておき
更新後に飛んできたロード完了イベントについて、世代番号が古い場合は、周辺チャンクメッシュ作成とコアによるメッシュ結合の計画を阻止します。

もう一つ、メッシュ作成とメッシュ結合の計画は、古い世代のものがすでに詰まっていることになるので
新しい世代になるまで、これを全部吐き出させます。

一旦整理後に見える動きは正しそうなので、これの動作を見ていきます。

実装の急所はこのへんかな

CreateChunkMeshBehaviour.cs

    void Update()
    {
        #region Queue から位置を取り出してMeshObjectをInstantiate
        this.createMeshObjectCount = 0;
        var skipCreateCount = 0;
        while (this.threadCount > this.createMeshObjectCount)
        {
            if (0 == this.createEntityQueue.Count)
            {
                break;
            }
            if (100 < skipCreateCount)
            {
                break;
            }
            var createChunkInfo = this.createEntityQueue.Dequeue();
            this.chunkWorld.ChunkInt3ToChunkKey(createChunkInfo.chunkInt3, out var chunkKeyXYZ);
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = chunkKeyXYZ.x * byteMax * byteMax + chunkKeyXYZ.z * byteMax + chunkKeyXYZ.y;
            var meshFilter = this.worldChunkMeshFilters[chunkIndex];
            this.CreateChunkObjectEntity(createChunkInfo, chunkKeyXYZ, chunkIndex, meshFilter);
            if (null == meshFilter)
            {
                this.createMeshObjectCount++;
            }
            else
            {
                skipCreateCount++;
            }
        }
        #endregion

        #region プレイヤーチャンクから一定の距離以上のチャンクを削除
        const int endSubtractPosition = ChunkWorld.farChunkRadius * (ChunkWorld.nearMergeRadius * 2 + 1) - 1;
        var destroyCount = 0;
        for (int meshFilterIndex = this.offsetWorldChunkMeshFilters; meshFilterIndex < this.offsetWorldChunkMeshFilters + this.limitWorldChunkMeshFilters; meshFilterIndex++)
        {
            if (this.worldChunkMeshFilters.Length <= meshFilterIndex)
            {
                this.offsetWorldChunkMeshFilters = 0;
                break;
            }
            if (10 < destroyCount)
            {
                break;
            }
            var meshFilter = this.worldChunkMeshFilters[meshFilterIndex];
            if (null != meshFilter)
            {
                var mergeChunkRefInfo = meshFilter.GetComponent<MergeChunkRefInfo>();
                var diff = mergeChunkRefInfo.chunkInt3 - playerChunkInt3;
                var maxSubtractPosition = Mathf.Max(Mathf.Abs(diff.x), Mathf.Abs(diff.y), Mathf.Abs(diff.z));
                if (endSubtractPosition <= maxSubtractPosition)
                {
                    meshFilter.sharedMesh.Clear();
                    Destroy(meshFilter.gameObject);
                    destroyCount++;
                }
            }
        }
        if (0 < destroyCount)
        {
            this.offsetWorldChunkMeshFilters += destroyCount;
        }
        else
        {
            this.offsetWorldChunkMeshFilters += this.limitWorldChunkMeshFilters;
        }
        #endregion
    }

ChunkWorld.cs

    void Update()
    {
        // チャンクサイズ以上離れないなら更新は走らない
        var distancePlayerToChunk = Vector3.Distance(this.playerCamera.position, this.playerChunkCenter);
        if (this.minChunkSize < distancePlayerToChunk)
        {
            UpdatePlayerChunk();
        }
        // チャンクデータのロードタスクが初期化されているならば、既存のタスクを止めて新しい非同期ロードを開始
        if (this.loadTaskCancelFlag)
        {
            if (null == this.loadChunkTask || this.loadChunkTask.IsCompleted)
            {
                var mainContext = SynchronizationContext.Current;
                this.loadTaskCancelFlag = false;
                this.loadChunkTask = Task.Run(() => {
                    var myLoadTaskGeneration = this.loadTaskGeneration;
                    for (int taskIndex = 0; taskIndex < this.chunkLoadTasks.Length; taskIndex++)
                    {
                        if (this.loadTaskCancelFlag)
                        {
                            break;
                        }
                        var taskData = this.chunkLoadTasks[taskIndex];
                        this.LoadChunkData(taskData);
                        // combineCoreChunkIndex が 0 以外の場合はコアメッシュ作成イベント -1 はプレイや付近の非結合を意味する
                        if (0 != taskData.combineCoreChunkIndex)
                        {
                            mainContext.Post(_ => {
                                this.onLoadChunkCoreEvent?.Invoke(taskData.coreChunkInt3, taskData.combineCoreChunkIndex, taskData.playerCameraTransform, myLoadTaskGeneration);
                            }, null);
                        }
                    }
                });
            }
        }
    }

動作確認したときの映像がこちら