1. 模块概述

  • 角色换装系统是项目中负责角色外观动态构建与实时切换的模块。
  • 通过骨骼共享 + 蒙皮重绑(SkinnedMeshRenderer 换骨)的方式合并到角色根骨架上,外观即时呈现,不存在多体模型同步动画的开销。
  • 模块同时集成 MagicaCloth2 物理布料和自定义 ColliderComponent,实现”装上披风→披风跟着角色其它部位的胶囊体做物理碰撞”、”卸下盔甲→盔甲自己带的碰撞胶囊体也从其它布料的碰撞列表中干净移除”这种布料/碰撞拓扑的双向同步。最后通过 RendererMaterialManager 通知外观渲染层重建 Renderer 索引,保证材质/特效系统总是看到当前最新的 SkinnedMeshRenderer 集合。

2. 设计思路与架构决策

2.1 为什么用 SkinnedMeshRenderer 换骨而不是子节点挂载?

  • 问题:玩家装备过多的情况下,如果每件装备都是一个独立 GameObject 自带 Animator/骨架,多体动画同步会出现帧延迟、布料抖动、性能成倍开销。
  • 方案:所有装备 Prefab 内只保留 SkinnedMeshRenderer + bones[] 引用,运行时把这些 bones 数组按名字映射到角色根骨架对应的 Transform,然后赋回 renderer.bones。最终所有装备共享一套骨骼,由角色身上唯一的 Animator 驱动。
  • 理由
    • 性能上一套骨骼蒙皮远低于多体同步;
    • 美术工作流上每件装备只要按统一骨骼命名规则导出即可,无需关心运行时绑定;
    • 这也是 Unity 行业内标准的”模块化角色”做法(OutfitSwap),但实现细节坑很多——尤其是装备含有角色没有的额外骨骼时怎么办,2.2 即处理这种情况。

2.2 装备含有”角色根骨架上没有”的额外骨骼时怎么办?

  • 问题:例如披风中段会有几根专属”披风骨”,角色基础骨架上不存在;如果直接绑定就会找不到 Transform,导致 SkinnedMeshRenderer 报错或穿模。
  • 方案:在 EquipMesh 中遇到 boneMap miss 时,沿装备 Prefab 内骨骼的 parent 链回溯,直到找到一个角色身上已存在的祖先骨骼,然后把整段缺失的子链按 Stack 顺序逐一 SetParent 到角色骨架上,并同步登记到 boneMap,方便后续装备复用。
  • 理由
    • 只在缺失时才重建,不破坏正常骨骼结构;
    • 按祖先回溯保证拓扑正确(父子顺序一致);
    • 重建过的骨骼立刻入 boneMap,下一件装备如果用到同一根骨骼就直接命中,避免重复重建;
    • 备选方案是在角色 Prefab 上预先放置所有可能的扩展骨骼,但那样会让”基础角色”承载所有装备的骨骼定义,扩展性差。

2.3 三层缓存的边界划分

  • 问题:一次装备/卸下/再装备/销毁的全流程中,资源加载、实例化、激活/隐藏是不同粒度的开销。如果只用一层 Dict,会么频繁 reload 资源,要么频繁 Instantiate,要么残留 GameObject 没释放。
  • 方案
    • cachedResources : path → AssetOperationHandle(YooAsset 句柄,控制资源引用计数)
    • cachedEquipmentInfoComponent : GUID → InfoComponent(场景里的实例)
    • cachedInfo : path → GUID(外部 API 用 path,内部用 GUID 索引实例)
  • 理由
    • 卸下默认不销毁实例,下一次”再次装备”成本只有 SetActive(true);
    • 资源 Handle 单独缓存,便于在 ClearAll 时统一 Release,避免内存泄漏;
    • 用 GUID(而不是 path 字符串)做实例索引,与运行时 EquipmentInfoComponent.equipmentGUID 保持一致,编辑器与运行时统一一套口径。

2.4 为什么 Equip / Unequip 的 isCachedInstance 是双分支?

  • 问题:换骨/布料初始化只需做一次,反复装备同件衣服不应重复执行。
  • 方案EquipmentInfoComponent.EquipMesh(bool isCachedInstance)EquipMagicaCloth(bool isCachedInstance) 内部分两支:
    • false:完整流程(换骨 + ReplaceTransform + Build)
    • true:仅 SetActive(true) + 把碰撞体重新挂回所有布料的碰撞列表
  • 理由:把”昂贵的初始化”和”廉价的激活”显式分离,让缓存收益直接落到代码路径上,而不是靠运行时去判断”我之前是不是初始化过了”。

2.5 与渲染管线的解耦交互

  • 通过 RendererMaterialManager.UpdateRenderers() 反向通知渲染层”我的 SkinnedMeshRenderer 集合变了”。换装模块只暴露一个调用点,材质替换、特效绑定、描边等所有依赖 Renderer 列表的系统都自动获得最新视图。后续即使加新的渲染类系统,只要走 RendererMaterialManager,换装这边一行代码不用改。

3. 关键代码片段

3.1 装备自动重建额外骨骼(核心)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// swap skinned mesh renderers bones
for (int i = 0; i < renderers.Count; ++i)
{
Transform[] bones = renderers[i].bones;
Transform[] newBones = new Transform[bones.Length];
for (int j = 0; j < bones.Length; ++j)
{
Transform bone = bones[j];
if (!boneMap.TryGetValue(bone.name, out newBones[j]))
{
// Is the bone the renderer itself?
if (bone.name == renderers[i].name)
{
newBones[j] = renderers[i].transform;
boneMap[bone.name] = newBones[j];
}
else
{
// Rebuild Bone:沿父链回溯,找到角色身上存在的祖先后再逐层 SetParent 重建
Stack<Transform> boneHierarchy = new Stack<Transform>();
boneHierarchy.Push(bone);
Transform foundBone = null;
while (bone.parent != null)
{
bone = bone.parent;
boneMap.TryGetValue(bone.name, out foundBone);
if (foundBone != null) break;
boneHierarchy.Push(bone);
}

if (foundBone != null)
{
while (boneHierarchy.Count > 0)
{
Transform insertBone = boneHierarchy.Pop();
insertBone.SetParent(foundBone, false);
foundBone = insertBone;
boneMap[foundBone.name] = foundBone; // ← 重建后立刻入字典,后续装备命中
if (boneHierarchy.Count == 0)
newBones[j] = foundBone;
}
}
else
{
Debug.LogError($"骨骼绑定失败: 找不到 {bone.name} 的对应骨骼。请检查装备与角色的骨骼结构是否兼容");
return;
}
}
}
}
renderers[i].bones = newBones;
Transform rootBone = renderers[i].rootBone;
if (rootBone != null && boneMap.TryGetValue(rootBone.name, out Transform newBone))
{
renderers[i].rootBone = newBone;
}
}

4. Q&A

4.1 你为什么选 SkinnedMeshRenderer 换骨而不是直接挂载子节点?

  • 参考回答
    • 早期是有考虑过子节点挂载的——那样实现最简单,每件装备一个 GameObject Instantiate 到角色身上,自带 Animator 跟着角色 Animator 同步播。
    • 但这个方案有三个硬问题:第一,多 Animator 同步必然有一帧延迟,特别是布料和动画混合时穿模和抖动很明显;第二,性能上 N 件装备就有 N 套骨骼蒙皮和动画采样开销;第三,工程上每件装备都要打包自己的骨架就违背了”模块化角色”的初衷。
    • 换骨方案的本质是把所有装备的 SkinnedMeshRenderer 重定向到角色根骨架上,最终 Unity 看到的是一个角色 + N 个共享骨架的 Renderer,由唯一 Animator 驱动。代价是要写换骨逻辑,特别是处理装备含额外骨骼的场景;但收益是性能和表现一次性达标,工程上美术按统一命名规则出资源即可。这是 Unity 行业里”模块化角色”的标准做法,落地的关键就是怎么把骨架拓扑重建做稳。

4.2 装备 Prefab 上有角色没有的扩展骨骼时你具体怎么处理?

  • 参考回答:场景就是比如披风中段会有专属的”披风骨”,角色基础骨架上没有。我的做法是:
    • 遍历装备 SkinnedMeshRenderer 的 bones 数组,每个 bone 拿 name 去 boneMap 里 TryGetValue,如果找到就直接用;找不到的话——首先排除一种特殊情况,就是这根 bone 名字等于 renderer 自己的名字,那它其实就是 renderer.transform,登记一下就行。
    • 否则就要重建:我用 Stack 把当前 bone 压栈,然后沿 parent 链往上走,每走一步检查这个祖先在不在 boneMap 里,没找到就继续压栈、继续往上。一直走到找到第一个在 boneMap 里的祖先 foundBone,停下来。
    • 接下来弹栈:每弹出一根骨骼就 SetParent 到 foundBone 下面,然后 foundBone 更新成它,并且把这根新骨骼登记到 boneMap。这样整段缺失的子链就被原样移植到了角色骨架对应位置。最后弹空时栈顶那根就是 renderer 真正要绑的目标,赋给 newBones[j]。
    • 这个设计的两个亮点:一是只在缺失时按需重建,不破坏正常拓扑;二是重建后立刻入 boneMap,下一件用同一根披风骨的装备直接命中,效率随穿戴次数递增。