Creator | 基于Assembler实现的图片切割及自定义遮罩

更舒适的阅读体验,请关注公众号
qrcode_for_gh_5f59886669d1_258

感谢群内大佬 honmono 的分享,也欢迎同学们入群交流

QQ群:521643513

@1099263878

Cocos(Mac版本)引擎源码位于 CocosCreator.app/Contents/Resources/engine/cocos2d/

以下使用 CocosEngine 代替路径

// 效果演示 //

1 自定义渲染组件-TexturePlus
show-texture-plus

2 实现自定义多边形渲染
show-texture-plus

3图片切割效果
split-texture

4 图片破碎效果
auto-split-texture

5 图片破碎后还原效果
auto-split-and-reset

6 碎片掉落效果
auto-split-fall

// 浅析 Assember //

源码路径位于 CocosEngine/core/renderer/assembler.js

对于 Assembler 的个人理解:

Assembler 中的核心是顶点数据,每个顶点都有位置,uv 信息,通过改变顶点的位置和 uv 信息,就可以实现一些例如只显示图片的一部分区域,图片部分区域拉伸(九宫格也是基于这个实现的),且修改顶点数据并不会打断合批,性能有保障

在 cocos 中的每一个渲染组件,例如 cc.Sprite,cc.Label,cc.Graphics 等, 它们都继承于 RenderComponent,且都有一个对应的 Assembler,从而实现不同的渲染效果
cocos-dir-1

Assembler 提供两个静态方法,register 和 init

register 方法将渲染组件和 Assembler 绑定,init 方法用于初始化 Assembler

自定义 Assembler 的核心就是将顶点数据填充到 RenderData 中

Assembler.register = function (renderCompCtor, assembler) {
    renderCompCtor.__assembler__ = assembler;
};
​
Assembler.init = function (renderComp) {
    let renderCompCtor = renderComp.constructor;
    let assemblerCtor =  renderCompCtor.__assembler__;
    while (!assemblerCtor) {
        renderCompCtor = renderCompCtor.$super;
        if (!renderCompCtor) {
            cc.warn(`Can not find assembler for render component : [${cc.js.getClassName(renderComp)}]`);
            return;
        }
        assemblerCtor =  renderCompCtor.__assembler__;
    }
    if (assemblerCtor.getConstructor) {
        assemblerCtor = assemblerCtor.getConstructor(renderComp);
    }
    
    if (!renderComp._assembler || renderComp._assembler.constructor !== assemblerCtor) {
        let assembler = assemblerPool.get(assemblerCtor);
        assembler.init(renderComp);
        renderComp._assembler = assembler;
    }
};

Assembler 的实现可以参考 Assembler2D,源码位于 CocosEngine/core/renderer/assembler-2d.js

以下是对的 Assembler2D 源码的一些注解

export default class Assembler2D extends Assembler {
    constructor () {
        super();
​
        this._renderData = new RenderData();
        this._renderData.init(this);
        
        this.initData();
        this.initLocal();
    }
    // 计算总共所需的空间大小
    get verticesFloats () {
        return this.verticesCount * this.floatsPerVert;
    }
    initData () {
        let data = this._renderData;
        data.createQuadData(0, this.verticesFloats, this.indicesCount);
    }
    // 更新顶点颜色信息
    updateColor (comp, color) {}
    // 更新顶点坐标信息
    updateWorldVerts (comp) {}
    // 将renderdata中的数据填充到buffer中, 也计算填充了三角形顶点索引
    fillBuffers (comp, renderer) {}
}
​
// 将这5个属性注入Assembler2D.prototype内
cc.js.addon(Assembler2D.prototype, {
    floatsPerVert: 5,       // 一个顶点所需的空间 xy两个,uv两个,color一个
​
    verticesCount: 4,       // 顶点个数
    indicesCount: 6,        // 三角形顶点个数
​
    uvOffset: 2,            // uv在buffer中的偏移量, 
    colorOffset: 4,         // color在buffer中的偏移量
​
    // 格式如 x|y|u|v|color|x|y|u|v|color|x|y|u|v|color|......
    // 当然也可以自定义格式
});
​
cc.Assembler2D = Assembler2D;

简单概况一下就是:

计算每个顶点的 position,uv,color(可以不要),以及三角形顶点索引,然后赋值到 buffer 内就行了,提供三角形顶点索引是因为 gpu 绘制图像都是绘制了一个个三角形而成,换而言之三角形是最小绘制单元,而顶点内的信息只需要在顶点发生变化时才需要更新

编辑器内可编辑的多边形区域的实现,可以看之前那篇 MaskPlus,里面实现了如何实现一个自定义多边形遮罩以及自定义 Gizmo

Creator | 编辑器中可操作顶点的多边形遮罩

// 自定义 Assembler //

1 计算顶点的世界坐标

用上图显示图片的自定义多边形区域为例,首先计算好多边形顶点数组 polygon,polygon 是基于节点坐标的,且按逆时针排序,计算过程可以直接参考我之前写的 MaskPlus

protected updateWorldVerts(comp: TexturePlus) {
    if (CC_NATIVERENDERER) {
        this.updateWorldVertsNative(comp);
    } else {
        this.updateWorldVertsWebGL(comp);
    }
}
​
protected updateWorldVertsWebGL(comp: TexturePlus) {
    let verts = this._renderData.vDatas[0];
​
    let matrix: cc.Mat4 = comp.node['_worldMatrix'];
    let matrixm = matrix.m,
    a = matrixm[0], b = matrixm[1], c = matrixm[4], d = matrixm[5],
    tx = matrixm[12], ty = matrixm[13];
​
    let justTranslate = a === 1 && b === 0 && c === 0 && d === 1;
    let floatsPerVert = this.floatsPerVert;
    if (justTranslate) {
        let polygon = comp.polygon;
        for(let i=0; i<polygon.length; i++) {
            verts[i * floatsPerVert] = polygon[i].x + tx;
            verts[i * floatsPerVert+1] = polygon[i].y + ty;
        }
    } else {
        let polygon  = comp.polygon;
        for(let i=0; i<polygon.length; i++) {
            verts[i * floatsPerVert] = a * polygon[i].x + c * polygon[i].y + tx;
            verts[i * floatsPerVert+1] = b * polygon[i].x + d * polygon[i].y + ty;
        }
    }
}

代码很简单,其中 tx,ty 是节点对应世界坐标的偏移量,代码中 polygon 是根据节点坐标得到的,这里进行了一次计算,a,b,c,d 是 cocos 为 Node 计算的旋转值

2 计算顶点的 uv 坐标

uv 坐标的计算可以有几种方式,可以做成局部拉伸的效果,也可以做成裁剪效果,这里就以裁剪效果为例

uv 坐标取值区间是 0~1,对应的是 texture 的宽和高,按比例取的,取 texture 的高是反着取的,因为 cocos 的世界坐标原点在左下角

/** 计算uv, 锚点都是中心 */
public static computeUv(points: cc.Vec2[], width: number, height: number) {
    let uvs: cc.Vec2[] = [];
    for(const p of points) {
        let x = MathUtils.clamp(0, 1, (p.x + width/2) / width);
        let y = MathUtils.clamp(0, 1, 1. - (p.y + height/2) / height);
        uvs.push(cc.v2(x, y));
    }
    return uvs;
}

将 uv 填充到 RenderData 内

/** 更新uv */
protected updateUVs(comp: TexturePlus) {
    let uvOffset = this.uvOffset;
    let floatsPerVert = this.floatsPerVert;
    let verts = this._renderData.vDatas[0];
    let uvs = [];
    if(comp.texture) {
        uvs = CommonUtils.computeUv(comp.polygon, comp.texture.width, comp.texture.height)
    }
    let polygon = comp.polygon;
    for(let i=0; i<polygon.length; i++) {
        let dstOffset = floatsPerVert * i + uvOffset;
        verts[dstOffset] = uvs[i].x;
        verts[dstOffset + 1] = uvs[i].y;
    }
}

3 计算顶点 color

/** 填充顶点的color */
public updateColor(comp: TexturePlus, color: number) {
    let uintVerts = this._renderData.uintVDatas[0];
    if(!uintVerts) return ;
    color = color != null ? color : comp.node.color['_val'];
    let floatsPerVert = this.floatsPerVert;
    let colorOffset = this.colorOffset;
​
    let polygon = comp.polygon;
    for(let i=0; i<polygon.length; i++) {
        uintVerts[colorOffset + i * floatsPerVert] = color;
    }        
}

这里可能会造成疑惑的是 color 填充进的是 uintVDatas,而之前的 uv 和 position 都是填充进的 vDatas,阅读 render-data 源码可以知道,uintVerts 和 vDatas 是共享的同一段 buffer

/** render-data.js */
updateMesh (index, vertices, indices) {
    this.vDatas[index] = vertices;
​
    // 将vertices.buffer当成参数传入, 他们共享同一段buffer
    this.uintVDatas[index] = new Uint32Array(vertices.buffer, 0, vertices.length);
    this.iDatas[index] = indices;
​
    this.meshCount = this.vDatas.length;
},
​
createData (index, verticesFloats, indicesCount) {
    let vertices = new Float32Array(verticesFloats);
    let indices = new Uint16Array(indicesCount);
    this.updateMesh(index, vertices, indices);
},

4
计算三角形顶点索引

因为三角形是最小的绘制单元,所以需要将多边形转换为一个个三角形让 gpu 渲染,计算三角形我这里选择的方式是耳切法,针对耳切法的实现网上已经有有很多了,我这里也不再赘叙

ps: 我也是看了白玉无冰大佬的帖子才了解的 链接地址:多边形裁剪图片(非mask,使用mesh),新增 gizmo 支持

代码也不复杂,需要注意的是 points 是有序的,且是逆时针方向排列,所以只需要循环判断是不是耳朵且三角形内没有包含其他点就行,找到后切掉再继续判断即可

// 将多边形分解为多个三角形
public static splitPolygonByTriangle(points: cc.Vec2[]): number[] {
    if(points.length <= 3) return [0, 1, 2];
    let pointMap: {[key: string]: number} = {};     // point与idx的映射
    for(let i=0; i<points.length; i++) {
        let p = points[i];
        pointMap[`${p.x}-${p.y}`] = i;
    }
    const getIdx = (p: cc.Vec2) => {
        return pointMap[`${p.x}-${p.y}`]
    }
    points = points.concat([]);
    let idxs: number[] = [];
​
    let index = 0;
    while(points.length > 3) {
        let p1 = points[(index) % points.length]
        , p2 = points[(index+1) % points.length]
        , p3 = points[(index+2) % points.length];
        let splitPoint = (index+1) % points.length;
​
        let v1 = p2.sub(p1);
        let v2 = p3.sub(p2);
        if(v1.cross(v2) < 0) {      // 是一个凹角, 寻找下一个
            index = (index + 1) % points.length;
            continue;
        }
        let hasPoint = false;
        for(const p of points) {
            if(p != p1 && p != p2 && p != p3 && this.isInTriangle(p, p1, p2 ,p3)) {
                hasPoint = true;
                break;
            }
        }
        if(hasPoint) {      // 当前三角形包含其他点, 寻找下一个
            index = (index + 1) % points.length;
            continue;
        }
        // 找到了耳朵, 切掉
        idxs.push(getIdx(p1), getIdx(p2), getIdx(p3));
        points.splice(splitPoint, 1);
    }
    for(const p of points) {
        idxs.push(getIdx(p));
    }
    return idxs;
}
// 判断一个点是否在三角形内
public static isInTriangle(point: cc.Vec2, triA: cc.Vec2, triB: cc.Vec2, triC: cc.Vec2) {
    let AB = triB.sub(triA), AC = triC.sub(triA), BC = triC.sub(triB), AD = point.sub(triA), BD = point.sub(triB);
    //@ts-ignore
    return (AB.cross(AC) >= 0 ^ AB.cross(AD) < 0)  && (AB.cross(AC) >= 0 ^ AC.cross(AD) >= 0) && (BC.cross(AB) > 0 ^ BC.cross(BD) >= 0);
}

5 在 assembler 中的使用

这一步的计算不要放到 fillBuffer 内,因为并不需要每帧计算,只需要在修改顶点时计算即可

this.indicesArr = CommonUtils.splitPolygonByTriangle(comp.polygon);
/** 更新顶点数据 */
protected updateVerts(comp: TexturePlus) {
    this.indicesArr = CommonUtils.splitPolygonByTriangle(comp.polygon);
    this.updateWorldVerts(comp);
}
​
/** 更新renderdata */
protected updateRenderData(comp: TexturePlus) {
    if (comp._vertsDirty) {
        this.resetData(comp);
        this.updateUVs(comp);
        this.updateVerts(comp);
        this.updateColor(comp, null);
        comp._vertsDirty = false;
    }
}

6 修改顶点后重新分配 RenderData

public initData() {
    let data = this._renderData;
    data.createQuadData(0, this.verticesFloats, this.indicesCount);
}
​
public resetData(comp: TexturePlus) {
    let points = comp.polygon;
    if(!points || points.length < 3) return ;
    this.verticesCount = points.length;
    this.indicesCount = this.verticesCount + (this.verticesCount - 3) * 2;
    this._renderData.clear();
    this.initData();
}

7 填充 buffer

//每帧都会被调用
fillBuffers(comp: TexturePlus, renderer) {
    if (renderer.worldMatDirty) {
        this.updateWorldVerts(comp);
    }
​
    let renderData = this._renderData;
​
    // vData里包含 pos, uv, color数据, iData中包含三角形顶点索引
    let vData = renderData.vDatas[0];
    let iData = renderData.iDatas[0];
​
    let buffer = this.getBuffer();
    let offsetInfo = buffer.request(this.verticesCount, this.indicesCount);
​
    // buffer data may be realloc, need get reference after request.
​
    // fill vertices
    let vertexOffset = offsetInfo.byteOffset >> 2,
        vbuf = buffer._vData;
    if (vData.length + vertexOffset > vbuf.length) {
        vbuf.set(vData.subarray(0, vbuf.length - vertexOffset), vertexOffset);
    } else {
        vbuf.set(vData, vertexOffset);
    }
​
    // fill indices
    let ibuf = buffer._iData,
        indiceOffset = offsetInfo.indiceOffset,
        vertexId = offsetInfo.vertexOffset;             // vertexId是已经在buffer里的顶点数,也是当前顶点序号的基数
​
    let ins = this.indicesArr;
    for(let i=0; i<iData.length; i++) {
        ibuf[indiceOffset++] = vertexId + ins[i];
    }
}

// 图片切割实现 //

图片切割其实就是做了线段和多边形的切割计算,原理就不多说了,直接上代码:

https://github.com/kirikayakazuto/CocosCreator_UIFrameWork/blob/SplitTexture/assets/Script/test/UISplitTexture.ts

// 源码地址 //

TexturePlus:https://github.com/kirikayakazuto/CocosCreator_UIFrameWork/blob/SplitTexture/assets/Script/Common/Components/TexturePlus.ts

TextureAssembler:https://github.com/kirikayakazuto/CocosCreator_UIFrameWork/blob/SplitTexture/assets/Script/Common/Components/TexturePlus.ts

51赞

mark!!!


为啥我打开和演示的不一样啊0.0

裁剪变成拉伸的效果了

大佬牛逼 :grinning:

战略mark一个!!!!大佬牛皮

mark,牛批

canvas 加clip就能实现 我已经实现了

这个Assembler和 Mesh 有什么区别? 感觉概念差不多, Mesh也可以操纵顶点来实现吧

mark mark

mark mark

厉害了,mark

这也太强了

mark!!!

战略插眼安排一下

mark!

mark!

markmark

mark大佬大佬

这个mark