【海量 Spine 合批】Spine同材质多纹理方案

基于 Cocos Creator 2.4.12,Web 平台。

一、背景

Web 端如果同屏需要显示上千只 Spine 怪物,叠加 Zindex 排序,对于DrawCall 来说可以说是灾难了。

我之前做过一次图片多纹理合批的尝试,性能优化5-MutilTexture地图物件多图渲染合批 其实当时就有在想,Spine 能不能也用一样的操作优一下呢?(最近又想起这件事来了,折腾一下。

二、思路

怪物Spine导致合批打断最常见的就是使用的图片发生了切换,导致引擎计算出材质实例的哈希值不同。那如果我们能够让所有的怪物使用相同的材质,这个问题不就解决了吗?

如果我们不合并贴图(要也行,但没必要一来就上最高难度是吧),只是:

  • 所有怪物共用一个材质,这个材质同时绑定多张纹理;
  • 在 Shader 中选择不同的纹理,进行采样。

哎好像有点靠谱了嗷。

怎么实现纹理的选择呢?这次又想到一个骚操作,在Sprite 合批里面,年轻的我还只会增加顶点数据的字段,但今日之我已非昨日之我!

UV 数据中的 U,表示顶点对应图片中的采样点位置(X,Y),一般是一个[0, 1]的数字,但其实,它是一个 float32 对象,这也就意味着,它可以是 0.5,也可以是 1.5、更可以是 2.5!

既然有这个本事就别浪费了,这样:
如果你是[0,1],那你就是第一张图片,数值还是表示图片中对应的位置
如果你是[2,3],那你就是第二张图片,但是采用计算的时候数字要-2
…

这不就既要又要了!毕竟在 Spine 这种复杂内容下,多一个顶点数据,也是一笔不小的开销。
(题外话:既然X能够复用,那是不是Y也…)

不过到这里其实和Sprite没什么太大区别。Spine的重点是Attachment,它们分别表示了每一个我们能够看到的部件(手、头、武器…),所以其实Spine的本质也是一堆图片。
所以我们要做的就是把同一个材质挂到所有Attachment上!

三、实现

1. 多纹理 Shader:按 UV 选择采样器

文件:monster-spine.effect

UV 编码规则:u' = u + 2 × 槽位下标。这样各槽位的 u.x 就会落在互不重叠的区间里:

槽位 0 1 2 … 7
u.x 区间 [0, 1] [2, 3] [4, 5] … [14, 15]

顶点着色器解出槽位下标,并还原原始 UV:

// 加 0.5 再 /2 取整
float slot = floor((a_uv0.x + 0.5) * 0.5);
v_slot = slot;
v_uv0 = vec2(a_uv0.x - slot * 2.0, a_uv0.y);

片元着色器按槽位下标选择采样器:

// GLSL ES 1.0 不支持用变量下标访问数组,只能分支选择,做个二分小优化
vec4 sampleSlot (float slot, vec2 uv) {
  if (slot < 3.5) {
    if (slot < 1.5) {
      if (slot < 0.5) return texture2D(texture, uv);
      return texture2D(texture1, uv);
    }
    if (slot < 2.5) return texture2D(texture2, uv);
    return texture2D(texture3, uv);
  }
  // ... 槽位 4 ~ 7 同理
}

3. 改写 Spine 数据:UV 偏移 + 纹理

文件:SlotSkinPatcher.ts

对所有怪物的 SkeletonData,遍历它的所有 atlas 区域:

export function patchToSlot(data: sp.SkeletonData, slot: number, texture: cc.Texture2D): void {

    const skelTex = new sp.SkeletonTexture({ width: own.width, height: own.height });
    skelTex.setRealTexture(texture);

    atlasRegions(data).forEach((region) => {
        region.u += slot * 2;       // UV 加上偏移值
        region.u2 += slot * 2;
        region.texture = skelTex;   // 统一换成第一张图片
    });
    refreshAttachmentUVs(data);     // 附件在创建时已算好 UV,需要重新计算
}

重点:

  • 纹理必须是槽位 0 的贴图。 引擎取材质时会把 texture 属性覆盖成区域的纹理。如果槽位 0 放的是别的贴图,就会被覆盖掉。统一设置也能保证哈希值的计算结果。
  • 使用 SHARED_CACHE 时要作废旧缓存。 动画缓存是按旧 UV 烘焙的,改完 UV 后需要调用 invalidAnimationCache() 重新烘焙。

4. 共享材质:一次绑定 8 张纹理

文件:SlotMaterial.ts

const SLOT_COUNT = 8;

constructor(effect: cc.EffectAsset, textures: cc.Texture2D[]) {
    for (let i = 0; i < SLOT_COUNT; i++) {
        this._textures[i] = textures[i] || textures[0];   // 空槽位用第一张图占位
    }
    this._material = cc.Material.create(effect);
}

/** 应用纹理到材质变体上,保证图集顺序一致,哈希值也会一致 */
apply(skeleton: sp.Skeleton): void {
    const variant = skeleton.setMaterial(0, this._material);
    for (let i = 0; i < SLOT_COUNT; i++) {
        variant.setProperty(i === 0 ? "texture" : "texture" + i, this._textures[i]);
    }
}

5. 骨骼接入

文件:MinBatchTest.ts

const skeleton = node.addComponent(sp.Skeleton);
skeleton.enableBatch = true;          // 开启合批
skeleton.setAnimationCacheMode(sp.Skeleton.AnimationCacheMode.SHARED_CACHE);
material.apply(skeleton);             // 使用共享多纹理材质
skeleton.skeletonData = data;         // data 需已经过 patchToSlot 处理

整体流程:

  1. 预加载怪物的Spine资源;
  2. 取每种怪物的贴图,按顺序放入槽位,创建共享材质;
  3. 对每种怪物的 SkeletonData 执行 patchToSlot;
  4. 生成怪物时套用共享材质。

四、效果

测试条件:3000 只怪、5 种贴图(伪)随机混排、54万 顶点。小米15Pro,在运行5秒后开始统计FPS均值,并等待均值相对稳定后记录。

共享材质关 共享材质开
材质种类 5 1
DrawCall 2417 16
平均FPS 25.7 33

开启后的 16 次 DrawCall 中,怪物占 9 次,剩下的是面板和调试信息。
按照预期,怪物应该可以完全合批,最后剩下1次DrawCal才对,为什么呢?
这里还有一个限制:每个批次的最多只能包含 65535 个顶点,所以如果你使用含有了较复杂的 Spine(一个Spine有大量顶点),或者数量过多,就会容易出现合批后DrawCall > 1的情况。

五、一些其他内容

实话说做完验证后,效果并没有我想象中那么巨大,最开始在PC上测试,甚至效果比手机还小。
后来想通了:DrawCall 的优化本质上只是减少了多次提交导致的一些额外损耗,但是并不能解决顶点数量多等本质问题。

然后以下是一些拓展内容:

  • 单材质建议 8 张纹理, WebGL1 保证片元着色器至少有 8 个纹理单元。
  • 怪物种类超过 8 种,可以在运行时把多张贴图合进同一张图集页,再把图集页放进槽位,甚至实现动态合图。但因为贴图变化,会需要重新处理缓存,也会有额外的开销。
  • 片元着色器多了分支采样,有少量额外的 GPU 开销,具体的效果可能因设备不同。
  • 方案只减少了 DrawCall,顶点数和 CPU 每帧填充顶点的开销不变,不能期望DC优化带来巨幅提升。或许降低 Spine 的复杂度和图片数量也是一个不错的优化手段(2D减面数?)。
1赞