RPS 教程第二部分 – 探索渲染图和 RPSL

首次发布时间:
最后更新:
Noah Cabral's avatar
Noah Cabral

RPS 教程第二部分 – 探索渲染图和 RPSL

引言

在上一个教程中,我们学习了如何创建一个简单的应用程序,将单个三角形渲染到屏幕上。在本教程的第二部分,我们将解答有关 RPSL 的关键问题,并更深入地理解 RPS 渲染图。

概述

渲染图中的资源

在教程介绍中,我们简要提到 RPS SDK 提供了一种优雅而高效的解决方案来管理瞬态内存。这体现在瞬态资源上,我们可以在渲染图中轻松创建这些资源。

瞬态资源的一个例子如下,

compute node tone_mapping([readwrite(cs)] texture src);
graphics node blt_swapchain_color_conversion(rtv rt : SV_Target0, srv src);
export void rps_main([readonly(present)] texture backbuffer)
{
ResourceDesc backbufferDesc = backbuffer.desc();
uint32_t width = (uint32_t)backbufferDesc.Width;
uint32_t height = backbufferDesc.Height;
texture colorBuffer = create_tex2d(RPS_FORMAT_R16G16B16A16_FLOAT, width, height);
clear( colorBuffer, float4(0, 0, 0, 0) );
// do work ...
tone_mapping(colorBuffer);
blt_swapchain_color_conversion(backbuffer, colorBuffer);
}

create_tex2d 内置节点声明了一个资源,作为 RPSL 程序指定的渲染图的一部分。create_tex2d 的返回值是声明资源上的默认资源视图。

我们将再次深入探讨这一点,资源视图是 RPS 的工作流。为了进一步指定默认视图,我们可以创建派生资源视图。这可以通过 texturebuffer 视图类型的成员函数来完成。例如,可以使用 .mips 创建一个视图,该视图包含一系列纹理 mip 的子资源范围。有关此类成员函数的完整列表,请参阅 rpsl.h

RPS SDK 会自动在图形 API 堆中创建和销毁这些瞬态资源;它还可以有效地进行资源别名,从而减少总内存使用量。同样,这些瞬态资源的创建,如同节点的调用一样,是虚拟的。它们在 RPSL 程序执行后以及渲染图更新期间创建。

至于 C API 端,可以在构建回调中调用 rpsRenderGraphDeclareResource 来声明一个瞬态资源。

RpsResourceId rpsRenderGraphDeclareResource(RpsRenderGraphBuilder hRenderGraphBuilder,
const char* name,
RpsResourceId localId,
RpsVariable hDesc);

此调用的第四个参数是指向资源描述的指针,应为 RpsResourceDesc 类型。对于每个声明的资源,localId 在每个渲染图中必须是唯一的。如果在同一个构建回调中进行两次具有相同 localId 的调用,则会导致未定义行为。在进行引用此资源的后续渲染图构建调用时,必须使用原始调用返回的 RpsResourceId

总的来说,关于通过 C API 构建渲染图的参考,可以在 test_builder_c.c 中找到具体示例。

资源类

在上一个部分,我们提到只有瞬态资源由 RPS 运行时管理。然而,这适用于渲染图中声明的*任何*资源。事实上,瞬态资源并不是唯一的资源类别。

渲染图可以处理其他类型的资源。资源类型的总列表包括:外部资源;持久资源;瞬态资源;当然,正如在第一个教程部分提到的,还有时间资源。

外部资源是通过入口节点传入渲染图的任何资源。这包括输出资源,有关详细信息请参阅 faq.md

持久资源是指其数据(除非明确清除)在渲染图更新之间保持不变的资源。瞬态资源是指任何非持久资源。

我们可以通过 RPS_RESOURCE_FLAG_PERSISTENT 资源标志来创建持久资源,

export void rps_main([readonly(present)] texture backbuffer)
{
ResourceDesc backbufferDesc = backbuffer.desc();
texture lastFrame = create_tex2d(
backbufferDesc.Format, backbufferDesc.Width, backbufferDesc.Height, 1, 1, 1, 1, 0, RPS_RESOURCE_FLAG_PERSISTENT);
// do work ...
copy_texture(lastFrame, backbuffer);
}

一个有用的思考持久资源的方式是将其类比为 CPU 端 的静态变量。这些变量只初始化一次,然后在稍后可以设置为另一个值。持久资源也遵循相同的思路——创建只发生一次,直到通过调用 create_tex2d 更改资源描述,资源数据才会保持不变。

外部资源和时间切片都是隐式持久资源。外部资源显而易见,如果时间资源不是持久的,那么访问历史切片将是不可能的。

RPS 渲染图结构

正如第一部分所述,渲染图的形式是调用节点的线性序列。我们也知道这个序列会被调度器阶段重新排序。此外,我们知道有称为图命令的东西,我们在录制时会遍历它们。

之前的讨论遗漏了一些重要的细节,并没有完全描绘全景。调度器阶段只是一个完整的渲染图阶段集中的一个阶段。因此,渲染图在各个阶段运行时会呈现多种不同的形式——特别是,正如我们所预期的,它会呈现出比简单的线性序列更复杂的图结构。

渲染图构建阶段

渲染图是如何通过各个阶段进行转换的

它以一个线性的、有序的命令节点序列开始,其顺序与着色器中指定的顺序相同。命令节点是已声明或内置节点的缓存调用,其中保存了参数值。第一个阶段接收此序列,并将其转换为有向无环图 (DAG),在需要的地方进行连接并插入新节点。从那里,调度器阶段的工作将所有 DAG 节点合并到一个称为运行时命令的线性序列中(这些就是之前提到的图命令)。运行时命令只是命令节点(或任何新插入的节点),它们已被明确安排由运行时后端进行录制;如果某些运行时命令被认为没有副作用,可能会在调度器阶段被消除。

并非所有阶段都会操作渲染图。其中有三个用于调试目的:CmdDebugPrintPhaseDAGPrintPhaseScheduleDebugPrintPhase。第一个阶段打印出先前提到的按程序顺序排列的命令节点序列。这是在任何渲染图阶段操作渲染图结构之前的形式。第二个阶段以 DOT 语言打印 DAG,以便可以使用 Graphviz 或其他图形可视化软件进行渲染。最后一个打印阶段打印调度器阶段的结果;也就是说,它打印出最终由宿主应用程序迭代以将 API 命令录制到某个命令缓冲区中的运行时命令的线性流。

渲染图的含义及其工作原理

既然我们了解了它是如何构建的,我们就可以开始讨论它的结构和含义了。

请看下面的图片,它展示了我们 MRT 测试中的一个 RPS DAG(位于 test_mrt_viewport_clear.rpsl

render_graph.PNG

顶部的标尺是时间线,表示要录制的命令的索引。要录制的命令以图中小于标尺的矩形形式可视化。这些命令是图中的节点,已进行了调度,并保留了 DAG 边以用于可视化。命令的颜色表示最小队列族,例如图形(青色)、计算(橙色)和复制(亮绿色)。这些颜色在 RPS-SDK 中是通用的。

如果您还不知道,此 SDK 提供了一个有用的工具来导航 RPS 渲染图——**RPSL Explorer**。它用于生成上图,并且可以在 /tools/rpsl_explorer 找到。该探索器通过集成一个分离的可视化库来工作,该库可以可视化资源生命周期、堆分配以及渲染图本身。将此集成到您的引擎中以访问相同的可视化功能是一个好主意。该库可以在这里找到:/tools/rps_visualizer/

好的,我们知道我们在看什么。但它意味着什么呢?

边表示节点之间的排序依赖关系,即当调度器阶段将节点放入线性命令流时,它必须遵守某些节点 A 的工作应在某些节点 B 的工作提交到 GPU 之前完成。

至于节点本身的含义,大致如下

除非另有说明,节点在图中的任何写入操作都保证看起来像是原子执行的。对于原子操作的考虑范围是图中访问同一子资源的节点集合。此保证归因于两个隐式和概念性的内存依赖:一个在节点访问之前,一个在节点访问之后。

这里,内存依赖的定义与 Vulkan 规范中的类似。当同时考虑开始/结束依赖时,可以说节点的写入操作发生在读取操作之前,并且对后续节点的访问是可用的和可见的,并且它们发生在先前的只读节点访问之后。

另一个同步元素是在渲染图执行之间,即任何节点对持久资源的写入操作的同步范围不仅限于单个渲染图执行,还包括跨时间的整个渲染图执行序列。

如果宿主应用程序从节点回调之外录制对外部资源的操作,则可能还需要同步。回想一下,入口节点参数(外部资源)的访问属性建立了资源如何在图的范围之外被访问。因此,如果外部范围或图内的访问写入外部资源,则需要同步。

我们以实际意义来结束

渲染图通过插入转换节点来实现其概念模型和节点模型。这些是上图中紫色的菱形,它们被调度为运行时命令矩形之间的批次。在录制渲染图并迭代一批转换时,这些转换被记录到命令缓冲区作为显式的 API 屏障(除非使用的运行时后端不支持它们)。

渲染图节点依赖

到目前为止,我们了解到节点对资源的每一次写入访问都与其他对该资源的访问进行了同步。我们还提到图中的每一条边都表示两个节点之间的提交顺序依赖。

但是,在什么情况下会插入图边呢?

如果两个连续的命令节点中,至少有一个节点包含对同一子资源的写入访问,则会发生这种情况。当运行时后端认为后续访问需要进行过渡时,也会发生边插入。例如,Vulkan 运行时后端要求对同一子资源的连续访问,如果具有 [readwrite(copy)] 属性,则需要进行过渡。最后,存在一组可以发生边插入的一般情况,例如连续的 UAV 访问。

渲染图数据流模型

为了完成本节,我们将提供一个关于上述所有规则的优雅概念化。

由于写入节点的同步以及图边插入的条件,我们可以逻辑地推断出渲染图的边表示图节点之间的*数据流*。这里,“数据流”是如何在 GPU 上执行期间,在帧期间,数据被节点在时间上访问的。对于任何进入节点的传入边,节点内的关联工作直到传入数据“到达”节点才能开始。

探索 RPSL

访问属性

正如在第一个教程部分提到的,每个节点的所有参数都可以用称为访问属性的东西进行装饰。访问属性描述了节点如何查看资源的特定访问类型。一般形式包括 readonlywriteonlyreadwrite,后跟包含在 () 中的属性参数。

严格来说readonly (RO) / writeonly (WO) / readwrite (RW) 中的任何一个都不是必需的,属性(例如 [relaxed])完全是允许的。有关 [relaxed] 属性的更多详细信息,请参阅 faq.md。也可以以无序列表形式指定访问属性,例如 [relaxed] [readwrite(ps, cs)]。

readonlywriteonlyreadwrite 顾名思义,即它们控制节点是否被允许读取、写入或同时读取和写入资源。writeonly 访问属性有一个额外的子句,即它表示可以丢弃所查看子资源范围的先前数据。此子句暗示以下相等关系:writeonly = readwrite(discard_before)。

访问属性参数

discard_before 是一个属性参数,表示节点通过此视图访问不关心之前的数据,因此数据可以被丢弃。discard_after 是一个对称参数,工作方式相反。形式上,这些是数据依赖指示符。RPS 可以自由地忽略它们或以其他方式将它们用于优化目的。

由于这些是数据依赖指示符而不是强指令,很容易看出诸如节点参数上的 readonly(discard_before) 永远不会映射到该节点记录的丢弃操作。这种丢弃构成了写入,当然,对视图的只读访问可能不会写入所查看的资源。对于 readonly 属性的丢弃参数,只有在任何周围的运行时命令节点对同一子资源范围具有写入访问权限时,才会记录丢弃操作。这是因为如果某些运行时命令具有 discard_before 属性,RPS 可能会根据需要用 discard_after 来修补之前的命令。

另一类属性参数描述了特定的访问类型。在第一个教程部分讨论的例子是 rendertarget。解释了具有此访问权限访问资源的节点将确保该资源能够作为渲染目标视图被节点访问,并且这会导致运行时后端进行潜在的状态/布局转换。总的来说,按照图形 API 的要求来访问此资源作为渲染目标视图所需的所有内容都由 RPS 运行时处理。这意味着,例如在 DX12 端,资源必须使用 D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET 创建。

因此,实际上,用户和 RPS 之间存在一个额外的约定。在提供外部资源时,您需要确保资源可以根据其在渲染图中的使用方式进行访问。

除了特定的访问类型,属性参数还用于指定资源可访问的管道阶段。例如,上面使用的代码片段之一是 [readwrite(ps, cs)],它表示所查看的子资源范围可由像素着色器和计算着色器管道阶段访问。这些访问属性类型例如被某些运行时后端用来建立在显式 API 屏障内使用的适当的同步范围。

要查看所有属性参数的文档,请参阅 rpsl.h

模块化 RPSL

在 RPSL 中实现模块化有两种主要方法

  1. HLSL 函数。

  2. 组合渲染图。

同样,渲染图命令节点是在 RPSL 程序执行期间被注意到调用的节点集。节点不必在程序的入口函数中调用——即使它们是从某个非入口的 HLSL 函数中调用的,效果也很好。

例如,考虑,

void foo()
{
nodeA();
nodeB();
nodeC();
}
entry void main([readwrite(present)] texture backbuffer)
{
foo();
}

上面的例子会生成一个具有 A、B 和 C 命令节点的图。

至于组合渲染图,这意味着将整个图绑定到节点回调。这可以通过调用 rpsProgramCreate 从任意 RPSL 入口点创建 RpsSubprogram,然后通过 rpsProgramBindNodeSubprogram 将该子程序绑定为节点回调来实现。组合渲染图的效果是“内联”子程序;“内联”意味着在主 RPSL 程序执行期间,不是记录要调用的节点,而是调用子程序 RPSL 入口点,从而构成渲染图的命令节点总集。需要注意的一个细节是,在录制图时要调用哪个回调的信息存储在 RpsSubprogram 中,这意味着将回调绑定到节点声明是在每个程序基础上完成的。这意味着两个程序可以共享一个节点声明,但该节点声明的运行时命令可以根据该节点所属的程序调用不同的回调。

例如,请参阅 test_subprogram_d3d12.cpp

调度工具

为了对最终调度的命令流进行细粒度控制,RPSL 提供了各种调度工具。

sch_barrier():

这是一个调度内建函数,可用于创建任何节点都无法跨越的屏障。它形成一组在屏障之前的节点 A 和一组在屏障之后的节点 B,使得集合 A 中的任何节点都不能在屏障之后调度,集合 B 中的任何节点都不能在屏障之前调度。

例如,

{
// this will never be scheduled as nodeB -> nodeA.
nodeA();
sch_barrier();
nodeB();
}

sch_barrier() 在某些“设置”节点必须首先录制时很有用。也许该节点对宿主应用程序端有副作用,影响了任何后续节点回调的行为,但否则不访问任何资源。从调度器的角度来看,这样一个节点可以存在于命令流中的任何位置,但作为开发者,我们理解它必须出现在所有其他节点之前。

[subgraph(atomic, sequential)]

这是一个属性,可以应用于 HLSL 函数或作用域。上面的标题列出了 atomicsequential 的属性参数。当 subgraph 属性应用于作用域时,作用域内的所有节点都包含在 subgraph 中;而当它应用于 HLSL 函数时,这将导致调用该函数所产生的所有节点都包含在 subgraph 中。

sequential 参数表示子图内节点的相对顺序将被保留。例如,考虑,

{
[subgraph(sequential)] {
nodeC();
nodeB();
nodeA();
}
nodeD();
}

在上面的示例中,调度器永远不会重新排序 nodeC -> nodeB -> nodeA 的相对顺序。但是,nodeD 仍然可能被插入到子图的作用域中(例如,形成一个运行时命令流,如 nodeC -> nodeD -> nodeB -> nodeA)。

atomic 参数意味着子图外部的节点不能被重新调度到子图的作用域内,反之亦然。原子子图的一个潜在有用的心智模型是将其视为功能上可以替换为单个节点。该节点将为子图节点访问的一组资源中的每个元素提供一个参数,其回调将通过内部过渡来组合子图的所有工作。

子图也可以递归指定,

{
[subgraph(sequential)] {
[subgraph(atomic, sequential)] {
nodeB();
nodeC();
}
[subgraph(sequential)] {
nodeD();
nodeE();
}
nodeA();
}
}

当子图嵌套时,会形成一个树状结构,叶节点是节点调用,如上面带有 nodeB()、nodeC() 等的示例。当根节点是顺序子图时,sequential 属性参数不适用于直接子节点,而是适用于叶节点的相对顺序。因此,上面的示例将调度为 B -> C -> D -> E -> A 的固定相对顺序。

async:

为了允许异步计算场景,可以使用 async 关键字来提示工作应该在非主图形队列上调度。此关键字用作调用节点时表达式的一部分。

考虑例如以下 RPSL 代码行,

async Procedural(proceduralTex, cbView, uint2(proceduralTexWidth, proceduralTexHeight));

当与 graphics 节点调用一起使用时,将忽略此关键字。

HLSL 语义

到目前为止,已经提到了诸如 SV_Viewport[n]SV_ScissorRect[n]SV_Target[n] 等 HLSL 语义作为 RPSL 关键字,可用于装饰节点参数,以便在命令回调中自动设置视口矩形、剪刀矩形和渲染目标。

还有其他用于绑定目标的语义,如 SV_DepthStencilSV_ResolveTarget[n]。这些可分别用于绑定深度/模板缓冲区和解析目标。例如,

node draw(rtv rt : SV_Target0, [readwrite(depth, stencil)] texture ds : SV_DepthStencil);
node draw_msaa(rtv rtMSAA : SV_Target0, [writeonly(resolve)] texture rt : SV_ResolveTarget0);

诸如 depthstencilresolve 等访问属性在回调中的自动设置方面没有任何作用。它们仅仅是为了建立此节点可以通过其访问资源视图的访问类型。SV_DepthStencil 语义以独立的方式控制深度/模板缓冲区的绑定,因此在 draw 的回调中无需执行此操作。在 DX12 后端,这是通过 OMSetRenderTargets 完成的,而在 Vulkan 后端,则会创建并开始具有适当附件的渲染通道。

关于 SV_ResolveTarget[n] 的行为,当节点已经有某些 SV_Target 时,必须使用它,即 SV_ResolveTarget[n] 将原本的常规渲染通道转换为最终将渲染目标解析到解析目标的渲染通道。解析会在宿主应用程序节点回调返回后自动记录。要仅执行解析而不将其附加到现有节点,存在内置的 resolve 节点。当然,如果开发人员选择这样做,没有什么可以阻止他们实现自定义解析节点。

其他语义,如 SV_ClearColor[n]SV_ClearDepthSV_ClearStencil,可用于指示 RPS 清除关联的便利绑定目标。

我们将通过以下代码片段演示这一点,

node clear_targets(rtv rt0 : SV_Target0,
rtv rt1 : SV_Target1,
rtv rt2 : SV_Target2,
dsv ds : SV_DepthStencil, // dsv = [readwrite(depth, stencil)] texture
float4 clearColor0 : SV_ClearColor0,
float clearDepth : SV_ClearDepth,
uint clearStencil : SV_ClearStencil);

调用上面的 clear_targets 节点时,我们可以提供要清除的值,

const uint w = 1280;
const uint h = 720;
texture rt0 = create_tex2d(RPS_FORMAT_R8G8B8A8_UNORM, w, h);
texture rt1 = create_tex2d(RPS_FORMAT_R16G16B16A16_FLOAT, w, h);
texture rt23 = create_tex2d(RPS_FORMAT_B8G8R8A8_UNORM, w, h, 1, 2);
texture ds = create_tex2d(RPS_FORMAT_R32G8X24_TYPELESS, w, h);
clear_targets(rt0, rt1, rt23.array(0), ds.format(RPS_FORMAT_D32_FLOAT_S8X24_UINT), float4(0, 1, 0, 1), 0.5f, 0x7F);

与视口和剪刀矩形的设置类似,这些清除操作会在调用宿主回调之前发生。

Noah Cabral's avatar

Noah Cabral

Noah Cabral 是 AMD 核心技术组的图形研发实习生。他对计算机科学和计算机图形学的一切都怀有长久的热情,并希望为最先进的实时图形的发展做出贡献。
© . This site is unofficial and not affiliated with AMD.