聊到游戏引擎架构,我见过不少团队一上来就扎进着色器代码里,追着某个光照模型调半天。但说实话,渲染系统架构真正的难点,从来不在画面上那些亮眼的公式,而在于怎么把场景数据、资源生命周期、多线程调度和 GPU 命令生成这条链路过顺。这篇是“游戏引擎架构深度解析”第二篇,专讲渲染系统架构:一帧画面从游戏逻辑产生,到最终显示到屏幕上,中间到底经过哪些环节,每个环节为什么必须存在,以及我实际操作中踩过的坑和验证过的做法。适合正在啃引擎底层、或者被渲染流程搞到焦头烂额的图形程序员和引擎开发者,也适合想从“会调参数”进阶到“懂结构”的同学。
1. 先把边界画清楚:渲染系统到底负责什么
1.1 从一帧画面说起
很多人以为渲染系统就是“把模型画出来”的那部分代码。实际上,一个成熟引擎里的渲染系统更像一个调度中心,核心职责是:收集需要绘制的内容、筛掉不需要绘制的内容、整理绘制依赖、生成 GPU 能执行的命令、并管理这些命令在何时以何种顺序提交。你可以把渲染系统当成剧组统筹——演员和场景都在那儿,但真正把它们按剧本搬到镜头前,需要有人看日程、排顺序、处理场地冲突。
具体到一帧,它要处理的事情包括:可见性判断、渲染代理的生成、批次合并、资源绑定状态管理、Pass 之间的依赖分析、命令列表的记录与提交、以及和场景线程/资源流送线程的同步。这些如果全部堆在同一个地方写,代码量会爆炸,调试也会变成灾难。所以第一位的设计原则就是职责分离。
1.2 渲染系统与相邻模块的接口
渲染系统不是孤岛。它至少要和三类系统打交道:场景系统拿数据,资源系统拿纹理和网格,线程系统提供调度能力。接口设计上,我比较推荐“单向数据流”的思路:场景系统产出渲染世界的快照,渲染系统读取这个快照,产生命令,资源系统异步提供资源,渲染系统只消费不阻塞。
说得再具体一点,游戏逻辑不应该能直接修改 GPU 资源。逻辑层想要改变颜色、打开特效,应该通过渲染代理(Render Proxy)或者渲染属性组件来表达意图,由渲染系统在合适的时机把这些意图转成实际的 GPU 状态变化。否则就会出现逻辑线程在改纹理,渲染线程正拿着这块纹理提交绘制命令,两个模块互相踩踏,还会让缓存失效逻辑散落得到处都是。
1.3 为什么渲染架构容易失控
我见过不止一个项目最后渲染代码变成一坨“大泥球”:主循环里动不动出现几十个全局变量,渲染状态像毛细血管一样从各个模块直接渗进来,加一个新特效要改动六个文件才能把资源绑定理顺。这种失控的本质不是技术不行,而是早期没人把边界定清楚,导致每个新需求都在走“最快路径”。预防的办法不是写一大堆抽象基类装样子,而是从第一天就规定清楚:谁可以碰渲染资源、谁负责创建命令、谁负责管理生命周期。违规了代码评审就拦下来。架构的约束力,是比任何设计模式都重要的东西。
2. 场景抽离:从游戏世界到 GPU 数据
2.1 场景节点与渲染代理
场景系统里挂了成千上万个节点,但渲染系统并不想认识所有节点,它只关心“可渲染的东西长什么样”。这就需要一层渲染代理:每个可渲染对象向渲染系统注册一个代理,代理里只放渲染需要的最小信息——网格引用、材质引用、变换矩阵、可见性标记,以及给剔除用的包围盒。这个代理不是场景节点的镜像,它是渲染系统自己的数据,能够在帧内被高效遍历。
实际操作里,我会让代理与场景节点的生命周期解耦:节点销毁时,代理不是立即删除,而是打一个“待回收”标记,等到渲染线程处理到安全时机再真正释放。否则你会在遍历渲染列表时,突然发现某个代理引用的网格已经被资产系统卸载了,轻则闪一下,重则直接触发图形 API 的报错。
2.2 可见性剔除的层次
场景里有大量物体,如果不做剔除,任何 GPU 都撑不住。剔除通常分几层做:第一层是粗粒度的视锥剔除,把相机视野外的物体扔掉;第二层是距离剔除和遮罩剔除,比如小树在远距离就不该画;第三层是更细粒度的遮挡剔除,利用上一帧的深度信息判断物体是否被挡住。还有人会做小范围的高频剔除,比如植被系统里按实例级别裁剪。
剔除这层设计,我建议大家不要迷信单一方案。CPU 视锥剔除实现简单,但对遮挡无效;GPU Driven 渲染可以一次 draw 处理大量实例,但调试难度高,移动端兼容性也参差不齐。比较稳妥的是分层配合:先用 CPU 做粗筛,再用 GPU Instance 化合并大批量静态物体,动态高亮物体走传统路径。
2.3 批次合并与实例化
处理完剔除之后,我们要尽量让 GPU 一次处理更多物体。两个方向:静态合批和实例化。静态合批适合同材质、不会动、位置固定的物体,把它们合并成一个大的网格提交;实例化适合大量重复的物体,比如一棵树重复出现一千次,渲染 API 只提交一次网格,靠实例数据区分位置和姿态。
但合批不是无脑做。合批后的网格没法单独剔除,容易白白增加不必要的顶点处理;动态物体的坐标每帧变化,合批缓存失效反而更慢。所以好用的做法是:静态场景走批处理,动态物件走实例化。材质不统一的物体不要强行合,切换状态带来的开销比省下的 draw call 还大。
2.4 灯光与阴影的数据组织
灯光数据在渲染系统里应该是一份紧凑的结构化数组,而不是散落在场景各处的组件。实时渲染在迭代光照时,会频繁遍历灯光列表,列表的缓存命中率直接影响 CPU 性能。我习惯把灯光按类型和影响范围排序:平行光单独处理,点光聚光按包围球剔除后装入一个数组。阴影方面,平行光常用级联阴影贴图(CSM),点光用立方体贴图,聚光用单个 2D 贴图。这些贴图在帧里要用到的数量很大,必须在帧开始时就统一分配好,不要画到一半突然生成,否则会打断 GPU 流水线,造成明显的卡顿帧。
3. 渲染路径选择的底层逻辑
3.1 前向渲染与延迟渲染
渲染路径选型是渲染架构里一个绕不开的决策。前向渲染的逻辑很直接:每个物体在同一个 Pass 里处理所有光照。优点是实现简单、透明物体支持自然、可以通过 MSAA 做全屏抗锯齿;缺点是灯光数量一多,每帧要执行的绘制次数就爆炸,性能急剧下降。
延迟渲染的思路是先不处理光照,只把颜色、法线、粗糙度、深度这些信息输出到几张 G-Buffer 纹理,然后在一个全屏 Pass 里统一计算光照。光照计算量不再跟物体数量挂钩,而是跟屏幕像素数挂钩,所以动态光源很多的大场景里,延迟渲染优势明显。但它的代价也很现实:G-Buffer 需要大量显存带宽,移动端带宽本来就紧张,写四张 RT 再读回来,帧率很容易掉;透明物体和不支持延迟渲染的特性还得单独走前向通道,管线因此复杂一倍。
3.2 移动端与 TBDR 架构
移动端 GPU 大多是 Tile-Based Deferred Rendering,也就是 TBDR。它把屏幕切成小块(Tile),在每一块上先做几何处理,再把光照和着色在片内完成,尽量减少对显存的访问。这种架构下,带宽是最宝贵的资源。你写一次 RT、读一次 RT,看着只是几条 API 调用,背后可能让整个 Tile 的中间结果被溢出到显存再读回来,成本翻了几倍。
移动端做渲染架构时,一个常见误区是把 PC 上的延迟渲染方案直接搬过来。结果就是发热、掉帧、帧率波动。我这边实测过的做法是:移动端优先考虑前向渲染 + 严格控制实时灯光数量,或者用“前向+”方案,把 G-Buffer 压缩到一张纹理上,减少读写次数。方案不是越复杂越好,能跑满帧且功耗低才是移动端的答案。
3.3 带宽估算:方案取舍的依据
选渲染路径时不要靠感觉,可以做一个简单的带宽估算。举个例子,一张 1080P 的 RT,R8G8B8A8 格式,大小大约是 1920×1080×4 字节,约 8.3MB。延迟渲染如果有 4 张这样的 RT,加上深度,一帧光 G-Buffer 的写入就是 30MB 以上;光照阶段还要读出来再做全屏计算,读写合计可能到 60MB 以上。60 帧就是每秒 3.6GB 的显存流量。放在 PC 上这不算什么,但放到手机 SoC 的共享内存架构上,这个数字足以让功耗直线上升。
带宽约束决定了渲染路径选择的大方向。做架构决策时,把“每帧要读写多少数据”算清楚,比纠结“哪个光照模型更炫”重要得多。
4. Render Graph:现代渲染架构的时间线
4.1 传统命令提交的痛点
早期渲染代码的写法是“画到哪算哪”:先画天空,再画不透明物体,然后画阴影,最后做后处理。每个 Pass 都要手动指定资源状态转换。但 GPU 资源在读写之间通常有状态要求,比如一张纹理从“作为渲染目标写入”切换到“作为采样器读取”,需要插入 Barrier 等待 GPU 完成前面的写操作。手工管理这些 Barrier,一旦 Pass 数量多了、流程改了,很容易漏插或者多插,漏插会导致画面闪、花,多插会白白浪费 GPU 等待时间。
4.2 Render Graph 的工作原理
Render Graph 解决的就是这个问题。它不再让每个 Pass 直接提交命令,而是先在一帧的开始把所有 Pass 的“意图”收集起来,形成一张有向无环图,每个节点是一个 Pass,节点之间的边是资源依赖。Graph 构建完成后,系统自动分析每个资源的首次写入、最后读取时间,确定它生存期有多长,需要插入哪些 Barrier,甚至能判断某张中间纹理是不是只在两个 Pass 之间用到,如果是,就把它设为 Transient 资源,使用内存池里临时分配的显存。
这种“先规划再执行”的思路,对人脑最直观的价值是:你不用再手工管资源状态。你可以声明式地写一个 Pass:
pass("GBuffer") .Writes(rtColor) // 输出颜色 .Writes(depthBuffer) .Executes([](CommandList& cmd) { // 实际的绘制命令 }); pass("Lighting") .Reads(rtColor) .Writes(rtScene) .Executes([&](CommandList& cmd) { // 全屏光照计算 });引擎会根据这两个 Pass 的读写声明,在GBuffer写入完成后自动插入 Barrier,然后再让Lighting读取rtColor。整个过程,Pass 之间的开发者都不用研究谁先谁后,只需声明依赖。
4.3 落地 Render Graph 的代价与建议
Render Graph 不是银弹。它完整落地需要所有渲染模块都改造成“Pass 声明 + Executor”模式,初期调试会非常痛苦,报错信息往往要经过 Graph 层翻译才能定位到具体 Pass。而且它的优势在大而全的引擎里最明显,如果只是一个几万行的轻量引擎,硬上 Graph 层可能是过度设计。
我的建议是:如果你的引擎已经有几十个 Pass,光照、阴影、后处理互相穿插,Barrier 开始经常漏插、加新功能要排查很久,那 Render Graph 值得引入;如果只有十几个 Pass 且流程稳定,做好 Pass 顺序和 Barrier 封装就够了。架构为实际复杂度服务,不为了名词好看。
5. 命令生成与提交:把多线程用好
5.1 从可见集合到绘制命令
渲染系统在帧开始时拿到的是经过剔除后的可见物体列表,但这些还是“物体”,不是 GPU 能懂的语言。我们需要把它们转换成绘制命令,包括:绑定哪份顶点缓冲、用哪个管线状态对象、绑定哪些描述符、设置哪些常量。这个转换是 CPU 密集工作,尤其当物体数量上万时,逐物体做状态切换会非常耗时。
所以现代引擎通常会做一层中间层:先把可见物体按材质、网格、光照状态排序,合并同类项,生成“绘制批次”,然后再把批次翻译成命令。这一步翻译得越快,留给 GPU 的时间就越多。排序算法上我见过有些团队用过简单粗暴的按材质 ID 排序,效果远好于频繁切换状态;更细的还会考虑深度排序去优化 Blending 和 Early-Z 的情况。
5.2 多线程记录命令列表
命令列表的记录是可以并行的。把可见列表分成多个区块,每个区块交给一个工作线程,每个线程独立记录自己的 CommandList,最终在主线程按顺序提交。听起来简单,实际坑很多。比如多个线程同时访问同一个资源分配器时,锁竞争会让并行的收益被吃掉。解决方法是给每个线程准备独立的内存分配器,或者用无锁队列,让线程间不共享可变状态。
另外一个很关键的约定:线程记录的只是“GPU 要做什么”,不能立刻去读 GPU 资源内容。所有数据都必须来自 CPU 侧已准备好的副本,或者来自上一帧 GPU 回读的结果。这个限制是刚性的,违反了就会出现不断等待 GPU 的空档期,整个多线程渲染就名存实亡了。
5.3 帧延迟与同步:不要一帧等一帧
渲染线程与主线程如果每帧都同步一次,主线程就会被渲染速度拖住。常见做法是引入帧环(Frame Ring):允许主线程比渲染线程领先一到两帧,渲染线程消费帧数据时,主线程已经在准备下一帧。这样做确实会带来输入延迟,但一两帧的延迟在大多数游戏里感知不明显,换来的是稳定的帧率和 CPU/GPU 并行度,收益划算。
但要小心循环依赖:如果渲染线程读的帧数据来自主线程的三帧前,而主线程正在改这块数据,会出现数据竞争。稳妥的方案是“双缓冲 + 帧号标记”,或者干脆把场景数据做成不可变性快照。具体怎么选,取决于内存预算和 CPU 性能,但原则是明确的——不要阻塞地等待 GPU 完成每一帧。
6. 资源与 Descriptor:容易被低估的架构死角
6.1 GPU 资源生命周期
GPU 资源不是 new 出来就能用,它有一个明确的生命周期:创建、上传、绑定、使用、释放。创建和释放尽量不要发生在渲染线程里,因为有可能是重操作,会阻塞提交。上传部分需要独立的上传队列,比如纹理数据从磁盘流送进来,先在 CPU 侧暂存,再异步拷贝到 GPU 显存。设计时一定要考虑资源竞争:上传中的纹理被绘制命令引用,轻则闪烁,重则崩溃。
资源释放尤其是个坑。GPU 可能还在使用某张贴图,CPU 这边已经销毁了资源,图形 API 的校验层一般会警告,但发布版本不会保护你。我通常的做法是延迟释放:把要释放的资源放进一个待回收队列,等它过了至少一帧的生命周期再真正释放,或者直接挂在帧环的结束回调里处理。
6.2 Descriptor 堆的分配策略
Descriptor(描述符)是资源绑定里的一层间接映射,它告诉 GPU“这个纹理在显存哪里、格式是什么”。现代图形 API 里,描述符堆的管理经常成为瓶颈。常见策略有两种:一种是每个绘制调用都临时分配几个描述符,这种方式灵活但吞吐量低,游戏里动辄几千个 draw call 就容易爆;另一种是 Bindless,把场景里所有可能需要用的纹理都放进一张大回表里,绘制时直接传索引,GPU 侧按索引采样,大大减少绑定开销。
Bindless 并非所有平台都支持,所以我一般建议做一套兼容层:支持时走 Bindless,不支持时退化为逐 draw 绑定。不要让业务代码感知到差异,而是抽象成“拿到纹理句柄,返回采样用的索引”这种接口。这样架构风险被隔离,未来平台升级时也容易迁移。
6.3 常见内存坑与规避
实际项目里最常遇到的两个资源问题:一是纹理/缓冲泄漏后不回收,一会儿内存就见底;二是描述符堆碎片化严重,一会分配一会释放,导致 GPU 无法有效复用。前者处理方式是在资源系统里加入“Leak Detector”,每当资源创建时记录调用栈,内存异常增加时能找到源头。后者可以通过分代分配器解决:把描述符分成固定大小的块,每一帧只在一两个块里分配,避免碎片。
有一种非常隐蔽的问题:切换渲染目标时,有些 API 实现会隐式地把整条管线 flush 一下,如果你是每帧几十个 RT 切来切去,性能就莫名其妙掉了。这个用 Render Graph 的“Pass 合并”可以缓解,让相邻且依赖相似的 Pass 尽量共享同一个渲染目标。
7. 调试与性能分析:用数据而不是感觉
7.1 帧时间分解与预算
做渲染优化,首先要搞清楚时间花在了哪。把一帧拆成几个阶段:场景遍历与剔除(CPU)、命令生成(CPU)、GPU 执行(顶点/像素/光照/后处理)、资源上传(IO/CPU+GPU)。在调试 UI 里画出各阶段耗时,可以直接看出瓶颈在 CPU 还是 GPU。如果 GPU 很闲而 CPU 很忙,多线程和命令生成的效率是重点;如果 CPU 很闲但 GPU 满载,优化方向就变成降低分辨率、减少 overdraw、简化特效。
设定预算也很重要。比如目标 60 帧,CPU 预算 16.7ms,你希望渲染线程只占其中 6ms,剔除和命令生成占 3ms,资源上传和其他零碎占 2ms,留 5ms 余量。预算制定后,每次性能回归测试都对照这份预算查,而不是只盯着平均帧数。
7.2 工具链:RenderDoc、平台 Profiler 和 GPU 计数器
没有工具支撑,渲染调试等于盲人摸象。我最常用的组合是:RenderDoc 用来截帧看资源状态、绑定情况、绘制调用顺序,排查画面问题非常顺手;平台级 Profiler 看的是硬件计数器,能告诉你顶点负债、像素负债、纹理带宽占用、Shader 占用。一个典型场景是:某个画面某些物体闪黑,RenderDoc 里能看到纹理未被正确采样,可能采样器状态没配或者资源状态不对;另一个场景是某场景帧率骤降,GPU 计数器显示像素负载爆表,说明 overdraw 严重,要做遮挡剔除或降低后处理分辨率。
有人会问:Profiler 显示的数字到底怎么读?我的经验是:先看 GPU 利用率是否接近 100%,如果接近,看顶点数和像素数哪个高;如果偏低,看 CPU 线程哪个吃满,再往下钻。整个过程像给系统做体检,靠指标定位,而不是猜。
7.3 一份可复用的优化清单
我把自己项目里多次用到的优化项按成本和收益整理过一遍,这里可以分享。
| 优化项 | 成本 | 收益 | 风险 |
|---|---|---|---|
| 降低渲染分辨率 | 低 | 很高 | 画面模糊需配 TAA 补偿 |
| 减少动态阴影级联数 | 低 | 高 | 远处影子质量下降 |
| 关闭次要物体投影 | 低 | 中 | 视觉体验略降 |
| 降低后处理特效分辨率 | 低 | 中 | 部分特效粒度变粗 |
| 合并小物体批次 | 中 | 中高 | 静态合批后剔除粒度变差 |
| 遮挡剔除 | 高 | 很高 | 错误剔除导致物体消失 |
| 纹理 mipmap 完善 | 低 | 中高 | 显存无明显增加 |
| 全局光照烘焙代替实时 | 高 | 很高 | 动态光照变化受限 |
这个清单不是万能药,但它给出了一个成本从低到高的顺序。优化时,我们先做低成本的,再决定要不要动贵方案。
8. 我在实际项目中遇到的经典问题实录
8.1 画面间歇性闪烁:Barrier 漏插
有一次场景里加入一个后处理链后,画面随机闪黑块。RenderDoc 截帧显示,某张中间纹理在 Pass A 里被写入,又在同一个 Pass 后半段被采样,结果因为状态没有及时转换,后面的采样拿到了半更新数据。解决办法不是加一个 Barrier 那么简单,而是把那个纹理拆成两张,一张写入一张读取,彻底消除读写冲突。这个经验让我意识到:看起来最省事的方案,有时不如数据分离更稳定。
8.2 移动端发热、掉帧:带宽爆表
在手机上跑一个 PC 移植过来的延迟渲染 Demo,帧数起初只有 20 帧。带宽估算之后发现,光 G-Buffer 写读就已经超预算。当时做了三件事:把 G-Buffer 从四张压到两张,把后处理 Bloom 降为半分辨率,再把阴影贴图分辨率从 2048 降到 1024。调整后帧数稳定在 50 帧,发热也明显改善。移动端优化的核心思路一直是:减少每像素读写的数据量。
8.3 多线程提交后仍然卡顿:锁竞争
给渲染系统加多线程命令记录后,帧率没怎么涨,偶尔还有卡顿。用 Profiler 抓出来,发现每个线程分配命令内存时都在抢同一个分配器,锁等待占了线程执行时间的 30%。改成线程局部分配器后,问题直接消失。这给我一个教训:并行化之前,先确认每一层的共享数据都处理干净,否则优化了个寂寞。
8.4 描述符分配频繁导致 GPU 阻塞
某个功能上线后,GPU 曲线偶尔跳到红线。排查发现每帧动态生成大量临时描述符,分配器会触发同步回收,卡住渲染管线。后来改成按帧预分配 256 个描述符槽位,用完循环复用,特殊情况再扩容,GPU 曲线就平稳了。
8.5 阴影边缘抖动:级联参数调不好
CSM 阴影在角色移动时边缘抖动得很明显。有同事一开始去调 shadow bias,效果一般。后来查下来,是级联分割比例不合理,近级联的贴图利用率太低。调整分割比例和加一点包围盒扩展后,抖动立刻缓解。这类问题提醒我,画面问题的根源经常在数据划分和参数分布,不在单个数值。
做完这些,我最大的感受是:渲染系统架构设计的成功,不是说用了什么高深技术,而是数据流转清晰、资源生命周期管得住、多线程没有互相拖后腿、出了问题能快速定位。很多性能问题和画面问题,追到根上都是架构层面的“依赖没理清”或者“边界没定好”。这套思路我后面在动画系统和流送系统里也一直在用。如果你正在架构自己的渲染模块,建议先画清楚一张“数据流图”,把谁产生数据、谁消费数据、谁释放数据标明白,再动手写代码,后面会省下非常多调试时间。