在 Vue 中嵌入 Unity WebGL 并实现双向通信

发布于:2025-08-31 ⋅ 阅读:(24) ⋅ 点赞:(0)

本文专为前端开发者撰写,将深入介绍如何在 Vue.js 项目中,通过 <iframe> 集成 Unity WebGL 应用,并建立一套基于 postMessage 的高级双向通信架构。本文不仅会展示 Vue 端的代码实现,还将独家揭秘如何改造 Unity 默认生成的 index.html 文件,将其从一个静态页面升级为一个功能强大的“通信桥梁”,最终实现 Vue 与 Unity 之间的完美解耦和高效交互。

一、 前言

在现代 Web 开发中,我们常常需要将 Unity 开发的复杂 3D 场景嵌入到 Vue 应用中。一个常见的痛点是前后端的紧密耦合。本文将为你呈现一套业界领先的解决方案,其核心在于改造 Unity 的 index.html,构建一个“通信桥梁”,让你告别直接调用 API 的脆弱模式,进入一个更优雅、更易维护的集成世界。

二、 核心架构:解耦的“通信桥梁”模式

我们集成的核心思想,不是让 Vue 直接与 Unity 对话,而是通过一个中间层——一个被精心改造过的 Unity index.html 文件——来充当“通信桥梁”

  • vue 应用 (父窗口): 我们的主应用,负责 2D UI 和业务逻辑。
  • 通信桥梁 (<iframe> 中的 index.html): 这是 Unity 团队交付给我们的 index.html,但它经过了改造。它的职责是:
  1. 接收 Vue 的指令: 监听来自 Vue 的 postMessage,并将其翻译成 Unity 能听懂的 unityInstance.SendMessage() API调用。
  2. 转发 Unity 的事件: 监听来自 Unity 内部的事件调用,并将其翻译成 postMessage 发送给 Vue。
  • Unity 应用 (在 <iframe> 中运行): 纯粹的 3D 引擎,专注于 3D 逻辑。

通过这种方式,Vue 和 Unity 互相不知道对方的技术细节,它们只通过标准的 postMessage “信件”与中间的桥梁沟通,实现了完美的解耦。

三、揭秘“通信桥梁”:如何改造 Unity 的 index.html

这是整个架构的精髓所在。Unity 默认生成的 index.html 是一个孤岛,我们需要通过以下改造,将它变为通信枢纽。

Unity 内部可以通过调用全局 JavaScript 函数来对外“喊话”。默认情况下,这些函数是空的,我们需要让它们把话传出去。

改造前 (Unity 原始代码)

//原代码是 Unity 构建出的标准模板,

<!DOCTYPE html>
<html lang="en-us">
<head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Unity Web Player | YangShanGang</title>
</head>
<body style="text-align: center; padding: 0; border: 0; margin: 0;">
    <canvas id="unity-canvas" width=3200 height=1800 tabindex="-1" style="width: 3200px; height: 1800px; background: #231F20"></canvas>

    <script src="Build/YSG.loader.js"></script>
    <script>
        var unityInstance;

        if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
            var meta = document.createElement('meta');
            meta.name = 'viewport';
            meta.content = 'width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes';
            document.head.appendChild(meta);

            var canvas = document.querySelector("#unity-canvas");
            canvas.style.width = "100%";
            canvas.style.height = "100%";
            canvas.style.position = "fixed";
            document.body.style.textAlign = "left";
        }

        function loadUnity() {
            createUnityInstance(document.querySelector("#unity-canvas"), {
                arguments: [],
                dataUrl: "Build/YSG.data",
                frameworkUrl: "Build/YSG.framework.js",
                codeUrl: "Build/YSG.wasm",
                streamingAssetsUrl: "StreamingAssets",
                companyName: "DefaultCompany",
                productName: "YangShanGang",
                productVersion: "0.1.0",
            }).then((instance) => {
                unityInstance = instance;
                console.log(" Unity 实例已加载");

                // 绑定全屏按钮(如果有的话)
                var fullscreenButton = document.querySelector("#unity-fullscreen-button");
                if (fullscreenButton) {
                    fullscreenButton.onclick = () => {
                        unityInstance.SetFullscreen(1);
                    };
                }
            }).catch((message) => {
                console.error("❌ Unity 加载失败: ", message);
            });
        }
        document.addEventListener("DOMContentLoaded", loadUnity);
        //________________________

        function HtmlCurrentPlotIndex(index) {
        }
  function GetCursorMoveData(jsonData) {
            if (unityInstance) {
                unityInstance.SendMessage("MockCursor", "GetCursorMoveData", jsonData);
            }
        }
  </script>
</body>
</html>

核心目标:实现应用解耦与双向通信

原始的 index.html 只能单向地被外部调用(通过全局 JS 函数),并且无法主动通知外部发生了什么。下面的修改实现了两个关键目标。

  • 建立从 Unity 到 Vue 的消息通道 (postMessage 发送)
  • 建立从 Vue 到 Unity 的消息通道 (postMessage 接收)

这使得 Vue 应用和 Unity 应用可以作为两个独立的实体进行开发,它们之间通过一个标准化的、类似 API 的方式进行对话,大大降低了耦合度。

实现 “Unity → Vue” 的消息转发

这是最重大的修改之一。

原始状态: 在原始文件中,所有以 Html 开头的函(如 HtmlCurrentPlotIndex, HtmlSendEventId)都是空函数体。

    // 原始代码
    function HtmlCurrentPlotIndex(index) {
        // (这里是空的)
    }
    function HtmlSendEventId(index) {
        // (这里是空的)
    }

这意味着,当 Unity 内部调用这些函数时,什么都不会发生。Unity 只是在“喊话”,但没人把它的话传出去。

修改后:将它们变成了消息转发器。

    // 您的修改后代码
    function HtmlCurrentPlotIndex(index) {
        window.parent.postMessage({
            name: 'HtmlCurrentPlotIndex', // 自定义消息名称,用于Vue中识别
            data: index                  // 传递的参数
        }, '*');
    }

    function HtmlSendEventId(index) {
        window.parent.postMessage({
            name: 'HtmlSendEventId',
            data: index
        }, '*');
    }

修改逻辑: 我们为所有 Unity 需要对外发送事件的 Html... 函数都填充了 window.parent.postMessage 的实现,使其成为一个消息转发器。

实现 “Vue → Unity” 的指令接收与分发

这是让架构变得非常优雅和解耦的关键。

原始状态: 原始文件只能通过直接调用全局函数(如 GotoShip('id-123'))来与 Unity 交互。这意味着前端必须知道所有这些全局函数的具体名称。

修改后:在 index.html 中添加了一个全局的 message 事件监听器,使其成为一个指令接收和分发中心。

    // 新增代码
    window.addEventListener('message', function(event) {
        let data = event.data;
        // 如果是 unityCommand 类型的消息
        if (data.type === 'unityCommand') {
            if (typeof window[data.method] === 'function') {
                window[data.method](data.param); // 调用相应的方法并传递参数
            }
        }
        // ... (还可以处理其他类型的消息)
    });

这段代码使 index.html 成为了一个指令分发中心。它监听所有来自 Vue 的消息,如果消息类型是 unityCommand,它就会根据消息中的 method 字段,动态地执行页面中已经存在的同名全局函数(如 GotoShip、SetVolume 等),从而触发对 unityInstance.SendMessage 的最终调用。

修改逻辑:

  • 监听消息: 这段代码会监听所有来自父窗口(Vue)的 postMessage 请求。
  • 协议解析: 它检查消息是否符合您定义的协议,即 data.type === 'unityCommand'。
  • 动态调用: 如果协议匹配,它会取出 data.method(例如,字符串 "GotoShip")和 data.param,然后动态地在当前 window 对象上查找并执行同名函数,即 window['GotoShip'](data.param)。
  • 执行指令: GotoShip 函数内部再调用 unityInstance.SendMessage,最终将指令送达 Unity 场景。
增加 Unity 加载完成的通知

为了让 Vue 知道何时可以安全地与 Unity 通信,我们需要在 Unity 实例加载完成后,主动通知 Vue。

原始状态: 原始文件加载完 Unity 实例后,只是在控制台打印一条日志。外部应用无法确切知道 Unity 何时准备就绪。

修改后:在 createUnityInstance 的 .then() 回调中,增加了一条 postMessage。

    // 修改后代码
    .then((instance) => {
        unityInstance = instance;
        // console.log(" Unity 实例已加载");
        window.top.postMessage({
            name: 'LoadSceneOver'
        }, '*')
    })

修改逻辑: 当 Unity 实例成功加载后,立刻向父窗口(Vue)发送一个名为 LoadSceneOver 的消息。Vue 应用在监听到这个消息后,就可以安全地开始向 Unity 发送初始化指令或执行其他依赖 Unity 的操作,避免了在 Unity 未准备好时调用而出错的问题。

修改后的整体代码:

<!DOCTYPE html>
<html lang="en-us">

<head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Unity Web Player | YangShanGang</title>
    <style>
        html,
        body {
            width: 100%;
            height: 100%;
            padding: 0;
            margin: 0;
            overflow: hidden;
        }
        
        #unity-container {
            position: absolute;
            width: 100% !important;
            height: 100% !important;
            overflow: hidden;
        }
        
        #unity-container.unity-desktop {
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%)
        }
        
        #unity-container.unity-mobile {
            position: fixed;
            width: 100% !important;
            height: 100% !important;
        }
        
        #unity-canvas {
            background: #231F20;
            width: 100% !important;
            height: 100% !important;
        }
        
        .unity-mobile #unity-canvas {
            width: 100% !important;
            height: 100% !important;
        }
    </style>
</head>
<body style="text-align: center; padding: 0; border: 0; margin: 0;">
    <canvas id="unity-canvas" tabindex="-1" style="width: 1920px; height: 1120px; background: #231F20"></canvas>

    <script src="Build/YSG.loader.js"></script>
    <script>
        var unityInstance;

        if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
            var meta = document.createElement('meta');
            meta.name = 'viewport';
            meta.content = 'width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes';
            document.head.appendChild(meta);

            var canvas = document.querySelector("#unity-canvas");
            canvas.style.width = "100%";
            canvas.style.height = "100%";
            canvas.style.position = "fixed";
            document.body.style.textAlign = "left";
        }

        function loadUnity() {
            createUnityInstance(document.querySelector("#unity-canvas"), {
                arguments: [],
                dataUrl: "Build/YSG.data",
                frameworkUrl: "Build/YSG.framework.js",
                codeUrl: "Build/YSG.wasm",
                streamingAssetsUrl: "StreamingAssets",
                companyName: "DefaultCompany",
                productName: "YangShanGang",
                productVersion: "0.1.0",
            }).then((instance) => {
                unityInstance = instance;
                // console.log(" Unity 实例已加载");
                window.top.postMessage({
                    name: 'LoadSceneOver'
                }, '*')

                // 绑定全屏按钮(如果有的话)
                var fullscreenButton = document.querySelector("#unity-fullscreen-button");
                if (fullscreenButton) {
                    fullscreenButton.onclick = () => {
                        unityInstance.SetFullscreen(1);
                    };
                }
            }).catch((message) => {
                // console.error("❌ Unity 加载失败: ", message);
            });
        }
        document.addEventListener("DOMContentLoaded", loadUnity);
        window.addEventListener('message', function(event) {
            let data = event.data;


            // 根据 data.name 来区分不同的消息类型
            if (data.name === 'loadInit') {
                // 处理初始化
                if (typeof window[data.method] === 'function') {
                    window[data.method](data.param); // 调用传递的方法并传参
                }
            }

            // 如果是 unityCommand 类型的消息
            if (data.type === 'unityCommand') {
                if (typeof window[data.method] === 'function') {
                    window[data.method](data.param); // 调用相应的方法并传递参数
                }
            }

            // 针对其他消息类型(例如警告数据处理)
            if (data.name === 'warnProps') {
                setTimeout(() => {
                    if (typeof window[data.method] === 'function') {
                        window[data.method](data.param);
                    }
                }, 1000); // 延时处理,避免冲突
            }

            // 根据不同的消息执行不同的处理
            if (data.name === 'callEvent') {
                if (typeof window[data.method] === 'function') {
                    window[data.method](data.param);
                }
            }

            if (data.name === 'callOverId') {
                if (typeof window[data.method] === 'function') {
                    window[data.method](data.param);
                }
            }
        });
 function HtmlCurrentPlotIndex(index) {
            console.log(" unity当前情节索引-------------:" + index);
            window.parent.postMessage({
                name: 'HtmlCurrentPlotIndex', // 自定义消息名称,用于Vue中识别
                data: index // 传递的参数(例如点击的UI名称)
            }, '*');
        }
       function HideOrShowHeatPointData(jsonData) {
            // console.log(jsonData, 'yjshu哈哈哈哈哈');

            if (unityInstance) {
                unityInstance.SendMessage("SceneManager", "HideOrShowHeatPointData", jsonData);
                // console.log(" unity热力图显隐---------" + jsonData);
            }
        }
//.....
    </script>
</body>

</html>

四、 步骤一:在 Vue 中嵌入 Unity 应用

集成 Unity 应用的第一步是让它在我们的页面中正确地显示出来。

1.文件放置

首先,将 Unity 团队提供的整个 WebGL 构建文件夹(通常包含一个 index.html 和其他资源目录)直接复制到你的 Vue 项目的 public 目录下。这样做可以确保在项目启动后,该文件夹内的所有内容都能作为静态资源被直接访问。下面是我的项目中unity给到的文件结构

    - public/
      - UnityBuild/      <-- 将 Unity 构建文件夹放在这里
        - index.html
        - Build/
        - StreamingAssets/
        - ...
      - favicon.ico
    - src/
      - ...

2.使用 <iframe> 作为容器

在你的 Vue 组件中,使用 <iframe> 标签来加载 Unity 的入口 HTML 文件。<iframe> 是最理想的选择,因为它创建了一个独立的沙箱环境,可以有效避免 CSS 样式污染和 JavaScript 变量冲突。

    <template>
      <div class="container">
        <!-- 你的其他 Vue UI 组件可以放在这里 -->
        <Header class="header" />
    
        <!-- 用于承载 Unity 的 Iframe -->
        <iframe
          ref="unityFrame"
          :src="unityUrl"
          class="unity-iframe"
          frameborder="0"
        ></iframe>
      </div>
    </template>
    
    <script setup lang="ts">
    import { ref } from 'vue';
    
    // Unity 应用的访问路径,相对于 public 目录
    const unityUrl = ref('/UnityBuild/index.html'); 
    
    // 创建一个 ref 来获取 iframe DOM 元素
    const unityFrame = ref<HTMLIFrameElement | null>(null);
    </script>
    
    <style scoped>
    .container {
      position: relative;
      width: 100vw;
      height: 100vh;
      overflow: hidden;
    }
    
    .header {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      z-index: 10; /* 确保 UI 在 iframe 上方 */
    }
    
    .unity-iframe {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      border: none; /* 移除边框 */
    }
    </style>

五、 步骤二:接收来自 Unity 的消息 (Unity → Vue)

当 Unity 场景中发生特定事件时(例如,一段介绍动画播放到某个节点),它会通过 window.parent.postMessage() 向我们的 Vue 应用发送消息。我们需要做的就是监听这些消息。

1.注册全局消息监听器

最佳实践是在组件挂载 (onMounted) 时添加监听器,并在组件卸载 (onUnmounted) 时移除它,以防止内存泄漏。

    import { ref, onMounted, onUnmounted } from 'vue';
    import { Store} from '@/store'; // 假设你使用 Pinia
    
    const store = Store();
    
    // 消息处理函数
    const handleMessage = (event: MessageEvent) => {
      // 安全起见,可以检查消息来源是否可信
      // if (event.origin !== "http://your-expected-origin.com") return;
    
      const { name, data } = event.data;
    
      // 使用 switch 根据消息名称分发任务
      switch (name) {
        case 'LoadSceneOver':
          console.log("Unity 场景已加载完毕!");
          // 可以在此时进行一些初始化调用
          break;
    
        case 'HtmlCurrentPlotIndex':
          console.log(`Unity 正在讲解图表: ${data}`);
          store.setActiveChart(data); // 更新状态管理中的当前图表
          break;
    
        case 'HtmlSendEventId':
          console.log(`接收到 Unity 预警事件 ID: ${data}`);
          store.showWarningPopup(data); // 触发显示预警弹窗
          break;
        
        // ...可以处理更多其他类型的消息
        default:
          break;
      }
    };
    
    onMounted(() => {
      // 注册一个全局的 message 事件监听器
      window.addEventListener('message', handleMessage);
    });
    
    onUnmounted(() => {
      // 移除监听器,避免内存泄漏
      window.removeEventListener('message', handleMessage);
    });

核心解读:

  1. window.addEventListener('message', ...) 是浏览器提供的标准 API,用于监听跨窗口消息。

  2. event.data 就是 Unity 发送过来的消息对象。我们通常会约定一个格式,比如 { name: '消息类型', data: '具体数据' },这样在 handleMessage 函数中就可以通过 name 来判断如何处理 data。四、 步骤三:向 Unity 发送指令。(我们监听 window 的 message 事件,并根据 event.data 中约定的 name 字段来执行不同的前端逻辑。)

六、 步骤三:向 Unity 发送指令

反向通信——从 Vue 控制 Unity 场景——是实现动态交互的核心。不同于直接调用 Unity 的 API,我们将采用一种更为优雅的 postMessage 机制,以实现彻底的前后端解耦。

前端通信接口的约定

我们与 Unity 侧约定,所有从 Vue 发往 Unity 的指令都将通过 postMessage 发送,并遵循以下数据结构:

    interface UnityCommand {
      type: 'unityCommand'; // 一个固定的标识符
      method: string;       // 要调用的方法名
      param?: any;          // 传递的参数
    }

在 Pinia Store 中封装发送函数

为了方便在项目的任何组件中调用,我们将发送逻辑封装在状态管理(如 Pinia)中。

    // src/store/store.ts
    import { defineStore } from 'pinia';
    
    export const useUnityStore = defineStore('unity', {
      state: () => ({
        unityFrame: null as HTMLIFrameElement | null,
      }),
      actions: {
        setUnityFrame(frame: HTMLIFrameElement) {
          this.unityFrame = frame;
        },
    
        /**
         * 向 Unity 发送指令
         * @param methodName 要在 Unity 的 index.html 中调用的全局函数名
         * @param parameter 要传递的参数,如果是对象会自动转为 JSON 字符串
         */
        sendToUnity(methodName: string, parameter?: any) {
          if (!this.unityFrame || !this.unityFrame.contentWindow) {
            console.warn('Unity iframe 尚未初始化!');
            return;
          }
    
          try {
            // 如果参数是对象,则字符串化
            const paramToSend = typeof parameter === 'object' && parameter !== null
              ? JSON.stringify(parameter)
              : parameter;
    
            // 使用 postMessage 发送标准格式的指令
            this.unityFrame.contentWindow.postMessage(
              {
                type: 'unityCommand',
                method: methodName,
                param: paramToSend
              },
              '*' // 注意:生产环境应指定为 Unity 应用的域名
            );
          } catch (error) {
            console.error('发送消息到 Unity Iframe 失败:', error);
          }
        },
      },
    });

注意: 在你的主组件 (index.vue) 的 onMounted 中,需要调用 store.setUnityFrame(unityFrame.value) 来初始化 unityFrame。

onMounted(() => {
  // store.warningId = 1;//测试预警播报
  store.setUnityFrame(unityFrame.value)
  shipType()
  window.addEventListener('message', handleMessage);//注册一个全局的 message 事件监听器
})

在业务逻辑中调用

现在,从任何组件中向 Unity 发送指令都变得非常简单和清晰


    <template>
      <!-- ... -->
      <div class="controls">
        <button @click="focusOnShip('Ship_007')">聚焦 007 号船</button>
        <button @click="toggleHeatmap(true)">显示热力图</button>
      </div>
    </template>
    
    <script setup lang="ts">
      import { Store} from '@/store'; // 假设你使用 Pinia
    
    const store = Store();
    
    function focusOnShip(shipId: string) {
      // 我们只需要知道方法名是 'GotoShip'
      store .sendToUnity('GotoShip', shipId);
    }
    
    function toggleHeatmap(show: boolean) {
      // 我们只需要知道方法名是 'HideOrShowHeatPointData'
      const params = { isShow: true};
      store .sendToUnity('HideOrShowHeatPointData', String(params .isShow);
    }
    </script>

核心解读:

  • 前端的视角: 作为前端开发者,我们完全不需要关心 Unity 的内部实现。我们的通信接口就是 unityStore.sendToUnity。我们只需查阅与 Unity 团队约定好的“方法名”列表,就可以自由地控制 3D 场景了。
  • 架构的优雅: 这种基于 postMessage 的双向通信模型,让 Vue 和 Unity 成了两个通过标准化信件(消息对象)沟通的独立应用,大大提升了项目的可维护性和扩展性。

网站公告

今日签到

点亮在社区的每一天
去签到