はじめに
UnityでWebGLビルドを行うときにブラウザと連携したくなることがあります。
公式には以下のページでやり方が解説されています。
Plugins
フォルダを作り .jslib
拡張子のファイルを配置して以下のように書きます。
mergeInto(LibraryManager.library, {
Hello: function() {
alert("Hello, world")
}
})
そしてC#のスクリプトを以下のように書きます。
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のコードで作成することが可能です。
以下のように書くことで Hoge
、Fuga
、Piyo
のメソッドを定義することができ、実行するとHello
を表示するメソッドになります。
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
具体的な使い方は以下の通りです。
mergeInto(LibraryManager.library, {
Hello: function() {
alert("Hello, world")
},
Hello__postset: 'console.log(42)'
})
この状態でビルドするとconsole.log(42)
が書き出されます。
そのためWebGLビルドを起動するとコンソールに42が表示されます。
これだけだとあまり意味がないように見えます。
この機能はメソッドの上書きと合わせることで真価を発揮します。
例えば上記の例ではHello
という名前のメソッドを定義していますが、__postset
内では_Hello
という名前でアクセスできます。
さらに_Hello
というメソッドを上書きすることもできます。
mergeInto(LibraryManager.library, {
Hello: function() {
alert("Hello, world")
},
Hello__postset: '_Hello = function(){ alert("Hello__postset") }'
})
こうすることでHello
を実行したときにHello, world
ではなくHello__postset
を表示することができます。
ここで重要なのは__postset
の内容はそのままソースに書き出され、ブラウザ上で実行されるということです。
つまり、グローバルに定義されたメソッドにアクセスすることができます。
よって以下のようにすることでUnityでWebGLビルドすることなくWebGLのプラグインを書くことができます。
mergeInto(LibraryManager.library, {
Hello: function() {
},
Hello__postset: '_Hello = Hello'
})
// ブラウザ側のコード
// Unityのインスタンス作成より前に定義しておく
function Hello() {
alert('Hello from js!')
}
汎用的にする
前述のとおり.jslib
ではJavaScriptを実行できます。
メソッド名を元に__postset
を作ると何度も同じことを書かなくて済みます。
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)
function Hoge() {
alert('Hoge')
}
function Fuga() {
alert('Fuga')
}
function Piyo() {
alert('Piyo')
}
これである程度便利になりましたが、グローバルに色々定義したくないのでブラウザ側で以下のようなメソッドを導入します。
const functionMap = new Map()
// jslibに書くメソッド
function __unity_getBinding(methodName) {
return functionMap.get(methodName)
}
// ブラウザ側のコードに書くメソッド
function bindFunction(methodName, func) {
functionMap.set(methodName, func)
}
これを使うようにコードを修正します。
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)
bindFunction('Hoge', () => alert('Hoge'))
bindFunction('Fuga', () => alert('Fuga'))
bindFunction('Piyo', () => alert('Piyo'))
C#とJavaScriptのデータ受け渡し
基本的にUnityの公式ページに書いてある通りですが文字列の渡し方は少し注意が必要です。
C#とJavaScriptで文字列を受け渡しする場合、lengthBytesUTF8
、stringToUTF8
、_malloc
、Pointer_stringify
などのメソッドを使用する必要があります。
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
}
})
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
などの中でしかアクセスできません。
bindFunction('Hello', strPointer => {
// Uncaught ReferenceError: Pointer_stringify is not defined
const str = Pointer_stringify(strPointer)
})
これを回避するシンプルな方法は引数として渡してしまうことです。
// ...省略
// __unity_getBindingの引数にPointer_stringifyを渡す
plugin[methodName+'__postset'] = `_${methodName} = __unity_getBinding(Pointer_stringify, '${methodName}')`
let Pointer_stringify = null
function __unity_getBinding(pointer_stringify, methodName) {
Pointer_stringify = pointer_stringify;
return functionMap.get(methodName)
}
bindFunction('Hello', strPointer => {
// OK
const str = Pointer_stringify(strPointer)
})
必要な引数は色々あるのでそれもまとめて渡せるようにします。
// 必要なメソッド
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}')`
}
let Module = null
let helperFunctions = null
function __unity_getBinding(module, _helperFunctions, methodName) {
Module = module
helperFunctions = _helperFunctions
return functionMap.get(methodName)
}
bindFunction('Hello', strPointer => {
// OK
const str = helperFunctions.Pointer_stringify(strPointer)
})
C#のメソッドをJavaScriptから呼び出す
公式推奨の方法はunityInstance.SendMessage
を使用する方法ですがC#側からActionを渡してそれをJavaScriptから実行することもできます。
C#側からActionを渡したい場合、staticで定義してMonoPInvokeCallback
属性を付けます
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
を使います。
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側に反映されます。
デモ
ソース
おまけ - TypeScriptで書く
TypeScriptで書く場合のサンプルを用意しました。
使い方
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')
})