CocosCreator 3.7.3 - 平铺节点组件 NodeTileHelper

之前有在论坛看过大城小胖大佬分享的优化思路,也看了一位C友的实现帖子。一直想自己做一个,抽空在3.7.3版本实现了这个功能组件。虽然改动了引擎代码,但水平有限,只能讲解我理解的一点内容。
  因为在ScrollerView或者Layout中总会排列一些相同的Item节点。

  

  比如图中2个Item,按正常节点树的遍历顺序是 Item、sp1、lb1、lb2、sp2、lb3、 Item、sp1、lb1、lb2、sp2、lb3。
  由于Label和Sprite的材质不同,所以合批会被打断,最终DC会变高。
  按照这种遍历顺序 总DC = Item的DC * Item的个数。

  于是有了另一个思路,希望遍历的顺序是 Item、Item、sp1、 sp1、lb1、lb1、lb2、lb2、sp2、sp2、lb3、lb3。
  这样不同的Item之间不会打断合批, 总DC = Item的DC,在一些显示多个相同Item的场景下可以大大降低DC。
  
  开始实现。
  1、得到并存储我们想要的遍历顺序
  对该组件下的子节点采取平铺遍历的方式。
  即相当于对所有子节点同时按照节点树的顺序去遍历。
  如上图,即为 Item、Item    =>     sp1、sp1    =>    lb1、lb1 =>    lb2、lb2    =>    sp2、sp2    =>    lb3、lb3
  最开始按照面对对象的思想,给每个Item配一个迭代器Iterator,每次所有迭代器调用next()按照节点树的顺序向下遍历一个节点,将得到的节点存入_tileChildren数组中。
  直到next()均为空。
  

  后来考虑到多层平铺,就不太方便了,然后采取了下面这种函数式迭代的方式来实现。
  特点就是比如第一层平铺在Layout上挂载,当Item中有一个子节点需要平铺,遍历到该子节点的时候,会将所有Item的这个子节点联合起来平铺,平铺效果最大化。

  记录思路(隔了一段时间再看,好久才看懂,哈哈,记录一下):

  1、_titleChildren 平铺遍历顺序结果
    tempStack 当前处理的这一层节点
    stack 待处理的若干层节点
    endStack 待处理的每一层的终止节点
    childStack 当前处理层的若干层子节点
    childEndStack 当前处理层的若干层的终止节点
  2、挂载了该组件的是content节点,下面是若干结构类似的Item节点
  3、将Item节点入stack和endStack作为初始化状态
  4、每次根据stack和endStack拿到当前要处理的这一层节点,存入tempStack中
  5、遍历当前tempStack中的当前层节点,idx = 0, idx 自增,根据idx遍历当前层节点的第idx个子节点,flag表示这次遍历第idx个子节点是否为空,若为false则表示当前层节点都没有idx个节点,则表示当前层节点的所有子节点都遍历完了,若为ture,则表示遍历完第idx个节点,需要存入childEndStack。title表示当前层节点是否有平铺组件,当有平铺组件的时候则该层下为若干Item,希望它们都能算同一层不打断。所以flag=true且title=false的时候,表示有idx子节点且无平铺,所以可以存入childEndStack,还有一种情况需要存入childEndStack就是tile=true时,中间不会存终止节点,所以当flag=false,表示当前层都处理完的时候,存入最后一个作为终止节点。

import { _decorator, Component, debug, error, Event, log, Node, NodeEventType } from "cc";
import { ComponentEvent } from "./interface";
const { ccclass, property, executeInEditMode } = _decorator;

/**
 * 该组件用于适合平铺遍历的节点
 * 使用:直接挂载在目标节点上即可
 *
 * 节点应满足以下条件:
 * 1、该节点下的子节点结构相同
 * 2、若多层平铺,则子平铺的该组件应将enabled置为false,只激活顶部的组件
 */
@ccclass('NodeTileHelper')
@executeInEditMode
export class NodeTileHelper extends Component {

    private _tileChildren: Node[] = [];

    protected onLoad(): void {
        this.node.on(NodeEventType.CHILD_ADDED, () => {
            this.node.dispatchEvent( new Event(ComponentEvent.NodeTileHelper.TileChildChange, true));
        });
       
        this.node.on(NodeEventType.CHILD_REMOVED, () => {
            this.node.dispatchEvent( new Event(ComponentEvent.NodeTileHelper.TileChildChange, true));
        });
    }

    onEnable() {
        this.node.on(ComponentEvent.NodeTileHelper.TileChildChange, () => { this.updateTileChildren(); });
        this.updateTileChildren();
    }

    updateTileChildren() {
        this._tileChildren.splice(0, this._tileChildren.length);

        /**
         * 1、对该组件下的节点做平铺遍历
         * 2、若在子节点中有NodeTileHelper,则对所有该子节点做平铺遍历
         */
        let stack: Node[] = [...this.node.children];
        let endStack: Node[] = [this.node.children[this.node.children.length-1]];
       
        let tempStack: Node[] = [];
        let childStack: Node[] = [];
        let childEndStack: Node[] = [];
        /** 每次循环处理stack栈顶的一层节点
         *  处理完之后从stack 和 endStack中移除对应节点
         */
        while (stack.length > 0) {
            tempStack.splice(0, tempStack.length);
            childStack.splice(0, childStack.length);
            childEndStack.splice(0, childEndStack.length);

            // 处理当前这一层节点
            let flag: boolean = true;   // 到达结束节点
            while (flag) {
                flag = stack[0] !== endStack[0];
                tempStack.push(stack.shift()!);
            }
            endStack.shift();
           
            // 处理该层节点的子节点
            flag = true;    // 处理完全部子节点
            let idx = 0;
            let tile = !!(tempStack[0].getComponent(NodeTileHelper));
            while (flag) {
                flag = false;
                for (let i = 0; i < tempStack.length; ++i) {
                    let node = tempStack[i];
                    if (node.children && node.children[idx]) {
                        childStack.push(node.children[idx]);
                        flag = true;
                    }
                }

                if ((flag && !tile) || (!flag && tile)) {
                    childEndStack.push(childStack[childStack.length-1]);
                }
                idx += 1;
            }

            // 添加子节点入stack 和 endStack
            for (let i = childStack.length-1; i >= 0; --i) {
                stack.unshift(childStack[i]);
            }

            for (let i = childEndStack.length-1; i >= 0; --i) {
                endStack.unshift(childEndStack[i]);
            }

            // 添加该层节点入 titleChildren
            this._tileChildren.push(...tempStack);
        }

        console.log("平铺节点:", this._tileChildren);
    }
}

  2、让引擎按照我们的顺序去渲染该节点
  
目前我只在Web进行了实现。
  从director的tick()函数看起,一层一层往下,可以看到,最终遍历节点、填充渲染数据的操作是在batcher-2d的walk()函数中。

  

   绿框中是原代码,红色框中是我添加的代码,我希望它在处理添加了NodeTileHelper组件的节点走我的逻辑。
  先分析一下它原先的walk做了什么。
  1、计算得到当前node的最终透明度,并设置到uiProps
  2、填充数据
  3、当有子节点的时候,递归处理,它的父节点的透明度的存储也是利用了递归,挺巧妙的,但是我用不上。

  现在实现我们的逻辑。

  

   我做的事情很简单,就是迭代我在NodeTileHelper中存储的顺序。
  只是我对节点透明度的计算,直接读父节点的透明度,没利用递归来存储,不方便,我感觉也没必要。
  其它部分就是复用原walk()的逻辑,因为我也不是很懂,所以只改自己懂的部分,哈哈。

  3、编译引擎,看看效果
  
在Layout组件上挂载上我们的NodeTileHelper。
  可以看见多个Item,但是DC只有1个Item的DC,完美。

  

   这个方案有一个显而易见的问题就是,因为我们的顺序是特定的,不是原本的节点树顺序。
  当Item有互相遮挡的时候,渲染就会出问题,Item下有2个sprite,sp1和 sp2,sp1是盖上一个Item的sp2上的。
  本来按照原先的遍历顺序是sp1、sp2、(Item2的)sp1、sp2,这样是OK的,但是我们的顺序是sp1、sp1、sp2、sp2,那么Item2的sp1就反而是在Item1的后面渲染了。
  不过一般来说,Item之前也不会互相遮挡,注意即可。
  Over。

posted @ 2023-08-07 11:12  比格海德  阅读(260)  评论(0)    收藏  举报