Deep Read

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)喂给驱动。

为什么这么设计?两个原因:

  1. 线程解耦。主线程只负责生成命令列表,真正和驱动打交道的是渲染线程,两者可以流水线并行——主线程在准备第 N+1 帧时,渲染线程还在提交第 N 帧。
  2. 全局优化的机会。命令先攒成完整列表再提交,引擎有机会在提交前做整帧级别的优化(比如合并相邻的 render pass,这对 TBDR 移动 GPU 意义很大)。

代价是心智模型的转变:C# 代码的执行顺序 ≠ GPU 的执行顺序,GPU 顺序由命令被记录进 context 的顺序决定。记住这一点,URP 的整个架构——先把 Pass 排好队,再统一遍历执行——就顺理成章了。

三层结构总览

URP 把一帧的工作拆成三层,自顶向下:

  1. Pipeline 层(UniversalRenderPipeline):每帧入口。遍历所有相机,处理相机堆栈(Base + Overlay),为每个相机准备数据并驱动它的 Renderer。
  2. Renderer 层(UniversalRenderer):负责单个相机的一次渲染。分两步:Setup 决定这一帧需要哪些 Pass 并按顺序入队,Execute 遍历队列逐个执行。
  3. 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 根本不会入队。

总结

把整帧流程串起来:

  1. Pipeline 层逐相机驱动:剔除 → 打包 RenderingData → 交给 Renderer;
  2. Setup 做决策:要不要中间纹理、要哪些 Pass,按时序意图入队;
  3. Execute 做执行:按 RenderPassEvent 排序、切块、逐个 Pass 把命令记录进 context;
  4. context.Submit() 把攒好的命令列表交给渲染线程,翻译成图形 API 调用,GPU 才真正开工。

URP 的灵活性和性能都来自这个"记录与提交分离、决策与执行分离"的结构:自定义 Pass 靠 RenderPassEvent 插进任意时序位置,不用改管线本身;而命令先成列表再提交,给了引擎按平台做整帧优化的空间。