3D 编辑器 FBX 模型颜色与动画问题修复记

前言

最近在开发 3D 编辑器(基于 Three.js)时,遇到了两个比较棘手的问题,都与 FBX 模型相关。一个是汽车模型的颜色显示异常(车身变深红/黑色,车轮变灰色),另一个是飞机模型的动画异常(头部某块模型突然变大)。这两个问题的根因都比较隐晦,值得记录一下排查和修复的过程。

问题一:汽车颜色显示异常

现象

加载一个白色的汽车 FBX 模型后:

更奇怪的是,只要在材质面板随便调一下颜色再调回来,颜色就对了。说明颜色数据其实在,只是恢复逻辑出了问题。

排查过程

第一步:看 captureOriginalMaterialState

模型加载时,我们会调用 captureOriginalMaterialState 来保存每个 mesh 的原始材质颜色,方便用户「重置」时恢复:

function captureOriginalMaterialState(obj) {
  if (obj.isMesh && obj.material) {
    const materials = Array.isArray(obj.material) ? obj.material : [obj.material]
    if (materials.length > 0) {
      // 只保存了第一个材质的颜色!
      obj.userData.originalColor = materials[0].color.clone()
    }
  }
}

问题很明显:只保存了第一个材质的颜色。FBX 模型的 Mesh 经常有多个材质(multi-material),比如车身 paint 材质、玻璃材质分开定义。保存第一个材质到 obj.userData.originalColor,然后在 applyMaterialConfigToMaterial 中恢复时,所有材质的颜色都变成了第一个材质的颜色

如果 FBX 文件的面索引映射到第二个材质,那些面就会变成 undefined/黑色。

第二步:看 Material.clone() 的坑

另一个更隐蔽的问题是 Three.js 的 Material.clone() 方法。在 Three.js 中,如果你对一个材质配置调用了 material = material.clone(),它的实现是:

clone() {
  return new this.constructor().copy(this)
}

copy(source) {
  this.userData = JSON.parse(JSON.stringify(source.userData)) // <-- 有问题!
  // ...
}

这里用 JSON.parse(JSON.stringify(userData)) 来深拷贝 userData。但 THREE.Color 对象在 JSON 序列化时变成了一个数字!比如白色 THREE.Color(0xffffff) 会被序列化为 16777215

然后 color.copy(16777215) 会失败,因为 copy 期望的是 THREE.Color 对象。

注:Three.js r180+ 对此做了改进,但早期的版本有这个问题。即使在新版本中,如果手动存储了 THREE.ColoruserData,序列化/反序列化仍然会导致问题。

第三步:综合在一起

captureOriginalMaterialState 保存了 THREE.Colorobj.userData.originalColor,然后 Material.clone() 把它序列化成了数字,然后 color.copy() 恢复时失败了——这就是为什么颜色会变成深红/黑色。

修复方案

修复 1:每个材质独立保存原始颜色

不用 obj.userData,而用 material.userData,并且保存为数字格式以避免序列化问题:

function captureOriginalMaterialState(obj) {
  if (obj.isMesh && obj.material) {
    const materials = Array.isArray(obj.material) ? obj.material : [obj.material]
    for (let i = 0; i < materials.length; i++) {
      const material = materials[i]
      if (!material) continue
      // 保存为十六进制数字,避免 Material.clone() 序列化 Color 对象时的丢失问题
      material.userData.originalColorHex = material.color.getHex()
    }
    // 向后兼容:如果只有一个材质,也保存到 obj.userData
    if (materials.length === 1 && materials[0]) {
      obj.userData.originalColor = materials[0].color.clone()
    }
  }
}

修复 2:恢复时使用数字格式

function applyMaterialConfigToMaterial(material, config, obj) {
  // 重置时恢复原始颜色
  if (reset) {
    // 优先从 material.userData.originalColorHex 恢复
    if (material.userData.originalColorHex !== undefined) {
      material.color.setHex(material.userData.originalColorHex)
    } else if (obj?.userData?.originalColor) {
      material.color.copy(obj.userData.originalColor)
    }
  }
}

这样既解决了多材质覆盖的问题,也避免了 Color 对象序列化丢失的问题。

问题二:FBX 动画 track 名称重复

现象

加载一个飞机 FBX 模型后播放动画,发现头部某块模型突然变大了一圈,然后恢复,反复出现。这不是正常的缩放动画,而是动画系统错误地应用了轨迹数据。

排查过程

打开 Chrome 开发者工具,查看 AnimationClip.tracks,发现 FBX 动画中有很多同名 track:

1.quaternion
1.quaternion  // 重名!
1.position
1.position    // 重名!

Three.js 的 AnimationMixer 在处理同名 track 时,会用最后一个匹配的 track 来驱动对应的节点。也就是说,如果两个骨骼/节点同名(比如都叫 "1"),它们的 .quaternion.position 轨道会互相冲突

FBXLoader 在加载模型时,如果多个节点没有显式命名,可能会生成相同的节点名(比如 "1", "2" 这样的数字命名)。这些节点在场景树中是独立的(不同父节点下),但 track 名称只看节点名,不看路径。

更深入的根因分析

原始修复代码是这样写的:

const nodeNameCounter = new Map()
gltf.animations.forEach(clip => {
  clip.tracks.forEach(track => {
    const dotIdx = track.name.lastIndexOf('.')
    const nodeName = track.name.substring(0, dotIdx)
    const count = nodeNameCounter.get(nodeName) || 0  // <-- bug 1
    nodeNameCounter.set(nodeName, count + 1)
    if (count > 0) {
      const prop = track.name.substring(dotIdx)
      track.name = `${nodeName}_${count}${prop}`       // <-- bug 2
    }
  })
})

这里有两个 bug

  1. 去重 key 是 nodeName 而非完整的 track.name
    比如一个节点有 .quaternion.position 两条 track,它们的 nodeName 都是 "1"
    第一次遇到 "1.quaternion" → count=0,不变。
    第二次遇到 "1.position" → count=1,被重命名为 "1_1.position"——但原本的 .position track 是正确的,不应该被重命名!

    正确的做法是:以完整的 track.name(如 "1.quaternion")作为去重 key,只有当完全相同的 track 名称出现两次时才重命名。
  2. 计数器跨 clip 共享
    如果模型有多个 animation clip(假设 clipA 有 2 个 "1.quaternion"clipB 有 1 个 "1.quaternion"),共享计数器会导致 clipB 中的 "1.quaternion" 被错误重命名,但实际上它在自己的 clip 中并不重复。

修复方案

gltf.animations.forEach(clip => {
  // 每个 clip 内部独立计数
  const trackFullNameCounter = new Map()
  clip.tracks.forEach(track => {
    const count = trackFullNameCounter.get(track.name) || 0
    trackFullNameCounter.set(track.name, count + 1)
    if (count > 0) {
      const dotIdx = track.name.lastIndexOf('.')
      if (dotIdx <= 0) return
      const nodeName = track.name.substring(0, dotIdx)
      const prop = track.name.substring(dotIdx)
      track.name = `${nodeName}_${count}${prop}`
    }
  })
})

核心改动:

同时,场景中的节点名也需要做同样的去重(这部分逻辑原本就是正确的):

const nodeNameCounter = new Map()
group.traverse(obj => {
  if (!obj.name) return
  const count = nodeNameCounter.get(obj.name) || 0
  nodeNameCounter.set(obj.name, count + 1)
  if (count > 0) {
    obj.name = `${obj.name}_${count}`
  }
})

经验教训

  1. 永远不要假设 Material.clone()userData 是安全的。如果你的 userData 中包含 Three.js 的自定义对象(如 Color, Vector3),序列化/反序列化会破坏它们。建议只存纯 JSON 可序列化的数据(数字、字符串、数组)。
  2. 多材质 Mesh 的处理要格外小心Array.isArray(obj.material) 分支往往被忽略,而 FBX 模型恰好大量使用多材质。
  3. 动画 track 的去重逻辑不能只看节点名。同一个节点可以有 .quaternion.position.scale 等多个 track,如果用节点名去重会导致假阳性重命名。
  4. 计数器的作用域很重要。跨 clip 共享计数器会导致错误的「副作用」——一个 clip 的重复计数不应该影响另一个 clip。
  5. FBX 模型的坑比 GLB 多。GLB/glTF 作为标准格式,通常有更规范的数据结构。FBX 作为专有格式,FBXLoader 生成的 Three.js 对象往往需要额外的手动修复。

结语

这两个 bug 表面上看是不相关的(一个在材质系统,一个在动画系统),但根因都指向了数据结构的边界情况处理不当——多材质数组的遍历遗漏、对象序列化的隐式转换、Map 作用域的误用。

写代码时「正常路径」往往很通畅,但「边界情况」才是真正的陷阱。这次的经验提醒我们:永远假设你处理的数组可能有多个元素,永远假设你保存的数据可能会被序列化

希望这篇记录对遇到类似问题的朋友有帮助。