前言
最近在开发 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.Color到userData,序列化/反序列化仍然会导致问题。
第三步:综合在一起
captureOriginalMaterialState 保存了 THREE.Color 到 obj.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:
- 去重 key 是
nodeName而非完整的track.name
比如一个节点有.quaternion和.position两条 track,它们的nodeName都是"1"。
第一次遇到"1.quaternion"→ count=0,不变。
第二次遇到"1.position"→ count=1,被重命名为"1_1.position"——但原本的.positiontrack 是正确的,不应该被重命名!
正确的做法是:以完整的track.name(如"1.quaternion")作为去重 key,只有当完全相同的 track 名称出现两次时才重命名。 - 计数器跨 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}`
}
})
})
核心改动:
- 使用
track.name(完整名称如"1.quaternion")作为 Map 的 key - 每个
clip初始化一个新的 Map,不共享
同时,场景中的节点名也需要做同样的去重(这部分逻辑原本就是正确的):
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}`
}
})
经验教训
- 永远不要假设
Material.clone()的userData是安全的。如果你的userData中包含 Three.js 的自定义对象(如Color,Vector3),序列化/反序列化会破坏它们。建议只存纯 JSON 可序列化的数据(数字、字符串、数组)。 - 多材质 Mesh 的处理要格外小心。
Array.isArray(obj.material)分支往往被忽略,而 FBX 模型恰好大量使用多材质。 - 动画 track 的去重逻辑不能只看节点名。同一个节点可以有
.quaternion、.position、.scale等多个 track,如果用节点名去重会导致假阳性重命名。 - 计数器的作用域很重要。跨 clip 共享计数器会导致错误的「副作用」——一个 clip 的重复计数不应该影响另一个 clip。
- FBX 模型的坑比 GLB 多。GLB/glTF 作为标准格式,通常有更规范的数据结构。FBX 作为专有格式,FBXLoader 生成的 Three.js 对象往往需要额外的手动修复。
结语
这两个 bug 表面上看是不相关的(一个在材质系统,一个在动画系统),但根因都指向了数据结构的边界情况处理不当——多材质数组的遍历遗漏、对象序列化的隐式转换、Map 作用域的误用。
写代码时「正常路径」往往很通畅,但「边界情况」才是真正的陷阱。这次的经验提醒我们:永远假设你处理的数组可能有多个元素,永远假设你保存的数据可能会被序列化。
希望这篇记录对遇到类似问题的朋友有帮助。