如果把三维系统只理解成“把模型渲染出来”,那它永远只能停留在 Demo 阶段。真正进入业务使用时,用户很快会提出一串更细、更硬、更不讲情面的要求:控制页和预览页要分开,预览端必须干净,控制端要像个真正可操作的工作台,控制权要明确,网页组件既要能交互又不能影响镜头,模型可被拖动但不能在某些页面乱跑,切换视角时镜头不能抖,双击模型不能把坐标轴弹给观看者看,菜单要暗色、要瘦、要贴近旧版,但又不能把旧版的包袱全背过来。
这篇文章要复盘的,就是我们怎样把这些看似零碎的要求,最终压缩成一套真的能交付、能演示、能让用户复制链接去试的控制与预览系统。它不是纸面上的架构设计,而是一轮轮真需求、真页面、真联调、真回归中被反复打磨出来的结果。文章里我会尽量把实现逻辑、代码片段、踩坑路径和决策依据都摊开写清楚,不写空泛的“最佳实践”,只写这次系统里真正发生过、真正影响体验和交付的部分。
为了方便你边看边测,这篇文章里我也直接放上两个在线地址。一个是控制地址,一个是预览地址。你可以同时打开两个窗口,一边拖动模型、一边切视角、一边看预览端如何跟随,就能更直观地理解下面提到的每一个问题为什么值得花这么多时间去修。
一、这次要解决的不是“实时同步”四个字,而是三类人同时能用
在很多技术讨论里,一说到控制页和预览页,大家第一反应都是协议、广播、角色、状态同步。但这次系统真正的难点,并不在于“有没有 WebSocket”,而在于要同时让三类人都用得顺。第一类是操作的人,他需要一个高响应、低误操作、操作语义明确的控制页面。第二类是观看的人,他不关心怎么控制,他只想看到稳定、干净、没有杂质的预览画面。第三类是交付的人,也就是把系统部署出去的人,他需要一条尽可能短的启动链路和尽可能容易解释的会话规则。
这三类人的目标并不一样。操作者讨厌界面啰嗦,观看者讨厌提示太多,交付者讨厌架构太重。如果只站在其中一类人的角度设计系统,就会自然把另外两类人坑到。比如如果只考虑开发者视角,最容易做出的是“什么都可配,什么都可控”的后台式控制页,但这恰恰会让预览端不够纯、操作者不够快。又比如如果只考虑展示效果,直接把预览端做成一个巨型只读画布,可能很漂亮,但控制链路、控制权、网页组件交互这些真实需求就会立刻失焦。
所以这次系统一开始,我就不把目标写成“做一个 live 页面”,而是写成“做一个控制页和预览页各司其职、但通过同一会话协作的系统”。这句话听起来只是措辞不同,但它决定了后面很多实现都不会走偏。因为当你脑子里一直记着“不是一个页面,而是两个职责不同但会联动的页面”,你就更容易对那些模糊地带下手,比如是否显示坐标轴、是否允许拖动、是否保留右侧菜单、是否要顶部提示、是否让预览端也具备页面级交互。
二、为什么最后不再要求先创建 Live 会话,而是直接靠 URL 进入
旧思路里,很多系统会先做一个会话列表页,再做一个创建按钮,再给每个会话分配 ID,控制页和预览页再从那个 ID 进入。这种方式并不是错,它对于偏平台型系统很合理,因为运营或管理员确实可能需要看到全部会话、管理会话历史、甚至做留存统计。但当前这个产品阶段,用户最明确的反馈不是“我要管理很多会话”,而是“不要先单独创建会话,控制页和预览页直接通过 URL 参数进入同一个会话”。
这其实是一个非常重要的产品信号。它说明用户在意的是低启动成本,而不是平台感。对他来说,一个会话存在的意义只是“让两个页面进到同一个同步空间”,而不是成为一个需要先注册、再查询、再跳转的业务对象。于是我们后来把会话入口改成了参数化形式:只要 URL 里带 `sceneId` 和 `channel`,后端就能根据这两个参数计算出同一个会话语义,不再需要先去创建一个独立记录。
const sessionId = computed(() => String(route.params.sessionId || route.query.sessionId || ''))
const channel = computed(() => String(route.query.channel || ''))
const boundSceneId = computed(() => Number(route.query.sceneId || 0))
function resolveLiveEntry() {
return {
sessionId: sessionId.value,
sceneId: boundSceneId.value,
channel: channel.value,
}
}
这种改法最大的收益不在技术,而在于解释成本骤降。你不用再告诉用户“先去列表页新建会话,再把链接发给别人”。你只需要告诉他:控制页和预览页把 `sceneId` 和 `channel` 保持一致,就是同一个会话。这条规则简单到几乎不需要文档。工程上越是接近“看一眼就懂”,后续联调、交付和培训越轻松。
三、控制页不是后台页,而是一个高频操作工作台
第二版控制页刚开始并不好看。这不是因为颜色不够黑,也不是因为按钮不够多,而是因为它还残留着很强的后台拼装感。卡片一块一块堆上去,每块都在说“我也是一个信息分区”,结果操作者真正高频用的内容反而被淹在结构感里。用户很快就指出:菜单样式和功能呈现尽量贴近第一版,不要生硬的卡片堆叠感,不显示“操作模式”,不显示“会话状态”,菜单放右侧,按钮统一暗色,不要白色按钮,未选中的开关也别亮得像默认组件。
这些反馈如果只当成视觉细节去处理,很容易落成一轮 CSS 修补。但我后来越来越确定,这实际上是在重设控制页的页面定位。控制页不是后台信息页,它是一个高频操作工作台。工作台的核心标准不是“信息分区完整”,而是“手到哪里、眼就到哪里、反馈立刻可读”。所以我在结构上做了几件事。第一,把菜单固定在右侧,让左侧画面永远是视觉主体。第二,把不需要操作的静态身份信息直接移除,不再占布局。第三,把真正会操作的部分收拢成窄而连续的 block,而不是视觉上彼此割裂的卡片。
控制页最后看起来更像一条“控制条带”,而不是一列“系统配置表单”。这不是术语游戏,而是非常直接地影响操作者效率。一个需要频繁点模型、切视角、开关动画、拖爆炸、调缩放的人,不会希望自己像在填后台管理表单。越接近工作台,越适合演示;越接近后台,越容易拖慢节奏。
<div class="control-viewport">
<EditorViewport
ref="viewportRef"
:readonly="!canControl"
:restrict-translate-to-plane="true"
:readonly-webpage-interactive="false"
initial-tool-mode="none"
/>
</div>
这段模板非常短,但它背后体现的正是这个思路:控制页里最重要的并不是菜单自己,而是菜单旁边的视口;菜单只是为视口服务,视口才是主舞台。
四、为什么要保留第一版的能力感,但不能照抄第一版的结构
用户反复强调第二版控制页要尽量贴近第一版,这个要求非常合理。因为第一版是长期打磨过的,用户已经形成操作记忆,尤其是镜头预设、镜头移动、模型控制这些能力的布局语义,已经被使用习惯验证过了。但“贴近第一版”绝不等于“把第一版 DOM 结构原样复制过来”。因为第二版的前提已经变了。它不仅要控制,还要同时服务控制权、只读退化、网页交互开关、场景视角兼容等新需求。
因此我最后采用的策略不是复制,而是抽能力、换表达。比如镜头移动区域保留了方向语义,但排布被改成更紧凑的 `4 x 2`,并让 `0` 按钮放在右箭头右侧,这是用户明确提过的。镜头预设保留了主镜头、细节、俯览、侧视的能力,但按钮样式收成统一暗色的紧凑网格,而不是把老版按钮视觉直接硬搬过来。场景视角能力也保留了,但数据读取兼容了 `cameraViews || views`,避免旧场景数据结构直接失效。
const cameraViews = computed(() => {
const sceneViews = sceneStore.currentScene?.cameraViews || sceneStore.currentScene?.views || []
if (sceneViews.length > 0) return sceneViews
return (sceneStore.currentScene?.animations || []).filter(item => item.type === 'camera-view')
})
这类兼容代码很不起眼,但它的价值很高。因为控制页不是一个新建空白页面,它要吃已经存在的大量场景数据。如果为了新 UI 强迫老数据也跟着改结构,成本会远比多写一层兼容高得多。真实工程里,最好的重构往往不是最激进的,而是能让旧资产继续工作的新表达。
五、预览页为什么必须极简,而且要有一种“它本来就不该说话”的克制
预览页一开始不是完全干净的。它顶部有过标题框、有过“Live 预览”之类的提示,也出现过控制者、模式状态这种开发者觉得有帮助、观看者却觉得打断画面的信息。用户很明确地指出:预览页面顶部不要显示 `Live 预览` 之类的标题框,预览页面保持纯预览画面,不显示多余提示信息。这里其实不只是审美偏好,而是观看角色的基本需求。
预览页的用户不是来学习这个系统怎么工作的,他是来看内容的。他关心模型、场景、镜头、热点和网页组件,不关心背后是哪一个客户端拿着控制权,也不关心当前工具模式是 `rotate` 还是 `translate`。这些信息对操作者有意义,对观看者大多是噪声。如果预览页顶部始终悬着一层框,你会发现它在任何真正的展示场景里都像一条多余的字幕。
所以预览页最后做了一个非常彻底的裁剪:不显示可见工具栏,不显示变换控件,不显示顶部标题,不显示申请控制,不显示接管模式,不显示任何对观看无直接帮助的信息。它只接受来自控制页和后端的运行时状态,然后尽量平滑地把它还原到画面里。你几乎可以把它理解成“一个有状态但没有存在感的渲染终端”。这句话听上去有点极端,但我恰恰觉得这是预览页最理想的状态。存在感越低,内容感越强。
六、控制权为什么后来从“立即生效”又补回了“申请同意”这条链路
这次项目中关于控制权的需求并不是一开始就稳定的。早期单模型那条轻量链路里,用户更偏向“谁申请谁立即成为控制者”,因为那时重点是去掉复杂流程,尽量轻。但在 8000 主版第二版控制页里,用户后来又明确提出:控制权流程需要真正可用,用户申请后点击同意必须生效,申请控制后当前控制者侧需要弹出明显的同意/拒绝提示框,不要把待处理申请放在菜单最底下。这说明系统所处的使用环境已经发生变化。
当一个系统开始进入更正式的协作场景,立即抢占虽然快,但未必合适。尤其当当前控制者正在演示,另一端发起接管时,如果没有一个足够显眼的确认流程,体验会显得很粗暴。所以后来我们在 live 主版里补回了“申请-同意/拒绝”这条链路。但这里有一个非常重要的实现原则:既然要做同意,就必须做成真正可见、真正能完成闭环的同意,而不是把待处理请求塞在侧边栏最底部,期待操作者自己注意到。
最后的实现方式,是当前控制者侧在收到 `pendingRequesterId` 后,直接弹出一个黑色主题的 `ElMessageBox.confirm`。它会明确告诉当前控制者哪一个客户端正在申请控制,并提供“同意”和“拒绝”按钮。这里我刻意没有让这个提示弱化成角落通知,因为控制权本身就是一个强操作,它理应获得强反馈。
async function promptControlRequest(requesterClientId) {
if (!requesterClientId || activeRequestPromptId.value === requesterClientId) return
activeRequestPromptId.value = requesterClientId
try {
await ElMessageBox.confirm(
`客户端 ${requesterClientId} 正在申请控制权限,是否同意?`,
'控制权申请',
{
customClass: 'live-control-request-dialog',
confirmButtonText: '同意',
cancelButtonText: '拒绝',
closeOnClickModal: false,
closeOnPressEscape: false,
}
)
liveSessionStore.approveControlRequest(requesterClientId)
} catch {
liveSessionStore.rejectControlRequest(requesterClientId)
} finally {
activeRequestPromptId.value = ''
}
}
从体验结果看,这个决策是对的。因为它同时满足了两个条件:一是当前控制者不会错过请求;二是申请者得到的确实是一条真实可用的流程,而不是一个看起来像有审批、实际没人注意的伪流程。控制权这类能力一旦做,就必须做成真家伙。
七、黑色弹框和暗色控件不是装饰,而是在统一心智
用户反复提到黑色主题统一,包括控制页按钮不要白色、开关未选中状态也保持暗色、编辑页“添加网页组件”弹框也要统一成黑色风格、控制权申请弹框样式也要统一成黑色风格。很多团队做界面收口时,容易把这种反馈归到“最后再抛给 UI 调一下”。但这类系统里,颜色和控件状态本身就承担了操作语义。如果控制页主体是暗色、但弹框忽然跳出一套白底默认 Element 风格,用户会立刻感到自己被拉离了当前工作上下文。
尤其是控制权这种强交互流程,它不只是要出现,还要在视觉上被识别成“这个动作属于当前系统”。所以我们后来不仅把按钮和开关样式收暗,还单独给控制权弹框定义了黑色主题的 `customClass`,包括背景、边框、文字、按钮 hover、active 效果都重新压了一遍。目的不是炫酷,而是保证页面的每一种关键反馈都像出自同一个产品,而不是三个 UI 库拼出来的临时产物。
统一视觉在这里带来的另一个隐性收益,是用户更容易形成“哪些元素可操作”的经验。当按钮、开关、单选框的表现方式稳定一致时,操作者会更快知道哪里该点、点下去会发生什么、当前状态是启用还是禁用。这种低认知负担,在频繁演示场景里非常值钱。
八、网页交互为什么是一个比看起来麻烦得多的问题
如果系统里没有网页组件,控制页与预览页的大部分问题都仍然只是 3D 问题。但一旦场景里允许嵌网页,事情马上会变复杂。因为网页组件本质上是一个嵌在 3D 空间里的 iframe。用户想点里面的内容时,视口本身就不应该抢鼠标;但用户想拖动镜头时,又不能因为 iframe 存在而彻底失去视角控制。这个矛盾如果处理不好,体验会非常撕裂:要么网页能点但镜头废了,要么镜头能转但网页死了。
这次的明确要求是:控制页面需要保留网页交互开关,但预览页面不要再放额外开关。也就是说,控制端既是配置入口,也是本地行为的即时生效端;预览端只需要跟随控制端给出的状态。这里最大的坑在于,一开始如果只把这个功能做成 UI 开关,而没有把它完整接进 runtime state 和 WebSocket 链路,那么你会得到一个“开关看起来能切,但只有本页部分生效”的半残能力。
后来真正修通它,是补齐了前端、预览端和后端三层逻辑。前端控制页增加 `webpageInteractive` 开关,并发出 `set-webpage-interaction` 事件;`EditorViewport.vue` 新增 `forcedWebpageInteractive` 作为本地强制态,确保控制端自己先立即生效;后端 `runtimeState` 和 `ws.go` 补上 `webpageInteractive` 字段与广播事件,保证预览端能从 snapshot 和增量消息两条路都拿到最新状态。
const forcedWebpageInteractive = ref(false)
function onRemoteControlCommand(event) {
const cmd = event?.detail || {}
if (!cmd.type || !currentScene.value) return
if (cmd.type === 'set-webpage-interaction') {
forcedWebpageInteractive.value = !!cmd.enabled
updateWebpageIframeInteraction(null, !!cmd.enabled)
return
}
}
这段代码看起来只是多了一个 `forcedWebpageInteractive`,但它非常关键。因为之前控制端开关不生效,根因就是本地视口逻辑仍然只认组件配置里的 `interactive`,没有一个 live 运行态层面的覆盖入口。很多实时系统问题都类似:不是功能没做,而是“本地即时态”和“远端同步态”之间少了一层桥。
九、为什么这个网页交互问题必须同时从本地即时生效和远端同步生效两边修
修网页交互时,一个很容易犯的错误是只盯着预览端。因为用户一说“开关不同步”,大家自然会去查消息有没有发过去、预览页有没有收到。但这次问题更微妙。控制端本地其实也不完全生效。也就是说,即便后端同步完全正常,控制端操作者自己打开开关后,页面里的 iframe 也未必马上能用。这个时候如果只修同步链路,用户仍会觉得功能坏了一半。
所以后来我处理这类问题的原则变得非常明确:凡是 live 交互能力,先保证控制端本地即时生效,再让预览端跟随。因为控制端是那个发起行为的人,他必须第一时间感知到变化。如果连发起者本地都不生效,那后面的同步就是空中楼阁。只有当控制端先真切地“变了”,后端广播和预览跟随才有意义。
这个经验不只适用于网页交互,也适用于其他很多 live 能力。比如控制模式切换、模型动画开关、爆炸恢复、热点打开。真正好用的控制系统,不是“发了一条消息”,而是“当前控制者眼前先发生了结果,然后其他页面再被拉齐”。顺序如果反过来,用户很容易产生迟疑和不信任。
十、相机同步的最大敌人不是延迟,而是本地自动逻辑抢控制
这次系统里一个很典型的坑,是相机明明已经在发同步了,预览端也在收,结果控制端还是会给人一种“视角老是被扯一下”的感觉。第一直觉可能会怀疑是后端把 `camera:update` 又回发给了自己,或者预览端回写了相机状态。但仔细排查后发现,根因并不是单纯的消息回环,而是控制端本地还有一堆自动逻辑也会动相机,比如选中模型后自动聚焦、某些模式切换时自动对准、镜头预设切换后的残余过渡,甚至 OrbitControls 自己的变化回调和自动跟焦逻辑之间也会互相抢。
这类问题的麻烦在于,表面现象非常像网络抖动,但根因其实在本地运行时。后来我们在 `EditorViewport.vue` 里给控制端加了一个“自动相机抑制窗口”思路,用 `markAutoCameraSuppressed()` 在用户真实拖动 OrbitControls 或刚结束拖动的一小段时间里,压住那些自动相机行为,避免它们在用户操作最密集的时候反向介入。
function markAutoCameraSuppressed(durationMs = 600) {
suppressAutoCameraUntil = performance.now() + durationMs
}
controls.addEventListener('start', () => {
orbitInteractionActive = true
markAutoCameraSuppressed(1200)
cameraTransition.active = false
})
controls.addEventListener('end', () => {
orbitInteractionActive = false
markAutoCameraSuppressed(800)
scheduleLiveCameraSync(true)
})
真正值钱的不是这几个时间参数,而是排查思路:不要一看到实时抖动就只盯着 socket。很多时候,网络层其实没错,是本地视口里的自动逻辑在跟用户抢方向盘。只要这点认错了方向,你会在协议层里绕很久,却始终改不出手感。
十一、预览端为什么不能直接暴露 TransformControls
预览端早期也出现过双击模型后显示坐标轴的问题。对于开发者来说,这只是一个 Three.js helper 是否挂上来的小事;对观看者来说,这就是一个很明显的“为什么我看到的是编辑器而不是展示页”的破绽。预览页一旦露出 TransformControls 的坐标轴,就等于把幕后工具层掀开给观众看了。
所以后面我们在预览页逻辑上做得很明确:预览端只跟随定位和移动视角,不暴露变换控件。双击模型如果要有反馈,也只能是聚焦或相机跟随,不应该出现 XYZ 轴、拖拽手柄或其他“可编辑”的视觉暗示。因为预览端的角色不是编辑,也不是控制,而是观看。只要视觉里出现可编辑控件,用户就会潜意识认为“这个页面也能动东西”,从而打破角色边界。
这背后其实是一个常见但容易被忽略的 UI 原则:不要给用户看见他没有权限使用的强交互器。它不仅会引起误解,还会让画面显得不专业。一个好的展示页应该让控制层存在于控制页,不在预览页偷露出来。
十二、模型平移为什么后来要“只在控制页限制前后移动”,不能误伤编辑器页
用户提出过一个非常明确的交互约束:控制页面里的模型平移,只允许上下左右,不允许前后方向移动。这是一个很强的演示场景约束,目的是避免操作者把模型沿视深方向拖乱,影响观看理解。从 live 控制页角度看,这个限制是合理的。但后来新问题马上出现了:编辑器里的模型也不能前后移动了。也就是说,我们在实现控制页限制时,不小心把通用视口层也一并改死了。
这个问题的本质,是把页面级约束写成了通用组件级约束。最初的实现里,`EditorViewport.vue` 在平移模式下统一 `showZ = false`,并在拖动期间强制锁 `translateLockZ`。从代码层看它很省事,因为所有页面都复用同一个视口组件;但从产品层看它明显不对,因为编辑器页和控制页的交互边界根本不同。编辑器页是创作工具,本来就应该允许完整的三维平移;控制页才需要人为收窄成更安全的演示语义。
最后的修复方式不是再加条件分支去判断路由,而是给 `EditorViewport` 增加了一个显式 prop:`restrictTranslateToPlane`。只有 `LiveControlPage` 传入这个 prop 时,平移模式才隐藏 Z 轴、才锁定 `translateLockZ`。这样控制页保留了“只允许上下左右”的约束,编辑器页则恢复正常三维平移。这个改法很小,但非常符合组件边界原则:通用组件不要偷带页面立场,页面需要的特殊语义由页面显式声明。
const props = defineProps({
readonly: { type: Boolean, default: false },
restrictTranslateToPlane: { type: Boolean, default: false },
})
function syncTransformAxisVisibility() {
if (!transformControls) return
if (props.restrictTranslateToPlane && transformControls.getMode() === 'translate') {
transformControls.showX = true
transformControls.showY = true
transformControls.showZ = false
return
}
transformControls.showX = true
transformControls.showY = true
transformControls.showZ = true
}
这次修复给我的提醒很直接:共享组件越多,就越要警惕“顺手把特殊需求塞进通用层”。因为你当下为了快写进去的每一个限制,都可能在另一个页面变成副作用。好的组件抽象不是把所有页面需求揉成一锅,而是让真正特殊的地方保持可开关、可声明。
十三、镜头移动按钮为什么看起来只是布局问题,实际上是在修操作节奏
用户对镜头移动区域提过一串很细的要求:前后移动要保留,对应 `+` 和 `-`;`0` 按钮放在右箭头右侧,不使用三行布局;按钮整体视觉继续向第一版靠拢。看上去像是在说排版,但背后其实是在调整操作节奏。控制页的镜头移动按钮不是一个偶尔点一下的低频功能,它在演示里可能被连续按、连续点、连续触发。这个时候按钮位置、读法、指尖路径、视觉密度都会影响手感。
我们最后把它做成一个 `4 x 2` 的紧凑网格,把加号、上移、减号排在第一行,把左、下、右、归零排在第二行。这样的好处不是“好看”两个字,而是它让用户在极短时间里建立了按键语义:第一行是纵深和上移,第二行是平面移动与复位。它不像传统十字键那样完全围绕一个中心,但更适合当前这套镜头语义,而且也更贴合用户已经明确提出的 `0` 位置要求。
这再次说明一个工程里很常见的事实:很多最终改善操作效率的改动,并不是引入了多高级的算法,而是把一个经常会用的控件摆到了真正顺手的地方。频繁操作区的布局,和核心逻辑一样值得认真对待。
十四、场景视角兼容、模型动画开关、爆炸、缩放,这些“普通功能”为什么也要写进控制页复盘
有人可能会觉得,场景视角、模型动画开关、爆炸、缩放这些能力不算这次最难的点,为什么也要花笔墨写进文章。原因很简单:控制页不是凭空存在的。它之所以能被用户接受,恰恰是因为它不仅解决了控制权和同步,还补齐了“原来就该有”的控制能力。如果第二版控制页只修好了协议、却缺掉原有控制能力,用户同样会觉得它不可用。
所以我们在这轮改造里持续补齐了几个非常关键的能力层:模型选择、轴动画开关、模型动画开关、爆炸距离和爆炸开关、统一缩放、复原当前模型、镜头预设、场景视角。很多能力表面上只是一个开关或一个 slider,但每个能力背后都要同时考虑三件事:控制端本地是否立即生效、是否需要同步到预览端、是否要从 snapshot 中恢复。只要漏一个维度,系统表现就会前后不一致。
举个例子,爆炸和复原就不是单一字段切换那么简单。关闭爆炸时用户还明确要求“只在恢复时回中心一次”,这就意味着除了状态本身,你还要处理相机与复位的关系。再比如模型动画和轴动画虽然都属于动画开关,但目标对象、数据来源和同步方式并不完全相同,做得草率一点就会让某些模型“看起来开了,实际上没动”。这些能力如果不被认真写进复盘,文章就只会停留在讲协议,无法解释为什么用户最终觉得这套控制页“能用了”。
十五、为什么 live 系统里的 snapshot 不是兜底,而是会话一致性的基础
很多实时系统刚开始会优先做增量消息,因为直觉上它更实时、更轻。但一旦页面允许后进入会话,或者出现刷新重进、控制权切换、网络短断恢复,这时只有增量消息是不够的。后进入的人不可能靠一串历史操作重演出当前状态,他需要一个当前时刻的完整快照。控制页和预览页也一样。如果没有 snapshot,后打开预览页的人看到的很可能是初始场景,而不是已经被控制端操作过的场景。
这就是为什么这次系统里,我一直把 runtime snapshot 看作基础设施,而不是兜底补丁。它要能带上当前场景、镜头、模型变换、爆炸状态、动画状态、网页交互状态等足够多的信息,让一个新进页面拿到它之后,能迅速重建出“现在就是这样”的运行时画面。然后在这个基础之上,增量消息继续负责后续变化。
真正可靠的实时系统,往往不是纯增量或纯全量,而是“当前状态靠快照对齐,连续手感靠增量补齐”。两者缺一不可。只做增量,晚进者会迷路;只做快照,连续操作会僵。系统稳定感,往往就藏在这种分工里。
十六、后端改动其实不大,但必须改在真正决定行为的地方
这次后端主要改动集中在 `editor-backend/api/live/v1/live.go` 和 `editor-backend/internal/logic/live/ws.go`。如果只看代码行数,它们不算大改;但从行为角度看,每个字段都很关键。比如加上 `webpageInteractive`,补上 `set-webpage-interaction` 的处理,或者让 `sceneId + channel` 这套参数真正能在 WebSocket 层识别成同一会话。后端问题最怕的不是“没写完”,而是“看起来有接口,实际上真正决定广播和恢复状态的地方没接上”。
我在这次项目里最大的警惕就是:不要被 UI 已经出现、API 已经返回这种表象骗了。真正决定系统行为的,是状态是否穿透到了 runtime、是否被广播、是否被 snapshot 持久表达、是否在新连接进入时还能恢复。只要后端链路里漏一环,前端再漂亮也只是在做幻觉。
function toggleWebpageInteraction(enabled) {
webpageInteractive.value = !!enabled
sendRuntimeCommand('set-webpage-interaction', { enabled: !!enabled })
liveSessionStore.sendEvent('set-webpage-interaction', { enabled: !!enabled })
}
这段前端代码为什么能工作,不是因为它自己做了两次发送,而是因为后端确实有能力吃下这个事件、修改 runtime state、再把新状态同步给其他客户端。前后端任何一端不完整,这个函数都只是一段看起来很努力的前端表演。
十七、为什么构建通过不代表线上生效,尤其是 8000 这种前后端合一的静态服务模式
这次还有一个特别容易被忽略的现实问题:前端构建通过,不代表线上实际运行的 8000 服务就已经是新逻辑了。因为当前主版前端产物是输出到 `editor-backend/resource/public`,再由 Go 服务直接提供静态资源。这意味着你本地把 Vite build 成功,只说明产物生成了,不说明当前正在运行的 8000 进程已经加载了新的后端 WebSocket 逻辑,更不说明线上浏览器拿到的一定是新资源。
像 `set-webpage-interaction` 这种功能,就会典型地暴露出这个问题:前端代码已经加了开关、本地构建也过了,但如果运行中的后端进程没重启,老的 `ws.go` 根本不认识这个事件,预览端当然不会同步。这个时候如果不把“运行进程版本”纳入排查范围,就很容易误判成前端没写对或者 socket 没连上。
这也是为什么这轮开发里,我始终把“本地先修、先验证思路、未经允许不提前上传服务器”当成执行原则。因为像这种前后端耦合的 live 链路,只要线上环境里混着旧进程、旧静态资源、旧缓存,你会非常难判断问题究竟是逻辑错了还是部署没生效。先把本地闭环打透,再决定何时上服务器,效率反而更高。
十八、这次复盘里最值得带走的工程方法,不是某个函数,而是三条排查顺序
如果要从这轮控制页和预览页开发里总结最可复制的方法,我会提三条排查顺序。第一条,遇到 live 体验不对时,先分清是本地即时态错了,还是远端同步态错了。不要一上来就怀疑 WebSocket。第二条,遇到视口交互不对时,先分清是页面级语义写错了,还是通用组件误伤了别的页面。不要把所有限制都写进公共层。第三条,遇到“明明改了代码但没生效”时,先检查运行中的前后端版本与静态资源是否真更新了,不要只看 build 是否成功。
这三条听起来不像代码技巧,但它们在项目里极其节省时间。很多工程返工,不是因为不会写,而是因为排查顺序一开始就错了。顺序一错,你会在错误的层里越挖越深。顺序一对,很多问题反而会在前几步就露出原形。
十九、如果再做一遍,我会更早把哪些边界画死
现在回头看,如果让我重来一次,我会更早把几条边界写死。第一,预览页从第一天起就保持纯预览,不要带任何顶部提示试探。第二,所有 live 运行态开关都默认要求“控制端本地即时生效 + 远端可同步 + snapshot 可恢复”三项同时满足,少一项都不算完工。第三,控制页对视口的特殊限制,比如二维平移约束,必须一开始就走显式 prop,而不是先写死在公共视口里。第四,涉及控制权的流程要么就极简抢占,要么就做成真实审批,中间态最伤体验。
这些边界如果更早明确,后面很多修修补补都能少一半。工程里最消耗人的从来不是写一段功能代码,而是反复拆掉那些一开始没想清楚的模糊地带。边界画得越清楚,系统越容易在连续需求里保持稳定形状。
二十、这套系统现在到底能拿去做什么
写到这里,可能还有人会问:所以这套控制页和预览页系统,除了“技术上做通了”,实际能拿去干什么?我的答案非常直接:它已经是一套可以放进真实业务链路里的演示与讲解工具。你可以用它做设备三维讲解,控制端一边切镜头一边打开网页组件给客户看参数;可以用它做销售演示,把链接发给观看者,预览端只看画面,控制端掌握节奏;可以用它做数字孪生展示,控制者在后台操作多个模型动作和热点,观看者只看到稳定的场景变化;也可以把它当成主编辑器之外的一条“更窄、更快、更适合演示”的出口。
它真正有价值的地方,不是功能数量比别人多,而是几个常见但总被忽略的痛点都被认真压下去了:控制权有明确语义,预览端够干净,网页交互能切换,镜头不会乱抢,模型平移限制不会误伤别的页面,控制页风格和操作节奏足够接近用户已有习惯。这些点每一个单看都不惊天动地,但合在一起,就把“一个能联动的页面”抬升成了“一套能拿去交付的系统”。
如果你正在评估一套 3D 控制展示系统是否真的成熟,我建议不要只看首屏截图,也不要只看有没有 WebSocket。去看它对这些细部问题是否认真。因为真正决定系统能不能进业务现场的,往往就是这些看似不起眼、但用户一上手就会立刻感知的地方。
二十一、最后的总结:交付级控制页和预览页,不是功能堆出来的,而是边界压出来的
这篇长文写到这里,最想强调的一句话其实很简单:交付级的控制页和预览页,不是把功能越加越多堆出来的,而是把边界越压越清楚压出来的。控制页负责操作,所以它需要完整能力、明确反馈、低误操作和强状态感。预览页负责观看,所以它需要纯、稳、静、克制。后端负责维持会话一致性,所以它要承接 URL 参数、runtime state、snapshot 和广播,而不是只当一个消息转发器。公共视口负责渲染,所以它不应该偷带页面特定立场,而应该把特殊约束交给页面显式声明。
从这个角度看,这次项目最大的收获甚至不是某个具体功能,而是把这些边界一条条画出来,然后让代码老老实实服从边界。你会发现,一旦边界清楚,很多问题其实并不复杂。难的是一开始没把边界讲透,结果每个功能都在试图跨过自己不该跨的那条线。
如果你也在做类似的三维控制、远程讲解、数字孪生演示或多端联动项目,希望这篇文章能给你带来的不是几段可复制的代码,而是一种更务实的判断标准:不要问“能不能做出来”,先问“谁在操作、谁在观看、谁在维护、哪些约束只属于某个页面、哪些状态必须同时本地生效和远端生效、哪些反馈必须强可见”。把这些问题先问对,你后面的方案大概率也不会差到哪里去。