Graphics

学习复盘:从“仪表盘”视角重新梳理 GPU 管线

Computer Graphics
GPU Architecture
Rendering Pipeline
Unity
Unreal Engine

写 Shader 总是感觉在盲写,最近试着把 GPU 内部流程具象化成一个“仪表盘”。这就当是我的学习笔记,通过“分阶段点灯”的方式,梳理数据从 Draw Call 到屏幕成像的全过程,并把 Unity 和 Unreal 的管线对号入座。

图形渲染管线示意图

0. 引言:我脑子里的“状态仪表盘”

以前写 着色器 (Shader) 的时候,我经常是一种“盲写”的状态。代码抄过来,放在 v2f 里能跑,放在 frag 里也能跑,但你要问我这行代码到底是在显卡的哪个角落运行的?它的代价是什么?我往往答不上来。

比如,当我看到一个透明气泡材质时,我潜意识里不仅看到了它的颜色,还看到了它背后疯狂运转的管线压力:

VISUAL_TARGET::TRANSPARENT_BUBBLESTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED
// 学习笔记:透明气泡的配置
// 这种材质的压力主要在片元阶段 (Frag) 和输出合并阶段 (OM),因为要处理混合。
<ShaderPipeline 
  stages={PIPELINE_RASTER}
  modifiedStages={['frag', 'om']} 
  label="VISUAL_TARGET::TRANSPARENT_BUBBLE"
/>

片元着色器 (Fragment Shader)输出合并 (Output Merger) 的灯同时亮起,这就意味着:大量的像素计算 + 混合叠加。这就是为什么美术同学随便放几个透明特效,帧率就掉得妈都不认的原因。

为了治好这种“虚”的感觉,我强迫自己在脑子里建立了一个 “GPU 状态仪表盘”。每当数据流过管线,对应的模块灯就会亮起。这篇复盘,就是我试图把这个脑内仪表盘具象化的过程。


1. 宏观流程:一份死板的“数据契约”

在深入细节之前,我得先纠正自己以前的一个误区:GPU 不是一个可以随意指挥的“多面手”,它更像是一个签了死合同的流水线工厂

这个工厂里有几千个工人(Core),为了让他们同时干活不打架,GPU 制定了一套不可逆的 “数据契约”

  1. 应用阶段 (Application):CPU 备料,发单。
  2. 几何阶段 (Geometry):GPU 接单,处理形状。
  3. 光栅化 (Rasterization):把形状拍扁,切碎。
  4. 像素处理 (Pixel Processing):上色,质检。

2. 细节复盘:数据到底经历了什么?

2.1 CPU 阶段:瓶颈的源头

一切的开始,并不是 GPU,而是 CPU。

CPU 得先把所有家当——模型顶点、法线、贴图、Shader 代码——统统搬进显存 (VRAM)。然后设置好状态(比如:“我现在要开深度测试了啊!”),最后吼出那句最重要的咒语:Draw Call

CPU pushing commands to GPU command buffer
交接棒:CPU 疯狂往缓冲队列里塞命令,GPU 在后面像吃豆人一样消费这些命令。

复盘点: 以前总听说 CPU 瓶颈,现在懂了。Draw Call 这东西很贵,如果 CPU 在一帧里喊了几千次“画这个!”,GPU 可能会一脸懵逼地问“就这?没吃饱啊”,而 CPU 已经累吐血了。


2.2 第一阶段:几何处理 (Geometry Processing)

数据终于进显卡了。这时候,仪表盘的前端亮了。

PHASE_1::GEOMETRY_PROCESSINGSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

这里的核心任务

  • 输入装配 (Input Assembler): GPU 像个不知疲倦的搬运工,从显存里读取顶点数据,根据索引把它们“组装”成一个个独立的三角形。

  • 顶点着色器 (Vertex Shader):【绝对主角】 这是我们写 Shader 最常接触的地方。它的核心任务只有一个:坐标变换

    这个过程就像《盗梦空间》里的层级穿越:

    模型空间 (Model)世界空间 (World)观察空间 (View)裁剪空间 (Clip)\text{模型空间 (Model)} \to \text{世界空间 (World)} \to \text{观察空间 (View)} \to \text{裁剪空间 (Clip)} Diagram showing transformations from Model Space to Screen Space
    从本地空间到裁剪空间:一个顶点的“搬家”旅程。

⚠️ 那些“默认关闭”的隐藏开关

在这个阶段,其实还有两个很容易被忽视的功能,它们默认是 OFF 的:

  • 曲面细分 (Tessellation): 开启后,GPU 会在两个顶点之间“无中生有”出更多三角形。

    • 复盘:这东西以前被吹得很神,用来做雪地压痕、海浪起伏。它的精髓在于**“动态”**——离相机近的地方碎一点,远的地方糙一点。
  • 几何着色器 (Geometry Shader): 它拥有更疯狂的权限——它可以凭空把一个点变成一个草丛(生成新的图元)。

    • 复盘:但这玩意儿是个时代的眼泪。因为它太重了,生成几何体的过程容易打断管线的并行节奏。现在大家想做类似的事情,基本都转投 计算着色器 (Compute Shader) 的怀抱了。

2.3 第二阶段:光栅化 (Rasterization)

这是从 3D 到 2D 的质变。几何体被“拍扁”了,准备变成像素。

PHASE_2::RASTERIZATIONSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

这里的核心任务

  • 生成片元 (Fragment Generation): GPU 扫描三角形,找出它覆盖了屏幕上的哪些格子。

    • 纠错:注意!这里生成的叫 “片元” (Fragment) 而不是像素。因为它们只是“候选人”,手里拿着数据,但还没经过测试,随时可能被丢弃。
  • 插值 (Interpolation): 这是硬件自动完成的魔法。顶点是红色的,底边是蓝色的,中间的渐变色就是这一步通过 重心坐标 (Barycentric Coordinates) 算出来的。

    Rasterization process turning a triangle into fragments
    光栅化:决定哪些像素归这个三角形管。

⚠️ 那些“默认关闭”的隐藏开关

  • 背面剔除 (Backface Culling): 虽然通常默认是 ON,但画双面旗帜、树叶时,我们需要手动关掉它,否则背面就是透明的。

  • 裁剪测试 (Scissor Test):【默认 OFF】 这不同于视锥体裁剪。它是一个强制的矩形框,框以外的像素直接不画。UI 系统(比如 UGUI)做滚动列表时,就是开这个开关来实现遮罩的。


2.4 第三阶段:像素处理 (Pixel Processing)

这是仪表盘最后端亮起的时候,也是显卡最烫、风扇转得最快的时候。

PHASE_3::PIXEL_PipelineSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

这里的核心任务

  • 片元着色器 (Fragment Shader):【绝对主角】 这里是计算光照、采样贴图的主战场。屏幕有多少像素,这段代码就要跑多少遍(甚至更多)。因为计算量大,这里通常是性能瓶颈的重灾区。

  • 输出合并 (Output Merger): 这是最后一道关卡,决定片元是“画上去”还是“被丢弃”。

    Diagram of per-sample operations like stencil and depth test
    闯关游戏:片元必须熬过裁剪、模板和深度测试,才有资格进入混合阶段。

⚠️ 那些“默认关闭”的隐藏开关

这里有一个我以前一直忽视,但其实非常强大的功能:

  • 模板测试 (Stencil Test):【默认 OFF】 每个像素除了有颜色和深度,还可以有一个 0-255 的整数标记(Stencil Value)。

    • 我的理解:这就像是给屏幕贴了一层“遮盖胶带”。
    • 应用场景:比如做 “透视眼”“描边” 效果。我们可以先画一个物体写入模板值 1,并不输出颜色(ColorMask 0)。然后再画另一个物体时告诉 GPU:“只在模板值等于 1 的地方画”。这就相当于在屏幕上挖了一个专门的通道,让你可以精确控制哪里的像素该显示。

3. 对号入座:引擎管线的步进式拆解

理解了上面三个阶段,再看 Unity 和 Unreal 的区别,其实就是它们控制这些灯亮起的顺序和频率不同。它们是在用软件逻辑指挥这套硬件契约。

为了看清楚它们的区别,我决定用仪表盘一步一步地跟踪它们的数据流。

Comparison diagram of Forward vs Deferred Rendering pipelines
Forward vs Deferred:一个是一次性算完,一个是先记账后结账。

3.1 Unity URP (通用管线):Forward vs Forward+

URP 的核心虽是前向渲染,但它经历了一次巨大的战术升级。我们要区分 传统 ForwardForward+ (分簇渲染)

  • 点灯特征“边走边吃”。走到哪,画到哪,算到哪。

1. 几何变换

STEP_1::GEOMETRY_TRANSFORMSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

GPU 拿到模型顶点,在 Vertex Shader 里把它从模型空间变换到屏幕空间。这时候数据还是几何形态。

2. 光栅化 (隐式)

这一步在硬件内部瞬间完成,三角形变成了无数个待处理的片元。

3. 重度光照计算 (最关键的区别)

STEP_3::HEAVY_LIGHTING_CALCSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

这是 URP 最累的一步,也是 Forward+ 展现魔法的地方。

  • 传统 Forward (旧版): 逻辑非常“憨”。Fragment Shader 拿到像素后,会把场景里所有的灯(比如 8 盏)挨个算一遍。哪怕这盏灯在背后,甚至根本照不到我,它也得跑一遍 dot(N, L)。这导致灯光数量受限严重。

  • Forward+ (新版 URP): 它是带“地图”的 Forward

    STEP_2.5::URP Forward+ TilingSTATUS::MODIFIED
    Compute Shader[SHADER]
    MODIFIED
    UNTOUCHED

    在 Fragment Shader 开工前,URP 会偷偷跑一个 Compute Shader,把屏幕切成小格子(Tiles),算出每个格子里到底只有哪几盏灯有效。 当 Fragment Shader 运行时,它会查表:“哦,我这个像素只受 2 号和 5 号灯影响”,然后只循环计算这 2 盏灯。这就是为什么 Forward+ 能支持几百盏灯的原因。

4. 写入屏幕

STEP_4::WRITE_TO_FRAMEBUFFERSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

计算出的颜色直接通过深度测试,写入帧缓冲区 (Framebuffer)。


3.2 Unity HDRP (高清管线)

HDRP 默认使用 延迟渲染 (Deferred Rendering)。这是一种 “先记账,后结账” 的策略。

第一步:光照剔除 (Compute Shader)

STEP_1::LIGHT_CULLINGSTATUS::MODIFIED
Compute Shader[SHADER]
MODIFIED
UNTOUCHED
Visualization of compute shader thread groups

在画任何东西之前,HDRP 先把屏幕切成无数个 16x16 的小格子 (Tiles)。利用 Compute Shader 的并行能力,算出每个格子里到底受哪几盏灯影响,生成一个列表。

第二步:填充 G-Buffer (几何阶段)

STEP_2::GBUFFER_FILLSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

这里开始画物体。但注意!Fragment Shader 此时非常轻松,它不计算任何光照。 它只是把物体的属性(颜色、法线、粗糙度)像填表一样,填入到一组巨大的纹理中(G-Buffer)。

  • 好处:不管场景里有 1000 盏灯还是 0 盏灯,这一步的开销是恒定的。

第三步:全屏光照计算

STEP_3::DEFERRED_LIGHTINGSTATUS::MODIFIED
Input Assembler[FIXED]
Vertex Shader[SHADER]
Tessellation[SHADER]
Geometry Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED

物体画完了,现在只剩下一张铺满屏幕的 G-Buffer。 HDRP 会画一个全屏的矩形,Fragment Shader 读取 G-Buffer 里的数据,结合第一步算好的光照列表,一次性把全屏幕的光照算出来。


3.3 Unreal Engine 5 (Nanite & Lumen)

UE5 基本上是在把 GPU 当作一个通用计算器在用,它是一个离经叛道的存在。

第一步:Nanite (网格处理)

STEP_1::NANITE_RASTERIZERSTATUS::MODIFIED
Input Assembler[FIXED]
Task Shader[SHADER]
Mesh Shader[SHADER]
Rasterizer[FIXED]
Fragment Shader[SHADER]
Output Merger[FIXED]
MODIFIED
UNTOUCHED
Visualization of Meshlets coloring

传统的 Vertex Shader 灯在这里经常是不亮的。 Nanite 为了处理数亿个多边形,使用 Task ShaderMesh Shader(甚至 Compute Shader 做软光栅)来动态处理网格簇 (Meshlets)。它彻底绕过了传统管线的前端瓶颈。

第二步:Lumen (全局光照)

STEP_2::LUMEN_GI_TRACESTATUS::MODIFIED
Compute Shader[SHADER]
MODIFIED
UNTOUCHED

在渲染的同时,后台并发跑着一套极其复杂的 Compute Shader。它们在追踪光线、计算间接光照(GI),然后把结果混合回主画面。


4. 总结

通过这个步进式的“仪表盘”视角,我终于明白:

所谓的 优化,其实就是一场关于“灯”的博弈:

  • URP 是想办法让 Frag 灯亮的时间短一点(减少光照计算)。
  • HDRP 是想办法让 Frag 灯只算有效的像素(延迟渲染),但代价是显存带宽这盏灯更红了。
  • UE5 则是嫌弃传统的灯不好用,自己用 Compute 灯重新造了一套轮子。

搞清楚数据在每个阶段的状态,以前那些看着头大的渲染特性(Stencil, Blending, Tessellation),现在看来也不过是管线上的一个个小开关罢了。