跳转至

带看集成指南

本文档面向接入众趣 zqsdk 的开发者,说明如何在自己的项目中实现「带看」功能。带看即两个用户(发起方与接收方)通过 IM 实时同步 VR 视角与操作,实现远程协同看房体验。你的项目需要:通过 URL 参数区分角色、集成 IM 通道、注册 SDK 带看事件、处理消息协议,并实现带看入口、场景列表与操作权限蒙层等 UI。


一、功能简介与前置条件

1.1 什么是带看

带看是基于 zqsdk 的实时协同看房能力:两个用户各自打开同一模型的 VR 页面,通过 IM(即时通讯)通道实时同步相机视角、场景切换、标尺操作等,实现「一方操作,另一方同步跟随」的远程带看体验。

核心能力包括:

  • 视角实时同步:一方移动/旋转相机,另一方自动跟随到相同视角。
  • 场景切换同步:一方切换到某个场景(如客厅、主卧),另一方同步跳转。
  • 标尺操作同步:一方开启/关闭全景标尺,另一方同步显隐。
  • 操作权限管理:SDK 自动管理操作权限(本地/远端/自由),当远端正在操作时,本地显示「对方正在操作」蒙层。

1.2 角色说明

带看涉及两个角色,通过 URL 参数区分:

角色 URL 参数 启动方式 说明
发起方 showtakelook=on 正常启动 housePlay.start() 页面上显示「带看」按钮,用户点击后建立 IM 连接
接收方 takelook=on 引导模式 housePlay.start('guid') 页面加载后自动初始化 IM,等待发起方同步视角

1.3 前置条件

  • 已接入众趣 zqsdk,并在 zqsdk.initcomplete 回调中拿到 window.housePlay(参见 VR 初始化展示指南)。
  • 已准备好 IM 服务:本仓库默认使用网易云信 NIM SDK,你也可以替换为腾讯云 TIM 或其他 IM 服务(需实现适配器接口)。
  • IM 相关凭证(appiduseridsigfuncuserid)由你的业务后端生成,通过 URL 参数传入前端页面。

1.4 示例截图

带看同步展示效果


二、整体架构与消息协议

2.1 架构概览

┌─────────────────────────────────────────────────────────┐
│                    发起方页面                              │
│  ┌──────────┐   ┌──────────┐   ┌───────────────────┐    │
│  │ 带看按钮  │──▶│ TakeLook │──▶│  IM 适配器 (NIM)   │───┐│
│  └──────────┘   │  Store    │   └───────────────────┘   ││
│                 │          │◀── housePlay 带看事件       ││
│                 └──────────┘                             ││
└─────────────────────────────────────────────────────────┘│
                                                           │
                        IM 通道(网易云信 / 腾讯云等)        │
                                                           │
┌─────────────────────────────────────────────────────────┐│
│                    接收方页面                              ││
│  ┌──────────────┐   ┌──────────┐   ┌─────────────────┐  ││
│  │ 操作权限蒙层  │◀──│ TakeLook │◀──│ IM 适配器 (NIM)  │◀─┘│
│  │ 场景列表面板  │   │  Store    │   └─────────────────┘   │
│  └──────────────┘   │          │──▶ housePlay 同步方法    │
│                     └──────────┘                          │
└─────────────────────────────────────────────────────────┘

2.2 消息协议

带看通过 IM 传输的消息遵循统一的 TakeLookMessage 协议,分为状态类操作类两种:

状态类消息(连接初始化)

用于双方建立连接并完成初始视角同步:

interface TakeLookStateMessage {
  type: 'state'
  data: {
    type: 'connectState'
    state: 'initdone' | 'initstate' | 'initstatedone'
    data?: unknown  // initstate 时携带当前 3D 坐标与角度
  }
}

操作类消息 — 核心操作(相机同步)

用于实时同步相机移动、旋转等操作:

interface TakeLookCoreOperation {
  type: 'operation'
  data: {
    type: 'CORE_OPERATION'
    data: unknown  // SDK uploadLocalActions 事件的原始数据
  }
}

操作类消息 — UI 操作(场景切换、标尺等)

用于同步场景切换、标尺显隐等 UI 级操作:

interface TakeLookUIOperation {
  type: 'operation'
  data: {
    type: 'UI_OPERATION'
    data: {
      type: 'UI'
      target: 'switchScene' | 'ruler' | string
      action: unknown  // switchScene 时为场景对象,ruler 时为 boolean
    }
  }
}

2.3 连接初始化握手流程

双方通过三次状态消息完成初始视角同步:

发起方                                    接收方
  │                                         │
  │  SDK 资源加载完成                         │  SDK 资源加载完成
  │  (LocalProgramLoadingCompleted)          │  (LocalProgramLoadingCompleted)
  │                                         │
  │  IM 连接成功后补发                        │
  │──── initdone ──────────────────────────▶│
  │                                         │  收到 initdone
  │                                         │  获取当前 3D 坐标
  │◀──── initstate (携带坐标数据) ───────────│
  │                                         │
  │  收到 initstate                          │
  │  执行 firstSyncRemoteActions             │
  │  (同步到对方视角)                         │
  │                                         │
  │  firstSyncActionsCompleted 触发          │
  │  setConnectionStatus(true)               │
  │──── initstatedone ─────────────────────▶│
  │                                         │  收到 initstatedone
  │                                         │  setConnectionStatus(true)
  │                                         │
  │◀═══════ 实时视角同步开始 ═══════════════▶│

三、集成步骤概览

步骤 内容
1 准备 URL 参数,区分发起方(showtakelook=on)与接收方(takelook=on
2 集成 IM 通道(NIM / TIM 或自定义适配器)
3 在 SDK complete 回调中判断带看模式,注册带看事件并按角色启动
4 实现消息收发与协议处理逻辑
5 实现带看 UI:入口按钮、场景列表面板、操作权限蒙层
6 (可选)实现 iframe 嵌入的双端演示页面

以下按步骤展开。


四、第一步:准备 URL 参数

带看功能通过 URL 参数传递角色信息和 IM 凭证。你的业务后端需要为双方各生成一组参数。

4.1 参数列表

参数 必填 说明
m 模型 ID
showtakelook 发起方必填 设为 on 表示显示带看入口按钮
takelook 接收方必填 设为 on 表示以带看接收方模式启动
appid IM 服务的 AppKey / AppID
userid 当前用户的 IM 账号
sig 当前用户的 IM 鉴权签名 / Token
funcuserid 对方用户的 IM 账号(消息发送目标)

4.2 URL 示例

发起方(经纪人端):

https://your-domain.com/vr?m=模型ID&showtakelook=on&appid=YOUR_APP_ID&userid=agent001&sig=TOKEN_A&funcuserid=customer001

接收方(客户端):

https://your-domain.com/vr?m=模型ID&takelook=on&appid=YOUR_APP_ID&userid=customer001&sig=TOKEN_B&funcuserid=agent001

注意 useridfuncuserid互换的:发起方的 funcuserid 是接收方的 userid,反之亦然。


五、第二步:集成 IM 通道

带看需要一个 IM 通道来传输消息。本仓库提供了适配器模式,默认实现了网易云信 NIM 适配器,并预留了腾讯云 TIM 适配器接口。

5.1 IM 适配器接口

无论使用哪种 IM 服务,都需要实现以下接口:

interface IIMAdapter {
  /** 初始化并连接 */
  init(options: { appId: string; userId: string; userSig: string }): Promise<void>
  /** 发送消息给指定用户 */
  sendMessage(toUserId: string, data: TakeLookMessage): void
  /** 断开连接并清理 */
  destroy?(): void
  /** 监听事件(至少支持 'message' 事件) */
  on?(event: string, callback: (data: unknown) => void): void
}

收到消息时,适配器需要解析 IM 原始消息并 emit 一个 message 事件,数据格式为:

interface IMReceivedMessage {
  from: string              // 发送方 userId
  to?: string               // 接收方 userId
  content: TakeLookMessage  // 解析后的带看业务消息
}

5.2 使用网易云信 NIM(默认)

安装依赖:

npm install netease-nim-web-sdk

NIM 适配器的核心逻辑:

  1. 初始化:调用 NIM.getInstance 传入 appKey(即 appId)、account(即 userId)、token(即 sig),在 onconnect 回调中 resolve。
  2. 接收消息:在 oncustomsysmsg 回调中解析消息内容。NIM 的自定义系统消息 content 为 JSON 字符串,格式为 { type: 'VRContent', VRContent: '...' },需要两次 JSON.parse 提取出 TakeLookMessage
  3. 发送消息:调用 imInstance.sendCustomSysMsg,将 TakeLookMessage 序列化后包装为 { type: 'VRContent', VRContent: JSON.stringify(data) } 发送。
// 接收消息的解析逻辑
function parseNIMMessage(msg) {
  let content = JSON.parse(msg.content)  // { type: 'VRContent', VRContent: '...' }
  let business

  if (content.type === 'VRContent' && content.VRContent != null) {
    business = JSON.parse(content.VRContent)  // TakeLookMessage
  } else {
    business = content
  }

  return { from: msg.from, content: business }
}

// 发送消息的封装逻辑
function sendNIMMessage(imInstance, toUserId, data) {
  let msg = { VRContent: JSON.stringify(data), type: 'VRContent' }

  imInstance.sendCustomSysMsg({
    scene: 'p2p',
    to: toUserId,
    isPushable: false,
    needPushNick: false,
    content: JSON.stringify(msg),
    sendToOnlineUsersOnly: false,
    apnsText: JSON.stringify(msg),
    done: function(error) {
      if (error) console.warn('NIM send failed', error)
    }
  })
}

5.3 替换为其他 IM 服务

若需使用腾讯云 TIM 或其他 IM,只需实现 IIMAdapter 接口并替换适配器创建函数即可。适配器内部负责:

  1. 连接 IM 服务
  2. 将收到的原始消息解析为 IMReceivedMessage 格式并 emit message 事件
  3. TakeLookMessage 序列化后通过 IM 发送

六、第三步:在 SDK complete 中判断带看模式并启动

zqsdk.initcomplete 回调中,根据 URL 参数判断是否为带看模式,并按角色执行不同的启动逻辑。

complete: function() {
  let housePlay = window.housePlay
  if (!housePlay) return

  // 先注册基础事件(加载进度、UI 就绪等)
  registerBaseEvents(housePlay)

  let isTakeLookParam = getUrlParam('takelook') === 'on'      // 接收方
  let isShowTakeLookParam = getUrlParam('showtakelook') === 'on' // 发起方
  let isTakeLookMode = isTakeLookParam || isShowTakeLookParam

  if (isTakeLookMode) {
    // 注册带看事件(见第四步)
    initTakeLookEvents(housePlay)

    if (isTakeLookParam) {
      // ── 接收方 ──
      // 以 guid 模式启动,等待发起方同步视角
      housePlay.start('guid')

      // 自动初始化 IM
      let appid = getUrlParam('appid')
      let userid = getUrlParam('userid')
      let sig = getUrlParam('sig')
      let funcuserid = getUrlParam('funcuserid')

      if (appid && userid && sig && funcuserid) {
        initIM({ appid, userid, sig, funcuserid })
      }
    } else {
      // ── 发起方 ──
      // 正常启动,用户点击「带看」按钮后再建立 IM
      housePlay.start()
    }
  } else {
    // 非带看模式:正常启动
    housePlay.start()
  }
}

关键区别: - 接收方使用 housePlay.start('guid') 以引导模式启动,SDK 不会自动开始漫游,而是等待远端同步视角。 - 发起方使用 housePlay.start() 正常启动,用户可自由操作,点击带看按钮后才建立 IM 连接。


七、第四步:注册带看事件与消息处理

7.1 注册 housePlay 带看事件

在 housePlay 可用后注册以下带看专用事件:

function initTakeLookEvents(housePlay) {
  // 1. SDK 资源加载完成 → 发送 initdone 开始握手
  housePlay.on('LocalProgramLoadingCompleted', function() {
    sendMessage({
      type: 'state',
      data: { state: 'initdone', type: 'connectState' }
    })
  })

  // 2. 本地操作上传 → 发送 CORE_OPERATION 同步相机
  housePlay.on('uploadLocalActions', function(data) {
    sendMessage({
      type: 'operation',
      data: { type: 'CORE_OPERATION', data: data }
    })
  })

  // 3. 首次同步完成 → 设置连接状态并通知对方
  housePlay.on('firstSyncActionsCompleted', function() {
    housePlay.setConnectionStatus(true)
    sendMessage({
      type: 'state',
      data: { state: 'initstatedone', type: 'connectState' }
    })
  })

  // 4. 操作权限变化 → 控制「对方正在操作」蒙层
  housePlay.on('validOperatorChanged', function(operator) {
    if (operator === 'remote') {
      yourApp.showRemoteOperatingOverlay()
    } else {
      yourApp.hideRemoteOperatingOverlay()
    }
  })
}

7.2 处理收到的消息

IM 适配器收到消息后,按消息类型分发处理:

function handleReceiveMessage(msg) {
  let housePlay = window.housePlay
  if (!housePlay) return

  let content = msg.content

  // ── 操作类消息 ──
  if (content.type === 'operation') {
    // 设置操作权限为远程正在操作
    housePlay.setValidOperator('remote')

    if (content.data.type === 'CORE_OPERATION') {
      // 核心操作:同步相机视角
      housePlay.syncRemoteActions(content.data.data)
    } else if (content.data.type === 'UI_OPERATION') {
      let target = content.data.data.target
      let action = content.data.data.action

      if (target === 'switchScene') {
        // 场景切换
        housePlay.warpToScene(action)
      } else if (target === 'ruler') {
        // 标尺显隐
        yourApp.setRulerVisible(!!action)
      }
    }
    return
  }

  // ── 状态类消息(连接初始化握手) ──
  if (content.type === 'state' && content.data.type === 'connectState') {
    let state = content.data.state

    if (state === 'initdone') {
      // 对方资源加载完成,回复当前视角状态
      let currentState = housePlay.getCurrentState()
      sendMessage({
        type: 'state',
        data: { type: 'connectState', state: 'initstate', data: currentState }
      })
    } else if (state === 'initstate' && content.data.data != null) {
      // 收到对方视角,执行首次同步
      housePlay.firstSyncRemoteActions(content.data.data)
    } else if (state === 'initstatedone') {
      // 握手完成,开始实时同步
      housePlay.setConnectionStatus(true)
    }
  }
}

7.3 补发 initdone 的时机

发起方通常在用户点击「带看」按钮后才建立 IM 连接,此时 SDK 的 LocalProgramLoadingCompleted 事件可能已经触发过了。因此 IM 连接成功后需要补发 initdone,否则双方无法完成握手:

function initIM(options) {
  // ... IM 初始化 ...

  adapter.init({ appId, userId, userSig }).then(function() {
    // IM 连接成功

    // 如果 SDK 资源已加载完成,补发 initdone
    if (localProgramLoaded) {
      sendMessage({
        type: 'state',
        data: { state: 'initdone', type: 'connectState' }
      })
    }
  })
}

7.4 场景切换与标尺同步

在带看模式下,场景切换和标尺操作需要同时执行本地操作并发送 IM 消息给对方:

// 场景切换:本地跳转 + 发送消息
function switchScene(scene) {
  sendMessage({
    type: 'operation',
    data: {
      type: 'UI_OPERATION',
      data: { type: 'UI', target: 'switchScene', action: scene }
    }
  })
  housePlay.gotoScenebyId(scene.index)
}

// 标尺同步:仅在带看已连接时发送
function rulerOperation(open) {
  if (!isTakeLookConnected) return

  sendMessage({
    type: 'operation',
    data: {
      type: 'UI_OPERATION',
      data: { type: 'UI', target: 'ruler', action: open }
    }
  })
}

八、第五步:实现带看 UI

UI 完全由你自定义,建议具备以下三个组件:

8.1 带看入口按钮

显示条件:SDK 已就绪 + URL 含 showtakelook=on + 尚未建立带看连接 + 非讲房模式 + 非换装模式。

点击行为: 1. 从 URL 参数中获取 appiduseridsigfuncuserid 2. 调用 IM 初始化建立连接 3. (可选)通过 postMessage 通知父页面,由父页面为接收方生成 URL 并打开

function handleTakeLookBtnClick() {
  let appid = getUrlParam('appid')
  let userid = getUrlParam('userid')
  let sig = getUrlParam('sig')
  let funcuserid = getUrlParam('funcuserid')

  initIM({ appid, userid, sig, funcuserid }).then(function() {
    // 可选:通知父页面(iframe 嵌入场景)
    window.top.postMessage({
      msg: 'MessageFromIframePage',
      data: {
        appid: appid,
        userid: funcuserid,    // 对方的 userid
        sig: sig,
        funcuserid: userid     // 当前用户作为对方的 funcuserid
      }
    }, '*')
  })
}

8.2 场景列表面板

显示条件:带看已连接 + 场景列表不为空。

数据来源:从 housePlay.model 中提取场景列表。优先读取 settings.nestscenes.scenes,若不存在则回退到 model.heroLocations。过滤掉「总述」和脚本项。

function getScenesFromHousePlay(housePlay) {
  // 优先使用 nestscenes
  let scenes = housePlay.settings?.nestscenes?.scenes
  if (scenes && scenes.length > 0) {
    return scenes
      .filter(function(s) { return s.name && s.name !== '总述' && s.script !== 1 })
      .map(function(s, i) { return { name: s.name, index: i, id: s.id } })
  }

  // 回退到 heroLocations
  let heroLocations = housePlay.model?.heroLocations
  if (heroLocations && heroLocations.length > 0) {
    return heroLocations
      .filter(function(s) { return s?.name && s.name !== '总述' && s.script !== 1 })
      .map(function(s, i) { return { name: s.name, index: i } })
  }

  return []
}

交互:点击场景卡片时调用 switchScene(scene) 同时执行本地跳转和远端同步。

8.3 操作权限蒙层

显示条件:当 SDK 触发 validOperatorChanged 且 operator 为 'remote' 时显示全屏蒙层,提示「对方正在操作」;当 operator 变为 'local''free' 时隐藏。

蒙层应覆盖整个 VR 区域,设置 pointer-events: autocursor: not-allowed,阻止本地用户在远端操作期间进行交互。


九、第六步(可选):iframe 双端演示

在开发调试阶段,可以使用 iframe 在同一页面中同时嵌入发起方和接收方,模拟双端带看效果。

<!DOCTYPE html>
<html>
<head>
  <title>带看演示</title>
  <style>
    #app { display: flex; height: 80vh; padding: 40px; }
    #app > iframe { margin: 30px 100px; width: 1000px; }
  </style>
</head>
<body>
  <div id="app">
    <!-- 发起方 -->
    <iframe src="http://localhost:8080?m=模型ID&showtakelook=on&appid=APP_ID&userid=user_A&sig=TOKEN_A&funcuserid=user_B"></iframe>
    <!-- 接收方(初始为空,由 postMessage 动态设置) -->
    <iframe id="iframe2" src=""></iframe>
  </div>
  <script>
    window.addEventListener('message', function(event) {
      if (event.data.msg === 'MessageFromIframePage') {
        var data = event.data.data
        var frame = document.querySelector('#iframe2')
        frame.contentWindow.location.href =
          'http://localhost:8080?m=模型ID&takelook=on' +
          '&appid=' + data.appid +
          '&userid=' + data.userid +
          '&sig=' + data.sig +
          '&funcuserid=' + data.funcuserid
      }
    })
  </script>
</body>
</html>

流程: 1. 左侧 iframe 加载发起方页面,用户点击「带看」按钮 2. 发起方通过 postMessage 将对方的 IM 凭证发送给父页面 3. 父页面监听 message 事件,为右侧 iframe 拼接接收方 URL 并加载 4. 双方 IM 连接成功后自动完成握手,开始实时同步


十、API 速查表

10.1 URL 参数

参数 说明
showtakelook on 发起方:显示带看入口按钮,正常启动
takelook on 接收方:以 guid 模式启动,自动初始化 IM
appid 字符串 IM 服务的 AppKey
userid 字符串 当前用户的 IM 账号
sig 字符串 当前用户的 IM 鉴权 Token
funcuserid 字符串 对方用户的 IM 账号

10.2 housePlay 方法(带看相关)

方法 说明
housePlay.start('guid') 以引导模式启动(接收方使用),等待远端同步
housePlay.getCurrentState() 获取当前 3D 坐标与角度(用于初始同步)
housePlay.firstSyncRemoteActions(state) 首次同步远端视角(收到 initstate 时调用)
housePlay.syncRemoteActions(actions) 实时同步远端操作(收到 CORE_OPERATION 时调用)
housePlay.setConnectionStatus(true) 设置带看连接状态为已连接
housePlay.setValidOperator(operator) 设置操作权限:'local' / 'remote' / 'free'
housePlay.warpToScene(scene) 跳转到指定场景(收到 switchScene 时调用)
housePlay.gotoScenebyId(index) 按索引跳转场景(本地主动切换时调用)

10.3 housePlay 事件(带看相关)

事件 回调参数 说明
LocalProgramLoadingCompleted SDK 资源加载完成,应发送 initdone 开始握手
uploadLocalActions data 本地相机操作数据,需通过 IM 发送给对方
firstSyncActionsCompleted 首次视角同步完成,应设置连接状态并通知对方
validOperatorChanged operator 操作权限变化:'remote' 时显示蒙层,其他时隐藏

10.4 消息类型速查

消息类型 data.type 用途
state connectState 连接初始化握手(initdone → initstate → initstatedone)
operation CORE_OPERATION 实时相机视角同步
operation UI_OPERATION UI 操作同步(场景切换、标尺等)

十一、常见问题与注意事项

  1. IM 连接时机与 initdone 补发
    发起方在用户点击按钮后才建立 IM,此时 LocalProgramLoadingCompleted 可能已触发。IM 连接成功后必须检查并补发 initdone,否则握手无法完成。

  2. start('guid') 与 start() 的区别
    接收方必须使用 start('guid') 以引导模式启动,SDK 不会自动漫游而是等待远端同步视角。发起方使用 start() 正常启动。若接收方误用 start(),双方视角会不一致。

  3. userid 与 funcuserid 互换
    发起方的 funcuserid 就是接收方的 userid,反之亦然。业务后端生成 URL 时需注意正确配对。

  4. 操作权限管理
    SDK 通过 validOperatorChanged 事件自动管理操作权限。当远端正在操作时,本地应显示蒙层阻止交互。不要手动调用 setValidOperator 来覆盖 SDK 的自动管理,除非在收到远端操作消息时需要将权限设为 'remote'

  5. 标尺同步
    在带看模式下切换标尺时,除了调用 SDK 的 enablePanoramaSize / disablePanoramaSize 控制本地显示外,还需通过 rulerOperation 发送 UI_OPERATION 消息同步给对方。

  6. 场景列表数据源
    场景列表优先从 housePlay.settings.nestscenes.scenes 获取(新版数据),若不存在则回退到 housePlay.model.heroLocations(旧版数据)。两者都需过滤掉 name === '总述'script === 1 的项。

  7. NIM 消息格式
    网易云信 NIM 使用自定义系统消息(sendCustomSysMsg),消息内容需要包装为 { type: 'VRContent', VRContent: JSON.stringify(业务数据) } 格式。接收时需要两次 JSON.parse。若替换为其他 IM,消息封装格式可自行定义,只需保证解析后为 TakeLookMessage 即可。

  8. postMessage 安全性
    iframe 嵌入场景中使用 postMessage 通知父页面时,生产环境建议将 '*' 替换为具体的 origin,避免安全风险。


十二、本仓库实现参考

若需参考本仓库的具体实现,可查阅以下文件:

文件 说明
src/types/takelook.ts 带看消息协议类型定义
src/stores/takelook.ts 带看 Store:状态管理、事件注册、IM 初始化、消息收发
src/plugins/im/types.ts IM 适配器接口定义
src/plugins/im/BaseIM.ts IM 适配器基类(基于 EventEmitter)
src/plugins/im/NIM/nim-adapter.ts 网易云信 NIM 适配器实现
src/plugins/im/TIM/tim-adapter.ts 腾讯云 TIM 适配器(预留,暂未实现)
src/plugins/im/index.ts IM 插件入口,导出适配器创建函数
src/modules/pc/takelook/NestTakelook.vue 带看模块根组件,编排按钮、蒙层、面板
src/modules/pc/takelook/NestTakeLookBtn.vue 带看入口按钮组件
src/modules/pc/takelook/NestTakeLookPanel.vue 带看场景列表面板组件(支持拖拽滚动)
src/modules/pc/takelook/NestTakeLookOverlay.vue 「对方正在操作」蒙层组件
src/stores/vr.ts VR Store 中带看模式的判断与启动逻辑
src/stores/toolbar.ts 工具栏 Store 中标尺同步带看的逻辑
src/stores/url-params.ts URL 参数解析(含带看相关参数)
takelook-demo.html iframe 双端演示页面