# 渲染图
渲染图(Render Graph)是小游戏框架用于管理整个渲染流程的体系,也可以认为其为渲染管线的一种抽象。
渲染图的基础是渲染节点,讲这些渲染节点组织起来,就构成了一幅图。在实际运行中,这些节点将会被拓扑排序,然后按照顺序执行。
注意目前渲染图并非是最终版本,只提供代码构建的模式,未来其将成为一种重要的资源。
# 节点
节点是渲染图的基础,其基类是engine.RGNode
,一个RGNode主要由输入、处理、输出三部分组成,让我们看看一个典型的节点的实现:
interface IRGCullNodeOptions {
lightMode: string;
initSize?: number;
}
class RGCullNode extends RGNode<{camera: 'Camera'}, 'MeshList', IRGCullNodeOptions> {
public inputTypes = {camera: 'Camera' as 'Camera'};
public outputType: 'MeshList' = 'MeshList';
public onActive(context: RenderSystem, options: IRGCullNodeOptions) {
this._output = new Kanata.ScalableList(options.initSize || 2048);
}
public onExecute(context: RenderSystem, options: IRGCullNodeOptions) {
this.getInput('camera').cull(this._output!, options.lightMode);
}
public onDisable(context: RenderSystem, options: IRGCullNodeOptions) {
this._output = undefined;
}
}
这是一个剔除节点,其构成分为三个部分:输入输出、初始化和生命周期。
# 输入输出
输入输出类型影响着其与其他节点的连接关系。在上面的剔除节点中,这几部分的体现为:
首先是输入的类型,需要在第一个类型参数和成员变量同时指定,在这里为{camera: 'Camera'}
和public inputTypes = {camera: 'Camera' as 'Camera'};
,之所以看起来要写两次,是因为TS的类型系统无法保证运行时的检查,而这个检查如果不严格要求是可以绕过的。在这个定义中,可见我们为camera
这个输入指定了输入类型Camera
。
其次是输出的类型,为第二个类型参数和成员变量,在这里为MeshList
和public outputType: 'MeshList' = 'MeshList';
。
输入和输出类型均在engine.IRGData
定义,开发者可自行扩展,目前内置的类型有:
类型Key | 对应类型 | 说明 |
---|---|---|
None | void | 空类型 |
Camera | BaseCamera | 相机 |
RenderTarget | RenderTexture/Screen | 渲染目标 |
MeshList | ScalableList | 剔除列表或绘制列表 |
LightList | BaseLight[] | 光源列表 |
# 初始化
在输入和输出之外,还有初始化类型参数。每个节点都可以拥有自己的初始化参数,来定制这个节点实现不同的功能。
初始化类型参数为RGNode的第三个类型参数,在此例中为IRGCullNodeOptions
,即{lightMode: string, initSize?: number}
,表明在创建这个节点时可以传入这种类型的对象来初始化这个节点。
而这初始化参数也会传入每个生命周期中,供开发者使用。
# 生命周期
类型参数决定着节点对外的接口和初始化,而生命周期则决定着节点内部的运行。以这个剔除节点可以看出,总共有三个生命周期,并且他们都有相同的参数。
- onInit: 当节点在某个刚被创建时触发。
- onActive:当节点在某个渲染图中,并且在图启动时被触发。
- onExecute:在每一帧渲染执行时触发。
- onDisable:当节点在某个渲染图中,并且在图停用或者被移除出图时被触发。
比如在剔除节点中,在图启动时创建了其输出的_output
,而在图执行时则使用了输入camera
的cull
方法,在图停用时销毁了_output
防止内存泄漏。
在生命周期的编写中,也可以看到其参数的作用:
- context:实际上就是上一章提到的
renderSystem
,可以使用它来获取渲染系统的一些信息。 - options:就是节点的初始化参数。
# 图
有了节点,便可以将这些节点连成图。渲染图,在小游戏框架中为engine.RenderGraph
,其提供了一些方法,来管理和驱动所有的渲染节点。
为了定制一个渲染图,开发者首先要定义自己的类:
export default class CustomRG extends RenderGraph {
public onActive(context: RenderSystem) {}
public onCamerasChange(cameras: BaseCamera[]) {}
public onExecuteBegin(context: RenderSystem) {}
public onExecuteDone(context: RenderSystem) {}
public onDisable(context: RenderSystem) {
}
可见,RenderGraph也有一套生命周期机制。但在了解生命周期之前,我们先需要了解图和节点之间的关系。
# 创建、销毁节点
细心的开发者应该已经察觉,在介绍节点的时候我们都没有讨论如何去创建和销毁节点,这是因为节点的创建销毁需要依赖RenderGraph而不能独立进行,这是为了保证图能够管理节点的整个生命周期。为了创建和销毁节点,RenderGraph提供了两个方法:
// 创建
const rgNode = rg.createNode(name, clz, options);
// 销毁
rg.destroyNode(rgNode);
创建节点必须要提供一个名字name
,这是为了方便调试,然后是节点的类clz
,比如上面提到的RGCullNode
,最后是这个类对应的初始化参数options
。
# 连接节点和编译
创建了节点后,需要把它们连接起来才能构成图:
// 连接两个节点
rg.connect(from, to, inputKey);
// 断开两个节点连接
rg.disconnect(from, node);
连接和断开的方法皆需要提供from
和to
两个节点,顾名思义就是从from
节点连接到to
节点,而对于连接,可能还需要提供inputKey
,这是由于一个节点可能有多个输入,所以需要一个key来决定这个from
节点需要对接到to
节点的哪个输入上。
当然,也可以不提供inputKey
,这意味着不需要将from
节点的输出作为to
节点的输入,在这种情况下,其实只是保证了两个节点的执行顺序。但一旦提供了inputKey
,就一定要保证from
的输出类型和to
的这个key的输入类型是一致的,否则会在编译时报错。
在节点连接完成后的图第一次运行时,将会执行编译过程。编译过程本质上就是将整个图的所有节点进行拓扑排序,同时校验各个节点的连接关系,同时执行onActive
生命周期。
# 生命周期
了解了节点如何创建和连接后,便可以进一步明确RenderGraph的生命周期了:
- onActive:在图被使用时触发,使用请见下一节。
- onCamerasChange:在场景中的摄像机列表改变时触发,开发者可以利用这个方法来以摄像机维度构建RenderGraph,这将会在后续的内建管线中说道。
- onExecuteBegin:在每帧渲染开始前触发,开发者可以在这里设置一些全局Uniform等。
- onExecuteDone:在每帧渲染结束后触发。
- onDisable:在渲染图不在使用时触发。
# 使用和调试
要使用RenderGraph,只需要创建完毕后在使用即可:
const rg = new CustomRG();
game.renderSystem.useRenderGraph(rg);
如果需要知道运行状况,开发者可以使用rg.showDebugInfo()
来看到拓扑排序后的节点顺序。