ARTICLE DETAIL

资讯详情

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

游戏引擎渲染系统架构设计:从RHI抽象到多线程渲染的工程实践

游戏引擎渲染系统架构设计:从RHI抽象到多线程渲染的工程实践 1. 渲染系统在游戏引擎中的定位与整体设计思路聊到游戏引擎架构渲染系统永远是那个最绕不开、也最容易被神化的模块。很多刚入行的朋友一提到渲染脑子里第一反应就是“写Shader”觉得只要把光照模型调好、把后处理堆上去画面就能起飞。但真正在引擎层面做过渲染架构的人都知道Shader只是冰山露出水面的那一角水面之下是资源管理、管线状态、跨平台抽象、多线程提交这一整套庞大而精密的工程体系。这一篇我就从架构设计的角度把渲染系统从顶层思路到底层实现拆开来讲尽量把“为什么这么设计”说透而不是只告诉你“有这么个东西”。渲染系统要解决的核心问题其实可以用一句话概括把场景数据高效、正确地转换成屏幕上的像素。听起来简单但“高效”和“正确”这两个词背后藏着无数的取舍。高效意味着你要考虑CPU和GPU的负载均衡、Draw Call的合并、状态的切换开销、内存带宽的占用正确意味着你要处理不同硬件的特性差异、精度问题、同步问题、资源生命周期问题。一个成熟的渲染系统本质上是在这两者之间不断寻找最优解。从整体架构上看现代游戏引擎的渲染系统通常分为几个层次。最上层是场景层负责描述“有什么要画”包括可见性剔除、渲染队列组织、材质与网格的绑定关系。中间是渲染管线层负责定义“怎么画”包括各种Pass的组织、渲染目标的切换、后处理的串联。再往下是RHI层Render Hardware Interface渲染硬件接口负责屏蔽不同图形API的差异把上层的抽象指令翻译成具体API调用。最底层就是驱动和GPU硬件这一层引擎基本不碰但必须理解它的行为特性。为什么要做这样的分层核心原因是解耦。场景层不应该关心你用的是哪套图形API管线层也不应该关心具体某个Mesh的顶点数据存在哪块显存里。这种解耦带来的直接好处是换图形API的时候只需要重写RHI层调整渲染效果的时候只需要改管线层优化剔除逻辑的时候只需要动场景层。如果没有这层抽象代码会迅速变成一团互相纠缠的泥球改一处崩三处。这里我要特别强调一下RHI层的设计哲学。很多人觉得RHI就是个简单的函数转发把DrawIndexed包一层就完事了。但实际上好的RHI设计要考虑的东西非常多。比如资源状态的跟踪在D3D12和Vulkan这类现代API里资源的状态转换是需要显式管理的RHI层必须帮上层处理好这些细节否则上层代码会变得极其臃肿。再比如命令缓冲的抽象不同API的命令提交模型差异很大RHI需要提供一套统一的模型让上层能够以一致的方式组织渲染命令。还有一个经常被忽视的点是线程模型。现代引擎的渲染系统几乎都是多线程的主线程负责逻辑更新和场景遍历渲染线程负责构建命令列表RHI线程负责实际提交。这三者之间的同步和通信是渲染架构里最容易出Bug的地方。我见过太多项目在这里翻车要么是资源在渲染线程还在用的时候被主线程释放了要么是命令列表的构建顺序和提交顺序不一致导致画面闪烁。这些问题的根源往往不是某个具体实现写错了而是架构设计阶段就没有把线程边界划清楚。从设计思路上讲我个人的经验是渲染系统的架构应该围绕“数据流”来设计而不是围绕“功能”来设计。什么意思呢就是说你不要想着“我要实现一个阴影功能所以加一个ShadowPass”而是要想“阴影需要哪些数据输入产生哪些数据输出这些数据在整个渲染流程中处于什么位置”。围绕数据流设计的好处是当你要加新功能的时候你只需要找到它应该插入的数据流节点而不需要去改动整个管线的结构。这种设计思路在面对需求频繁变化的项目时优势会非常明显。2. 渲染管线的核心组成与关键细节解析2.1 从应用阶段到光栅化的完整链路渲染管线这个词听起来很学术但拆开来看其实就是一条流水线数据从CPU端出发经过一系列加工最终变成GPU能理解的绘制指令然后GPU再把这些指令变成屏幕上的像素。这条流水线大致可以分为应用阶段、几何阶段和光栅化阶段每个阶段都有它独特的关注点和优化空间。应用阶段是CPU主导的主要工作包括可见性剔除、渲染状态设置、Draw Call提交。这个阶段最核心的优化目标是减少CPU开销。很多人一提到渲染优化就想到GPU但实际上在很多场景下CPU才是瓶颈。特别是当场景中有大量独立物体的时候每个物体一次Draw CallCPU光是在驱动层做状态验证和命令打包就能把帧率拖垮。所以应用阶段的关键技术就是批处理和实例化把多个物体的绘制合并成一次提交。几何阶段是GPU主导的包括顶点着色、曲面细分、几何着色、裁剪、屏幕映射等。这个阶段的核心是顶点处理顶点着色器的效率直接决定了GPU的顶点吞吐量。这里有个常见的误区很多人觉得顶点着色器很简单不就是把顶点从模型空间变换到裁剪空间吗但实际上顶点着色器里往往还要处理法线变换、切线空间计算、顶点动画、蒙皮等复杂逻辑。特别是蒙皮一个角色模型可能有几万个顶点每个顶点受四根骨骼影响这个计算量是相当可观的。光栅化阶段是把几何图元转换成片元的过程然后片元着色器负责计算每个像素的最终颜色。这个阶段的优化重点是减少过度绘制和提高缓存命中率。过度绘制是指同一个像素被多次着色虽然最终只有最后一次的结果可见但前面的计算全部浪费了。解决过度绘制的一个常用手段是早期深度测试在片元着色器执行之前就把被遮挡的片元丢弃掉。但早期深度测试能否生效取决于片元着色器有没有修改深度值如果Shader里写了discard或者修改了深度输出早期深度测试就会失效。2.2 RHI层的抽象设计与跨平台适配RHI层的设计是整个渲染架构里最考验工程能力的地方。它要在“抽象程度足够高”和“性能损耗足够小”之间走钢丝。抽象程度太高上层用起来舒服但底层可能为了兼容各种API而做出妥协导致性能损失抽象程度太低上层就要写大量平台相关代码维护成本飙升。我见过几种不同的RHI设计风格。一种是薄抽象基本上就是把各个API的函数名统一一下参数结构稍微包装一下上层还是能感觉到不同API的差异。这种设计的好处是性能损耗极小坏处是上层代码需要写很多#ifdef来区分平台。另一种是厚抽象RHI提供一套完全统一的资源模型和命令模型上层完全感知不到底层用的是哪个API。这种设计的好处是上层代码干净坏处是RHI层本身非常复杂而且可能在某些平台上无法充分利用硬件特性。我个人的倾向是中等抽象资源管理和命令提交做统一抽象但保留一些平台特有的扩展接口。比如纹理的创建、缓冲区的管理、管线状态的设置这些用统一接口但像Mesh Shader、光线追踪这类平台特有功能就通过扩展接口暴露上层按需使用。这样既保证了主体代码的跨平台性又不会因为过度抽象而丧失硬件特性。在具体实现上RHI层需要处理几个关键问题。第一个是资源生命周期管理GPU资源什么时候创建、什么时候销毁、什么时候可以安全地被GPU读取。这里最麻烦的是延迟销毁因为GPU是异步执行的你CPU端“释放”了一个资源GPU可能还在用。所以RHI层通常需要一个延迟销毁队列等GPU执行到某个同步点之后再真正释放。第二个是命令缓冲的管理现代API都支持多线程构建命令缓冲RHI需要提供一套机制让上层能够方便地并行录制命令。第三个是状态跟踪与验证在Debug模式下RHI应该能够检测出资源状态错误、管线状态不匹配等问题帮助开发者尽早发现Bug。2.3 Shader管理与编译流程Shader管理是渲染系统里另一个容易被低估的模块。很多人觉得Shader不就是写个文本文件然后编译一下吗但在实际项目中Shader的管理远比想象中复杂。首先是变体爆炸的问题一个材质可能支持多种光照模式、多种阴影质量、多种后处理开关这些组合起来就是几十上百个变体。如果每个变体都单独编译和存储包体大小会迅速膨胀。所以引擎通常需要一套Shader变体管理机制在运行时根据需要动态编译或者从预编译缓存中加载。Shader的编译流程也值得说道。通常分为离线编译和运行时编译两种。离线编译是在打包阶段就把Shader编译成目标平台的字节码运行时直接加载速度快但包体大。运行时编译是在游戏运行的时候才编译包体小但首次加载会有卡顿。很多引擎采用混合方案常用的Shader离线编译不常用的运行时编译并且把编译结果缓存起来下次启动就不用再编译了。这里有个实操中经常踩的坑Shader编译的线程安全。如果你在多个线程里同时编译Shader而编译器的某些全局状态没有做好隔离就可能导致编译结果错误甚至崩溃。我遇到过好几次因为Shader编译竞争导致画面异常的问题排查起来非常痛苦因为错误不是必现的跟线程调度时机有关。后来我们的做法是给Shader编译单独开一个线程所有编译请求都排队处理虽然牺牲了一点并行度但稳定性大大提升。3. 实操过程与核心环节实现3.1 搭建一个最小可用的渲染管线光讲理论容易飘我拿一个实际的最小渲染管线来举例把从初始化到出画面的完整流程走一遍。这个管线不追求效果只追求把架构跑通方便你理解各个模块是怎么串起来的。首先是设备初始化。这一步要创建RHI设备、交换链、命令队列。以D3D12为例你需要先创建ID3D12Device然后创建命令队列、命令分配器、命令列表再创建交换链和描述符堆。这些对象的创建顺序有依赖关系设备必须最先创建交换链依赖设备命令列表依赖命令分配器。在RHI层这些会被封装成统一的接口比如RHICreateDevice()、RHICreateSwapChain()、RHICreateCommandList()。然后是资源创建。你需要创建顶点缓冲区、索引缓冲区、纹理、常量缓冲区。顶点缓冲区和索引缓冲区用来存储几何数据纹理用来存储贴图常量缓冲区用来传递每帧变化的参数比如相机矩阵。在创建资源的时候要指定资源的用途和访问方式比如顶点缓冲区是VertexBuffer用途纹理是ShaderResource用途。这些信息在D3D12里对应D3D12_RESOURCE_STATES在Vulkan里对应VkImageLayoutRHI层需要把它们统一起来。接下来是管线状态对象PSO的创建。PSO描述了GPU如何执行绘制用哪个顶点着色器、哪个片元着色器、什么混合模式、什么深度测试模式、什么光栅化状态。在D3D12里PSO是一个不可变对象创建之后不能修改要改只能重新创建。这个设计的好处是驱动可以在创建PSO的时候做大量优化坏处是如果PSO种类太多创建和切换的开销会很大。所以实际项目中PSO的管理和缓存是一个专门的课题。最后是渲染循环。每一帧的流程大致是更新常量缓冲区、重置命令分配器、录制命令列表、关闭命令列表、提交到命令队列、Present。录制命令列表的时候要设置管线状态、设置根签名参数、设置顶点缓冲区和索引缓冲区、调用DrawIndexed。这些步骤在RHI层都有对应的封装上层只需要按顺序调用即可。3.2 渲染队列的组织与排序策略渲染队列的组织方式直接影响到渲染的正确性和效率。最朴素的做法是按物体在场景中的顺序依次绘制但这会带来两个问题一是状态切换频繁效率低下二是透明物体和不透明物体的绘制顺序有严格要求不能随便排。所以实际引擎中渲染队列通常按材质类型和深度来排序。不透明物体按材质分组相同材质的物体放在一起这样可以减少状态切换。透明物体则必须按从远到近的顺序绘制因为透明混合是不满足交换律的先画近的再画远的结果就错了。这个排序工作通常在应用阶段完成排序的结果决定了命令列表的录制顺序。这里有个细节值得展开排序的粒度。如果按单个物体排序排序开销会很大因为场景中可能有几万个物体。如果按材质排序又可能因为同一材质的物体分布在不同深度导致深度排序失效。常见的折中方案是两级排序先按材质分组组内再按深度排序。这样既减少了状态切换又保证了透明物体的绘制顺序。还有一个容易被忽视的点是渲染队列的并行构建。在多线程渲染架构中渲染队列的构建可以并行化主线程负责收集可见物体然后把物体分配到多个工作线程每个工作线程负责一部分物体的排序和命令录制。最后主渲染线程把所有命令列表按顺序合并提交。这种架构可以充分利用多核CPU但要注意工作线程之间的数据隔离避免竞争条件。3.3 后处理链的搭建与性能考量后处理是现代游戏画面不可或缺的一部分Bloom、景深、色调映射、抗锯齿这些效果都是通过后处理实现的。后处理链的搭建看起来简单就是一连串的全屏Pass但实际做起来有很多性能上的坑。第一个坑是渲染目标的切换开销。每个后处理Pass都需要一个输入纹理和一个输出纹理如果每个Pass都单独创建纹理显存占用会很大而且纹理切换本身也有开销。常见的优化是纹理池化预先分配一组纹理按需借用和归还避免频繁创建销毁。另一个优化是合并Pass把多个简单的后处理合并成一个Shader减少渲染目标切换次数。第二个坑是带宽瓶颈。后处理本质上是对全屏像素的读写操作分辨率越高带宽压力越大。在4K分辨率下一个简单的全屏Pass就要读写几十MB的数据。如果后处理链有七八个Pass带宽压力就非常可观了。所以后处理的设计要尽量减少Pass数量能合并的合并能降分辨率的降分辨率。比如Bloom效果通常会在降分辨率的纹理上做模糊最后再上采样合并这样能大幅减少带宽消耗。第三个坑是精度问题。后处理的中间结果通常需要更高的精度因为多次迭代计算会放大误差。如果中间纹理用8位精度很容易出现色带和精度损失。所以后处理的中间纹理通常用16位浮点甚至32位浮点但这又会增加带宽消耗。这里需要在精度和性能之间做权衡我的经验是颜色相关的中间结果用16位浮点亮度相关的用32位浮点最终输出用8位。4. 常见问题与排查技巧实录4.1 渲染异常问题速查表渲染系统的Bug排查是最考验经验的因为很多问题的表现相似但根因完全不同。我整理了一份常见问题速查表覆盖了大部分我在实际项目中遇到过的情况。问题表现可能原因排查方向画面全黑相机矩阵错误、渲染目标未绑定、Shader编译失败检查相机参数、验证渲染目标绑定、查看Shader编译日志画面闪烁资源竞争、命令列表提交顺序错误、双缓冲同步问题检查多线程资源访问、验证命令提交顺序、检查Present同步物体消失视锥剔除错误、深度测试失败、索引缓冲区越界关闭剔除测试、检查深度范围、验证索引数据颜色异常颜色空间不匹配、纹理格式错误、混合模式错误检查sRGB设置、验证纹理格式、检查混合状态性能骤降Draw Call过多、状态切换频繁、Shader变体编译统计Draw Call数量、分析状态切换次数、检查Shader编译时机画面撕裂垂直同步未开启、交换链缓冲数量不足开启垂直同步、增加交换链缓冲数量这张表只是起点实际排查的时候还需要结合具体的日志和调试工具。比如RenderDoc可以抓取一帧的完整渲染过程看到每个Draw Call的输入输出是排查渲染问题的利器。PIX和Nsight也是常用的GPU调试工具可以分析GPU的耗时分布和瓶颈位置。4.2 多线程渲染的同步陷阱多线程渲染是性能优化的利器但也是Bug的重灾区。我踩过的最多的坑就是资源生命周期管理。具体场景是这样的主线程决定销毁一个纹理调用RHI的销毁接口RHI把纹理标记为待销毁但此时渲染线程可能还在用这个纹理录制命令。如果RHI立即释放了纹理渲染线程就会访问到野指针导致崩溃或者画面异常。解决这个问题的标准做法是延迟销毁。RHI维护一个待销毁资源队列每次提交命令列表的时候把当前帧的待销毁资源标记一个帧号等GPU执行到该帧号之后的同步点时再真正释放这些资源。这样就能保证GPU不再使用这些资源之后才释放。实现上可以用帧计数器加围栏Fence来做每次Present之后检查围栏的完成值释放已经安全的资源。另一个常见的坑是命令列表的线程安全。D3D12的命令列表不是线程安全的多个线程不能同时往同一个命令列表里录制命令。所以每个线程必须有自己独立的命令列表最后再统一提交。但命令分配器的重置也有讲究必须等GPU执行完该分配器上的所有命令之后才能重置否则会破坏正在执行的命令。这个同步点通常也是通过围栏来管理的。4.3 Shader变体爆炸的应对策略Shader变体爆炸是每个中大型项目都会遇到的问题。一个材质可能因为不同的光照模式、不同的阴影质量、不同的平台特性而产生大量变体。如果不加控制变体数量可能达到几千甚至上万个导致编译时间极长、包体极大、运行时内存占用极高。应对策略有几个层面。第一个层面是变体裁剪在打包阶段根据项目的实际需求只编译用到的变体。比如项目不支持某种光照模式就把相关变体全部剔除。第二个层面是运行时动态编译对于不常用的变体不在打包时编译而是在运行时按需编译并且把编译结果缓存起来。第三个层面是变体合并通过把一些差异不大的变体合并成一个减少变体总数。比如把一些布尔开关改成运行时分支虽然会增加一点运行时开销但能大幅减少变体数量。这里有个实操经验变体裁剪一定要在项目早期就做。我见过太多项目在后期才发现变体数量失控这时候再去做裁剪工作量巨大而且容易遗漏。正确的做法是在Shader编写阶段就规划好变体的组织方式把变体维度控制在合理范围内并且建立自动化的变体统计和裁剪流程。4.4 跨平台渲染的兼容性处理跨平台是游戏引擎必须面对的问题不同平台的GPU架构、驱动行为、API特性都有差异。我总结了几条跨平台渲染的经验。第一条是不要依赖未定义行为。不同GPU对未定义行为的处理方式不同在A卡上正常的代码在N卡上可能就出问题。比如纹理采样时坐标超出范围有些GPU会返回边界值有些会返回黑色有些会返回随机值。所以Shader里一定要做好边界处理不要指望硬件帮你兜底。第二条是精度声明要明确。不同GPU对浮点精度的处理不同移动端GPU通常对精度更敏感。Shader里应该明确声明变量的精度比如mediump、highp避免因为精度问题导致画面差异。第三条是特性检测要运行时做。不要假设某个平台一定支持某个特性而是要在运行时查询设备能力根据查询结果选择不同的渲染路径。比如Mesh Shader不是所有GPU都支持引擎需要提供回退路径在不支持的设备上用传统的顶点管线。第四条是测试要覆盖目标平台。跨平台问题往往在开发机上发现不了必须在实际目标设备上测试。我建议在项目早期就建立多平台测试流程不要等到后期才做平台适配那时候改造成本会高很多。5. 渲染架构的演进方向与个人实践体会5.1 从传统管线到GPU驱动渲染传统渲染管线的瓶颈越来越明显特别是在Draw Call数量上。每个物体一次Draw CallCPU端的开销随着物体数量线性增长。虽然批处理和实例化能缓解这个问题但当场景复杂度达到一定程度时CPU仍然会成为瓶颈。所以业界开始探索GPU驱动渲染的方案把可见性剔除和Draw Call生成的工作从CPU转移到GPU。GPU驱动渲染的核心思路是把场景中所有物体的信息上传到GPU然后在Compute Shader里做视锥剔除和遮挡剔除把可见物体的Draw Call参数写入一个间接绘制缓冲区最后用ExecuteIndirect或者DrawIndirect一次性提交所有绘制。这样CPU只需要提交一次命令大大减少了CPU开销。这个方案听起来很美但实际落地有不少挑战。首先是GPU剔除的精度问题GPU做遮挡剔除需要用到上一帧的深度缓冲如果相机移动很快剔除结果可能不准确导致物体闪烁。其次是间接绘制的性能问题在某些GPU上间接绘制的开销比直接绘制还大特别是当间接绘制的参数需要从显存读取的时候。所以GPU驱动渲染不是银弹需要根据具体场景和硬件特性来决定是否采用。5.2 实时光线追踪对渲染架构的影响实时光线追踪是这几年渲染领域最大的变革。它改变了传统的光栅化管线用光线求交来代替光栅化能够实现更真实的光照效果。但对渲染架构来说光线追踪带来的不仅仅是多了一个渲染路径而是整个架构的调整。首先是加速结构的管理。光线追踪需要构建BVHBounding Volume Hierarchy加速结构这个结构的构建和更新是额外的开销。动态场景的BVH更新尤其麻烦因为物体移动之后BVH需要重建或者更新这个开销可能比渲染本身还大。所以引擎需要一套高效的BVH管理机制支持增量更新和异步构建。其次是着色器的组织方式。光线追踪的着色器模型和传统光栅化不同它使用Ray Generation、Intersection、Any Hit、Closest Hit、Miss等着色器这些着色器的组织和编译方式都需要引擎做适配。而且光线追踪的着色器通常需要访问整个场景的数据这对资源绑定和内存管理提出了新的要求。最后是混合渲染管线。目前大多数游戏采用的是光栅化加光线追踪的混合方案光栅化负责主体渲染光线追踪负责阴影、反射、全局光照等效果。这种混合方案对渲染架构的灵活性要求很高引擎需要能够方便地在光栅化和光线追踪之间切换和组合。5.3 我在渲染架构实践中的几点体会做了这么多年渲染架构有几个体会特别深。第一个是架构设计要留有余地。渲染技术发展很快今天的设计可能明年就过时了。所以架构要有足够的扩展性能够方便地接入新的渲染技术。比如RHI层的设计要考虑到未来可能出现的新的图形API管线层的设计要考虑到新的渲染效果的接入。第二个是性能优化要数据驱动。不要凭直觉去优化要用工具去测量。我见过太多人花大量时间优化了一个不是瓶颈的地方结果帧率纹丝不动。正确的做法是先用Profiler找到真正的瓶颈然后针对性地优化。CPU瓶颈就优化Draw Call和状态切换GPU瓶颈就优化Shader和带宽内存瓶颈就优化资源管理。第三个是稳定性比极致性能更重要。渲染系统的Bug往往是最难排查的因为它涉及CPU、GPU、驱动、硬件多个层面。一个偶现的画面闪烁可能花几天都找不到原因。所以在架构设计阶段就要考虑稳定性做好资源验证、状态检查、错误处理宁可牺牲一点性能也要保证稳定。第四个是跨平台适配要趁早。不要等到项目后期才考虑跨平台那时候改造成本极高。在架构设计阶段就要考虑不同平台的差异把平台相关的代码隔离在RHI层上层代码保持平台无关。同时要建立多平台的持续集成和测试流程尽早发现和解决平台兼容性问题。渲染系统架构是一个需要长期积累的领域没有捷径可走。每一个设计决策背后都有它的历史原因和适用场景理解这些原因比记住结论更重要。希望这篇内容能给正在做渲染架构或者准备深入这个方向的朋友一些参考少走一些我当年走过的弯路。
返回列表