Unity WebGL与前端双向通信:SendMessage与jslib桥接实战
2026/9/13 15:23:02 网站建设 项目流程

简介:针对Unity WebGL与Web前端双向通信需求,这份工具脚本及测试Demo面向Unity开发者和Web前端工程师,定位解决Unity WebGL构建包与网页JavaScript互相调用、消息传递的常见痛点,同时可作为初学者上手WebGL互操作实验的参考。压缩包共21个文件,以unitypackage插件包为核心,辅以js、html、css等前端示例文件,以及unityweb构建产物、png效果截图和rtf说明文档,整体约3.89MB,目录层次清晰,便于快速定位与部署验证。其中png演示运行界面,js与html/css展示前端侧的监听与发送逻辑,unitypackage则为Unity工程提供现成通信脚本,省去重复封装底层接口的麻烦。目前已有5168人学习下载,适合希望在自己的Unity项目与浏览器页面间建立实时消息通道的中高级开发者参考;通过内置测试Demo,可快速理解Unity与浏览器的外部接口调用机制,并直接迁移到实际项目中。

1. 两个运行时之间,一条双向消息链路

PC 端玩过 Unity 串口通信的人,第一次接触 WebGL 构建产物时会发现老套路全部失效:串口库编不过、Socket 连不上、以前随手用的 Application.ExternalCall 也调不通。Unity WebGL 被编译成 WebAssembly 后,它和页面是两个完全隔离的运行时,各自维护独立内存和对象生命周期。网页里点按钮想驱动 Unity 场景,Unity 里发生的事件想实时显示到页面上,中间必须有一条跨运行时的双向消息链路。

这个 unitypackage 里带的正是把链路跑通的最小可运行 Demo:Unity 场景、C# 通信脚本、jslib 桥接文件、完整 index.html 和 Build 目录一次性配齐。核心要演示的就一句话:前端通过 SendMessage 把命令送进 Unity,Unity 通过 jslib 导出函数把事件送回页面,消息往返不经过服务器。适合做数字孪生大屏、B 端系统嵌 Unity 场景、H5 操作面板驱动 3D 模型的开发同学。下文按我拆这套包的实际顺序,把加载时序、参数边界、字符编码和排错手段逐个过一遍。

2. Unity 侧通信脚本:SendMessage 与 jslib 导出的分工

2.1 双向通信在 WebGL 下的真实能力边界

跨运行时通信这件事,本质上和进程通信(IPC)面对的问题一样:两边不能共享内存指针,只能把消息序列化成字节流,再约定好还原格式。Unity WebGL 给开发者只保留了两种可靠通道。

第一种是从 JavaScript 调用 C#,入口是 Unity 实例暴露的 SendMessage(gameObjectName, methodName, arg)。这个 API 很稳定,但参数类型极其有限,只接受一个 string、number 或 boolean,不支持对象、数组、函数回调和多参数。你传一个对象过去,Unity 侧拿到的是一个被改写成字符串的值,直接反序列化必然失败。所以前端到 Unity 的消息,工程上几乎都走「命令 + JSON 字符串」的约定。

第二种是从 C# 调用 JavaScript,旧教程里的 Application.ExternalCall 在 WebGL 平台已经被官方标记为不建议使用,网页端绕开它是对的。正规做法是在 Assets/Plugins/WebGL 下放一个 .jslib 插件,C# 侧用 [DllImport("__Internal")] 声明外部函数,Unity 编译器会把这次调用编译成 wasm 模块对 JS 函数的直接导入。jslib 里的函数可以随意访问 window、document,以及你预先挂载在全局对象上的业务方法。

调用方向入口 API参数限制适用场景
JS → C#instance.SendMessage单参数,string/number/bool按钮点击、路由指令、外部事件驱动
C# → JSjslib 导出函数无强限制,指针与字符串均可Unity 内事件上报、进度回调、错误推送

实际使用时,这两个方向的通道能力不对称。SendMessage 简单但结构弱,适合传「方法名 + 一个字符串参数」;jslib 函数有字符串指针和内存操作能力,但调用它的是 C# 代码,前端并不能主动感知。因此一套合格的双向通信方案,必然是两条腿走路:策略层的业务命令走 SendMessage,事件与上报走 jslib,两条链路在 C# 侧汇合到同一个分发入口。

2.2 在 Unity 工程里搭出最小 C# 通信脚本

打开 unitypackage 后,Demo 场景里已经挂好了通信脚本。如果你要自己重搭一套,我一般按下面的最小结构写,它同时覆盖两条通道:

using System.Runtime.InteropServices; using UnityEngine; public class WebGLComm : MonoBehaviour { // 对应 jslib 里导出的 JsCallReply 函数 [DllImport("__Internal")] private static extern void JsCallReply(string json); // C# -> JS,静态方法便于任何脚本直接调用 public static void NotifyWeb(string json) { #if UNITY_WEBGL && !UNITY_EDITOR JsCallReply(json); #endif } // JS -> C#,前端 SendMessage 调用的是这个实例方法 public void ReceiveFromWeb(string json) { Debug.Log("[Unity] ReceiveFromWeb: " + json); var msg = JsonUtility.FromJson<UnityMessage>(json); // 这里按 msg.cmd 分发到场景内具体的业务行为 } }

这段代码有三个关键点。第一,NotifyWeb 被写成静态方法,业务脚本里直接 WebGLComm.NotifyWeb(json) 就能把消息送出去,不需要到处找对象引用。第二,ReceiveFromWeb 是实例方法,因为 SendMessage 的反射查找只认场景中挂载物体上的非静态方法,你把场景物体命名为 MainUI,前端才能稳定命中。第三,中间层的 #if 预处理保证了在 Unity 编辑器里运行不会调用不存在的 __Internal 符号,编辑器里走了空分支,远程调试时前端那边不会收到任何数据,这个行为要提前跟同事对齐。

2.3 jslib 文件里的消息透传与防御

C# 侧声明了外部函数,紧接着要在 Assets/Plugins/WebGL 下建一个 mylib.jslib 文件,函数名必须和 DllImport 声明完全一致:

mergeInto(LibraryManager.library, { JsCallReply: function (jsonPtr) { var json = UTF8ToString(jsonPtr); if (window.unityMessageHandler) { window.unityMessageHandler(json); } else { window.__pendingUnityMessages = window.__pendingUnityMessages || []; window.__pendingUnityMessages.push(json); } } });

mergeInto 是这个插件体系的固定写法,Unity 构建时会把这个对象合并进 wasm 模块的外部函数表。UTF8ToString 负责把 C# 传入的内存指针还原成 JS 字符串,中文和特殊字符都由它处理,前端不需要再做 decodeURIComponent。window.unityMessageHandler 是我在 index.html 里预埋的全局钩子,它存在时消息直接转发给业务代码,不存在时消息先暂存在 pending 队列,等前端框架初始化完成后再统一消费。这个防御逻辑解决了链路中最常见的“Unity 先加载完、页面后挂载 handler”错位问题。

要注意 jslib 文件里尽量不要写业务逻辑,它只负责边界转发。业务逻辑写在页面端或者 C# 端,否则构建内容一多,jslib 里的代码会变成谁也维护不了的万金油层。

3. index.html 加载产物与 Unity 实例绑定

3.1 Build、TemplateData、index.html 三者的协作关系

拿到包的第一件事,我习惯用 VSCode 打开 index.html,观察网页界面代码的构成。会看到 Unity WebGL 构建产物解压后至少有三部分:Build 目录存放 wasm 二进制、loader.js 和 framework.js,这是运行时本体;TemplateData 存放加载进度条、Logo 和页面样式;index.html 是入口,负责拉取 loader.js 并创建 Unity 实例。三者是相对路径关系,整个目录直接丢到 Nginx 或任意静态服务器就能跑,不需要额外配置。

对应页面的加载逻辑如下:

<div id="unity-container"> <canvas id="unity-canvas" width="960" height="600"></canvas> </div> <script src="Build/loader.js"></script> <script> var unityInstance = null; createUnityInstance( document.querySelector("#unity-canvas"), { arguments: [], dataUrl: "Build/Demo.data.unityweb", frameworkUrl: "Build/Demo.framework.js.unityweb", codeUrl: "Build/Demo.wasm.unityweb", companyName: "DefaultCompany", productName: "UnityWebTest", productVersion: "1.0" }, function(instance) { unityInstance = instance; } ); </script>

createUnityInstance 的第一个参数是 canvas 节点,第二个参数里 dataUrl、frameworkUrl、codeUrl 必须与 Build 目录下的实际文件名逐一对应,有一点偏差整个加载都会卡在进度条。第三个参数是实例创建完成的回调,只有在这个回调里拿到的 function(instance) 才是前端与 C# 通信的正式入口。Unity 新版本默认生成的 index.html 就是这个结构,旧项目里如果看到 UnityLoader.instantiate 的写法,可以直接替换成上面的形式,效果等价。

3.2 绑定 Unity 实例并封装 SendMessage

前面例子里全局变量 unityInstance 容易引起误会,这里说清楚:createUnityInstance 回调接收的 instance 是 Unity 运行时实例,它才是调用 SendMessage 的唯一合法对象。加载器本身 UnityLoader 只是工厂,不持有任何实例方法。所以进入业务页面后,第一步就是把 instance 缓存起来,然后做一层自己的发送封装。

var app = {}; app.unityInstance = null; app.sendToUnity = function(gameObjectName, methodName, payload) { if (!app.unityInstance) { console.warn("unity instance not ready"); return false; } var message = typeof payload === "object" ? JSON.stringify(payload) : String(payload); app.unityInstance.SendMessage(gameObjectName, methodName, message); return true; };

封装层做了三件事:锁定实例引用、把对象序列化成字符串、未就绪时给出显式警告。这层封装非常值得保留,前端业务组件只需要调用 app.sendToUnity("MainUI", "RotateCamera", { angle: 90 }),无需关心 Unity 内部方法签名。等到后期需要做消息队列、消息去重、带版本号路由时,改这一层就够了。如果你做过 Vue 或 React 的组件通信,这里可以类比成父组件统一在 props 出口做数据收敛,子组件不直接改全局状态。

前端按钮的实际调用:

document.querySelector("#btn-rotate").addEventListener("click", function() { app.sendToUnity("MainUI", "RotateCamera", { angle: 90, duration: 2 }); });

这个 click 事件不直接操作 Unity 内任何属性,只发出指令。Unity 侧的 RotateCamera 方法收到字符串参数后,再反序列化并控制相机旋转。这样的好处是页面和 Unity 之间的耦合被限制在一个很薄的协议层里,将来换 3D 场景或者换前端框架,协议不变,两边都不用重写。

3.3 页面隔离场景下的消息路由选择

到了正式项目里,Unity 页面多数是嵌入在 iframe 里的,样式、全局变量、三方库都不需要和主应用挤在一起。这时的通信会多一层 document 边界:iframe 内的 index.html 通过 postMessage 接收父页面指令,再转交给 app.sendToUnity。跨窗口通信必须校验来源,否则任何站点都能通过 postMessage 控制你的场景。

// iframe 内的 index.html window.addEventListener("message", function(e) { if (e.origin !== "https://your-host") return; app.sendToUnity(e.data.go, e.data.method, e.data.payload); });

这里 e.origin 校验不能省略,只要做 iframe 隔离就一定会有别的业务页面挂在同一个父窗口下,事件来源不把关,场景就可能被其他页面的残留事件误触发。如果你在小程序 web-view 或者 uni-app 的 web-view 里内嵌这套 H5,消息通道还需要再绕一层 JS-SDK 桥,链路变长后最好在前端维护一个 pendingMap,消息发出后记录回调,Unity 回传结果时按消息 ID 匹配,这样才不会出现乱序。

4. 消息协议设计:JSON 编解码与中文处理

4.1 为什么最终选择“JSON 字符串”作为统一协议

SendMessage 的参数限制决定了不能直接在边界上传对象。曾经有人尝试把前端对象逐字段拆开,循环调用 SendMessage,字段一旦变多,时序完全不可控;也有人想绕过参数限制直接传数组,Unity 侧收到后连类型都对不上。WebAssembly 边界上唯一稳定可靠的数据形态是字符串,而 JSON 是字符串里表达结构信息最成熟的选择。前端把业务数据封装成 JSON 字符串,C# 侧用 JsonUtility 反序列化,整个链路可读、可记录、可在浏览器里直接断点查看。

这个选型还有一个额外收益:错误排查成本低。消息内容可以直接打印到控制台复制出来,放到任何 JSON 工具里验证格式。如果当初选择二进制协议或者自定义分隔符,光是排查一个字段溢出就要耗费大量时间。对于 B 端数字孪生项目来说,消息频率通常不会超过每秒几十条,JSON 的解析开销完全可接受,没必要为了性能引入 MessagePack。

4.2 C# 端用 JsonUtility 解析前端消息

JsonUtility 是 Unity 内置序列化库,不需要引入额外 DLL,WebGL 构建时包体增量也最小。它的使用约束很明确:目标类必须带 [System.Serializable] 特性,字段必须与 JSON 键一一对应,顶层必须是对象而不能是数组。

[System.Serializable] public class UnityMessage { public string cmd; public int direction; public string payload; } void RotateCamera(string json) { var msg = JsonUtility.FromJson<UnityMessage>(json); // msg 对应前端传来的 { "cmd": "rotate", "direction": 1, "payload": "90" } transform.Rotate(0, msg.direction * 90f, 0); }

前端传来的 JSON 键名是 cmd、direction、payload,C# 侧字段名必须严格保持一致。如果前端用下划线风格比如 direction_value,C# 侧也要定义成相同的下划线字段。不要指望 JsonUtility 会做蛇形到驼峰的自动映射,它没有这个能力。字段类型也要提前约定:前端传来数字 1,对应 int;传来 "90",对应 string;传来 true/false,对应 bool。我见过不少联调失败,就是因为前端把数字用引号包住,C# 侧按 int 解析直接报错。

4.3 从 Unity 回传数据时的序列化与属性陷阱

Unity 回传给前端的方向也对称,用 JsonUtility.ToJson 把事件实体转换成字符串,再通过 jslib 送到页面。关键在于字段定义方式,这个坑特别隐蔽:

[System.Serializable] public class UnityEventMessage { public string type; public string objectName; public float progress; }

这里必须用 public 字段,不能用自动属性。如果写成 public float progress { get; set; },JsonUtility 序列化时直接忽略它,前端收到的 JSON 里根本不存在 progress 键。很多同学配置半天看不到进度条更新,最后发现是 C# 侧把字段写成了属性。Unity 的 JsonUtility 只认可字段和 [SerializeField] 成员,不处理 get/set 属性,这是历史设计限制,习惯了 Newtonsoft.Json 的人很容易在这里栽跟头。

4.4 中文、特殊符号与字符编码的防坑清单

C# 字符串通过 jslib 进入前端时,Unity 运行时已经自动完成 UTF-8 编码,前端用 UTF8ToString 还原,中文不会乱码,也不需要再做 escape。但实际联调中编码相关问题仍然高频出现,最常见的是前端同学习惯性在消息上套 encodeURIComponent,Unity 端拿到的是 %E4%B8%AD%E6%96%87 这样的转义序列,JSON 反序列化直接失败。正确做法是:不做 URL 编码,原始中文直接放入 JSON 字符串。

失败现象最常见原因核查方式
Unity 收到 %E5%BC%80 开头字符串前端多做了 encodeURIComponent去掉编解码,直接传原始中文
前端收到 JSON 缺少字段C# 侧误用自动属性改成 public field,或加 [SerializeField]
SendMessage 无任何反应消息发送早于实例创建完成在回调中缓存 instance,发送前判空
jslib 中 alert 能弹但数据为 null字符串指针被当成数字统一使用 UTF8ToString 还原
中文丢失只剩问号在 C# 侧手动转码 GBK删除所有显式编码,交给运行时处理

这些问题的共性是:对运行时默认编码做了多余干预。Unity WebGL 这条链路从 C# 到 wasm 到 JS,每一跳官方都已经处理好了编码,人为插入转换反而破坏数据。

5. 用“链路探针”确认消息到底丢在哪一跳

5.1 做一个双向心跳探针

双方通信看似跑通时,真正上线前我强烈建议加一个“链路探针”。原理很简单:Unity 每 5 秒主动向页面发一个 ping 事件,页面收到后立刻用 SendMessage 回一个 ack,Unity 记录 ack 数量并与发送次数做差值,差值就是丢在哪一跳的直接证据。这个探针不需要 UI,完全靠浏览器控制台观察。

public class ProbeBehaviour : MonoBehaviour { public float intervalSeconds = 5f; public int ackCount = 0; private int sendCount = 0; private float timer = 0f; void Update() { timer += Time.deltaTime; if (timer < intervalSeconds) return; timer = 0f; sendCount++; var msg = JsonUtility.ToJson(new WebPingMessage { type = "ping", sequence = sendCount, timestamp = Time.realtimeSinceStartup }); WebGLComm.NotifyWeb(msg); Debug.Log("[probe] sent #" + sendCount + ", ack=" + ackCount); } public void OnAck(string from) { ackCount++; Debug.Log("[probe] ack from " + from); } }

对应的页面端处理要确保收到 ping 就回 ack,不附加任何业务判断:

window.unityMessageHandler = function(json) { var msg = JSON.parse(json); if (msg.type === "ping") { app.sendToUnity("ProbeBehaviour", "OnAck", "page"); } };

探针跑起来后,控制台里看到 sent 和 ack 的差值稳定为零,说明两条通道都通畅;差值增长,说明有一条方向会丢消息,此时再分别排查实例就绪时机、handler 挂载顺序、JSON 字段映射这些具体环节,排查范围直接缩小一半。

5.2 验证加载时序与开发构建日志

探针要求 Unity 在非编辑器环境输出日志,这有一个前置条件:Build Settings 里必须勾选 Development Build。否则 Release 构建会剥离大部分 Debug.Log,控制台里只看到 wasm 加载日志,探针日志完全消失,容易误判为链路故障。

提示:Development Build 会保留 Debug.Log 和更详细的堆栈,体积比 Release 大一些,只作为联调专用构建,正式上线前要切回 Release 并重新验证消息收发。

时序问题建议直接看浏览器 Network 面板里 wasm 文件的加载完成时间。Unity 实例创建需要几秒,如果前端页面在框架路由里过早调用了 app.sendToUnity,封装层只会返回 false,探针里看到的现象就是 Unity 侧完全没有消息进账。这个问题的本质是前端页面生命周期和 Unity 加载生命周期没有打通,解法不是把路径写死在初始化代码里,而是把待发送消息先放进队列,等实例回调触发后再 flush,和 index.html 里 pending 队列的设计保持一致。

最后用一次真实场景收尾:场景加载完成后,Unity 在一个循环动画里每秒调用 WebGLComm.NotifyWeb 上报帧率,页面侧收到后把帧率画在 DOM 上。如果帧率数字一直不动,先看探针的差值是否归零,再看 message 里的字段是否被 JsonUtility 过滤,最后看发送频率是否超过浏览器对 console 输出的节流阈值,三条路径排查完,这类通信问题基本都能定位到具体一跳。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询