spine换装实战总结(2.4.7)

背景

最近项目中用到spine的换装需求,官方文档里有提及,但也仅仅能作为思路参考,照搬到项目中并不实用。查找了一些资料,但感觉都不很完善。主要的问题:
一般都只描述了针对单一外部图片的换装。
会打断spine的合批渲染。

考虑我们的项目需求:数十个英雄,7,8种装备,有的装备会由多个部件组成(例如肩甲分左右),有的会有序列帧特效,每种的数量上不封顶。这导致:
一定会需要用图集的方式来管理众多的装备资源。
在不处理合批渲染的情况下,一个英雄换装后理论上drawcall数量为2n+1,n为更换的部件数,单个英雄轻松上20 +。所以要解决合批渲染的问题,否则drawcall压力非常大。
spriteFrame到region

spine换装的基本原理是更换attachment的region,spine里的region概念和creator中spriteFrame对应。我们使用图集管理装备资源的话,加载后得到也是spriteFrame,所以第一步是根据spriteFrame构建region信息。核心代码:
makeRegionIno(frame: cc.SpriteFrame, key: string): regionInfo {
let texture = frame.getTexture()
let rect = frame.getRect()
let origSize = frame.getOriginalSize()
let offset = frame.getOffset()
let rotate = frame.isRotated()

    let info: regionInfo = {
        key: key,
        rect: rect,
        origSize: origSize,
        offset: cc.v2(
            (origSize.width - rect.width) * 0.5 + offset.x,
            (origSize.height - rect.height) * 0.5 + offset.y
        ),
        degrees: rotate ? 270 : 0,
        texture: texture,
    }
    return info
}

其中参数解释:
rect表达了散图在图集中的位置和大小,origSize表达了散图在包含不透明区域时的原始大小。这两个参数在region和spriteFrame里的定义是一致的。
offset则不一样:在spriteFrame里offset描述的是散图的不透明区域偏离中心的量,在region里描述的是偏离左上角的量(图集坐标系以左上角为原点),这里用图示说明一下。

编辑
添加图片注释,不超过 140 字(可选)
degrees表达散图旋转的角度。这里也不一样:在spine本身导出时打包的图集中散图旋转设定为逆时针方向旋转也就是90度,而一般打包工具多为顺时针方向(这里我用的TexturePacker),也就是270度。degrees参数的使用后面会看到。
texture就是图集的纹理对象。
然后就可以根据构建的region信息来实现换装,核心代码:
updateRegion(attachment: any, regionInfo: regionInfo) {
let region = attachment.region

    let texture = regionInfo.texture;
    let rect = regionInfo.rect
    let origSize = regionInfo.origSize
    let offset = regionInfo.offset
    let degrees = regionInfo.degrees

    region.x = rect.x
    region.y = rect.y
    region.width = rect.width
    region.height = rect.height
    region.originalWidth = origSize.width
    region.originalHeight = origSize.height
    region.offsetX = offset.x
    region.offsetY = offset.y

    region.rotate = degrees != 0
    region.degrees = degrees

    if (region.rotate) {
        region.u = rect.x / texture.width
        region.v = rect.y / texture.height
        region.u2 = (rect.x + rect.height) / texture.width
        region.v2 = (rect.y + rect.width) / texture.height
    } else {
        region.u = rect.x / texture.width
        region.v = rect.y / texture.height
        region.u2 = (rect.x + rect.width) / texture.width
        region.v2 = (rect.y + rect.height) / texture.height
    }

    let skeletonTexture = new sp.SkeletonTexture({
        width: texture.width,
        height: texture.height
    });
    skeletonTexture.setRealTexture(texture);
    region.texture = skeletonTexture

    if (attachment instanceof sp.spine.MeshAttachment) {
        attachment.updateUVs()
    } else if (attachment instanceof sp.spine.RegionAttachment) {
        let uvs = attachment.uvs;
        if (region.degrees == 90) {
            uvs[2] = region.u;
            uvs[3] = region.v2;
            uvs[4] = region.u;
            uvs[5] = region.v;
            uvs[6] = region.u2;
            uvs[7] = region.v;
            uvs[0] = region.u2;
            uvs[1] = region.v2;
        } else if (region.degrees == 270) {
            uvs[6] = region.u;
            uvs[7] = region.v2;
            uvs[0] = region.u;
            uvs[1] = region.v;
            uvs[2] = region.u2;
            uvs[3] = region.v;
            uvs[4] = region.u2;
            uvs[5] = region.v2;
        } else {
            uvs[0] = region.u;
            uvs[1] = region.v2;
            uvs[2] = region.u;
            uvs[3] = region.v;
            uvs[4] = region.u2;
            uvs[5] = region.v;
            uvs[6] = region.u2;
            uvs[7] = region.v2;
        }
        attachment.updateOffset()
    }
}

主要过程就是计算region 的 uv,并填充attachment的uvs,这是attachment渲染时使用的顶点序列。
其中要说明的是,当attachment的类型为MeshAttachment时,直接调用updateUVs函数,spine的runtime中已经处理了各种旋转角度的情况,可查阅引擎源码(或直接去官网下载ts版的runtime来看)。

编辑

切换为居中
各种选装角度的处理
但是attachment的类型为RegionAttachment时,runtime库只处理了默认的90度的情况,所以要自己添加270度情况的处理。

编辑

切换为居中
只处理了默认90度的情况
对于RegionAttachment的类型,还需要调用updateOffset,这是因为换上去的装备的不透明区域和原来的装备并不一定是完全重合的,也就是region的offset参数可能发生了变化。而MeshAttachment的类型不需要(实际spine也没有实现)是因为spine导出图集时,使用了mesh网格的图片都不会裁剪透明区域(mesh的顶点可能绑定在图片的透明区域)也就是offset都为0。这也意味着如果我们换的是带有mesh的装备,在打图集的时候也不能裁剪透明区域。我这里是把两类装备分别打图集管理。
多纹理材质实现合批

先看源码,spine的渲染数据组装部分:

编辑

切换为居中
根据attachment信息获取材质

编辑

切换为居中
用作材质缓存的key
spine的Assembler在组装渲染数据时,遍历所有待渲染的Attachment,根据tex.getId() + src + dst + _useTint + useModel组成的key来确定是否切换material,也就是不同的纹理就会导致切换材质而断批。我们的思路就是让这个key中不再包含纹理ID,也就不会因为Attachment的纹理不同而断合批。(当然如果spine制作时使用了不同的混合模式还是会断批的)。
这里我用了染色模式下的darkColor的alpha通道来传递纹理序号。这个通道在spine的runtime中是没有用的,引擎这边用他来传递了是否是alpha预乘的信息。alpha预乘对于spine组件来说是个全局的信息,所以我把它拆出来作为一个uniform变量,腾出了这个通道给纹理序号(否则要修改顶点格式,有点兴师动众)。因此,spine的染色模式(_useTint)要确保开启。
核心代码(位于spine-assembler.js):
// 纹理序号,用于对应材质的uniform变量
let _texIdx = 0

function _getSlotMaterial(tex, blendMode) {
let src, dst;
switch (blendMode) {
case spine.BlendMode.Additive:
src = _premultipliedAlpha ? cc.macro.ONE : cc.macro.SRC_ALPHA;
dst = cc.macro.ONE;
break;
case spine.BlendMode.Multiply:
src = cc.macro.DST_COLOR;
dst = cc.macro.ONE_MINUS_SRC_ALPHA;
break;
case spine.BlendMode.Screen:
src = cc.macro.ONE;
dst = cc.macro.ONE_MINUS_SRC_COLOR;
break;
case spine.BlendMode.Normal:
default:
src = _premultipliedAlpha ? cc.macro.ONE : cc.macro.SRC_ALPHA;
dst = cc.macro.ONE_MINUS_SRC_ALPHA;
break;
}

let useModel = !_comp.enableBatch;
let baseMaterial = _comp._materials[0];
if (!baseMaterial) return null;

// The key use to find corresponding material
// 材质的key不再包含纹理的id
// let key = tex.getId() + src + dst + _useTint + useModel;
let key = src + dst + _useTint + useModel;
let materialCache = _comp._materialCache;
let material = materialCache[key];

!_comp.textures && (_comp.textures = {})

if (!material) {
    if (!materialCache.baseMaterial) {
        material = baseMaterial;
        materialCache.baseMaterial = baseMaterial;
    } else {
        material = cc.MaterialVariant.create(baseMaterial);
    }

    material.define('CC_USE_MODEL', useModel);
    material.define('USE_TINT', _useTint);
    //增加是否alpha预乘的uniform变量
    material.define('PREMULTIPLIED_ALPHA', _premultipliedAlpha);
    // update texture
    // material.setProperty('texture', tex);

    // update blend function
    material.setBlend(
        true,
        gfx.BLEND_FUNC_ADD,
        src, dst,
        gfx.BLEND_FUNC_ADD,
        src, dst
    );
    materialCache[key] = material;
}

// 根据纹理id设置序号
let texID = tex.getId()
if (_comp.textures[texID] == undefined) {
    let idx = Object.keys(_comp.textures).length
    _comp.textures[texID] = idx

    Object.values(materialCache).forEach(mat => {
        mat.setProperty('texture' + (idx <= 0 ? "" : idx), tex);
    })
}
_texIdx = _comp.textures[texID]

return material;

}

assembler的 fillVertices函数中:
//将_darkColor的alpha通道用来传递纹理序号
// _darkColor.a = _premultipliedAlpha ? 255 : 0;
_darkColor.a = _texIdx;
shader:

CCEffect %{
techniques:

  • passes:
    • vert: vs
      frag: fs
      blendState:
      targets:
      • blend: true
        rasterizerState:
        cullMode: none
        properties:
        texture: { value: white }
        texture1: { value: white }
        texture2: { value: white }
        texture3: { value: white }
        texture4: { value: white }
        texture5: { value: white }
        texture6: { value: white }
        texture7: { value: white }
        alphaThreshold: { value: 0.5 }
        coverColor: {
        value: [1.0, 1.0, 0.0, 1.0],
        }
        }%

CCProgram vs %{

precision highp float;

#include
#include

in vec3 a_position;
in vec4 a_color;
#if USE_TINT
in vec4 a_color0;
#endif

in vec2 a_uv0;
out vec2 v_uv0;

out vec4 v_light;
#if USE_TINT
out vec4 v_dark;
#endif

void main () {
mat4 mvp;

#if CC_USE_MODEL
mvp = cc_matViewProj * cc_matWorld;
#else
mvp = cc_matViewProj;
#endif

v_uv0 = a_uv0;

v_light = a_color;
#if USE_TINT
v_dark = a_color0;
#endif

gl_Position = mvp * vec4(a_position, 1);
}

}%

CCProgram fs %{

precision highp float;

uniform sampler2D texture;
uniform sampler2D texture1;
uniform sampler2D texture2;
uniform sampler2D texture3;
uniform sampler2D texture4;
uniform sampler2D texture5;
uniform sampler2D texture6;
uniform sampler2D texture7;
in vec2 v_uv0;

in vec4 v_light;
#if USE_TINT
in vec4 v_dark;
#endif

// in float texture_idx;

uniform COVER {
vec4 coverColor;
};

#include
#include

void main () {
vec4 texColor = vec4(1.0);

#if USE_TINT
if(v_dark.a<0.003){
CCTexture(texture, v_uv0, texColor);
}
else if(v_dark.a<0.007){
CCTexture(texture1, v_uv0, texColor);
}
else if(v_dark.a<0.011){
CCTexture(texture2, v_uv0, texColor);
}
else if(v_dark.a<0.015){
CCTexture(texture3, v_uv0, texColor);
}
else if(v_dark.a<0.019){
CCTexture(texture4, v_uv0, texColor);
}
else if(v_dark.a<0.023){
CCTexture(texture5, v_uv0, texColor);
}
else if(v_dark.a<0.027){
CCTexture(texture6, v_uv0, texColor);
}
else if(v_dark.a<0.031){
CCTexture(texture7, v_uv0, texColor);
}
#else
CCTexture(texture, v_uv0, texColor);
#endif

vec4 finalColor;

#if USE_TINT
finalColor.a = v_light.a * texColor.a;
#if PREMULTIPLIED_ALPHA
finalColor.rgb = (texColor.a - texColor.rgb) * v_dark.rgb + texColor.rgb * v_light.rgb;
#else
finalColor.rgb = (1.0 - texColor.rgb) * v_dark.rgb + texColor.rgb * v_light.rgb;
#endif
#else
finalColor = texColor * v_light;
#endif

finalColor.rgb = finalColor.rgb * (1.0 - coverColor.a) + coverColor.rgb * coverColor.a * finalColor.a;

ALPHA_TEST(finalColor);

gl_FragColor = finalColor;
}

}%
PS:shader中的coverColor是我用来做受击闪白效果的,可以忽略。
另外需要注意的地方:
每次发生换装,都要清除一下spine组件上添加的纹理序号缓存,因为换了装备有的纹理不再用了。类似这样:mySpine.textures = null;
一般多纹理的数量支持上限是8张,这里我们项目通过别的方式保证了不会超过8张,所以assembler的代码中没有再处理。
目前只支持spine的realtime模式。(后面会尝试private cache模式,以及原生的实现)
最终效果:
视频封面
上传视频封面

好的标题可以获得更多的推荐及关注者
PS:装备上的序列帧效果就是按美术提供的序列帧间隔实时更换装备。

3赞