
游戏引擎架构深度解析二渲染系统架构上篇聊完引擎整体的模块划分和启动流程这次我把镜头拉到游戏引擎里最硬核、也最容易被新人劝退的部分渲染系统架构。之所以把渲染单独拆一篇是因为它和引擎里其他系统几乎完全不同——物理、动画、寻路这些系统即使写得再差游戏还是能跑但渲染系统一旦架构设计有问题别说优化性能连加一个新特性都可能要推倒重来。如果你正在做引擎开发或者用商业引擎但想搞清楚底层渲染管线是怎么组织的这篇内容值得你花十分钟慢慢读。我见过太多项目踩进同一个坑功能堆得很快但一到中后期帧率上不去、内存爆涨、GPU瓶颈摸不着头所有问题最后都指向渲染这块。我想说的核心观点是渲染系统架构的难点不在怎么画一个三角形而在怎么在一帧16毫秒内稳定地画完成百上千个三角形、管理好资源和状态、并且不给上层逻辑添乱。这一篇我尽量讲清楚渲染系统的层级划分、关键模块的职责、以及我在实际项目里总结的架构取舍思路。1. 为什么渲染系统是引擎里最难啃的一块骨头很多刚入门的朋友会以为渲染系统难在数学和图形学原理比如光照模型、着色器、矩阵变换这些。其实这些只是基础真正的难度在工程层面。我做了这么多年引擎相关的工作一个很深的体会是渲染系统是引擎里耦合面最广的系统它几乎和所有模块都有关联。1.1 渲染系统要回答的四个核心命题不管什么引擎渲染系统本质上都在回答四个问题这一帧要画什么用什么状态来画按什么顺序画怎么在性能预算内画完第一个问题画什么听起来简单实际上要处理场景管理、可视性剔除、LOD选择、实例归组这一大串逻辑。第二个问题用什么状态画说的是渲染状态——Shader、纹理、混合模式、深度测试、渲染目标这些东西组合起来数量爆炸如果管理不当CPU光是切换状态就能把帧率拖垮。第三个问题按什么顺序画涉及到透明物体排序、渲染目标切换、Pass依赖关系。第四个问题怎么在预算内画完则是渲染系统所有优化手段的终极目标。这四个问题不是独立的它们彼此纠缠。比如你想做动态分辨率就得考虑后处理链的依赖关系你想做GPU Driven渲染就得重构剔除和提交的逻辑。所以架构设计的第一步不是急着写代码而是想清楚这四个问题的答案在引擎里分别落在哪个模块。1.2 从单帧时间预算反推架构诉求我习惯用一个很朴素的思考方式来做架构决策把你的帧预算画成一张时间表。假设目标是60帧一帧只有16.6毫秒这部分时间既要给CPU跑逻辑又要给GPU画画。通常CPU侧的游戏逻辑加上物理、动画可能吃掉4到6毫秒剩下的CPU时间要给渲染系统的场景遍历、剔除、状态收集和命令提交同时GPU侧需要一个稳定的10毫秒上下。从这个时间表就能看出两个架构诉求第一CPU侧的渲染工作不能做太多串行计算能并行就要并行第二GPU侧的渲染工作不能频繁中断或等待CPU否则流水线就废了。这两个诉求直接决定了渲染系统要采用多线程框架、延迟提交、资源预创建这些基础设计。我把这些称为被时间预算逼出来的架构它不是某个工程师灵光一现的产物而是工程约束的自然结果。还有一个被很多人忽略的点渲染系统的架构必须支持性能可预测。也就是说画面复杂度提高时帧率应该平滑下降而不是突然掉到个位数。这就要求架构里有明确的预算控制机制比如LOD切换、粒子数上限、动态分辨率缩放这些它们本质上是渲染系统提供给上层的旋钮。2. 硬件抽象层RHI跨平台渲染的地基几乎所有现代引擎在原生图形API之上都有一层自己的封装Unreal里叫RHIUnity里类似的概念是SRP底层封装自研引擎也基本都会做这一层。这一节我想重点聊聊RHI设计里最容易被低估的几个点。2.1 为什么引擎非要自己包一层API有朋友会问直接用Vulkan或者DX12不就行了吗为什么要费劲封装直接用的后果是你的引擎被绑定在单一平台上。游戏引擎的价值之一就是跨平台你要同时出PC、主机、移动端PC上可能还要分DX11、DX12、Vulkan那如果业务代码里到处是Vulkan的API调用每个平台的移植都是一场噩梦。封装还有一层更实际的理由原生API对使用者要求太高。Vulkan和DX12都是高显性API你必须自己管理内存分配、同步、资源状态转换任何一个环节错位都是崩溃或者性能黑洞。而游戏逻辑层的开发者、甚至渲染特性开发者不应该需要关心这些底层细节。RHI的作用就是把安全使用图形API这件事集中处理让上层拿到的是一套简单、安全、抽象程度合适的接口。2.2 公开接口与私有实现接口设计的黄金分割RHI的接口设计是门学问接口太细上层代码就退化成直接操作API封装失去意义接口太粗又无法表达底层特性性能会打折扣。我见过比较好的做法是分层设计对外暴露一种命令式的接口比如BeginFrame、BeginRenderPass、Draw、EndRenderPass、EndFrame对接底层时再翻译成具体API的调用。关键点在于RHI层的接口要尽可能屏蔽资源状态的概念。比如在Vulkan里一张纹理从着色器读变成渲染目标写需要插入屏障这是底层概念不应该暴露给上层。我的做法是让RHI在资源绑定时自动推断状态转换或者在Draw之前统一做一次屏障批量插入。批量插入屏障非常重要逐个插入屏障的代价是GPU流水线深度被频繁打断性能会肉眼可见地下降。2.3 跨后端一致性从代码规范到资源全局换名封装完了之后真正烧脑的是跨后端行为的一致性。DX11、DX12、Vulkan、Metal这四个后端的行为细节差异非常多——纹理布局、渲染目标格式支持、缓冲对齐要求、Shader编译结果全都不一样。我自己踩过最深的坑是纹理格式同样一张RGBA16F纹理在PC上是4字节对齐在主机和移动端可能是其他分布方式直接导致上传到GPU后出现奇怪的采样结果。为了解决这类问题RHI层需要定义一套引擎纹理格式枚举并在后端里做映射。同时还要注意一个细节不同API对资源名的解析粒度不一样。DX11用全局命名描述符Vulkan要手动创建DescriptorSetMetal用的是ArgumentBuffer。我的经验是在RHI层做一套资源绑定表的统一抽象上层声明我这张PBR材质需要这几张贴图RHI层负责为不同后端生成对应的资源描述结构。3. 多线程渲染框架让游戏线程和渲染线程高速并发如果你去看一个典型引擎的帧时间线会发现游戏线程、渲染线程、GPU三者在做流水线式的工作。这一节我想拆开讲讲多线程渲染框架的工程实现这部分的架构选择直接决定了引擎的上限。3.1 经典双线程模型PlayFrame与RenderFrame行业里最经典的模型是双线程游戏线程更新逻辑物理、动画、AI渲染线程负责把场景数据变成GPU能消费的命令。引擎通常维护两个帧状态游戏帧PlayFrame和渲染帧RenderFrame。游戏线程在第N帧修改游戏状态渲染线程在消费第N-1帧的场景快照两者之间通过命令缓冲区传递数据。这个模型有一个天然特性延迟一帧。也就是说画面里显示的内容其实是一帧之前的逻辑状态。对这种延迟绝大多数项目是接受的因为这一帧的延迟换来了CPU多核利用率的巨大提升。不过也有例外——比如格斗游戏、音游这类对输入延迟极其敏感的项目它们会做特殊处理比如把渲染管线的命令提交提前或者引入无延迟的特殊路径。3.2 延迟提交的数据结构设计游戏线程和渲染线程之间传递的数据不是把场景数据拷贝一份——那太昂贵了。真实做法是游戏线程把这一帧我做了什么记录成一系列命令写进一块线程安全的环形缓冲。渲染线程从另一端读取命令并执行。命令通常是轻量级的移动一个Entity、变换一个光源、更新一个材质参数、提交一个DrawCall。这里有个设计细节值得多说两句命令里的数据尽量放值类型而不是指针。你想场景里的MeshComponent第N帧可能被销毁了但渲染线程还在处理第N-1帧的引用如果不小心用了已释放的内存直接就挂了。我的习惯是所有跨线程传递的数据都拷贝成值或者用强引用指针配合智能指针的延迟释放机制。后者更复杂但省内存项目大了以后值得做。3.3 同步点、Fence与Query代价极高的握手多线程渲染里最需要小心的操作是同步。比如你要做GPU回读ReadBack比如把深度缓冲读回来做遮挡剔除或者读取上一帧的GPU时间戳这些操作如果直接同步会把整个流水线卡死。我的建议是永远不要做立即同步的GPU回读而是用Fence围栏和Query查询机制。具体做法是在需要回读的位置提交一个Fence渲染线程继续往下跑不回等。当下下下一帧需要数据的时候才去检查Fence是否已经完成。如果完成了就读取结果没完成就沿用上一帧的数据。这套机制能保证你不在主线程上傻等。还有个衍生的经验GPU时间戳Query不要每帧都做那东西本身也有开销我习惯只在调试模式或者固定间隔比如每秒一次开启。4. 场景数据组织与剔除系统别让GPU看到不该看的东西GPU的渲染能力是有上限的不是说场景里有100万个物体GPU就能轻松画100万个物体。剔除系统的目标就是把不可见的物体过滤掉让提交给GPU的DrawCall和三角形数量尽可能少。这块的架构设计直接关系到你在复杂场景下的性能表现。4.1 场景图与加速结构八叉树、BVH还是GPU Driven引擎里的场景通常组织成场景图Scene Graph渲染系统会在场景图上建立自己的加速结构最常用的是八叉树和BVH层次包围盒树。八叉树适合静态场景BVH适合动态物体比较多、物体位置频繁变动的场景。还有一种是专门给GPU设计加速结构比如虚幻引擎里的Cluster树把物体按位置聚类生成多级包围盒让GPU能并行剔除。怎么选我的建议是不要过度设计。如果你的游戏场景规模不大、物体数量几千个一个排序好的平面数组加上简单的视锥剔除也够用。但如果你的目标平台是主机或者高端PC场景里要支持几十万物体那就要认真考虑BVH或者GPU Driven的剔除方案。架构设计的一个基本原则是先测量再优化。没有性能数据支撑的结构选型容易变成自我感动的过度工程。4.2 三级剔除链路视锥、遮挡与背面剔除不是只有一个环节。最常见的剔除链路是三级视锥剔除Frustum Culling、遮挡剔除Occlusion Culling、背面剔除Backface Culling。视锥剔除是第一步把不在相机视锥体内的物体筛掉这一步在CPU侧完成成本相对低遮挡剔除是第二步把被其他物体完全挡住的物体筛掉这一步可以用软件遮挡查询Software Occlusion或者GPU回读深度数据来做背面剔除是第三步通常发生在GPU光栅化阶段把背对相机的三角形丢弃。三个环节里最容易搞错的配合是视锥剔除和遮挡剔除的顺序。很多人以为遮挡剔除应该在视锥剔除之前做其实反过来更高效。理由很简单视锥剔除的测试成本低先把大量显然不可见的物体滤掉再做昂贵的遮挡测试整体开销最省。4.3 实例化与GPU提交把Draw Call压进个位数剔除完成之后接下来要考虑的是怎么把想画的物体组织成高效的DrawCall。现代引擎的做法基本是同类材质的物体走实例化Instancing渲染把位置、颜色、动画数据放进一个Buffer一次DrawCall画几百个实例。引擎会维护一个材质桶Material Bucket按材质和渲染状态分组每组收集这个帧所有需要画的实例。这里我要强调一个经验DrawCall本身不是魔鬼状态切换才是。从DX12和Vulkan开始API的设计目标就是让开发者以更粗的粒度提交命令一次提交里包含完整的PipelineState、资源绑定和绘制调用。所以架构上要按PipelineState切换最少化的原则来组织提交顺序。如果两个物体的材质不同但Shader和状态基本一致也可以尝试合并渲染如果连状态都不一样硬合并反而会引入额外开销。5. 渲染管线主干从场景数据到屏幕像素的完整旅程有了RHI、多线程框架、场景组织和剔除下一步就是把整个帧的渲染流程串起来。这一节我讲渲染管线的主干阶段以及每个阶段的架构职责。5.1 脚本管线与引擎管线哪些环节交给开发者商业引擎一般会把渲染管线做成可扩展的最典型的是Unity的SRPScriptable Render Pipeline让开发者自己编排Pass顺序。自研引擎的做法通常相反核心管线是写死的但开放若干扩展点比如后处理特效、自定义Pass、光照模型替换。两种思路各有取舍我的感受是完全开放等于把复杂度全扔给使用方结果就是一批项目各写各的渲染逻辑后期维护痛不欲生完全封闭又缺乏灵活性。折中方案是引擎提供一套默认管线但把静态开关做成配置项把自定义Pass做成接口。5.2 几何准备与材质分类每帧开始渲染系统要收集场景里所有需要绘制的几何体生成相应的渲染命令。几何准备包含了Mesh绑定、动画蒙皮、GPU蒙皮结果的提交、LOD选择等。这里需要特别注意LOD的选择时机——它应该在剔除之前还是之后答案是先剔除粗粒度的AABB再做LOD切换。道理很简单LOD切换要比剔除昂贵一些先滤掉不可见的物体能省下不少LOD计算。材质分类发生在命令提交之前。引擎会把所有绘制调用分成几大类不透明物体、透明物体、AlphaTest物体如树叶、特殊Pass如深度预pass、阴影pass。不透明物体内部的顺序对最终画面几乎没影响可以按状态排序来减少切换透明物体必须从远到近排序否则混合结果不对。5.3 光照通道与阴影策略光照策略是渲染管线架构的分水岭。传统Forward渲染管线逻辑简单适合移动端Deferred渲染管线在G-Buffer里存了位置、法线、颜色等信息支持动态光源的能力强但对带宽和显存要求高更多项目现在选择Forward即Tiled Forward把光源按屏幕区域分桶在Forward基础上实现了动态光的规模支持。阴影方面现代引擎的主光源阴影基本都是CSMCascade Shadow Map按照离相机的距离分几级深度纹理近处精细远处粗糙。架构上要注意阴影Pass是在主Pass之前还是之后我的经验是主光源阴影在深度预Pass阶段就绘制得到一个全屏的阴影贴图再在主Pass里采样这样主Pass和阴影绘制互不阻塞。此外动态阴影对性能影响极大架构里必须有阴影距离/分辨率可配置的选项。5.4 透明物体排序与后处理收尾透明物体是渲染管线里最麻烦的环节。粒子、半透明面片、体积雾这些在后期出现得非常多如果无脑按远到近排序每换一个物体的排序位置就会打断状态连续性和合批DrawCall数量会暴涨。我的经验是透明物体不要用全局距离排序而是用半透明队列深度排序的组合。即先按渲染队列分桶队列内再按距离排序同时降低透明物体的合批预期把它和半透明的视觉正确性放在优先级更高的位置。后处理阶段通常是帧的最后一段屏幕空间特效SSAO、SSR、Bloom、色调映射Tonemapping、颜色分级Color Grading依次串在一条后处理链上。要注意Tonemapping必须放在HDR数据的最后一步而Bloom要在Tonemapping之前否则高光会被Tonemapper裁掉。后处理链的设计还要考虑渲染目标的格式转换比如Bloom在HDR目标上操作Tonemapping输出到LDR后备缓冲。6. 现代引擎的渲染进阶特性Compute、异步计算与超分方案写到这一节我觉得有必要把最近几年渲染架构的变化单独拿出来说一说。GPU硬件迭代、新图形API普及让渲染架构有了新的维度。6.1 Compute Shader如何改写了渲染管线如果只说一个现代渲染管线和十年前最大的区别那就是Compute Shader的大规模引入。Compute Shader不是用来画三角形的它是一个通用的GPU并行计算单元可以做粒子更新、布料模拟、后处理、剔除、LOD选择这些原本在CPU侧做的工作。渲染架构上Compute Shader改变了CPU每帧算好数据再交给GPU的模型变成了GPU每帧自己算自己用。最典型的例子就是GPU Driven剔除。传统的CPU剔除模式是CPU遍历所有物体的包围盒筛掉不可见的再把可见的提交给GPU。而在GPU Driven模式下物体数据直接放在GPU侧GPU通过Compute Shader做视锥剔除和遮挡剔除把可见物体的索引写进一个GPU Buffer然后直接在小三角形绘制阶段引用。这套方案的CPU开销极低但工程复杂度高你需要处理GPU数据回读、间接绘制Indirect Draw、显存管理。如果项目对极致性能没要求我不推荐贸然全量上GPU Driven混合方案更现实。6.2 Async Compute挤出来的性能余量异步计算队列Async Compute是现代GPU的一个特性图形队列和计算队列可以并行执行。渲染架构里怎么用典型的场景是主Pass在光栅化像素同时Compute队列在做下一帧的剔除或者后处理的特效计算两者并行不冲突。但我必须提醒一句Async Compute不是说开就开的它需要非常精细的调度。如果你的计算任务和图形任务之间有数据依赖强行并行只会互相等待反而更慢。我见过一些项目为了炫耀特性硬上Async Compute结果帧率还不如串行。我的建议是先做性能剖析确认GPU图形负载有空闲周期再上异步计算并且要给异步计算任务分配单独的CommandQueue和Fence。6.3 超分方案DLSS/FSR/XeSS的架构视角这几年很热的超分技术——DLSS、FSR、XeSS——从架构上看其实是一个重建后处理Pass。它的原理是先用低分辨率渲染场景再用运动矢量和时序累积把低分辨率画面重建为高分辨率画面。渲染架构上的关键点是这套重建Pass需要拿到运动矢量MV和深度信息所以你在G-Buffer或者延迟渲染的结构里必须保留一份运动矢量输出。很多老引擎最初没有运动矢量通道要做超分支持就得上大的架构改动。另一个架构问题是渲染分辨率与显示分辨率的解耦。传统管线里渲染目标和后处理链都是按实际输出分辨率来的支持超分之后你应该把整个渲染分辨率单独提成配置光照和阴影的级联数量要跟着渲染分辨率走不然超分会把低分辨率下的锯齿也一并放大。7. 工程实践中的坑资源生命周期、合批策略与调试手段最后这一节我想分享一些架构之外的实战经验。这些经验不是从API文档里能学到的大多数是踩坑踩出来的。7.1 Draw Call的真相与合批策略先说Draw Call。很多人一听到Draw Call就色变好像它是性能毒瘤。其实DrawCall的代价在现代API下已经大幅降低了尤其在DX12和Vulkan里一次DrawCall开销可能只有几十个纳秒。真正贵的还是状态切换和资源绑定。所以合批策略的核心目标不是减少DrawCall数量而是减少状态切换次数。合批有三种常见方式静态合批Static Batching适用于不动且材质相同的物体动态合批Dynamic Batching适用于小物体但动态合批会让物体变成CPU侧处理顶点数有上限一多就白搭实例化Instancing是游戏里最实用的适合大量材质相同的物体比如草地、树林、同屏敌人。架构上要支持合批材质的参数必须能放进一个Buffer交给GPU采样而不是为每个物体单独绑定材质常量。7.2 资源生命周期引用计数之外的麻烦渲染系统和资源管理系统的交互比大多数人想的要复杂。纹理、Mesh、Shader、材质这些资源分布在CPU和GPU两侧生命周期不一致。最容易出问题的情况CPU侧的加载线程把一张纹理丢进渲染队列但在渲染线程使用它之前CPU侧引用已经释放了GPU侧还在采样结果就是花屏或崩溃。我常用的办法是延迟N帧释放FrameDelayedDelete。所有GPU资源在CPU侧引用计数归零后不立即销毁而是扔进一个待销毁队列等到渲染线程确认为止。另一个办法是资源版本化修改一张纹理时不是改原来的GPU资源而是创建新版资源让已经提交的绘制命令引用旧版资源等旧版资源不再被引用后才能回收。这套机制是渲染系统稳定性的基石省不掉的。7.3 渲染调试从帧捕获到性能分析最后聊聊怎么调试渲染系统。渲染调试和普通逻辑调试完全不同——你没法在GPU里打断点所以依赖的是帧捕获工具和GPU性能分析器。RenderDoc、PIXWindows/Xbox、NSightNVIDIA、Xcode GPU Frame Debugger这些工具每个平台都有对应帧捕获的流程基本都是捕捉一帧查看DrawCall列表、资源绑定、Shader、渲染目标内容定位画面异常出在哪一步。性能分析层面的建议是不要把性能问题全押在美术身上。架构上一定要预留性能剖析的接口比如GPU时间戳Query、每个Pass的耗时统计。我自己找性能问题时的固定套路先把引擎跑在Release模式下打开GPU时间戳统计看哪个Pass占比最高然后针对那个Pass做减半测试——把它的负载减一半看总帧率变化。如果减半后帧率大幅提升瓶颈就在这个Pass如果帧率没怎么变那这Pass就不是瓶颈别浪费时间在它上面折腾。我在实际项目里的体会是渲染系统架构最需要的是合理分层、明确职责、预留扩展。架构的好不好不看它用了多新的技术而看项目做到后期加一个新功能比如新的后处理、新的阴影方案时你要不要动全局代码。需要动说明分层和抽象就是失败的只需要加一个小模块那这个架构就撑得住。渲染系统没有银弹但好的架构能让你在性能大坑出现之前先看到问题在哪里。这一篇算是渲染系统架构的地基篇后续如果有机会我想再把光照系统、GPU Driven渲染、体积云和大气散射这些具体方向继续拆开讲那些才是真正让画面出效果的部分。