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

Unity WebGL 中 JavaScript层调用 C++ 接口

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

Unity WebGL 中 JavaScript层调用 C++ 接口

C++ 接口编写方式 1

extern "C"
{
    EMSCRIPTEN_KEEPALIVE
    void func1(const char* a, const char* b, const char* c) {
        // captureException(errorType, errorMessage, stackTrace);
        LOGI("func1 called!!!!!!!!!!!!!!!!!!!!!!!!!");
    }
}

Unity3D->WebGL C++生成JS Stub

  • EMSCRIPTEN_KEEPALIVE 只能保证符号在 .a 文件里保留,不能保证该静态库在 Unity3D 面向 WebGL 平台最终链接时会被拉入并写入导出表。

生成的启动脚本里需要出现 var _func1 = Module["_func1"] = createExportWrapper("func1");

  • 只有当 对应 *.cpp 里的接口被 C# 代码显式引用 时,该对象文件才会在 Unity 链接阶段被拉入,并写入导出表,生成同样的 var _func1 = Module["_func1"] = createExportWrapper("func1");

  • 因此,C# 侧需显式声明:

[DllImport("__Internal")]
internal static extern void func1(string errorType,
                                  string errorMessage,
                                  string stackTrace);

TypeScript / JavaScript 调用方式

在 TypeScript 脚本中先声明接口:

interface EmscriptenModule {
  ccall: (funcName: string,
          returnType: string,
          argTypes: string[],
          args: any[]) => any;
  cwrap: (funcName: string,
          returnType: string,
          argTypes: string[]) => (...args: any[]) => any;
  _malloc: (size: number) => number;
  _free:  (ptr: number) => void;
  _func1:        (errorType: string,
                  errorMessage: string,
                  stackTrace: string) => void;
}

declare var Module: EmscriptenModule;

调用方案与测试结果

  • Module._functionName: Direct pointers to exported C functions (with underscore)
Module._func1(a, b, c);

调用成功,但字符串参数传递失败

Module._func1(
  allocateUTF8OnStack(a),
  allocateUTF8OnStack(b),
  allocateUTF8OnStack(c));

(该方法未测试)

  • Module.ccall: Helper to call C functions with automatic type conversion
Module.ccall('func1', 'void', ['string','string','string'], [a, b, c]);

调用成功, 字符串参数传递成功.

以上方案直接在 TypeScript 内调用;脚本经 rollup 打包后,通过 mergeInto(LibraryManager.library, { InitCrashReport: function() { … } }) 注册到 globalThis

另一种调用方案

mergeInto(LibraryManager.library, { InitCrashReport: function() { … } }) 中注册 globalThis.CrashReport.native.func1,再内部调用:

调用方式 结果
_func1 调用成功,参数传递失败
Module._func1(...) 同上,字符串仍为 null
Module.ccall('func1', … ) 调用成功,且参数传递成功

C++ 暴露接口的方案 2(Embind)

EMSCRIPTEN_BINDINGS(crash_reporter) {
    emscripten::function("func3",
                         &func3,
                         emscripten::allow_raw_pointers());
}

对应 JavaScript 调用:

Module.func3(param1, param2, param3);

Unity3D 编译阶段未加入 --bind-lembind,将报 missing function: _embind_register_function 因此此方案尚未测试。


C++ 导出接口的方案 3(EXPORTED_FUNCTIONS)

不使用 EMSCRIPTEN_KEEPALIVE,而在编译配置中通过 -s EXPORTED_FUNCTIONS=['_func1', ...] 显式导出(未测试)。

Module的概念

从编译到运行的视角快速梳理

  1. 编译 (em++)

    • 生成二进制 Wasm (*.wasm) + JS glue (*.framework.js)
    • Module 对象的定义就在 glue 里——它负责加载 Wasm、提供 runtime API(ccall_malloc …)。
  2. 链接 & 导出表

    • 所有写在 EXPORTED_FUNCTIONS 列表里的符号,或打了 EMSCRIPTEN_KEEPALIVE 的符号,在 Wasm 里生成 (export "_foo" ...)
    • glue 文件为每个导出插入 var _foo = Module["_foo"] = createExportWrapper("foo");
  3. 运行时

    • HTML 模板先加载 UnityLoader.js → 再加载 *.framework.js.
    • glue 创建 Module(或 Module() Promise),拉取 *.wasm, 完成实例化。
    • Module.ccall() 内部:查表找 Module["_foo"] → 把 JS 参数转成底层指针 → 调用 wasmInstance.exports["_foo"]. 下面按 编译—链接—运行时导出 的顺序,把上面三条要点扩展成更“工程师/工具链”视角的专业说明,帮助你彻底理解 EMSCRIPTEN_KEEPALIVE、Unity WebGL 链接流程与 C# 显式引用 之间的关系。

从C++到wasm的过程

EMSCRIPTEN_KEEPALIVE 的本质:仅阻止 编译期链接后优化 删除符号

阶段 Emscripten / LLVM 所做的事 EMSCRIPTEN_KEEPALIVE 能力
Clang ➜ LLVM IR(源→.o) 把 C++ 转成 LLVM IR;前端优化 (inline, DCE)。 把目标函数/变量打上 __attribute__((used))__attribute__((visibility("default"))),强制保留 IR 定义并设为“可导出”
lld (wasm-ld) 全局链接(.o + .a.wasm) 1️⃣ 解析静态库 (.a) 按需抽取 object file;2️⃣ 合并符号表;3️⃣ LTO / GC dead code。 对已被抽取进来的 object file 生效:符号不被 LTO / GC 删除。但如果 object 根本没被抽取,KEEPALIVE 也救不了。

🔑 关键点archive (.a) 的抽取规则与 ELF/COFF 完全一致——只有当链接器在解析“未定义符号”时看到需要的符号,才会把对应 .o 拉进来KEEPALIVE 并不会自动把整个 .o 标记成“必须抽取”。


Unity WebGL 的静态库归档与最终链接

crash.a (包含 func1.o)
             │
            (归档后放入 Assets/Plugins/WebGL/)
             │
  ┌──────────▼───────────┐
  │      il2cpp.exe      │  ↩️ 生成 .cpp stub,记录所有 [DllImport] 符号
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │   em++(编译 stub)   │  ↩️ 产生 undefined symbol: func1
  └──────────┬───────────┘
             │
  ┌──────────▼───────────┐
  │   wasm-ld / lld      │  ↩️ 解析 undefined symbol → 抽取 func1.o
  └──────────┬───────────┘
             │
      【Link & LTO】
             │
           crash.wasm
           crash.js
  • C# [DllImport("__Internal")] ⇒ IL2CPP 生成一个 裸外部符号 func1,在 stub C++ 中以 extern "C" 声明; 这让 wasm-ld 在解析时发现 “func1 未定义”,于是从 crash.a 抽取 func1.o。 (没有这个未定义引用就不会抽取,导致 func1 被完全略过。)

  • Unity 的 emscripten 参数默认不开 --whole-archive,因此 .a 里的 object 必须“用到”才能被拉入。

  • 一旦 func1.o 被抽取,EMSCRIPTEN_KEEPALIVE 才能阻止后续 LTO/DCE 把 func1 优化掉。


3 导出表与 createExportWrapper

生成的启动脚本需出现

var _func1 = Module["_func1"] = createExportWrapper("func1");
  • 生成时机emcc 在最终 JS shell 阶段把 EXPORTED_FUNCTIONS 数组写进 wasmExports,再用 createExportWrapper 生成 JS stub。
  • 组成来源
    1. 自动扫描 EMSCRIPTEN_KEEPALIVE 符号;
    2. 手动 -s EXPORTED_FUNCTIONS=['_func1']
    3. 默认运行时方法列表(_malloc 等)。
  • func1 没有进入最终 .wasm,则不会出现在 EXPORTED_FUNCTIONS,对应 JS stub 也不会生成 ⇒ 浏览器侧报 “missing function”。