ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

游戏引擎渲染系统架构设计:从分层架构到RHI抽象与性能优化

游戏引擎渲染系统架构设计:从分层架构到RHI抽象与性能优化 1. 渲染系统在游戏引擎中的定位与整体设计聊到游戏引擎架构渲染系统永远是那个最显眼、最复杂、也最容易被神话的模块。很多刚入行的朋友一提到渲染脑子里蹦出来的就是 Shader 怎么写、光照怎么算但真正在引擎层面做架构设计时你会发现 Shader 只是冰山露出水面的那一角。水面之下是资源管理、线程调度、图形 API 抽象、管线状态组织这一整套工程体系。这篇文章我就从架构师的视角把渲染系统从顶层设计到底层实现拆开来讲尽量说人话也尽量把“为什么这么设计”讲透。渲染系统在引擎里承担的核心职责其实就一句话把场景数据变成屏幕上的一帧画面。但这句话展开之后涉及的东西非常多。场景里可能有几万个物体每个物体有网格、材质、贴图、骨骼动画还有各种光源、阴影、后处理效果。渲染系统要在每帧 16.6 毫秒60 帧甚至 8.3 毫秒120 帧的时间预算内把这些东西组织好、提交给 GPU、并且保证画面正确。这中间任何一个环节设计得不好都会直接反映到帧率上。我在实际项目里见过太多这样的情况美术抱怨场景一复杂就掉帧程序查了半天发现是渲染线程和逻辑线程互相等待或者材质切换过于频繁导致 DrawCall 爆炸。这些问题的根源往往不在某一行 Shader 代码而在渲染系统的架构设计。所以理解渲染系统架构不只是为了面试时能背出几个名词而是为了在真正遇到性能瓶颈时知道该从哪里下手。这篇文章适合有一定引擎使用经验、想往引擎底层深入的同学也适合正在做自研引擎或者想理解商业引擎渲染流程的开发者。我会从整体设计思路讲起然后逐层拆解核心模块最后落到实操层面的管线搭建和问题排查。整个内容基于我在多个项目中的实践经验包括移动端和主机端的渲染管线搭建尽量给出可以直接参考的方案。1.1 渲染系统的核心需求与设计目标设计一个渲染系统首先要明确它要满足哪些需求。我把这些需求归纳为四个维度正确性、性能、可扩展性、可维护性。这四个词听起来很虚但每一个都对应着具体的架构决策。正确性是底线。画面不能出现明显的穿帮、闪烁、光照错误。这要求渲染系统对渲染顺序、深度测试、混合模式有严格的管理。比如透明物体必须在不透明物体之后渲染并且要按深度排序阴影贴图的分辨率和偏移量要合理设置否则会出现阴影 acne 或者 peter-panning。这些细节在架构层面就要有对应的机制去保证而不是靠每个使用引擎的人自己去踩坑。性能是游戏引擎渲染系统最核心的竞争力之一。同样的画面效果谁的帧率高、谁的功耗低谁就能在移动端或者主机端获得更好的体验。性能优化在架构层面主要体现为减少 CPU 到 GPU 的提交开销、合理利用 GPU 的并行能力、避免不必要的状态切换和资源绑定。举个具体的例子早期很多引擎每渲染一个物体就切换一次材质和 Shader导致 DrawCall 数量极高。现代引擎普遍采用材质排序和合批策略把使用相同 Shader 和贴图的物体放在一起渲染大幅降低提交开销。可扩展性决定了引擎能不能跟上图形技术的发展。今天流行 PBR明天可能流行某种新的全局光照方案如果渲染系统把这些效果硬编码在管线里每次加新特性都要大改那这个引擎就没法持续演进。好的架构会把渲染管线设计成可配置的阶段组合每个阶段负责一个明确的任务新增效果只需要插入新的阶段或者替换某个阶段的实现。可维护性则关系到团队协作的效率。渲染系统代码量巨大涉及图形 API、数学、资源管理等多个领域如果模块之间耦合严重一个人改一处代码可能影响整个团队。我在实际项目中会特别强调渲染系统的分层设计上层是场景管理和渲染策略中层是管线和阶段调度下层是 RHI 和图形 API 封装。每一层只依赖下一层的接口不跨层调用。1.2 渲染系统的分层架构与模块划分基于上面这些需求一个成熟的渲染系统通常会分成四层。我用一个实际项目的结构来举例说明。最上层是渲染策略层负责决定这一帧要渲染哪些内容、用什么方式渲染。比如前向渲染还是延迟渲染阴影用 CSM 还是单张阴影贴图后处理开哪些效果。这一层会根据画质设置、硬件能力、场景类型动态调整。比如在移动端低端机上自动关闭实时阴影改用烘焙光照在 PC 高端机上开启光线追踪反射。这一层的输出是一系列的渲染任务描述而不是具体的图形 API 调用。第二层是渲染管线层负责把渲染策略翻译成具体的渲染阶段序列。一个典型的管线可能包含深度预pass、阴影pass、不透明pass、透明pass、后处理pass。每个 pass 有自己的输入输出、渲染目标、状态配置。管线层还要处理 pass 之间的依赖关系比如后处理必须等所有几何渲染完成阴影pass必须在主pass之前。这一层是渲染系统的骨架决定了整个渲染流程的拓扑结构。第三层是渲染资源层管理所有 GPU 资源纹理、缓冲区、渲染目标、管线状态对象。这一层要处理资源的创建、销毁、复用、内存管理。比如渲染目标的池化避免每帧创建销毁造成内存碎片纹理的流式加载根据距离和可见性动态调整 mipmap 级别。资源层还要处理跨帧的资源复用比如阴影贴图可以每两帧更新一次减少 GPU 开销。最底层是RHIRender Hardware Interface层也就是渲染硬件接口。这一层把不同图形 APIDirectX、Vulkan、Metal、OpenGL ES的差异封装起来向上提供统一的接口。RHI 的设计质量直接影响引擎的可移植性和性能。比如 Vulkan 和 DirectX 12 都支持多线程命令提交RHI 就要提供对应的命令列表机制而 OpenGL ES 不支持多线程RHI 就要在内部做兼容处理。RHI 还要管理图形 API 的对象生命周期比如 Vulkan 的管线对象创建开销很大需要缓存和复用。这四层之间通过明确的接口通信上层不关心下层的实现细节。比如渲染策略层说“渲染一个带阴影的场景”管线层决定用哪些 pass资源层准备对应的渲染目标和纹理RHI 层最终调用图形 API。这种分层让每一层都可以独立演进比如把 RHI 从 DirectX 11 升级到 DirectX 12上层的管线逻辑基本不用改。2. 渲染管线的核心流程与关键技术点渲染管线是渲染系统的心脏它定义了从场景数据到最终画面的完整流程。不同引擎的管线设计差异很大但核心思路是相通的。这一章我会把管线拆成几个关键环节逐个讲清楚它们的作用、实现方式和优化技巧。2.1 从场景数据到渲染命令的转换过程每帧开始时渲染系统拿到的是场景的快照一堆带有变换矩阵、网格、材质的物体加上相机参数和光源信息。这些数据要经过一系列转换才能变成 GPU 能执行的渲染命令。第一步是可见性剔除。场景里可能有几万个物体但相机视野内可能只有几百个。剔除就是把不可见的物体排除掉减少后续处理的开销。常见的剔除方式有视锥剔除、遮挡剔除、距离剔除。视锥剔除最简单用相机的视锥体去测试物体的包围盒不相交就剔除。遮挡剔除更复杂需要判断物体是否被其他物体挡住通常用硬件遮挡查询或者软件光栅化来实现。距离剔除则根据物体到相机的距离超过阈值就剔除常用于小物件或者远景。我在实际项目里发现视锥剔除的实现细节对性能影响很大。如果用每帧遍历所有物体做包围盒测试CPU 开销会很高。优化方式是用空间划分结构比如八叉树或者 BVH把物体组织成层次结构剔除时从根节点开始快速排除大块不可见区域。另一个技巧是把静态物体和动态物体分开处理静态物体的可见性可以缓存只在相机移动较大时才重新计算。第二步是渲染排序。剔除之后剩下的物体需要按一定顺序提交给 GPU。排序的主要目的是减少状态切换和保证渲染正确性。不透明物体通常按材质和 Shader 排序把使用相同材质的物体放在一起减少纹理绑定和常量缓冲区更新。透明物体必须按从远到近排序保证混合结果正确。排序本身也有开销物体数量多时需要用高效的排序算法比如基数排序。第三步是渲染命令生成。每个物体需要生成对应的渲染命令包括设置管线状态、绑定顶点缓冲和索引缓冲、绑定材质常量、发起绘制调用。这一步是 CPU 开销的大头尤其是在 DrawCall 数量多的时候。优化方式包括把多个小网格合并成一个大网格减少 DrawCall使用实例化渲染一次绘制多个相同网格的物体把常量数据打包到统一缓冲区减少更新次数。这里有个经验值得分享很多新手会忽略渲染命令生成的线程安全问题。如果渲染线程和逻辑线程并行逻辑线程修改场景数据时渲染线程可能正在读取导致数据竞争。解决方案是双缓冲或者快照机制逻辑线程在帧开始时把场景数据复制一份给渲染线程之后逻辑线程的修改不影响当前帧的渲染。这个复制本身有开销所以通常只复制必要的渲染数据而不是整个场景。2.2 渲染目标管理与多Pass渲染的组织方式渲染目标Render Target是渲染管线里的核心资源。简单说它就是 GPU 可以往里面画图的画布。最终画面要输出到屏幕但中间过程通常需要多个渲染目标比如阴影贴图、法线缓冲、深度缓冲、后处理中间结果。管理渲染目标的关键是复用和池化。如果每帧都创建和销毁渲染目标内存分配和释放的开销会很大而且容易造成内存碎片。好的做法是维护一个渲染目标池按尺寸和格式分类。需要时从池里取用完还回去。池的大小根据项目需求设定通常保留最近几帧用过的渲染目标避免频繁创建。另一个重点是渲染目标的格式选择。格式决定了每个像素占用的内存和精度。比如阴影贴图通常用深度格式不需要颜色通道法线缓冲需要高精度通常用 16 位或 32 位浮点后处理中间结果可以用 8 位颜色节省带宽。格式选择要在精度和性能之间权衡。我在移动端项目里会特别小心因为移动 GPU 的带宽有限用错格式可能导致帧率直接掉一半。多 Pass 渲染的组织方式决定了管线的灵活性。最简单的做法是硬编码 Pass 顺序比如先阴影、再不透明、再透明、最后后处理。这种方式实现简单但扩展性差加一个新效果就要改管线代码。更好的做法是把 Pass 设计成可插拔的模块每个 Pass 声明自己的输入输出和依赖关系管线调度器根据依赖关系自动排序。这样新增 Pass 只需要注册到管线里不需要改调度逻辑。不过可插拔设计也有代价就是调度开销和调试复杂度。我在实际项目中会根据需求选择如果项目效果固定硬编码管线更简单高效如果项目需要频繁试验新效果可插拔管线更合适。还有一种折中方案是混合式核心 Pass 硬编码扩展 Pass 可插拔。2.3 渲染线程与主线程的并行协作机制现代游戏引擎普遍采用多线程架构渲染线程和主线程逻辑线程并行工作。这样做的原因是 CPU 和 GPU 的工作可以重叠当 GPU 在渲染上一帧时CPU 已经在准备下一帧的数据。理想情况下CPU 和 GPU 都满负荷工作帧率最大化。但多线程渲染也带来了复杂性。最大的问题是数据同步。主线程更新场景数据时渲染线程可能正在读取同一份数据。如果直接共享内存就会出现数据竞争导致画面闪烁或者崩溃。解决方案主要有两种双缓冲和命令队列。双缓冲的思路是维护两份场景数据主线程写一份渲染线程读另一份每帧交换。这样读写分离没有竞争。但双缓冲需要复制数据如果场景数据量大复制开销不可忽略。优化方式是只复制渲染需要的数据比如变换矩阵、材质参数而不是整个场景图。命令队列的思路是主线程把渲染命令写入队列渲染线程从队列读取并执行。命令队列的好处是解耦更彻底主线程不需要关心渲染线程的状态。但命令队列的设计要小心队列满了会阻塞主线程队列空了渲染线程会空闲。通常用环形缓冲区实现配合条件变量做同步。我在实际项目里还遇到过一个坑渲染线程和主线程的帧率不匹配。比如主线程跑 60 帧渲染线程只能跑 30 帧这时候如果主线程每帧都提交渲染命令渲染线程就会积压。解决方案是限制命令队列的长度主线程发现队列满时主动等待或者降低主线程的更新频率。这个策略要根据具体游戏的逻辑复杂度来调整。3. RHI 层的设计与图形 API 抽象实践RHI 是渲染系统里最接近硬件的部分也是最能体现引擎工程质量的地方。这一章我会讲 RHI 的设计原则、常见抽象方式以及在实际项目中如何平衡抽象和性能。3.1 RHI 的核心职责与抽象层次RHI 的核心职责是屏蔽不同图形 API 的差异向上提供统一的渲染接口。听起来简单但做起来非常考验设计功力。因为不同图形 API 的模型差异很大比如 DirectX 11 是状态机模型Vulkan 是命令缓冲区模型OpenGL 是全局状态模型。要把它们统一到一个接口下必须找到合适的抽象层次。抽象层次太高会丢失底层 API 的性能优势。比如 Vulkan 的多线程命令提交、显式内存管理如果 RHI 接口设计得太简单这些特性就用不上。抽象层次太低又会让上层代码依赖具体 API失去可移植性。我在实际项目里的经验是RHI 接口应该暴露图形 API 的通用概念比如设备、队列、命令列表、管线状态、资源但具体参数和用法可以按 API 能力分级。举个例子命令列表的抽象。DirectX 12 和 Vulkan 都支持多线程录制命令列表OpenGL ES 不支持。RHI 可以提供一个命令列表接口在支持的平台上直接映射到底层 API在不支持的平台上用单线程模拟。上层代码统一用命令列表录制渲染命令不需要关心底层是否真的多线程。另一个例子是资源绑定。DirectX 11 用槽位绑定Vulkan 用描述符集。RHI 可以抽象出“绑定组”的概念把一组资源打包绑定。在 DirectX 11 上绑定组展开成多个槽位在 Vulkan 上绑定组对应描述符集。这样上层代码只需要管理绑定组不需要关心底层绑定方式。3.2 跨平台渲染接口的设计与实现要点跨平台是 RHI 设计的主要挑战之一。不同平台的图形 API、驱动行为、硬件能力都有差异。我在做跨平台渲染时会重点关注以下几个方面。能力查询是第一步。不同 GPU 支持的特性不同比如是否支持几何着色器、计算着色器、纹理压缩格式、多重采样级别。RHI 要提供能力查询接口上层根据能力选择渲染路径。比如移动端可能不支持某些高级阴影技术就要有降级方案。资源格式的差异也很常见。比如某些平台不支持特定的纹理格式需要转换。RHI 可以在资源创建时做格式映射把不支持的格式转成最接近的 supported 格式。这个转换要尽量在离线阶段完成避免运行时开销。着色器编译是另一个痛点。不同平台用不同的着色器语言DirectX 用 HLSLVulkan 用 SPIR-VMetal 用 MSL。通常的做法是写一套跨平台着色器源码用工具链编译到各平台的目标格式。这个工具链的稳定性直接影响开发效率。我在项目里会尽量把着色器变体管理好避免编译爆炸。同步机制的差异也要处理。Vulkan 和 DirectX 12 需要显式同步比如栅栏、信号量、屏障。OpenGL 和 DirectX 11 是隐式同步。RHI 要提供统一的同步接口在显式同步平台上映射到底层原语在隐式同步平台上做空操作或者插入必要的屏障。3.3 图形 API 特性差异的兼容处理实际项目中图形 API 的特性差异会带来很多兼容问题。我整理了一个常见差异对照表方便大家参考。特性DirectX 11DirectX 12VulkanOpenGL ESMetal多线程命令提交不支持支持支持不支持支持显式内存管理不支持支持支持不支持部分支持描述符集槽位绑定描述符堆描述符集槽位绑定参数缓冲管线状态对象运行时组合预创建预创建运行时组合预创建计算着色器支持支持支持部分支持支持几何着色器支持支持支持不支持不支持处理这些差异的策略是能力分级 降级路径。引擎定义几个能力等级比如高端、中端、低端。每个等级对应一组渲染特性。运行时根据设备能力选择等级然后走对应的渲染路径。比如高端设备用延迟渲染 光线追踪中端设备用前向渲染 屏幕空间反射低端设备用简化光照 烘焙阴影。降级路径的设计要提前规划不能等到项目后期才补。我在项目初期就会和美术、策划沟通确定不同画质等级下的效果差异然后在渲染系统里预留降级开关。这样后期优化时只需要调整开关不需要重构管线。4. Shader 管理与渲染状态组织Shader 是渲染系统里最贴近效果的部分也是开发者最常打交道的。但 Shader 管理在引擎层面有很多工程问题比如变体管理、编译优化、状态组织。这一章我会重点讲这些容易被忽略但非常影响效率的内容。4.1 Shader 变体管理与编译优化策略Shader 变体是引擎开发里的一个经典难题。一个 Shader 可能有几十个宏开关每个开关组合产生一个变体。如果全部组合变体数量会爆炸。比如 10 个开关就是 1024 个变体编译时间和包体大小都受不了。解决变体爆炸的核心思路是按需编译 变体剔除。按需编译是指只编译实际用到的变体而不是预编译所有组合。引擎在运行时检测到某个变体被请求才触发编译。这样可以大幅减少编译数量但会带来运行时卡顿因为编译 Shader 可能耗时几百毫秒。变体剔除是指通过分析场景和材质提前排除不可能用到的变体。比如某个材质不支持某类光照对应的 Shader 变体就可以剔除。剔除可以在打包阶段做也可以在运行时做。打包阶段剔除更彻底但需要准确的分析工具运行时剔除更灵活但会增加运行时开销。我在实际项目里的做法是两者结合打包阶段做静态分析剔除明显不可能的组合运行时做动态缓存把用过的变体缓存起来避免重复编译。同时给 Shader 编译加异步支持编译在后台线程进行编译完成后替换占位 Shader。这样玩家几乎感觉不到卡顿。还有一个技巧是变体分组。把变体按功能分组比如基础光照组、阴影组、后处理组。每组独立编译和管理。这样修改某个功能时只需要重新编译对应的组不需要全量编译。这个策略在大型项目里能节省大量迭代时间。4.2 渲染状态对象的组织与切换开销优化渲染状态包括深度测试、混合模式、剔除模式、模板测试等。这些状态在图形 API 里通常打包成管线状态对象PSO。PSO 的创建和切换都有开销尤其是在 DirectX 12 和 Vulkan 里PSO 创建可能耗时几十毫秒。优化 PSO 管理的核心是预创建 缓存 排序。预创建是指在加载阶段就把常用的 PSO 创建好避免运行时创建。缓存是指把创建过的 PSO 存起来下次用相同状态时直接取。排序是指在渲染时按 PSO 排序把使用相同 PSO 的物体放在一起渲染减少切换次数。PSO 的数量也要控制。如果每个材质都创建一个 PSO数量可能上千内存和管理开销都很大。通常的做法是把 PSO 按状态组合分类相同状态组合的材质共享 PSO。比如所有不透明、双面、无混合的材质共享一个 PSO只是 Shader 和常量不同。我在项目里还遇到过一个坑PSO 的创建是线程安全的但某些驱动在创建 PSO 时会阻塞渲染线程。解决方案是把 PSO 创建放到单独的后台线程创建完成后通过队列通知渲染线程。这个机制在 Vulkan 上尤其重要因为 Vulkan 的 PSO 创建开销比 DirectX 12 更大。4.3 材质系统与 Shader 参数的绑定机制材质系统是连接美术资源和 Shader 的桥梁。美术在编辑器里调整材质参数比如颜色、粗糙度、金属度这些参数要在渲染时传给 Shader。参数绑定的效率直接影响渲染性能。常见的绑定方式有两种常量缓冲区和统一缓冲区。常量缓冲区在 DirectX 里叫 Constant Buffer在 Vulkan 里叫 Uniform Buffer。它的特点是容量小、更新快适合每帧或每物体变化的参数。统一缓冲区容量大适合不常变化的全局参数比如相机矩阵、光照参数。参数绑定的优化关键是减少更新次数。如果每个物体都更新一次常量缓冲区CPU 开销会很大。优化方式是把多个物体的参数打包到一个大缓冲区里用偏移量区分。这样只需要更新一次缓冲区GPU 根据偏移量读取对应参数。这个技术叫动态常量缓冲区或者实例化常量缓冲区。另一个优化是参数分组。把参数按更新频率分组每帧更新的放一组每物体更新的放一组每材质更新的放一组。这样更新时只需要更新变化的部分不需要全量更新。我在项目里会把相机参数、时间参数、全局光照参数放在每帧组把物体变换、材质参数放在每物体组把纹理绑定放在每材质组。材质系统的设计还要考虑美术的使用体验。参数命名要清晰默认值要合理预览要实时。我在项目里会做一个材质编辑器美术可以在里面调整参数并实时看到效果。编辑器通过引擎的材质接口更新参数渲染系统自动处理参数绑定和 Shader 变体切换。这样美术不需要关心底层实现只需要关注效果。5. 渲染系统性能分析与常见问题排查渲染系统的性能问题往往不是单一原因造成的而是多个环节叠加的结果。这一章我会分享一些实用的性能分析方法和常见问题的排查思路都是我在实际项目中踩过坑总结出来的。5.1 渲染性能瓶颈的定位方法定位渲染性能瓶颈第一步是确定是 CPU 瓶颈还是 GPU 瓶颈。方法很简单降低渲染分辨率如果帧率明显提升说明是 GPU 瓶颈如果帧率不变说明是 CPU 瓶颈。另一个方法是看 GPU 时间戳如果 GPU 时间接近帧时间说明 GPU 满载。确定瓶颈类型后再进一步细分。CPU 瓶颈通常来自 DrawCall 过多、状态切换频繁、资源更新开销大。GPU 瓶颈通常来自像素填充率不足、顶点处理过重、带宽受限。DrawCall 分析是最常用的 CPU 侧分析。引擎通常有统计面板显示每帧的 DrawCall 数量。如果 DrawCall 超过几千就要考虑合批优化。合批的方式包括静态合批把静态物体合并成一个大网格、动态合批运行时合并小网格、实例化一次绘制多个相同网格。每种方式有适用场景静态合批适合不动的场景物件实例化适合大量重复物体。GPU 时间线分析是 GPU 侧分析的核心工具。通过图形调试工具如 RenderDoc、PIX可以抓取一帧的 GPU 时间线看到每个 Pass 的耗时。如果某个 Pass 耗时异常就针对性地优化。比如阴影 Pass 耗时高可能是阴影贴图分辨率太大或者阴影投射物体太多后处理 Pass 耗时高可能是全屏效果太多或者分辨率太高。我在项目里会定期做性能回归测试每次提交代码后自动跑一遍性能测试场景记录帧率和各 Pass 耗时。如果发现性能下降就对比上一次的数据快速定位是哪个改动导致的。这个机制在团队协作里非常有用避免性能问题积累到后期才爆发。5.2 常见渲染问题的排查与解决思路渲染问题排查是每个渲染程序员的家常便饭。我整理了一个常见问题速查表覆盖了大部分日常遇到的问题。问题现象可能原因排查方法解决方案画面闪烁深度冲突、双缓冲不同步检查深度测试和深度写入调整深度偏移、启用深度预pass物体消失视锥剔除错误、包围盒不准关闭剔除看是否恢复修正包围盒、调整剔除阈值阴影锯齿阴影贴图分辨率低、偏移不当提高分辨率看是否改善增大阴影贴图、调整偏移和滤波透明物体排序错误排序算法问题、深度写入冲突检查透明物体排序列表修正排序、关闭透明物体深度写入性能突然下降资源泄漏、状态切换增加对比前后帧的统计信息修复泄漏、优化状态排序画面偏色颜色空间不一致、伽马校正错误检查渲染目标和输出格式统一颜色空间、修正伽马校正排查渲染问题的核心思路是二分法。把渲染流程分成几段逐段禁用看问题是否消失。比如怀疑是后处理导致的就关闭后处理看画面是否正常。如果正常说明问题在后处理如果不正常继续往前找。这个方法虽然笨但非常有效。另一个技巧是可视化调试。把中间结果直接输出到屏幕比如法线缓冲、深度缓冲、阴影贴图。这样能直观看到数据是否正确。我在项目里会做一个调试视图系统按快捷键切换不同的调试输出。这个系统在排查问题时能节省大量时间。5.3 移动端与主机端的渲染优化经验移动端和主机端的渲染优化差异很大我分别说一下。移动端的特点是带宽有限、GPU 架构特殊、发热降频。优化重点是减少带宽占用和 GPU 负载。具体措施包括使用压缩纹理格式如 ASTC、ETC2减少纹理内存和带宽使用低精度格式如 16 位浮点存储中间结果避免过多的全屏后处理因为全屏操作消耗带宽使用基于瓦片的渲染优化利用移动 GPU 的片上内存。移动端还有一个坑是发热降频。手机长时间高负载运行会发热GPU 降频后帧率下降。解决方案是动态调整画质比如检测到温度升高时降低分辨率或关闭某些效果。这个策略要平衡画质和流畅度通常给玩家一个选项让他们自己选择。主机端的特点是硬件固定、性能可预测、支持高级特性。优化重点是充分利用硬件特性比如异步计算、光线追踪、可变速率着色。主机端的优化可以更激进因为不需要考虑太多硬件兼容性。我在主机项目里会大量使用计算着色器做后处理利用异步计算重叠渲染和计算任务。主机端还有一个优势是可以深度定制管线。比如针对特定游戏的渲染需求定制一套专用的管线去掉通用引擎里不需要的部分。这样能榨取更多性能。但这个做法牺牲了通用性只适合项目后期优化阶段。6. 渲染系统的扩展与未来演进方向渲染系统不是一成不变的随着硬件和图形技术的发展它也在不断演进。这一章我会聊一些扩展方向和演进趋势以及在实际项目中如何为未来预留空间。6.1 可扩展渲染管线的设计模式可扩展性是渲染系统架构设计的重要目标。我在前面提到过可插拔 Pass 的设计这里再展开讲一下具体实现。可插拔 Pass 的核心是接口标准化。每个 Pass 实现统一的接口声明自己的输入输出、依赖关系、执行条件。管线调度器根据这些声明自动组织 Pass 的执行顺序。这样新增 Pass 只需要实现接口并注册不需要修改调度器。接口设计要考虑几个关键点。输入输出要明确比如一个 Pass 需要深度缓冲作为输入输出到颜色缓冲。依赖关系要声明比如后处理 Pass 依赖所有几何 Pass 完成。执行条件要支持比如某个 Pass 只在特定画质等级下执行。资源生命周期要管理比如临时渲染目标在 Pass 结束后释放。我在项目里实现过一个基于有向无环图的管线调度器。每个 Pass 是图中的一个节点依赖关系是边。调度器对图做拓扑排序得到执行顺序。如果图中有环说明依赖关系有误调度器报错。这个设计让管线的组织非常灵活新增效果只需要加节点和边。不过可插拔设计也有代价就是调试复杂度增加。Pass 多了之后很难直观看出整个管线的流程。解决方案是提供一个管线可视化工具把 Pass 和依赖关系画出来。这个工具在排查管线问题时非常有用。6.2 新兴图形技术对渲染架构的影响图形技术在快速发展一些新技术正在改变渲染系统的架构设计。我挑几个有代表性的说一下。Mesh Shader是近年来的一个重要技术。它把传统的顶点处理管线替换成更灵活的任务着色器和网格着色器。Mesh Shader 可以直接处理网格簇不需要传统的顶点缓冲和索引缓冲。这对渲染架构的影响是深远的传统的顶点输入装配阶段可能被淘汰渲染系统需要重新设计几何处理流程。目前 Mesh Shader 主要在高端 PC 和主机上支持移动端还在跟进。光线追踪是另一个重要方向。硬件光线追踪让实时光追成为可能但它的渲染流程和传统光栅化差异很大。渲染系统需要同时支持光栅化和光追两条路径并且要处理两者的混合比如光栅化渲染主画面光追渲染反射和阴影。这对管线的组织提出了新要求需要更灵活的 Pass 调度和资源管理。可变速率着色允许在同一帧内对不同区域使用不同的着色速率。比如画面中心用全速率边缘用半速率。这个技术可以大幅降低 GPU 负载但需要渲染系统支持按区域配置着色速率。架构上要增加着色速率的管理和调度。这些新技术对渲染架构的共同要求是更灵活的管线组织和更细粒度的资源管理。传统的固定管线越来越难适应可配置、可扩展的管线设计会成为主流。6.3 从项目实践看渲染架构的取舍最后聊一下实际项目中的取舍。渲染架构设计没有银弹每个决策都有代价。我分享几个我在项目中做过的取舍。通用性 vs 专用性。通用引擎的渲染系统要支持各种游戏类型设计上会偏保守保留很多可配置项。专用引擎可以针对特定游戏优化去掉不需要的部分性能更好但复用性差。我在项目里的做法是核心管线通用效果层专用。核心管线处理几何、光照、阴影这些通用需求效果层根据游戏类型定制比如卡通渲染、写实渲染。画质 vs 性能。这是永恒的取舍。高端设备可以开高画质低端设备必须降画质。关键是降级要平滑不能让玩家感觉到明显的画质断层。我在项目里会做多级画质预设每级预设对应一组渲染特性玩家可以根据设备性能选择。同时提供自定义选项让玩家微调。开发效率 vs 运行效率。有些架构设计开发效率高但运行效率低比如过度抽象导致运行时开销大。有些设计运行效率高但开发效率低比如硬编码管线。我在项目里的做法是核心路径追求运行效率工具和编辑器追求开发效率。核心路径的代码要精简、直接避免不必要的抽象工具和编辑器可以用更高级的抽象提高开发速度。渲染系统的架构设计是一个持续迭代的过程。没有一开始就完美的架构都是在项目中不断调整和优化出来的。重要的是理解每个设计决策背后的原因知道在什么场景下用什么方案。希望这篇文章能给正在做渲染系统或者想深入理解渲染架构的朋友一些参考。
返回列表