Unity WebGL 中 CSharp ↔ JavaScript ↔ Native 互操作编写方法
本文目录 24 个章节
Unity WebGL 中 CSharp ↔ JavaScript ↔ Native 互操作编写方法
| 概念 | 说明 | 常用命令/设置 |
|---|---|---|
| 定位 | LLVM-based 交叉编译器,把 C/C++ → WebAssembly (+JS runtime)。 | emcc / em++ |
| 核心产物 | xxx.wasm (代码) + xxx.js (运行时+glue) |
-o out.js(会同时写 wasm) |
| 运行时模式 | 1) Mono JS 文件(默认);2) MODULARIZE → 实际输出一个 JS Module;3) WASM=2 (-sSTANDALONE_WASM) 纯 wasm + minimalist JS 引导 |
-sMODULARIZE=1 -sEXPORT_ES6=1 |
| 交互 API | - cwrap/ccall:JS 调 C;- EXPORTED_FUNCTIONS / EMSCRIPTEN_KEEPALIVE:把符号放进导出表;- Embind:C++/JS 面向对象互调 |
docs Interacting-with-code (emscripten.org) |
| 内存模型 | 线性内存 = Static + Stack + Heap;ALLOW_MEMORY_GROWTH=1 让 Heap 动态扩;TypedArray 直接映射 |
HEAP8/HEAP32… |
| 文件系统 | 内置 MEMFS, IDBFS, WORKERFS;可 FS.mount |
--preload-file, --embed-file |
| 多线程 | PThreads → Web Workers;需 -sUSE_PTHREADS=1 并在 HTML/小游戏里勾 “多线程” |
|
| 压缩 | Emscripten 不压缩;Unity WebGL 会生成 .wasm.br / .wasm.gz;微信小游戏 不支持 Unity 的自解压,需禁用或自行处理 |
Player Settings → Compression: Disabled |
| 调试 | -g4 生成 source-map;-sSAFE_HEAP=1 越界检测;EM_LOG=1 打印 C printf 到 console |
编写 .jslib
// Assets/Plugins/WebGL/simple_bridge.jslib
mergeInto(LibraryManager.library, {
// 打个招呼:把字符串打印到浏览器控制台
JS_Hello: function (ptr, len) {
// 把 C# 侧传来的 UTF-8 字符串读出来
var msg = UTF8ToString(ptr, len);
console.log("[JS] Hello from C#: " + msg);
},
// 返回浏览器窗口宽度
JS_GetScreenWidth: function () {
return window.innerWidth | 0; // 32-bit int
}
});
C#声明调用
using System;
using System.Runtime.InteropServices;
using System.Text;
using UnityEngine;
public class JsDemo : MonoBehaviour
{
#if UNITY_WEBGL && !UNITY_EDITOR
// 把字符串地址和长度传给 JS
[DllImport("__Internal")]
private static extern void JS_Hello(IntPtr strPtr, int length);
[DllImport("__Internal")]
private static extern int JS_GetScreenWidth();
#endif
void Start()
{
// -------- 调 JS 打招呼 --------
string msg = "你好,JavaScript!";
// 转成 UTF-8 bytes,固定到 GCHeap,拿指针
byte[] utf8 = Encoding.UTF8.GetBytes(msg + '\0');
GCHandle handle = GCHandle.Alloc(utf8, GCHandleType.Pinned);
try
{
IntPtr ptr = handle.AddrOfPinnedObject();
JS_Hello(ptr, utf8.Length - 1); // 不含 '\0'
}
finally
{
handle.Free();
}
// -------- 调 JS 取窗口宽 --------
int w = JS_GetScreenWidth();
Debug.Log("Window width from JS = " + w);
}
}
[DllImport("__Internal")] 告诉 IL2CPP 去当前 Wasm 模块里找符号 JS_Hello。
JS → C#
如果你想让浏览器侧错误反向通知 Unity,只需:
// 在 unity-bridge.ts
import { detectSDK } from './adapters/platform';
const game = (window as any).gameInstance; // UnityLoader 生成的实例
export function forwardToUnity(eventId: string): void {
if (game) game.SendMessage('CrashSightBootstrap', 'OnJsError', eventId);
}
// 每次 sendEvent 成功后调用 forwardToUnity()
CSharp直接调用Native
- 只用到 int/float 指标量,没有类/模板,不用
--bind,包体最小。
编写Cpp代码
// Plugins/WebGL/native_lib.cpp
#include <emscripten/emscripten.h>
#include <cmath>
// 用 extern "C" 消除 C++ 名字改编,让导出符号保持 "Multiply"
extern "C" {
// 一定要 EMSCRIPTEN_KEEPALIVE,或稍后用 -s EXPORTED_FUNCTIONS 把名字写进去
EMSCRIPTEN_KEEPALIVE
int Multiply(int a, int b)
{
return a * b;
}
EMSCRIPTEN_KEEPALIVE
float Distance(float x1, float y1, float x2, float y2)
{
float dx = x1 - x2, dy = y1 - y2;
return std::sqrt(dx * dx + dy * dy);
}
} // extern "C"
通过 EMSCRIPTEN_KEEPALIVE 标记对函数进行导出。
若不想写 KEEPALIVE,可在编译时 -sEXPORTED_FUNCTIONS="['_Multiply','_Distance']"。
使用Emscripten编译成WebAssembly代码
rem ① 用 Emscripten 3.x 编译 .o
emcc -c native_lib.cpp -O3 --no-entry -sWASM=1 -sALLOW_MEMORY_GROWTH=1 -o native_lib.o
rem ② 打包成静态库
emar rcs native_lib.a native_lib.o
--no-entry:声明“这是库文件,没有main()”。-sALLOW_MEMORY_GROWTH=1:可按需增内存,免手算-sINITIAL_MEMORY。
把生成的 native_lib.a 放进 Assets/Plugins/WebGL/
Unity 看到 .a + WebGL 勾选 就会在最终链接阶段把它拉进主 Wasm。
CSharp声明调用
// Scripts/Native.cs
using System.Runtime.InteropServices;
using UnityEngine;
public static class Native
{
#if UNITY_WEBGL && !UNITY_EDITOR // 避免 Editor/GameView 报找不到符号
[DllImport("__Internal")] private static extern int Multiply(int a, int b);
[DllImport("__Internal")] private static extern float Distance(float x1, float y1,
float x2, float y2);
#else
private static int Multiply(int a, int b) => 0;
private static float Distance(float x1,float y1,
float x2,float y2) => 0f;
#endif
public static void Demo()
{
Debug.Log("5 × 7 = " + Multiply(5, 7));
Debug.Log("dist = " + Distance(0, 0, 3, 4)); // -> 5
}
}
__Internal= “从当前 Wasm 模块导出表找符号”。
运行时流程
- UnityLoader.js 下载+实例化 Wasm → 导出表包含
_Multiply/_Distance。 - IL2CPP 的
DllImport调用 → JS glue (cwrap) 自动生成一层 JS stub。 - stub 把 C# 参数序列化到线性内存,再调用导出函数,返回值复制回托管侧。
目标函数:
Multiply(int,int),已出现在 Wasm 导出表里 (_Multiply)
- 加载阶段
UnityLoader.js
└─ fetch("build.wasm") (XHR/”小游戏”本地FS)
└─ WebAssembly.instantiateStreaming()
→ Instance.exports = { …, _Multiply, … }
- IL2CPP 生成的 JS stub
Unity 构建流程在
wasm.framework.js里插入一段:
// 自动执行的 init code(节选)
var _Multiply = Module.cwrap(
'Multiply', // C 符号(无下划线)
'number', // 返回值类型
['number','number']); // 参数类型数组
cwrap 来自 Emscripten 运行时,会:
- 查
instance.exports['_Multiply'] - 生成 JS 函数:
function js_Multiply(a,b){
// ① 把 JS number 写进栈
// ② 调 Wasm _Multiply(a,b)
// ③ 把返回值 rewrap 成 JS number
}
- 把它挂到
_Multiply变量,供 IL2CPP 调用。
托管端 (C#) 调用 IL2CPP 在
Native.Demo()里生成如下 JS 调用:var ret = _Multiply(a,b); // a,b 已转成 JS number // ret 仍是 JS number,IL2CPP 再转成 C# int 提交给脚本层数据在内存里的流向
C# int ─┐ (P/Invoke marshaller)
▼
JS number ─┐ (cwrap 填栈,HEAP32)
▼
Wasm int32 ←┘ (真正执行 a*b)
▲
└───────── 返回值路径同理
CSharp通过 js glue 调用native
编写Cpp代码
#include <emscripten/bind.h>
int Multiply(int a, int b) { return a * b; }
int CrashDivideByZero() { return 1 / 0; } // trap
int CrashOutOfBounds() {
volatile int* p = reinterpret_cast<int*>(0x80000000); // >2 GB
return *p; // trap
}
EMSCRIPTEN_BINDINGS(my_mod) {
emscripten::function("Multiply", &Multiply);
emscripten::function("CrashDivideByZero",&CrashDivideByZero);
emscripten::function("CrashOutOfBounds", &CrashOutOfBounds);
}
使用Emscripten编译成WebAssembly代码
rem ① 用 Emscripten 3.x 编译 .o
emcc -c native_lib_js.cpp -O3 --bind --no-entry -sWASM=1 -sALLOW_MEMORY_GROWTH=1 -o native_lib_js.o
rem ② 打包成静态库
[emar rcs native_lib.a native_lib.o](<emar rcs native_lib_js.a native_lib_js.o>)
把生成的 native_lib.a 放进 Assets/Plugins/WebGL/
什么时候用 Embind / --bind ?
- 暴露 C++ 类、模板容器、
std::string/std::vector给 JS - 需要 JS↔C++ 双向回调
配置 --bind Emscripten 会:
- 复制一段 Embind runtime (~20–30 KB JS);
- 生成
Module['Multiply'] = function(){ … }包装函数。
编写jslib(Glue → Native)
// Assets/Plugins/WebGL/native_bridge.jslib
mergeInto(LibraryManager.library, {
NativeLib_Init: function () { // ← 用 function,不用箭头
return 0;
},
JsMultiply: function (a, b) {
// 方式①:直接调用链接符号(推荐)
return _Multiply(a, b); // 前导下划线在链接时决议
// 方式②:如果你喜欢 Module:
// return Module['_Multiply'](a, b);
},
CrashDivideByZero: function () {
return _CrashDivideByZero();
},
CrashOutOfBounds: function () {
return _CrashOutOfBounds();
}
});
_Multiply前导下划线:链接后 C 符号惯例;也可写Module['_Multiply']。.jslib与主 Wasm 在 同一作用域,可直接调用裸符号_Multiply。
CSharp声明调用
[DllImport("__Internal")] static extern int JsMultiply(int a, int b);
[DllImport("__Internal")] static extern int CrashDivideByZero();
[DllImport("__Internal")] static extern int CrashOutOfBounds();
这里导出的其实是 .jslib 里那段 JS 函数,JS 再调用 Embind 包装 → 真正 Wasm 函数。
Embind的作用 - “类/回调”
- 暴露 C++ 类、模板容器、
std::string/std::vector给 JS - 要 JS↔C++ 双向回调
C++ (calc.cpp)
#include <emscripten/bind.h>
#include <functional>
static std::function<void(int)> g_cb; // 存一份全局回调
void registerCallback(emscripten::val jsFunc)
{
// 把 JS 函数包装成 std::function
g_cb = [jsFunc](int result) {
jsFunc(result); // 调用 JS
};
}
void heavyCalc(int x, int y)
{
int r = x * y; // 假装这是很重的计算
if (g_cb) g_cb(r); // 计算完回调 JS
}
EMSCRIPTEN_BINDINGS(my)
{
emscripten::function("registerCallback", ®isterCallback);
emscripten::function("heavyCalc", &heavyCalc);
}
JS 调用 (index.js / .jslib)
Module.onRuntimeInitialized = () => {
Module.registerCallback(function (r) { // JS → C++
console.log('C++ result =', r); // → JS
});
Module.heavyCalc(6, 7); // => 控制台打印 42
};
Native -> JS
| 方法 | 写法 | 何时用 | 额外编译参数 |
|---|---|---|---|
① EM_JS |
把 JS 当作 C 函数实现 内联写在 .cpp | 简单逻辑、少量函数 | 无 |
② EM_ASM_* |
在现有 C 函数里“插一句 JS” | 只想偶尔执行 JS 语句 | 无 |
③ --js-library |
把 JS 函数放到独立 library_xxx.js,在 C/C++ 里 extern 调用 |
函数多、想保持 C/JS 分离 | -sEXPORTED_FUNCTIONS + --js-library library_xxx.js |
EM_JS:最直接的“一键同步调用”
#include <emscripten/emscripten.h>
#include <cstdlib>
extern "C" {
// JS 实现,C 侧看起来就是普通函数
EM_JS(void, LogInt, (int v), {
console.log('Native says: ' + v);
});
// 带返回值
EM_JS(int, Rand100, (), {
return Math.floor(Math.random() * 100);
});
EMSCRIPTEN_KEEPALIVE
int MultiplyAndLog(int a, int b)
{
int r = a * b;
LogInt(r); // 直接同步 console.log
return r + Rand100(); // 同步拿 JS 返回值
}
}
- 任何 参数 / 返回值 只能是
int | double | float | pointer; 要传字符串,用指针+UTF8ToString()转码(见下表)。
| C++ ↔ JS | 转换示例 |
|---|---|
const char* s → JS |
UTF8ToString(s) |
JS 字符串 → int ptr |
stringToUTF8(str, ptr, len) + 把指针回传 |
EM_ASM:在 C 里“插一行 JS”
#include <emscripten/emscripten.h>
extern "C" {
EMSCRIPTEN_KEEPALIVE
int Clamp(int x)
{
// 把 x 发到 JS,返回 min(max(x,0),100)
return EM_ASM_INT({
const v = $0; // $0 对应第 1 个参数
return Math.min(Math.max(v,0), 100);
}, x);
}
}
EM_ASM_VOID / INT / DOUBLE系列根据返回类型不同。- 更像“内联汇编”,适合一两行 JS。
JS-Library 文件:模块化 + Tree-Shaking 友好
写一个 library_native.js
// 必须用 `mergeInto(LibraryManager.library, {...})`
mergeInto(LibraryManager.library, {
// 函数名随意,但最终导入到 wasm 的名字要与 C 声明一致
js_sin: function (d) { // d: double
return Math.sin(d);
},
js_log_int: function (v) {
console.log('log_int from library: ' + v);
}
});
C/C++ 声明并调用
extern "C" {
extern double js_sin(double d); // 没有实现体,链接时会去 JS 找
EMSCRIPTEN_KEEPALIVE
double SinTimes2(double v) {
double s = js_sin(v);
return 2.0 * s;
}
}
编译
Unity3D Engine 构建WebGL应用时, 会自动处理该过程.
emcc -c native_lib.cpp -O3
emcc native_lib.o \
--js-library library_native.js \
-sEXPORTED_FUNCTIONS="['_SinTimes2']" \
-sSIDE_MODULE=0 -sWASM=1 -O3 \
-o native_lib.a # 最终静态库给 Unity
--js-library里的每个函数都会成为 Wasm 的导入(import), 链接到最终build.framework.js / build.loader.js的作用域中。
把“Native → JS” 再传回 C#?
已经同步返回就可以直接带回 C#:
[DllImport("__Internal")] private static extern int MultiplyAndLog(int a, int b);
// C# 调用会在一帧内完成;MultiplyAndLog 内部同步进出 JS。
若 JS 异步(例如 fetch()),就必须 回调或 Asyncify——
纯同步桥只有以上三种做法,无法跨线程等待浏览器异步操作。
常见注意点
| ⚠️ 场景 | 处理办法 |
|---|---|
| 字符串/数组 | 在 C/C++ 开 malloc,JS 用 UTF8ToString(ptr) 读;写反向则 stringToUTF8。 |
| 双端同名冲突 | JS-Library 函数内部务必用普通 function,不要使用箭头函数保存 this。 |
| 优化体积 | 用 -O3 -sELIMINATE_DUPLICATE_FUNCTIONS=1 -sENVIRONMENT=web,并删除未引用的 JS-Library 函数(tree-shaking)。 |
| 多文件链接 | 所有 .a 给 Unity,一次性由 emscripten‐clang 链到主 wasm。 |
调用JS异步函数并回调
C#(Unity) ──► Wasm(C++) 同步函数 StartWxRequest
│
▼
EM_JS 里调 wx.request (异步)
│
▼
wx.request success / fail 回调
│
▼
JS 用 unityInstance.SendMessage(...)
把结果推回指定的 C# MonoBehaviour
C/C++(native_lib.cpp)
// Assets/Plugins/WebGL/native_lib.cpp
#include <emscripten/emscripten.h>
#include <cstdlib>
extern "C" {
// ★ ① 供 JS 回调的函数(必须导出)
EMSCRIPTEN_KEEPALIVE // 或写进 -sEXPORTED_FUNCTIONS
void OnWxResponse(int ok, const char* jsonPtr)
{
const char* json = jsonPtr ? jsonPtr : "{}";
printf("[Native] wx.request %s : %s\n", ok ? "SUCCESS" : "FAIL", json);
free((void*)jsonPtr); // ccall/allocateUTF8 用 malloc,记得 free
}
// ★ ② C# 同步调用的入口:只转去 JS 做异步
EMSCRIPTEN_KEEPALIVE
void StartWxRequest(const char* urlPtr)
{
EM_ASM({ // urlPtr = $0
const url = UTF8ToString($0);
// 容错:非微信环境直接回调失败
if (typeof wx === 'undefined' || !wx.request) {
Module.ccall('OnWxResponse', 'void',
['number','string'], [0, allocateUTF8('no wx')]);
return;
}
wx.request({
url,
success: res => {
const json = JSON.stringify(res.data ?? {});
Module.ccall('OnWxResponse', 'void',
['number','string'],
[1, allocateUTF8(json)]);
},
fail: err => {
Module.ccall('OnWxResponse', 'void',
['number','string'],
[0, allocateUTF8(err.errMsg)]);
}
});
}, urlPtr);
}
} // extern "C"
OnWxResponse(int ok, const char* json)就是同步地被 JS 调用。allocateUTF8用_malloc,因此 C 里可free。- 编译时把
OnWxResponse/StartWxRequest写进-sEXPORTED_FUNCTIONS(或用EMSCRIPTEN_KEEPALIVE)。
emcc -O3 -c native_lib.cpp --no-entry -sWASM=1
emar rcs native_lib.a native_lib.o # 给 Unity 使用