URP渲染流程分析
目录+
URP 的一帧是怎么画出来的?这篇笔记梳理从引擎每帧调用管线入口,到最终图像 Blit 上屏的完整流程。重点不在贴源码,而在理清两件事:Pass 队列是如何组织的(Setup),以及这些 Pass 是如何真正被 GPU 执行的(Execute 与延迟执行模型)。更底层的 CPU→GPU 命令提交与硬件管线,见 渲染流水线完全解析渲染流水线完全解析diagram.svg 完整的渲染流水线是一个高度并行、高度优化的过程。 这里按 数据流动 的顺序拆解:CPU 应用准备 → 驱动与命令提交 → GPU 前端几何处理 → GPU 后端像素处理 → 显示。 应用准备(CPU 端) 这一阶段完全在 CPU 上运行,是渲染的"备菜"阶段:把网格、纹理从慢速硬盘读进内存(RAM),解码成 GPU 能理解的原始数据结构(顶点数组、像素位图)。整个过程是串行的,受限于 I/O 和 CPU 解码速度。 数据从磁盘到 RAM 要走一条完整链路:应用程序(Unity/UE)向操作系统发出文件读取请求(如 ReadFile API),内核的文件系统驱动定位硬盘上的文件(如 Tank.fbx、Tank_Diffuse.png),通过磁盘控制器读取;数据先被读入系统文件缓存(RAM),再复制到应用进程的内存空间。 进入内存后还要解析(Parsing)成引擎可用的结构: 网格**:CPU 上的解析库(如 Assimp 或引擎自定义代码)读取 .fbx 的二进制内容,解码成顶点数组(float3 位置。
前置:SRP 的延迟执行模型
理解 URP 之前必须先理解 SRP(Scriptable Render Pipeline)的执行模型,否则后面"入队"、"执行"这些词都会产生误解。
C# 侧的渲染代码不直接向 GPU 发任何指令,它做的只是"记录"。核心对象是 ScriptableRenderContext,可以把它理解为一个命令列表:
context.DrawRenderers(...)、context.DrawSkybox(...)等调用,只是往列表里追加一条"将来要画这些东西"的记录;- 更细粒度的操作(设置渲染目标、清屏、Blit、设 shader 变量)先记录到
CommandBuffer里,再通过context.ExecuteCommandBuffer(cmd)把这段命令拷贝进 context 的列表——注意是拷贝到"当前位置",所以ExecuteCommandBuffer的调用时机决定了这些命令和DrawRenderers的先后顺序; - 直到调用
context.Submit(),这份命令列表才被一次性提交给引擎的渲染线程,由渲染线程翻译成图形 API 调用(D3D12 / Vulkan / Metal)喂给驱动。
为什么这么设计?两个原因:
- 线程解耦。主线程只负责生成命令列表,真正和驱动打交道的是渲染线程,两者可以流水线并行——主线程在准备第 N+1 帧时,渲染线程还在提交第 N 帧。
- 全局优化的机会。命令先攒成完整列表再提交,引擎有机会在提交前做整帧级别的优化(比如合并相邻的 render pass,这对 TBDR 移动 GPU 意义很大)。
代价是心智模型的转变:C# 代码的执行顺序 ≠ GPU 的执行顺序,GPU 顺序由命令被记录进 context 的顺序决定。记住这一点,URP 的整个架构——先把 Pass 排好队,再统一遍历执行——就顺理成章了。
三层结构总览
URP 把一帧的工作拆成三层,自顶向下:
- Pipeline 层(
UniversalRenderPipeline):每帧入口。遍历所有相机,处理相机堆栈(Base + Overlay),为每个相机准备数据并驱动它的 Renderer。 - Renderer 层(
UniversalRenderer):负责单个相机的一次渲染。分两步:Setup决定这一帧需要哪些 Pass 并按顺序入队,Execute遍历队列逐个执行。 - Pass 层(
ScriptableRenderPass):最小执行单元。阴影、不透明物体、天空盒、后处理……每个都是一个独立的 Pass,各自把渲染命令记录进 context。
Pipeline 层:每帧入口
引擎每帧调用管线的 Render 方法,伪代码如下:
Render(context, cameras):
对相机排序 # Base Camera 排在它的 Overlay 之前
for camera in cameras:
if 是游戏相机:
RenderCameraStack(context, camera)
# 先渲染 Base,再把各 Overlay 依次叠加到 Base 的结果上
else:
渲染单个相机 # Scene 视图、预览窗口等
无论走不走堆栈,最终每个相机都会落到同一个单相机渲染流程:
RenderSingleCamera(context, cameraData):
renderer = 相机绑定的 Renderer
1. renderer 设置剔除参数
2. cullResults = context.Cull() # CPU 视锥/遮挡剔除
3. 构建 RenderingData # 打包剔除结果、光照、阴影、后处理等全部信息
4. renderer.AddRenderPasses() # Renderer Feature 在此注入自定义 Pass
5. renderer.Setup() # 决定内置 Pass 的取舍与顺序,入队
6. renderer.Execute() # 遍历队列,记录所有渲染命令
7. context.Submit() # 命令列表提交给渲染线程
RenderingData 是贯穿全程的数据包:后续每个 Pass 都从它里面取自己需要的东西(相机参数、剔除结果、光源列表),而不是各自去查询场景。第 4 步说明了自定义 Pass 的注入时机在内置 Pass 之前——但注入早不代表执行早,执行顺序由后面的排序决定。
Renderer 层之一:Setup 编排队列
Setup 是决策中心。它不画任何东西,只做两件事:判断这一帧需要什么,然后按序入队。
Setup(context, renderingData):
读取状态: 是否需要深度图 / 是否开后处理 / 是否有阴影 ...
# 决定是否渲染到中间纹理(off-screen)
if 需要后处理 or 相机堆栈 or 渲染缩放 or HDR格式不匹配 ...:
创建临时的 Color / Depth RT,渲染目标指向它
else:
渲染目标直接指向屏幕后备缓冲
# 按渲染时序入队
if 有主光阴影: 入队 主光 ShadowCaster Pass
if 有附加光阴影: 入队 附加光 ShadowCaster Pass
if 需要深度预渲染: 入队 Depth Prepass
入队 不透明物体 Pass
if 清屏方式是天空盒: 入队 Skybox Pass
if 后续需要抓屏: 入队 CopyColor Pass
if 后续需要深度图: 入队 CopyDepth Pass
入队 透明物体 Pass
if 开了后处理: 入队 PostProcess Pass
if 结果还在中间纹理上: 入队 FinalBlit Pass
两个值得展开的点:
中间纹理。现代管线很少直接画到屏幕上:后处理要拿"画完的整帧"当输入,相机堆栈要把多个相机的结果叠起来,这些都要求先渲染到一张临时 RT,最后再由 FinalBlit 拷到屏幕。反过来,如果一个相机什么特性都没开,URP 会跳过中间纹理直接画到屏幕,省一次全屏拷贝的带宽——这是移动端优化的常见考量。
入队顺序即时序意图,但不是最终顺序。每个 Pass 带一个 RenderPassEvent 枚举值(BeforeRenderingShadows、AfterRenderingOpaques 之类,本质是整数),真正的执行顺序由 Execute 阶段按这个值排序决定。Renderer Feature 注入的自定义 Pass 正是靠指定 RenderPassEvent,插到内置流程的任意缝隙里。
Renderer 层之二:Execute 执行队列
这是原来容易被一笔带过、实际上最值得看的部分。Execute 把 Setup 攒好的队列变成 context 里一长串有序命令:
Execute(context, renderingData):
# 1. 排序:按 RenderPassEvent 对队列做稳定排序
# 值相同的 Pass 保持入队先后,这就是自定义 Pass 能精确插缝的机制
对 Pass 队列排序
# 2. 按事件值把队列切成几个执行块
blocks = [BeforeRendering | MainRenderingOpaque | MainRenderingTransparent | AfterRendering]
# 3. 逐块执行
执行 BeforeRendering 块 # 阴影等,此时还没绑定相机目标
设置相机全局属性 # VP 矩阵、时间、屏幕参数等 shader 全局变量
执行 MainRenderingOpaque 块 # 深度预渲染、不透明、天空盒、拷贝深度/颜色
执行 MainRenderingTransparent 块 # 透明物体
执行 AfterRendering 块 # 后处理、FinalBlit、Gizmos
# 每个 Pass 的执行都是同一套动作:
for pass in block:
绑定该 Pass 的渲染目标,按需清屏
pass.Execute(context, renderingData) # 把本 Pass 的命令记录进 context
为什么要切块?因为块边界是管线状态发生本质变化的地方:阴影 Pass 画的是 Shadow Map,用的是光源视角的矩阵,必须在设置相机属性之前执行完;而相机全局属性一旦设好,不透明到透明这一大段共享同一套相机状态。切块让这些状态设置只做一次,而不是每个 Pass 重复做。
再强调一次延迟执行:整个 Execute 跑完,GPU 还什么都没画。所有 Pass 只是把命令记录进了 context,直到 Pipeline 层最后的 context.Submit(),这一帧的命令列表才真正发往渲染线程。
Pass 层:几个代表性 Pass
DrawObjectsPass:画场景物体
不透明和透明物体用的是同一个类的两个实例,区别只在配置:渲染哪个队列(Opaque / Transparent)、按什么排序(不透明从前往后利用 Early-Z,透明从后往前保证混合正确)。
执行时核心就一句 context.DrawRenderers(cullResults, drawingSettings, filteringSettings):把剔除幸存者按过滤和排序设置批量记录绘制命令。SRP Batcher 在这一层生效(原理见 性能优化-SRP合批性能优化-SRP合批SetPass Call 为什么贵
CPU 提交一次绘制,真正贵的不是 Draw Call 本身,而是它前面的 SetPass Call——切换渲染状态的那一步。每次 SetPass,CPU 端要:
1. 选择要使用的 Shader 变体(Variant),绑定对应的管线状态;
2. 把这个材质的所有属性从 CPU 内存写入 GPU 的常量缓冲区(CBuffer)。
这一步之所以慢,是因为它走的是图形驱动的通用路径:驱动要校验状态合法性、重新组织资源绑定表,材质数据每次都要重新走一遍 CPU→GPU 的上传。场景里材质一多,即使共用同一个 Shader,传统管线也要为每个材质完整做一遍 SetPass。
SRP Batcher 要解决的就是这个:让使用相同 Shader 变体的多个材质共享一次 SetPass。注意它减少的是 SetPass Call,Draw Call 的次数并不变——只是每次 Draw 变得极其廉价。
核心思路:材质数据常驻显存
传统批处理(Static/Dynamic Batching,见 [[性能优化-静态合批]])的思路是合并网格,把多个物体拼成一)——同一 shader variant 的物体,其材质常量已常驻 GPU 缓冲,切换材质不再需要重建绑定,大幅摊薄逐 Draw Call 的 CPU 开销。
PostProcessPass:后处理迷你管线
后处理内部自成一条小流水线,多个效果依次处理同一帧图像:
PostProcess(cmd):
source, destination = 两张临时 RT
for effect in [StopNaN, SMAA, 景深, 运动模糊, ...]:
if effect 启用:
Blit(source -> destination, effect 的材质)
交换 source 和 destination # Ping-Pong
# 最后一步:Uber Pass
把 Bloom / 色调映射 / 暗角 / 颜色分级等参数全部设给 Uber 材质
Blit(source -> 最终目标, Uber 材质)
两个设计点:Ping-Pong Buffer——两张 RT 交替当输入输出,上一个效果的输出直接成为下一个的输入,不用为每个效果各开一张临时 RT,省显存也省带宽;Uber Shader——能合并的效果全部塞进一个 shader,用一次全屏 Blit 出结果,减少 RT 切换次数。RT 切换在 TBDR 架构的移动 GPU 上意味着整块 tile 内存的 store/load,是后处理最贵的开销之一。
FinalBlitPass:上屏
管线最后一站,任务单纯:把中间纹理上处理完的最终图像拷贝到真正的相机目标(屏幕或用户指定的 RT),顺带完成色彩空间转换(Linear → sRGB,为什么需要这步见 线性空间和伽马空间线性空间和伽马空间写 shader 时给一个像素输出 0.5,屏幕上显示出来的却不是 50% 的物理亮度;两张贴图直接相加混合,结果比预期暗一截。这些问题的根源是同一件事:渲染管线里同时存在两套数值——线性空间和伽马空间,搞不清数据当前在哪个空间,计算就会出错。 同一个亮度,两种记法 线性空间和伽马空间不是两个"地方",而是同一个亮度的两种记数方法。一盏灯发出 21.8% 的物理亮度(光子数是最大值的 21.8%),要把它存成一个 0~1 的数字,有两种记法: - 线性记法:直接存 $0.218$。数字和光子数成正比,乘 2 就是两倍的光——这个数就"在线性空间"。 - 伽马记法:先算 $0.218^{1/2.2} = 0.5$,存 $0.5$——这个数就"在伽马空间"。 灯没有变,亮度没有变,变的只是写下来的数字。就像 1.8 米和 5.9 英尺是同一个身高,0.218(线性)和 0.5(伽马)是同一个亮度。判断一个值在哪个空间,只需要问一句:这个数字被 $x^{1/2.2}$ 抬亮过吗? 把线性值编码进伽马空间的这步幂运算,叫伽马编码/矫正;反方向的 $x^{2.2)。如果 Setup 阶段判断不需要中间纹理,这个 Pass 根本不会入队。
总结
把整帧流程串起来:
- Pipeline 层逐相机驱动:剔除 → 打包
RenderingData→ 交给 Renderer; - Setup 做决策:要不要中间纹理、要哪些 Pass,按时序意图入队;
- Execute 做执行:按
RenderPassEvent排序、切块、逐个 Pass 把命令记录进 context; context.Submit()把攒好的命令列表交给渲染线程,翻译成图形 API 调用,GPU 才真正开工。
URP 的灵活性和性能都来自这个"记录与提交分离、决策与执行分离"的结构:自定义 Pass 靠 RenderPassEvent 插进任意时序位置,不用改管线本身;而命令先成列表再提交,给了引擎按平台做整帧优化的空间。