egret UI 合批

图集方案

使用 Texture Merger

Egret 官方提供的图集工具,使用比较简单,打开工具,选择 Sprite Sheet

然后将要打图集的图片拖到窗口中,

然后会弹出新建项目确认框,输入名字,点击确定。

最后点 导出,导出合图文件,导出时,可以选择导出比例(100%、200%、50%):

导出后资源如下:

然后将导出的资源放到项目 Resource 目录下,将资源加到 default.res.json(这里注意,json 文件格式一定要选择 sheet 类型)

然后就可以在 UI 编辑器里使用图集里的资源了 111_json.00

代码中使用图集:

// 下面的方法需要先在 default.res.json 中设置加载组
// 并且手动加载组中图集资源后,才能使用如下方式获取图集

// 加载整个图集
let ss: egret.SpriteSheet = RES.getRes("111_json")
let tex: egret.Texture = ss.getTexture("00")
let b1: egret.Bitmap = new egret.Bitmap(tex);

// 通过二级 key 加载图集,如果有多张图集出现同名的子 key,
// 返回最后加载的图集中的子 key 对应的贴图
let b2: egret.Bitmap = new egret.Bitmap(RES.getRes("00"));

// 指定图集以及子键
let b3: egret.Bitmap = new egret.Bitmap(RES.getRes("111_json.00"));

let b4: egret.Bitmap = new egret.Bitmap(RES.getRes("111_json#00"));

使用 FreeTexturePacker

官方自带的工具已经很好用了,不过有些时候,图集操作可以通过批处理命令,自带的工具不支持,因此选用其他方案。TextrurePacker 收费,这里选用免费的 FREE TEXTURE PACKER 1,按照文档上的操作,初步实现打出图集:

  • 安装 nodejs
  • 下载官方给的 demo,并解压
  • 在 demo 目录打开命令窗口
  • 执行: npm install -g grunt
  • 执行: npm install
  • 执行: grunt

最终图集生成在目录下的 dest 目录中:包含 一张图集 + 一个 json 文件,demo 中给的代码打出的图集 egret 编辑并不能识别,需要修改打包脚本 js 文件 2

module.exports = function(grunt) {
    grunt.initConfig({
        free_tex_packer: {
            demo: {
                files: [
                    { expand: true, src: 'src/**/*', 
                        basePath: 'src/', filter: 'isFile' }
                ],
                options: {
                    dest: 'dest',
                    textureName: "atlas",
                    width: 1024,
                    height: 1024,
                    fixedSize: false,
                    padding: 0,
                    allowRotation: true,
                    detectIdentical: true,
                    allowTrim: true,
                    exporter: "Egret2D", // old:  Pixi
                    removeFileExtension: true,
                    prependFolderName: true
                }
            }
        }
    });
    
    grunt.loadNpmTasks('grunt-free-tex-packer');
    grunt.registerTask('default', ['free_tex_packer']);
};

改完后依旧不行,就去查看 egret 的图集格式3

{
    "file":"111.png",
    "frames":{
        "00":{"x":364,"y":384,"w":68,"h":93,"offX":4,
            "offY":12,"sourceW":121,"sourceH":121},

        "01":{"x":272,"y":461,"w":67,"h":93,"offX":9,
            "offY":13,"sourceW":121,"sourceH":121},
    }
}

修改成 Egret2D 后,demo 中打出的图集 json 不是 egret 格式

{
    "file": "atlas.png",
    "frames": {
        "00": {"x": 0,"y": 0,"w": 121,"h": 121,"hw": 60.5,"hh": 60.5},
        "01": {"x": 0,"y": 121,"w": 121,"h": 121,"hw": 60.5,"hh": 60.5},
    }
}

查看 free-tex-packer-core2 源码,可以看到导出模板:

// Egret2D.mst
{
    "file": "{{config.imageName}}",
    "frames": {
        {{#rects}}
        "{{{name}}}": {
            "x": {{frame.x}},
            "y": {{frame.y}},
            "w": {{frame.w}},
            "h": {{frame.h}},
            "hw": {{frame.hw}},
            "hh": {{frame.hh}}
        }{{^last}},{{/last}}
        {{/rects}}
    }
}

因此参考文档使用自定义的导出模板:

// template.txt
{
  "file": "{{config.imageName}}",
  "frames": {
    {{#rects}}
    "{{{name}}}": {
        "x": {{frame.x}}, 
        "y": {{frame.y}}, 
        "w": {{frame.w}}, 
        "h": {{frame.h}}, 
        "offX":{{spriteSourceSize.x}},
        "offY":{{spriteSourceSize.y}},
        "sourceW":{{spriteSourceSize.w}},
        "sourceH":{{spriteSourceSize.h}}
    }{{^last}},{{/last}}
    {{/rects}}
  }
}

参数:
x:小图的有效像素区域在大图中的起始坐标 x
y: 小图的有效像素区域在大图中的起始坐标 y
w: 小图的有效像素区域在大图中的宽度
h: 小图的有效像素区域在大图中的高度
offX:原始图片的左上角非透明区域的起始坐标 x (未开启 trim, offX = 0)
offY:原始图片的左上角非透明区域的起始坐标 y (未开启 trim, offY = 0)
sourceW:原始图片的宽度
sourceH:原始图片的高度 \

然后修改 grunt demo 代码,改成读取目录,分别创建对应的图集,当然也可以增加自定规则,将某几个目录打成一张图集,或者哪些目录不打图集:

String.prototype.format = function() {
    var formatted = this;
    for( var arg in arguments ) {
        formatted = formatted.replace("{" + arg + "}", arguments[arg]);
    }
    return formatted;
};

let exporter = {
    fileExt: "json",
    template: "./template.txt",

    // 去除透明部分在合图
    // 这个配置会覆盖 options 中的,一定要配置
    allowTrim: true 
}

function getOption(textureName)
{
    let options = {
        dest: 'dest',
        textureName: textureName,
        fixedSize: false,
        padding: 1,
        allowRotation: true,
        detectIdentical: true,
        powerOfTwo: true,
        allowTrim: true,
        trimMode: "trim",
        packer: "MaxRectsPacker",
        exporter: exporter,
        removeFileExtension: true,
        prependFolderName: true
    }
    return options
}

function getAtlasInfo(src, atlasName)
{
    let srcPath = "{0}/*".format(src)
    let basePath = "{0}/".format(src)
    let atlas =  {
        files: [
            {expand: true, src: srcPath, basePath: basePath, filter: 'isFile'},
        ],
        options: getOption(atlasName) 
    }
    return atlas
}

function getTexturePackConf(rootDir)
{
    let packerConf = {}
    var fs = require("fs")
    var path = require("path")

    fs.readdirSync(rootDir, { withFileTypes: true}).forEach(function(dir) {
        var filePath = path.join(rootDir, dir.name)
        if(dir.isDirectory())
        {
            packerConf[dir.name] = getAtlasInfo(filePath, dir.name)
        }
    })

    return packerConf
}

module.exports = function(grunt) {
    let rootDir = "./src"
    rootDir = "E:/work/project/H5/ClockBloodUI/ClockTower/resource/ui_res"
    let packConf = getTexturePackConf(rootDir)
    grunt.initConfig({
        free_tex_packer: packConf
    });
    
    grunt.loadNpmTasks('grunt-free-tex-packer');
    grunt.registerTask('default', ['free_tex_packer', ]);
};

结果如下:

然后就是扫描已有的皮肤文件,将目前引用的贴图信息,修改成图集信息。

动态合图

KM 上看到一篇动态合图的文章,里面讲到网页版拉取小图会比拉取大图速度快,因此动态合图也是一个方案,不过 KM 上讲的技术点比较少,还需要看源码,下面是对 egret 渲染的源码分析:

egret 渲染流程

egret 全局渲染器

egret 有两个渲染器 systemRendercanvasRender

namespace egret.sys 
{
    // 忽略下面坑爹的官方注释,web 端下渲染主要使用到的是 systemRender
    // WebGLRenderer 中有几处地方会使用到  canvasRenderer

    // 用于碰撞检测绘制
    export let systemRenderer: SystemRenderer;
    // 显示渲染器接口
    export let canvasRenderer: SystemRenderer;
}

这两渲染器的基类都是 SystemRenderer

export interface SystemRenderer 
{
    render(displayObject: DisplayObject, buffer: RenderBuffer, 
      matrix: Matrix, forRenderTexture?: boolean): number;
    
    drawNodeToBuffer(node: sys.RenderNode, buffer: RenderBuffer, 
    matrix: Matrix, forHitTest?: boolean): void;

    renderClear();
}

egret 引擎代码入口在项目工程中的 index.html 中的 js 代码,在这里会根据当前设备信息来创建对应的渲染器(canvasRenderer 目前看源码只有渲染矢量节点,旧的文本渲染时用到):

  • webgl: systemRender 就是 WebGLRenderer,canvasRenderer 是 Canvas 渲染器
  • canvas: 这个时候,两个渲染器都是 Canvas 渲染器
// index.html
egret.runEgret({ renderMode: "webgl"});

// src/egret\web\EgretWeb.ts
function runEgret(options?: runEgretOptions): void 
{
    sys.CanvasRenderBuffer = CanvasRenderBuffer;
    setRenderMode(options.renderMode);
}

function setRenderMode(renderMode: string): void 
{
    if (renderMode == "webgl" && WebGLUtils.checkCanUseWebGL()) 
    {
        sys.RenderBuffer = web.WebGLRenderBuffer;
        sys.systemRenderer = new WebGLRenderer();
        sys.canvasRenderer = new CanvasRenderer();
        sys.customHitTestBuffer = new WebGLRenderBuffer(3, 3);
        sys.canvasHitTestBuffer = new CanvasRenderBuffer(3, 3);
        Capabilities["renderMode" + ""] = "webgl";
    }
    else 
    {
        sys.RenderBuffer = web.CanvasRenderBuffer;
        sys.systemRenderer = new CanvasRenderer();
        sys.canvasRenderer = sys.systemRenderer;
        sys.customHitTestBuffer = new CanvasRenderBuffer(3, 3);
        sys.canvasHitTestBuffer = sys.customHitTestBuffer;
        Capabilities["renderMode" + ""] = "canvas";
    }
}

egret 渲染 Player

创建完渲染器,还需要创建 Player,这个是直接渲染可见节点的对象,会直接获取 stage 上的 displayList,调用渲染函数 drawToSurface

// src\egret\player\Player.ts
export class Player extends HashObject 
{
    public constructor(buffer: RenderBuffer, stage: Stage, entryClassName: string) 
    {
        super();
        this.stage = stage;
        this.screenDisplayList = this.createDisplayList(stage, buffer);
    }

    private createDisplayList(stage: Stage, buffer: RenderBuffer): DisplayList 
    {
        let displayList = new DisplayList(stage);
        displayList.renderBuffer = buffer;
        stage.$displayList = displayList;
        return displayList;
    }

    $render(triggerByFrame: boolean, costTicker: number): void 
    {
        if (egret.nativeRender) {
            egret_native.updateNativeRender();
            egret_native.nrRender();
            return;
        }

        if (egret.sys.systemRenderer.renderClear) {
            egret.sys.systemRenderer.renderClear();
        }

        let stage = this.stage;
        let t1 = egret.getTimer();
        let drawCalls = stage.$displayList.drawToSurface();
        let t2 = egret.getTimer();
        if (triggerByFrame && this.showFPS) {
            fpsDisplay.update(drawCalls, t2 - t1, costTicker);
        }
    }
}

Player 创建也是在 runEgret 这个函数中,不过不是直接创建出 Player,而是创建网页节点解析对象 WebPlayer。我们在运行一个 egret 项目网页后,通过使用浏览器的检查功能,可以看到页面 body 中只有一个 div 标签,这个标签负责渲染整个游戏中的所有图元。

创建流程如下:

// src\egret\web\EgretWeb.ts
function runEgret(options?: runEgretOptions): void 
{
  sys.CanvasRenderBuffer = CanvasRenderBuffer;
  setRenderMode(options.renderMode);

  // 创建 Player 对象
  let list = document.querySelectorAll(".egret-player");
  let length = list.length;
  for (let i = 0; i < length; i++) 
  {
      let container = <HTMLDivElement>list[i];
      let player = new WebPlayer(container, options);
      container["egret-player"] = player;
  }
}

通过调试可以知道,egret 对 div 标签创建了一个 WebPlayer

在 WebPlayer 中又包含一个 Player 对象,这个 Player 对象负责渲染

export class WebPlayer extends egret.HashObject implements egret.sys.Screen {

  public constructor(container: HTMLDivElement, options: runEgretOptions) {
      super();
      this.init(container, options);
  }

  private init(container: HTMLDivElement, options: runEgretOptions): void {
      console.log("Egret Engine Version:", egret.Capabilities.engineVersion)
      let option = this.readOption(container, options);
      let stage = new egret.Stage();
      stage.$screen = this;

      let buffer = new sys.RenderBuffer(undefined, undefined, true);
      let player = new egret.sys.Player(buffer, stage, option.entryClassName);
      this.player = player;
  }
}

DisplayList

Player 渲染调用的是 DisplayList 的接口,DisplayList 会从 root 节点开始渲染图元

export class DisplayList extends HashObject 
{
    private renderBuffer = new RenderBuffer();

    public drawToSurface(): number 
    {
        let drawCalls = 0;
        let buffer = this.renderBuffer;
        buffer.clear();
        drawCalls = systemRenderer.render(this.root, buffer, this.offsetMatrix);
    }
}

最后调用到 WebGLRenderer 中的渲染函数:

export class WebGLRenderer implements sys.SystemRenderer 
{
    public render(displayObject: DisplayObject, buffer: sys.RenderBuffer, 
        matrix: Matrix, forRenderTexture?: boolean): number 
    {
        this.nestLevel++;
        let webglBuffer: WebGLRenderBuffer = <WebGLRenderBuffer>buffer;
        let webglBufferContext: WebGLRenderContext = webglBuffer.context;
        let root: DisplayObject = forRenderTexture ? displayObject : null;

        webglBufferContext.pushBuffer(webglBuffer);

        //绘制显示对象
        webglBuffer.transform(matrix.a, matrix.b, matrix.c, matrix.d, 0, 0);

        this.drawDisplayObject(displayObject, webglBuffer, matrix.tx, 
            matrix.ty, true);

        webglBufferContext.$drawWebGL();
        let drawCall = webglBuffer.$drawCalls;
        webglBuffer.onRenderFinish();

        webglBufferContext.popBuffer();
        let invert = Matrix.create();
        matrix.$invertInto(invert);
        webglBuffer.transform(invert.a, invert.b, invert.c, invert.d, 0, 0);
        Matrix.release(invert);
        
        return drawCall;
    }
}

从根节点开始渲染,进入 drawDisplayObject 函数,并逐一变量子节点生成渲染指令:

// cacheAsBitmap: 节点会有自己的 displayList 
// 并将节点渲染到一张贴图上

// WebGlRenderer.ts

private drawDisplayObject(displayObject: DisplayObject, buffer: WebGLRenderBuffer, 
    offsetX: number, offsetY: number, isStage?: boolean): number 
{
    // 忽略 cacheAsBitmap 的情况
    // let displayList = displayObject.$displayList;
    let node: sys.RenderNode = displayObject.$getRenderNode()

    if(node)
    {
        switch (node.type) 
        {
            case sys.RenderNodeType.BitmapNode:
                this.renderBitmap(<sys.BitmapNode>node, buffer);
                break;
            case sys.RenderNodeType.TextNode:
                this.renderText(<sys.TextNode>node, buffer);
                break;
            case sys.RenderNodeType.GraphicsNode:
                this.renderGraphics(<sys.GraphicsNode>node, buffer);
                break;
            case sys.RenderNodeType.GroupNode:
                this.renderGroup(<sys.GroupNode>node, buffer);
                break;
            case sys.RenderNodeType.MeshNode:
                this.renderMesh(<sys.MeshNode>node, buffer);
                break;
            case sys.RenderNodeType.NormalBitmapNode:
                this.renderNormalBitmap(<sys.NormalBitmapNode>node, buffer);
                break;
        }
    }

    let children = displayObject.$children;
    if (children) 
    {
        if (displayObject.sortableChildren && displayObject.$sortDirty) {
            //绘制排序 按照 zIndex 排序
            displayObject.sortChildren();

        let length = children.length;
        for (let i = 0; i < length; i++) 
        {
            let child = children[i];
            switch (child.$renderMode) {
                case RenderMode.NONE:
                    break;
                case RenderMode.FILTER:
                    drawCalls += this.drawWithFilter(child, buffer, 
                        offsetX2, offsetY2);
                    break;
                case RenderMode.CLIP:
                    drawCalls += this.drawWithClip(child, buffer, 
                        offsetX2, offsetY2);
                    break;
                case RenderMode.SCROLLRECT:
                    drawCalls += this.drawWithScrollRect(child, buffer, 
                        offsetX2, offsetY2);
                    break;
                default:
                    drawCalls += this.drawDisplayObject(child, buffer, 
                        offsetX2, offsetY2);
                    break;
            }
        }
    }
}

顺道提一嘴,如果层级使用了 sortableChildrenzIndex 的会出现事件层级跟渲染层级不一致的情况就是在这里

// src/egret/display/DisplayObjectContainer.ts
$hitTest(stageX: number, stageY: number): DisplayObject 
{
    let found = false;
    let target: DisplayObject = null;
    // 事件响应没有对子节点进行排序,而是从下往上遍历
    // 因此如果需要使用到 zIndex 这里需要做排序
    for (let i = children.length - 1; i >= 0; i--) 
    {
        const child = children[i];
        if (child.$maskedObject) 
        {
            continue;
        }
        target = child.$hitTest(stageX, stageY);
    }
}

渲染图片的函数主要就是 renderBitmap

private renderBitmap(node: sys.BitmapNode, buffer: WebGLRenderBuffer): void 
{
    buffer.context.drawImage(image, 
        data[pos++], data[pos++], data[pos++], data[pos++],
        data[pos++], data[pos++], data[pos++], data[pos++], 
        node.imageWidth, node.imageHeight, node.rotated, node.smoothing);
}


WebGLRenderContext.drawImage(
    image: BitmapData,
    sourceX: number, sourceY: number, sourceWidth: number, sourceHeight: number,
    destX: number, destY: number, destWidth: number, destHeight: number,
    imageSourceWidth: number, imageSourceHeight: number, 
    rotated: boolean, smoothing?: boolean): void 
{
    let buffer = this.currentBuffer;

    this.drawTexture(texture,
        sourceX, sourceY, sourceWidth, sourceHeight,
        destX, destY, destWidth, destHeight,
        imageSourceWidth, imageSourceHeight,
        undefined, undefined, undefined, undefined, rotated, smoothing);
}

WebGLRenderContext.drawTexture(
    texture: WebGLTexture,
    sourceX: number, sourceY: number, sourceWidth: number, sourceHeight: number,
    destX: number, destY: number, destWidth: number, destHeight: number, 
    textureWidth: number, textureHeight: number,
    meshUVs?: number[], meshVertices?: number[], 
    meshIndices?: number[], bounds?: Rectangle, 
    rotated?: boolean, smoothing?: boolean): void 
{
    let buffer = this.currentBuffer;
    
    // 调用 $drawWebGL 绘制
    if (meshVertices && meshIndices) 
    {
        if (this.vao.reachMaxSize(meshVertices.length / 2, meshIndices.length)) 
        {
            this.$drawWebGL();
        }
    } 
    else 
    {
        if (this.vao.reachMaxSize()) 
        {
            this.$drawWebGL();
        }
    }

    // 往 this.drawData 推 drawData
    this.drawCmdManager.pushDrawTexture(texture, count, 
        this.$filter, textureWidth, textureHeight);

    buffer.currentTexture = texture;
    // 增加顶点数据
    this.vao.cacheArrays(buffer, 
        sourceX, sourceY, sourceWidth, sourceHeight,
        destX, destY, destWidth, destHeight, 
        textureWidth, textureHeight,
        meshUVs, meshVertices, meshIndices, rotated)
}

public WebGLRenderContext.$drawWebGL() {
    let length = this.drawCmdManager.drawDataLen;
    let offset = 0;
    for (let i = 0; i < length; i++) 
    {
        let data = this.drawCmdManager.drawData[i];
        // 忽略上传 indicesArray 信息

        this.drawData(data, 0);
    }
}

最后是调用 gl 的地方

private drawData(data: any, offset: number) 
{
    let gl = this.context;
    let program: EgretWebGLProgram;
     switch (data.type) {
        case DRAWABLE_TYPE.TEXTURE:
            //这段的切换可以优化  filter 滤镜 后处理 ?
            // getProgram 获取顶点跟片源 shader
            if (filter) {
                if (filter.type === "custom") {
                    program = EgretWebGLProgram.getProgram(gl, filter.$vertexSrc, 
                    filter.$fragmentSrc, filter.$shaderKey);
                } 
            } 
            else if (filter.type === "glow") {
                program = EgretWebGLProgram.getProgram(gl, 
                    EgretShaderLib.default_vert, 
                    EgretShaderLib.glow_frag, "glow");
            }
            else
            {
                program = EgretWebGLProgram.getProgram(gl, 
                    EgretShaderLib.default_vert, 
                    EgretShaderLib.texture_frag, "texture");
            }

            this.activeProgram(gl, program);
            this.syncUniforms(program, filter, data.textureWidth, 
                data.textureHeight);
            offset += this.drawTextureElements(data, offset);
        }
}

// src/egret/web/WebSysImpl.ts
function drawTextureElements(renderContext, data, offset) 
{
    var webglrendercontext = renderContext;
    var gl = webglrendercontext.context;
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, data.texture);
    var size = data.count * 3;
    gl.drawElements(gl.TRIANGLES, size, gl.UNSIGNED_SHORT, offset * 2);
    return size;
}

了解完 egret 渲染流程后,我找到了 egret 论坛中的一篇帖子4,主要思路是利用 egret 本身提供的 cacheBitMap 功能,渲染时:

  • 创建一张大图 RenderTexture
  • 将需要渲染的节点放到一个根节点上,并按照尺寸依次在大图上申请空间,并根据返回的位置设置控件在根节点上的坐标
  • 使用 renderTexture 的方法,将这个根节点渲染到 RenderTexture 上,这样就生成了图集
  • 图集生成完毕,替换掉渲染节点中的 Texture 信息将其指向大图即可。
// 收集渲染节点,并将其放置到根节点上
this.pack = new MaxRectsBinPack(this.maxSize, this.maxSize, false);
for (var key in textMap) 
{
    var qlabelList = textMap[key].qlabelList;
    var textField = qlabelList[0].textField;
    textField.width += 2;
    textField.height += 2;
    var bounds = textField.getBounds();
    // 使用切图算法,获取子节点在图集上的位置信息
    var rect = this.pack.insert(bounds.width, bounds.height);
    if (!rect.width) {
        throw ("DSpriteSheet的尺寸" + this.maxSize + 
            "溢出,请新建一个DSpriteSheet对象");
    }
    // 根据切图算法返回的位置,设置节点位置
    textField.x = rect.x;
    textField.y = rect.y;
    textMap[key].bounds = rect;
    this.container.addChild(textField);
}

if (!this.spriteTexture) 
{
    this.spriteTexture = new egret.RenderTexture();
}

// 将布局好的节点全部渲染到 renderTexture 上
this.spriteTexture.drawToTexture(this.container, 
    new egret.Rectangle(0, 0, this.maxSize, this.maxSize));

// 生成切图信息,方便从大图 renderTexture 生成小图的 Texture 信息
if (!this.spriteSheet) 
{
    this.spriteSheet = new egret.SpriteSheet(this.spriteTexture);
}

for (var key in textMap) 
{
    var qlabelList = textMap[key].qlabelList;
    for (var i = 0; i < qlabelList.length; i++) 
    {
        var qlabel = qlabelList[i];
        var bounds = textMap[key].bounds;
        var texture = this.spriteSheet.getTexture(key);
        // if (texture) {
        //     qlabel.texture = texture;
        // }
        // else {

        // 从大图上获取对应小图的信息创建出该节点的贴图
        // 并替换节点的贴图
        qlabel.texture = this.spriteSheet.createTexture(key,
            Math.round(bounds.x),
            Math.round(this.maxSize - bounds.height - bounds.y),
            Math.round(bounds.width),
            Math.round(bounds.height)
        );

        qlabel.textField.width -= 2;
        qlabel.textField.height -= 2;
        qlabel.onRender.call(qlabel);
        // }
    }
}

参考这个思路,后续实现 Image 节点的动态合图。

参考资料

1. Free Texture Pakcer grunt module

2. free-tex-packer-core git 使用文档

3. Egret SpriteSheet 文件格式规范

4.一个高性能的文本控件