Udonについて

VRChatのワールド用実行基盤として、Udon VMという仮想マシンが存在します。
このUdon VMは、Udon Assemblyから生成されたバイトコードを実行します。

Udon Assemblyは、現状では公式のUdonGraphやUdonSharpなどからコンパイルにより生成されますが、Udon Assemblyを直接手書きすることも可能です。
本記事では、Udon Assemblyを記述する方法についてご紹介します。

Udon Assembly Program Asset

Udon Assemblyを直接記述できるアセットであるUdon Assembly Program Assetを作成するには、VRChat World SDKをインポートした上で、以下のようにします。

Projectタブ > 右クリック > Create > VRChat > Udon > Udon Assembly Program Asset

これでアセットファイルが生成されます。
アセットファイルを選択すると、Inspectorに設定画面が表示されます。

テキストエリアが空欄なので、分かりやすいようにアセンブリを入力してみるとこうなります。

各部を説明します。

①: アセンブリをクリップボードにコピーするボタン
②: アセンブリの入力欄
③: パブリック変数
④: コンパイル後のバイトコードを逆アセンブルした結果(またはコンパイルエラーメッセージ)

この②にアセンブリを入力してゆくことになりますが、アセンブリのコードにエラーがあるとフレームごとにエラーメッセージが出力され続けるというトンデモ仕様があります。
これは、コードの入力途中にも適用され、入力途中のコードは当然エラーと解釈されるので、コードを書いている最はずっとエラーが出続けます。
ログファイルを圧迫することもありますので、コードの記述はテキストエディタで行ない、コピーしたものを入力欄にペーストする方法が良いと思います。
ただ、これを気にしないというのであれば直接入力することもできます。

この方法でもコードにミスがあれば大量のエラーが出てしまいますが、Projectタブからアセットファイルの選択を解除してInspectorが閉じられるとエラーは止まりますので、覚えておきましょう。

Udon Assemblyの構造

Udon Assemblyは大きく分けて2つの部分により構成されます。

  • データ部
  • コード部

これらを以下のように記述します。

.data_start
    # データ部の記述
.data_end

.code_start
    # コード部の記述
.code_end

.data_startから.data_endの間がデータ部、.code_startから.code_endの間がコード部です。
なお、コードは行単位で解釈されますが、途中に空行を入れたり、命令文の途中に空白を入れたりすることは自由です。
また、#はコメントです。

データ部の記述

まずデータ部の記述例を示します。

.data_start

    .export _name
    .sync _name, none
    _name: %SystemString, "Akane"
    _value: %SystemSingle, 12.345

.data_end

この例では_name変数はstring型で、初期値は"Akane"であると定義されています。
また、_value変数はfloat型で、初期値は12.345であると定義されています。
さらに、_nameはパブリックで同期されています。

順を追って見ていきましょう。
まずは、.exportの行と.syncの行を無視して、_name_valueの行に注目します。

変数定義

_name: %SystemString, "Akane"

変数定義は3つの部位により構成されます。

  • 変数名
  • 初期値

<変数名>: %<型>, <初期値>
のように記述します。

変数名

アルファベットと数字とアンダースコア_を使えます。
加えて、角括弧[]と三角括弧<>の文字も使用できます。
ただし、最初の文字はアルファベットとアンダースコアのみが許可されます。

【有効】
x
X
abc123
_Number<0>
__this_is_a_variable[][]__>>>

【無効】
1
222x
$abc

また、後述するコード部で使用するラベル名と同じ名前をつけても全く問題ありません(文脈で区別可能だからです)。

注意

詳細は不明ですが、Udonには予約変数が存在するようです。
予約名を回避するために、変数名の先頭をアンダースコアにすると良いといわれています。

C#(.NET)における型を独自の記法で記述します。
ただし、VRChatが許可する型のみが有効です。

例えばint型は、フルネームでSystem.Int32なので、SystemInt32と記述します(ドットが消去されていることに注意してください)。
Udonでは配列も使用できますが、例えばint[]型の場合、SystemInt32Arrayと最後にArrayを付加します。

型のUdon名称を得るための規則を以下に示します。

  1. 対象の型の名前空間名と型名をピリオド無しで結合する(System.StringSystemString
  2. 子クラスの場合は、親クラスの1に対して型名を付加する(Cinemachine.CinemachinePathBase+AppearanceCinemachineCinemachinePathBaseAppearance
  3. ジェネリック型の場合は、型引数に本規則を適用した名称を列挙する(System.Collections.Generic.List<int>SystemCollectionsGenericListSystemInt
  4. 配列型の場合は、[]を書かず、Arrayを付加する(System.DateTime[]SystemDateTimeArray

変数定義において、この規則により得た名称の先頭に%を付加して表記します。

初期値

限られたリテラルのみ使用可能です。

表記補足
objectnullnullのみ指定可能、struct型はdefault値に初期化される
uint0xFFFFFFFF16進数表記
int1234567890整数
float12.345小数
string“abcdefgh”““で囲った文字列
GameObjectthisthisを指定すると、このUdonBehaviourを所有するGameObjectに初期化される

当然ながら、objectGameObjectはそれを継承する型にも使用可能です。

以上で変数定義は完了です。

属性

データ部に記述できるのは、変数定義と、それに付随する属性の指定です。
指定できる属性は2種類です。

  • export属性
  • sync属性

export属性

対象の変数をPublicに指定します。
Public変数はUnityのInspectorで値を指定することができます。

以下のように記述します。

.export _name
_name: %SystemString, null

sync属性

対象の変数を同期します。
同期された変数は他のクライアントとの通信で値が更新されます。
同期については本記事では詳しく説明しません。

同期にはモードが3つあります。

  • none
  • linear
  • smooth

以下のように記述します。

.sync _value, none
_value: %SystemSingle, 0.0

データ部は以上です。

コード部の記述

コード部の例を示します。

.code_start
    .export _start
    _start:
        PUSH, _message
        PUSH, _str
        COPY
        PUSH, _str
        EXTERN, "UnityEngineDebug.__Log__SystemObject__SystemVoid"
        JUMP, 0xFFFFFFFC
    .export _custom
    _custom:
        PUSH, _message
        EXTERN, "UnityEngineDebug.__Log__SystemObject__SystemVoid"
        JUMP, 0xFFFFFFFC
.code_end

Udon Assemblyのコード部は以下の3要素から成っています。

  • 命令
  • ラベル
  • 属性

命令のPUSHという語が示すように、Udon VMはスタックマシンです。
スタックマシンは、スタックに値を積んだり消費したりしながら計算を行なう機械のことです。

スタックマシンのイメージ

スタックはデータ構造の一種で、後入れ先出しLIFO)という特徴があります。
データを順番にスタックに入れてゆくと、取り出すときは、後に入れたものを先に取り出す、ということです。

図を見て分かるように、数列12をこの順にスタックへプッシュすると、ポップしたときに21と返されます。
これがスタックの挙動です。

以下の命令列について考えてみましょう。
命令列は上の行から順に実行されます。

PUSH, a
PUSH, b
COPY

PUSHは、変数をスタックに積む命令です。
つまり、

  • 変数aを積む
  • 変数bを積む
  • コピーする

です。
COPYは、スタックから2つ取り出し、1つ目の変数に2つ目の変数の内容をコピーする命令です。
先ほどの例の通り、abと積んだので、取り出したときにbaとなります。
従って、baをコピーし、この命令列は終了します。

別の例を見てみます。

PUSH, a
PUSH, b
PUSH, c
COPY
PUSH, d
COPY

まず、abcがスタックに積まれます。
次にコピーなので2つ取り出すと、cbとなりますので、cbとコピーされます。
aは取り出されずにスタックの底に残りました。
そこにdを積み、コピーです。
daと取り出されるので、daとコピーされます。
まとめると、この命令列はcbdaという動作になりました。

スタックマシンは以上のように動作します。

命令の種類

命令記法命令長(byte)説明
NOPNOP4何もしない
PUSHPUSH, var8変数をスタックにプッシュする
POPPOP4スタックから1つ捨てる
JUMPJUMP, label8コード部のラベルへ処理を遷移させる
JUMP_IF_FALSEJUMP_IF_FALSE, label8スタックから1つ取り出し、値がFalseの場合のみJUMP
JUMP_INDIRECTJUMP_INDIRECT, var8varに格納されたアドレス(uint値)にJUMP
COPYCOPY4スタックから2つ取り出し、1つ目の変数に2つ目の値をコピーする
EXTERNEXTERN, “method”8指定された名前のメソッドを実行(パラメータ分スタックを消費)
ANNOTATION?4不明

JUMP系命令について

JUMP系命令は3種類あります。

  1. JUMP, label
  2. JUMP_IF_FALSE, label
  3. JUMP_INDIRECT, var

1は問答無用に命令の実行位置を指定したラベルへ遷移します。

PUSH, a
PUSH, b
JUMP, jump
PUSH, c  # 実行されない
jump:  # ラベル
COPY  # ここから実行

この例の場合、abがスタックに積まれ、その後jumpラベルまで遷移し、COPYが実行されます。

2はスタックから1つ取り出し、Falseの場合のみ遷移します。
取り出した値はBooleanのみ許可されます。
Trueであれば素通りして次の命令に行きます。

PUSH, a
PUSH, b
JUMP_IF_FALSE, jump1
PUSH, c  # b == true
JUMP, jump2
jump1:
PUSH, d  # b == false
jump2:
COPY

ちょっと複雑ですが、上記のようにするとifelseの分岐を表現できます。

3は変則的ですが、指定された変数に格納されたuint値のアドレスへ処理を遷移します。
アドレスとは何かというと、コード部には先頭から順にアドレスが割り振られています。
前掲の表にアセンブリの命令長を示しましたが、命令は命令長分のサイズを持ち、アドレス0から順にメモリに格納されていると考えてください。

先頭のアドレス0PUSH, aが格納されていたら、PUSHの命令調は8なので、次の命令のアドレスは8となります。

命令長は4または8ですので、16進数で表記すると、最右の数値は必ず048Cのいずれかになります。

このようにして、命令の位置はラベルだけでなく数値のアドレスでも表せるのですが、JUMP_INDIRECT命令の場合、指定された変数の値は必ず数値のアドレスとして解釈されますのでご注意ください。

.data_start
    jumpVar: %SystemUInt32, 0x000001F0
.data_end
.code_start
    ...
    JUMP_INDIRECT, jumpVar  # アドレス0x000001F0へ遷移する
    ...
.code_end

もちろん、変数の値を変更することで別の位置へ遷移することもできます。
この命令は、関数の呼び出し元へ戻る際などに使用します。

最後に、0xFFFFFFFCへの遷移は実行を終了する際に使います。

EXTERN命令について

.NETのメソッド呼び出しを行ないます。
例えば、UnityEngine.Debug.Log(obj)を呼ぶには以下のようにします。

PUSH, obj
EXTERN, "UnityEngineDebug.__Log__SystemObject__SystemVoid"

メソッドの名称は、基本的には以下のように決定されます(この規則に準じていないメソッド名もあります)。

  • 型名
  • ピリオド
  • アンダースコアx2
  • メソッド名
  • アンダースコアx2
  • 引数の型(無ければSystemVoid、複数ある場合はアンダースコアx1で区切る)
  • アンダースコアx2
  • 戻り値の型(無ければSystemVoid

メソッドを呼ぶには変数のPUSHが必要です。
以下のようにします。

PUSH, <インスタンス>  # 静的メソッドの場合は無し
PUSH, <引数1>
PUSH, <引数2>
...
PUSH, <戻り値を受け取る変数>  # SystemVoidの場合は無し
EXTERN, "method"

イベント

コード部のラベルにexport属性を付加すると、UdonBehaviourのイベントとして認識されます。
既存のイベントを記述する場合、名称に注意してください。
イベント名称をラベル名称に変換するには以下のようにします。

  • Startイベント → _startラベル
  • Updateイベント → _updateラベル
  • OnPlayerJoinedイベント → _onPlayerJoinedラベル
.code_start
    .export _start
    _start:
        ...
    .export _update
    _update:
        ...
.code_end

まとめ

ざっとですが、Udon Assemblyについて見てみました。
普段UdonGraphやUdonSharpでコーディングする方も、これらの言語自体のバグに当たることは少なくないと思います。
そんなときには、Udon Assemblyをチェックしてみてください。
アセンブリが読めると解決策が思いつくかもしれません。