Render Graph
全库目录221
本文目录21
给渲染器加一个模糊效果,算法本身可能很简单:把场景颜色降采样,模糊,再合成回最终颜色。但接入一帧时,还得回答几个问题:降采样结果什么时候才能读?临时纹理什么时候可以回收?效果关闭以后,前面的通道还要不要执行?
这些问题都依赖“谁读了谁的结果”。渲染图(Render Graph,也称 Frame Graph)把每个通道的资源读写声明收集起来,据此安排执行顺序、同步和内存。通道仍负责具体绘制或计算,图编译器则把它们组织成可以提交给GPU的执行计划。下面沿着这条模糊链看图怎样做出这些决定。
声明与执行
先把“这个通道做什么”和“它依赖什么”分开。模糊通道的回调负责采样与滤波,读写声明告诉图:它读取降采样结果,写出一张供合成使用的纹理。有了这些信息,图才能在录制具体命令之前分析整条链。
记录通道与逻辑资源
→ 建立读写依赖和资源版本
→ 从可观察输出反向标记有效通道
→ 安排执行顺序与队列
→ 计算资源使用区间和物理分配
→ 生成转换、屏障及跨队列同步
→ 执行回调并提交命令这里记录的纹理句柄先表示“后面要用这份数据”,不保证此时已经分配了物理纹理。等图看清哪些通道会执行、资源会活多久,再决定实际分配。回调执行时,再通过图提供的资源和命令接口取得对应资源。
上面的顺序是典型流程,具体引擎可能合并其中一些步骤。先抓住声明、编译和执行之间的分工,再去读某个引擎的实现,接口就容易对应到它正在解决的问题。
资源版本和依赖
用版本标识内容
读写声明里,“使用了这张纹理”还不够。假设一张纹理先写入场景颜色,后面又写入某个效果:中间的通道到底要读场景结果,还是效果结果?如果图把两次内容混为一谈,就会连错依赖。
所以图会区分初始内容、第一次写入后的内容和后续版本。有的实现给每次内容变化一个显式版本句柄,有的维护最后写入者与当前读取者集合。两种方式都是在回答:这次读取要等哪次写入,后续覆盖又要等哪些读取结束。
最直观的是写后读:模糊通道得等降采样结果写好。反过来,某个通道还在读旧内容时,后续写入也不能提前覆盖它,这就是读后写;连续两次写入则保留覆盖顺序。具体执行与内存可见性由同步实现落实,通用语义见 GPU同步与屏障。
两个通道都只读时通常能并行,但布局、队列归属和API状态仍可能增加约束。这也是声明要交代实际访问类型和子资源范围的原因:采样哪一级Mip、写哪一层附件,会影响图生成的同步。
内容依赖与执行约束
用一个反复写入的纹理X来看内部记录。A写X的第一版,B读取它;C完整覆盖X,D读取第二版。版本表为X1 → A、X2 → C,因此内容依赖是A → B和C → D。B读取时明确拿X1,D明确拿X2,图不会只凭纹理名字猜最后一次写入。
若两个版本用同一个物理纹理,B还要在C覆盖之前结束,这条B → C是读后写执行约束。连续写入也有A → C顺序。这里容易混淆:D只需要X2时,A和B没有数据消费者,本可以删除。若把所有WAW/WAR边都当成内容依赖向前追溯,就会错误保留旧写入。
一个便于理解的编译顺序是:先沿版本生产者建立内容依赖,确定哪些通道存活,再为存活通道安排物理资源和访问顺序。支持独立版本分配的图还可以让两个版本使用不同物理内存,解除部分覆盖约束;下面的参考实现为了明确边界,每个名字只使用一个物理资源。
VersionKey = (ResourceId, VersionNumber)
producer[X1] = A
producer[X2] = C
dataDependencies[B] = {A}
dataDependencies[D] = {C}
保留B和D后:A → B (RAW), B → C (WAR), C → D (RAW)
只保留D后:C → D (RAW),A和B剔除通道剔除
可观察结果的反向标记
依赖建立以后,图就能判断哪些工作真的有用。沿着模糊链往前看,每个通道似乎都能执行;从最终输出往回看,才知道有没有人需要它们的结果。
图从呈现颜色、导出资源和明确的外部副作用出发,反向保留它们依赖的通道。如果合成不再读取模糊纹理,而这条链也没有其他副作用,模糊和只为它服务的降采样就可以一起剔除。
不过,有用结果未必只通过下一次采样体现。写外部纹理、异步回读或查询统计也可能是可观察副作用,按照引擎约定声明后,它们同样能成为保留通道的依据。
所以一个通道消失时,先检查输出有没有接到消费者,副作用有没有声明。临时关闭剔除能帮助判断“通道本身有问题,还是图认为它没用”,最终接入仍靠真实的输出依赖。
最小标记算法
内部可以为每个版本保存生产者,为每个通道保存读取版本。把最终输出的生产者和副作用通道入栈,遇到一个新通道就标记它,再加入它读取的数据生产者。已经标记的通道跳过,循环结束后未标记的通道就是剔除候选。
live = set()
stack = output_producers + side_effect_passes
while stack:
index = stack.pop()
if index is None or index in live:
continue
live.add(index)
stack.extend(producer[version] for version in passes[index].reads)导入资源的生产者可以为空,因为内容由图外提供。读不存在的版本则是错误,不能用空生产者掩盖未初始化数据。该段是下方可运行参考编译器的标记核心,副作用由调用者显式声明。
最小依赖示例
把前面的降采样、模糊和合成写成声明,就得到下面这段API中立伪代码。输入是已有场景颜色,输出是呈现颜色;纹理描述符交代尺寸和格式,Read与Write分别声明采样和颜色附件写入。这里的接口是模型示意,接入工程时映射到目标引擎的真实API。
scene = graph.Import(sceneColor)
half = graph.CreateTexture(halfSize, RGBA16F)
blurred = graph.CreateTexture(halfSize, RGBA16F)
output = graph.Import(presentColor)
graph.AddPass("Downsample")
.Read(scene, Sampled)
.Write(half, ColorAttachment)
.Execute(DrawDownsample)
graph.AddPass("Blur")
.Read(half, Sampled)
.Write(blurred, ColorAttachment)
.Execute(DrawBlur)
graph.AddPass("Compose")
.Read(scene, Sampled)
.Read(blurred, Sampled)
.Write(output, ColorAttachment)
.Execute(DrawCompose)
graph.MarkOutput(output)
graph.CompileAndExecute()沿代码追踪,Downsample写出half,Blur读取它并写出blurred,Compose最后读取场景颜色和模糊结果。图看到的就是这条生产与消费关系,回调内部怎样滤波由通道自己负责。
试着删除Compose对blurred的读取。在这段没有其他副作用的模型里,模糊链失去了消费者,两个通道就可以一起消失。恢复读取后,图也恢复对应的纹理状态转换与写后读同步。这是接入目标引擎时可做的验证步骤,用来检查声明是否真正参与依赖编译。
生命周期与别名复用
在实际执行顺序上统计区间
通道保留下来以后,图还能看清每份数据什么时候有用。一张临时纹理从首次有效访问开始,到最后一次访问结束,这段区间决定它何时还不能被其他数据覆盖。
如果两张纹理的使用区间不重叠,尺寸和分配约束也兼容,分配器就有机会让它们共用物理内存。不过,上面例子里的half与blurred在模糊通道中一个被读、一个被写,两份内容同时要用,因此不能仅凭先后创建就把它们放到同一内存。
具体优化可能是堆别名,也可能是纹理池复用,由引擎分配器决定。两者都减少重复分配,但资源对象和物理内存的管理方式不同,通用生命周期见 GPU资源生命周期。
使用区间来自存活通道的实际顺序,不来自源代码声明行号。串行参考模型遍历每个通道的读写,把资源第一次出现的位置记为FirstUse,最后一次出现记为LastUse。闭区间[2,7]和[8,12]不重叠;[2,7]和[7,12]则在通道7同时使用,不能共享同一分配。
def may_alias(first, second, compatible):
return compatible and (
first.last_use < second.first_use
or second.last_use < first.first_use
)compatible还包括大小、对齐、内存类型与API资源约束。结果只说明寿命允许复用,不说明布局、别名屏障和初始化已经完成。图外输出还要活到提取或消费者结束,不能在最后写入处立刻回收。
跨队列与跨帧寿命
异步计算会让这件事更复杂。CPU记录时,两个通道可以有明确的先后编号;GPU上它们却可能在不同队列同时执行。分配器要看跨队列依赖,确认前一份数据已结束访问,才能复用对应内存。
内存换给另一个资源以后,还涉及别名屏障或状态初始化。新资源如果第一次使用就是读取,读到的可能是前一份数据留下的内容,所以先清除或完整写入,再让后续通道消费它。
历史纹理又多了一层时间关系:这一帧结束后,下一帧还会用到它。把它当作普通帧内临时纹理,图可能在本帧最后一次访问后回收或复用,历史内容就丢了。因此历史由外部所有者跨帧持有,每帧导入图,同时维护GPU完成条件。
调度与合并
队列并行与关键路径
拓扑顺序只保证生产者先于消费者,不保证帧时间最短。先用四个通道追踪等待:G生成深度,耗时2;C读取深度并计算遮蔽,耗时3;S读取深度绘制阴影,耗时4;L读取C和S做光照,耗时2。数字是人为设定的时间单位,用来计算依赖,不是设备测量。
如果所有工作放在图形队列,合法顺序G → C → S → L耗时11。让C在计算队列执行,同时图形队列继续S,则得到:
时间 0 2 5 6 8
图形队列 [ G ][ S ][ L ]
计算队列 [ C ]
同步 G完成后C可读;C和S都完成后L可读L最早在max(2+3,2+4)=6开始,总长8。收益来自C与S之间没有内容依赖,且此模型假设它们同时运行时各自耗时保持不变。若C还要读S的输出,C最早只能在6开始,L回到9开始;给C换一个队列并没有创造可重叠的工作。
即使依赖允许重叠,提交边界也会改变结果。假设图形队列把G和S放在同一提交中,工程只在这份提交完成时发出供计算队列等待的信号,则C等到6才开始。加上一次0.5单位的交接延迟,C在6.5到9.5运行,L又等0.5单位后开始,整帧变成12。它比串行11还长,原因是同步粒度把本可重叠的C推迟到了S后面。要恢复前面的8单位模型,必须在G结束处形成足够早的可等待信号,并承担相应提交和同步成本。
资源竞争还能进一步改变每个通道的时长。C和S若都受内存带宽限制,重叠后C可能从3增至5、S从4增至6,此时L在8开始,总长10。队列图仍有重叠,关键路径收益却缩小。因此CPU参考调度只能算给定时长和等待的结果,真实收益要从GPU时间线读取。
调度与内存也相互牵制。C的输出从写入开始一直保留到L消费;若L被另一条依赖推迟,输出的寿命也被拉长。两个资源在不同队列上的声明编号先后并不能证明它们互斥。分配器需要一条完成先发生、复用后发生的实际顺序;没有这条关系时,即使按编号得到不重叠区间,也不能安全共享内存。
附件驻留与通道合并
另一类优化发生在同一图形队列内部。考虑A绘制不透明物体,写颜色和深度;B绘制透明物体,继续对同尺寸、同采样数的颜色附件做混合,并读取深度做测试。如果后端允许合并,两段工作可以处于同一个原生渲染通道,附件在片上连续使用。B结束后再把最终颜色保存到外部内存,能够省去A结束保存、B开始加载的中间往返。
合并要求保持附件内容和读写语义。若A清除颜色、B继续累积,合并后的内部边界必须保留A的结果;若错误地在B前再清除,透明物体就会混到黑底上。反过来,B要求全清除而实现只保留旧内容,也会留下A的颜色。图的资源依赖只能确定内容需要存在,具体清除、加载和保存动作仍由后端生成。
再插入一个模糊通道K:它从A的颜色采样左邻、当前和右邻像素,写半分辨率纹理,B最后采样它。K要求一般纹理采样,并且输出尺寸发生变化,已经不能直接套用上面的“相同附件连续绘制”关系。局部输入附件适用于特定位置的读取语义,不会自动提供任意邻域采样能力;跨瓦片的邻居尤其需要额外存储与执行支持。此时图应生成实际纹理读取所需的保存、状态与同步,不能为了合并把普通采样标成局部附件读取。
可以用一个带宽上界感受这项优化的量级:1920×1080的RGBA16F颜色附件每像素8字节,一次完整保存约16.6 MB,紧接着完整加载再读16.6 MB。若确实省去这两次主存访问,理论原始数据量约减少33.2 MB/帧。深度、压缩、部分覆盖、驱动缓存及MSAA都会改变实际流量,这个乘法不是实测带宽,也不能直接换算成毫秒。
在Unity 6 URP中,可从图查看器核对原生通道合并与附件信息。验证时先使用A/B这条简单路径,再加入邻域模糊K,看合并边界、附件保存以及实际命令是否变化。某个后端保留了合并也不代表邻域访问免费;它可能采用了额外的中间资源或其他设备能力,必须沿资源和命令继续检查。
引擎对应
读具体引擎时,就沿着“声明怎样交给图、图怎样调用通道”找入口。Unity 6 URP在RecordRenderGraph中记录资源与回调,使用TextureHandle、访问声明和RasterGraphContext。Unreal通过FRDGBuilder、通道参数结构和RDG资源句柄表达同一类关系。
名字不同,接入时仍要把真实资源访问交代清楚,图才能据此安排工作。具体接口与版本前提分别见 URP Render Graph自定义Pass 和 Unreal RDG Pass实现。
验证方法
可运行参考编译器
reference.py使用Python3.10以上标准库实现版本生产者、反向剔除、存活通道RAW/WAR/WAW顺序、拓扑排序与闭区间。每个资源名只映射一个物理资源,不生成真实GPU屏障,也不实现并行队列或子资源分配。
python 知识库/examples/rendergraph/reference.py --out 知识库/examples/rendergraph/build输出JSON包含顺序、剔除通道、边和资源区间。测试保留/删除模糊消费者、旧版本覆盖、显式副作用、未初始化读取与闭区间冲突。要求同时保留同一物理资源已覆盖版本时,参考编译器通过循环约束报错;完整引擎可以选择独立版本内存解决。
调度与带宽参考计算
模型检查用Python3标准库计算上述串行11、早期交接8、批次交接12、带宽竞争10四种结果,并核对RGBA16F保存与加载的数据量。命令为python 知识库/examples/mechanism_depth/rendergraph/test_schedule.py。这些计算用于检查时间线的因果关系,不实现GPU调度器。
引擎与GPU验证
分配和同步由图编译阶段安排,通道回调只负责具体工作。画面正确时,声明里的问题可能暂时没有暴露。主动改变一个依赖或编译选项,再看图和实际命令怎样响应,就能检查它是否按预期理解了这些声明。下面几步分别检查消费者、初始化、跨队列和跨帧资源。
- 给测试通道唯一名称,在图查看器和 GPU 捕获中定位输出及消费者。
- 去掉最终读取,检查无副作用通道是否被剔除。
- 禁用别名复用,与正常路径比较输出;差异提示初始化或生命周期声明有误。
- 开关异步计算,检查队列时间线、等待区间和临时内存峰值。
- 连续运行两个相机、动态分辨率和历史重置场景,确认外部资源描述与帧间状态一致。
观察结果时,把图编译CPU时间、GPU通道时间、同步等待和资源峰值放在一起。一条调度路径可能降低GPU等待,却增加图编译或内存开销;局部变化因此要结合整帧判断。
图能组织已有工作,但不会改变通道内部的滤波算法、格式与分辨率。成本来自这些内容时,优化仍要回到通道本身。
本文的最小依赖代码用于解释通用模型,验证步骤供目标工程执行。本机参考编译器15项检查已执行通过,范围为串行CPU模型。未在Unity或Unreal中编译、运行这条模糊链,也未测量图编译、GPU时间或内存峰值。
相关主题
参考资料
- Yuriy O'Donnell,FrameGraph: Extensible Rendering Architecture in Frostbite,GDC 2017。
- Unity 6 URP图优化说明,队列同步、资源寿命与移动设备原生通道合并。
- Unity 6 URP Render Graph,记录与执行模型。
- Unreal Render Dependency Graph,参数、剔除及调试约定。