欢迎光临
我们一直在努力

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

本文专为前端开发者撰写,将深入介绍如何在 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,但它经过了改造。它的职责是:
  • 接收 Vue 的指令: 监听来自 Vue 的 postMessage,并将其翻译成 Unity 能听懂的 unityInstance.SendMessage() API调用。
  • 转发 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);
    });

    核心解读:

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

  • 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 成了两个通过标准化信件(消息对象)沟通的独立应用,大大提升了项目的可维护性和扩展性。
    赞(0)
    未经允许不得转载:171主机测评 » 在 Vue 中嵌入 Unity WebGL 并实现双向通信
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址