渲染系统是游戏引擎里最能体现工程功底的一块。很多刚入行的同学觉得渲染就是写Shader、调材质、往屏幕上画三角形,但真正到了项目里你会发现,写出一个能跑的Demo容易,写一个能撑住大世界、多人同屏、大量动态物体的渲染系统,难度完全不在一个量级上。我见过不少项目前期花了很多精力在特效和美术表现上,结果一到压测就掉到十几帧,定位了半天发现不是某个Shader复杂,而是渲染系统架构没搭对。
这篇是《游戏引擎架构深度解析》的第二篇,专门聊渲染系统的架构。上一篇我们讨论过引擎整体骨架,这次就到最热闹、最复杂的渲染环节了。适合正在做引擎、或者想从应用层往底层走的同学,也适合项目里负责性能优化、需要理解渲染链路的技术美术和客户端程序员。
渲染系统的架构,简单说就是一件事:把业务逻辑里“有什么东西”变成GPU能执行的“画这些像素”的指令,并且尽可能高效、稳定、可维护。听起来直接,但中间隔着场景管理、资源管理、线程调度、显存同步、API抽象、驱动适配一堆关卡。你想画一个角色,背后是从Transform、Mesh、Material到GPU资源上传,再到Draw Call提交、渲染状态切换的一整条流水线。任何一个环节设计得不好,都会在特定场景下炸给你看。
1. 渲染系统的架构定位:它到底在解什么题
1.1 渲染系统的职责边界
先画个边界。渲染系统不是“画图系统”,它不负责产生美术内容,也不负责物理模拟,它拿到的输入是“场景里应该显示哪些对象、它们是什么材质、在什么位置”,要负责的输出是“一帧图像”。中间的所有环节,才是渲染系统的核心地盘。
实际项目里,这个地盘可以拆成四大块。
第一,场景数据管理。游戏里的场景通常由玩家、怪物、建筑、植被、粒子、灯光组成,但这些对象不是都归渲染系统管。渲染系统一般通过组件、接口或者反射等方式,从上层拿到“可渲染对象”集合。这里需要解决的问题是:场景里的对象这么多,怎么快速找出当前相机看得见的那一部分。
第二,渲染资源管理。Mesh、Texture、Shader、Material、Pipeline State,这些资源从加载、上传、缓存、更新到销毁,是一个完整的生命周期问题。谁在什么时候把模型数据送进显存?纹理什么时候异步上传?Shader编译要在什么线程做?这些细节直接影响加载速度和运行时的流畅度。
第三,绘制流程组织。这就是管道(Pipeline)的概念:一帧里面先画什么、后画什么,哪些物体走前向渲染,哪些走延迟渲染,阴影贴图、反射探针、后处理、半透明排序、UI叠加,这些Pass和子Pass必须由渲染系统统一调度。
第四,硬件API封装与驱动。现在常见的图形API有DX12、Vulkan、Metal,也有还在大量使用的老旧API。渲染系统不能把业务代码绑死在某一个API上,所以在引擎内部一定会有一层抽象,把底层API差异吃掉。好的架构里,业务代码甚至不会感知到自己跑在DX12还是Vulkan上。
1.2 渲染系统的两个评价维度:正确性与效率
渲染系统最后能不能用,就看两件事:第一,画得对不对;第二,够不够快。
画得对不对,指的是渲染结果的确定性。同一帧的画面,在相同输入下必须稳定复现,不能在A机器上正常、在B机器上出现黑块、贴图闪烁、延迟一帧的错误。这个“确定性”往往是渲染架构最大的隐性成本来源。你会发现排查一个花屏问题,最后可能追到一个资源生命周期的问题:某张贴图在帧提交后才被释放,导致GPU读取到一半数据没了。
够不够快,则要看整个渲染系统能否在16毫秒(60fps)内完成一帧的准备和提交。时间预算通常要拆得很细:场景剔除多少毫秒、资源准备多少、命令编码多少、等待GPU多少。我曾见过一个项目的帧时间分配里,光一个Resource Upload就吃掉4ms,最后改成本帧延迟提交加预分配缓存,直接降到0.5ms。方案本身不难,难的是架构上给这种优化留了空间,你才知道问题出在哪一层。
说得直白一点,渲染系统架构的任务,就是用一套清晰的分层和数据结构,让正确性可保证、让性能瓶颈可定位。做不到这两点,其它都是空中楼阁。
2. 分层架构设计:渲染系统为什么必须拆成多层
2.1 一个典型的四层渲染架构
如果给一个成熟的商业引擎或自研引擎画剖面图,渲染系统从上到下大致是四层。
第一层是引擎应用层,也叫业务接入层。这一层负责从游戏逻辑中收集需要渲染的对象,常见的做法是组件系统里挂一个RenderableComponent,逻辑帧更新时把Transform、可见性、材质引用同步到渲染系统。这个层的核心原则是:不直接碰GPU API,也不关心GPU资源怎么排布。
第二层是场景与剔除层。这一层维护渲染场景的加速结构,接收应用层注册进来的渲染对象,提供射线检测、视锥剔除、遮挡剔除、距离剔除等服务。世界大的时候,这里还需要处理对象的增减和动态分区,比如四叉树、八叉树、双层网格或BVH。
第三层是渲染图与Pass调度层。这是现代引擎渲染架构的核心,典型代表就是FrameGraph或RenderGraph。它把一帧的渲染过程拆成一个个Pass,每个Pass声明自己读哪些资源、写哪些资源,资源依赖关系连成一张有向无环图。调度器再根据这张图决定执行顺序、资源生命周期、同步点甚至异步计算重叠。没有这层抽象,你要手工管理RT切换、资源屏障、生命周期,项目一复杂就是灾难。
第四层是驱动抽象层,也就是RHI(Render Hardware Interface)。这一层封装具体的图形API,向上提供统一的设备、队列、命令缓冲、资源对象、渲染状态抽象,向下对接DX12、Vulkan、Metal或老旧的GL。现代API很多概念是一致的,比如CommandBuffer、PipelineState、DescriptorSet,封装起来语义很清晰。值得提醒的是,RHI提供的抽象粒度不能太细,否则它本身会成为性能瓶颈。
2.2 分层解决的实际问题
很多人以为分层是“工程师洁癖”,好像只是为了代码好看。实际上这几个层解决的是非常现实的问题。
第一个问题是可替换性。硬件API在变,老旧API迟早要淘汰;引擎需要支持主机、PC、移动端不同平台。如果没有RHI这一层,全工程散布着API调用,更换底层API等于重写一遍引擎。有这一层,新增平台只需要写一套RHI实现,上层代码不动。
第二个问题是可测试性。渲染逻辑一旦和GPU紧耦合,单元测试几乎无从下手。分层的意义在于:场景剔除和Pass调度可以脱离真实GPU跑测试,你用软件模拟的深度信息也能验证遮挡剔除逻辑对不对。我见过一些团队会把剔除结果跑在CPU端做验证,构建一个纯逻辑测试环境,稳定性明显提高。
第三个问题是并行优化空间。没有分层,你想把“场景准备”和“命令编码”放到不同线程上去,会发现职责纠缠在一起根本拆不开。分层的边界本身,就是天然的并行切分点。应用层收集对象、剔除层计算可见性、调度层生成RenderPass,这三者在帧间可以流水线化执行。
3. 渲染核心子系统的内部拆解:场景管理、资源管理和GPU提交
3.1 场景管理:别把整个场景交给GPU
场景管理在整个渲染系统里常被低估,但它决定了渲染系统能撑多大复杂度。核心问题是:一个几平方公里的大世界,可能有几十万个物体,你不能每一帧把所有网格都提交给GPU。
业界通用的做法是引入空间加速结构。室内场景常用BSP或遮挡体,开放大世界常用四叉树、八叉树或双层网格,配合动态加载。每帧开始后,渲染线程拿到相机位置和视锥体,从加速结构里取出候选集合,做一次精确视锥剔除,再做遮挡剔除和距离剔除。这一步做完,几十万物体通常只剩几千。
这里有一个我在实际项目里踩过的坑:剔除要求在CPU上维护一份覆盖全场景的数据,但世界加载和卸载很频繁,如果空间结构更新策略写得太简单,会出现“删除对象还留在渲染队列里”的悬空引用,画面表现为死亡模型闪现。建议做法是:空间结构里存的是对象ID或共享句柄,而不是裸指针;删除走延迟回收,等当帧完成之后再清理。这个问题在架构层面就能规避,不要等出了Bug再去打补丁。
3.2 渲染资源管理:生命周期和上传策略决定成败
渲染资源管理相比普通内存管理有更多约束。首先,资源要跨CPU/GPU边界传输;其次,资源可能要被多个渲染Pass引用;再者,底层API对资源的创建、上传、销毁时机有严格规定,尤其Vulkan和DX12要求显式管理资源屏障和生命周期。
Mesh、纹理、着色器是三类主要资源。Mesh通常是静态数据,加载后上传到GPU,之后基本不动;纹理则可能是流式加载,地形、贴图集、虚拟纹理都有各自的流送策略;着色器更麻烦,源码要编译成平台相关的字节码,还涉及热更新、管线状态缓存。
实际架构上,我建议搞一个“资源上传队列”。当你从磁盘加载完模型,不是立刻上传到显存,而是交给后台上传线程,它把上传专用缓冲区里的数据拷到GPU资源,并在合适时机发信号。帧逻辑不必等待上传完成,只有当该资源真正被当前帧绘制引用时才需要同步等待。
生命周期方面,GPU使用的资源必须确保在GPU执行完相关命令之前不被释放。很多引擎用“渲染帧延迟计数”来解决:Main线程提交第N帧命令后,第N-K帧的命令可能还在GPU中执行,因此资源释放要推迟至少K帧。这个K的大小等于CPU领先GPU的帧数。
3.3 命令提交:从描述性命令到GPU可执行指令
这是渲染架构里最像编译流程的部分。上层的RenderGraph是一张逻辑依赖图,但它不能直接被GPU执行。调度器要做三件事。
第一,优化Pass顺序。根据资源依赖和屏障需求排序,尽量减少RenderTarget切换和资源屏障数量。同一个RenderTarget的Pass尽量挨在一起,避免反复切换。
第二,生成命令缓冲。每个Pass展开为一系列命令:设置管线状态、绑定描述符、设置顶点缓冲、索引缓冲、DrawCall。这个展开过程可以并行,因为不同RenderPass的命令之间没有数据依赖。
第三,提交执行。现代API一般支持多队列,图形队列、计算队列、拷贝队列可以并发执行。架构上应该在RHI层暴露队列抽象,让资源上传走拷贝队列、纯计算任务走异步计算队列、主渲染走图形队列,把GPU的并行能力吃满。
我见过不少团队的架构在命令提交这层做得很“接地气”,就是CPU端一堆DrawCall挨个调用,看起来代码没多少抽象,但一上DX12就卡得没法看。原因就是老式命令模式不知道资源屏障在哪,让驱动拼命猜。RenderGraph式结构的杀手锏,就是把“屏障”变成可计算的依赖信息,让调度器提前布局,而不是让驱动在运行时猜。
4. 多线程与同步:渲染架构的高性能关键
4.1 渲染线程模型:从单线程到帧内多线程
游戏引擎的渲染线程模型经历过明显的演化。早期引擎就是主线程直接调用图形API,逻辑、物理、渲染都在一条线程上,简单但帧率上限低。后来拆出专门的渲染线程,主线程负责游戏逻辑,渲染线程负责生成并提交命令,两个线程之间通过帧数据队列同步。这个模型现在仍是很多引擎的主流。
再往上有两个演进方向:一是复用多个工作线程并行做剔除、裁剪、生成命令;二是把整个帧的渲染数据从逻辑帧中彻底解耦,让渲染线程可以提前最多两帧开始工作。比如用Job System把可见性结果分成若干区块,每个区块一个线程去做精确剔除、LOD筛选、实例化合并,最后汇总到命令生成阶段。
这里要特别强调帧数据的拷贝问题。渲染线程不能直接读游戏逻辑的数据,否则会出现“一边写一边读”的数据竞争。架构上通常把跟渲染相关的数据打包成一个不可变的FrameData结构,逻辑线程每帧生成一份快照,渲染线程只读快照。这个设计看起来有点浪费内存,但换来的是确定性,而且快照创建可以在逻辑线程并行处理。
4.2 帧同步与延迟:CPU领先GPU几帧最好
CPU和GPU执行速度不同,如果CPU每帧提交完指令后立刻等GPU执行完,帧率就完全被GPU拖死;如果CPU无限制地领先GPU,又会导致操作延迟和内存堆积。所以架构必须设计一个有限长度的帧环形队列。
经典做法是维持一个“帧保险丝”:CPU提交完第N帧命令后,不等待,直接计算下一帧;但如果CPU已经比GPU领先了比如三帧,就主动阻塞,等GPU消费完一帧再继续。这个数字一般从2到4不等,视API和平台而定。太大会增加操作延迟,太小会让CPU频繁空等。
同步原语方面,现代API使用Fence和Semaphore。Fence用于CPU等待GPU完成某个队列操作,Semaphore用于GPU队列之间的先后依赖。架构上有一个很容易踩的坑:滥用CPU侧的wait造成管线空闲。比如每帧都强制“上传完成后再渲染”,就会彻底破坏流水线。正确做法是把上传、渲染放到独立队列,用Fence只在真正需要数据的那个Pass之前等待。
我个人的建议是,把“等待”作为显式的、罕见的操作来设计。如果一个系统里到处都是wait,说明数据流设计出了问题。健康状态下的渲染架构,绝大多数帧应该无阻塞提交,只有资源首次加载或跨帧同步点才出现等待。
5. 我在实际项目中遇到的渲染架构问题与排查技巧
5.1 花屏、黑闪和资源竞争,八成是生命周期问题
渲染系统的问题有个特点:表象在屏幕上,根子往往在数据管理上。黑闪、花屏、贴图发紫莫名其妙消失,我排查下来大半都是资源生命周期和帧同步出了问题。
最典型的场景是:美术加载了某张角色贴图,玩家快速切场景,贴图被旧引用释放,但还在GPU命令队列里被引用,于是这一帧出现花屏,眼睛还没来得及看清,下一帧又恢复正常。这种“一分钟闪现一次”的问题最难复现,需要抓帧对比,还得在架构层面看看资源释放是不是做了帧延迟。
排查顺序我通常是这样:先看崩溃或花屏是否伴随资源ID失效;再查资源引用计数和释放时机;最后用渲染调试工具抓一帧,看缺失的纹理、网格和管线状态有没有在加载列表里。抓帧工具各平台都有,PC上RenderDoc,主机各有厂商工具,移动端也有类似方案。
5.2 帧率瓶颈:先判断CPU Bound还是GPU Bound
调性能时最容易犯的错误是闭眼优化细节。正确的做法是先定位瓶颈是CPU还是GPU。方法很简单:降低分辨率,如果帧率提升了就是GPU Bound;如果帧率没变,那就是CPU Bound。
CPU Bound常见原因有:剔除算法低效、命令生成线程有串行阻塞、场景对象数量过大、GC或资源加载线程卡了帧。GPU Bound则常见于Overdraw过多、后端MSAA、复杂后处理、带宽不足。架构设计里,对CPU Bound的优化空间更大,因为你可以并行化、可以提前剔除、可以合并DrawCall。如果是GPU Bound,多半是渲染策略问题,比如后处理链条过长、分辨率策略不对。
我还习惯在引擎里做一套“定时器层级”,每个层级别单独计耗时,比如剔除多少、可见性回收多少、命令生成多少、提交多少。这样性能问题一出现,直接看报表就知道卡在哪层,不用靠猜。
5.3 加载卡顿:流式加载不能挡住主帧
最后说一个非常影响体验的问题:加载。很多项目建模阶段不觉得,一到大地图就露馅。玩家走着走着卡一下,鬼知道是加载贴图、编译Shader还是同步上传闹的。
架构上要对“加载”做等级划分:紧急加载走同步,必须立刻用;预加载走后台,提前几秒开始;流式加载更进一步,以区块为单位,按相机移动方向提前触发。而Shader编译,最好离线处理或首次运行时生成缓存,千万不要在战斗中间临时编译,否则卡死没商量。
上传这块我强烈建议采用命令缓冲后台提交,在主帧看不到上传引起的阻塞。你可以在调试模式里加一条规则:超过N毫秒的同步上传一律报警。有了报警,这类卡顿就会变成可追踪的性能问题,而不是玄学。
写到最后的一点体会
不算总结,就说一句实在话:渲染系统架构的难度不在某个算法,而在你看不到的地方——数据流、生命周期、同步。Shader写错了还能靠眼睛调,架构写歪了,项目到后期会天天出奇怪Bug,问题还很难稳定复现。如果你要做一个新引擎,或者重构现有渲染模块,先把场景管理和资源生命周期这两块想清楚,再去追求高级的渲染特性和华丽的后处理。地基不稳,画得再漂亮也经不住多人同屏的一轮压测。希望这篇拆解对你自己的架构设计有点帮助,有不同见解欢迎一起聊。