creator 3.x渲染分析

326 阅读6分钟

发现对creator3.x的渲染架构分析的比较少

学习了解creator渲染,包括其他开源游戏引擎的一切基础,需要你得知道如何使用webgl进行渲染一张图片,这是最基本的知识,不会的自己补课喽。

还是从最开始的源头查起来,我们写个demo,只渲染一个sprite,构建的场景应该尽可能的简单,Camera记得只保留一个,我们先不看3D场景的Camera。

image.png

image.png

然后定位下最终的webgl渲染提交逻辑

export function WebGLCmdFuncDraw (device: WebGLDevice, drawInfo: Readonly<DrawInfo>): void {
    const { gl } = device;
    const { ANGLE_instanced_arrays: ia, WEBGL_multi_draw: md } = device.extensions;
    const { gpuInputAssembler, glPrimitive } = gfxStateCache;
    if (gpuInputAssembler) {
        const indexBuffer = gpuInputAssembler.gpuIndexBuffer;
         if (gpuInputAssembler.gpuIndirectBuffer){
             // ...
         } else if (drawInfo.instanceCount && ia){
             // ...
         } else if (indexBuffer) {
            if (drawInfo.indexCount > 0) {
                const offset = drawInfo.firstIndex * indexBuffer.stride;
                // 真正的提交渲染在这里
                gl.drawElements(glPrimitive, drawInfo.indexCount, gpuInputAssembler.glIndexType, offset);
            }
        } else if (drawInfo.vertexCount > 0) {
            gl.drawArrays(glPrimitive, drawInfo.firstVertex, drawInfo.vertexCount);
        }
    } 
}

profiler的干扰

我发现profiler.hideStats()即使隐藏了,最终的渲染数据还会提交上去,drawInfo.indexCount是36。

我们要完全排除掉这个干扰,在profiler.showStats()下断点,发现调用的地方

 var _proto = Profiler.prototype;
_proto.init = function init() {
  // 1. 从Settings里面获取配置信息
  var showFPS = !!settings.querySettings(Settings.Category.PROFILING, 'showFPS'); 
  if (showFPS) {
    this.showStats();
  } else {
    this.hideStats();
  }
};

_proto.querySettings = function querySettings(category, name) {
  if (category in this._override) {
    // 2. 从override里面获取,排查谁在赋值这个override
    var categorySettings = this._override[category];
    if (categorySettings && name in categorySettings) {
      return categorySettings[name];
    }
  }
  if (category in this._settings) {
    var _categorySettings = this._settings[category];
    if (_categorySettings && name in _categorySettings) {
      return _categorySettings[name];
    }
  }
  return null;
};
_proto.overrideSettings = function overrideSettings(category, name, value) {
  if (!(category in this._override)) {
    this._override[category] = {};
  }
  // 3. 赋值的地方
  this._override[category][name] = value;
};

查到最后,是application.js里面

cc.game.init({
  debugMode: true ? cc.DebugMode.INFO : cc.DebugMode.ERROR,
  settingsPath: this.settingsPath,
  overrideSettings: {
    profiling: {
      showFPS: this.showFPS // 开关控制在这里
    }
  }
}).then(function () {
  return cc.game.run();
});

我们直接改源码,貌似这部分的逻辑是构建时动态生成的。

 gl.drawElements(glPrimitive, drawInfo.indexCount, gpuInputAssembler.glIndexType, _offset);

之后我就发现提交的顶点索引数据为6,符合预期了

开始追踪渲染数据

顺着堆栈往上找

image.png

发现最终的数据是落在this.queue里面,看下push地方

image.png

看到了_passPoll是个RecyclePool,是个复用池子,数据追踪到了renderObj的身上,接着追

image.png

最终数据又落在了this._pipleline身上,看下此时的this是ForwardStage

image.png

我们再看上上层的调用,就是RenderFlow的逻辑了

image.png

追到这里,我们基本可以确定数据即使在this._pipleline上,接下来的方向就是找往pipleline.pipelineSceneData.renderObjects里面存放数据的逻辑了,因为我们追了这么久,发现只有这里面的数据才会被提交渲染。

pipleline.pipelineSceneData.renderObjects

最简单的就是搜renderObjects.push(发现就2处逻辑在push

为什么可以这样看源码,因为所有的engine每一帧都是先擦除再绘制,绘制时,engine必须收集顶点信息,也就是每一帧engine都是要重新收集顶点信息的,这样才能保证渲染的结果是实时的,也正是基于这个思想,其实我们再看engien时,就可以猜着看,假如如果是我会怎么实现,这里面其实是纯逻辑的东西,还不牵扯高深的数学知识和图形知识,说白了就是在看engine架构。

image.png

看到这里,我们就发现了,数据又是从skybox.model里面过来的,最终的数据是落在pipeline.pipelineSceneData.skybox.model

到这里就有点麻烦了,我们需要知道这个model来自哪里,不是很好找

回头再看下完整的数据链路,可能有点长

pipleline.pipelineSceneData.renderObjects[?]
    .model.subModel[?]
    .inputAssembler.drawInfo

发现subModel是个数组,并且engine是必须将这个model进行一些赋值操作的,所以可以尝试调试下创建subModel的逻辑,首先我们需要找到Model的相关代码

image.png

这里有个小技巧,我们Console里面查看下相关的函数,chrome会告诉我们Function的地方,点击就能直接跳转过去,大概翻一下附近的代码,我们就能看到创建subModel的相关逻辑

image.png

一般来说,这个subModel肯定在 renderObjects.push(getRenderObject(skybox.model, camera));的时候已经创建好了,并且是被缓存复用的,不会每帧都创建,要是我设计也会这样,场景里面的model是跟业务逻辑挂钩的,需要开发者自己控制这些model,engine负责收集model信息就好了。

所以要调试创建的这些model,我们需要重新刷新下游戏,就会命中,在我的这个demo里面只会命中一次。

// submodule.ts
public initialize (subMesh: RenderingSubMesh, passes: Pass[], patches: IMacroPatch[] | null = null): void {
    const root = cclegacy.director.root as Root;
    this._device = deviceManager.gfxDevice;
    _dsInfo.layout = passes[0].localSetLayout;

    // 这个subMesh.iaInfo就包含了当前shader的各种信息
    this._inputAssembler = this._device.createInputAssembler(subMesh.iaInfo);
    this._descriptorSet = this._device.createDescriptorSet(_dsInfo);
        
}

// webgl2-devices.ts
public createInputAssembler (info: Readonly<InputAssemblerInfo>): InputAssembler {
    const inputAssembler = new WebGL2InputAssembler();
    inputAssembler.initialize(info);
    return inputAssembler;
}
// webgl2-input-assembler.ts
public initialize (info: Readonly<InputAssemblerInfo>): void {
    if (info.vertexBuffers.length === 0) {
        console.error('InputAssemblerInfo.vertexBuffers is null.');
        return;
    }

    this._attributes = info.attributes;
    this._attributesHash = this.computeAttributesHash();
    this._vertexBuffers = info.vertexBuffers;

    if (info.indexBuffer) {
        this._indexBuffer = info.indexBuffer;
        // indexCount的计算来源
        this._drawInfo.indexCount = this._indexBuffer.size / this._indexBuffer.stride; 
        this._drawInfo.firstIndex = 0;
    } else {
        const vertBuff = this._vertexBuffers[0];
        this._drawInfo.vertexCount = vertBuff.size / vertBuff.stride;
        this._drawInfo.firstVertex = 0;
        this._drawInfo.vertexOffset = 0;
    }
}

其实追了这么久,我们只是追到了第一个参数,知道indexCount是怎么计算出来的

 gl.drawElements(glPrimitive, drawInfo.indexCount, ...)

到这里,我们还是没有找到model怎么和sprite关联上的,感觉初期的思路就是有问题的。

RenderBuffer

回想下webgl渲染图片的过程,需要将数据绑定在buffer上,所以在gl.drawElements之前,肯定要设置关联的VertexBuffer/IndexBuffer的。

调试了下gl.bufferData,发现使用的第二个参数都是size,也就是仅仅分配可内存大小,并没有初始化buffer的数据

gl.bufferData(gl.ARRAY_BUFFER, gpuBuffer.size, glUsage);

如果要更新里面的数据,就只能使用gl.bufferSubData,事实上engine也是这么做的,核心的更新数据的代码如下:

// webgl2-commands.ts
export function WebGL2CmdFuncUpdateBuffer (device: WebGL2Device, gpuBuffer: IWebGL2GPUBuffer, 
    buffer: Readonly<BufferSource>, offset: number, size: number): void {
    if (gpuBuffer.usage & BufferUsageBit.INDIRECT) {
        gpuBuffer.indirects.clearDraws();
        const drawInfos = (buffer as IndirectBuffer).drawInfos;
        for (let i = 0; i < drawInfos.length; ++i) {
            gpuBuffer.indirects.setDrawInfo(offset + i, drawInfos[i]);
        }
    } else {
        const buff = buffer as ArrayBuffer;
        const { gl } = device;
        const cache = device.stateCache;

        switch (gpuBuffer.glTarget) {
        case gl.ARRAY_BUFFER: {
            if (device.extensions.useVAO) {
                if (cache.glVAO) {
                    gl.bindVertexArray(null);
                    cache.glVAO = null;
                }
            }
            gfxStateCache.gpuInputAssembler = null;

            if (cache.glArrayBuffer !== gpuBuffer.glBuffer) {
                gl.bindBuffer(gl.ARRAY_BUFFER, gpuBuffer.glBuffer);
                cache.glArrayBuffer = gpuBuffer.glBuffer;
            }

            if (size === buff.byteLength) {
                // 如下,你会看到很多使用buffereSubData将数据更新到对应buffer的逻辑
                gl.bufferSubData(gpuBuffer.glTarget, offset, buff);
            } else {
                gl.bufferSubData(gpuBuffer.glTarget, offset, buff.slice(0, size));
            }
            break;
        }
        case gl.ELEMENT_ARRAY_BUFFER: {
            if (device.extensions.useVAO) {
                if (cache.glVAO) {
                    gl.bindVertexArray(null);
                    cache.glVAO = null;
                }
            }
            gfxStateCache.gpuInputAssembler = null;

            if (cache.glElementArrayBuffer !== gpuBuffer.glBuffer) {
                gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gpuBuffer.glBuffer);
                cache.glElementArrayBuffer = gpuBuffer.glBuffer;
            }

            if (size === buff.byteLength) {
                gl.bufferSubData(gpuBuffer.glTarget, offset, buff);
            } else {
                gl.bufferSubData(gpuBuffer.glTarget, offset, buff.slice(0, size));
            }
            break;
        }
        case gl.UNIFORM_BUFFER: {
            if (cache.glUniformBuffer !== gpuBuffer.glBuffer) {
                gl.bindBuffer(gl.UNIFORM_BUFFER, gpuBuffer.glBuffer);
                cache.glUniformBuffer = gpuBuffer.glBuffer;
            }

            if (size === buff.byteLength) {
                gl.bufferSubData(gpuBuffer.glTarget, offset, buff);
            } else {
                gl.bufferSubData(gpuBuffer.glTarget, offset, new Float32Array(buff, 0, size / 4));
            }
            break;
        }
        default: {
            error('Unsupported BufferType, update buffer failed.');
        }
        }
    }
}

至此我们了解的engine对buffer数据的更新方式,先申请内存大小,然后统一更新数据。

这个和2x区别很大:

2x是预申请申请一个很大的buffer,然后assembly通过buffer[index]的方式,将数据更新到对应位置上,最后提交给GPU。

UIRender

UIRenderer.renderData

从提交的渲染数据开始查起

收集渲染数据的过程

image.png

从文件命名上,可以看到一些架构的思路,root持有batcher,batcher通过accesor访问到具体的buffer,最后将buffer里面的ia/va,通过gl.bufferSubData的方式提交给GPU

数据来源是_iaPool

batcher-2d就是一个单例,batcher每次在uploadBuffer时,遍历的bufferAccessors来自于requestsRenderData时,会自动根据strideBytes关联对应的accessors

从sprite的assembly可以看到,一直在修改renderData.chunk.vb

sprite的数据就是和这个mesh-buffer要关联上,并且放到meshBuffer.iData/meshBuffer.vData里面

更新渲染

主循环在遍历node

public walk (node: Node, level = 0): void {
    if (!node.activeInHierarchy) {
        return;
    }
    const children = node.children;
    const uiProps = node._uiProps;
    const render = uiProps.uiComp as UIRenderer;
    if (!approx(opacity, 0, EPSILON)) {
        // Render assembler update logic
        if (render && render.enabledInHierarchy) {
            render.fillBuffers(this);// for rendering
        }
    }
}

creator3.x在使用typescript重构后,阅读源码的确方便了,至少再也不用边运行边调试。

几个比较重要的渲染过程函数

assembler.fillBuffers: 提交渲染数据

image.png

assembler.updaetRenderData: 更新渲染数据

这种方式是在组件创建时,开发者根据需要在这个回调里面触发

image.png

这种方式是通过设置dirty主动刷新

image.png

如何设置dirty呢, 渲染组件有一个markForUpdateRenderData函数,就是在往_dirtyRenderers里面塞数据,看到这里就明白了大致之间的关系

image.png

这个updateAllDirtyRenderers过程发生的顺序,是在frameMove之前,frameMove里面就是在遍历场景后,进行fillBufers操作

image.png

assembler.updateColor:更新颜色

用户修改颜色,会触发对应的回调

image.png

顶点和uv的关系

image.png