这次迭代集中解决了 SceneView 完整版 3D 编辑器里几个会直接影响交付体验的问题:大模型加载后页面明显卡住,复制同一个模型时交互阻塞,点击模型子部件时主线程抖动,爆炸视图中心偏移,以及 Live 预览页和控制页里坐标轴职责混在一起。它们看起来分散,底层都指向同一个问题:3D 编辑器要持续控制每一次遍历、每一次深拷贝、每一次材质克隆和每一次交互控件挂载。
今天的工作把系统从“能用”推进到“更适合真实模型和真实演示”。在 3D 编辑器里,性能问题很少只来自某一行代码。一个 GLB 模型加载进来后,可能同时触发包围盒计算、材质转换、部件树构建、射线列表刷新、动画注册、爆炸数据生成和右侧面板同步。如果这些工作全部挤在一次同步调用里,再小的模型也会逐渐拖慢,大模型会立刻把问题放大。
这篇复盘会按真实排查路径展开:先讲现象,再讲根因,再讲为什么最终选择轻量复制、共享克隆、分帧后处理、O(1) 部件索引和 Live 页面职责分离。重点是说明这些改动为什么能让一个 3D 编辑器更接近可交付状态。
一、这次优化面对的是几类真实卡顿
用户最先反馈的是操作感:上传或打开一个比较大的无人机 GLB 模型后,页面加载完成到可操作之间有明显等待;复制模型时,点击复制后的等待时间过长;选中模型子级时,鼠标反馈不够轻;在 Live 页面里,预览端和控制端的坐标轴表现偏离使用语义。这些问题有一个共同特点:它们都在真实使用时让人觉得系统“重”。
这类问题排查时需要同时看完整链路和单点耗时。比如复制卡顿,表面是复制按钮慢,根因可能是场景对象里塞进了大量派生数据,复制时被 JSON 深拷贝完整扫了一遍。模型加载卡顿,表面是 GLTFLoader 慢,根因可能是加载成功后的同步后处理远比下载和解析更重。子级选择卡顿,表面是点击 Mesh 慢,根因可能是每次点击都在全树遍历、克隆材质、生成快照。
因此这次采用分阶段处理:模型数据从哪里来,模型对象如何克隆,哪些数据必须立即生成,哪些数据可以下一帧生成,哪些数据只在拖拽时需要,哪些数据属于运行时派生字段,哪些字段保存到场景后会反过来拖慢复制和同步。
| 问题 | 根因方向 | 处理策略 |
|---|---|---|
| 大 GLB 加载后卡住 | 加载后同步遍历过多 | 先显示模型,再分帧构建派生数据 |
| 复制模型卡顿 | 深拷贝包含大体积派生字段 | 复制前瘦身,只保留必要业务字段 |
| 子级点击卡顿 | 点击时全树遍历和材质克隆 | 提前建立索引,点击走轻量高亮 |
| 爆炸偏移 | 方向计算使用对象原点 | 按可见中心计算方向并写回位移 |
| Live 坐标轴混乱 | 预览页和控制页职责混用 | 预览页只展示,控制页保留三维操作 |
二、大模型加载:先让用户看到,再处理重活
GLB 模型加载完成后,很多编辑器会立即做一整套初始化:计算包围盒、归一化尺寸、转换材质、收集所有 Mesh、构建部件树、注册动画、生成爆炸列表、刷新右侧面板、建立射线命中数组。代码写起来很顺,但浏览器主线程会被一次性占住。模型越大,节点越多,这个阶段越容易成为真正的卡顿来源。
这次的关键改动是调整顺序:模型加载成功后,优先完成能让用户“看到模型、选中模型”的必要工作;爆炸数据、部件树、动画、材质覆盖这类重工作放到后续帧分批执行。这样做的价值很直接,页面不再等所有派生数据准备完才响应,首屏体验会明显变轻。
async function loadGLBModel(model) {
const clonedScene = cloneSceneShared(templateScene)
scene.add(clonedScene)
modelObjectMap.set(model.id, clonedScene)
addMeshesForRaycast(model.id, clonedScene)
requestAnimationFrame(() => {
buildExplosionParts(model, clonedScene)
buildModelPartTree(model, clonedScene)
registerModelAnimations(model, clonedScene)
scheduleApplyModelMaterials(model)
})
}
这段伪代码表达的是顺序变化。真正重要的是把“用户立刻需要”和“系统稍后需要”分开。用户刚打开场景时,最关心的是模型是否出现、是否可选中、画面是否保持响应。材质覆盖、部件树、动画列表可以稍后一两帧补齐,只要最终状态一致,体验会更顺。
这里还有一个容易被忽略的点:分帧是为了把必要工作放到更合适的时机。对于真实项目里的大模型,某些遍历就是要做,比如部件树和爆炸列表。关键在于它们是否必须挡住首个交互帧。答案通常很明确:显示模型和保持页面响应优先。
三、共享克隆:避免同一个模型重复消耗资源
很多场景会反复使用同一个 GLB,比如同一款产品复制多个实例,或者同一个设备在不同位置出现。最直接的做法是每次重新解析一份完整 Scene,再把 geometry 和 material 都独立复制出来。这种方式实现简单,但内存和 CPU 都会膨胀。尤其复制模型时,如果每次都深度克隆全部材质和几何体,卡顿会被快速放大。
这次我们把缓存对象定位成不可变模板。第一次加载某个模型 URL 时,把解析出的 gltf.scene 保存为模板;后续实例从模板克隆。普通 Mesh 共享 geometry 和 material 引用,骨骼模型则使用 SkeletonUtils.clone 保持骨骼绑定正确。这样可以减少重复解析和重复分配,避免同一个模型的多个实例互相污染。
function cloneSceneShared(sourceScene) {
const clonedScene = hasSkinnedMesh(sourceScene)
? SkeletonUtils.clone(sourceScene)
: sourceScene.clone(true)
clonedScene.traverse((child) => {
if (!child.isMesh) return
const sourceMesh = child.userData?.sourceMesh
if (sourceMesh?.geometry) child.geometry = sourceMesh.geometry
if (sourceMesh?.material) child.material = sourceMesh.material
})
return clonedScene
}
共享克隆有一个边界:模板必须保持不可变。实例运行时的父级、选中状态、爆炸偏移、临时高亮、右侧面板状态都属于实例。模板只承担“资源来源”的角色,实例承担“场景对象”的角色。这个分界一旦清晰,复制同源模型时就能明显减少成本。
四、复制卡顿:真正的问题在深拷贝对象太胖
复制模型卡顿的根因很典型:场景对象被用于持久化、编辑状态和运行时派生数据三种用途。随着功能增长,一个 model 对象里会逐渐出现 children、materialsMeta、explosion.partList、动画运行时字段、部件树缓存等数据。这些字段对编辑器很有用,但并不都适合进入复制时的深拷贝。
如果复制逻辑直接用 JSON.stringify 或类似方式复制整个对象,就会把这些派生字段全部扫一遍。对于大模型,children 和爆炸列表可能非常大,复制时不仅要序列化,还要重新分配,主线程自然会卡住。
最终采用的方案是在复制前瘦身。复制前明确挑选业务字段,排除运行时派生字段,并记录副本来源。这样副本可以马上用原模型模板走轻量加载路径,部件树和材质元信息在后续需要时再生成。
function cloneModelForDuplicate(model) {
const {
children,
materialsMeta,
explosion,
runtimeState,
...baseModel
} = model
return {
...baseModel,
id: createModelId(),
name: `${model.name} 副本`,
__duplicatedFrom: model.id,
explosion: explosion ? { ...explosion, partList: [] } : undefined,
}
}
这个改动很小,但收益很大。它把复制从“复制一个带着全部运行时负担的大对象”,变成“复制一个足够描述模型实例的轻对象”。后续视口根据 __duplicatedFrom 和模型 URL 复用已经存在的资源模板,复制体验就会接近添加一个轻量实例。
五、材质处理:按需克隆,分批应用
材质系统是 3D 编辑器里很容易变重的地方。为了支持颜色、金属度、粗糙度、透明度和环境反射强度的覆盖编辑,系统需要在某些情况下克隆材质并写入覆盖值。但如果每次加载模型都无条件克隆全部材质,就会把没有材质覆盖需求的模型也拖慢。
这次优化将材质处理拆成两个原则。第一,没有材质覆盖配置时,跳过材质克隆,让模型继续使用共享材质。第二,有材质覆盖配置时,使用批处理方式逐步应用,避免一次遍历大量 Mesh 阻塞页面。
function scheduleApplyModelMaterials(model) {
if (!model?.materialOverrides || Object.keys(model.materialOverrides).length === 0) {
return
}
requestAnimationFrame(() => {
applyModelMaterialsBatched(model, modelObjectMap.get(model.id))
})
}
这个判断看起来普通,却体现了编辑器性能优化里很关键的一点:不要让少数模型需要的高级能力,成为所有模型的默认成本。材质覆盖是强功能,但只有用户真的配置了覆盖值,它才需要进入重路径。
六、子级选择:从全树扫描到索引命中
模型子级选择也是一个典型的慢点。用户点击模型某个 Mesh 时,编辑器需要知道它属于哪个模型、对应哪个部件、是否要高亮、是否要更新右侧树、是否要生成拖拽快照。如果每次点击都从整棵 Scene Graph 里扫描,复杂模型会很快变慢。
这次改动引入了部件索引。模型加载时建立 Mesh 到模型部件信息的映射,点击时通过 Map 直接命中。高亮也从“克隆材质并替换”改成“尽量修改 emissive 的轻量高亮”。对于无法使用 emissive 的材质,再走更保守的处理路径。
const modelPartIndexMap = new Map()
function indexModelParts(modelId, group) {
group.traverse((child) => {
if (!child.isMesh) return
modelPartIndexMap.set(child.uuid, { modelId, mesh: child })
})
}
function selectMeshPart(mesh) {
const partInfo = modelPartIndexMap.get(mesh.uuid)
if (!partInfo) return
highlightMeshEmissive(mesh)
selectModel(partInfo.modelId)
}
这类优化的意义不只是减少一次遍历。它让点击行为变得稳定可预测:点击就是查索引、设置选中、轻量高亮。拖拽所需的完整快照延迟到拖拽真正开始时再生成,避免普通点击承担拖拽路径的成本。
七、爆炸视图:用可见中心修正方向
爆炸视图的偏移问题在 3D 项目里非常常见。很多模型的对象原点并不在几何中心,尤其是经过建模软件导出、合并、烘焙或复杂层级处理后,Mesh 的原点可能离可见几何很远。如果爆炸方向按对象原点计算,用户看到的效果就会偏:部件沿着不符合视觉直觉的方向飞出去。
这次修复采用可见中心作为方向计算依据。先通过包围盒计算对象在世界坐标下的可见中心,再用可见中心相对模型中心的方向决定爆炸方向。写回时仍然更新对象原点位置,但位移量来自可见中心的世界位移差。
function getObjectVisualCenterWorld(object) {
const box = new THREE.Box3().setFromObject(object)
if (box.isEmpty()) return object.getWorldPosition(new THREE.Vector3())
return box.getCenter(new THREE.Vector3())
}
function resolveExplosionOffset(part, modelCenter, distance) {
const visualCenter = getObjectVisualCenterWorld(part)
const direction = visualCenter.sub(modelCenter).normalize()
return direction.multiplyScalar(distance)
}
这个处理会让 baked geometry、偏心原点模型和复杂层级模型的爆炸表现更接近用户预期。编辑器里的“中心”应该更多服务视觉操作,优先贴近用户看到的几何中心。
八、TransformControls:预览页和控制页职责必须分开
Live 链路里的坐标轴问题本质上是页面职责问题。预览页是展示终端,观看者只需要看到控制端传来的结果。控制页是操作终端,操作者需要选中模型、拖动坐标轴、调整位置。两个页面都复用同一个 Viewport 组件,这让实现更统一,但也要求组件明确知道当前页面语义。
这次修复后,远程同步选中模型时,预览页会保留选中和高亮状态,同时显式 detach TransformControls。也就是说,预览页可以知道“哪个模型被控制端选中了”,画面保持纯展示。控制页则去掉平面约束,双击模型后显示完整三维 TransformControls,让操作者可以沿 X、Y、Z 三个方向调整模型。
function applyRemoteSelection(modelId) {
selectModel(modelId)
if (props.readonly) {
transformControls.detach()
return
}
attachSceneObjectControls(modelId)
}
这个改动让 Live 系统的语义更清楚:预览端负责还原状态,控制端负责产生操作。预览端显示坐标轴会制造误导,因为观看者看到的是一个似乎可操作的控件;控制端限制在平面移动会削弱真实编辑能力,因为 3D 场景本身需要完整三轴操作。
九、场景切换:让旧异步任务自动失效
当模型加载和后处理被拆到后续帧执行后,就必须处理另一个问题:用户可能在异步任务完成前切换场景。如果旧场景的后处理继续写入新场景状态,就会出现幽灵模型、旧材质覆盖、新旧射线列表混合等问题。
解决方式是引入场景版本号。每次清空场景或切换场景时递增 sceneVersion,异步后处理启动前记录当前版本,真正执行时再次比较。版本不一致时直接丢弃旧任务。
let sceneVersion = 0
function clearSceneObjects() {
sceneVersion += 1
modelObjectMap.clear()
modelPartIndexMap.clear()
}
function runDeferredModelSetup(model, group) {
const version = sceneVersion
requestAnimationFrame(() => {
if (version !== sceneVersion) return
buildModelPartTree(model, group)
})
}
只要引入分帧、异步加载、远程同步,就需要一套过期任务治理。版本号方案实现简单,适合这种“旧任务只要过期就丢弃”的场景。
十、从 Lite 迁移到完整版:同步思路比复制代码更重要
这次很多修复来自 Lite 版先前已经验证过的经验,完整版结构和 Lite 存在明显差异。直接整文件替换会带来更大风险,因为完整版包含更多页面、更多状态和更多控制链路。最终采用的是最小迁移:把已经验证有效的核心思想迁移过去。
例如,Lite 版里已经验证过 sceneId 优先加载、可见中心爆炸、模型模板克隆、子级选择轻量化、多模型射线列表累积等思路。迁移到完整版时,需要结合完整版已有的 LivePreviewPage、LiveControlPage、材质覆盖、后台资源和完整场景状态来重新落位。
这类迁移最容易踩的坑是“把一个版本里的函数原样搬过去”。函数名一样时,上下文、数据流和生命周期仍然可能不同。更稳的方式是先确认问题模型、状态入口和页面职责,再把修复思想落到现有结构里。
十一、验证:构建通过只是第一步
这次优化后执行了前端构建,确认 Vite 能正常产出资源,并由 Go 后端托管到 9000 端口。构建过程中出现 chunk 体积提示,这类提示属于体积优化方向。更关键的验证来自交互路径:大模型加载后页面可响应,复制模型更顺畅,点击多个模型能正确命中,Live 预览页保持纯展示,Live 控制页双击模型后显示三维坐标轴。
对于 3D 编辑器,这类验证要尽量贴近真实用户动作。构建、打开页面、编辑页检查和 Live 页面检查分别覆盖不同风险。因此每次改动都要对应一条真实操作路径。
| 验证路径 | 期望结果 |
|---|---|
| 加载大 GLB 模型 | 模型先显示,后处理不阻塞首个交互 |
| 复制同源模型 | 复用模板资源,避免深拷贝大字段 |
| 点击模型子级 | 索引命中,轻量高亮 |
| 切换场景 | 旧异步后处理失效 |
| 打开 Live 预览页 | 只展示画面和远程状态 |
| 打开 Live 控制页 | 双击模型显示完整三维 TransformControls |
十二、这次迭代带来的工程经验
第一,3D 编辑器的性能优化要围绕“对象生命周期”展开。模型从 URL 到模板、从模板到实例、从实例到场景对象、从场景对象到持久化数据,每一步都要知道哪些字段是源数据,哪些字段是派生数据,哪些字段是运行时状态。
第二,大模型优化优先处理同步重活。很多时候加载成功后的遍历、材质处理、部件树和爆炸数据才是卡顿来源。先显示模型,再分帧处理,可以显著改善体感。
第三,复制性能要从数据结构入手。复制前瘦身比复制后清理更有效,因为深拷贝的成本已经在复制阶段发生了。让模型对象保持轻量,派生字段按需生成,编辑器会更耐用。
第四,复用组件时必须保留页面语义。Live 预览页和控制页都可以用同一个 Viewport,readonly、TransformControls、远程选中、网页交互这些行为必须根据页面角色分开处理。组件复用的目标是减少重复,同时保持不同页面的交互边界。
第五,真实工程里最有价值的优化通常是小而准的。新增一个索引、跳过一次无意义材质克隆、去掉一个派生字段深拷贝、让旧异步任务失效,这些改动单独看都不夸张,组合起来就会明显改变系统手感。
十三、结语:3D 编辑器的交付感来自很多细节叠加
这次迭代让完整版编辑器更接近真实项目里的使用状态。大模型能更轻地进场,同源模型复制更顺,子级选择更快,爆炸视图更符合视觉直觉,Live 预览和控制页的职责更清晰。对于 3D 编辑器来说,这些细节就是交付感的一部分。
真正能交付的编辑器,必须同时照顾资源、状态、交互和页面角色。模型越大,场景越复杂,用户越依赖实时演示,这些底层细节越会被放大。今天这轮优化的核心价值,就是把这些容易在 Demo 阶段被忽略的问题,提前压到系统结构里解决。