この記事について
前回の記事は3Dアセットの圧縮・配信・保存の仕組みを見てきました。今回は、少し視点を変えて「UnityやUnreal Engineでブラウザ向けに書き出したゲームは、Three.jsのようなJavaScriptライブラリを使っているのか?」という疑問に答えていきます。
結論から言うと、答えはNoです。UnityやUnreal Engineは、Three.jsとはまったく異なるアプローチでブラウザに3Dゲームを持ち込んでいます。
Three.jsとUnity/Unrealのアプローチの違い
まず全体像を整理します。
| Three.js | Unity WebGL / Unreal Web | |
|---|---|---|
| 実体 | JavaScriptで書かれたWebGLラッパーライブラリ | C++/C#で書かれたゲームエンジン本体を丸ごとブラウザ実行形式に変換したもの |
| 動作原理 | ブラウザのJSエンジン上でシーングラフを構築し、WebGLコマンドを発行する | エンジンとゲームロジックをまとめて「WebAssembly」という形式に変換し、それを(ほぼネイティブに近い速度で)ブラウザ上で実行する |
| 想定する開発者 | Web開発者がJavaScriptで直接3D表現を組み立てる | Unity/Unrealのエディタと言語(C#/C++)で普段通り開発したものを、そのままWeb向けに出力する |
つまりThree.jsは「はじめからWeb向けに設計されたライブラリ」であるのに対し、Unity/Unrealは「本来PCやコンソール向けのネイティブアプリを、変換技術でブラウザに持ち込んでいる」という、成り立ちが根本的に違うものです。
そもそも、ブラウザでバイナリなんて実行できるのか?
ここで素朴な疑問が浮かびます。「ブラウザは元々JavaScriptを実行する場所のはずなのに、C++で書かれたコード(バイナリ)なんて動かせるのか?」という点です。
答えは、「WebAssembly」というブラウザに内蔵された専用の実行エンジン(サンドボックス化された仮想マシン)の上でなら動く、というものです。
WebAssembly(略してWasm)は、x86やARMのようなCPU固有の機械語ではなく、ブラウザ内蔵のWasm実行エンジン専用のバイトコードです。ブラウザのJavaScriptエンジン(ChromeのV8やFirefoxのSpiderMonkeyなど)には、このWasmバイトコードを安全に、かつ高速に実行するための仕組みが組み込まれています。
MDNの解説では、WebAssembly自体はDOM(Webページの構造)に直接アクセスすることができず、整数や浮動小数点数のようなプリミティブ型を渡してJavaScriptを呼び出すことしかできない、と説明されています。つまりWasmは「隔離された計算エンジン」であり、画面に何かを表示したり、GPUを操作したりするには、必ずJavaScriptを経由する必要があります。
UnityがWebAssemblyに変換されるまでの流れ
UnityのC#スクリプトが、最終的にブラウザで動くWasmになるまでには、いくつかの変換ステップがあります。Unity公式マニュアルの説明を整理すると、次のような流れになります。
- IL2CPP:Unityが開発した技術で、C#の.NETバイトコードを対応するC++のソースコードに変換します
- Emscripten:C/C++のコードをWebAssemblyやJavaScriptにコンパイルできるツールチェイン(コンパイラ群)です。MDNのチュートリアルでも、Emscriptenを使うとWasmモジュールと、それをブラウザ上で使うために必要なJavaScriptの「グルーコード(橋渡し用コード)」を一緒に生成できると説明されています
Unreal EngineはもともとC++で書かれたエンジンなので、Unityのような「IL2CPP変換」のステップは不要で、エンジンのC++コードを直接Emscriptenに通してWasm化する、というよりシンプルな経路になります。とある技術解説記事では、EmscriptenはC/C++コードをJavaScriptやWebAssemblyへ変換するトランスパイラであり、Unreal Engineもこの技術をWebビルドに利用していると紹介されています。
呼び出し元はやっぱりJavaScript
ここが今回の核心です。「Wasmというバイナリ相当のコードが動く」と言っても、それを起動する主体は依然としてJavaScriptです。
Emscriptenでコンパイルすると、.wasmファイルと必ずセットで.jsファイル(グルーコード)が生成されます。このJSファイルが行っている処理を簡略化すると、次のようなイメージになります。
// Emscriptenが生成するグルーコード(簡略化イメージ)
fetch('game.wasm')
.then(response => WebAssembly.instantiateStreaming(response, importObject))
.then(result => {
const instance = result.instance;
// Wasm側の関数(C++のmain関数やUnityのメインループ)をJSから呼び出す
instance.exports._main();
});
流れを整理すると:
- JavaScriptが
fetchで.wasmファイルをダウンロードする -
WebAssembly.instantiate(またはストリーミング版のinstantiateStreaming)でWasmをコンパイル・初期化する - JavaScriptから
instance.exports.xxx()という形で、Wasm内部の関数を呼び出す
つまり、Wasmの読み込み・起動は常にJavaScriptが主導している、ということです。
Wasm側からWebGLを呼ぶには?
もう一つの疑問として、「C++側で書かれたOpenGL/WebGLの描画命令は、どうやって画面に反映されるのか」という点があります。
Wasm自体はブラウザのcanvasやWebGLのコンテキストに直接触れることができません。そこでEmscriptenは、SDLやOpenGL、OpenALといったよく使われるC/C++ライブラリを、Web APIを使って再実装しており、それぞれについてWasmとWeb APIをつなぐJavaScriptのグルーコードを用意しています。
イメージとしては、こういう対応関係になります。
C++側でglDrawArrays()のようなOpenGL関数を呼んでいるように見えても、コンパイル時にその呼び出しは「JS側のWebGL呼び出しに変換するためのインポート関数への参照」に置き換えられています。実行時には、Wasm → JS → ブラウザの本物のWebGL API、という経路をたどって初めて画面に絵が出ます。
Unityの実際のファイル構成
Unity WebGLビルドを実際にブラウザで開くと、次のようなファイル群が読み込まれます。
<script src="Build/game.loader.js"></script>
<script>
createUnityInstance(canvas, {
dataUrl: "Build/game.data", // アセットデータ(第2回で扱った部分)
frameworkUrl: "Build/game.framework.js", // Emscripten生成のグルーコード
codeUrl: "Build/game.wasm", // Unityエンジン本体+ゲームロジック
});
</script>
-
game.loader.js:全体の読み込みを統括するJavaScript -
game.framework.js:Emscriptenが自動生成したグルーコード -
game.wasm:UnityエンジンとC#のゲームロジックがまとめてコンパイルされたバイナリ -
game.data:シーンやアセットのリソースファイル(第2回で説明した「アセットの塊」がここに入る)
つまり第2回で触れた「HTML/JSとは別に置かれる3Dアセットの塊」は、Unityの場合はこの.dataファイルとして具体的に実体化している、ということになります。
この記事のポイントまとめ
- UnityやUnreal EngineはThree.jsを使っておらず、エンジン本体とゲームロジックを丸ごとWebAssemblyにコンパイルするという全く別のアプローチを取っている
- WebAssemblyはブラウザ内蔵の専用実行エンジン上で動く、隔離されたバイトコードであり、DOMやWebGLに直接アクセスすることはできない
- Wasmの読み込み・起動・WebGL呼び出しは、すべてEmscriptenが自動生成する「JavaScriptグルーコード」が仲介している
- UnityではC# → (IL2CPP) → C++ → (Emscripten) → WebAssembly、という変換の流れをたどる
次回は、この仕組みの先にある「GPUの種類やドライバによる相性問題」を掘り下げます。せっかくWebGL/WebGPUで動かしても、GPUベンダーやドライババージョンによって挙動が変わってしまう理由を見ていきます。