3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

エンジニア作業飲み集会Advent Calendar 2022

Day 3

UdonAssemblyで制御文を表現する

Last updated at Posted at 2022-12-02

はじめに

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つの部分により構成されます。

  • データ部
  • コード部

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

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

image.png

UdonAssemblyで制御文を表現する

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

値の評価

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

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

代入

U#
var x = a;
UdonAssembly
PUSH, a
PUSH, x
COPY

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

四則演算(加算)

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項の場合)

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文

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文

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と大体一緒です。
ifelse ifのブロックは命令が規則的に並んでいるのが分かります。

while文

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

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

for文

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文

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#のforeachforと同じものです。
ループカウンタと条件式の評価が自動生成されるだけです。

関数(定義と呼び出し)

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を読めると便利かもしれません。

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

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?