渲染
渲染全景图
直觉问题
GPU 是如何把几十个顶点描述的数据,最终变成显示器上百万个彩色像素点的? CPU 负责思考“画什么”,GPU 负责执行“怎么画”——这个分工是怎么做到的?
理解 GPU 渲染全景图,就像理解“从图纸到摩天大楼”的全过程:CPU 是建筑师,GPU 是建筑队和装修队的统称。建筑师画了设计图,装修队日夜赶工把图纸变成具体的“像素” reality。
核心概念白话讲:CPU vs GPU 的分工
CPU:总指挥,负责“调度”与“决策”
CPU(中央处理器)像一个指挥大师,它会根据游戏逻辑、物理引擎、UI 事件等,决定下一帧要画什么。但它并不会自己去画每一个像素——那太慢了。
CPU 的工作流程大致是:
- 计算物体的世界坐标、旋转、相机位置(** preparation 阶段**)。
- 将顶点数据、索引数据、纹理资源上传到 GPU 的显存(VRAM)。
- 调用图形 API(WebGL / WebGPU / Vulkan / DirectX),发出Draw Call:“GPU 老兄,请帮我渲染这个物体!”
- 等待 GPU 完成渲染,然后继续下一帧循环。
GPU:高度并行的“像素流水线”
GPU(Graphics Processing Unit)的本质是一个由成千上万个微小核心(Streaming Processors)组成的阵列。它不像 CPU 那样擅长复杂分支和逻辑判断,但它的核心数极多,可以在一秒内并行处理上亿个微小任务。
GPU 渲染一帧画面的核心流程如下:
- Vertex Processing:处理顶点(位置、纹理坐标等)。
- Primitive Assembly & Culling:将顶点连接成三角形,剔除掉看不到的面(背面剔除)。
- Rasterization:将三角形打调成像素大小的“拼图”。
- Fragment Processing:计算每个像素的颜色。
- Per-Sample Operations:深度测试、模板测试、混合,最终写入帧缓冲。
你可以把 GPU 想象成一个拥有上千条生产线的现代化工厂,每条生产线只负责一个像素。CPU 只需要把原料(顶点数据)和配方(Shader)发下去,所有生产线就同时开始并行生产。
从顶点数据到屏幕像素的旅程
graph LR
A[CPU 准备数据
顶点、索引、纹理、Uniform] --> B{GPU 接收指令
Draw Call};
B --> C[Vertex Shader
顶点处理 & 变换];
C --> D[Primitive Assembly
图元装配 & 背面剔除];
D --> E[Rasterization
光栅化:三角形 -> 像素拼图];
E --> F[Fragment Shader
片元着色 & 纹理采样];
F --> G[Per-Sample Ops
深度/模板/混合];
G --> H[Framebuffer
屏幕或纹理];
GPU 渲染管线硬件流程
graph TB
subgraph CPU[CPU 端]
A1[准备数据
顶点/索引/纹理]
A2[调用 Draw Call]
end
subgraph GPU[GPU 端]
B1[Vertex Input
从 Buffer 读取数据]
B2[Vertex Shader
MVP 矩阵变换]
B3[Primitive Assembly
三角形组装]
B4[Primitive Culling
背面剔除]
B5[Rasterization
生成 Fragment]
B6[Fragment Shader
计算颜色]
B7[Per-Sample Ops
深度测试/混合]
B8[Framebuffer
写入最终颜色]
end
A1 --> A2 --> B1 --> B2 --> B3 --> B4 --> B5 --> B6 --> B7 --> B8
原理与机制:GPU 渲染管线详解
1. 顶点输入阶段 (Vertex Input)
GPU 从**顶点缓冲(Vertex Buffer)**读取数据。顶点不仅包含位置(x, y, z),还可以包含颜色(color)、法线(normal)、纹理坐标(uv)等。
对数据格式有要求吗?WGSL 中通过 @location(index) 来匹配顶点数据布局。
2. 顶点处理阶段:Vertex Shader
Vertex Shader 是渲染管线的第一个可编程阶段。
- 输入:单个顶点(位置、颜色、UV 等)。
- 核心任务:将顶点从模型局部空间转换到裁剪空间(Clip Space)。通常做法是使用 MVP 矩阵(Model-View-Projection)进行变换。
- 输出:顶点在裁剪空间中的坐标(
gl_Position或clipPosition),以及传递给后续阶段的数据(颜色、纹理坐标等)。
GLSL 示例:基础 Vertex Shader
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec2 aTexCoord;
uniform mat4 uMVPMatrix;
out vec2 TexCoord;
void main() {
gl_Position = uMVPMatrix * vec4(aPos, 1.0);
TexCoord = aTexCoord;
}
WGSL 示例:基础 Vertex Shader
struct VertexInput {
@location(0) aPos: vec3<f32>,
@location(1) aTexCoord: vec2<f32>,
};
struct VertexOutput {
@builtin(position) clipPosition: vec4<f32>,
@location(0) TexCoord: vec2<f32>,
};
@group(0) @binding(0)
var<uniform> uMVPMatrix: mat4x4<f32>;
@vertex
fn main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
output.clipPosition = uMVPMatrix * vec4<f32>(input.aPos, 1.0);
output.TexCoord = input.aTexCoord;
return output;
}
数学原理:空间变换链 如同在 01 中提到的 MVP 概念,实际数学模型是:
其中:
- :顶点在模型本身的局部坐标系中的坐标
- :模型变换(Model),包含位移、旋转、缩放
- :视图变换(View),相机将世界坐标转换到相机坐标系
- :投影变换(Projection),将相机空间的点映射到裁剪空间(齐次坐标 范围)
- :裁剪空间坐标
3. 图元装配与剔除 (Primitive Assembly & Culling)
顶点经过处理后,GPU 将它们连接成图元(Primitive)。最常见的图元是三角形。
- 图元装配:将顶点按顺序(或索引)连接成三角形。
- 背面剔除(Back-face Culling):摄像机背后(法线朝向背面)的三角形将被剔除,不做进一步处理。例如,一个内部面完全看不到,可以直接跳过。
渲染管线硬件图示意:
/-------------------------------------------\
| CPU: Draw Call + 资源上传 |
|-------------------------------------------|
| GPU 渲染管线 |
| [Vertex Input] -> [Vertex Shader] |
| | |
| v |
| [Primitive Assembly] |
| | |
| v |
| [Rasterization] -> [Fragment Shader] <- [纹理采样]
| | |
| v |
| [Per-Sample Ops: 深度测试/模板测试/混合] |
| | |
| v |
| [Framebuffer: 色彩/深度/模板] |
\-------------------------------------------/
4. 光栅化 (Rasterization)
这是 GPU 渲染管线中最“物理”的一个阶段。它负责将连续的三角形幻化为离散的像素。
- 任务:判断一个三角形覆盖了屏幕上的哪些像素。
- 结果:生成片元(Fragment)。片元包含坐标、颜色候选、深度值等信息。它不是最终的“像素”,而是等待 Fragment Shader 计算的“候选像素”。
光栅化的核心算法是扫描线填充(Scanline Fill)或边缘填充(Edge Equation)。GPU 硬件有专门的 Rasterizer 电路来加速这个过程。
5. 片元处理阶段:Fragment Shader
Fragment Shader(在 OpenGL 中常叫 Pixel Shader,WGSL 沿用 Fragment Shader 术语)是管线的第二个可编程阶段。
- 输入:从 Vertex Shader 插值(Interpolate)过来的数据(颜色、UV 坐标等),以及 Fragment 的屏幕坐标。
- 核心任务:根据纹理、光照、材质属性计算最终像素的颜色。
- 输出:该 Fragment 的颜色(RGBA),以及可选的深度值(Depth)。
Fragment Shader 的速度直接决定了像素填充率(Fill Rate)。一个复杂的渲染管线中,它是最易成为瓶颈的阶段。
GLSL 示例:基础 Fragment Shader
#version 330 core
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D uTexture;
void main() {
vec4 texColor = texture(uTexture, TexCoord);
FragColor = texColor;
}
WGSL 示例:基础 Fragment Shader
@group(0) @binding(1)
var uTexture: texture_2d<f32>;
@group(0) @binding(2)
var uSampler: sampler;
struct FragmentInput {
@location(0) TexCoord: vec2<f32>,
};
@location(0)
var<out> FragColor: vec4<f32>;
@fragment
fn main(input: FragmentInput) {
let texColor = textureSample(uTexture, uSampler, input.TexCoord);
FragColor = texColor;
}
6. 逐样本操作 (Per-Sample Operations)
所有 Fragment 在写入 Framebuffer 之前,都要经过一系列硬件级的像素级测试和混合。
| 测试/操作 | 作用 | GLSL 控制 | WGSL 控制 |
|---|---|---|---|
| Scissor Test | 裁剪掉视口外的像素 | glEnable(GL_SCISSOR_TEST) | GPURenderPassDescriptor.scissorRect |
| Stencil Test | 通过模板缓冲(Stencil Buffer)执行遮罩或轮廓操作 | glStencilFunc / glStencilOp | GPURenderPipelineDescriptor.stencil |
| Depth Test | 深度测试,决定 Fragment 是否可见 | glDepthFunc(GL_LESS) | GPURenderPipelineDescriptor.depthStencil |
| Blending | 前后颜色按 Alpha 值进行混合 | glBlendFunc / glBlendEquation | GPUBlendState |
| Dithering | 将高精度颜色映射到低精度显示器 | 自动(通常开启) | 自动(通常开启) |
7. 帧缓冲输出 (Framebuffer Write)
最终通过所有测试的 Fragment 颜色会被写入 Framebuffer。现代渲染中,Framebuffer 不一定直接是屏幕,也可能是离屏纹理(Offscreen Texture)或多重渲染目标(MRT)。
GPU vs CPU:并行模型的根本差异
| 特性 | CPU | GPU |
|---|---|---|
| 核心数 | 4 - 64 个物理核心 | 成百上千个 Streaming Processors |
| 擅长任务 | 复杂逻辑、高分支、串行任务 | 大规模并行、结构重复的简单计算 |
| 内存架构 | 通用 RAM + 多级缓存 | 专用 VRAM + 极高带宽(e.g. GDDR6X) |
| 编程模型 | 顺序执行,上下文切换 | SIMT(单指令多线程),Warp/Wavefront 并行 |
| 延迟 | 低延迟(单任务快) | 高延迟(单任务慢,但任务并行时吞吐量大) |
SIMT(Single Instruction Multiple Threads):GPU 的每个“Warp”(NVIDIA,通常为 32 线程)或“Wavefront”(AMD,通常为 64 线程)内的所有线程执行同一指令,但操作不同数据。如果线程之间出现分支(如 if-else),会导致Warp 内分支发散,严重降低 GPU 利用率。
常见误区与陷阱
1. 以为 GPU 是“更快的 CPU”
- 误区:GPU 核心数多,所以任何计算任务丢给它都超强。
- 真相:GPU 不适合高分支、频繁内存随机访问的任务。如果 Shader 中有太多
if...else,同一 Warp 内的线程路径不同,会导致Warp Divergence,性能暴跌。
2. 认为 Vertex 和 Fragment 之间可以“直接通信”
- 误区:Vertex Shader 声明一个
out变量,Fragment Shader 用in接收,感觉像是在两个函数间传参。 - 真相:这叫做 插值(Interpolation)。GPU 硬件会在三角形的三个顶点之间,按像素位置对数据进行线性插值(Linear Interpolation)。它不是精确的“传参”,而是系统性的“插值重建”过程。这解释了为什么 Vertex Shader 中的数据到 Fragment Shader 中只能在三角形内部进行平滑过渡。
3. 忽略“Draw Call 是 CPU 的瓶颈”
- 误区:CPU 丢一个 Draw Call 给 GPU 就像点击“发送”一样瞬间完成。
- 真相:Draw Call 存在巨大的 CPU 开销。CPU 需要设置渲染状态(材质、纹理、Shader)、绑定资源、调用底层驱动。一次 Draw Call 可能要消耗几百到几千个 CPU 周期。现代渲染管线的核心优化方向之一就是减少 Draw Call(如使用 Instancing、合并材质、使用 GPU-Driven Rendering 等)。
4. 混淆 GLSL 与 WGSL 的坐标系规则
- GLSL / WebGL
$y轴:在 NDC(标准化设备坐标)中,y轴向上为正(屏幕中心为原点)。 - WGSL / WebGPU
$y轴:在 NDC 中,y轴向下为正(左上角为原点)。WebGPU 也遵循 Clip Space 的此规范,等同于 DirectX 的惯例。这意味着在 WebGPU 中,如果你从 WebGL 迁移代码,可能需要反转y轴或修改投影矩阵。
5. 认为 GPU 渲染是“连续的过程”
- 误区:GPU 一帧一帧地连续渲染,像放电影一样。
- 真相:GPU 渲染过程高度并行化,处理上通常为**双缓冲(Double Buffering)或三缓冲(Triple Buffering)**机制。GPU 渲染当前帧(Back Buffer)时,屏幕显示的是上一帧(Front Buffer)。渲染完成后交换两者(Swap Operation)。这避免了画面撕裂(Tearing)问题。
延伸阅读与自测
权威索引
- The Rasterization Stage (GPU Gems): NVIDIA GPU Gems - 光栅化硬件流水线与性能优化的深入解读。
- A Trip Through the Graphics Pipeline (Fabien Sanglard): fabien-sanglard 的个人博客文章 - 详细介绍了 GPU 内部结构。
- WebGPU Specification (Render Pipeline): W3C WebGPU Spec - 浏览 WebGPU 的
RenderPipeline和CommandEncoder部分,了解 API 层面的映射。 - OpenGL ES 3.2 Spec - Chapter 9, 10: 描述了光栅化、插值、深度测试和混合的数学公式。通过 WebSearch 检索 “OpenGL ES 3.2 specification” 获取。
进阶思考题
- 为什么 GPU 平行执行 Shader 时需要进行“Warp 内分支发散”优化? 请用代码举例说明如何通过重构逻辑减少 Warp Divergence。 2.如果 CPU 渲染一帧需要 16ms,GPU 渲染只需要 4ms,瓶颈在 CPU 还是 GPU?如果 GPU 核心占用率只有 20%,瓶颈又在哪?
- 在现代 GPU 上,Fragment Shader 的复杂度和贴图纹理数量对性能的影响哪个更大? 为什么 GPU Profiler 通常会建议你优先检查 Frag Shader 的 ALU(算术逻辑单元)利用率?
总结
GPU 渲染管线是一个从**几何空间(Vertex)到像素空间(Pixel)**的“加工厂”。
- 理解 CPU 与 GPU 的分工,是做好高性能渲染的第一步。
- 掌握 Vertex → Primitive → Fragment → Output 这四个核心阶段,你就能看懂绝大多数复杂的渲染问题。
- GLSL 与 WGSL 虽然 API 不同,但它们都遵循同样的 GPU 渲染硬件架构。学习 WGSL/WebGPU 并不会让你抛弃 GLSL 知识,反而会让你对 GPU 的本质看得更清楚。