UdonAssemblyで制御文を表現する

# はじめに

VRChatで毎週金曜日に開催されているイベント「エンジニア作業飲み集会」のアドベントカレンダーということで、VRChatのワールドのギミックをプログラミングするための言語である「Udon」に関連した記事を書かせていただきます。

# Udonについて

Udonは「VRChat SDK - Worlds」のバージョン3に付属するプログラミング言語です。
[公式の解説ページ](https://docs.vrchat.com/docs/what-is-udon)にもプログラミング言語だと書かれています。
しかし、実際には「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つの部分により構成されます。

- データ部
- コード部

これらを以下のように記述することになります。
（`#`はコメントです）

```:UdonAssembly
.data_start
    # データ部の記述
.data_end

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

`.data_start`から`.data_end`の間がデータ部、`.code_start`から`.code_end`の間がコード部です。

### データ部の記述

データ部はこのように記述されます。

```:UdonAssembly
.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コードが読みにくい、という問題があります）

### コード部の記述

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

```: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）という特徴があります。
データを順番にスタックに入れてゆくと、取り出すときは、後に入れたものを先に取り出す、ということです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2954307/94a7557f-a02d-95dc-5b77-c20516b60a0a.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2954307/b8dc4a54-e30c-e8f3-aa22-9435a09e758e.png)

# UdonAssemblyで制御文を表現する

注：以下で紹介するUdonAssemblyは、読みやすいように手を加えています。

### 値の評価

```csharp:U#
a // このような文はC#では許可されませんが、イメージとして紹介します
```
```:UdonAssembly
PUSH, a
```

値を評価することは、その値を`PUSH`することに相当します。
UdonAssemblyでは、値は全て変数により表現されます。
したがって、U#上でリテラルだとしても、その値はUdonAssembly上では何らかの変数に格納されています。
`PUSH`された変数は、後続の処理により消費されるはずです。

### 代入

```csharp:U#
var x = a;
```
```:UdonAssembly
PUSH, a
PUSH, x
COPY
```

`COPY`により代入を表現します。
最初の`PUSH, a`が右辺の値の評価、次の`PUSH, x`と`COPY`が代入処理、と分けて考えると理解しやすいです。

### 四則演算（加算）

```csharp:U#
var x = a + b;
```
```:UdonAssembly
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項の場合）

```csharp:U#
var x = a + b + c;
```
```:UdonAssembly
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文

```csharp:U#
if (condition) // 条件
{
    // 分岐
    x = a;
}
// 後続処理
x = y;
```
```:UdonAssembly
# 条件
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文

```csharp:U#
if (condition1) // 条件1
{
    // 分岐1
    x = a;
}
else if (condition2) // 条件2
{
    // 分岐2
    x = b;
}
else
{
    // 分岐3
    x = c;
}
// 後続処理
x = y;
```
```:UdonAssembly
# 条件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文

```csharp:U#
while (condition)
{
    x = a;
}
```
```:UdonAssembly
loop:
PUSH, condition
JUMP_IF_FALSE, endWhile
PUSH, a
PUSH, x
COPY
JUMP, loop
endWhile:
```

`while`は`if`と似ています。
末尾に先頭へ戻る`JUMP`があるだけです。

### for文

```csharp:U#
for (var i = 0; i < 10; i++)
{
    x = a;
}
```
```:UdonAssembly
# 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文

```csharp:U#
foreach (var el in arr)
{
    x = a;
}
```
```:UdonAssembly
# 配列の長さを取得
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`と同じものです。
ループカウンタと条件式の評価が自動生成されるだけです。

### 関数（定義と呼び出し）

```csharp:U#
void Start()
{
    var x = Fn(1, 2);
}

int Fn(int a, int b)
{
    return a + b;
}
```
```:UdonAssembly
.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を読めると便利かもしれません。

ここまでお読みいただきありがとうございました。