---
title: "WebGL应用-wasm与javascript互操作底层实现"
author: "Perrin Yong"
author_profile: https://www.pystone.net/profile/
published_by: "Perrin Yong"
canonical: https://www.pystone.net/notes/webgl-wasm-js-interop-implementation/
type: note
content_role: unspecified
visibility: public
id_stability: rename-stable
source_path: "10-计算机、信息技术与工程/05-游戏图形与运行时/WebGL/WebGL应用-wasm与javascript互操作底层实现.md"
content_hash: 39901984a112ed76d5e97ea5b43e7637178ca8cc08efd4fe104c0a8b3e7412d9
knowledge_version: 224c990773de.5fa8af6e39fa
site_commit: 224c990773de166d23a886306577dd90379529ce
notes_commit: 5fa8af6e39fa3891d1b9b4832bfa6c4e0ecaaf0a
---
# WebGL应用-wasm与javascript互操作底层实现
## 编译阶段：Clang → LLVM Bitcode

```cpp
// 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`（中间表示，仍含符号信息）
```cpp
; 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.keepalive` 把 `Add` 放入 **Wasm Export Section**
3. **emcc** 继续生成启动脚本

```bash
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) → 函数索引_。

```cpp
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。


3. **Glue JS 取出导出对象**
    完成 `.wasm` 写入后，`emcc` 在加载脚本里跑

```js
Module['_Add'] = asm['Add'];
```

### Glue JS 内容
```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进行存储.
```js
{
    '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
```js
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

```html
<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++ 侧代码:
```js
mergeInto(LibraryManager.library, {
  jsLog: function (x) {
    console.log('value:', x);
  }
});

```
`mergeInto` 把你声明的函数字典并入 **Emscripten 系统库**——这表示 “如果 C/C++/IL2CPP 代码里声明了同名的 `extern`，就在运行时用这里的 JS 实现”。

C/C++ / IL2CPP 侧出现的代码:
```cpp
// plugin.cpp  (或 IL2CPP 生成的 Test.cpp)
extern "C" void jsLog (int);   // ❶ 声明，**无实现**
void Foo() { jsLog(42); }      // ❷ 普通调用
```

在链接时, 命令添加参数:
```bash
--js-library "<绝对路径>/MyPlugin.jslib"
```

### C++侧: 编译与链接过程
```LLVM IR
declare void @jsLog(i32)              ; 仍未解析，留给链接器

define dso_local void @Foo() {
entry:
  call void @jsLog(i32 42)            ; 对 jsLog 的一次直接 call
  ret void
}
```

缺口 ⇒ 导入 — 链接器把它写成 Import Section #2 的条目：
```wasm
(import "env" "jsLog" (func $jsLog (param i32)))
```
* module 默认是 "env"；字段名是源符号 "jsLog"。
* $jsLog 获得一个 函数索引（如 0）。

```wasm - WAT
(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 的机制

```bash
emcc add.cpp              \
     --js-library mylib.jslib \
     -sEXPORTED_FUNCTIONS=_Add \
     -o add.js
```

* 在链接脚本里（emcc 内部文件 `library.js`），Emscripten先声明一个**全局单例**

```js
var LibraryManager = {
  library: {}             // 运行时系统库会被附着在这里
};
```

* 当编译器处理 --js-library MyPlugin.jslib 时，它会在链接阶段——

```js
// 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`）里出现：

```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`
```js
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` 指针填入导入表
```js
// —— 在 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 生产线:
```mathematica
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++

```csharp
// 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 运行时必需函数

1. 链接命令附带
```bash
-sEXPORTED_FUNCTIONS=@functions.json
```
* 如果你同时在 C++ 插件里写了 `EMSCRIPTEN_KEEPALIVE`，那条记录同样被保留

### 2.3 产出
* `build.wasm`　　　　　← 含 IL2CPP 及插件机器码
* `build.framework.js`　← 加载器 + `.jslib` 合并 + Unity 内核启动代码

---

## 3. .jslib 注入阶段：C++ 调用 JS

```js
// Assets/Plugins/MyPlugin.jslib
mergeInto(LibraryManager.library, {
  jsLog: function(x) { console.log("value:", x); }
});
```

* Unity 自动为每个 `.jslib` 添加 `--js-library`，无需手写
* C# 调用
```csharp
[DllImport("__Internal")] static extern void jsLog(int x);
```

---

## 4. 运行阶段：浏览器 → Unity 实例

1. `createUnityInstance(canvas, config)` 下载 `build.framework.js`
2. 内部加载并实例化 `build.wasm`
3. JS 侧若需直呼导出函数，可依旧使用

```js
const add = unityInstance.Module.cwrap('Add','number',['number','number']);
```
4. C# 侧 `Add()`、`jsLog()` 调用则按照 IL2CPP 生成的 thunk → Wasm → JS 路径执行


## Cocos的情况

Cocos 原本使用的就是JS层的脚本。
Emscripten 可以将C++编译成wasm+js glue的形式， JS脚本本身就支持 JS Glue 的加载和运行。

```ts
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`
