LoginSignup
19
10

More than 1 year has passed since last update.

[Unity]WebGLのプラグインをモダンに書く方法

Last updated at Posted at 2022-12-05

はじめに

UnityでWebGLビルドを行うときにブラウザと連携したくなることがあります。
公式には以下のページでやり方が解説されています。

Plugins フォルダを作り .jslib 拡張子のファイルを配置して以下のように書きます。

hello.jslib
mergeInto(LibraryManager.library, {
    Hello: function() {
        alert("Hello, world")
    }
})

そしてC#のスクリプトを以下のように書きます。

Hello.cs
public class Hello : MonoBehaviour
{
    // jslibのmergeIntoに渡したオブジェクトのキーと同じ名前のメソッドを作る
    [DllImport("__Internal")]
    private static extern void Hello();
    
    void Start()
    {
        // alert("Hello, world")が実行される
        Hello();
    }
}

しかしこの書き方には問題点があります。
それはビルド時にアセットとして含めないといけないということです。
JavaScriptのコードを少し変えただけでUnityのビルドを行っていては時間がかかてしまいます。
今回はこの問題を解消してモダンにプラグインを書く方法を紹介します。

jslibにはJavaScriptのコードを書ける

.jslib のファイルはJavaScriptのコードを書くことができます。
当たり前のように聞こえますがこれにより mergeInto に渡すオブジェクトをJavaScriptのコードで作成することが可能です。
以下のように書くことで HogeFugaPiyoのメソッドを定義することができ、実行するとHelloを表示するメソッドになります。

hello.jslib
const plugin = {}

const methodNames = [
    'Hoge',
    'Fuga',
    'Piyo',
]

for (let i = 0; i < methodNames.length; i++) {
    const methodName = methodNames[i]
    plugin[methodName] = function () { alert('Hello'); }
}

mergeInto(LibraryManager.library, plugin)

__postset を使用する

UnityのWebGLビルドはemscrpitenを使用しています。
Unity側のドキュメントには記載されていないことでもemscripteのドキュメントを読むことでわかることがあります。
emscriptenには__postsetという機能があります。
__postsetコンパイル後のJavaScriptのソースにそのまま書き出されるという機能です。

Interacting with code — Emscripten JavaScript limits in library files

具体的な使い方は以下の通りです。

hello.jslib
mergeInto(LibraryManager.library, {
    Hello: function() {
        alert("Hello, world")
    },
    Hello__postset: 'console.log(42)'
})

この状態でビルドするとconsole.log(42)が書き出されます。
そのためWebGLビルドを起動するとコンソールに42が表示されます。
これだけだとあまり意味がないように見えます。
この機能はメソッドの上書きと合わせることで真価を発揮します。
例えば上記の例ではHelloという名前のメソッドを定義していますが、__postset内では_Helloという名前でアクセスできます。
さらに_Helloというメソッドを上書きすることもできます。

hello.jslib
mergeInto(LibraryManager.library, {
    Hello: function() {
        alert("Hello, world")
    },
    Hello__postset: '_Hello = function(){ alert("Hello__postset") }'
})

こうすることでHelloを実行したときにHello, worldではなくHello__postsetを表示することができます。
ここで重要なのは__postsetの内容はそのままソースに書き出され、ブラウザ上で実行されるということです。
つまり、グローバルに定義されたメソッドにアクセスすることができます

よって以下のようにすることでUnityでWebGLビルドすることなくWebGLのプラグインを書くことができます。

hello.jslib
mergeInto(LibraryManager.library, {
    Hello: function() {
    },
    Hello__postset: '_Hello = Hello'
})
main.js
// ブラウザ側のコード
// Unityのインスタンス作成より前に定義しておく
function Hello() {
    alert('Hello from js!')
}

汎用的にする

前述のとおり.jslibではJavaScriptを実行できます。
メソッド名を元に__postsetを作ると何度も同じことを書かなくて済みます。

hello.jslib
const plugin = {}

const methodNames = [
    'Hoge',
    "Fuga",
    "Piyo",
]

for (let i = 0; i < methodNames.length; i++) {
    const methodName = methodNames[i]
    plugin[methodName] = function () { }
    plugin[methodName+'__postset'] = `_${methodName} = ${methodName}`
}

mergeInto(LibraryManager.library, plugin)
main.js
function Hoge() {
    alert('Hoge')
}
function Fuga() {
    alert('Fuga')
}
function Piyo() {
    alert('Piyo')
}

これである程度便利になりましたが、グローバルに色々定義したくないのでブラウザ側で以下のようなメソッドを導入します。

lib.js
const functionMap = new Map()
// jslibに書くメソッド
function __unity_getBinding(methodName) {
    return functionMap.get(methodName)
}

// ブラウザ側のコードに書くメソッド
function bindFunction(methodName, func) {
    functionMap.set(methodName, func)
}

これを使うようにコードを修正します。

hello.jslib
const plugin = {}

const methodNames = [
    'Hoge',
    "Fuga",
    "Piyo",
]

for (let i = 0; i < methodNames.length; i++) {
    const methodName = methodNames[i]
    plugin[methodName] = function () { }
    // __unity_getBindingで実際に実行するメソッドを取得する
    plugin[methodName+'__postset'] = `_${methodName} = __unity_getBinding('${methodName}')`
}

mergeInto(LibraryManager.library, plugin)
main.js
bindFunction('Hoge', () => alert('Hoge'))
bindFunction('Fuga', () => alert('Fuga'))
bindFunction('Piyo', () => alert('Piyo'))

C#とJavaScriptのデータ受け渡し

基本的にUnityの公式ページに書いてある通りですが文字列の渡し方は少し注意が必要です。
C#とJavaScriptで文字列を受け渡しする場合、lengthBytesUTF8stringToUTF8_mallocPointer_stringifyなどのメソッドを使用する必要があります。

hello.jslib
mergeInto(LibraryManager.library, {
    // C#側からもらった文字列に_helloを付けて返す
    Hello: function(strPointer) {
        // C#側から渡される文字列はポインタになってるのでPointer_stringifyで文字列にする
        const str = Pointer_stringify(strPointer)
        // 渡された文字列にhelloをつける
        const result = str + '_hello';
        // そのまま文字列を返せないのでヒープを確保してそれを返す
        const bufferSize = lengthBytesUTF8(str) + 1
        const buffer = Module._malloc(bufferSize)
        // 確保したヒープに書き込み
        stringToUTF8(result, buffer, bufferSize)
        
        // C#に返したヒープは自動でfreeされる
        return buffer
    }
})
Hello.cs
public class Hello : MonoBehaviour
{
    [DllImport("__Internal")]
    private static extern void Hello(string str);
    
    void Start()
    {
        Debug.Log(Hello("a")); // => a_hello
        Debug.Log(Hello("b")); // => b_hello
        Debug.Log(Hello("c")); // => c_hello
    }
}

Pointer_stringifyなどのメソッドは__postsetなどの中でしかアクセスできません。

main.js
bindFunction('Hello', strPointer => {
    // Uncaught ReferenceError: Pointer_stringify is not defined
    const str = Pointer_stringify(strPointer)
})

これを回避するシンプルな方法は引数として渡してしまうことです。

hello.jslib
// ...省略

// __unity_getBindingの引数にPointer_stringifyを渡す
plugin[methodName+'__postset'] = `_${methodName} = __unity_getBinding(Pointer_stringify, '${methodName}')`
lib.js
let Pointer_stringify = null
function __unity_getBinding(pointer_stringify, methodName) {
    Pointer_stringify = pointer_stringify;
    return functionMap.get(methodName)
}
hello.js
bindFunction('Hello', strPointer => {
    // OK
    const str = Pointer_stringify(strPointer)
})

必要な引数は色々あるのでそれもまとめて渡せるようにします。

hello.jslib
// 必要なメソッド
const helperFunctionNames = [
    'lengthBytesUTF8',
    'stringToUTF8',
    'Pointer_stringify',
]

// { lengthBytesUTF8: lengthBytesUTF8, ... }のようなオブジェクトの文字列を作成する
const helperFunctions = '{' + helperFunctionNames.map(x => `${x}:${x}`).join(',') + '}'

for (let i = 0; i < methodNames.length; i++) {
    const methodName = methodNames[i]
    plugin[methodName] = function () { }
    plugin[methodName+'__postset'] = `_${methodName} = __unity_getBinding(Module, ${helperFunctions}, '${methodName}')`
}
lib.js
let Module = null
let helperFunctions = null
function __unity_getBinding(module, _helperFunctions, methodName) {
    Module = module
    helperFunctions = _helperFunctions
    return functionMap.get(methodName)
}
main.js
bindFunction('Hello', strPointer => {
    // OK
    const str = helperFunctions.Pointer_stringify(strPointer)
})

C#のメソッドをJavaScriptから呼び出す

公式推奨の方法はunityInstance.SendMessageを使用する方法ですがC#側からActionを渡してそれをJavaScriptから実行することもできます。
C#側からActionを渡したい場合、staticで定義してMonoPInvokeCallback属性を付けます

Hello.cs
public class Hello : MonoBehaviour
{
    [DllImport("__Internal")]
    private static extern void RegisterCallback(Action<string> callback);
    
    [MonoPInvokeCallback(typeof(Action<string>))]
    private static void Callback(string str)
    {
        Debug.Log(str);
    }
    
    void Start()
    {
        RegisterCallback(Callback);
    }
}

渡されたActionはJavaScript側ではポインタになっています。
このポインタを実行するためにはModule.dynCall_xxxを使います。

main.js
bindFunction('RegisterCallback', callbackPtr => {
    const callback = str => {
         // ヒープを確保してそれを渡す
        const bufferSize = helperFunctions.lengthBytesUTF8(str) + 1
        const buffer = Module._malloc(bufferSize)
        helperFunctions.stringToUTF8(str, buffer, bufferSize)
        
        // メソッドを実行する
        // viの部分はメソッドの引数や戻り値に応じて変更する
        Module.dynCall_vi(callbackPtr, buffer)
        
        // ヒープを解放する
        Module._free(buffer)
    }

    // 一秒毎ににコールバックを実行する
    let i = 0
    setInterval(() => {
        callback(`Hello from js: ${i++}`)
    }, 1000)
})

まとめ

これらを活用することで.jslibにコードを書くことなくブラウザ側のコードだけでWebGLのプラグインを書くことができます。
ブラウザ側のコードということはモダンな開発環境を整えるのも難しくありませんね。

おまけ - サンプルプロジェクト

inputフィールドがブラウザ側に配置されてあり、入力内容がUnity側に反映されます。

webglsample.gif

デモ

ソース

おまけ - TypeScriptで書く

TypeScriptで書く場合のサンプルを用意しました。

使い方

main.ts
import {
    bindFunction,
    Pointer,
    buildFunctionVoid_Int, buildFunctionVoid_IntString,
} from 'unity-webgl-binding'

bindFunction('Init', () => {
    alert('Init!')
})

bindFunction('RegisterCallback', (x: Pointer, y: Pointer) => {
    const func1 = buildFunctionVoid_Void(x)
    const func2 = buildFunctionVoid_IntString(y)
    
    func1()
    func2(1, 'hello')
})
19
10
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
19
10