V7投稿|源码角度分析spine换装实现思路 ( js engine )

本教程引擎源代码基于 cocos creator v2.4.10

从engine角度思考spine换肤

从webgl的角度我们思考下,engine对接spine,最终都是要将顶点数据提交到webgl,所以只要顺着提交顶点的逻辑反推,这件事就有迹可循。

之前的文章,我们了解到cocos creator会将提交的顶点数据放到一个很大的buffer,而在官方文档自定义渲染中也提到fillBuffers这个关键函数,其实当你对engine的render-flow熟悉后,也能够想出来fillBuffers的函数在整个渲染架构中的位置。

_proto._render = function (node) {
    let comp = node._renderComponent;
    comp._checkBacth(_batcher, node._cullingMask);
    comp._assembler.fillBuffers(comp, _batcher);
    this._next._func(node);
};

creator spine 渲染剖析

回到spine,首先要找的就是Assembler,这是一切的起点。

spine-asembler.js 主要的渲染逻辑都在这里面,阅读顺序可以根据数字标号看

ModelBatcher.prototype = {
     getBuffer (type, vertextFormat) {
        let key = type + vertextFormat.getHash();
        let buffer = _buffers[key];
        if (!buffer) {
            if (type === 'mesh') {
                buffer = new MeshBuffer(this, vertextFormat);
            } else if (type === 'spine') {
                buffer = new SpineBuffer(this, vertextFormat);
            }
            _buffers[key] = buffer;
        }
        return buffer;
    }
}
class SpineAssembler{
    fillBuffers (comp, renderer) {
        // 3. 成员变量_buffer来自那个大的buffer,相关的逻辑可以往上看,这里不再解释
        _buffer = renderer.getBuffer('spine', _vertexFormat);
    }
    realTimeTraverser(){
        // 遍历所有的骨骼
        for (let slotIdx = 0, slotCount = locSkeleton.drawOrder.length; slotIdx < slotCount; slotIdx++) {
            slot = locSkeleton.drawOrder[slotIdx];
            // 6. Attachment来自slot的成员变量,slot也提供了相关的接口
            attachment = slot.getAttachment();

            isRegion = attachment instanceof spine.RegionAttachment;
            isMesh = attachment instanceof spine.MeshAttachment;
            isClip = attachment instanceof spine.ClippingAttachment;
            // 7. 每一帧都会设置纹理,修改纹理可以从region下手,数据来源也是Attachment
            material = _getSlotMaterial(attachment.region.texture._texture, slot.data.blendMode);
            // 纹理不同,主动提交一次
            if (_mustFlush || material.getHash() !== _renderer.material.getHash()) {
                _mustFlush = false;
                _renderer._flush();
                _renderer.node = _node;
                _renderer.material = material;
            }
            if(isRegion){
            }else if(isMesh){
                 vbuf = _buffer._vData, // 2. vbuff来自成员变量_buffer
                 triangles = attachment.triangles
                // compute vertex and fill x y
                attachment.computeWorldVertices(slot, 0, attachment.worldVerticesLength, vbuf, _vertexFloatOffset, _perVertexSize);
            }

            // 5. 确认vbuf就是要渲染的buffer后,接下来问题就变成了查找uvs的计算逻辑,这里可以看到是来自Attachment
            uvs = attachment.uvs;
            for (let v = _vertexFloatOffset, n = _vertexFloatOffset + _vertexFloatCount, u = 0; v < n; v += _perVertexSize, u += 2) {
                // 1. 填充vbuf的u、v,换肤的本质就是要改uv,所以就从这里下手,查找vbuf的来源
                vbuf[v + 2] = uvs[u];           // u
                vbuf[v + 3] = uvs[u + 1];       // v
            }
         }
    }
}
function _getSlotMaterial (tex, blendMode) {
    let src, dst;
    // 省略了src, dst的逻辑
    let useModel = !_comp.enableBatch;
    let baseMaterial = _comp._materials[0];
    if (!baseMaterial) return null;

    // The key use to find corresponding material
    let key = tex.getId() + src + dst + _useTint + useModel;
    let materialCache = _comp._materialCache;
    let material = materialCache[key];
    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);
        // 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;
    }
    return material;
}

经过以上的分析,可以看出,我们想要换肤,只需要修改slot.attachment即可,具体实现过程中,会遇到RegionAttachment/MeshAttachment,相关的代码量也不大,可以大致阅读下,不同的Attachment换肤实现的方式也有差异,都有关于u,v,u2,v2的相关逻辑。

当你对渲染相关的代码非常熟悉后,其实实现换肤就非常容易了,最核心的就是attachment.uvs设置好即可

对应的Attachment分析图

image

多实例污染的问题

当有多个实例时,修改其中一个spine的Attachment,会导致所有的实例都发生变化,这当然不是我们想要的,究其原因:

Slot.prototype.setToSetupPose = function () {
    this.color.setFromColor(this.data.color);
    if (this.darkColor != null)
        this.darkColor.setFromColor(this.data.darkColor);
    if (this.data.attachmentName == null)
        this.attachment = null;
    else {
        this.attachment = null;
        // atttachment是从缓存中获取的,也就是大家用的都是一个object
        // js的object类型参数传递的弱引用
        this.setAttachment(this.bone.skeleton.getAttachment(this.data.index, this.data.attachmentName));
    }
};

Slot.prototype.getAttachment = function () {
    return this.attachment;
};
Slot.prototype.setAttachment = function (attachment) {
    if (this.attachment == attachment)
        return;
    this.attachment = attachment;
    this.attachmentTime = this.bone.skeleton.time;
    this.deform.length = 0;
};

幸好attachment提供了copy函数,这样就能解决多实例的问题,虽然会增加一点内存

const attachment: sp.spine.Attachment = slot.getAttachment();
const copyAttachment = attachment.copy();
slot.setAttachment(copyAttachment);

copy的实现逻辑,本质是new了一个新的object,需要注意的是,RegionAttachment/MeshAttachmentcopy实现是不同的,下边以MeshAttachment举例

MeshAttachment.prototype.copy = function () {
    if (this.parentMesh != null)
        return this.newLinkedMesh();
    var copy = new MeshAttachment(this.name);
    // 注意这个region,所有的attachment用的也是同一个,同样也会发生多实例污染的问题
    // 解决办法也是 new
    copy.region = this.region; 
    copy.path = this.path;
    copy.color.setFromColor(this.color);
    this.copyTo(copy);
    copy.regionUVs = new Array(this.regionUVs.length);
    spine.Utils.arrayCopy(this.regionUVs, 0, copy.regionUVs, 0, this.regionUVs.length);
    copy.uvs = new Array(this.uvs.length);
    spine.Utils.arrayCopy(this.uvs, 0, copy.uvs, 0, this.uvs.length);
    copy.triangles = new Array(this.triangles.length);
    spine.Utils.arrayCopy(this.triangles, 0, copy.triangles, 0, this.triangles.length);
    copy.hullLength = this.hullLength;
    if (this.edges != null) {
        copy.edges = new Array(this.edges.length);
        spine.Utils.arrayCopy(this.edges, 0, copy.edges, 0, this.edges.length);
    }
    copy.width = this.width;
    copy.height = this.height;
    return copy;
};

设置纹理

attachment.region.texture._texture渲染时提交的纹理数据来自region

const tex2d: cc.Texture2D;
const skeTexture = new sp.SkeletonTexture({ width: tex2d.width, height: tex2d.height });
skeTexture.setRealTexture(tex2d);

const region = new sp.spine.TextureAtlasRegion();
region.texture = skeTexture;

切换皮肤的纹理时,我们需要注意texture的类型,new sp.SkeletonTexture的参数及其含义可以参考engine的实现

  • skeleton-data.js
_getTexture: function (line) {
    let names = this.textureNames;
    for (let i = 0; i < names.length; i++) {
        if (names[i] === line) {
            let texture = this.textures[i];
            // 纹理的宽高
            let tex = new sp.SkeletonTexture({ width: texture.width, height: texture.height });
            tex.setRealTexture(texture);
            return tex;
        }
    }
    cc.errorID(7506, line);
    return null;
},

总结

总体来说spine换肤的实现,需要对 creator spine 渲染逻辑非常了解,才能写的游刃有余。

个人感觉,总体难度一般,并没有牵扯矩阵相关的知识,都是纯逻辑,论坛中也有现成可用的代码可以参考,但是存在多实例、MeshAttachment有bug的问题,当然如果移植过来没有问题最好,一旦出现问题,如果不熟悉底层根本无法下手。

实现过程中,我们需要时刻以提交的顶点数据作为基准,就很容易的想到怎么实现功能,有spine相关问题的,欢迎留言讨论,一起学习一起进步。

12赞

:ox: :ox: :ox: :ox:

你这动作也太快了吧,大佬

大佬高产!

牛逼 牛逼

大佬牛逼,mark一下

大佬做一下3d 的动态模型局部换皮呗

换肤过程中,可能需要观察uv顶点数据,我顺带开发了一个小插件 https://store.cocos.com/app/detail/5821

大佬有3d的不,代码级别的吧,因为换的部件很多

3.8.2版本换了api接口,旧的也没有了,setSlotTexture 失败或者被污染

用的2.4.x,发现局部换装时,如果从A套装换B套装过程,A套装有插槽123,B是只有12,换到B会残留A的插槽3,必须数据对齐,也就是B也需要新建一个插槽3并且与之对应有插槽3一样的结构,才能更换成功,这种有什么好的解决方法优化插槽部位结构 无需对齐吗

换装本质就是替换插槽附件 为啥你说的我看不懂 你是怎么换的

就是A套装有三个插槽 B套装有两个插槽 A换B后会残留A多出来的那个插槽显示