如果你是个前端,第一次接到“把Unity WebGL项目嵌进我们的官网,还要能跟页面按钮互动”这种需求,大概率会在两个地方被卡住:一是构建产物怎么加载,二是两边到底怎么互相喊话。JavaScript和Unity WebGL的通信方法,说难不难,说简单也有一堆细坑,网上资料要么只讲了老的SendMessage用法,要么一上来就丢一整套框架,新手看完直接劝退。
这篇文章我不讲虚的,直接用一套“先跑通再优化”的思路,把 JS 调用 Unity、Unity 回调 JS 的完整链路讲清楚。你不用提前懂什么底层原理,照着抄就能用,同时我会把每一步为什么这么写的逻辑也顺带解释明白。适合正在做Web端3D集成、Unity开发者对接前端、或者纯粹想搞懂这套通信机制的同学,相信看完能少走几周的弯路。
我一直认为,搞技术最怕的不是不会,而是被碎片化资料带偏方向。所以下面的内容我会按实际接项目的顺序来组织:先想清楚通信模型,再配置Unity侧,然后打通两条通道,最后用完整案例串起来。整个过程以“易上手”为第一优先级,但也不会让你只知其然。
1. 通信需求从哪来:Unity WebGL在网页里的真实形态
1.1 浏览器里的Unity不是“游戏”,而是一个封闭模块
很多第一次接触Unity WebGL的人,会下意识以为构建产物里那个.html就是整个游戏页面,用 iframe 一嵌就完事。但实际上,Unity WebGL 的产物是几件套:一个index.html、一个loader.js、一个.wasm、一个.data,有时还有一个.framework.js。浏览器加载时,loader先创建Unity的运行时实例,然后在指定容器里渲染Canvas,整个引擎跑在一个独立的内存空间里。
这个“独立空间”是理解通信问题的核心前提。JavaScript跑在浏览器环境里,Unity跑在WebAssembly虚拟机里,两边虽然都在同一个网页中,但变量、函数、堆栈完全隔离。你不能在 JS 里直接访问 Unity 的某个 C# 对象的公开字段,也没法在 C# 里直接调用 window 上的全局函数。想让两边对话,只能走引擎或插件暴露出来的“官方通道”。
这就像两个相邻国家,各有各的法律和海关,想互相贸易必须走指定的口岸,而不能直接把卡车开过边境。搞懂了这个模型,你再去看后面的 API,会发现一切都很顺理成章。
1.2 三条通信路径,先分清再动手
以实际开发中最常用的角度划分,JavaScript和Unity之间的通信主要有三种路径:
- 路径A:JS 主动调用 Unity 里的某个 C# 方法。最典型的是网页按钮触发Unity场景里的物体运动,核心API是
unityInstance.SendMessage。 - 路径B:Unity 主动通知 JS。最典型的是Unity某个动画播完,需要网页更新界面显示,核心是实现一个 jslib 插件,再通过C#调用。
- 路径C:请求-响应式回调。JS发起一个请求,Unity处理完后再回调JS,核心是给请求加一个 ID,然后靠一套回调注册表把返回值送回去。
路径C本质上是在路径A和路径B之上封装的一层“协议”,并不是独立的底层机制。新手建议先扎实掌握A和B,因为所有框架、SDK本质上都在封装这两条通道。如果一开始就奔着某个封装好的通信库去学,遇到问题反而更难看透。
1.3 选型建议:别被旧资料带到沟里
现在搜索“Unity WebGL JS通信”,搜索到的大概率还是Application.ExternalCall的用法。这里明确说一下:这个API在旧版本Unity里确实可用,但Unity后来已经移除了对它的官方支持,新版本构建出的产物里基本找不到对应实现。如果你照着老教程写,大概率编译能过,但运行时报“函数未定义”,白白浪费时间。
官方推荐的做法,从C#侧调用JS时,用jslib插件配合DllImport;JS侧调用Unity侧时,用SendMessage。这两条通道加在一起,足以应付绝大多数项目需求。不要上来就研究什么底层二进制接口互操作,那是真正需要自定义复杂编解码时才需要碰的东西。
2. 动手前的Unity侧配置:这几步不做好,后面全白搭
2.1 Player Settings 里的关键项
在Unity里构建 WebGL 之前,先打开 Build Settings 切换到 WebGL 平台,然后点 Player Settings 检查几项:
- WebGL 版本:建议保持 WebGL 2.0,Unity 2020 之后创建的项目默认就是,能支持更多图形特性,和现代浏览器兼容性也更好。
- Compression Format:本地开发调试时建议先设为 Disabled 或 Brotli。如果你选了 Brotli,部署时Web服务器必须能返回对应的
Content-Encoding头,很多新手本地点开一片空白,就是这个原因。 - Enable Exceptions:如果想在浏览器里看到C#抛出的异常堆栈,可以在Development Build模式下勾选,正式发布时记得关掉,否则体积和性能都会受影响。
这些设置看似和通信无关,但通信调试依赖Unity正常跑起来。我见过太多人因为压缩格式问题页面白屏,还以为是JS调Unity的代码写错了,排查半天方向完全反了。
2.2 模板选择与Unity实例的挂载点
Unity WebGL默认构建的HTML页面,是把Canvas直接挂到body里的。如果你希望页面其他部分由自己的前端代码控制,建议不要直接用默认模板,而是建一个自定义HTML模板,里面预留一个div容器,并让Unity实例渲染进去。
模板创建方法:在Unity项目里新建Assets/WebGLTemplates/MyTemplate/index.html,然后在 Player Settings 的 WebGL Template 里选它。模板中通常会有一段var unityInstance = UnityLoader.instantiate("container", "build.json")或新版createUnityInstance的代码,你需要做的就是把 container 的id指向自己的div。
为什么这一步很关键?因为unityInstance这个对象是JS操作Unity的入口,自定义模板就意味着你能控制这个实例如何被创建、在哪里暴露、以及里面可以随时插入业务需要的初始化逻辑。很多通信时序问题,其实都可以在这一层用一段标准初始化代码解决。
2.3 一个最小可用的C#接收方法
在Unity场景里新建一个空物体,挂上一个脚本,脚本里写一个public方法。这个方法是给JS预留的“接收端口”。
using UnityEngine; public class BridgeReceiver : MonoBehaviour { public void OnWebMessage(string message) { Debug.Log("[Unity] receive from JS: " + message); } }这里要注意三个点:
- 方法必须 public,否则SendMessage找不到。
- 所在GameObject必须处于激活状态,且场景里有且只有一个同名对象,不然SendMessage会报“找不到对象”或者产生不确定的调用结果。
- 方法名大小写敏感,JavaScript那边写错一个字母都调不到。
这个脚本虽然简单,但它是整个通信链路的第一步,先把这一步跑通,后面的复杂封装才有基础。
3. 从JavaScript喊话Unity:SendMessage是主力通道
3.1 准确语法与参数规则
JS侧调用Unity方法的标准写法是:
unityInstance.SendMessage(objectName, methodName, value);objectName:Unity场景里目标GameObject的名字。methodName:挂在该对象上的某个组件中的public方法名。value:可选,要传递的参数,支持字符串和数字。在Unity 2021及之后的版本里,也支持传递简单对象,但为了兼容和稳定,我建议统一用字符串或数字。
没有参数时,第三项可以直接不传,例如:
unityInstance.SendMessage("BridgeReceiver", "OnWebMessage");当你传数字时,Unity侧可以定义为int或float,引擎会尝试做隐式转换。当你传字符串时,建议C#侧参数类型固定为string,减少类型不匹配的边界问题。
3.2 先等Unity就绪再喊话:时序问题的三种解法
新手最容易踩的坑是页面一加载就去调用SendMessage,结果控制台报错“Unity instance is not ready”,或者干脆静默失败。原因是Unity实例创建是异步的,场景加载也需要时间,JS脚本不能假设“页面load完就万事大吉”。
解法有三种,按推荐程度排序:
第一种:在Unity初始化完成后,主动通知JS。C#侧在Start方法里调用jslib插件里的函数,告诉JS“我已经准备好了”。这样JS就知道可以开始调用了,这个方案最可靠,也是我实际项目里最常用的。
第二种:在JS侧轮询。利用setInterval或requestAnimationFrame不断检查unityInstance是否存在、是否有SendMessage方法,等它出现再执行。
function waitForUnity(callback) { if (window.unityInstance && typeof window.unityInstance.SendMessage === 'function') { callback(); } else { setTimeout(() => waitForUnity(callback), 200); } }第三种:在自定义模板里,拿到unityInstance后把它挂到window上,并且设置一个全局标志位。后续所有页面业务代码都只跟window.unityInstance交互,避免闭包作用域带来的混乱。
我强烈建议直接把第三种和第一种结合起来:模板里暴露实例,Unity启动后通知JS。这样既保证实例存在,又保证场景里的目标对象已经加载完毕。
3.3 传字符串、传数字,还是传JSON?
实际项目中,只传一个字符串或者数字往往不够用。比如你要把用户在网页上填写的昵称、分数、关卡ID一次传给Unity,最干净的方式是传JSON字符串。
JavaScript侧这样写:
const payload = JSON.stringify({ playerName: 'Alice', score: 99, level: 3 }); unityInstance.SendMessage('BridgeReceiver', 'OnWebMessage', payload);Unity侧用JsonUtility解析:
[System.Serializable] public class PlayerMessage { public string playerName; public int score; public int level; } public void OnWebMessage(string json) { PlayerMessage msg = JsonUtility.FromJson<PlayerMessage>(json); Debug.Log(msg.playerName + " / " + msg.score + " / " + msg.level); }这里有个很重要的坑:JsonUtility不支持直接解析JSON数组,也不支持Dictionary。如果你想从一个形如[{"a":1},{"a":2}]的数组里取数据,JsonUtility.FromJson<T>会失败或者得到空值。解决办法是包一层对象,比如{"items":[...]},或者干脆换用第三方库,想图省事就尽量在通信协议设计上避开顶层数组这种结构。
4. 从Unity回传消息给JavaScript:避开旧API,用jslib插件
4.1 为什么新项目别碰 Application.ExternalCall
最早Unity WebGL支持从C#直接调用Application.ExternalCall("jsFuncName", param),当年的写法确实方便,一行代码就能把消息丢给浏览器。但它有几个硬伤:不支持返回值、参数序列化逻辑老旧、在新版Unity里被移出官方支持列表。如果你现在还在用2020以上的Unity,费劲写出来的ExternalCall代码很可能构建时能过,运行时代码却找不到对应入口。没必要在这个API上恋战。
正确姿势是jslib插件。它的本质是允许你在Unity工程里放一份JavaScript文件,并把C#方法映射到JS方法,等于为Unity和浏览器之间打开一道专用闸门。
4.2 从创建到调用:手写一个最小jslib
在Unity项目的Assets/Plugins/WebGL/目录下新建一个文本文件,后缀改成.jslib,例如BridgeLib.jslib。内容写:
mergeInto(LibraryManager.library, { SendToJS: function (str) { var text = UTF8ToString(str); if (window.onUnityMessage) { window.onUnityMessage(text); } } });然后在C#脚本里,通过DllImport引入这个函数:
using System.Runtime.InteropServices; using UnityEngine; public class BridgeSender : MonoBehaviour { [DllImport("__Internal")] private static extern void SendToJS(string message); public void SendStatus(string status) { SendToJS(status); } }注意两点:
- jslib里函数名要和C#侧
DllImport声明的函数名严格一致,包括大小写。 UTF8ToString是Unity WebGL运行时提供给jslib的辅助函数,用来把C#传出来的字符串指针转成JS字符串。如果不用这个函数,你拿到的基本上是乱码或内存地址,这就是很多人回传中文乱码的根本原因。
构建完成后,当Unity侧调用SendToJS("hello"),浏览器里就会触发window.onUnityMessage这个全局函数。如果你不放心全局函数是否存在,jslib里可以先做判断:
if (window.onUnityMessage) { window.onUnityMessage(text); } else { console.warn("onUnityMessage not defined"); }这种防御式写法在页面组件还没完全初始化时会救你一命。
4.3 在jslib里主动操作DOM,还是只触发回调?
一种常见做法是直接在jslib里操作DOM,比如更新一个div的文本。如果项目只用Unity一个模块,这么写没问题,够简单。但如果你的页面还有前端框架,比如Vue或React,直接在jslib里改DOM会绕过框架的数据流,容易导致状态不同步。
所以我更推荐jslib只负责“把消息抛出去”,具体DOM操作交给页面框架里的回调函数做。也就是说,jslib里只调用window.onUnityMessage,前端模块注册这个事件,在事件里统一处理数据并更新UI。这样分层,通信层和业务层是解耦的,后面换UI框架也不太会影响Unity侧代码。
4.4 中文和特殊字符的编码安全问题
中文在跨通道传输时特别容易出问题。如果你在jslib里看到一堆乱码,第一反应应该检查是不是没有经过UTF8ToString解码,而不是怀疑Unity或浏览器有问题。反过来,JS传中文给Unity时,SendMessage内部会统一处理编码,一般不会出乱码,但到了C#侧如果要拼接字符串或做长度判断,注意C#和JS对字符串长度的计算方式不同,C#里string.Length按UTF-16字符数,JS的length在大多数情况下也按UTF-16,但如果遇到特殊emoji,两边可能对不上。
另外,如果从网页传入的内容包含用户输入,直接拼到HTML里会有XSS风险。我的习惯是Unity回调到JS后,更新页面文本一律用textContent或框架的数据绑定,不直接把字符串塞进innerHTML。这算是一个容易被忽略的安全习惯。
5. 数据交换的实用技巧:别让通信变成传话筒
5.1 统一数据结构,给每条消息起个“名字”
通信内容少的时候,你说一句“移动”,我说一句“好的”,怎么都能行。一旦场景复杂,调用多了,没有协议约束就会乱套。我建议所有JS到Unity的调用都包装成这种结构:
window.sendToUnity('player.move', { x: 1, y: 2 });内部实现是:
window.sendToUnity = function (event, data) { const msg = JSON.stringify({ event: event, data: data || {} }); window.unityInstance.SendMessage('BridgeReceiver', 'OnWebMessage', msg); };Unity侧收到后,先解析event字段分发到不同处理方法,再解析data字段。这样Unity侧只需要维护一个入口方法,新加功能时不用反复暴露新的C#方法,对前端来说调用入口也统一了。本质上就是消息总线模式,好处是扩展方便,坏处是不适合对性能极端敏感的场景,但WebGL通信本身就不是为了高频大流量设计的,这点取舍基本没问题。
5.2 高频调用时的降频策略
有时候你会遇到一秒钟要发几十次状态更新的需求,比如在网页上拖动滑块,要求Unity实时响应。直接每次change事件都发一条消息,会明显感觉卡顿,因为Unity处理一条消息的开销比普通JS函数调用大不少。
我的做法是给高频事件加一个“节流器”:比如滑块input事件触发时,不立即发送,而是记录目标值,用requestAnimationFrame保证每秒最多发一两次。或者,直接改成“滑块松开”时发一次最终值,配合Unity侧做插值过渡,效果反而更顺滑。这里没有统一答案,但核心理念是减少消息条数,而不是优化单条消息的传输速度。
5.3 基于回调ID的请求-响应模式
如果你想做“JS问Unity要一个数据,Unity拿到后回传”的完整双向交互,只在Unity侧处理完直接调用JS还不够,因为JS侧可能需要知道这次回传对应的是哪个请求。办法是给请求编个号。
let requestId = 0; const pendingCallbacks = {}; window.requestFromUnity = function (action, payload, callback) { const id = ++requestId; pendingCallbacks[id] = callback; const msg = JSON.stringify({ id: id, action: action, data: payload }); window.unityInstance.SendMessage('BridgeReceiver', 'OnRequest', msg); }; window.onUnityResponse = function (json) { const res = JSON.parse(json); if (pendingCallbacks[res.id]) { pendingCallbacks[res.id](res.data); delete pendingCallbacks[res.id]; } };Unity C#侧拿到请求后,处理完,把结果通过jslib回传,带上同一个id。这就形成了一次完整的请求-响应闭环。这套模式在很多实际项目里极其好用,比如网页加载存档数据、Unity请求页面的登录态,都属于这种场景。
6. 完整实战:一个按钮控制Unity动画并回传状态的Demo
6.1 场景与需求说明
我用一个最常见的完整例子把所有代码串起来。需求是:网页上有两个按钮,点击“开始旋转”时,Unity场景里的Cube旋转90度,旋转完成后通知网页显示“旋转完成”;点击“恢复位置”时,Cube回到初始位置,网页显示“已复位”。
这个Demo虽然简单,但覆盖了JS到Unity、Unity到JS两条通道,也覆盖了时序处理和加载完成后才注册事件的标准流程。
6.2 Unity侧完整代码与jslib插件
先在场景里创建一个Cube,再创建一个空物体BridgeContainer,挂载下面的脚本:
using UnityEngine; using System.Runtime.InteropServices; public class BridgeContainer : MonoBehaviour { [DllImport("__Internal")] private static extern void NotifyJSRotationDone(string message); public void StartRotation() { StartCoroutine(RotateRoutine()); } public void ResetRotation() { StopAllCoroutines(); transform.rotation = Quaternion.identity; NotifyJSRotationDone("reset"); } private System.Collections.IEnumerator RotateRoutine() { Quaternion target = Quaternion.Euler(0, 90, 0); float duration = 1.0f; float elapsed = 0f; Quaternion start = transform.rotation; while (elapsed < duration) { elapsed += Time.deltaTime; transform.rotation = Quaternion.Slerp(start, target, elapsed / duration); yield return null; } transform.rotation = target; NotifyJSRotationDone("finished"); } }对应Assets/Plugins/WebGL/BridgeLib.jslib:
mergeInto(LibraryManager.library, { NotifyJSRotationDone: function (str) { var message = UTF8ToString(str); if (window.onUnityNotify) { window.onUnityNotify(message); } else { console.warn("window.onUnityNotify not registered"); } } });6.3 JS侧页面代码与联调步骤
HTML中先提供一个承载Unity的容器unity-container,再把模板构建出来的Unity实例挂载到这里。页面JS核心逻辑如下:
window.onUnityNotify = function (message) { const status = document.getElementById('status'); if (message === 'finished') { status.textContent = '旋转完成'; } else if (message === 'reset') { status.textContent = '已复位'; } }; function startRotate() { if (window.unityInstance) { window.unityInstance.SendMessage('BridgeContainer', 'StartRotation'); } } function resetRotate() { if (window.unityInstance) { window.unityInstance.SendMessage('BridgeContainer', 'ResetRotation'); } }注意window.onUnityNotify要在Unity实例加载完成前就定义好,否则Unity启动后立刻回调时找不到函数,状态就丢了。这也是我推荐全局函数而不是局部变量的原因,全局变量天然规避了作用域时序问题。
部署和调试时,不要直接用file://协议打开HTML,WebGL的加载器会因跨域限制直接失败。建议在项目根目录跑一个简单的本地静态服务器,比如:
python -m http.server 8080然后访问http://localhost:8080进行联调。如果你用的是Vite或Webpack的前端工程,直接把构建产物放到public目录下,用开发服务器打开也一样。
7. 常见问题与避坑速查表
7.1 典型报错对照表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
控制台报SendMessage is not a function | 调用时机太早,unityInstance尚未创建完成 | 用waitForUnity或Unity主动通知就绪后再调用 |
SendMessage提示找不到目标对象 | GameObject名字拼写错误、目标未激活 | 检查场景中对象名称与激活状态 |
| C#方法没有执行 | 方法不是public、方法名不匹配 | 确认public修饰符,检查大小写 |
| Unity调用JS无反应 | jslib文件路径不对、DllImport没配对、window函数未定义 | 确认Assets/Plugins/WebGL路径,检查__Internal,加日志 |
| 中文传到页面乱码 | jslib里没用UTF8ToString解码 | 所有C#字符串入JS前都要UTF8ToString |
| 页面白屏不加载 | 压缩格式配置不对、服务器缺少编码头 | 本地调试先关闭压缩,部署时配好Brotli头 |
| 画面有,但按钮点了没反应 | 模板里unityInstance没挂到window上 | 在模板初始化完成后统一window.unityInstance = ... |
7.2 定位问题的一套固定调试流程
遇到通信问题,我建议按下面的顺序排查,而不是根据直觉乱翻代码:
- 先在Unity侧入口方法里加
Debug.Log,确认消息是否真的进了Unity。浏览器控制台也能看到Unity的日志,这一步能直接判断问题在JS侧还是Unity侧。 - 如果Unity侧没收到,检查JS的
SendMessage参数,打印一下unityInstance是否存在。 - 如果Unity侧收到了但浏览器没反应,检查jslib里的
window.onUnityNotify是否存在,并在jslib里加一个console.log确认函数被调用。 - 确认都是单次调用,没有定时器或帧循环覆盖状态。
这套流程只要走一遍,90%的问题都能定位出来。剩下10%大概率出在构建缓存上,Build一次再测。
7.3 几条独家心得
做这种跨端通信,我的体会是先“约定协议”再“写功能代码”。哪怕项目很小,我也建议至少把消息名、数据结构、回调函数名先用一个文档列出来,否则前端和Unity两边各写各的,联调时一对暗号对不上,那才叫痛苦。
另一个经验是先跑通最小链路再上复杂功能。我第一次做类似集成时,一上来就写完整的数据同步流程,结果花了三天排查环境问题,后来发现Unity根本还没构建成功。后来每次都是先用一个Hello字符串把两端打通,再逐步加代码,效率高得多。
如果你打算长期维护这类项目,建议把通信层单独封装成一个模块,比如bridge.js,里面集中管理所有的消息格式、等待Unity就绪的逻辑、以及回调注册表。这样即使Unity场景内部实现大改,网页侧的接口也能保持稳定,算是这类集成项目里最值得做的一笔长期投资。