# 渲染图

渲染图(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

其次是输出的类型,为第二个类型参数和成员变量,在这里为MeshListpublic outputType: 'MeshList' = 'MeshList';

输入和输出类型均在engine.IRGData定义,开发者可自行扩展,目前内置的类型有:

类型Key 对应类型 说明
None void 空类型
Camera BaseCamera 相机
RenderTarget RenderTexture/Screen 渲染目标
MeshList ScalableList 剔除列表或绘制列表
LightList BaseLight[] 光源列表

# 初始化

在输入和输出之外,还有初始化类型参数。每个节点都可以拥有自己的初始化参数,来定制这个节点实现不同的功能。

初始化类型参数为RGNode的第三个类型参数,在此例中为IRGCullNodeOptions,即{lightMode: string, initSize?: number},表明在创建这个节点时可以传入这种类型的对象来初始化这个节点。

而这初始化参数也会传入每个生命周期中,供开发者使用。

# 生命周期

类型参数决定着节点对外的接口和初始化,而生命周期则决定着节点内部的运行。以这个剔除节点可以看出,总共有三个生命周期,并且他们都有相同的参数。

  1. onInit: 当节点在某个刚被创建时触发。
  2. onActive:当节点在某个渲染图中,并且在图启动时被触发。
  3. onExecute:在每一帧渲染执行时触发。
  4. onDisable:当节点在某个渲染图中,并且在图停用或者被移除出图时被触发。

比如在剔除节点中,在图启动时创建了其输出的_output,而在图执行时则使用了输入cameracull方法,在图停用时销毁了_output防止内存泄漏。

在生命周期的编写中,也可以看到其参数的作用:

  1. context:实际上就是上一章提到的renderSystem,可以使用它来获取渲染系统的一些信息。
  2. 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);

连接和断开的方法皆需要提供fromto两个节点,顾名思义就是从from节点连接到to节点,而对于连接,可能还需要提供inputKey,这是由于一个节点可能有多个输入,所以需要一个key来决定这个from节点需要对接到to节点的哪个输入上。

当然,也可以不提供inputKey,这意味着不需要将from节点的输出作为to节点的输入,在这种情况下,其实只是保证了两个节点的执行顺序。但一旦提供了inputKey,就一定要保证from的输出类型和to的这个key的输入类型是一致的,否则会在编译时报错。

在节点连接完成后的图第一次运行时,将会执行编译过程。编译过程本质上就是将整个图的所有节点进行拓扑排序,同时校验各个节点的连接关系,同时执行onActive生命周期。

# 生命周期

了解了节点如何创建和连接后,便可以进一步明确RenderGraph的生命周期了:

  1. onActive:在图被使用时触发,使用请见下一节。
  2. onCamerasChange:在场景中的摄像机列表改变时触发,开发者可以利用这个方法来以摄像机维度构建RenderGraph,这将会在后续的内建管线中说道。
  3. onExecuteBegin:在每帧渲染开始前触发,开发者可以在这里设置一些全局Uniform等。
  4. onExecuteDone:在每帧渲染结束后触发。
  5. onDisable:在渲染图不在使用时触发。

# 使用和调试

要使用RenderGraph,只需要创建完毕后在使用即可:

const rg = new CustomRG();

game.renderSystem.useRenderGraph(rg);

如果需要知道运行状况,开发者可以使用rg.showDebugInfo()来看到拓扑排序后的节点顺序。