
让 AIRI 的 VRM 3D 舞台可解释、可调试ThreeScene 生命周期重构与窗口级 VRM 缓存实战【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi导读本文以 AIRI 项目 DevLog 2026.03.14 记录的 PR #1194 为主线完整复盘这次针对stage-ui-threeVRM/Three.js 运行时的一次清理 重设计 调试日记式重构。重构覆盖了窗口级 VRM 实例缓存、异步加载竞态防护、ThreeScene显式生命周期模型、TresCanvas尺寸为 0 的加载死锁修复以及基于 Eventa 的首条 3D 运行时追踪链路。读完本文你将掌握一套可复用的 Three.js/Vue 舞台生命周期治理方法论并能直接对照仓库源码验证每一处实现细节。1. 背景为什么 VRM 舞台看起来像渲染 bug其实是生命周期 bugAIRI 的 3D 舞台VRM / Three.js 运行时横跨 Web、桌面与移动端。在 PR #1194 之前这个舞台已经积累了一批特征相似的 bugVRM 实例可能重叠旧模型表现得像从未真正消失舞台可能卡死在loading状态反复加载不同 VRM 模型会让 GPU 与内存占用滑入不健康区间深度销毁与资源归属不一致难以判断到底哪个 scene 拥有当前模型。更让人头疼的是开发环境下的复现路径某些按钮第一次点击就可能让场景部分子树重挂载、把舞台重新踢回loading并卡死。作者在文档中给出的第一诊断非常直白——此前太多运行时行为是顺带依附在 Vue 组件生命周期上的mount 意味着可能加载unmount 意味着可能销毁remount 意味着可能重建一切两个 scene 几乎同时触碰同一份状态时后写入者胜出。文档原话是That is not a design. That is just surviving until the next remount. 而当舞台上叠加了主舞台、设置预览场景、HMR、异步模型加载、object URL、缓存的 GPU 资源与跨窗口行为之后这套顺带式协调就会全面崩坏。由此产出了五个明确的设计目标让场景所有权显式化explicit scene ownership让模型替换显式化explicit model replacement让销毁逻辑具备原因感知reason-aware disposal让过期的异步工作变得无害stale async work harmless让运行时足够可观测能用证据代替猜测。2. 窗口级 VRM 缓存stash / take / clear与 scopeKey第一个结构性改动是解耦的 VRM 缓存。核心诉求很朴素同一个窗口内、同一个路由作用域下的场景如果暂时卸载再重挂载应当能复用已脱离detached的 VRM 实例而不是每次都付一遍 parse compile 的成本。文档给出了核心数据结构interface ManagedVrmCacheState { detachedByScope: Recordstring, ManagedVrmInstance | undefined }每个ManagedVrmInstance保存当前被脱离的运行时资源包VRM、它的Group、AnimationMixer、表情控制器emote controller、modelSrc与scopeKey。在仓库实现 vrm-instance-cache.ts 中可以看到完整定义export interface ManagedVrmInstance { emote: ReturnTypetypeof useVRMEmote group: Group interactionColliders: VrmInteractionColliderSet mixer: AnimationMixer modelSrc: string scopeKey: string vrm: VRM }三个关键设计决策在源码中清晰可见① 缓存状态存在模块级并接入 HMR。import.meta.hot.data被用来跨模块热更新保留缓存const hotData import.meta.hot?.data as { managedVrmCacheState?: ManagedVrmCacheState } | undefined const managedVrmCacheState hotData?.managedVrmCacheState ?? { detachedByScope: {} } if (import.meta.hot) import.meta.hot.data.managedVrmCacheState managedVrmCacheState② scopeKey 由window.location.href派生见 VRMModel.vue 中的getManagedVrmScopeKey。实践含义是每个浏览器窗口有自己独立的缓存状态每个路由作用域有自己的 detached 槽位HMR 不会因为模块重载就清空缓存。主舞台与设置预览可能指向同一个modelSrc但它们不属于同一个场景生命周期——全局乐观缓存会让所有权迅速变得暧昧而窗口本地 scope 键控的缓存更容易推理。③ 三个 API 配合原因感知的销毁策略export function takeManagedVrmInstance(scopeKey: string, modelSrc: string) { ... } export function stashManagedVrmInstance(instance: ManagedVrmInstance) { ... } export function clearManagedVrmInstance(scopeKey: string) { ... }take只有在cached.modelSrc modelSrc时才命中miss 时返回undefinedstash在槽位已被占用时会返回被逐出的旧实例evicted由调用方决定其生死。对应的销毁策略在 VRMModel.vue 的componentCleanUp与shouldDestroyVrmResources/shouldStashVrmResources中体现component-unmount时能暂存就暂存stashManagedVrmInstancemodel-switch时激进销毁shouldDestroyVrmResources对model-switch返回true走clearManagedVrmInstance 深度销毁缓存条目被逐出或失效时深度销毁。文档特别强调A cache is not a memory leak with a more polite name. 缓存条目的健康度也有校验函数isManagedVrmInstanceReusable它尝试updateMatrixWorldhumanoid.update()一旦抛错即视为不可复用并销毁加载器回退到正常加载路径。3. 让 VRM 加载竞态安全load - validate - commit有了缓存之后加载过程也必须更加自律。旧问题的本质是异步加载可能乱序完成。用户快速切换模型、或场景在某个加载仍在飞行途中时重挂载过期工作仍可能迟到并篡改活动场景。仓库实现用模块内递增的loadSequence配合两个辅助函数见 VRMModel.vuelet loadSequence 0 function invalidatePendingLoads() { loadSequence 1 return loadSequence } function isLoadRequestCurrent(requestId: number) { return loadSequence requestId }文档给出的模式const requestId invalidatePendingLoads() if (!isLoadRequestCurrent(requestId)) // eslint-disable-next-line no-useless-return return这个检查贯穿整个 VRM 加载流程等待 scene 之后、读取缓存之后、加载 VRM 之后、加载 idle 动画之后、提交实例之前。源码中可以看到一旦发现请求已过期disposeDetachedVrm会把加载到一半的 VRM 及其 Group 深度销毁而不是提交if (!isLoadRequestCurrent(requestId)) { disposeDetachedVrm(nextVrm, nextVrmGroup) return }缓存命中路径同样受此约束复用一个 detached 实例之前必须通过校验且必须确认请求仍是最新否则实例被重新 stash 或销毁绝不贸然提交。这使 VRM 加载的概念流变成显式的三段式load - validate - commit4.ThreeScene生命周期重构scenePhase 绑定事务 变更锁缓存与竞态治理之后真正的大头开始了ThreeScene需要一个生命周期模型。重构前ThreeScene、TresCanvas、OrbitControls、相机状态与VRMModel之间的依赖是真实存在但过于隐式的。一旦子树因 HMR 或其他更新路径重挂载这套松散协调就可能分崩离析。重构前与重构后的生命周期图如下来自本文档原图见 assets/ThreeScene-before.avif 与 assets/ThreeScene-after.avif重构引入了几组关键概念显式的scenePhase绑定事务深度binding transaction depth由 phase 与事务状态派生的变更锁mutation lockVRM 模型就绪与场景就绪两个信号更清晰的分割。4.1 六个阶段ScenePhase类型定义在 model-store.tsexport type ScenePhase pending | loading | binding | mounted | no-model | error4.2 两个就绪信号与绑定事务至少存在两个相互独立的就绪信号且它们可能以任意顺序到达VRMModel已加载并产出 bootstrap 数据OrbitControls能访问真实相机与 renderer 支持的 DOM 元素。ThreeScene通过绑定事务协调这两个信号。整体流程与 ThreeScene.vue 中的beginSceneBindingCycle、completeSceneBinding、onOrbitControlsReady、onVRMModelLoaded等函数一一对应VRMModel发出loadStart开启一个绑定周期beginSceneBindingCycle重置事务 →beginSceneBindingTransaction→ 将 phase 置为loadingVRMModel随后发出 bootstrap 数据与loadedonVRMSceneBootstrap暂存pendingSceneBootstraponVRMModelLoaded将modelPhase置为ready并触发completeSceneBindingOrbitControls独立发出orbitControlsReadyonOrbitControlsReady在模型已就绪时同样触发completeSceneBinding当绑定真正可以完成时ThreeScene进入binding阶段应用 bootstrap 状态applySceneBootstrap在下一个 tick 更新 controlscontrolsRef.value?.update()关闭事务endSceneBindingTransaction解析出最终 phaseresolveScenePhaseAfterBinding。4.3 绑定事务与变更锁的实现事务深度与变更锁定义在 model-store.tsconst scenePhase refScenePhase(pending) const sceneTransactionDepth ref(0) const sceneMutationLocked computed(() scenePhase.value ! mounted || sceneTransactionDepth.value 0) function beginSceneBindingTransaction() { sceneTransactionDepth.value 1 } function endSceneBindingTransaction() { sceneTransactionDepth.value Math.max(0, sceneTransactionDepth.value - 1) } function resetSceneBindingTransactions() { sceneTransactionDepth.value 0 }sceneMutationLocked并非数据库意义上的硬锁而是运行时协调锁只要场景未完全 mounted、或绑定事务仍处于打开状态UI 层的写入就不应把场景当作稳定状态。该锁被用来禁用或延迟设置面板的写入并阻止OrbitControls过早激活——controlEnable计算属性ThreeScene.vue同时要求enableOrbitControls、controlsReady、modelPhase ready且!sceneMutationLocked。另外值得注意bindingRevision被用于防重入。commitLastCommittedModelSrc要求expectedRevision bindingRevision.value且 pending 与 active 的modelSrc一致才提交lastCommittedModelSrc从机制上保证提交永远只对当前这代绑定生效。5. 模型选择器与预览路径的清理在修复ThreeScene的过程中作者发现模型选择器与预览路径也有各自的生命周期问题。5.1 预览场景的显式销毁预览渲染路径会为 VRM 预览创建离屏offscreenWebGLRenderer但此前的清理路径不够强。修复让预览 teardown 显式化步骤包括停止动画动作stopAllAction深度销毁预览 VRM清空预览场景销毁 renderer强制上下文丢失force context loss撤销 object URL将离屏 canvas 尺寸归零。5.2 模型 URL 生命周期与竞态防护舞台模型 URL 逻辑也暴露出脆弱点更新过程中选中 URL 可能短暂变成undefined足以触发一次无谓的 teardown-and-reload 循环。修复思路是把选中模型当作稳定状态对待仅在下一个 URL 真正就绪时才替换用请求序列号request sequence守护异步更新谨慎撤销旧 blob URL而不是急切地立刻撤销。6. 那个拒绝消失的 bugTresCanvas尺寸为 0 的加载死锁完成上述所有修复后作者预期舞台终于不会卡在loading——但它仍然会卡。最终定位到一条关键线索与一个真正的死锁。线索开发模式下tresjs/core注册了响应vite:afterUpdate的 HMR 路径。这不仅限于.vue/.ts变更——UnoCSS 重新生成__uno.css也可能触发子树重挂载。这解释了为什么在 dev 中连某些按钮的第一次点击都可能让 Three 场景的部分区域重挂载新 class 产生 CSS 更新CSS 更新又触发子树 remount。真正的死锁来自加载 UI 本身。文档给出的死锁链路loading starts - parent becomes display:none - TresCanvas measures 0x0 - ready never fires - scene never reaches mounted - loading overlay never goes away具体而言舞台页面曾经用v-show!isLoading包裹WidgetStage导致等待离开 loading 时TresCanvas的父元素变成display: none。而 Tres 从父元素测量自身尺寸父元素隐藏 → 测得0x0→ready永不触发 → 舞台永远无法离开 loading。修复并不复杂但必须在确认根因后执行让舞台始终保持挂载在 DOM 中把 loading UI 移到舞台上方独立的覆盖层overlay通过Screen给TresCanvas显式width/height而不是依赖一个可能消失的父元素。这一改动在 ThreeScene.vue 的模板中直接可见——Screen通过v-slot{ width, height }提供尺寸并显式传给TresCanvas的:width/:heightScreen refscreenRef v-slot{ width, height } relative TresCanvas :widthwidth :heightheight :cameracamera :antialiastrue :dprrenderScale :tone-mappingACESFilmicToneMapping :tone-mapping-exposure1 :clear-alpha0 readyonTresReady renderonTresRender 这个修复最终拔掉了整轮调试中最恼人的它还在挂起类 bug。7. 一次回归Web 端的KeepAlive陷阱桌面端问题基本解决后作者回到 Web 应用并立刻发现新的回归VRM 设置页看起来被锁死写入锁似乎永不释放。症状指向sceneMutationLocked但根因不在ThreeScene内部而在 apps/stage-web/src/App.vue 中。旧代码使用KeepAlive :include[IndexScenePage, StageScenePage] component :isComponent / /KeepAlive这意味着导航进入设置页后主页场景仍可能存活在路由树中——同一份共享状态上可能同时运行着两个ThreeScene实例主页场景与设置预览场景。两者都在上报自己的 scene phase 与 mutation 状态锁语义因此被搅浑从设置页视角看锁似乎永远无法完全安定。修复就是移除这层KeepAlive包装。当前 App.vue 的模板已经改为直接RouterViewErrorBoundary结构隐藏场景真正停止存活后锁语义恢复一致RouterView v-slot{ Component } ErrorBoundary titleSomething went wrong while rendering this page. error(err, _, info) console.error([ErrorBoundary], info, err) component :isComponent / /ErrorBoundary /RouterView这个案例很有价值生命周期 bug 不一定发生在你正在重构的组件内部也可能藏在应用壳层app shell的路由缓存策略里。8. 用 Eventa 建立 3D 运行时的第一条追踪链路本次 PR 中作者最看重的一部分是追踪tracing。当前追踪工作仍然比较基础但已远胜于完全靠直觉和console.log调试 VRM 舞台。追踪层位于 packages/stage-ui-three/src/trace以 Eventa 作为事件契约。从 eventa.ts 可以看到完整的事件面性能侧renderer info 快照、hit-test 读回耗时、每帧 VRM 更新分解stage-ui-three:trace:three-scene:render-infodraw calls、geometries、triangles、textures、lines、pointsstage-ui-three:trace:three-scene:hit-test-read读回耗时与读回区域stage-ui-three:trace:vrm:update-frame每帧各子系统耗时分解animation mixer、humanoid、lookAt、blink/saccade、emote、lip-sync、expression、node constraint、spring bone、frame hooks 等。生命周期侧load / dispose、缓存 take/stash/clear、scene phase 变化、事务 begin/end/resetstage-ui-three:trace:vrm:load:start / end / errorstage-ui-three:trace:vrm:dispose:start / endstage-ui-three:trace:vrm:cacheaction:stash/take/clearresult:hit/miss/stored/evicted/emptystage-ui-three:trace:three-scene:phase、:subtreetresCanvasRef/controlsRef/modelRef/dirLightRef的 attached/detached、:transactionbegin/end/reset 及 reason、:component-state、:mutation-lock。事件载荷的类型定义在 types.ts例如ThreeSceneTraceTransactionReason枚举了component-unmount/initial-load/model-reload/model-switch/no-model/subtree-remount/unknown与销毁与事务语义一一对应。在桌面端这些事件通过 Eventa 转发进一个简单的诊断视图。事件体统中的originId如three-scene:${随机串}见 ThreeScene.vue 的stageThreeSceneTraceOriginId让多个场景实例的事件可以相互区分——这正是在第 7 节KeepAlive回归中区分到底哪个 scene 在说话的关键能力。追踪是本次 PR 为未来埋下的重要伏笔。文档列出的 TODO 是构建一个真正的ThreeScene可观测性工具更好的生命周期内省、更好的性能时间线、更好的资源核算与场景关联以及面向 3D 运行时的更完整 O11y 面。追踪的开关与上下文管理可在 trace/context.ts 与配套的 trace/context.test.ts、trace/snapshots.test.ts 中继续深挖。9. 总结PR #1194 到底做了什么回顾这次重构PR #1194 的成果清单是清理了内存泄漏与销毁路径引入窗口级 VRM 复用缓存让异步加载更少竞态为ThreeScene建立显式生命周期模型scenePhase 绑定事务 sceneMutationLocked修复TresCanvas尺寸为 0 的加载死锁暴露并修复 Web 端KeepAlive回归为这套运行时建立第一条可用的追踪路径。更重要的是它把一堆松散耦合的行为变成了可以被解释、被推理、被调试的状态机。文档结尾作者的自评值得反复咀嚼at least now the runtime feels like it has an owner again.从工程方法论的角度这次重构给出的可迁移经验包括给隐式依赖一个名字把顺带生效的组件生命周期行为提炼为显式 phase 与事务是所有后续修复的地基缓存必须绑定所有权与销毁策略缓存条目同样需要原因感知的处置stash / destroy / evict否则只是给内存泄漏起了个体面的名字异步加载必须有请求代际generation任何 await 之后都要确认我还是不是最新的一代过期结果应销毁而非提交测量尺寸的组件不能依赖会被隐藏的父级display: none与0x0测量、ready不触发之间会形成自锁死循环路由缓存策略会与运行时状态机互相干扰KeepAlive保活的两个场景共享状态时锁语义必然混乱可观测性要在第一轮就建Eventa 追踪让验证发生了什么代替猜测发生了什么。如果你想继续阅读源码建议从 ThreeScene.vue、vrm-instance-cache.ts、VRMModel.vue 与 model-store.ts 这四处切入追踪相关的完整事件契约在 trace/eventa.ts。VRM 相关议题的后续跟进也可以从本文档作者持续追踪的 issue #1173 出发继续探索。【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考