完整版 3D 编辑器性能与 Live 控制链路优化复盘

这次迭代集中解决了 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 对象里会逐渐出现 childrenmaterialsMetaexplosion.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 优先加载、可见中心爆炸、模型模板克隆、子级选择轻量化、多模型射线列表累积等思路。迁移到完整版时,需要结合完整版已有的 LivePreviewPageLiveControlPage、材质覆盖、后台资源和完整场景状态来重新落位。

这类迁移最容易踩的坑是“把一个版本里的函数原样搬过去”。函数名一样时,上下文、数据流和生命周期仍然可能不同。更稳的方式是先确认问题模型、状态入口和页面职责,再把修复思想落到现有结构里。

十一、验证:构建通过只是第一步

这次优化后执行了前端构建,确认 Vite 能正常产出资源,并由 Go 后端托管到 9000 端口。构建过程中出现 chunk 体积提示,这类提示属于体积优化方向。更关键的验证来自交互路径:大模型加载后页面可响应,复制模型更顺畅,点击多个模型能正确命中,Live 预览页保持纯展示,Live 控制页双击模型后显示三维坐标轴。

对于 3D 编辑器,这类验证要尽量贴近真实用户动作。构建、打开页面、编辑页检查和 Live 页面检查分别覆盖不同风险。因此每次改动都要对应一条真实操作路径。

验证路径期望结果
加载大 GLB 模型模型先显示,后处理不阻塞首个交互
复制同源模型复用模板资源,避免深拷贝大字段
点击模型子级索引命中,轻量高亮
切换场景旧异步后处理失效
打开 Live 预览页只展示画面和远程状态
打开 Live 控制页双击模型显示完整三维 TransformControls

十二、这次迭代带来的工程经验

第一,3D 编辑器的性能优化要围绕“对象生命周期”展开。模型从 URL 到模板、从模板到实例、从实例到场景对象、从场景对象到持久化数据,每一步都要知道哪些字段是源数据,哪些字段是派生数据,哪些字段是运行时状态。

第二,大模型优化优先处理同步重活。很多时候加载成功后的遍历、材质处理、部件树和爆炸数据才是卡顿来源。先显示模型,再分帧处理,可以显著改善体感。

第三,复制性能要从数据结构入手。复制前瘦身比复制后清理更有效,因为深拷贝的成本已经在复制阶段发生了。让模型对象保持轻量,派生字段按需生成,编辑器会更耐用。

第四,复用组件时必须保留页面语义。Live 预览页和控制页都可以用同一个 Viewport,readonly、TransformControls、远程选中、网页交互这些行为必须根据页面角色分开处理。组件复用的目标是减少重复,同时保持不同页面的交互边界。

第五,真实工程里最有价值的优化通常是小而准的。新增一个索引、跳过一次无意义材质克隆、去掉一个派生字段深拷贝、让旧异步任务失效,这些改动单独看都不夸张,组合起来就会明显改变系统手感。

十三、结语:3D 编辑器的交付感来自很多细节叠加

这次迭代让完整版编辑器更接近真实项目里的使用状态。大模型能更轻地进场,同源模型复制更顺,子级选择更快,爆炸视图更符合视觉直觉,Live 预览和控制页的职责更清晰。对于 3D 编辑器来说,这些细节就是交付感的一部分。

真正能交付的编辑器,必须同时照顾资源、状态、交互和页面角色。模型越大,场景越复杂,用户越依赖实时演示,这些底层细节越会被放大。今天这轮优化的核心价值,就是把这些容易在 Demo 阶段被忽略的问题,提前压到系统结构里解决。