玩家看到的每一帧画面,都是 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 工作流
- 标记静态遮挡体(大墙面、地形)为
Occlusion Static。 - 烘焙:
Window > Rendering > Occlusion Culling > Bake。 - 运行时引擎用烘焙数据快速剔除被遮挡的物体。
- 动态物体(敌人、玩家)可额外启用 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 Mobile | PC 主机、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. 最佳实践与总结
渲染优化决策清单(按性价比排序):
- 先做视锥剔除:零成本、收益最大,且是后续所有剔除的基础。
- 静态合批 / GPU Instancing 优先:移动端大量重复物直接 Instancing,别贪图写方便。
- 图集 + 材质合并:减少纹理状态切换,这是 Draw Call 里最隐蔽的成本。
- 遮挡剔除按场景复杂度决定:小场景不需要,大世界优先软遮挡/HZB。
- 用 Frame Debugger 数 Draw Call:Unity 的
Window > Analysis > Frame Debugger、Godot 的Rendering > Debug能逐 Pass 拆解,先看数据再动手。
常见坑:
- 把 Draw Call 归因于三角形数(其实是状态切换)。
- 在移动端跑 Deferred 却不压缩 GBuffer。
- 忽略 Alpha Test(
clip)会打断批次且禁用了深度优化。 - 动态物体与静态物体混批导致静态合批整体失效。
渲染管线的本质是预算管理:给每个 Pass、每次状态切换、每块带宽记账,找到最贵的项优化。帧率不是玄学,是预算表的执行结果。
相关阅读:游戏引擎架构:ECS 与资源管理 讲述渲染前 CPU 侧的场景结构;游戏性能剖析与优化 讲述如何用 Profiler 定位渲染瓶颈;跨专题可参考 计算机图形学 的光栅化与着色基础。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。