ARTICLE DETAIL

资讯详情

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

游戏引擎渲染系统架构设计:RHI、帧图与多线程并行实践

游戏引擎渲染系统架构设计:RHI、帧图与多线程并行实践 写这篇的时候正好有同行在群里问“引擎渲染这块到底该怎么分层设计”我第一反应就是这问题其实比“用什么引擎”更值得聊透。游戏引擎的渲染系统架构说到底就是在回答一件事——怎么把一堆逻辑数据场景、物件、灯光、材质高效可靠地变成屏幕上的像素同时不让你的游戏线程和渲染线程互相打架。尤其这两年引擎规模越做越大移动端、主机、PC 多平台并行渲染层只要架构设计得不够干净后面每一个新渲染特性几乎都会变成重构的导火索。这篇是“游戏引擎架构深度解析”系列的第二篇上一篇聊了引擎整体的模块划分和驱动框架这篇专门把渲染系统拉出来解剖。它的受众不限于图形程序员——只要你打算深入一个渲染引擎的内部或者想自己搭一套可扩展的渲染骨架哪怕你目前只有游戏逻辑层开发经验这篇文章也能帮你建立一张完整的渲染架构地图。我会尽量聊“设计思路为什么是这样”而不是背八股比如为什么要有 RHI渲染硬件接口即 Render Hardware Interface这层抽象为什么帧图Frame Graph能救你于各种资源冲突的水火以及你自己改架构时最容易翻车的几个细节。1. 渲染系统在引擎里的位置——先想清楚边界1.1 渲染系统管的和不管的很多新手一上来就急着写 Renderer结果写成了一锅粥物理系统调用它动画系统调用它UI 也往里面塞代码……最后渲染线程和逻辑线程耦合得解不开。在成熟引擎里渲染系统通常只管三块场景数据收集与剔除、渲染资源管理、GPU 命令生成与提交。换句话说它不直接处理动画采样、物理碰撞、逻辑事件那些系统只负责把“可视信息”写进渲染系统约定的数据接口里比如实例化数据数组、变换矩阵数组、光照列表。渲染系统在下一次帧循环开始时再去消费这些数据。我见过很多自制引擎的通病就是没画清这条线。例如物理系统需要给渲染系统提供刚体姿态正常情况下物理系统每帧更新完刚体数组后把数据拷贝一份到渲染侧可见的 buffer 里就够了不需要去调用渲染系统的公共接口。这样做的核心目的是解耦频率和节奏物理以固定步长 tick渲染却跟显示器的垂直同步走两者节奏天然不一致。通过独立 buffer 作为共享接口各自节奏互不拖累。1.2 渲染引擎与游戏引擎的边界划分游戏引擎里通常有这些子系统场景管理、动画、物理、音频、网络、脚本、渲染。渲染系统往往是最大也最复杂的一块但它依然只是一个子系统。它的边界就是“输入数据 输出图像”内部可以无限复杂但对外接口必须稳定。这里分享一个我自己从实操中提炼出的设计原则渲染系统对外暴露的应该是渲染数据对象Render Proxy而不是引擎内部组件对象。什么意思比如场景里有一个StaticMeshActor它属于游戏逻辑层。渲染系统不做任何与之相关的业务判断只拿它的变换矩阵、网格资源、材质属性来填充自己的渲染代理。这样即使将来逻辑层把组件模型从 Actor 换成 ECS或者从继承体系换成组合体系渲染系统都可以不受影响地稳定运行。这套边界带来的直接好处是你的渲染系统可独立测试。把场景数据 dump 出来丢进一个纯离线的渲染测试程序里跑还能输出图像和性能数据。这种可测试性对后面做回归验证有决定性价值。2. 渲染系统的整体分层——从场景数据到屏幕像素2.1 渲染前端与渲染后端渲染系统可以粗略分成前端和后端。前端负责消费场景数据剔除不可见的物体按渲染顺序组织可渲染对象生成包含绘制命令的帧数据后端负责把这些命令翻译成具体图形 API 的调用处理好管线状态切换、资源绑定向 GPU 提交命令并执行。为什么要分前后端当年我只做一个平台时觉得这个分层多余后来同时适配 DirectX 11 和 OpenGL ES 3.0 时这个分层的价值立刻显现出来——前端的剔除、排序、批次合并在所有平台上完全一致平台相关的部分被收进后端一个比较薄的适配层换一个图形 API 只需要重写两三千行后端适配代码而不用动整个渲染逻辑。前端的主要工作量包括可见性剔除包括视锥剔除、遮挡剔除、预计算遮挡剔除数据的应用。排序包括按材质、距离、渲染队列Render Queue等维度排序目的是稳定绘制状态。渲染对象批次整理把同网格同材质的小对象合并成大批次提交。后端的工作量核心是管理图形 API 对象比如管线状态对象PSO、描述符堆、帧缓冲。维护渲染目标Render Target的创建、切换、生命周期。提交渲染命令执行 GPU 同步。2.2 渲染线程与主线程的并行模型自成一派的渲染架构里最常见的节奏是“主线程运行游戏逻辑渲染线程消费主线程产出的渲染数据”。两个线程之间通过线程安全的任务队列或者带有帧序号的数据 Buffer 来传递数据。帧外缓冲Frame Buffer机制是其中最关键的设计。常见做法是维护 2 到 3 帧的环形缓冲主线程写入第 N 帧数据渲染线程读取第 N-1 帧数据中间用帧序号来保护同步。直接好处就是主线程和渲染线程之间的锁竞争概率被压到极低渲染线程永远不需要等待主线程计算每帧数据可以提前开始渲染上一帧。这个方案的代价是渲染线程比主线程慢一帧甚至更多输入延迟略微上升。可对比电竞类游戏和高互动性的玩法需求40 ms 内的输入延迟多数可以接受但如果做节奏极快的玩法则要考虑外推或直接让主线程参与渲染工作。注意这里不建议用“渲染线程一帧、主线程一帧、互相等待”的乒乓模式实测下来它的帧时间波动极大容易造成卡顿感。3. 核心模块拆解——渲染架构里最值得研究的四个部分3.1 RHI渲染硬件接口抽象层如果只允许你从渲染系统里挑一个模块来做重点设计我的答案是 RHI而不是 GPU 资源管理或者管线状态系统。因为 RHI 决定你的引擎能走到多远的平台边界。所谓 RHI就是把 Vulkan、DirectX 12、Metal、OpenGL 等所有图形 API 统一成一套抽象接口。它在概念上跟驱动层差不多负责创建资源、提交命令、管理同步并且最大程度消除平台差异。例如所有图形 API 都有管线状态的概念但 DirectX 12 需要你显式创建 PSOOpenGL 则是在调用时动态地组合状态。RHI 得在这两者之间找一个足以覆盖所有 API 的统一模型。实现 RHI 时有几个值得反复确认的设计选择资源命名纹理、Buffer、采样器等资源在各平台上的句柄模型不同。统一用引擎侧句柄并维护平台资源对象池避免句柄膨胀。内存分配图形 API 需要你自行管理显存。RHI 层建议提供资源生命周期标记动态、静态、流动不同标记走不同分配策略才能兼顾效率和碎片可控。同步原语DirectX 12 的 Fence 和 Vulkan 的 Semaphore 语义不一致RHI 层要把同步事件抽象成“等待一个 GPU 时间点”和“触发一个 GPU 时间点”而不是暴露具体平台对象。拿引擎适配新平台举例虽然理论上 RHI 做薄就行了但现实没有这么简单。不同 API 的 texture 格式布局不同统一格式前尤其要注意深度纹理和存储纹理的处理。以我的实测经验RHI 层宁可稍微冗余一点也不要为了追求极简而砍掉平台特殊能力例如固有异步计算队列、混合队列等能力。后期做高性能特性时这些能力几乎是必然要用的。3.2 帧图Frame Graph让资源生命周期和渲染顺序自动可验证老一辈引擎包括早期的 UE、Unity 等的资源生命周期管理高度依赖手动 EnsureResource、手动释放、手动标记依赖这带来的后果是每新增一个渲染 Pass就要到处查资源该什么时候创建、什么时候释放到后期几乎人人自危。而帧图架构则是把这个过程显式化、自动化。帧图的作用可以理解为“描述一帧内所有渲染 Pass 和它们对资源的读、写、创建、删除依赖”。它把渲染过程建模成一个图Pass 是节点资源是边。引擎拿到这张图之后可以做四件自己手工做容易出错的事资源生命周期自动计算某个 RenderTarget 只有 Pass A 写、Pass B 读那引擎就知道它应该从 Pass A 开始存活在 Pass B 结束后可以回收。自动插入资源屏障Barrier在 Pass 之间的过渡处根据资源读写方式生成正确的状态转换。内存复用互不重叠的资源可以共享底层显存降低显存峰值。Pass 裁剪与合并如果某个 Pass 的结果没有消费者可以直接从执行序列里裁剪掉。实现帧图时最需要花心思的是虚拟资源Virtual Resource设计。你不能让每个 Pass 直接引用一个实际纹理对象因为那样顺序一变资源句柄就全乱了。最靠谱的方案是Pass 声明自己需要的资源描述大小、格式、用途帧图负责解析描述、分配实际资源再把实际资源句柄发给 Pass。听起来像依赖注入没错就是 IoC 思想在渲染架构中的应用。帧图的缺点也很明显它强制你把一帧所有的 Pass 全部列出来很难支持完全动态的任意识图结构。不过实操中多数游戏引擎的 Pass 结构相对固定所以这一限制可以接受。3.3 资源管理和 GPU 内存控制渲染资源管理中最常见的错误是没有分层管理 GPU 内存。很多引擎用一个大 Pool 一装到底结果就是纹理、网格、动态 Buffer、临时渲染目标全挤在一起运行时碎片问题层出不穷。成熟一点的方案是按资源类型和生命周期拆成多个池静态资源池网格、纹理、着色器等创建后几乎不再变化的资源通常常驻显存。动态数据池每帧更新的骨骼数据、顶点动态数据、草、粒子的粒子 Buffer。这个池要做环形更新结构避免每帧重新分配。渲染目标池临时渲染目标如果放任分配你会立刻看到显存峰值飙升。合理的做法是优先复用帧图里的虚拟资源再检索渲染目标池中是否已有同尺寸同格式的资源可用。还有一个小技巧可能大多数文档里不太强调显存分配要避免跨系统内存拷贝。如果频繁用 CPU 写数据再传 GPU请务必保证 CPU 内存的布局与 GPU 期望的连续布局一致。自作聪明地先按结构体数组排布后再转成数组结构体在高频更新场景中会白费大量性能。3.4 着色器系统与管线状态管理很多工程把着色器编译、变体管理、反射信息解析混在渲染线程里结果运行时加载时断时续。着色器系统还需要单独分层离线或半离线编译平台上编译好的着色器字节码可以作为缓存提交到内容管线里运行时拿到的是已编译过的二进制而不是逼迫用户在进游戏时现场编译。变体管理一个材质的阴影变体、平台变体、质量级变体非常多。不要用“每个变体生成一个新的着色器文件”这种粗暴方案风险在于变体爆炸。可以用宏定义交叉生成并按“关键字组合去重”的方式缓存到哈希表里组合数量尽量控制在几百以内。反射查询着色器的输入布局、常量参数布局等需要引擎反射机制支持。合并常量 Buffer把各 Pass 都需要的全局参数时间、相机矩阵、光照参数放进全局常量区逐对象的模型矩阵、材质颜色放进逐对象区这样做状态切换少驱动效率高很多。管线状态管理则是对标 RHI把各种图元拓扑、混合模式、深度状态、着色器组合包装成一个不可变 PSO。这个“不可变”是极其重要的设计——一旦创建并放入查找缓存之后找到相同组合直接复用绝不要在现场动态修改状态。如果你把 PSO 当可变对象用那缓冲区更新和状态切换的损耗会瞬间吃掉你了不起的性能预算。4. 实操过程——拆解一帧渲染数据的流动4.1 一帧渲染数据的前半生收集、剔除、排序我用一个简化但足够真实的流程来说明一个实际渲染系统在每帧里处理什么。假设有一个主线程、一个渲染线程主线程往前跑一帧第一主线程遍历场景里的所有渲染代理这些代理由场景中注册的各种组件创建。此处我强调一下不要在本阶段里去动态创建渲染数据结构因为那样会造成大量临时分配。比较靠谱的做法是渲染代理池的扩容尽量发生在加载场景阶段运行时基本只复用池内的槽位。第二可见性剔除。视锥剔除肯定要做用 SIMD 加速效果极好。剔除结果写进一个可持久化的“可见集合Visible Set”数组里每组数据包含网格句柄、材质句柄、包围盒、距离桶、矩阵索引。这一步的关键不是算法多高深而是剔除结果千万别用std::vector重度动态扩展。我在工程里预分配 65536 个 item 的容量实时场景基本绕开运行时分配。第三排序和分批。按“材质排序键 网格排序键 距离排序键”联合排序把相同渲染状态的物体聚集成批次。这里需要特别注意的是不要在同一帧里既想按材质聚合又想按距离从前到后严格排序两者通常是冲突的得根据项目需求确定哪个优先。以半透明物体为例必须严格从后往前否则混合结果出错不透明物体则尽可能按材质排序以减少状态切换。4.2 渲染命令的生成与提交主线程完成收集之后生成的是“渲染命令”的前缀部分哪些物体要画、用什么材质、在哪个渲染 Pass。把所有信息线性写入一个命令 Buffer。命令 Buffer 里每项是紧凑的字节块各 Pass 自带解析器。为什么不直接写成结构体内的函数调用因为命令行需支持跨线程传递和任意组合排序。渲染线程读取这一帧的命令 Buffer 后开始第二阶段针对每个 Pass根据当前的渲染目标、深度状态、管线状态向 RHI 提交真正的图形 API 命令。这个阶段还要处理全局参数提交例如相机矩阵、光照参数等最好全部写进一个统一的全局常量 Buffer并且只在上一次我提到的那几种全局状态变化时更新避免每帧重复上传相同数据。4.3 延迟渲染/前向渲染/混合渲染的架构取舍架构层面还有一个绕不开的决定你的渲染管线的照明模型是前向、延迟、还是两者混合前向渲染直观、资源开销低适合移动平台但动态光源数量受限。延迟渲染则把光照计算推迟到屏幕空间能容纳成百上千个光源且材质复杂度和几何复杂度相对解耦缺点是带宽要求高且 MSAA 支持不友好。混合渲染则根据物体类型走不同路径。从架构角度我建议把“光照模型”和“渲染管线结构”分离开来。不要让某个 Pass 内部逻辑里面写满一堆光照计算的细节光照计算的实现要给成独立的可替换模块。例如你可以设计一个 LightAccumulator 接口延迟模式下读取 GBuffer前向模式下在同一个 Pass 内直接计算如果是混合渲染它可以先输出基础颜色再叠加光照。这样做的好处是白天用前向调静态烘焙光照夜晚切延迟来跑一堆动态光两种光照模式下共用场景收集和剔除管线改动局限于最终着色器阶段。4.4 延迟渲染与 G-Buffer 资源规划如果采用延迟渲染G-Buffer 的好处能够大幅简化动态光源的架构但需要非常小心地规划缓冲的布局。最常见的 G-Buffer 包括基础颜色RGBA8法线/世界坐标RGBA16F 或 RGBA8 压缩法线材质属性金属度、粗糙度、AO 等深度纹理实操经验是尽量少依赖单一大容量 G-Buffer。四个 render target 已经偏高带宽移动端通常压缩成两张或三张。你可以用八进制打包法即法线放到两个 8 位通道材质属性合并进一张 RGBA8 中只有金属和粗糙度需求较高的项目才考虑 16F。带宽节省的效果跑一下 profiler 立刻能看出来。我测试过一个略极端的项目G-Buffer 从四张 1080p 的 RGBA16F 改成三张 RGBA8深度之后帧时间直接减少了 18%。代价是材质的表达精度有微小损失但美术侧不仔细看完全分辨不出来这是极其划算的取舍。最终方案还得看你的目标平台PC 上带宽相对余裕移动端则应该优先压缩带宽。4.5 渲染目标与后处理链路后处理这一块的架构安排最容易踩的坑是每一帧无脑创建一堆临时渲染目标。比如 Bloom 可能需要多级降采样目标、SSAO 需要若干随机采样目标、色调映射需要中间目标如果每个 Pass 都去新建目标帧图的资源复用优势在这时候体现得无比明显。我的建议是所有全屏后处理 Pass 的渲染目标都应该放进帧图统一分配。以 Bloom 为例不应当在 Pass 内部 new 一个 1/4 分辨率纹理而应当在帧图里声明“Bloom Pass 需要一个尺寸格式为 R11G11B10用途为采样写入”的资源让帧图在整帧的维度分配并复用。这样Bloom 的目标和 SSAO 的中间目标极有可能叠用同一块显存带宽与开帧的峰值内存双双下降。后处理链路里还容易忽略的是在全屏 Pass 中做渲染状态假设。千万别用默认纹理采样器状态每个采样指令都要明确展开采样器描述否则在部分驱动上会出现奇怪的边缘拉伸或采样异常。我自己在某个项目里排查过半个下午的异常亮边最后发现是采样器寻址模式默认成了 Repeat导致贴图边缘采样越界。这种事即便对老手也不算罕见。5. 常见问题与排查技巧实录5.1 帧率上不去CPU 瓶颈在渲染线程不少团队做过这个事把 GPU 换成高端卡结果帧率纹丝不动大材小用。这时候先查 CPU 侧多半是渲染线程的 CPU 帧时间成为主瓶颈。我碰到的典型原因与对策如下绘制调用Draw Call过多比如超过 3000 缠在移动设备上。对策是利用实例化渲染合批静态网格特别是大量重复物体。植被、路灯、碎石全部拉到实例化路径中效果非常好。管线状态切换过于频繁对策是合并 PSO 缓存尽量把同材质同状态的物体聚到一起。曾见过一个项目画笔切换次数占渲染线程消耗 40%仅仅因为排序时没有把材质状态作为主要排序键。顶点 Buffer 绑定过窄同一个网格多次绘制每次都重新绑定顶点和索引缓冲。对策是把静态网格的多个 Submesh 合并到一个 Buffer 里仅通过索引范围来区分。5.2 白屏或黑屏资源状态和 Barrier 问题一块黑屏或白屏渲染架构里面的可能原因太多了。但如果你在使用帧图架构后还出现黑屏重点怀疑文件资源 Barrier 缺失、资源被复用但内容没清空。Barrier 问题通常很难直接 debug因为结果往往是“时好时坏”。排查方式每个 Pass 输出到一个独立 RenderTarget关闭帧图自动屏障插入手动在 Pass 边界插入全管道屏障。如果画面正常那说明确实是某些资源缺少精确的等待机制。需要进一步定位到具体资源可按“只在写资源时插入屏障、只在读资源时插入屏障”的二分法配合 Dump 每一帧的屏障序列日志来逐步缩小范围。5.3 GPU 内存溢出与显存碎片显存碎片比较隐蔽通常表现为场景加载后显存占用稳定切换几轮关卡后逐渐升高最后分配失败。真正的原因往往是资源池里的小对象被反复创建和销毁而驱动显存分配不归还给系统。一个比较实用的策略分两层所有帧内临时资源尽量使用帧图的复用机制避免每帧新建。长时间存活但很少更新的资源统一放进专用池并在场景加载时做一次池收缩。排查显存泄漏给资源簿记上引用计数或生命周期标签每 N 帧输出一次资源实例数量清单对比参考帧。5.4 渲染结果与 CPU 端结果不一致的同步问题多线程渲染中最头疼的“分支结果抖动”根源在于渲染线程看到的是一帧之前的数据。如果逻辑侧对物体的变换矩阵做非幂等修改比如累加旋转渲染侧拿到的数据可能是两帧前的于是出现抖动。解决方式是在传递渲染数据时带上明确的时间戳或帧序号渲染侧在接收数据时做一次缓存。如果你对延迟敏感渲染线程可以在主线程完成当前帧逻辑后直接用栅栏强制等待该帧数据就绪尽管这样损失了并行性但消除了抖动。若能接受轻微输入延迟则环形缓冲队列方案往往更好。5.5 移动端带宽和发热问题的专项排查写渲染架构时移动端的带宽和发热比桌面端敏感得多。发热直接表现为掉帧甚至触发平台温控降频。真机上受温度影响一定不要只看一帧平均帧率最好用小窗口统计帧时间分布连续跑 5 分钟才能看出来。我这边移动端渲染架构优化的经验排序高优先级保证 G-Buffer 带宽压缩户型大、屏幕大的项目尤其关键。合入半分辨率与四分之一分辨率后处理的带宽收益在移动端远比桌面端明显。小心像素填充率。全屏 Pass 多一道功耗立刻上一个台阶用得上帧图裁剪掉没有消费者引用的 Pass尽量压低全屏 Pass 数量。6. 渲染架构的演进方向——我在实际工程里看到的趋势引擎渲染架构这些年变化很快。十几年前大家都在手工维护 RenderPass 和状态切换如今帧图、自动屏障、显式 GPU 同步已经成为大中规模引擎的标配。根据我个人的实践观察现在引擎架构的重心正在往两个方向偏移。第一是数据导向和可扩展性。ECS 与渲染系统的整合越来越紧密渲染系统不再是面对一堆“对象”而是面对连续布局的组件数组。这意味着 CPU 缓存命中率显著提高绘制批次整合也更好做。如果自己在设计渲染架构从一开始就应该考虑“渲染数据尽量以数组的数组SoA方式存放”而不是让每个物件持有自己的渲染数据。我做过类似的改造内存遍历速度直接翻倍。第二是异步计算和渲染工作流。新的图形 API 允许图形队列与异步计算队列并发执行渲染架构需要意识到不只一条 GPU 指令流。常规设计里把后处理、粒子模拟与主场景渲染放在不同队列可以真正利用 GPU 的空闲执行单元。但这个特性对 Barrier 管理的要求更高更依赖帧图来做跨队列依赖的验证。架构定稿之前至少要预留出“异步计算是否启用”的开关否则后期想加这个能力改动面会很大。第三是可调试性。渲染架构设计得再精巧出问题的时候不能有效调试等于零。所以我特别建议在渲染系统里内建一套结构化的 debug 设施例如采集每帧渲染命令、输出资源生命周期报告、Pass 耗时热力图、GPU 同步事件时间线。没有这些设施当你面对一堆并发命令时只能看见“好好的画面突然变黑一片”毫无头绪。从架构第一天就留好这些调试位成本低、收益高。7. 根据个人经验再补充几点实操心得再啰嗦一些我踩过坑之后的体会。第一渲染线程的内部任务粒度需要仔细控制。按场景块切任务或许很方便但容易导致负载不均衡。更推荐的做法是要并行就把可见集合切分成连续内存块每块分配一个工作线程要并行度更高可以把不同 Pass 放在不同线程上跑但需要注意 Pass 间依赖排序。实测下来每个任务 1 到 2 毫秒的粒度在通用 CPU 上表现最平衡。第二统一处理 Shader 变体避免爆炸。做一个同样面向多平台的项目当时材质的变体组合达到数千加载时间满目疮痍。后来换成宏开关按需组合、运行时按关键字查缓存的模型加载时间降到可接受范围。凡是超过阈值的高阶特性能退化成统一模型就退不要为极少数特殊材质保留所有组合路径。第三永远别忘了移动端真机与浏览器的差异。桌面模拟器永远看不出显存带宽和发热问题。项目上线前一定要有一轮针对不同 SoC 的功耗与性能回归测试。这个测试不是跑一遍基准就行而是直接在真机电池曲线和帧时间分布上做判断别老盯着平均值平均值会掩盖很多偶发性卡顿。第四对于“多平台”引擎统一异步与并行策略非常重要。不要给每个平台写一套独立的并行策略。我的经验是先选一个中等复杂度的平台往往 PC 或者高端安卓机把架构做对之后再将平台差异隔离到 RHI 层而不是让渲染逻辑到处散落平台判断。如果一开始就为每个平台特化渲染代码后期维护这些特化分支会让你心力交瘁。渲染系统的架构设计跟写游戏玩法代码有个巨大的区别它需要你同时关注并发、资源生命周期、平台差异和硬件执行模型甚至还要从美术工作流的角度反向塑造设计。想一次性把架构定得完美不现实。更务实的路线是选一套清晰的分层骨架把渲染前端、后端、帧图、资源池、着色器系统各自约束好边界再根据项目需要逐步迭代。至少我就发现有了明确的架构分层之后后期换平台、加新特性、调性能时的痛苦程度真的会低很多。
返回列表