☰
Unity WebGL 与 Vue 双向通信实战:从打包配置到数据交互的完整 Demo
2026/10/16 6:45:34 网站建设 项目流程

简介:这份资源是面向前端与游戏开发者的Unity WebGL与Vue.js集成实战示例,重点解决在网页中嵌入Unity内容并实现双向通信的问题。包内共457个文件,以281个dll、71个pdb等Unity构建产物为主,辅以asset、json、png、js、html、unityweb及vue组件等,压缩包约17.92MB,完整保留了从Unity工程到WebGL导出再到Vue加载的目录结构。已有7133人学习下载,说明该集成方案在社区中具有较高参考价值。读者可从中了解Unity导出WebGL的构建配置、Vue组件在mounted钩子中加载Unity实例的方式,以及通过Unity消息系统与WebGL数据通信实现双向交互的具体写法,包括控制按钮、状态切换与数据传递等场景。源码可直接对照分析,帮助开发者掌握游戏引擎与前端框架融合的关键技巧,适合具备一定Vue基础、希望拓展Web端互动内容能力的中高级开发者。

1. 当 Unity 撞上 Vue:一份能跑通的 WebGL 交互 Demo 到底长什么样

如果你正在做数字孪生、在线展厅或者教育类 Web 应用,大概率会遇到这个需求:三维场景用 Unity 做,界面和业务逻辑用 Vue 写,两者还得能互相传数据。听起来简单,真动手就会发现坑一个接一个——Unity 打包出来的 WebGL 产物怎么塞进 Vue 项目?C# 怎么调 JavaScript 的函数?Vue 又怎么反过来给 Unity 发消息?这份 Demo 就是冲着这些问题来的,它把 Unity WebGL 与 Vue 的双向通信链路完整跑通,包含 Unity 侧 C# 脚本、前端 Vue 组件以及打包配置。适合已经会一点 Unity 和 Vue、但卡在“怎么让它们说上话”这一步的开发者。下面我按实际拆包和复现的顺序,把关键环节一个个过一遍。

2. 先理清通信模型:Unity WebGL 和 Vue 到底谁调谁

2.1 两种调用方向与底层机制

Unity 打包成 WebGL 后,本质上是一堆 JavaScript 胶水代码加一个 WebAssembly 模块。它和外部网页的通信只有两条路:一条是 Unity 主动调用页面上的 JavaScript 函数,另一条是页面上的 JavaScript 调用 Unity 实例暴露出来的方法。前者靠Application.ExternalCall或者更现代的[DllImport("__Internal")]方式,后者靠SendMessage方法。

在 Vue 这边,你拿到的是 Unity 的gameInstance对象,通过gameInstance.SendMessage('挂载脚本的物体名', '方法名', 参数)就能把数据送进 Unity。反过来,Unity 要调 Vue 的方法,最稳的做法是在 Vue 里把回调函数挂到window上,Unity 侧用Application.ExternalCall('函数名', 参数)触发。注意,Application.ExternalCall在新版 Unity 里已经标记过时,但很多存量项目还在用,Demo 里两种方式都给了示例。

为什么不用jslib插件?因为jslib更适合封装底层浏览器 API,而业务层面的双向通信,直接用SendMessage加window回调更直观,调试也方便。我一般会建议把通信层单独抽成一个 Vue 的 composable 或者一个 Unity 桥接类,别把SendMessage散落在各个组件里,否则后期改一个方法名要全局搜索替换,血泪经验。

2.2 数据格式的约定与序列化

Unity 和 JavaScript 之间传参,只支持字符串和数字这类简单类型。你要传对象或者数组,必须自己序列化成 JSON 字符串。Demo 里统一用 JSON 作为通信协议,Unity 侧用JsonUtility.ToJson序列化,Vue 侧用JSON.parse解析。反过来 Vue 发给 Unity 的也是 JSON 字符串,Unity 收到后用JsonUtility.FromJson反序列化。

这里有个容易翻车的点:JsonUtility不支持字典和顶层数组,你的数据结构必须是可序列化的类或结构体,字段还得是 public 的。如果业务数据里有嵌套字典,要么换成List<KeyValuePair>,要么引入第三方序列化库。Demo 为了保持依赖干净,只用了 Unity 自带的JsonUtility,数据结构也刻意设计得扁平。实际项目里如果数据结构复杂,建议在 Unity 侧换用Newtonsoft.Json,但要注意 WebGL 平台对反射的支持限制,IL2CPP 裁剪可能会把用到的类裁掉,需要额外配置link.xml。

3. Unity 侧打包配置与 C# 通信脚本怎么写

3.1 WebGL 打包参数逐项说明

在 Unity 里打开Build Settings,切换到 WebGL 平台,点击Player Settings进入发布设置。几个关键参数直接决定能不能和 Vue 正常配合:

  • Resolution and Presentation里的Canvas Width和Canvas Height建议设为 0 或者留空,让前端 CSS 控制尺寸,否则 Unity 会写死内联样式,Vue 里再调就很别扭。
  • Other Settings里的Color Space选Gamma,WebGL 对线性空间的支持在部分浏览器上仍有兼容问题,除非你明确需要线性光照。
  • Publishing Settings里的Compression Format选Disabled或者Gzip。如果选Brotli,服务器必须配置对应的Content-Encoding响应头,否则浏览器加载会直接报错。本地调试用Disabled最省事。
  • Publishing Settings里的Decompression Fallback建议勾上,这样即使服务器没配压缩头,Unity 也能用 JavaScript 解压,代价是加载稍慢。

打包完成后你会得到三个核心文件:Build文件夹下的.wasm、.data、.framework.js,以及根目录的index.html。Vue 项目里只需要Build文件夹和index.html里引用的加载逻辑,不需要整个index.html照搬。

3.2 C# 脚本里的双向通信实现

下面这个脚本挂在 Unity 场景里的一个空物体上,物体名假设为Bridge。它负责接收 Vue 发来的消息,并向 Vue 发送数据。

using System.Runtime.InteropServices; using UnityEngine; public class WebGLBridge : MonoBehaviour { // 声明外部 JavaScript 函数,用于向 Vue 发送消息 [DllImport("__Internal")] private static extern void SendToVue(string action, string payload); // Vue 通过 SendMessage 调用此方法,参数为 JSON 字符串 public void OnReceiveFromVue(string json) { var msg = JsonUtility.FromJson<VueMessage>(json); switch (msg.action) { case "loadModel": // 触发模型加载逻辑 Debug.Log($"加载模型: {msg.payload}"); break; case "setColor": // 修改物体颜色 var renderer = GameObject.Find("Target").GetComponent<Renderer>(); renderer.material.color = ColorUtility.TryParseHtmlString(msg.payload, out var c) ? c : Color.white; break; } } // Unity 主动通知 Vue,比如场景加载完成 void Start() { var data = new UnityMessage { status = "ready", info = "场景初始化完成" }; SendToVue("onUnityReady", JsonUtility.ToJson(data)); } // 示例:每帧检测按键,按下空格时通知 Vue void Update() { if (Input.GetKeyDown(KeyCode.Space)) { var data = new UnityMessage { status = "spacePressed", info = "空格键被按下" }; SendToVue("onUnityEvent", JsonUtility.ToJson(data)); } } } [System.Serializable] public class VueMessage { public string action; public string payload; } [System.Serializable] public class UnityMessage { public string status; public string info; }

这段代码的逻辑很直白:OnReceiveFromVue是 Vue 调用的入口,收到 JSON 后按action字段分发处理;SendToVue是声明在.jslib里的外部函数,Unity 编译时会把它链接到 JavaScript 实现。注意DllImport的字符串必须是"__Internal",这是 WebGL 平台的固定写法,写错了打包不会报错,但运行时调用会静默失败,这个坑我踩过不止一次。

对应的.jslib文件放在Assets/Plugins/WebGL目录下,内容如下:

mergeInto(LibraryManager.library, { SendToVue: function (actionPtr, payloadPtr) { var action = UTF8ToString(actionPtr); var payload = UTF8ToString(payloadPtr); // 调用 Vue 挂载在 window 上的全局回调 if (window.UnityBridge && window.UnityBridge.onMessage) { window.UnityBridge.onMessage(action, payload); } } });

UTF8ToString是 Emscripten 提供的工具函数,负责把 C# 传过来的字符串指针转成 JavaScript 字符串。window.UnityBridge是 Vue 侧约定的全局对象,后面会看到它怎么挂上去。

4. Vue 侧集成:加载 Unity 实例并建立双向通道

4.1 在 Vue 组件中动态加载 Unity 构建产物

Unity 打包出来的Build文件夹整个拷到 Vue 项目的public目录下,比如public/unity/Build。然后在 Vue 组件里动态创建 script 标签加载loader.js,等加载完成后再调用createUnityInstance。

// UnityViewer.vue <template> <div ref="unityContainer" class="unity-container"></div> </template> <script> export default { name: 'UnityViewer', data() { return { unityInstance: null, isLoaded: false }; }, mounted() { this.loadUnity(); }, methods: { loadUnity() { const script = document.createElement('script'); script.src = '/unity/Build/unity.loader.js'; script.onload = () => { const canvas = document.createElement('canvas'); canvas.id = 'unity-canvas'; canvas.style.width = '100%'; canvas.style.height = '100%'; this.$refs.unityContainer.appendChild(canvas); window.createUnityInstance(canvas, { dataUrl: '/unity/Build/unity.data', frameworkUrl: '/unity/Build/unity.framework.js', codeUrl: '/unity/Build/unity.wasm', streamingAssetsUrl: '/unity/StreamingAssets', companyName: 'Demo', productName: 'VueUnityDemo', productVersion: '1.0' }).then((instance) => { this.unityInstance = instance; this.isLoaded = true; this.setupBridge(); }).catch((err) => { console.error('Unity 实例创建失败:', err); }); }; document.body.appendChild(script); }, setupBridge() { // 挂载全局回调,供 Unity 调用 window.UnityBridge = { onMessage: (action, payload) => { console.log('收到 Unity 消息:', action, payload); this.$emit('unity-message', { action, payload }); } }; }, // 向 Unity 发送消息的公共方法 sendToUnity(action, payload) { if (!this.unityInstance) return; const json = JSON.stringify({ action, payload }); this.unityInstance.SendMessage('Bridge', 'OnReceiveFromVue', json); } } }; </script>

这段代码做了三件事:动态加载 Unity 的 loader 脚本、创建 canvas 并调用createUnityInstance、挂载window.UnityBridge让 Unity 能回调进来。SendMessage的第一个参数'Bridge'必须和 Unity 场景里挂载脚本的物体名完全一致,大小写敏感。第二个参数是方法名,第三个是字符串参数。如果 Unity 侧方法签名不是单个 string,比如用了 int 或 float,SendMessage也能传,但 JSON 方案统一用 string 更省心。

4.2 父子组件通信与状态同步

实际项目里,Unity 视图通常只是页面的一部分,外面还有按钮、表单、列表等 Vue 组件。Demo 里把UnityViewer作为子组件,父组件通过ref调用sendToUnity,同时监听unity-message事件更新 Vue 的状态。

// App.vue 片段 <template> <div> <button @click="changeColor">改变 Unity 物体颜色</button> <p>Unity 状态: {{ unityStatus }}</p> <UnityViewer ref="viewer" @unity-message="handleUnityMessage" /> </div> </template> <script> import UnityViewer from './components/UnityViewer.vue'; export default { components: { UnityViewer }, data() { return { unityStatus: '等待连接' }; }, methods: { changeColor() { this.$refs.viewer.sendToUnity('setColor', '#ff0000'); }, handleUnityMessage({ action, payload }) { if (action === 'onUnityReady') { this.unityStatus = '已就绪'; } else if (action === 'onUnityEvent') { this.unityStatus = payload; } } } }; </script>

这种模式的好处是通信逻辑集中在UnityViewer里,父组件不用关心SendMessage的细节。如果多个组件都要和 Unity 通信,可以把unityInstance和sendToUnity抽到一个 Vuex store 或者一个全局 composable 里,避免层层传递 ref。Demo 为了保持简单,只做了一层父子通信,但思路是可以直接扩展的。

5. 避坑排查:这五个问题几乎每个人都会遇到

5.1 现象:Unity 加载后画面全黑,控制台无报错

原因通常是压缩格式和服务器响应头不匹配。Unity 默认用 Brotli 压缩.wasm和.data文件,但 Vue 开发服务器或者 Nginx 没有返回Content-Encoding: br,浏览器拿到的是压缩后的二进制流,却按未压缩解析,自然一片黑。

解决:在Player Settings里把Compression Format改成Disabled,重新打包。或者保留 Brotli,在服务器配置里加上对应的Content-Encoding和Content-Type。本地调试阶段建议直接禁用压缩,省去排查时间。

5.2 现象:SendMessage调用后 Unity 没反应,也不报错

原因有两个可能:一是物体名或方法名拼写错误,SendMessage找不到目标时会静默忽略;二是方法所在的脚本没有挂载到场景中,或者物体被禁用了。

解决:先在 Unity 里确认物体名和脚本方法名完全一致,注意大小写。然后在OnReceiveFromVue方法第一行加Debug.Log,打包后打开浏览器控制台看有没有输出。如果没有,说明消息根本没送到,检查 Vue 侧SendMessage的第一个参数。如果有输出但逻辑没执行,检查switch分支的action值是否匹配。

5.3 现象:Unity 调用window.UnityBridge时报 undefined

原因是 Vue 侧挂载window.UnityBridge的时机晚于 Unity 的首次调用。Unity 的Start方法可能在createUnityInstance的then回调之前就执行了,此时window.UnityBridge还没定义。

解决:把window.UnityBridge的挂载提前到createUnityInstance调用之前,或者在 Unity 侧加一个重试机制。Demo 里的做法是在setupBridge里挂载,但Start里的首次通知可能丢失。更稳妥的方案是在 Vue 的mounted钩子里先挂载window.UnityBridge,再加载 Unity 脚本。如果业务允许,也可以在 Unity 侧延迟几帧再发送首次消息。

5.4 现象:IL2CPP 裁剪导致 JSON 反序列化失败

原因:Unity WebGL 默认开启Managed Stripping Level,如果设为High,JsonUtility.FromJson用到的类可能被裁掉,运行时抛ArgumentException或者返回 null。

解决:在Player Settings的Managed Stripping Level里选Low或者Disabled,或者在Assets目录下建一个link.xml文件,显式保留用到的数据类。link.xml内容如下:

<linker> <assembly fullname="Assembly-CSharp"> <type fullname="VueMessage" preserve="all"/> <type fullname="UnityMessage" preserve="all"/> </assembly> </linker>

5.5 现象:Vue 路由切换后 Unity 画面卡死或内存泄漏

原因:Vue 的组件销毁时没有释放 Unity 实例。createUnityInstance返回的实例如果不调用Quit或者销毁 canvas,WebAssembly 的内存不会自动回收,多次切换路由后浏览器标签页内存暴涨。

解决:在 Vue 组件的beforeDestroy钩子里调用this.unityInstance.Quit(),并手动移除 canvas 和加载的 script 标签。如果页面需要频繁切换,考虑用v-show代替v-if,让 Unity 实例常驻,避免反复创建销毁。

6. 进阶技巧:把通信层抽成可复用的 composable

Demo 跑通之后,下一步就是把它变成项目里能复用的基础设施。我一般会把 Unity 的加载、通信、销毁逻辑全部塞进一个 Vue composable,比如叫useUnity,返回unityInstance、sendToUnity、isLoaded这几个响应式引用。这样任何组件只要const { sendToUnity } = useUnity()就能发消息,不用关心底层是SendMessage还是别的什么。

// composables/useUnity.js import { ref, onBeforeUnmount } from 'vue'; export function useUnity(options) { const unityInstance = ref(null); const isLoaded = ref(false); let canvas = null; let script = null; const load = () => { script = document.createElement('script'); script.src = options.loaderUrl; script.onload = () => { canvas = document.createElement('canvas'); canvas.id = 'unity-canvas'; options.container.appendChild(canvas); window.createUnityInstance(canvas, options.config).then((instance) => { unityInstance.value = instance; isLoaded.value = true; }); }; document.body.appendChild(script); }; const sendToUnity = (objectName, methodName, payload) => { if (!unityInstance.value) return; const json = typeof payload === 'string' ? payload : JSON.stringify(payload); unityInstance.value.SendMessage(objectName, methodName, json); }; onBeforeUnmount(() => { if (unityInstance.value) { unityInstance.value.Quit(); unityInstance.value = null; } if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas); if (script && script.parentNode) script.parentNode.removeChild(script); }); return { unityInstance, isLoaded, load, sendToUnity }; }

这个 composable 把加载、发送、销毁三个生命周期都管起来了。options里传容器元素、loader 路径和createUnityInstance的配置对象。sendToUnity做了 JSON 序列化的兜底,传对象和传字符串都行。onBeforeUnmount里调用Quit并清理 DOM,避免内存泄漏。

还有一个实用技巧:在开发阶段,把 Unity 发来的消息全部打印到控制台,并加上时间戳。这样排查通信时序问题时,能清楚看到是 Vue 先发了消息但 Unity 还没加载完,还是 Unity 发了消息但 Vue 的回调还没挂上。我习惯在window.UnityBridge.onMessage里加一行console.log('[Unity→Vue]', Date.now(), action, payload),在sendToUnity里加一行console.log('[Vue→Unity]', Date.now(), objectName, methodName, payload)。别小看这两行日志,联调阶段能省下大量猜谜时间。

从那以后我每次集成 Unity WebGL 到前端项目,都强制先跑一遍这个 Demo 的通信链路,确认SendMessage和window回调都通了,再往上叠业务逻辑。希望帮到你。

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

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

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

立即咨询