返回「计算机、信息技术与工程」

WebGL应用-wasm与javascript互操作底层实现

WebGL应用 wasm与javascript互操作底层实现 编译阶段:Clang → LLVM Bitcode

更多
Markdown 结构化数据
本文目录 17 个章节

WebGL应用-wasm与javascript互操作底层实现

编译阶段:Clang → LLVM Bitcode

// add.cpp
extern "C" {
    EMSCRIPTEN_KEEPALIVE          // 标记为“绝不可被裁剪且需导出”
    int Add(int a, int b) { return a + b; }
}
  1. Clang 读取 add.cpp

  2. 加入 -O3 -flto 等优化参数

  3. 生成 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

  1. wasm-ld 把 bitcode 与系统库合并
  2. 根据 emscripten.keepaliveAdd 放入 Wasm Export Section
  3. emcc 继续生成启动脚本
emcc add.bc -sSTANDALONE_WASM=1 \
   -sEXPORTED_RUNTIME_METHODS=ccall,cwrap \
   -o add.js
  • add.wasm → 机器码与导出表
  • add.js  → 加载器 + 堆初始化 + Module._Add 别名

链接步骤对导出符号的处理

  1. 链接器收集符号 EMSCRIPTEN_KEEPALIVE@Add 落进 llvm.used / emscripten.keepalive 记录,wasm-ld 读取后把该符号标记为 must-export

  2. 生成导出条目 在写入二进制模块时,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。

  1. 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 数字转换为 Wasm i32,执行后把结果再转换回 JS number

通过.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); }
}
  1. IL2CPP 把 C# 编译后的 IL 转成 Test.cpp
  2. [DllImport] 生成“空壳” extern "C" int Add(int,int);
  3. Test.cpp 与你的 C++ 插件代码一起交给 Emscripten

2. Bee + Emscripten 阶段:统一编译 → 链接

  • Bee 构建系统调用 em++,附带 Unity 预设参数:
    • -Oz -flto 压缩体积
    • -sEXPORT_ALL=0 禁用默认导出
    • -sEXPORTED_RUNTIME_METHODS=… 明确只导出 ccall, cwrap
  1. Bee 在中途生成 functions.json

    • 收录所有 [DllImport("__Internal")] 名称(例如 Add
    • 再加上 IL2CPP / UnityEngine 运行时必需函数
  2. 链接命令附带

-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 实例

  1. createUnityInstance(canvas, config) 下载 build.framework.js
  2. 内部加载并实例化 build.wasm
  3. JS 侧若需直呼导出函数,可依旧使用
const add = unityInstance.Module.cwrap('Add','number',['number','number']);
  1. 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 → Wasmcwrap 把 JS 数字传入线性内存并调用索引
  • Wasm → JSjsLog(sum) 命中 env.jsLog → 触发你在 jslib 里的 console.log
  • 返回 Wasm 把结果写回寄存器 → JS 包装函数转换成 number