simplestarの技術ブログ

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

PlayFab:ログインタイミングを気にせずプログラミングする術

最初にひらめいたのはアクションをキューイングすればいいんだよ!の一言

言葉をプログラムに書き下すと次の通り
ちゃんと期待通り動いた

前の記事
simplestar-tech.hatenablog.com

の TitleData を取得する関数の処理、コールバックで成功を返すつくりなので、このように改造が自由

    /// <summary>
    /// タイトルデータを取得
    /// </summary>
    /// <typeparam name="T">取得したいデータのクラスを指定</typeparam>
    /// <param name="callback">取得成功時の処理</param>
    public static void GetTitleData<T>(UnityAction<T[]> callback)
    {
        PlayFabLogin.AfterLoginCall(() =>
        {
            PlayFabClientAPI.GetTitleData(new GetTitleDataRequest { }, result =>
            {
                var type = typeof(T);
                var titleData = JsonSerializer.Deserialize<T[]>(result.Data[type.Name]);
                callback?.Invoke(titleData);
            },
                error => { Debug.LogError($"GetTitleData: Fail...{error.GenerateErrorReport()}"); });
        });
    }

からくりは次のようにして実装しました。

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

/// <summary>
/// PlayFab のログインをゲーム開始時に試行し、ログイン前に呼ばれた API 処理を処理
/// </summary>
public class PlayFabLogin : MonoBehaviour
{
    /// <summary>
    /// 引数アクションをログインしてから実行する
    /// </summary>
    /// <param name="action"></param>
    public static void AfterLoginCall(UnityAction action)
    {
        if (isPlayFabLogin)
        {
            // もうログインしてるんだからそのまま実行
            action?.Invoke();
        }
        else
        {
            // まだログインしてないのでキューイング
            actionQueue.Enqueue(action);
        }
    }

    void Start()
    {
        // タイトルIDをメタデータから取得
        if (string.IsNullOrEmpty(PlayFabSettings.TitleId))
        {
            Debug.LogError("PlayFab TitleId is Empty!");
            return;
        }
        // ログインを試行
        var request = new LoginWithCustomIDRequest { CustomId = "GettingStartedGuide", CreateAccount = true };
        PlayFabClientAPI.LoginWithCustomID(request, OnLoginSuccess, OnLoginFailure);
    }

    /// <summary>
    /// ログイン成功
    /// </summary>
    /// <param name="result">EntityTokenなど</param>
    void OnLoginSuccess(LoginResult result)
    {
        Debug.Log("PlayFab ready to call API");
        // キューにあるタスクを順に実行
        while (0 < actionQueue.Count)
        {
            var action = actionQueue.Dequeue();
            action?.Invoke();
        }
        // ログインしたフラグ変更
        isPlayFabLogin = true;
    }

    /// <summary>
    /// 何らかの理由でログインに失敗
    /// </summary>
    /// <param name="error">エラー情報</param>
    void OnLoginFailure(PlayFabError error)
    {
        Debug.LogWarning("Something went wrong with PlayFab API call.");
        Debug.LogError($"Here's some debug information: {error.GenerateErrorReport()}");
        // ログインせずゲーム続けると、どんどんキューに詰められてスタックオーバーフローするかもね
    }

    /// <summary>
    /// ゲーム開始時にログインするが、もしもその前に API コールがあったらこのキューに関数が詰められる
    /// </summary>
    static Queue<UnityAction> actionQueue = new Queue<UnityAction>();
    /// <summary>
    /// PlayFab のタイトルログインに成功したら true に変わる
    /// </summary>
    static bool isPlayFabLogin = false;
}

同じように、ログインタイミング気にしてログインしたか確認して~とかいう依存コード書く前に、参考にどうぞ

PlayFab:タイトルデータで接続先サーバーを知る

基本はここで学びます。
qiita.com

任意のデータ構造を配列で取得できるようになったら、少し汎用的なタイトルデータ取得コードを記述します。

C#ジェネリックの型名を文字列で取得するには

var type = typeof(T);
var titleData = JsonSerializer.Deserialize<T[]>(result.Data[type.Name]);

これを活用して、関数を一つ書けば、Json シリアライズする型ごとに値を取得することができるようになります。
実装は次の通り

using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;
using UnityEngine.Events;
using Utf8Json;

public class PlayFabTitleData : MonoBehaviour
{
    private void Update()
    {
        // Test コードです
        if (Input.GetKeyDown(KeyCode.A))
        {
            GetTitleData<CubeWalkServer>((titleData) => {
                foreach (var data in titleData)
                {
                    Debug.Log($"name = {data.Name}, host = {data.Host}");
                } 
            });
        }
    }

    /// <summary>
    /// タイトルデータを取得
    /// </summary>
    /// <typeparam name="T">取得したいデータのクラスを指定</typeparam>
    /// <param name="callback">取得成功時の処理</param>
    public static void GetTitleData<T>(UnityAction<T[]> callback)
    {
        PlayFabClientAPI.GetTitleData(new GetTitleDataRequest { }, result => {
            var type = typeof(T);
            var titleData = JsonSerializer.Deserialize<T[]>(result.Data[type.Name]);
            callback?.Invoke(titleData); }, 
            error => { Debug.LogError($"GetTitleData: Fail...{error.GenerateErrorReport()}"); });
    }

    /// <summary>
    /// オンラインチャットサーバー情報:タイトルデータ
    /// </summary>
    public class CubeWalkServer
    {
        public string ID { get; set; }
        public string Name { get; set; }
        public string Type { get; set; }
        public string Host { get; set; }
        public int Port { get; set; }
    }
}

amazon linux 2 ami でインスタンス作って docker インストール手順

amazon linux 2 ami でインスタンス作って docker インストール手順

sudo amazon-linux-extras install docker
sudo service docker status
sudo service docker stop
sudo service docker start
sudo service docker status

sudo docker ps

動いた

そうそう sudo うざいので docker コマンドを sudo なしで実行するには docker グループに ec2-user を追加してあげます。
sudo gpasswd -a $(whoami) docker

以降は docker コマンドに sudo いらずになります

WinSCP秘密鍵を .pem を .ppk に変換して接続
dotnet release フォルダを丸コピー
Dockerfile フォルダがあるところで

sudo docker build . -t cubewalkroom:1.0

sudo docker run -d --rm -p 12000:12000 --name cubewalkroom cubewalkroom:1.0 dotnet CubeWalkServer.dll 12000

対象の EC2 インスタンスの IP アドレスを使ってクライアントから接続
void InitializeMagicOnion()
{
// サーバー情報を指定して接続
this.channel = new Channel("13.XXX.XX.XXX", 12000, ChannelCredentials.Insecure);

問題なく接続完了

Magic Onion:複数ポート番号のサーバーをdockerコンテナで

手順だけ記録しておきます。

基本
qiita.com

ここで Dockerfile を記述しているけど

FROM mcr.microsoft.com/dotnet/core/runtime:2.2
WORKDIR /app
COPY . .

とエントリポイントを削除

あとサーバー側の実装で引数一個目で port 番号を受け取って利用するようにしておく。

using Grpc.Core;
using MagicOnion.Hosting;
using MagicOnion.Server;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.Threading.Tasks;

namespace CubeWalk.Server
{
    class Program
    {
        static async Task Main(string[] args)
        {            
            if (1 > args.Length || !int.TryParse(args[0], out int port))
                return;

            GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());

            // for SSL/TLS connection
            //var config = new ConfigurationBuilder().AddEnvironmentVariables().Build();
            //var certificates = new List<KeyCertificatePair> { new KeyCertificatePair(File.ReadAllText("server.crt"), File.ReadAllText("server.key")) };
            //var credential = new SslServerCredentials(certificates);

            await MagicOnionHost.CreateDefaultBuilder()
                .UseMagicOnion(
                    new MagicOnionOptions(isReturnExceptionStackTraceInErrorDetail: true),
                    new ServerPort("0.0.0.0", port, ServerCredentials.Insecure))
                    // for SSL/TLS Connection
                    //new ServerPort(config.GetValue<string>("MAGICONION_HOST", "127.0.0.1"), 12345, credential))
                .RunConsoleAsync();
        }
    }
}

out フォルダをカレントにイメージをビルドして

docker build . -t cubewalkroom:1.0

次の run コマンドで port フォワードして、とりあえず 12000 ポートでサーバーを立てます。

$ docker run -d --rm -p 12000-12100:12000-12100 --name cubewalkroom cubewalkroom:1.0 dotnet CubeWalkServer.dll 12000

続いて、ポートを一つずつ消費しながらサーバーを立てます。

$ docker exec -d cubewalkroom dotnet CubeWalkServer.dll 12001
$ docker exec -d cubewalkroom dotnet CubeWalkServer.dll 12002
$ docker exec -d cubewalkroom dotnet CubeWalkServer.dll 12003
.
.
.

それぞれの port に対して接続して利用することができました。

追記:
なんのためにポートを複数使う?
プロセスを分離できるので、分けたあとのお互いの影響 0 じゃん
と思っていたけど、内心パフォーマンスの分散にもなるかなって感じてました→原理的にむしろ悪くする

同じポートを使って詰まることはなく、ソケットを作ってお互い通信し、そのソケットにカーネルがパケットを振り分けるので
つまり、ポート一つに全アクセスが集中するのではなく、分散したソケット同士で通信するので、ポート一つでも分散できていたという話

こんな書式で複数ポートをフォワーディングできるという、ただそれだけの記事になったが
副次的にいろいろとポートとソケット、LinuxNIC, hard IRQ とか知ることができた

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 表示のものを登録した銀行口座に出金してみたが…処理が数分では終わらず、どうも数日かかる模様?
いいえ、後で確認した感じ JPY 換算を実行して USD 表示から JPY 表示にしてから資金の移動を行うと正常に処理されました(円で手数料が引かれて)
資金の移動には換算が必要な工程であることを覚えておくと良いかもしれません

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 対応が入りそうなのですぐに古い情報になってしまいそうですが
勘所としておさえ、新しい環境にも容易に移植できる技術者が増えることを期待します。

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