WebGL应用-wasm与javascript互操作底层实现
WebGL应用 wasm与javascript互操作底层实现 编译阶段:Clang → LLVM Bitcode
本文目录 17 个章节
WebGL应用-wasm与javascript互操作底层实现
编译阶段:Clang → LLVM Bitcode
// add.cpp
extern "C" {
EMSCRIPTEN_KEEPALIVE // 标记为“绝不可被裁剪且需导出”
int Add(int a, int b) { return a + b; }
}
Clang 读取
add.cpp加入
-O3 -flto等优化参数生成 LLVM Bitcode (
add.bc)EMSCRIPTEN_KEEPALIVE被翻译成llvm.used/emscripten.keepalive记录,告诉后续环节“这个符号别删,并且可见”。
产出: add.bc(中间表示,仍含符号信息)
; ModuleID = 'add.cpp'
source_filename = "add.cpp"
define dso_local i32 @Add(i32 %a, i32 %b) #0 {
entry:
%sum = add nsw i32 %a, %b
ret i32 %sum
}
@llvm.used = appending global [1 x i8*] ; ← clang 把 KeepAlive
[i8* bitcast (i32 (i32, i32)* @Add to i8*)] ; 写进 llvm.used
, section "llvm.metadata"
define dso_local→ 设为 “默认可见”,方便后面写入 Wasm 的 Export Section@llvm.used→ 链接时看到这里就 不会 把@Add裁剪掉,也会把它加入 keep-alive 列表
链接阶段:wasm-ld → add.wasm + Glue JS
- wasm-ld 把 bitcode 与系统库合并
- 根据
emscripten.keepalive把Add放入 Wasm Export Section - emcc 继续生成启动脚本
emcc add.bc -sSTANDALONE_WASM=1 \
-sEXPORTED_RUNTIME_METHODS=ccall,cwrap \
-o add.js
add.wasm→ 机器码与导出表add.js→ 加载器 + 堆初始化 +Module._Add别名
链接步骤对导出符号的处理
链接器收集符号
EMSCRIPTEN_KEEPALIVE让@Add落进llvm.used/emscripten.keepalive记录,wasm-ld读取后把该符号标记为 must-export。生成导出条目 在写入二进制模块时,
wasm-ld进入段 ID 7(Export Section),为每个需要导出的符号追加一条记录: 名称长度 → UTF-8 名称 → kind (0=func) → 函数索引。
07 ;; section id = Export
07 ;; payload_len = 7 bytes
01 ;; export_count = 1
03 41 64 64 ;; name_len=3 "A d d"
00 ;; kind = func
00 ;; func index = 0
读法:浏览器解析到段 7 时,立即把名称 “Add” 与函数索引 0 关联,稍后暴露到 exports.Add。
- Glue JS 取出导出对象
完成
.wasm写入后,emcc在加载脚本里跑
Module['_Add'] = asm['Add'];
Glue JS 内容
var Module = {}; // 用户可在加载前覆写配置
var asm; // 1️⃣ 预留全局引用
function createWasm() {
var info = {
env: { // 导入对象
memory: wasmMemory,
table: wasmTable
}
};
// 2️⃣ 浏览器侧实例化 .wasm
return WebAssembly.instantiateStreaming(fetch('add.wasm'), info)
.then(function (result) {
asm = result.instance.exports; // 3️⃣ 拿到 Export Object
return asm; // 并存进外层变量
});
}
/*** 自执行 ***/
createWasm().then(function () {
// 4️⃣ 到这里 asm["Add"] 就是真正的函数入口
Module['_Add'] = asm['Add']; // 绑定一个更友好的别名
});
注: 不同版本Emscripten, 不同环境下, 生成的js脚本会有差异,但是本质基本相同.
C++ 导出的接口 定义为 wasmExports , 可被运行时JavaScript调用;
C++ 需要使用的extern接口, 被定义为 wasmImports , 运行时在创建Wasm时, 将需要导入C++中的接口, 通过env进行存储.
{
'env': wasmImports,
'wasi_snapshot_preview1': wasmImports,
}
运行时的过程:
- 调用createWasm创建Wasm
instantiateStreaming解析二进制,并把 Export Section 映射成普通 JS 对象- 浏览器加载侧实例化 .wasm
- 将C++导出的对象存入asm中 供JavaScript调用:
result.instance.exports包含所有导出的 C 函数——键名=符号名、值=浏览器创建的 JS 包装函数 - 脚本把包装函数再贴给
Module['_Add'],方便开发者使用
关于WebAssembly.instantiateStreaming
WebAssembly.instantiateStreaming(fetch('add.wasm'), importObject)
.then(({instance, module}) => {
// instance.exports === asm
});
- HTTP fetch 返回一个
ReadableStream<Response>。 - Streaming 编译:引擎边收数据边调用编译器前端,遇到 Section 7 即可把
"Add"写进exports字典。 - 实例化:完成字节验证后,分配线性内存 / 表,解析
import,执行模块start函数(若存在)。 - 返回:解析好的
instance.exports(含 “Add”)与原始module一起回调给 JS。
若浏览器不支持 streaming,Emscripten 的运行时会自动降级到:
fetch → arrayBuffer → WebAssembly.instantiate(一次性编译)。
调用阶段浏览器 → JS → Wasm
<script src="add.js"></script>
<script>
addOnPostRun(() => {
const add = Module.cwrap('Add', 'number', ['number','number']);
console.log(add(2, 3)); // 5
});
</script>
Module._Add/cwrap将 JS 数字转换为 Wasmi32,执行后把结果再转换回 JSnumber
通过.jslib 将JS脚本导入C++
C++ 侧代码:
mergeInto(LibraryManager.library, {
jsLog: function (x) {
console.log('value:', x);
}
});
mergeInto 把你声明的函数字典并入 Emscripten 系统库——这表示 “如果 C/C++/IL2CPP 代码里声明了同名的 extern,就在运行时用这里的 JS 实现”。
C/C++ / IL2CPP 侧出现的代码:
// plugin.cpp (或 IL2CPP 生成的 Test.cpp)
extern "C" void jsLog (int); // ❶ 声明,**无实现**
void Foo() { jsLog(42); } // ❷ 普通调用
在链接时, 命令添加参数:
--js-library "<绝对路径>/MyPlugin.jslib"
C++侧: 编译与链接过程
declare void @jsLog(i32) ; 仍未解析,留给链接器
define dso_local void @Foo() {
entry:
call void @jsLog(i32 42) ; 对 jsLog 的一次直接 call
ret void
}
缺口 ⇒ 导入 — 链接器把它写成 Import Section #2 的条目:
(import "env" "jsLog" (func $jsLog (param i32)))
- module 默认是 "env";字段名是源符号 "jsLog"。
- $jsLog 获得一个 函数索引(如 0)。
(module
;; Import Section
(import "env" "jsLog" (func $jsLog (param i32)))
;; Function Section:本地函数 Foo 索引=1
(func $Foo (result _) ;; 无返回值
i32.const 42
call $jsLog) ;; 通过索引 0 跳入“外部插槽”
(export "Foo" (func $Foo))
)
所有“仅声明不定义”的 extern 最终都会变成类似的 env.函数字段 导入槽。
JS侧: --js-library 的机制
emcc add.cpp \
--js-library mylib.jslib \
-sEXPORTED_FUNCTIONS=_Add \
-o add.js
- 在链接脚本里(emcc 内部文件
library.js),Emscripten先声明一个全局单例
var LibraryManager = {
library: {} // 运行时系统库会被附着在这里
};
- 当编译器处理 --js-library MyPlugin.jslib 时,它会在链接阶段——
// pretend code executed in emcc link step
var jslibSource = read('MyPlugin.jslib');
eval(jslibSource); // => 执行 mergeInto(...)
// MyPlugin.jslib 里的
mergeInto(LibraryManager.library, {
jsLog: function (x) { console.log('value:', x); }
});
LibraryManager.library 里多了一个 jsLog。
解析未解析符号 当链接器需要解决 C 层的
jsLog,刚好发现LibraryManager.library里已有同名函数,于是把它作为 Wasm 导入 env.jsLog生成 Glue 代码: 最终输出的
build.framework.js(或a.out.js)里出现:
// Library section (大约在头几百行)
var jsLog = function (x) {
console.log('value:', x);
};
function instantiateAsm(imports) { /*...*/ }
// 核心:把 LibraryManager.library 与系统默认库合并到 asmLibraryArg
var asmLibraryArg = {
"memory": wasmMemory,
"table": wasmTable,
"abort": _abort,
// ↓↓↓ 你的函数在这里 ↓↓↓
"jsLog": function (x) { console.log('value:', x); }
// …还原封装了所有 mergeInto 得到的成员
};
createWasm()把asmLibraryArg装进env
function createWasm() {
var info = { env: asmLibraryArg }; // 关键一行:组装 importObject
// 也可能再加 wasi_snapshot_preview1: asmLibraryArg,视配置而定
return WebAssembly.instantiateStreaming(fetch('a.out.wasm'), info)
.then(function (result) {
return result.instance.exports;
});
}
运行时
- 浏览器实例化
add.wasm时,把jsLog指针填入导入表
// —— 在 a.out.js / build.framework.js 前百行 ——
var asmLibraryArg = {
memory: wasmMemory,
table: wasmTable,
/* 下面这行来自你的 .jslib → mergeInto */
jsLog: function (x) { console.log('value:', x); }
};
function createWasm() {
var info = { env: asmLibraryArg }; // importObject
return WebAssembly.instantiateStreaming(
fetch('a.out.wasm'), info)
.then(res => res.instance.exports); // exports → asm
}
- 当游戏脚本执行
jsLog(42)
Wasm 代码运行到 call_import jsLog 指令 → 跳到 JS
浏览器验证二进制、解析 Import Section。
看到 module="env", field="jsLog" ——> 取
info.env.jsLog。把该 JS 函数放进 Wasm 的导入表槽位 0。
Wasm 在执行
call $jsLog时,直接索引到 导入表槽 0。该槽保存的正是 JavaScript 包装函数
Unity WebGL 构建流程对互操作的处理
Unity3D 会接管链接过程, 会自动处理 .jslib 库。
Unity WebGL 生产线:
C# ─► IL2CPP ─┐ (1)IL2CPP 把托管字节码转 C++
│
C/C++ 源(含插件) ─┴─► clang/LLVM ─► wasm-ld ─► *.wasm
│
(2)Emscripten ├── framework.js ← 自动 Glue
└── 自定义 <*.jslib> ← 手写 Glue(mergeInto)
- IL2CPP → C++ → Wasm Unity 把脚本转换成纯 C++,再交给 Emscripten 编出 Wasm
.jslib所有浏览器侧定制都写在.jslib,通过mergeInto(LibraryManager.library, { ... })合并进 Emscripten 运行时- Wasm & Glue 最终会得到一对文件:
build.framework.js(JS 启动器 + Heap + API Stub)和build.wasm(机器码)。
1. IL2CPP 阶段:C# → C++
// Test.cs
using System.Runtime.InteropServices;
public class Test {
[DllImport("__Internal")] // 符号待在“内部”库,IL2CPP 不提供实现
private static extern int Add(int a, int b);
void Start() { var x = Add(2,3); }
}
- IL2CPP 把 C# 编译后的 IL 转成
Test.cpp - 为
[DllImport]生成“空壳”extern "C" int Add(int,int); Test.cpp与你的 C++ 插件代码一起交给 Emscripten
2. Bee + Emscripten 阶段:统一编译 → 链接
- Bee 构建系统调用
em++,附带 Unity 预设参数:-Oz -flto压缩体积-sEXPORT_ALL=0禁用默认导出-sEXPORTED_RUNTIME_METHODS=… 明确只导出ccall,cwrap等
Bee 在中途生成
functions.json:- 收录所有
[DllImport("__Internal")]名称(例如Add) - 再加上 IL2CPP / UnityEngine 运行时必需函数
- 收录所有
链接命令附带
-sEXPORTED_FUNCTIONS=@functions.json
- 如果你同时在 C++ 插件里写了
EMSCRIPTEN_KEEPALIVE,那条记录同样被保留
2.3 产出
build.wasm← 含 IL2CPP 及插件机器码build.framework.js← 加载器 +.jslib合并 + Unity 内核启动代码
3. .jslib 注入阶段:C++ 调用 JS
// Assets/Plugins/MyPlugin.jslib
mergeInto(LibraryManager.library, {
jsLog: function(x) { console.log("value:", x); }
});
- Unity 自动为每个
.jslib添加--js-library,无需手写 - C# 调用
[DllImport("__Internal")] static extern void jsLog(int x);
4. 运行阶段:浏览器 → Unity 实例
createUnityInstance(canvas, config)下载build.framework.js- 内部加载并实例化
build.wasm - JS 侧若需直呼导出函数,可依旧使用
const add = unityInstance.Module.cwrap('Add','number',['number','number']);
- C# 侧
Add()、jsLog()调用则按照 IL2CPP 生成的 thunk → Wasm → JS 路径执行
Cocos的情况
Cocos 原本使用的就是JS层的脚本。 Emscripten 可以将C++编译成wasm+js glue的形式, JS脚本本身就支持 JS Glue 的加载和运行。
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
// 声明 createMyModule,使 TS 不报错
declare function createMyModule(opts?: any): Promise<any>;
@ccclass('WasmBridge')
export class WasmBridge extends Component {
onLoad () {
// 动态加载生成的 loader
import('wasm/mylib.js').then(async (factory: any) => {
const module = await factory(); // 等 Wasm 实例化
const callJsThenMul = module.cwrap(
'callJsThenMul', 'number', ['number', 'number']
);
const res = callJsThenMul(2, 3); // 触发整条链路
console.log('result from Wasm:', res); // 10
});
}
}
- TS → Wasm
cwrap把 JS 数字传入线性内存并调用索引 - Wasm → JS
jsLog(sum)命中env.jsLog→ 触发你在 jslib 里的console.log - 返回 Wasm 把结果写回寄存器 → JS 包装函数转换成
number