GLB 材质改了没反应,重置还会残留:材质覆盖键与预览重建链路复盘

做 3D 编辑器的人几乎都遇到过这个反馈:模型已经上传成功,右侧材质面板里也能看到材质项,颜色、金属度、粗糙度都能改,结果画布里的模型纹丝不动。用户会直接把它归类为“材质功能失效”,但真正的根因往往更隐蔽,因为它发生在 UI 列表和运行时材质对象之间的映射层。

这次我们在 SceneView Lite 和完整版主编辑器里先后遇到了一组连续问题。第一层现象是外部导入的 GLB 材质列表能选中,右侧参数能改,预览和正式应用却没有任何效果。第二层现象更迷惑:部分模型在连续调色、改粗糙度、再点重置之后,颜色像是越改越深,重置也恢复不干净。继续向下追后发现,这两类问题分别落在材质系统的两个基础层:材质键映射和预览回放基线。

一、问题现象为什么容易误判

这类问题最容易把人带偏,因为表面上看,系统的很多环节都是“正常”的:

也正因为这些都“像是好的”,团队一开始通常会优先怀疑材质类型不对、贴图覆盖冲突、Phong 和 Standard 转换有问题,或者以为是某个 loader 对 GLB 的材质恢复不完整。可这次真正失效的点比这些都更早,属于目标材质查找阶段。

二、根因:列表用一个名字,应用时找另一个名字

问题来自这条链路的不一致:

  1. 编辑器收集材质列表时,会遍历模型内所有 mesh 和材质。
  2. 如果材质本身有 material.name,就直接拿它做 key。
  3. 如果材质没有名字,列表逻辑会用 Material_${index} 兜底。
  4. 用户在面板里操作时,实际写入的是这个兜底 key 对应的 materialOverrides
  5. 但运行时真正应用覆盖时,旧逻辑却只按 material.name 查。

这就造成了一个非常典型的数据断层:面板里看到的是 Material_0Material_1,渲染侧实际去找的却是空字符串。列表上选中了目标,运行时却没有任何材质能命中。

// 列表收集阶段
const name = mat.name || `Material_${i}`

// 旧的应用阶段
const matName = material.name || ''
const materialOverride = matName ? model.materialOverrides?.[matName] : null

如果导入模型里所有材质都自带命名,这套逻辑表面上完全没问题;只有在遇到“无名材质”时,它才会暴露出来。很多设计师导出的 GLB 正好就会命中这个边界条件,所以这个 bug 在真实项目里出现频率并不低。

三、为什么无名材质在 GLB 里很常见

不少团队默认以为从 Blender、3ds Max、Maya 导出的模型一定会带材质名,实际并不稳定。下面几种情况都可能让 material.name 为空:

对“纯展示播放器”来说,这不算问题,因为渲染层只要拿到材质实例就能显示;对“可编辑的 3D 场景编辑器”来说,这就是基础数据缺口,因为面板侧必须要有一个稳定 key,才能把人类操作映射回具体材质。

四、稳定修复方案:统一材质覆盖键

这次修复的核心思路很简单:整个系统只认一套材质键规则,列表、预览修改、正式应用、批量应用、材质元数据收集全部共用它。具体实现是抽出一个统一函数 getMaterialOverrideKey

function getMaterialOverrideKey(material, index = 0) {
  const explicitName = typeof material?.name === 'string' ? material.name.trim() : ''
  if (explicitName) return explicitName

  const userDataKey = typeof material?.userData?._overrideKey === 'string'
    ? material.userData._overrideKey.trim()
    : ''
  if (userDataKey) return userDataKey

  return `Material_${index}`
}

这个规则有三个层次:

优先级来源用途
1material.name优先保留外部模型已有的显式材质名
2material.userData._overrideKey在材质 clone、转换或复用后仍保持稳定命名
3Material_${index}给无名材质提供兜底 key

这一步看起来像一个小工具函数,实际上是把编辑器里的“材质引用协议”明确下来了。只要协议统一,材质系统就能稳定工作。

五、为什么还要把 key 写进 userData

很多人第一反应会是:既然兜底规则已经有了,运行时直接每次重新算 Material_${index} 不就够了。这个想法对最简单的单次遍历成立,但对真实 3D 编辑器不够稳,因为运行时还会发生这些事:

如果 key 只依赖“当前这一次遍历下标”,一旦材质顺序在某条链路里变化,列表 key 和应用 key 仍然有漂移风险。所以我们在捕获材质原始状态时,顺手把统一 key 固化进 material.userData._overrideKey

materials.forEach((mat, idx) => {
  if (!mat) return
  mat.userData = mat.userData || {}
  if (!mat.userData._overrideKey) {
    mat.userData._overrideKey = getMaterialOverrideKey(mat, idx)
  }
})

这样做之后,哪怕材质对象后续被复制、替换、升级成另一种材质类型,只要 userData 被保留下来,编辑器就仍然能通过同一个键命中它。

六、修复时必须同时改的四条路径

这类问题很容易“修一半”。如果只修列表,用户能看见材质;如果只修正式应用,拖动预览时还是没反应;如果只修单次应用,批量重刷后又回退。稳定方案必须把四条路径同时拉齐:

1. 材质列表元数据收集

const name = getMaterialOverrideKey(mat, i)
if (!model.materialsMeta[name]) {
  model.materialsMeta[name] = { name, meshName: child.name || 'Mesh' }
}

2. 右侧面板拖动时的预览修改

if (materialName && getMaterialOverrideKey(material, i) !== materialName) continue

3. 正式应用材质覆盖

const matName = getMaterialOverrideKey(material, i)
const materialOverride = matName ? model.materialOverrides?.[matName] : null
if (materialOverride) applyMaterialConfigToMaterial(material, materialOverride, child)

4. 批量应用和重建材质元数据

大模型和复杂场景常常走批处理链路,这一段如果不改,重新加载、批量刷新、切场景后问题会再次出现。也正因为这一点,这次修复我们同时同步到了 Lite 和完整版主编辑器,而不是只修一个项目。

七、第二个根因:预览直接叠加 patch,重置就会残留

把无名材质 key 修好之后,我们又收到了第二类反馈:有些模型第一次调色是生效的,但多拖几次颜色、透明度、金属度之后,材质会越来越偏,点重置也不能完全回到初始状态。用户的体感通常会描述成“颜色在叠加”“越调越脏”“重置不彻底”。

这类问题的关键特征是:正式保存后的最终效果有时是对的,问题主要出现在右侧面板拖动滑块和颜色选择器的实时预览阶段。也就是说,状态层记录值本身未必错,错的是预览如何把这次改动应用到运行时材质实例上。

旧逻辑大致是这样的:

applyMaterialConfigToMaterial(material, model.material, child)
applyMaterialConfigToMaterial(material, partMaterial, child)
applyMaterialConfigToMaterial(material, patch, child)

表面上看这很自然,实际问题在于这里的 material 已经是“上一轮预览改过后的材质实例”。当用户连续拖动滑块时,新的 patch 不是基于原始材质重新计算,而是在当前已经被修改过的颜色、PBR 参数和贴图引用上继续叠加。这样一来,任何没有在本轮 patch 里显式覆盖的字段,都可能残留上一轮状态。

这也是为什么用户会看到几种典型异常:

八、稳定修复方案:每次预览都从原始材质重建

这类问题的稳定修法和前面的 key 修复很像,本质上也是统一协议。预览链路不再把 patch 直接叠到“当前材质”,而是每次都按同一顺序从原始材质重新计算一遍:

  1. 先恢复材质原始快照,也就是 reset 到模型初始材质状态。
  2. 重放全局材质配置 model.material
  3. 重放当前部件的材质配置 partMaterial
  4. 重放当前材质已有的持久化覆盖 materialOverrides[materialName]
  5. 最后再应用本次拖动产生的临时预览 patch。
applyMaterialConfigToMaterial(material, { reset: true }, child)
applyMaterialConfigToMaterial(material, model.material || {}, child)
if (partMaterial) {
  applyMaterialConfigToMaterial(material, partMaterial, child)
}
if (storedMaterialOverride) {
  applyMaterialConfigToMaterial(material, storedMaterialOverride, child)
}
applyMaterialConfigToMaterial(material, previewPatch, child)

这样做之后,预览就从“增量叠加”变成了“基于固定基线的完整重放”。用户每拖一次滑块,Three.js 场景里看到的结果都是这一次输入对应的最终状态,而不是很多轮历史预览叠加后的偶然结果。

这一步还有一个额外好处:重置逻辑终于和预览逻辑用上了同一条基线。只要原始快照记录完整,点击重置就会稳定回到初始材质,而不是只能恢复其中一部分字段。

九、为什么预览链路还要补 PBR 材质转换

预览回放改成“从原始材质重建”之后,还有一个细节必须补齐:有些外部模型材质最初是 MeshPhongMaterialMeshLambertMaterial,而右侧面板又允许用户实时预览 metalnessroughnessenvMapIntensity 这类 PBR 参数。如果预览阶段不做材质升级,正式应用和预览看到的材质能力就不一致。

所以这次我们在预览链路里也加了同样的判断:只要本轮 patch、全局材质、部件材质或当前材质 override 中出现了 PBR 参数,预览阶段就先把 Phong/Lambert 材质转换成 MeshStandardMaterial,再走后续的基线重放。这保证了拖动滑块时看到的效果和最终落库后的效果是同一套逻辑。

十、这类问题为什么用户感知特别差

从用户视角看,“材质改了没反应”属于非常伤信任的一类问题,因为它不像加载失败那样明显。加载失败至少知道系统坏了,材质面板失效则会让人产生更糟的判断:系统看起来功能很多,但关键能力不可靠。

对于做产品展示、工业演示、展厅交互的客户来说,材质编辑是高频动作。他们会直接调整车漆颜色、金属反射、磨砂程度、玻璃透明度。如果面板有参数、模型却不动,编辑器在商业场景里的专业感就会快速下降。

所以从产品角度看,这次修复价值并不只是“补了一个边界条件”,而是让 SceneView 在外部导入模型上的材质编辑链路更接近可交付状态。真正可卖、可交付的 3D 编辑器,必须把这类看似细小的运行时映射问题抹平。

十一、排查这类材质问题的实用清单

以后再遇到“材质改了不生效”,可以按下面这个顺序排查,效率会高很多:

检查点确认内容
材质列表model.materialsMeta 里是否真的收到了目标材质
材质键列表 key、预览 key、正式应用 key 是否完全一致
无名材质material.name 为空时是否有稳定兜底
预览基线每次拖动时是否先回到原始材质,再重放已有配置
重置逻辑重置是否真正恢复了颜色、PBR、透明度、发光和贴图引用
克隆链路材质 clone 或类型转换后,稳定 key 是否被保留
批处理链路批量应用是否复用了同一套 key 规则
PBR 转换Phong/Lambert 转 Standard 后,覆盖是否仍命中

如果一个系统同时支持 GLB、FBX、OBJ、多材质 mesh、批量刷新、局部部件材质覆盖,这份清单几乎每次都能用上。

十二、结论

这次材质问题最后落到了两个根因上。第一,无名材质在面板展示链路里使用了 Material_${index},在运行时应用链路里却只按 material.name 查找,导致状态层能记录修改,渲染层却找不到目标材质。第二,预览链路直接在当前材质实例上叠加 patch,导致连续编辑后历史状态残留,用户看到的结果就会越来越脏,重置也恢复不完整。

稳定修复方案也很明确:先抽出统一的材质覆盖键函数,把同一套 key 规则落到材质列表、预览修改、正式应用、批量应用和材质原始状态记录里,再通过 userData._overrideKey 固化下来;再把预览链路改成“每次都从原始材质重建并完整重放配置”,让预览、重置和最终应用共用同一条基线。

对 3D 场景编辑器来说,真正难的地方从来不是把一个模型渲染出来,而是把“用户看到的控制项”“预览阶段的临时结果”和“最终生效的运行时对象”严格一一对应起来。SceneView 这次补上的,就是材质编辑链路里最影响交付体验的两段基础工程能力。