Imaginantia

思ったことを書きます

雑記240320 内部実装いろいろ

ディレクター以外の話を書きます。普遍的な話しか書かないので、実用性があることもあるかもしれません。

Udon関連

全体管理

本当に普遍的な話ですが、複雑な物を作るためには、それを簡単にする手段が必要です。一般にはこれを「独立した各要素を作り、それらを組み合わせる」ことで行います。

それを実現するのに必要なことが:

  • 各要素の責務を明らかにすること
  • お互いに知るべきでないことを知らせないこと
  • 知りたいことをちゃんと伝えること

です。それを行う方法は色々あって、ぶっちゃけなんでもいいです。例えば要素の責務は「私の直観」に従って決めているので、チームが大きくなるとこれはうまくいかなくなります。

で、今回その各機能とやらはだいたいワールドにちょうど一個あったりしていて、一般にこういうものを Singleton と呼んだりしますが、まぁ Udon の場合は普通に GameObject として顕現することになるので普通に参照を渡すことで実現できます。

が、各 GameObject に参照先をいっぱい渡すのは面倒くさい (必要なものが増えたときに増やすのもめんどい) ので、全部を集めた Udon を作りました。そしてそれを参照するのも面倒なので、GameObject.Find で拾ってきています。

各々の野良オブジェクトは初期化のタイミングで GameObject.Find を呼んで「全部集めたUdon」を拾ってきます。あとはそのメンバにいろんな Singleton が入ってるので参照し放題ということになっています。

美しいかというと一切そんなことはないですが、まず Udon はだるい環境だということを考えるとそのトレードオフとして良い塩梅だと思っています。

 

今回良い設計が出来たかというと一切そんなことはなく、全体的にカスみたいな構造をしていますが、ゲームって「思ってたよりもいろんなものに影響する」ケースが一般に多く、逃れられないものな感じがありますね (大体アニメーションのせい)。

今回は「構造が把握できなくなったら作り直す」戦略でどうにかなっていました。幾ら頑張って作ったものでもヤバいことになってたら破壊するしかないです。その点ほぼ全部自分でやっていたのは本当に楽で、「なんかデータが変な流れ方をしている感覚」があったら気軽に組み直してました。

プログラムを組み直すの、結局ロジックは変わらないので (設計さえ頭の中で組めれば) 意外と素直に済むのでおすすめです。何よりもその方が作る速度が速いので。

状態管理

一般にプログラムの最もである部分が「状態」で、これを如何に減らすかが構造を単純化する為の肝みたいなところあります。

例えば機能だけを見ると単純だけど、アニメーションとかエフェクトとかが付いてくるといつの間にか状態がめっちゃ増えてるみたいなことがよくあります。

というわけで、今回はアニメーションもエフェクトも状態を持たないようになっています。view-model みたいなやつです。今回は view はシェーダのこと、model は MaterialPropertyBlock (に設定するパラメータ) のことですね。

Animator はよくわかんないので使っていませんが、別にどっちでも良いと思います。私にとって楽だからこうなってるだけですね。

基本的には「表示したいデータ」と「表示開始時刻」を MaterialPropertyBlock に突っ込んで MeshRenderer にセットすることでアニメーションが行われます。シェーダでは「現在時刻 (後述)」との差分を取って、いい感じにアニメーションするようにします。

エフェクト (パーティクル類) は別の Udon に全部投げてます (後述)。中途で制御する必要がないやつはこっちです。

シェーダでアニメーションを書くと「どうしても view が状態を持てない」構造になるので強制力が働いて、自然に綺麗になっていくので嬉しいです。まぁ代わりに「瞬間的にモノを出して消す」ようなことをすると不連続な動きになったりするんですが、今回は許容しました。

例えばメガホンをターゲットに向けたときにちょっと反応するのは、こうなっています:

public void Focus(int bar, float beat) {
    pb.SetVector("_FocusTime", new Vector4(bar, beat, 0, 1));
    renderer.SetPropertyBlock(pb);
    colliders[0].enabled = false;
    colliders[1].enabled = true;
}

public void Blur(int bar, float beat) {
    pb.SetVector("_FocusTime", new Vector4(bar, beat, 0, 0));
    renderer.SetPropertyBlock(pb);
    colliders[0].enabled = true;
    colliders[1].enabled = false;
}

これを見てすぐわかるように「片手でフォーカスした後、もう片手でフォーカス当ててから外すと、フォーカスしてない状態に戻る (まだフォーカスは残ってるはずなのに!)」んですが、戻ったところで判定処理に一切関係しない (状態を持ってないので自明) ので特に問題ありません。コライダーがちっちゃくなってちょっと判定狭くはなるけど、大体の人には問題ありません。

というわけで、どうにか Udon をシンプルにしつつシェーダで好き勝手を書くことでなんとかしました。状態さえなければバグなんて起きないのよ。

同期

しかし状態は変化するわけで、加えてなんとか同期を上手くやる必要があります。それでもどうにか見通しよく組む為に、「ひたすら robust にする」という方針がとても有効でした。どういうことかというと:

  • 同期はいつ行われるかわからないので、いつ行われても大丈夫なように組む。
    • せめて「1小節」(1.7秒) くらいは待てるように組んでいます
    • 後続に影響のあるイベントは ManualSync でゆっくり渡します
  • 「変化しにくいもの」を同期させる。
    • 機械のオン/オフそのものではなく、「いつからオン/オフか」を同期しています。
  • 負荷が掛かってもイベントがスキップされないように、時刻ベースで処理を組む。
    • late-joiner の為にもこれは必須要件だったりします
      • joinした瞬間に「これまで実行されていたであろうイベント」を順次実行
  • うっかりデータが変な値になってるかもしれないので、たまにチェックして修正する。
    • 音再生タイミングがズレてることを検知したら一時停止し、後に復帰
    • 各着色用のビンは1フレームに1個ずつ表示位置を検証したりしてます
  • うっかりイベントが複数発火されても大丈夫にする。 (冪等性)
    • リズムの判定を複数人でやっても問題ないようにとか
  • 一部のデータが整合してなくても、全体の流れに影響させない。
    • ラインを流れるモチポリの色は実は人によって異なる場合があるけど、「全タスク完了」判定には影響しないしラストのモチポリの色にも影響しません
    • 着色数も人によって異なるけど Owner の値を信用することにしています

要は「データをできる限り信用しない」「雑にやっても大丈夫にする」っていうことです (Eventual Consistency に近い話)。

当然「これまでの結果を信用する」モデルよりも対処することがたくさんになって大変ですが、一度組めば後は適当に値を投げたらうまく動くようになるのでだいぶ気楽になります。

まぁ実際には「素直に組んだら微妙に不整合が発生することになった」→「仕方ないので不整合から復帰できるようにした」が殆どですが。

根本的に分散処理なので不整合からは逃れられないと思っているので、「どうにか整合させる」よりは「不整合に対処する」方がなんだかんだ楽だと思います。

 

ところで複数人でデバッグしていたら色々と"ありえない"不具合がたくさん出てきて、どうして~と悩んでいたら、原因が「UdonSynced な配列変数に null が入っていた為、その Udon の同期が一切行われなくなったから」でした (どっかに公式の言及ありませんでしたっけ?忘却…)。OnPostSerialization でもらえる SerializationResult を観察してやっとわかりました。

でも自分の書いたコードはちゃんといろんな状況に対応できてるはずだから (不変条件とか契約とかその辺の概念もおすすめ)、もしや私の知らないぶいちゃの仕様があるのか、と思って調査できました。ありました。

時間合わせ

Amebient の時には AudioSource.time をベースに時間合わせをしていましたが、時間が経つと負荷などによりズレが起きることがわかっていました。今回は「完璧」に合わせる必要があるのでどうにか全員の共通クロックが必要です。

なんと Networking.GetServerTimeInSeconds() は全プレイヤーでほぼ共通の値を返してくれます! 毎フレーム呼んでも綺麗な値が返ってくるので、今回はこれを基準に処理を走らせることにしました。

ただこの値は当然 AudioSettings.dspTime とズレます。仕方ないので「初期化時に差分量を保存」「ある程度ズレたら差分量を計算しなおす」ようにしました。

また音楽ベースのワールドなので基本的には「小節」「拍」単位で時間がもらえると便利です。というわけで:

  • 時間軸は dspOffset, beatTimeOffset, bpm で決定する。beatTimeOffsetbpm は同期する。
  • 現在時刻 t = Networking.GetServerTimeInSeconds() に対して「拍時間」 double bt = (t - beatTimeOffset) * bpm / 60 が定まる。
  • 「小節数」 int bar = (int) (bt / 4), 「小節内拍数」 float beat = (float) (bt - bar * 4) が計算できる。
  • 音を鳴らすときは (bar * 4 + beat) / bpm * 60 + beatTimeOffset + dspOffsetPlayScheduled を呼べば良い。

このようにして全プレイヤーで共通の時間軸を手に入れることができました。嬉しい。この辺の管理をする Udon は相変わらず Metronome と呼んでいます。

拍時間そのものではなく小節数/拍数 (以降 bar/beat) に分離しているのは、「1日インスタンスが継続すると200000拍くらいになって18bit消費する」「シェーダに渡すと float になって仮数部23bitなので拍内の時間間隔が5bitくらい (15msくらい?) しか貰えなくて足りない」からです。int + float で表現するようにすれば余裕です。それはそう。

単純な floor/frac ではなく4拍単位にしたのは、今回ちゃんと「音楽」をやる必要があったので、そっち側の convention に合わせた方が都合が良かったから、です。が、小節数が 1 から始まる点についてはプログラム的にも面倒なので 0 始まりになってます (モニタの表示では +1 してます)。

この bar/beat の値は SetGlobalFloat/SetGlobalInteger してあるのでシェーダ側から自由に参照できるようになっています。なのでアニメーションとかはこの値からの差分を計算することで「アニメーション開始から何拍経ったか」 がわかるわけです。

 

また、今回BPM変化する予定があったので、そのようにしました。bpm が変わるということは beatTimeOffset が変わるということですが、この値は計算すれば出ます。のでなんとかなりました。

全てを bar/beat 単位で管理したので、途中でBPM変化が発生しても、アニメーションは勝手に拍に合ってくれます。つまり自動的に加減速することになります。

イベント処理

拍タイミングに合わせた処理が非常に多いことが想定されたので、「16分のタイミングで自動的にイベントを発火してくれる Event Listener」を作りました。

特に、負荷が掛かっても「前フレームから現在までのイベントを発火する」仕組みなので、イベント抜けが発生しません。

private int lastBar16th = 0;
private int lastStep16th = 0;
public override void PostLateUpdate()
{
    int bar = lastBar16th;
    int step = lastStep16th;
    int curBar = time0; // bar
    int curStep = Mathf.CeilToInt(time1 /* beat */ * 4);
    if(curStep == 16) {
        curBar++;
        curStep = 0;
    }
    int count = (curBar - bar) * 16 + (curStep - step);
    for(int i=0;i<count;i++) {
        for(int k=0;k<rhythmCallCount;k++) {
            var c = rhythmCalls[k];
            c.OnMetroCall(bar, step);
        }
        step++;
        if(step == 16) {
            bar++;
            step = 0;
        }
    }
    lastBar16th = curBar;
    lastStep16th = curStep;
}

IRhythmCall を継承して OnMetroCall を実装して MetronomeSetCallback をお願いすると、後は定期的な処理が書けます。ついでに Update ほど頻度高くないので負荷軽減にも寄与していますね。

16分のことをなんて呼ぶべきかわからなかったので step という名前にしていますが、これによって bar/beat 管理と bar/step 管理が入り混じったりしています (型が違うので判別は容易なんですけどね)。ちょっとミスったかも。

機械の音再生

効果音とかは素直に PlayScheduled で鳴らせばいいんですが、「常時鳴ってる機械」をそれでやるには 流石に AudioSource が足りません。Quest だと同時再生24個までらしい (検証しました) ので、効果音とプレイヤーボイスのマージンを考えると「機械音で10個くらい」に収める必要がありました。

各機械は 場所が違う のでどうしても AudioSource を分ける必要があって、結果として「1機械1AudioSource」で処理する必要があるということになります。というわけでそうなっています。

やり方は:

  • 5秒くらいの AudioClip を loop で流し続けておく
  • 1小節の始まりのタイミングで「次の1小節分の音バッファを GPU で計算して RenderTexture に書き込み」しておく
  • 生成された音バッファを VRCAsyncGPUReadback で読み出して AudioClip.SetData して追記していく

以上です。別にシェーダじゃなくてもいいんですが今の Udon だと速度が足りないのでシェーダしかないと思います。

AudioClipのバッファは2小節分収まる長さが必要で、今回ワールドの最低BPMが 90 なので、2小節 * 4拍 / (90bpm / 60秒) で 5.333秒になっています。

開発中はまだ Unity 2019 だったので Quest で VRCAsyncGPUReadback が動いてなくて 恐々としていました。

 

VRChat で GPU で音バッファを生成するためには音源をテクスチャに焼く必要があります。焼きました。

このテクスチャが一番ワールドで容量を食っています。音源ファイルなら音源らしい圧縮が出来るんですけどね…。

音源データを初期化時にコピーしようとすると float[]Color[] に変換しなきゃいけなくて、このコピーをQuestでやると1秒以上掛かっていたのでやめたんです。同じ型の配列の部分コピーとかなら System.Array.Copy で全部対応できるのに…。

って思ってたらどうやら beta 版なら BitConverter / Buffer.BlockCopy が追加されてるらしい です。これからやる人はこれを使いましょう。

 

シェーダに渡すリズムデータは対象小節数のものだけでなく一個前の小節のものも必要です、前小節の音のリリースがまだ残ってる可能性があるので。逆に言えば機械の音は1小節以下の長さに抑えてもらいました。

float4 o = 0;
for (int j = 0; j < 32; j++) {
    int step = j - 16; // 負なら前小節の音
    float clipInfo = GetRhythm(index, _RequestBar, step);
    int clipIndex = (int) floor(clipInfo);
    if (clipIndex == 0) continue;
    int offset = 0;
    if (j >= 16) offset = step * len / 16;
    else offset = ((16 + step) * prevLen / 16) - prevLen;
    o += sampleClip(clipIndex, pixelIndex - offset);
}

ちなみに「あるBPMに対してサンプル数 (48000 * 4 / (bpm / 60)) が整数になるとは限らない」ので、同じBPMでも各小節内のサンプル数が微妙に変わります (lenprevLen がコレです)。

 

音生成をする為にシェーダにリズムデータを送っているわけですが、実はこれは SetGlobalFloatArray で送っていて、そのまま各機械のアニメーション (を行うシェーダ) でも参照していたりします (GetRhythm 関数)。

パーティクル

今回はパーティクルをちゃんと出したいなあと思っていたので、パーティクルシステムから作りました。

昔はパーティクルの生成/消去がGPU的に面倒だなあと思っていたんですが、Knot Puzzle を作ったときにうまいやり方がわかりました。これ です。

あとは「1フレーム中にパーティクルを大量に生成したい場合」にどうするかなんですが、Udon 側に queue を作って順次送り出すことにしました。このワールドのパーティクルの出現は基本的にリズムに合っているので、各小節始まりのタイミングで既に出すべきパーティクルが確定しています。

よって小節始まりのタイミングで自作パーティクルシステムに全情報を送って、ちょっとフレーム掛けてGPUに順次転送していくことにしました。1フレームに2個まで送れます。

ただそれでも間に合わないのがバトンを振ったときのパーティクルで、これだけ「16人×2本」のパーティクルが毎フレーム生成される可能性があります。仕方がないので、人毎に処理IDを分離しました。

というわけで、64種類のパーティクルを同時に処理できるパーティクルシステムを作りました。実際の内訳はこんな感じです:

  • 0: 衝撃波 (一番よく出てくるやつ)
  • 1: もちに着色料を落としたときの飛沫
  • 2: 顔形成時の熱
  • 3: 蒸し器から出てくる水蒸気
  • 4: モチポリを焼いたときの図形系パーティクル
  • 5: 未使用
  • 6: 旗を振ったときの矢印
  • 7: 射出されているモチポリ
  • 8: DAIFUKU FACTORY行きのもち
  • 9: DAIFUKU FACTORY行きのもち、でかい方
  • 10: モチポリがパイプに格納されるときの飛沫
  • 11: 窯にモチポリが入っていく直前のもち
  • 12: モチポリとクロミちゃんがメガホンを向けたときの六角形エフェクト
  • 13: 衝撃波、白い方 (UI系)
  • 14: 文字とリズムが合ったときにモチポリと一緒に弾けるリング状のエフェクト
  • 32-47: 通常バトンを振った時の白い粒
  • 48-63: 旗を振った時の白い線

各ID毎に 256 個まで出せます。これで 256x256 のテクスチャに収まってるので意外と計算側はちっちゃいもんなんですよね。

あとは表示側の頂点シェーダで適当に読み出していい感じにすればいい感じになります。なりました。

各パーティクルは float4 を 4 つ分の情報を持っていて、共通で位置と時間、あとは好きなプロパティ (9次元分) を渡せるようになっています。

ライティング

Questでちゃんと綺麗な動的ライティングをやりたかったので、やりました。

これは Resonark4 のとき の仕組みと似たようなことをやっていて、工場全体に 16x32x32 のライトプローブがあるような感じで自前でライティング処理を書いています。

  • 各照明 (16個) の光量を決定
  • 各照明ごとのライトプローブデータと光量を乗算して、総和を取って、RenderTextureに書き出し
  • 各オブジェクトのライティング時に読み出す

ライトプローブの配置は、とりあえずグリッドで置いたらオブジェクト内部に入ったりして散々だったので、Houdini で「オブジェクトの裏に居るなら表に押し出す」ようにしています。いい感じになりました。

それでも一部の影が変な出方をしていたので、ライトプローブデータ全体に対して 3x3x3 カーネルの Gaussian Blur掛けました。とてもいい感じになりました。

各オブジェクトのライティングは baseColor * ((diffuse + metallic + ambient + rim) * probe + spotLight + emission) です。metallic っつっても適当な (R\cdot V)^\alpha を 2 個足し合わせてるだけです。

が、Questだと案の定重かったので、「metallic, rim のパラメータを無視」「probe の計算は L0 のみ*1」にして、どうにかしました。正直ギリギリですね…。

余談

Coroutineが欲しい

エンディング時にいろんな出来事が順番に起きるわけですが、それをどう綺麗に記述するかに悩みました。特に late-joiner 対応があるので、「データとして」イベントを記述しなければなりません。

悩んだ結果、こうなりました。

string[] processEventContents = new string[] {
    "-36,0,K0",
    "-18,0,K1",
    "-14,0,K2",
    "-12,1,H",
    "-11,0,X",
    "-10,0,K3",
    "-9,0,K4",
    "-9,4,K5",
    "6,0,G",
    "7,0,K6",
    "7,8,K7",
    "7,12,F0",
    "11,12,F1",
    "15,12,F2",
    "17,12,F3",
    "19,12,F4",
    "20,12,F5",
    "21,12,M0",
    "22,4,M1",
    "22,6,M2",
    "22,12,M3",
    ...

これは bar/step とイベント名のリストです。初期化時にこれをパースして保存しておいて、「時間が来たら、イベント名で SendCustomEvent を飛ばす」という実装にしました。

大量に関数を生やすので、名前付けが面倒になって1文字 + 番号になりました。まぁ中身は短いので見たらわかります。

Coroutineが欲しいです。でもC#のCoroutineは私が欲しいものとは別物なのかな?まぁいいや。

メッシュの Bounds

よくメッシュの見た目を vertex shader で好き勝手に弄るので、bounds を変更したいことがしばしばあります。

今まで Mesh.bounds を弄ってアセットとして保存しなおしていたんですが、なんかそれだと VRChat 上でうまく反映されないことがありました。

悩んだ結果、Renderer.localBounds を見つけました。私が欲しかったのはこれです。ありがとうございました。

メッシュの UV に情報を詰める

Unity の Mesh は UV1~UV8 までありますが、これは Vector4 が入ります

が、スクリプト経由じゃないと多分生成できないと思います。なので、Houdiniで「XY成分を持ったメッシュ」と「ZW成分を持ったメッシュ」を吐いて、エディタスクリプトでくっつけるなどしました。

とは言え結局 UV1 + UV2 の 8次元で収まっていたので、普通に UV8 とかを使ったほうが早い気がしています。使ったのは:

  • ビンの水面を作るために四面体分割をしていて、4頂点の位置情報 (12) を position (3) + uv (4) + uv2 (4) に埋め込んだ
    • 分割した四面体たちは、必ず2頂点が同じY座標を持っていたので、うまく選択することで11次元になったんだけど、別に uv3 を使えばよかった気もする
  • 星の配置は Scatter で relax したいので Houdini 生成なんだけど各星を回転させたいので、ローカル座標 (3) + UV (2) + 乱数 (2) + 中心位置 (3) が入ってる
    • これも大した必要性はなさそう (UVはローカル座標で十分そう) (ちなみに色は頂点カラーに入ってる)

Android の UI

Android でも何もしなくても動くんですが、唯一バトンの出し方がありません。ので、ボタンをつけました。

が、普通に UI の Button を置いたら反応してくれませんでした。なんかレイが出てないのかな?色々試したけど面倒になって。

代わりに Interact を入れたキューブを視界端に貼り付けました。ちゃんと反応しました。

おまけ: おもちパイプライン

一般的な話ではないですが一応ちょっとだけ。

あのもちたちは UdonGPU が協調して動かしています。「どの位置に居るべきか」は Udon が持ってるんですが、「実際にそこまでゆっくり動かす」のはシェーダでやってます。

まぁまず前提としてパイプライン上のもちは GameObject としては別に動いてなくて、全部シェーダで見た目をコントロールしています。毎フレームもち情報が Udon から送られています。

で、GPU上のフィードバックループがあって、「ラインの端からどれくらいの位置に居るか」をだんだん動かしています。

そしてその値を VRCAsyncGPUReadback で拾って「各ラインの終端にたどり着いた」ことを確認して、Udon で「次のラインに放り込む」という処理が走ります。

なので Udon が処理する必要があるのはラインの切り替わりを跨いだもちだけで、結構短めに済んでいます。ちなみに各ラインは queue で管理されていて、head が動くので全体をずらしたりはしてません。あと最初から長さがわかっているので色々単純化されていたりする。

パイプがどこを伝っているのかの情報は全部テクスチャに焼いてあります (Houdiniで)。先述のもち相対位置計算時に同時にワールド座標上でのもちの位置も計算していて*2、描画時にその値を参照するようになっています。

ラインに依ってアドホックな処理がめちゃくちゃ入っていて最悪みたいな感じになってます。そんなものですね。

おわりに

以上、エンジニアリング的な話でした。

正直こんな話は頑張れば良いというだけで、作品自体の面白さには特に関係がないので「面白くない話題」ではあります。

が、この辺がブラックボックスになって仕組みが accessible じゃなくなってしまうのもなんかまた違う気がするので、とりあえずこういう形で書きました。

今まで色んなところでいろんなことをやってきた集大成っぽい感じになって面白かったです。

おわり。

*1:L1 だと RGB 毎 に xyzw を 2度 (Z方向の linear 補間) サンプルする必要があって6回、L0 だけなら 1px に収まるので補間だけやって2回なので3倍軽い

*2:ぷるぷるさせる為