はじめに
VRChatで毎週金曜日に開催されているイベント「エンジニア作業飲み集会」のアドベントカレンダーということで、VRChatのワールドのギミックをプログラミングするための言語である「Udon」に関連した記事を書かせていただきます。
Udonについて
Udonは「VRChat SDK - Worlds」のバージョン3に付属するプログラミング言語です。
公式の解説ページにもプログラミング言語だと書かれています。
しかし、実際には「UdonGraph」「UdonSharp」「UdonAssembly」などがプログラミング言語としての機能をもつものであり、単に「Udon」と言った場合、実行環境である「UdonVM」も含めたUdonシステム全体のことを指すケースが多いように感じます。
ここで言うUdonシステム全体というのは、
- UdonGraphやUdonSharpでプログラミングしたコードが、
- UdonAssemblyにコンパイルされ、ワールドと一緒にアップロードされて、
- そのワールドにJoinするとUdonVMにより実行される。
という概観になります。
ワールド制作者がどのような言語でプログラミングしたとしても、UdonVMが実行するのはUdonAssemblyです。
ですので、UdonAssemblyが自由に書ければUdon用の独自言語を作ることも可能です。
筆者は現在Udon用の言語を作成中であることもあって、UdonAssemblyについて色々調べました。
本記事では、UdonSharpが実際どのようなUdonAssemblyを生成するのかを紹介し、if
文やfor
文、関数などがUdonAssemblyでどのように表現されるのかを見てみたいと思います。
UdonAssembly
UdonAssemblyは、内部的にはバイナリだと思われますが、文字列で表現することが可能です。
この文字列表現であれば、人間が容易に判読できます。
UdonAssemblyの構造
UdonAssemblyは大きく分けて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
-
.export 変数名
- 変数をパブリックにします。
-
.sync 変数名, none|linear|smooth
- 変数を同期します。
-
変数名: %型名, 初期値
- 変数を宣言します。
変数の宣言に「初期値」というものがありますが、これは限られた型のリテラルしか指定できません。
型 | 表記 | 補足 |
---|---|---|
object | null | nullのみ指定可能、struct型はdefault値に初期化される |
uint | 0xFFFFFFFF | 16進数表記 |
int | 1234567890 | 整数 |
float | 12.345 | 小数 |
string | "abcdefgh" | 文字列 |
GameObject | this | thisを指定すると、このUdonBehaviourを所有するGameObjectに初期化される |
表記できる型が限られるため、UdonSharpではthis
以外の全ての初期値をnull
にしておき、UdonAssemblyProgramAssetの機能により、後から初期値を流し込む、という方法を取っています。
(このせいで、制御用の変数の値まで全部null
になってしまい、したがってUdonAssemblyコードが読みにくい、という問題があります)
コード部の記述
コード部の例を示します。
.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
-
.export ラベル名
- ラベルをパブリックイベントのエントリポイントとします。
-
ラベル名:
- ジャンプ先としてマークします。
-
命令
- 4byte命令です。
-
命令, 引数
- 8byte命令です。
命令には以下のものがあります。
命令 | 記法 | 命令長(byte) | 説明 |
---|---|---|---|
NOP | NOP | 4 | 何もしない |
PUSH | PUSH, var | 8 | 変数をスタックにプッシュする |
POP | POP | 4 | スタックから1つ捨てる |
JUMP | JUMP, label | 8 | コード部のラベルへ処理を遷移させる |
JUMP_IF_FALSE | JUMP_IF_FALSE, label | 8 | スタックから1つ取り出し、値がFalseの場合のみJUMP |
JUMP_INDIRECT | JUMP_INDIRECT, var | 8 | varに格納されたアドレス(uint値)にJUMP |
COPY | COPY | 4 | スタックから2つ取り出し、1つ目の変数に2つ目の値をコピーする |
EXTERN | EXTERN, “method” | 8 | 指定された名前のメソッドを実行(パラメータ分スタックを消費) |
ANNOTATION | ? | 4 | 不明 |
(JUMP, 0xFFFFFFFC
という命令は、プログラムの終了を意味します。)
スタックマシン
上表の説明の中に「スタック」という言葉がありますが、UdonVMはスタックマシンとして動作します。
スタックマシンとは、データをスタックにプッシュしたりポップしたりしながら動作する状態機械のことです。
スタックはデータ構造の一種で、後入れ先出し(LIFO)という特徴があります。
データを順番にスタックに入れてゆくと、取り出すときは、後に入れたものを先に取り出す、ということです。
UdonAssemblyで制御文を表現する
注:以下で紹介するUdonAssemblyは、読みやすいように手を加えています。
値の評価
a // このような文はC#では許可されませんが、イメージとして紹介します
PUSH, a
値を評価することは、その値をPUSH
することに相当します。
UdonAssemblyでは、値は全て変数により表現されます。
したがって、U#上でリテラルだとしても、その値はUdonAssembly上では何らかの変数に格納されています。
PUSH
された変数は、後続の処理により消費されるはずです。
代入
var x = a;
PUSH, a
PUSH, x
COPY
COPY
により代入を表現します。
最初のPUSH, a
が右辺の値の評価、次のPUSH, x
とCOPY
が代入処理、と分けて考えると理解しやすいです。
四則演算(加算)
var x = a + b;
PUSH, a
PUSH, b
PUSH, x
EXTERN, "SystemInt32.__op_Addition__SystemInt32_SystemInt32__SystemInt32"
UdonAssemblyに加算命令は無いので、EXTERN
でdotnetのメソッドを呼んでいます。
加算のメソッドであるstatic int operator+(int a, int b)
が、"SystemInt32.__op_Addition__SystemInt32_SystemInt32__SystemInt32"
という名前になっています。
長い名前ですが、メソッドの名前には規則性があります。
所属クラス.__メソッド名__引数1_引数2_...__戻り値
のように読むことができます。
四則演算(3項の場合)
var x = a + b + c;
PUSH, a
PUSH, b
PUSH, tmp
EXTERN, "SystemInt32.__op_Addition__SystemInt32_SystemInt32__SystemInt32"
PUSH, tmp
PUSH, c
PUSH, x
EXTERN, "SystemInt32.__op_Addition__SystemInt32_SystemInt32__SystemInt32"
3項ある場合は、一時変数が必要となります。
tmp = a + b
を計算し、次にx = tmp + c
を計算しています。
if文
if (condition) // 条件
{
// 分岐
x = a;
}
// 後続処理
x = y;
# 条件
PUSH, condition
JUMP_IF_FALSE, endIf
# 分岐
PUSH, a
PUSH, x
COPY
endIf:
# 後続処理
PUSH, y
PUSH, x
COPY
まず条件式を評価(PUSH
)し、それをJUMP_IF_FALSE
が消費します。
JUMP_IF_FALSE
はif文のような分岐で有用です。
条件式がtrue
ならこの命令は素通りし、false
の場合は分岐部分を飛ばして後続処理(endIf
)までJUMP
します。
もちろん、条件式、分岐、後続処理は元のコードの内容に従って置き換えられます。
if - else if - else文
if (condition1) // 条件1
{
// 分岐1
x = a;
}
else if (condition2) // 条件2
{
// 分岐2
x = b;
}
else
{
// 分岐3
x = c;
}
// 後続処理
x = y;
# 条件1
PUSH, condition1
JUMP_IF_FALSE, branch2
# 分岐1
PUSH, a
PUSH, x
COPY
JUMP, endIf
branch2:
# 条件2
PUSH, condition2
JUMP_IF_FALSE, branch3
# 分岐2
PUSH, b
PUSH, x
COPY
JUMP, endIf
branch3:
# 分岐3
PUSH, c
PUSH, x
COPY
endIf:
# 後続処理
PUSH, y
PUSH, x
COPY
先ほどのif
と大体一緒です。
if
とelse if
のブロックは命令が規則的に並んでいるのが分かります。
while文
while (condition)
{
x = a;
}
loop:
PUSH, condition
JUMP_IF_FALSE, endWhile
PUSH, a
PUSH, x
COPY
JUMP, loop
endWhile:
while
はif
と似ています。
末尾に先頭へ戻るJUMP
があるだけです。
for文
for (var i = 0; i < 10; i++)
{
x = a;
}
# iを初期化
PUSH, 0
PUSH, i
COPY
loop:
# 条件式の評価
PUSH, i
PUSH, 10
PUSH, condition
EXTERN, "op_LessThan"
PUSH, condition
JUMP_IF_FALSE, endFor
# ループ内部
PUSH, a
PUSH, x
COPY
# iをインクリメント
PUSH, i
PUSH, 1
PUSH, i
EXTERN, "op_Addition"
# 条件式の評価へ戻る
JUMP, loop
endFor:
for
は処理が少し混み合っていますが、条件式の評価とインクリメントの位置関係に注意しましょう。
foreach文
foreach (var el in arr)
{
x = a;
}
# 配列の長さを取得
PUSH, arr
PUSH, len
EXTERN, "Array.get_Length"
# ループカウンタを初期化
PUSH, 0
PUSH, i
COPY
loop:
# 配列長と比較
PUSH, i
PUSH, len
PUSH, condition
EXTERN, "op_LessThan"
PUSH, condition
JUMP_IF_FALSE, endForEach
# 配列から要素を取得
PUSH, arr
PUSH, i
PUSH, el
EXTERN, "Array.Get"
# ループ内部
PUSH, a
PUSH, x
COPY
# iをインクリメント
PUSH, i
PUSH, 1
PUSH, i
EXTERN, "op_Addition"
# 条件式の評価へ戻る
JUMP, loop
endForEach:
U#のforeach
はfor
と同じものです。
ループカウンタと条件式の評価が自動生成されるだけです。
関数(定義と呼び出し)
void Start()
{
var x = Fn(1, 2);
}
int Fn(int a, int b)
{
return a + b;
}
.export _start
_start:
# 終了アドレスをあらかじめ用意
PUSH, 0xFFFFFFFC
# Fnの呼び出し元アドレスを用意
PUSH, ret_addr
# 引数を代入
PUSH, 1
PUSH, Fn_a
COPY
PUSH, 2
PUSH, Fn_b
COPY
# Fnへ遷移
JUMP, Fn
ret:
# この位置へ戻ってくる
PUSH, ret_value
PUSH, x
COPY
# 終了アドレスを代入
PUSH, jump_addr
COPY
# 終了
JUMP_INDIRECT, jump_addr
Fn:
# 足し算を計算
PUSH, Fn_a
PUSH, Fn_b
PUSH, ret_value
EXTERN, "op_Addition"
# ret_addrを代入
PUSH, jump_addr
COPY
JUMP_INDIRECT, jump_addr
関数の定義を簡易的に表すとこうなります。
先頭で終了アドレスをPUSH
しておくことで、エントリポイント(この場合Start
)からreturn
したときにプログラムが終了するように工夫されています。
また、エントリポイントではなく他の関数に呼ばれた場合は、事前に戻り先のアドレスをPUSH
しておくことで、終了ではなく、呼び出し元への復帰を実現できます。
まとめ
U#とUdonAssemblyの対応を紹介しました。
実際のUdonAssemblyは、変数名やメソッド名が長くて読みにくかったり、リテラルが隠されているためどのような値なのか不明だったりしますが、基本的には本記事に載せた制御文が大体そのままの形で使われています。
UdonAssemblyを実際に書くことはほぼ無いと思いますが、なんらかの不具合に遭遇した際に、UdonAssemblyを読めると便利かもしれません。
ここまでお読みいただきありがとうございました。