
简介针对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 里带的正是把链路跑通的最小可运行 DemoUnity 场景、C# 通信脚本、jslib 桥接文件、完整 index.html 和 Build 目录一次性配齐。核心要演示的就一句话前端通过 SendMessage 把命令送进 UnityUnity 通过 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.FromJsonUnityMessage(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 idunity-container canvas idunity-canvas width960 height600/canvas /div script srcBuild/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; } ); /scriptcreateUnityInstance 的第一个参数是 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 内置序列化库不需要引入额外 DLLWebGL 构建时包体增量也最小。它的使用约束很明确目标类必须带 [System.Serializable] 特性字段必须与 JSON 键一一对应顶层必须是对象而不能是数组。[System.Serializable] public class UnityMessage { public string cmd; public int direction; public string payload; } void RotateCamera(string json) { var msg JsonUtility.FromJsonUnityMessage(json); // msg 对应前端传来的 { cmd: rotate, direction: 1, payload: 90 } transform.Rotate(0, msg.direction * 90f, 0); }前端传来的 JSON 键名是 cmd、direction、payloadC# 侧字段名必须严格保持一致。如果前端用下划线风格比如 direction_valueC# 侧也要定义成相同的下划线字段。不要指望 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。但实际联调中编码相关问题仍然高频出现最常见的是前端同学习惯性在消息上套 encodeURIComponentUnity 端拿到的是 %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 回一个 ackUnity 记录 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 输出的节流阈值三条路径排查完这类通信问题基本都能定位到具体一跳。本文还有配套的精品资源点击获取