Imaginantia

思ったことを書きます

VRノードエディタについて考える

Ayanoさんと話していたときに言った「VRChat上でASEっぽいのができる仕組み」、作ろうと思ってたんですが時間がなさそうなので頭の中の設計だけ置いておこうと思います。

特に私の確認なく作って頂いて良いし、好き勝手すればいいと思います (当然)。作らなくても良いです。

まぁ、あと「実装なき設計」をそのまま出力したこと今までなかったので、それはそれで面白いかな、という実験でもあります。

概要

unitypackageの形で、VRChat上でノードを操作 (機能を変更・In-Outを結合/削除・パラメータを変更) してシェーダプログラムを表現・実行する仕組みを提供します。

prefabぽん置きでワールドにとりあえず置ける (ノード生成/削除機能があれば十分) 他、予めUnity内でセットアップしておくことも可能なら尚嬉しそう。

実装ではシェーダ特有の成分 (計算の実行) とそうでない成分 (計算グラフ表現) を分離して、他の用途 (純粋な計算・手続き的表現など) にも応用できると尚嬉しそう。

データ構造

基礎

「ノード」とは、実際にワールドに存在する"板切れ"のことです。「機能」とは、ノードに割り当てられているもので、ノードの処理内容を示します。各ノードは「いくつか (0を含む) の入力」と「1つの出力」を持ちます。

「入力」や「出力」は、実際にノードへ入力/出力されるデータの送り元・送り先のことで、ノード間を「値」が行き来する経路として用いられます。

「値」は 3.0 や float2(1.0, 4.0) などの何次元かのベクトルを表していて、「型」が定まっています。

「型」とは、1次元, 2次元, 3次元, 4次元ベクトルのうちのどれか1つです (簡略化の為にテクスチャ型は扱わないでいいかなと思います) 。

これだと「×」機能は「1次元用乗算」「2次元用乗算」のようにそれぞれ分ける必要があってめんどくさいです。可能であれば機能の型宣言で「n次元」を用いて入力側からunificationさせていくことが出来れば良いと思います。シェーダにおける関数は基本的にcomponent-wise (n→n→nとか) で、唯一珍しいのが dot (n→n→1) くらいです。そういう疑似genericsは (ちゃんとわかっていれば) 意外と簡単だと思います。

例えば上の図の「Brick」ノード (レンガテクスチャのサンプルを想定しています) は「Brick」機能を備えており、唯一の入力には「×」ノードが接続されています。「Brick」機能は「Brick」という名前と「入力は1つあり、2次元ベクトルを受け取る」「出力は1つあり、4次元ベクトルを返す」という情報から成ります (実際にどう計算するかは記述されていません)。

型の自動変換

ノード間結合 (以降「エッジ」) は、送信ノードの該当出力のベクトルの次元と、受信ノードの該当入力のベクトルの次元の対が、以下の表のどれかに該当するとき成立します。またそのとき、付加処理が実行されます。

送信側の次元 受信側の次元 付加処理
任意 送信側と同じ 無し
1 1より大きい数 受信直前の位置に「複製コネクタ」を追加
1より大きい数 送信側の次元より大きい数 受信直前の位置に「ゼロ埋めコネクタ」を追加
1より大きい数 送信側の次元より小さい数 受信直前の位置に「削除コネクタ」を追加

ただし、実装がめんどくさい場合は、これを実装しなくて良いです。(送信側の次元と受信側の次元が一致しているとき、そしてそのときに限り結合を成立させることができるとします) (代わりに各コネクタに対応する機能を追加する必要がある)

ちなみに1つの入力に複数の出力を挿すことはできませんが、1つの出力を複数の入力に繋げることはできます。

 

また、あるノードの出力が巡り巡ってまた入力に戻ってくるとき、循環していることを示す表示を行います。(計算上は循環入力をとりあえず0とみなせば良いと思います)

機能パラメータ

一部の「機能」には「パラメータ」(数値を1つ保持すると期待されるモノ) が設定されています。これには入力依存であるもの (特定入力がある場合にはパラメータが無効化されるケース) と、そうでないものがあります (但し、面倒であれば入力依存をやめて良いです)。

例えば「float4」機能には「浮動小数点数の値を4つ保持することができる」ことになっており、「float4」機能を割り当てられたノードは浮動小数点数の値を実際に4つ保持します。

同様に「Slider」機能では最小値と最大値と現在地の3つの値を保持できます (ただし、面倒であれば最小値は0、最大値は1に固定して良いと思います)。

 

以降、ノードとエッジによって構成された計算式表現を「計算グラフ」と呼びます。

計算の実行

2048x2048のRGBAFloatのCustomRenderTextureを用意し、そのうち256x256領域を1つのノードで使用します。これにより、ノードは最大64個までしか出せないことになります。

計算結果はRGBAとして保存しますが、ノードの出力次元よりも大きい場合は「最後の要素」を複製して保存しておくことにします (1次元なら全コンポーネントにRと同じ値が入る)。

各ノードには0から63までのindexが振られており、実際にノードの入力/出力関係はindexを用いて参照されます。

1フレームにつき「エッジ1つ分」の計算が走ることとします。これにより、毎フレームの計算量は一定に抑えられますが、計算グラフそのものの正しい計算結果が出るとは限らないということになります。

ただし、これでは「Time」機能 (実装は割愛) のようにフレームによって異なる値を返す機能を「出力を複数の入力に繋ぐ」ようなグラフを作成した場合、最終結果が正しく計算できないことになります (最終出力に到達するまでに掛かるフレーム数が異なる可能性がある為)。 残念ながら単純な対処は思い浮かんでいませんが、いくつか候補があります:

  • 高々64ノードなので全部計算してしまう (実際可能なはず!)
  • 速すぎるノードを遅延させる為に別のノードを仮想的に挟む or 強制的に挟ませる (ユーザに委ねるとメモリ確保の手間が減る)
  • 無視する

計算用シェーダにはグラフの情報がfloat4の128要素の配列として渡ります (各ノードにつきfloat4が2つ)。各コンポーネントの成分は以下です:

  • X0: 機能のid (予め割り当てておく)、未使用なら-1
  • Y0: 入力先indexのリストを64進数として解釈した値 (floatは16777216 = 644まで正確に表現可能なので、4入力までなら大丈夫) (それだとremapには足りないので他チャンネルを使っても良い)
  • Z0, W0, X1, Y1, Z1, W1: パラメータ (または入力チャンネル)

出力は不要です (計算するには拾うだけで良いので)。

これを用いて次のように値を計算していきます:

  • 現在位置のローカルUVに対して
  • 各入力に対応するノードの前フレームの出力を取得 (ノードが無い場合はデフォルト値を参照)
  • それぞれに対して機能のidに対応する処理を実際に計算

もしも、全てを1Fで計算するならば、適当に float4 buffer[64]; を作ってトポロジカルソート順に (これは循環判定の際に勝手に出てきそう) 計算していけば良いと思います。そういえば 昔つくったノードエディタ はほぼそんな実装です。

出力結果は各ノードにそのまま表示されます。

ただし、これは入力がUVであることを前提としています。例えばSkybox用にdirectionを取りたい場合はノード上の表示は球であってほしい気もするし、他の表示方法もいろいろありそう。複数の入力方式を利用できる場合には「ノードが何の種類の入力に依存しているか」によってプレビューモデルを差し替えられると嬉しいですね。また、その場合だとテクスチャに綺麗に格納できないので、ぶっちゃけ毎フレーム全計算走らせていいんじゃないかと思います。というか多分そうするしかない、いろいろと。

UI

ノードは「機能名」と「現在の計算結果」、また「入力群」「出力群」を表示していれば良いと思います。本当の板切れだと軽すぎるのでフレームだけちょっと厚いと良さそう。裏から見ても綺麗に見える (UVが反転する) と嬉しそう。

入力/出力を区別するためにコネクタには何らかのマークのようなものがあると良いかもしれない。できれば入力と出力が噛み合うもの (凸と凹みたいな)。

また、エッジは次元の情報を持っているので、これもまた何か表現方法があってほしい (例えば次元の数だけ線を引く?だけどまず空間に線を平行に引くのがめんどくさい、曲線に「標構」の情報を持たせなきゃいけない)。

根本的にエッジをどう描画するかも問題で、例えばBezierに乗せて曲線にしても良いし、ちょっと伸ばしてから直線という方法もあります。この辺は趣味かな。

まずエッジが無ければ良いのでは?というのも1つで、例えば最初から2次元グリッドを前提として常に「出力は右に進む」ことにするとか (出力位置操作用の機能を別で立てる)。定められた板の上でわちゃわちゃ選択していくのは色々と楽そう (運搬もできる) ではあるけど、細かい位置調節 (によるプログラム構造の可視化) は出来ない。ただ、当然いろいろと楽にはなります。正直そっちのほうが好きかも。

ノードはpickup出来てひゅるひゅる動かせる他、「持っているノードの入力(または出力)部分が、他のノードの出力(または入力)部分に重なったとき」にエッジ接続を行おうかなと思います。この段階では型検査が通らなくてもエッジの生成だけはしちゃっても良い気がします。

入力/出力を直接つかんで繋げる方式も別に良いような気はする。ノードでぺちんってするほうがなんか繋がった感あるかなって思ったくらい。

繋がっているエッジの端点をつかんでpickupUseDownすると削除とか?出力部をつかむと複数に入力されてるときに困っちゃうので、これは入力部だけでできるべきかも。

 

ノードをpickupUseDownすると機能選択パレットみたいなのが出てきて、機能を変えられるようになります。平面上のポインタみたいな感じで選べそう。

また、ノードのパラメータ部をつかむとスカラー値選択UIが出てきます。数直線なんだけどひねると移動に対する変化量が増えるみたいなのをほんのり考えていました。

数字表記は指数表記のほうがいいのかな。でも正直見にくいから併記とかあるといいのかもしれないけど。

スライダーはいい感じに実装すればいいと思います。pickupでもUnityUI式でも…?一貫性的にはpickup。

同期

基本的に全てを同期します (ノードの機能選択パレット・パラメータのスカラー値選択UIの出し入れは同期しなくて良さそう)。

ノードの位置は普通にVRC_ObjectSync、なのでこれはContinuousSync。機能やパラメータのデータは全て1つのUdonBehaviourで管理したほうがいいかも (こっちをManualSyncにする)。

ノードはSpawnさせたいのでVRC_ObjectPoolでも使えば良さそう。

おわり

こんなんでできるんですか?

 

正直UIを作るのがつらそうなだけで、計算部分はすごい簡単だとおもいます。

ノードっぽくなくても良いなら、なんかいい感じの表現能力のある形式を思いついたらまぁそれでやりたい。かも。

個人的にはグリッドは好きなんすけどね…。VRっぽくないか (?)

もうちょっとmotivation (動機) があると絞れたかもしれないですが、とりあえずこれくらいで…。