游戏渲染管线基础:从视锥剔除到 GPU 绘制

深入游戏渲染管线:帧渲染循环与 Render Pass、摄像机与视锥体剔除、绘制批次与合批(静态/动态/图集)、遮挡剔除(Z-Prepass/软遮挡/HZB)、GPU 渲染管线(VS/PS/光栅化)概览,附 Unity/Godot 对照。

玩家看到的每一帧画面,都是 CPU 与 GPU 协作产出的结果。很多客户端开发者能写好玩法逻辑,却对"画面是怎么画出来的"缺乏系统认知——于是遇到黑屏不知道查渲染管线、遇到卡顿不知道是 Draw Call 还是填充率问题。本文从渲染循环出发,依次拆解摄像机与视锥、绘制批次与合批、遮挡剔除,最后俯瞰 GPU 管线全貌。它是 游戏引擎架构:ECS 与资源管理 的天然续篇:场景图产出 Transform 层级,而渲染管线把这份层级变成像素。

对服务端转客户端的开发者,本文的"预算"思维与 游戏性能剖析与优化 一脉相承——渲染优化的本质是资源预算管理。

1. 渲染循环与帧结构

1.1 一帧画面如何生成

一帧渲染由多个 Render Pass(渲染阶段) 组成,典型的帧结构:

Frame N 渲染流程
 ├── Depth Pre-Pass(可选):只写深度,为后处理遮挡剔除
 ├── Opaque Pass:所有不透明物体,深度测试开启
 ├── Skybox / 远景
 ├── Transparent Pass:透明物体,深度写入关闭、混合开启
 ├── Post-Processing:Bloom / Tonemap / SSAO / 抗锯齿
 └── UI Pass:最后绘制,通常正交投影
// Unity 自定义渲染管线(SRP)中显式排布 Pass
void Render(ScriptableRenderContext ctx) {
    // 1. 设置相机
    ctx.SetupCameraProperties(camera);

    // 2. 不透明物体
    var opaque = new DrawingRenderSettings { flags = opaqueFlags };
    ctx.DrawRenderers(cullingResults, ref opaque);

    // 3. 后处理
    if (camera.allowPostProcessing) ApplyPostFX(ctx, camera);

    // 4. 天空盒
    ctx.DrawSkybox(camera);
    ctx.Submit();
}

1.2 Render Pass 的顺序法则

  • 不透明先、透明后:不透明物体写深度,透明物体按由远到近排序混合,否则透明叠加会错乱。
  • 深度测试 ≠ 深度写入:透明物体仍要测试深度(被挡住就不画),但不写深度(否则遮挡后面物体)。
  • 颜色缓冲只在最后读回:任何"读屏幕像素"的操作(Bloom、后处理)都意味着一次 Readback / 拷贝,能省则省。

2. 摄像机与视锥

2.1 投影矩阵与视锥体

摄像机把 3D 世界映射到 2D 屏幕,核心是视锥体(Frustum):6 个平面(近、远、左、右、上、下)围成的金字塔。

            远平面
           /        \
   左平面 /  视锥体  \ 右平面
         /            \
        /______________\  近平面
        \     视点     /
         \            /

核心公式:投影矩阵把视锥内的点映射到 NDC(归一化设备坐标 [-1,1])。一旦点超出 NDC 范围,就在屏幕外,无需绘制。

2.2 视锥剔除(Frustum Culling)

CPU 侧最基础的减面手段:对每个物体包围体与 6 个平面做平面侧判定,全在内部才提交绘制。

// 平面侧判定:一个点 P 相对平面 (n, d)
// 若 n·P + d < 0 则在平面外侧
bool Inside(FrustumPlane plane, Bounds bounds) {
    // 用包围盒的"最靠近平面的顶点"做判定(clip space 简化版)
    var p = bounds.center + bounds.extents * plane.normal.Sign();
    return Vector3.Dot(plane.normal, p) + plane.distance >= 0;
}

bool IsVisible(Bounds bounds, Frustum frustum) {
    foreach (var plane in frustum.planes)
        if (!Inside(plane, bounds)) return false;  // 任一平面在外即剔除
    return true;
}

增量剔除:大世界常用四叉树 / BVH / 空间哈希先粗筛"候选区域",再对候选物体做精确视锥测试,把 O(n) 降为 O(log n)。

2.3 视锥与分辨率无关的裁剪

近平面裁剪(Near Clip)会产生几何裁剪——三角形被裁掉一半。这一步在 GPU 光栅化阶段完成,但极端视角下会出现"三角形闪烁"(深度精度不足,Z-Fighting)。

3. 绘制批次与合批

3.1 Draw Call 的成本本质

一个 Draw Call = CPU 提交一条渲染命令 + GPU 切换状态(绑定 Shader/纹理/管线状态)。状态切换是最大的开销,每切换一次纹理、Shader 或材质,GPU 流水线可能要 flush。

所以优化目标不是"减少三角形",而是减少状态切换与命令数:

术语含义代价
Draw Call一条提交给 GPU 的绘制命令CPU-GPU 通信延迟
SetPass Call切换渲染状态(Shader/Blend)管线 flush,昂贵
三角形数几何复杂度主要影响光栅化填充率

3.2 静态合批(Static Batching)

把不动的物体在启动时合并成一个大 Mesh,多个 GameObject 共享一次 Draw Call。条件是顶点数据在运行时不变。

静态合批前:100 个石头 = 100 次 Draw Call(每个都要状态切换)
静态合批后:100 个石头合并为 1 个 Mesh = 1 次 Draw Call

Unity 勾选 Static 即启用;Godot 的 MultiMesh 或 MeshInstance3D 的 bake 类似。

3.3 动态合批与 GPU Instancing

  • 动态合批:每帧 CPU 把满足条件的小物体顶点拼进一个 Buffer。要求:相同材质、顶点数少、无骨骼/无蒙皮。CPU 拼接有成本,物极必反。
  • GPU Instancing:一份顶点、多次实例绘制。CPU 只提交实例变换数组,GPU 用 SV_InstanceID 区分实例。这是移动端大量重复物(草、树、子弹)的首选方案。
// HLSL:Instancing 顶点着色器
struct VSIn {
    float3 pos : POSITION;
    // 每个实例的变换矩阵
    float4x4 instMatrix : INSTANCE0; // Unity 自动填充
};
struct VSOut {
    float4 sv_pos : SV_Position;
};

VSOut main(VSIn input, uint instanceID : SV_InstanceID) {
    VSOut o;
    o.sv_pos = mul(unity_ObjectToWorld, float4(input.pos, 1)); // 简化
    return o;
}

3.4 纹理图集与材质合并

Draw Call 的隐藏成本是纹理绑定切换。把多个小图合并进一张图集(Atlas),配合同一材质,就能合批:

方案手段限制
图集小图拼大图,UV 偏移采样需预留出血(padding),mipmap 可能串色
材质合并用 _MainTex 一张纹理替换多个材质丢失逐物体材质差异
SRP Batcher(Unity)减少 CPU 侧 SetPass 调用要求 Shader 兼容 SRP

Unity 的 SRP Batcher 与 Godot 的 RenderingServer 在引擎层面已经把"每物体提交材质状态"优化成"一次提交批量数据",移动端项目默认开启收益最大。

4. 遮挡剔除(Occlusion Culling)

4.1 为什么需要遮挡剔除

视锥剔除只处理"在不在屏幕内",但 被墙挡住的物体同样在视锥内,白白浪费填充率。遮挡剔除(Occlusion Culling)的目标:找出被完全遮挡的物体并跳过绘制。

视锥内但被遮挡:    视锥外:
  [A][墙][B]           [C]
 B 在视锥内,但被墙挡住 → 应剔除
 C 完全在视锥外        → 应剔除

4.2 主流遮挡剔除方案

方案原理优势劣势
Z-Prepass + 遮挡查询先只画深度,再用深度测试挡物体准确、GPU 友好多一次深度绘制
软遮挡(Software Occlusion)CPU 光栅化粗深度缓冲(HZB)不依赖 GPU 查询回读CPU 开销
手动遮挡标记(Occlusion Culling 数据)烘焙场景遮挡体编辑器可视化动态物体失效
遮挡缓冲(HZB/Hi-Z)GPU 生成层次化深度,CPU 快速查询高性价比需要延迟渲染或深度预 pass

4.3 Unity 的 Occlusion Culling 工作流

  1. 标记静态遮挡体(大墙面、地形)为 Occlusion Static。
  2. 烘焙:Window > Rendering > Occlusion Culling > Bake。
  3. 运行时引擎用烘焙数据快速剔除被遮挡的物体。
  4. 动态物体(敌人、玩家)可额外启用 Occlusion Culling 的 dynamic 队列(代价更高)。

移动端提示:HZB / GPU 遮挡查询在低端 GPU 上可能因回读(Readback)导致同步停顿,优先用软遮挡或 Z-Prepass。

5. GPU 管线概览

5.1 从顶点到像素

GPU 渲染管线把 CPU 提交的几何数据变成屏幕像素:

输入装配 → 顶点着色器(VS) → 曲面细分(可选) → 几何着色器(可选)
        → 光栅化(Rasterization) → 像素/片元着色器(PS/FS) → 输出合并(Blend/Depth)
阶段职责常见计算
顶点着色器 VS顶点坐标变换、顶点属性传递clipPos = mul(MVP, pos)
光栅化三角形 → 像素片元,插值属性重心坐标插值、深度插值
像素着色器 PS每个片元的颜色计算光照、纹理采样、法线计算
输出合并深度测试 + 混合遮挡处理、Alpha 混合

5.2 顶点着色器的最小示例

// HLSL 顶点着色器:世界变换 + 投影
cbuffer Matrices {
    float4x4 _ObjectToWorld;   // 模型→世界
    float4x4 _WorldToClip;     // 世界→裁剪空间
};

struct VSOut {
    float4 clipPos : SV_Position;
    float3 worldPos : TEXCOORD0;
};

VSOut VertexMain(float3 localPos : POSITION) {
    VSOut o;
    float4 world = mul(_ObjectToWorld, float4(localPos, 1));
    o.clipPos = mul(_WorldToClip, world);
    o.worldPos = world.xyz;
    return o;
}

// 像素着色器:简单的半兰伯特漫反射
float4 PixelMain(VSOut input) : SV_Target {
    float3 n = normalize(WorldNormal(input.worldPos));
    float ndl = saturate(dot(n, normalize(_LightDir)));
    float diffuse = ndl * 0.5 + 0.5;   // 半兰伯特,暗部不死黑
    return float4(diffuse, diffuse, diffuse, 1);
}

5.3 前向 vs 延迟渲染

维度前向(Forward)延迟(Deferred)
光源数量多光源成本爆炸多光源友好(GBuffer 解耦)
内存低GBuffer 占用高(移动端受限)
透明物体天然支持需单独 Forward 补绘
抗锯齿 MSAA支持不支持(需 TAA/FXAA)
代表移动端主流、Godot MobilePC 主机、Unity Deferred+

移动端黄金组合:Forward + 少量光源 + 烘焙光照贴图 + SSAO 后处理。别为了"更真实"盲目上 Deferred,带宽会吃掉帧率。

5.4 分辨率与带宽

片元工作量正比于像素数,即分辨率。渲染分辨率缩放(渲染缩放详解见性能优化篇)能直接线性降低填充率。GPU 上纹理带宽 > 计算,所以压缩纹理(ASTC/ETC2)、减少过采样是移动端第一优先级。

5.5 CPU 与 GPU 的协作:命令缓冲与同步

渲染是 CPU 与 GPU 的异步流水线。CPU 把绘制命令写入命令缓冲(Command Buffer),GPU 异步消费;两者之间靠Fence/同步原语避免数据竞争:

CPU 线程:  提交命令A → 提交命令B → 提交命令C → 读回结果(等待 Fence)
              │           │           │              │
GPU 队列:  ┌─▼──┐    ┌─▼──┐    ┌─▼──┐             │
            │命令A│    │命令B│    │命令C│             │
            └────┘    └────┘    └────┘   ← 异步执行 ┘
// 简化:命令缓冲 + Fence(类似 Vulkan/D3D12 概念)
RenderCommandBuffer cmd;
cmd.Record([&]() {
    cmd.SetPipeline(graphicsPipeline);
    cmd.Draw(vertexBuffer, indexCount);
    cmd.SetPipeline(uiPipeline);
    cmd.Draw(uiVertexBuffer, uiIndexCount);
});
queue.Submit(cmd);              // 异步提交,CPU 不阻塞
queue.WaitForFence(frameEnd);   // 仅在需要读回结果时等待

关键启示:

  • 读回(Readback)是敌人:把 GPU 结果拷回 CPU(如遮挡查询结果、像素拾取)需要 GPU 停 CPU 等,是同步停顿的主要来源。能不做就不做,或隔几帧做一次。
  • 渲染与逻辑重叠:现代引擎让逻辑帧 N 与渲染帧 N+1 重叠执行(double buffering),隐藏一部分延迟。Unity 的 CommandBuffer、Godot 的 RenderingServer 都遵循"CPU 提交、GPU 异步消费"的模型。

6. 最佳实践与总结

渲染优化决策清单(按性价比排序):

  1. 先做视锥剔除:零成本、收益最大,且是后续所有剔除的基础。
  2. 静态合批 / GPU Instancing 优先:移动端大量重复物直接 Instancing,别贪图写方便。
  3. 图集 + 材质合并:减少纹理状态切换,这是 Draw Call 里最隐蔽的成本。
  4. 遮挡剔除按场景复杂度决定:小场景不需要,大世界优先软遮挡/HZB。
  5. 用 Frame Debugger 数 Draw Call:Unity 的 Window > Analysis > Frame Debugger、Godot 的 Rendering > Debug 能逐 Pass 拆解,先看数据再动手。

常见坑:

  • 把 Draw Call 归因于三角形数(其实是状态切换)。
  • 在移动端跑 Deferred 却不压缩 GBuffer。
  • 忽略 Alpha Test(clip)会打断批次且禁用了深度优化。
  • 动态物体与静态物体混批导致静态合批整体失效。

渲染管线的本质是预算管理:给每个 Pass、每次状态切换、每块带宽记账,找到最贵的项优化。帧率不是玄学,是预算表的执行结果。

相关阅读:游戏引擎架构:ECS 与资源管理 讲述渲染前 CPU 侧的场景结构;游戏性能剖析与优化 讲述如何用 Profiler 定位渲染瓶颈;跨专题可参考 计算机图形学 的光栅化与着色基础。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 游戏性能剖析与优化:从 Profiler 到平台适配
  2. 游戏网络同步:延迟补偿、客户端预测与回滚
  3. 游戏 AI:行为树、寻路与群集行为