发现对creator3.x的渲染架构分析的比较少
学习了解creator渲染,包括其他开源游戏引擎的一切基础,需要你得知道如何使用webgl进行渲染一张图片,这是最基本的知识,不会的自己补课喽。
还是从最开始的源头查起来,我们写个demo,只渲染一个sprite,构建的场景应该尽可能的简单,Camera记得只保留一个,我们先不看3D场景的Camera。
然后定位下最终的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,符合预期了
开始追踪渲染数据
顺着堆栈往上找
发现最终的数据是落在this.queue里面,看下push地方
看到了_passPoll是个RecyclePool,是个复用池子,数据追踪到了renderObj的身上,接着追
最终数据又落在了this._pipleline身上,看下此时的this是ForwardStage
我们再看上上层的调用,就是RenderFlow的逻辑了
追到这里,我们基本可以确定数据即使在this._pipleline上,接下来的方向就是找往pipleline.pipelineSceneData.renderObjects里面存放数据的逻辑了,因为我们追了这么久,发现只有这里面的数据才会被提交渲染。
pipleline.pipelineSceneData.renderObjects
最简单的就是搜renderObjects.push(发现就2处逻辑在push
为什么可以这样看源码,因为所有的engine每一帧都是先擦除再绘制,绘制时,engine必须收集顶点信息,也就是每一帧engine都是要重新收集顶点信息的,这样才能保证渲染的结果是实时的,也正是基于这个思想,其实我们再看engien时,就可以猜着看,假如如果是我会怎么实现,这里面其实是纯逻辑的东西,还不牵扯高深的数学知识和图形知识,说白了就是在看engine架构。
看到这里,我们就发现了,数据又是从skybox.model里面过来的,最终的数据是落在pipeline.pipelineSceneData.skybox.model
到这里就有点麻烦了,我们需要知道这个model来自哪里,不是很好找
回头再看下完整的数据链路,可能有点长
pipleline.pipelineSceneData.renderObjects[?]
.model.subModel[?]
.inputAssembler.drawInfo
发现subModel是个数组,并且engine是必须将这个model进行一些赋值操作的,所以可以尝试调试下创建subModel的逻辑,首先我们需要找到Model的相关代码
这里有个小技巧,我们Console里面查看下相关的函数,chrome会告诉我们Function的地方,点击就能直接跳转过去,大概翻一下附近的代码,我们就能看到创建subModel的相关逻辑
一般来说,这个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
从提交的渲染数据开始查起
收集渲染数据的过程
从文件命名上,可以看到一些架构的思路,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: 提交渲染数据
assembler.updaetRenderData: 更新渲染数据
这种方式是在组件创建时,开发者根据需要在这个回调里面触发
这种方式是通过设置dirty主动刷新
如何设置dirty呢, 渲染组件有一个markForUpdateRenderData函数,就是在往_dirtyRenderers里面塞数据,看到这里就明白了大致之间的关系
这个updateAllDirtyRenderers过程发生的顺序,是在frameMove之前,frameMove里面就是在遍历场景后,进行fillBufers操作
assembler.updateColor:更新颜色
用户修改颜色,会触发对应的回调