深入理解CocosCreator3D渲染管线
CocosCreator3D 刚刚发布了 3.0 Preview 版,首次将2D和3D版本合并到了一起,经过多个版本迭代,渲染架构大幅升级与优化,非常值得深入学习和研究,以下是官网的 性能与框架介绍
- 多渲染后端框架,已支持 WebGL 1.0 和 WebGL 2.0
- 面向未来的底层渲染 API 设计
- 基于 Command Buffer 提交渲染数据
目前渲染相关文档并不完善,本文将从源码入手分析C3D多渲染后端框架GFX,引擎默认的前向渲染管线实现,以及如何实现自定义渲染管线。
目录
多渲染后端框架GFX
GFX是针对渲染层做的高级抽象和封装,以达到编写一次渲染代码,适配不同渲染后端的目的。
目前源码中可见引擎已经适配了以下几种渲染后端:
- WebGL
- WebGL2
- OpenGL ES2
- OpenGL ES3
- Metal
- Vulkan
GFX可以理解为实现C3D引擎渲染的最基础接口,实现自定义渲染只能通过GFX提供的接口和规则来编写,以往在Cocos引擎中直接编写GL代码的方式已经成为过去。
GFX接口设计更贴近Vulkan等下一代渲染接口,为了说明GFX如何抽象渲染层,我们通过 WebGL 渲染做一个对比,然后再用GFX接口来实现同样的功能。
WebGL渲染过程
下面的示例代码展示了一次简单的 WebGL 渲染,目的是显示一张2D纹理:
function prepare(){
// texture
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(/**...*/);
gl.texImage2D(/** fill image data */);
// shader
var shader = someCreateShaderFunc("vert...", "frag...");
gl.useProgram(shader);
// vertex buffer
var vb = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vb);
gl.bufferData(/** fill vert data */;
// bind attributes
var attr = gl.getAttribLocation(shader, 'a_position');
gl.enableVertexAttribArray(attr);
gl.vertexAttribPointer(attr, /**...*/);
var attr = gl.getAttribLocation(shader, 'a_texCoord');
gl.enableVertexAttribArray(attr);
gl.vertexAttribPointer(attr, /**...*/);
// indices buffer
var ib = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib);
gl.bufferData(/** fill indices data */;
}
prepare();
render();
function render(){
// begin draw
//gl.bindFramebuffer(gl.FRAMEBUFFER, /**...*/);
gl.clearColor(/**...*/);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.viewport(/**...*/);
/** set states: depth test, stencil test, blend ... */
gl.useProgram(shader);
// set uniforms
gl.uniform(/**...*/);
// draw
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
// end draw
//gl.bindFramebuffer(gl.FRAMEBUFFER, null);
requestAnimationFrame(render);
}
这里可以把渲染过程简单分为2个阶段,数据准备和渲染执行 ,渲染执行又可分为数据绑定和绘制调用。实际游戏中渲染执行中会反复执行多个 DrawCall,直到完成一帧中的所有绘制。
-
数据准备:创建和提交数据到GPU
- 创建纹理
- 创建并编译Shader,也就是WebGLProgram
- 准备顶点数据,绑定attributes layout
- 准备索引数据
-
渲染执行:
- 准备画布,清除颜色/深度缓冲,设置viewport等
- 数据绑定:(Set Pass Call)
- 绑定纹理
- 设置uniform参数(Shader中的固定变量)
- 绘制调用:(Draw Call)
- 调用绘制函数
- 数据绑定:(Set Pass Call)
- 渲染结束
- 准备画布,清除颜色/深度缓冲,设置viewport等
数据准备:可以理解为模型数据(顶点和纹理等)的上传
渲染执行:每一帧都会调用,刷新游戏画面
数据绑定:一般抽象为材质数据,切换数据绑定则相当于切换不同的材质
GFX中的对象
Device:抽象GFX渲染设备,提供与设备交互的渲染接口,具体实例化为WebGLDevice,VKDevice等。
此外定义了15种渲染对象类型:
export enum ObjectType {
UNKNOWN,
BUFFER,
TEXTURE,
RENDER_PASS,
FRAMEBUFFER,
SAMPLER,
SHADER,
DESCRIPTOR_SET_LAYOUT,
PIPELINE_LAYOUT,
PIPELINE_STATE,
DESCRIPTOR_SET,
INPUT_ASSEMBLER,
COMMAND_BUFFER,
FENCE,
QUEUE,
WINDOW,
}
其中Fence(同步信号)和Window在WebGL实现中并没有用到。Texture,Sampler,Shader,FrameBuffer比较好理解,跟GL对象差异不大,其他9种对象类型分别是
- Buffer:抽象VB,IB,UB等各种数据缓冲。
- InputAssembler:集中管理VB,IB,Attributes,IndirectBuffer等各种输入。
- DescriptorSet:集中管理UB,Texture,Sampler等。
- DescriptorSetLayout:描述DescriptorSet绑定的布局。(相当于告诉GPU如何读取DescriptorSet数据)
-
CommandBuffer:将每一个渲染动作抽象为命令提交到队列,submit时统一执行,支持异步渲染。
(为Vulkan等下一代渲染接口准备,WebGL并未实现) - Queue:CommandBuffer队列?(WebgGL实现只有一个空的Queue,CommandBuffer只用了一个默认的)
- RenderPass:存放颜色缓冲区和深度缓冲区,也就是画布。
- PipelineLayout DescriptorSetLayout和其他扩展信息,如WebGL2实现中有WebGL2PipelineLayout信息。
-
PipelineState:主要由以下状态动态组建
- PipelineLayout
- Shader
- RenderPass
- RasterizerState:CullMode,PolygonMode等
- DepthStencilState:DepthTest,StencilTest等
- BlendTarget:Blend设置
- BlendState:BlendTarget集合等
- InputState:Attributes
DescriptorSet参考Vulkan中的概念:
GFX渲染过程
基于上述定义的基础概念,如果使用GFX实现同样的功能,代码如下:
function prepare() {
// vertex buffer
const vertexBuffers = device.createBuffer(/** buffer info */);
vertexBuffers.update(/** fill vert data */);
// indices buffer
const indicesBuffers = device.createBuffer(/** buffer info */);
indicesBuffers.update(/** fill indices data */);
// bind attributes
const attributes: Attribute[] = [
new Attribute('a_position', Format.RG32F),
new Attribute('a_texCoord', Format.RG32F),
];
const IAInfo = new InputAssemblerInfo(attributes, [vertexBuffers], indicesBuffers);
const assmebler = device.createInputAssembler(IAInfo);
// material, texture(sampler), shader, pass
const material = new Material();
material.initialize({ effectName: 'some shader name' });
sampler = device.createSampler(/**samplerInfo*/);
texture = device.createTexture(/** ...*/);
const pass = material.passes[0];
const binding = pass.getBinding('mainTexture');
pass.bindTexture(bingding, texture);
shader = ShaderPool.get(pass.getShaderVariant());
const descriptorSet = DSPool.get(PassPool.get(pass.handle, PassView.DESCRIPTOR_SET));
descriptorSet.bindSampler(binding, sampler);
descriptorSet.update();
}
prepare();
render();
function render() {
device.acquire();
const cmdBuff = device.cmdBuff;
const framebuffer = root.framebuffer;
const renderArea = new Rect(0, 0, device.width, device.height);
cmdBuff.begin();
// bind framebuffer, clear, set states ...
cmdBuff.beginRenderPass(framebuffer.renderPass, framebuffer, renderArea/** */);
// bind PipelineState
const pass = material.passes[0];
const pso = PipelineStateManager.getOrCreatePipelineState(device, pass, shader, framebuffer.renderPass, assmebler);
cmdBuff.bindPipelineState(pso);
cmdBuff.bindDescriptorSet(SetIndex.MATERIAL, pass.descriptorSet);
cmdBuff.bindInputAssembler(assmebler);
// draw
cmdBuff.draw(assmebler);
cmdBuff.endRenderPass();
cmdBuff.end();
device.queue.submit([cmdBuff]);
device.present();
requestAnimationFrame(render);
}
整理一下GFX渲染流程:
-
数据准备:创建和提交数据到GPU
- 创建Material,初始化Effect
- 创建Texture和Sampler,并绑定Texture到Pass
- 创建InputAssembler
- 创建GFX Shader
- 根据Pass,从对象池获取DescriptorSet, 绑定并更新
-
提交渲染指令:准备画布beginRenderPass
- 数据绑定:(Set Pass Call)
- 获取PSO对象,绑定PSO
- 绑定DescriptorSet
- 绑定IA
- 绘制调用:(Draw Call)
- 调用绘制函数draw(WebGL直接绘制)
- 数据绑定:(Set Pass Call)
-
执行渲染队列
- 提交并执行CommandBuffer
对比WebGL可以发现,渲染流程几乎一模一样,这有利于我们快速学习,但是细节上却有很大的区别,这也是号称面向未来的渲染API设计的原因,这里在GFX之上又封装了一些概念:
- Effect:C3D独有语法的Shader原始文件,类似Unity的ShaderLab。
- Pass:包含BlendState,RasterizerState等所有信息,全部按位存于handle里面,非常的高效。
- Material:对应一个Effect,可以有多个Pass。
- Shader(GFX Shader):结合pass指定编译宏组合动态创建,非常灵活。
显然GFX抽象的接口使用起来更加方便和灵活,OpenGL状态机只提供最细粒度的状态设置接口,如果渲染状态切换,OpenGL需要设置一大堆标志位,现在可以直接切换Pipeline,并且C3D使用了非常多的对象池来优化性能。
C3D渲染管线
C3D渲染管线基于GFX接口,再次做了一层封装,方便应用层灵活使用,大致的渲染流程如下
其中Camera数量可以有多个,Canvas(内含正交OrthCamera)也可以有多个。Flow和Stage都可以自定义和自由组合,Stage负责执行具体渲染指令。
C3D渲染架构UML
渲染相关类定义非常多,而且关系错综复杂,很多相互引用,这里列举一下几个关键类的含义:
- Root:可以理解为渲染大总管,集中管理所有渲染相关的对象,包含RenderPipeline,RenderWindow,RenderScene,Cameras,UI
- RenderPipeline:渲染管线,定义一组RenderFlow队列
- RenderWindow:渲染窗口,可以是屏幕缓冲也可以是离屏缓冲,可能有多个
- RenderView:渲染视图,Camera对象的渲染层表示
- RenderScene:整个Scene场景对应的渲染层对象
- UI:Scene场景中所有Canvas对应的渲染对象,统一由UI管理,C3D单独为UI创建了一个RenderScene用于存放UI渲染模型UIBatchedModel,所以Root中一共有2个RenderScene
- RenderFlow:定义一组渲染Stage
- RenderStage:渲染具体的实现,如ForwardStage,UIStage
C3D前向渲染管线
前向渲染管线是C3D提供的默认渲染管线,实现了3个类型的Flow:
- ShadowFlow:渲染阴影。
- ForwardFlow:对应一个3D Camera,可能有多个。
- UIFlow:对应一个ui_Canvas,可能有多个。
另外橙色步骤比较关键,主要负责性能优化:
- GenBatchedModel:UI动态合批。
- SceneCulling:裁剪渲染对象。
- FillQueue:根据裁剪后的对象,填充Instanced队列,Batched队列 ,不透明队列,透明队列。
C3D自定义染管线
C3D支持自定义渲染管线,我们尝试在ForwPipeline中新建一个后处理Flow,右键依次新建Forward Pipeline Asset,PostRenderFlow和PostRenderStage。
点击刚才创建的Pipeline资源,打开Inspector,设置对应的Flow和Stage,将PostRenderFlow插入ForwardFlow和UIFlow之间。
然后重载PostRenderFlow的activate方法,实现Flow的初始化
public activate(pipeline: ForwardPipeline) {
super.activate(pipeline);
// create framebuffer
}
重载PostRenderStage的render方法,实现自定义渲染逻辑
render(view: RenderView) {
const pipeline = this._pipeline as ForwardPipeline;
const cmdBuff = pipeline.commandBuffers[0];
const device = pipeline.device;
const renderPass = this.frameBuffer!.renderPass;
cmdBuff.begin();
cmdBuff.beginRenderPass();
// insert custom render code here
cmdBuff.endRenderPass();
cmdBuff.end();
bufs[0] = cmdBuff;
device.queue.submit(bufs);
}
最后打开Project Setting,切换至我们的Pipeline,然后就可以运行了!
结束
Creator的3D功能正在努力完善,后续将会推出更多高级和实用功能,我们将在第一时间体验。
最后十分感谢一直免费开源的Cocos,提供给我们直面源码的机会,祝福Cocos十周年,下一个十年更精彩!
参考文档
1、CocosCreator3D用户手册
2、Mozilla WebGL API
3、learnopengl.com
4、Vulkan 资源绑定和状态管理