Unity 角色换装系统
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中遇到boneMapmiss 时,沿装备 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 | // swap skinned mesh renderers bones |
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,下一件用同一根披风骨的装备直接命中,效率随穿戴次数递增。
评论





