
前阵子项目上线角色外观自定义刚开服就收到大量用户反馈换身时装、组队打本就掉帧。起初团队都以为是美术面数或者特效问题拿 profiler 一查发现真正的元凶是 draw call——一个角色整套时装最多能拆出十几个材质四个人同屏就是四五十个批次移动端直接被打穿。后来重构这套 Spine 换装系统从最原始的整包换皮改成局部换肤加动态合批单角色渲染批次压到 1~2同屏战斗再也没因为外观系统掉过帧。整个过程踩的坑不少这篇把思路链路、关键代码和落地方案完整拆一遍希望能帮到正在做 2D 角色自定义、捏脸换装、UGC 外观系统的朋友。1. 从整包换皮到局部换肤一次真实性能事故复盘1.1 换装系统的三条路线只要是拿到 Spine 资源做换装基本逃不出下面三种实现方式。第一种是整包换皮。美术把每一套外观都导出一份完整的 Spine 工程包含骨骼、动画、皮肤、图集运行时切换整套 SkeletonData。好处是逻辑简单编辑器里怎么拼游戏里就怎么换几乎不用写换装逻辑。坏处是资源量成倍膨胀100 套时装就是 100 份骨骼动画每份都有冗余的动画数据内存和包体都扛不住。第二种是按部位拆包加运行时的 SetAttachment。把骨架动画单独导一份作为基底再把头发、脸、上衣、下装、武器这些部位拆成独立小图集。换装时通过skeleton.SetAttachment(slotName, attachmentName)把对应插槽的附件换掉。这套方案解决了资源复用骨骼动画只有一份但会引入新的渲染问题——每个部位一张图集就是每个部位一个材质单角色 draw call 跟着部位数量线性上涨。第三种就是本文要讲的局部换肤加动态合批。在方案二的基础上把所有换装附件统一到同一张或少数几张图集里运行时把多个 部位 skin 合并成一个复合 skin角色最终只走一个材质提交渲染。这套方案的难点不在换肤本身而在图集编排和运行时数据重建。1.2 全量换皮为什么先被否定我们的项目一开始就是整包换皮。原因很现实美术出资源最快程序也省事一个外观对应一套文件出了问题很容易排查。但上线前压测就绷不住了。外观池子一共 80 套时装每套都带独立的 1024 或 2048 图集再加上基础骨骼动画总资源量冲到了接近 3GB。更难受的是玩家的外观是一个自由组合系统发配了 20 种发型、30 种上衣、25 种下装理论组合数是 15000 种总不能预烘焙 15000 份资源。整包换皮的另一个隐形问题是加载。切换外观时Unity 需要重新加载图集、SkeletonData 和材质首帧卡顿和切换延迟非常明显。用一个中端安卓机测试换整套时装平均耗时 500ms 到 900ms玩家在背包界面点一下要等一秒这个状态下根本没法谈体验。1.3 换装优化盯住三个指标后端出身的同事可能只关心内存和包体但客户端渲染优化我建议团队统一盯住三个数字单角色 draw call正常情况下2D 游戏角色单一个体不应超过 3 个 DC否则同屏批量一上来就爆炸。合批率实际提交批次和顶点批次数量的比例换装后的部件是否真的被合并到同一个渲染调用里。切换耗时玩家从选中外观到实际看到角色的时间标准是缓存命中时不超过 50ms。后面所有重构工作本质都是围绕这三个指标展开。局部换肤解决资源复用动态合批解决 draw call缓存和异步解决切换耗时。2. 吃掉 Spine 换装原理Skin、Slot、Attachment 三者的协作2.1 角色的皮到底是怎么工作的Spine 里一个角色的渲染结构可以粗略理解成三层骨骼Bone负责控制变换插槽Slot是我们能看到的挂点附件Attachment是真正渲染出来的图片或网格。换装不改变骨骼和动画只改变插槽上挂的附件。比如头发这个插槽昨天挂着短发附件今天换成长发附件动画照样播放只是显示的内容变了。皮肤Skin则是这套机制的套餐打包器。一个 Skin 本质上是一张映射表记录了若干个插槽分别挂哪个附件。Spine 官方工程里的默认皮肤就是把所有插槽的初始附件统一管理起来。局部换肤的思路就是为每一个部位单独建一个 Skin运行时把它们合并成一张复合 Skin。2.2 换装配件的数据组织不管是局部换肤还是动态合批资源规范永远是第一位的。我们在项目里定了几条硬性规则所有换装配件必须包含部位前缀例如face_eye_01、hair_long_02、body_armor_03。每个部位对应一个 Slot 名称不允许一个部件跨两个 Slot。所有部位 Skin 共享同一套骨骼命名和动画曲线。换装配件的原点、锚点必须统一以骨骼原点为基准避免换装后模型飘移。这套规范看起来简单但实际执行起来要靠编辑器工具强制检查。我们写了自动化校验Spine 资源导出的 JSON 里扫描所有 Slot 和 Attachment 命名只要不符合规范就直接把 CI 挂掉。否则靠人工检查迟早会在某个版本里出现一件衣服把角色撑飞的事故。2.3 局部换肤的核心流程假设美术已经导出了head.json、body.json等部位资源每个 json 里都包含一个独立的 Skin比如发型的 Skin 叫hair_01上衣的 Skin 叫body_05。运行时要做的是把这些部位 Skin 全部合并到主 SkeletonData 的皮肤集合中。以 spine-csharp 4.x 的 API 为例核心流程是这样// 1. 加载主角色资源 var skeletonDataAsset Resources.LoadSkeletonDataAsset(characters/base); var mainSkeletonData skeletonDataAsset.GetSkeletonData(false); // 2. 遍历所有部位资源每个部位资源都解析成一个 skeletonData var partLoader new SkeletonJson(new AtlasAttachmentLoader(partAtlasAsset)); var partData partLoader.ReadSkeletonData(partJsonText); // 3. 把部位 skin 里的附件全部塞到一个复合 skin 中 var compositeSkin new Skin(avatar_ avatarKey); foreach (var part in parts) { var partSkin partData.FindSkin(part.skinName); if (partSkin ! null) compositeSkin.AddSkin(partSkin); // 注意不同版本 API 命名略有差异 } // 4. 设置到角色上 skeleton.SetSkin(compositeSkin); skeleton.SetSlotsToSetupPose();这里有两个极其容易踩的细节。第一SetSkin之后必须调用SetSlotsToSetupPose()否则插槽还停留在上一个附件状态画面会显示错乱。第二AddSkin这个名字在不同的 spine-unity 版本里不太一样老版本可能叫AddSkin(Skin other)新版本接口变化过最好以自己项目用的运行库源码为准。3. 动态合批把换装附件统一到一张图集3.1 合批失败的根源不在代码在图集很多同学以为换装卡是粒子特效太多或者角色面数爆炸但我拿 profiler 一帧一帧看发现真正的瓶颈是材质数量。Spine 在 Unity 里的渲染流程是这样的SkeletonAnimation会为同一个材质下的所有附件生成一个合并网格换材质就会切 submesh而 submesh 数量直接对应 draw call。也就是说如果头发一张图集、衣服一张图集、武器一张图集单角色至少 3 个 DC。要是时装里还有配件、披风、特效一位数到十来个个 DC 非常常见。所以动态合批的核心目标只有一个让角色身上所有换装配件都走同一张图集、同一个材质。材质少了合批问题就从根源上解决了。3.2 方案一编辑期预烘焙一张换装总集如果项目的外观池是固定的、没有热更编辑期合图是最稳的做法。具体操作是写一个编辑器工具把全部换装配件的小图按部位排列到一张大图上比如 4096x4096 的图集。每个部件占一个矩形区域在 Spine 的 Atlas 文件里对应一个独立的 region。运行时换装时只需要从主 SkeletonData 的 Skin 里找到对应的附件然后SetAttachment即可。这个方案的好处是运行时零压力不涉及纹理合并加载速度和内存都很好控制。单角色因为所有附件都指向同一张图和同一个材质最终渲染批次就是 1。缺点是组合数受限。如果外观池特别大比如 UGC 玩家自己上传的贴图没法在编辑期知道会出现什么组合。这时候就需要走运行时动态合并。3.3 方案二运行时动态合并外部外观玩家上传贴图、活动热更新外观、或者外观来自 AssetBundle 而无法预烘焙时我们需要在运行时把多张源纹理合并成一张运行时图集。一个比较直接的实现是使用 Unity 的Texture2D.PackTextures。public Texture2D PackRuntimeAtlas(ListTexture2D parts, int maxSize 2048) { var packed new Texture2D(maxSize, maxSize, TextureFormat.RGBA32, true); // 第二个参数是 padding留足像素避免边缘采样串色 Rect[] uvRects packed.PackTextures(parts.ToArray(), 4, maxSize); // 这里拿到每个部件在合并纹理中的 uv 矩形 foreach (var part in parts) { var rect uvRects[index]; // 记录矩形的 u/v/u2/v2供下一步重建 attachment 使用 } return packed; }PackTextures会把所有源纹理拷贝到新纹理的对应区域并返回归一化 UV 矩形。拿到矩形之后需要根据这些 UV 重建附件。如果是普通的 RegionAttachment创建伪代码大致是var region new AtlasRegion(); region.name part.name; region.width part.texture.width; region.height part.texture.height; region.u uvRect.x; region.v uvRect.y; region.u2 uvRect.xMax; region.v2 uvRect.yMax; var attachment new RegionAttachment(part.name); attachment.Region region; attachment.X 0; attachment.Y 0; attachment.UpdateOffset();如果是 MeshAttachment则要更细致一些。MeshAttachment 原本的 UV 数组是映射到源图集纹理的合图后需要把原来每一组 UV 都映射到新矩形内var srcMesh (MeshAttachment)oldAttachment; var newMesh new MeshAttachment(srcMesh.Name); newMesh.SetRegion(runtimeRegion); Array.Copy(srcMesh.Vertices, newMesh.Vertices, srcMesh.Vertices.Length); Array.Copy(srcMesh.Triangles, newMesh.Triangles, srcMesh.Triangles.Length); for (int i 0; i srcMesh.UVs.Length; i 2) { newMesh.UVs[i] Mathf.Lerp(uvRect.xMin, uvRect.xMax, srcMesh.UVs[i]); newMesh.UVs[i 1] Mathf.Lerp(uvRect.yMin, uvRect.yMax, srcMesh.UVs[i 1]); }做完这些再把新的附件设置到对应插槽然后调用SetSlotsToSetupPose()。运行时会发现所有部件都指向同一张运行时图集材质自然就统一了。这里要特别强调上述代码是流程示意图不是无脑复制就能跑。不同 spine-unity 版本的Vertices、UVs属性存在差异有的版本 UV 数据不在公开字段里需要通过Attachment.ComputeWorldVertices或者序列化数据间接获取。实现前务必先读一遍自己用的运行库源码。3.4 一个隐藏的大前提原纹理必须能拿到像素数据运行时合图的前提是源纹理能读到像素。Texture2D.PackTextures要求源纹理可读否则会在打包时抛异常。如果外部资源是 AssetBundle 里正常导入的图集Unity 默认不勾选 Read/Write是拿不到像素数据的。两种处理方式一是导入阶段复制一份可读纹理到内存换装时直接用二是通过 RenderTexture 把 GPU 端纹理拷贝出来但会增加一次 GPU 回读的开销尽可能放在加载阶段而不是换装瞬间做。从实际体验看可控外观池在加载阶段预处理最稳UGC 外观这种不可控情况才需要临时处理而且要加上一层耗时遮罩不要让玩家看到界面卡死。4. 落地三件套缓存、异步、内存控制4.1 外观组合缓存把重复打包的结果直接命中动态合批容易让人陷入一个误区既然可以运行时合成那就每次换装都现合。实际上纹理合并是一个比较重的操作包含像素拷贝、纹理创建、UV 重建不可能在玩家点击的瞬间同步执行。我们的做法是引入外观组合缓存。每个外观组合生成一个稳定 Key比如FACE_0123_BODY_0098_HAIR_0234_WEAPON_0001以 Key 为索引缓存合成完成的 Skin 和运行时图集。命中缓存时直接 SetSkin换装耗时可以压到 20ms 以内。缓存不能无限增长否则内存会被撑爆。项目里用 LRU 策略限制最近 32 个组合淘汰时要把运行时创建的图集和 Skin 引用一并释放。4.2 异步加载别让换装卡主线程第一次遇到某个新组合时合成过程不可能瞬间完成。我们的方案是把换装拆成两步第一步先用默认外观或者当前外观继续显示同时启动异步加载加载源纹理和部位数据。第二步所有资源到位后在一个后台线程里做数学计算和 UV 重建最后回到主线程完成纹理合并和 SetSkin。Unity 的纹理合并PackTextures必须在主线程调用但 UV 换算、附件克隆、命名组合这些纯 CPU 的运算可以放到子线程。需要特别注意的是子线程不要碰任何 UnityEngine.Object只处理基础数据类型。我们封装了一个>