ARTICLE DETAIL

资讯详情

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

游戏引擎渲染系统架构拆解:分层、数据流与多线程实践

游戏引擎渲染系统架构拆解:分层、数据流与多线程实践 引擎架构这个系列我本来计划先从最面子上看得见的资源管理聊起但后台不少朋友一直在催渲染部分说游戏跑起来漂不漂亮、帧率稳不稳一大半都押在渲染系统上。这确实是实话。渲染系统是引擎里最贴近“画面”的子系统也是耦合点最多的子系统场景要喂给它资源要喂给它玩法逻辑想拿摄像机参数和灯光状态也要穿过它。这次就把渲染系统架构拆开聊一聊重点讲清楚它内部怎么分层、数据怎么流转、多线程怎么协调以及我在实际项目里踩过的那些坑。这篇偏架构设计不抠具体图形学公式适合正在搭引擎或者准备重构渲染模块的同行参考。1. 从整体看渲染系统它到底管哪些事1.1 渲染系统在引擎里的边界很多人一上来就急着写渲染代码结果做着做着发现渲染器和 GamePlay 耦合得一团乱。原因很简单没先想清楚渲染系统的边界在哪。我习惯用厨房来类比GamePlay 是点菜的客人资源系统是仓库渲染系统是厨房里的灶台和炒锅。客人不会冲进厨房自己颠勺厨房也不会天天跑去仓库翻货架两边之间要有传菜窗口也就是引擎里那些公开的接口。渲染系统真正接管的是“从拿到一张可绘制物体列表开始到把最终颜色写进后缓冲为止”的这一整段链路。它不负责物理计算也不负责动画采样更不应该直接去读磁盘上的贴图文件。动画系统会把骨骼矩阵写进一块缓存资源系统会把网格和贴图加载到 GPU 显存渲染系统只负责在每一帧正确地把这些东西组合起来、剔除掉看不见的部分、按顺序提交绘制命令。边界一旦模糊后面就会出现“渲染线程里做资源 IO”“GamePlay 直接改 Shader 参数导致状态混乱”这类灾难现场。1.2 核心职责与数据流渲染系统每帧的核心工作可以拆成几步收集场景中所有可绘制对象、做可见性剔除、生成绘制列表、提交给 GPU、呈现。看起来简单但每一步都有讲究。先看收集这一步。现在主流引擎普遍用实体组件系统或者对象模型来组织场景渲染系统拿到的应该是“渲染源数据”而不是直接操作游戏对象。什么意思一个角色的 GameObject 身上可能挂了几十个脚本渲染系统只关心它的 Transform、Mesh、Material、Layer 标记这四样东西。在引擎我最常用的做法是当物体进入场景时渲染系统创建对应的渲染代理RenderProxy之后每一帧只从一个集中式列表里拿数据根本不碰业务脚本。这个隔离就是渲染系统和玩法逻辑能并行开发的前提。数据流上要特别注意方向性。场景变化是业务线程产生的渲染系统要靠“脏标记”或者“快照”机制把变化同步过来而不是让渲染线程回头去查所有对象的状态。否则两个线程同时读写同一个 Transform轻则出现物体抖动重则直接崩溃。后面讲线程模型的时候我会再展开。1.3 一个常见的分层思路渲染系统内部我一般分四层从低到高RHI 层渲染硬件接口只封装 Vulkan、Metal、DirectX 12 这些 API 的差异向上提供 Pipeline、Buffer、纹理、描述符等资源对象。渲染器核心层管理 Pass 的依赖关系、管线状态缓存、资源生命周期相当于一个“绘制命令的组织中心”。渲染功能层光照、阴影、后处理、粒子、地形这些具体效果都在这层实现每个功能模块只依赖核心层提供的接口。场景适配层把业务场景的数据转成渲染系统认识的 RenderObject做剔除和排序。这个分层不是拍脑袋定的背后有几个很现实的理由。第一跨平台RHI 层把硬件差异吃掉上层就不用写一堆#if defined(VULKAN)这种宏。第二并行开发功能层的人可以只管效果不用关心底层 API 细节。第三测试容易每一层都有清晰边界想要单测某个 Pass只需要造出它依赖的输入资源就行。2. 渲染管线架构从场景数据到像素2.1 前向渲染、延迟渲染与权衡渲染管线选型是很多人纠结的第一个大问题。传统前向渲染就是每个物体走一遍完整的光照计算灯光越多每个物体的开销线性增长延迟渲染则先把几何信息颜色、法线、深度等写进多张 G-Buffer再在屏幕空间做光照把光照计算复杂度从“物体数 × 灯光数”降到“像素数 × 灯光数”。我在这两种方案上的选择经验是桌面端大型项目不透明物体走延迟基本没悬念灯光可以堆到几十上百个还保持稳定帧率但移动端延迟渲染的 G-Buffer 多目标渲染对带宽太不友好常见的八核手机芯片带宽就那么多光写四张 128bit 的 RT 就能吃掉一大半。所以移动端我现在更推荐 Clustered Forward聚类前向渲染思路是把视锥体在三维空间切成一个个小格子Cluster每个格子维护一份灯光索引列表渲染物体时先查它覆盖了哪些格子只算这些格子对应的灯。这样灯光数量不再直接惩罚所有物体同时又能保留前向渲染的 MSAA 支持和透明物体友好性。有些项目会选择混合管线不透明静态物体走延迟角色、粒子、半透明物体走前向。但混用时要小心 G-Buffer 和光照结果在两种路径之间传递的问题尤其是遇到 SSGI、SSAO 这类需要法线和深度信息的后处理效果透明物体单独走一道光照会显得和场景融合不到一起。2.2 FrameGraph 思维用依赖图管理 Pass我以前写过不少传统引擎的渲染流程代码大概长这样先清屏然后画 shadow map再画场景再画天空盒再画后处理。每个 Pass 自己在代码里申请资源、写资源、释放资源上一个 Pass 和下一个 Pass 之间靠注释约定“你要先执行我在你后面”。这种写法前期很爽后期加效果就是噩梦。后来受了 FrameGraph 思路的启发我改成让每个 Pass 声明式的描述自己读取哪些资源、生产哪些资源、依赖哪个 Pass。引擎收集完所有 Pass 描述之后自动构建一张依赖图再根据图去排序、复用资源、合并 Barrier。一开始迁移工作量很大因为要重写所有 Pass 的接口但这个投入很值得。后来我加一个新后处理效果基本就是写一个描述节点声明“我要读上一帧的颜色和深度输出一个新的 RT”剩下资源分配和同步问题都交给框架。FrameGraph 还有一个隐藏收益它对资源生命周期做了精确管理。以前容易犯的错是后处理临时 RT 每帧 new 一个忘释放显存稳步上涨。FrameGraph 会根据依赖图统一分配和回收往往能把同时存活的后处理缓冲数量从十几个压到六七个。2.3 GPU Driven Rendering 与剔除下沉这几年 GPU Driven RenderingGPU 驱动渲染越来越流行。传统路径是 CPU 每帧遍历场景做剔除再逐个提交绘制命令瓶颈很明显CPU 端花在遍历和维护绘制列表上的时间以及每帧上传给 GPU 的命令数据量。GPU Driven 的思路正好反过来把所有实例的变换矩阵、包围盒、材质索引这些数据一次性上传到 GPU Buffer剔除计算也放到 GPU 上做用 Compute Shader 或者专用的剔除管线最后直接发 Indirect Draw 命令CPU 只负责启动任务不再逐物体提交。这个方案在开放世界项目里特别有用。比如一大片城市建筑或者森林植被动辄上万个静态实例CPU 逐个剔除再绘制就废了GPU 并行剔除却能轻松扛住。代价是数据组织和资源管理复杂度上了一个台阶实例数据要在 GPU 端高效管理LOD 切换要自己做碰撞和渲染的加速结构要分家。另一个很实际的问题是有些老设备的驱动对 Indirect Draw 和 Compute 的支持很差所以我一般会在架构里保留 CPU 剔除和 GPU 提交的双路径运行时根据设备能力选择。3. 关键子模块的架构设计3.1 场景管理渲染代理与脏标记场景管理这一层我强调最多的就是不要直接耦合业务对象。我自己的项目里会有一套这样的机制游戏对象进入场景时盖一个渲染代理的印章这个代理只保存渲染关心的最小数据集合对象属性变了不做实时同步而是给代理打一个脏标记到下一帧的同步点统一更新。脏标记要分级不能一脏就全同步。我一般把数据分成三类变换类位置、旋转、缩放、材质类颜色、贴图引用、自定义参数、可见性类Layer、是否可被阴影投射/接收。Transform 自己动的时候不用去更新材质材质变化也不用让网格重新上传。这样既减少了同步带宽也避免了线程互写的风险。还有一点容易被忽略当物体销毁时代理的回收要和渲染线程帧同步。引擎里如果直接在主线程删除代理渲染线程可能正好在用这份数据就会悬空。我的做法是延迟回收把要删的代理放进一个“待删除队列”等渲染线程跑到安全点再统一清理。这也是很多新手写引擎最容易崩溃的地方。3.2 剔除系统视锥、遮挡与层级剔除剔除系统是渲染性能的大头。最基础的是视锥剔除拿摄像机的 6 个裁剪面去和物体包围盒做相交测试。这一步不难优化空间主要在加速结构上。但真正拉开差距的是遮挡剔除。静态场景可以用预计算的遮挡数据UE 和很多商业引擎都有类似方案动态场景就得靠每帧的遮挡查询Occlusion Query或者软件遮挡剔除。我的经验是GPU 遮挡查询的回读等待是性能陷阱查询发起后 GPU 还在忙别的CPU 傻等回读结果帧率就掉下来了。更稳的做法是拿上一帧的深度缓冲做粗粒度遮挡测试或者用纯 CPU 的软件光栅化把几千个物体压到几百个再对剩下的做精确剔除。层级剔除HLOD也值得提。对于远距离的静态物体群与其挨个剔除不如把一片小物体合并成一个低模代理距离远到一定程度就只画代理。这个思路对植被和城市建筑群非常有效。动态物体的 LOD 更修剪距离阈值控制就好了但要注意变化别太频繁否则 LOD 切换会让人看着觉得物体在“跳”。3.3 材质与 Shader 变体管理材质系统表面上做的事是“把一堆参数绑定到 Shader”但真正考验架构的是 Shader 变体管理。一个角色 Shader可能同时有皮肤、无皮肤、双面、雾效、骨骼动画、自阴影、视差贴图等多个特性开关每一项都开和关组合出来就是几百个变体。如果打包的时候丧心病狂地全部编译工程时间和包体体积都会爆炸。我踩过这个坑之后学到的教训是给 Shader 的每个特性开关定义成独立的宏不在材质里显式声明就不编译这个变体运行时再按需加载变体做一个“用到了才编译”的变体缓存。编译时间从几十分钟下降到几分钟之后团队所有人的心态都不一样了大家都更愿意加新特性了。渲染层面对材质透明的点还有一个管线状态Pipeline State的缓存。不要每帧根据材质参数直接设置管线而是以“Shading Model 特性组合 混合模式”为 key构建一张缓存表绘制时直接用。这套缓存能显著降低驱动状态切换开销在移动端尤其明显。3.4 灯光管理的经验灯光模块的架构核心不是“怎么画灯光”而是“每帧到底有多少灯需要算”。我见过很多项目美术往场景里摆了一百多盏灯建筑之间光晕乱飞然后性能报表上一个物体的绘制开销直接乘了灯数帧率当场去世。比较好的做法是给灯光做一个空间索引把场景划分格子每帧根据相机位置收集最近的、并且影响范围与相机可见体相交的灯光最多取几十个写在灯光列表里。再配合前面讲的 Clustered Forward把灯光索引按格子分发到 GPU所有物体都能高效取到自己对应的灯。阴影管理是另一个大头。方向光阴影一般用级联阴影CSM我通常只开 23 级级联再结合距离阈值动态调整阴影分辨率。点光源和聚光灯的阴影需要提前规划 Shadow Atlas 的范围别让每盏灯都独占一张足够大的 shadow map那样内存和更新开销都会失控。4. 多线程渲染与 RHI 抽象4.1 线程模型与命令缓冲现代引擎基本都有一个清晰的线程模型游戏线程GameThread负责更新逻辑渲染线程RenderThread负责收集渲染数据并生成命令工作线程负责剔除、动画、物理等并行任务。渲染线程不是直接调用图形 API而是把操作录制成“命令缓冲”提交给后端统一执行。这套东西最核心的价值是让游戏线程不必等待 GPU 执行完相当于打饭窗口可以一直接单后面厨房忙得过来就慢慢做。但线程之间必须做帧同步。游戏线程在跑第 N 帧渲染线程可能还在处理第 N-1 帧的数据所以你需要一个“帧描述符”来传递快照通常叫 FrameSync里面记录了帧号、命令列表、资源版本。延迟一帧是最常见的配置响应速度和并行度都比较平衡VR 项目需要更低延迟就得压缩这个缓冲有时候会牺牲一些并行度。4.2 为什么需要 RHI 层RHI 层存在的理由很简单一套引擎要跑在 PC、主机、移动端图形 API 却有好几种。没有 RHI 层就得在渲染功能代码里到处写 API 分支维护成本高到没边。不过 RHI 的设计有门道。我不推荐把 RHI 做得太大什么功能都往上塞应该只暴露“最小公共子集”同时留好扩展位。比如移动端很多 GPU 是 Tile-Based 架构支持 Memoryless 渲染目标能在片内保存而不回写内存这是移动端性能利器但桌面 API 没有这个概念。RHI 如果强制抹平一切差异那移动端的优势就没了。正确做法是在 RHI 里加一个可选接口桌面端返回空实现移动端返回真实现。另一个常被低估的点是RHI 不只是 API 的封装它还封装了内存模型和队列语义。接 Vulkan 和 DX12 的时候资源状态转换、Descriptor 管理、命令队列这三大块的复杂度比想象中大得多。如果 RHI 不把这些抽象好上层写功能时很容易被底层细节拖死。4.3 资源状态管理与 Barrier 痛点Vulkan 和 DX12 里资源状态切换比如从可读变成可写、从渲染目标变成着色器输入需要显式插入屏障Barrier。这个东西如果漏了画面会出现花屏、黑块甚至驱动崩溃而且有时候问题还只在特定 GPU 上出现排查起来非常痛苦。我在 RHI 层专门做了一个资源状态跟踪器维护一份“谁在用这个资源、当前处于什么状态、上一次使用点在哪”的表。每次请求使用资源时跟踪器对比新旧状态把需要的 Barrier 合并到一批最后一次性提交而不是每换一次资源就插一个 Barrier。原因很简单数据同步在 GPU 上是有代价的批量的同步比零散的同步便宜得多。除了 Barrier资源复用也很重要。FrameGraph 在设计时就能算出很多中间渲染目标只在某些 Pass 间短暂存活可以把它们安排到同一块显存区域通过别名复用。这个优化能把移动端默认的显存占用压低 30% 以上非常可观。5. 实操中的常见问题与排查技巧5.1 Draw Call 与合批策略很多人一提到性能问题张口就是 Draw Call 太多其实真正的开销在于 Draw Call 之间的状态切换切换管线、绑定顶点缓冲、更新描述符每一件都可能让 GPU 流水线断流。所以合批的本质是减少状态切换次数而不只是减少命令数。合批通常有三板斧静态合批适合不动的小物件把多个 Mesh 拼成一个、动态合批适合小物体但要求顶点属性和材质完全一致、GPU Instancing适合大量重复物比如草地、围栏、路灯。我的建议是移动端优先用 Instancing静态合批虽然效果直观但会让内存和网格加载逻辑变复杂而且一旦某个子物体要求独立变换合批就得拆开反而更麻烦。更重要的经验是别让合批需求绑架美术流程——为了强行合批把所有纹理塞进一张图集结果 UV 浪费、内存翻倍、图集频繁更新得不偿失。5.2 贴图内存与流送渲染架构里容易忽视的是时域资源贴图什么时候加载、什么时候释放、什么时候只加载 mipmap 的一部分。我实现过一个串流管理器它维护每张贴图的“优先级”根据物体距相机的距离和屏幕占比动态决定该加载哪些 mipmap 级别。这样做的好处是开放世界从高空飞进街道的过程远处的地形贴图不需要全分辨率远处只要最低 mip接近后再慢慢加载高清级别。这里有个很关键的预算调整原则显存预算不能打满。移动端 GPU 显存通常和系统内存共用UI 缓冲、离屏 RT、纹理串流都有可能突然占掉一大块如果串流管理器把预算压到 95% 以上一旦出现一个比较极端的大场景立刻触发强制回收帧率就会剧烈波动。我一般把串流预算留 10%15% 的余量。5.3 GPU 性能剖析经验遇到渲染性能问题我第一件事永远是先定位瓶颈方向CPU Bound 还是 GPU Bound。判断办法很简单把渲染线程的工作量减半比如跳过一半后处理看帧率变化如果帧率稳定不变说明瓶颈在 CPU反过来从设置里把垂直同步关掉观察某一帧的 GPU 时间也能判断。如果是 CPU Bound重点查 Draw Call 数、状态切换次数、脚本体量如果是 GPU Bound就用帧捕获工具比如 RenderDoc、Nsight、Xcode Instruments或者移动端的 Adreno Profiler、Mali Offline Compiler。我最常犯的错是在 PC 上优化了半天 GPU 开销结果真机还是掉帧后来一查才发现 PC 是 CPU 瓶颈完全没对症。跨平台项目一定要在目标设备上做 profiling。5.4 架构演进中的重构心得最后聊点架构演进的实操经验。渲染架构尽量不要一步到位先把“场景 → 剔除 → 提交 → 呈现”这条主线垂直打通再去加各种功能模块。如果一开始就在设计海量抽象层大概率会做出一堆没经过实战检验的过度设计。我最怕的是项目里出现“新旧双渲染系统并行”的过渡状态新系统做了一半老系统还在维护两个系统同时被不同需求方使用。最稳的做法是保留旧管线做兜底新管线在功能开关的控制下逐步接管每接一个场景就验证一次确认没问题再切过去。同时要确保所有 GamePlay 层对渲染的访问都通过接口收口不要让人直接在业务代码里 new 一个 RenderPass那样后面重构会很痛苦。6. 最后说点个人体会做渲染架构这几年我越来越觉得好的架构并不是设计得多么精巧而是能在需求不断变化的时候依然让人一眼看懂数据是怎么流转的、资源是谁在管理、帧和帧之间是怎么同步的。漂亮的设计图谁都会画真正难的是几个月后再打开代码时还能不动脑子就定位问题。如果让我重新做一个引擎我会更早引入 FrameGraph 和 GPU Scene 这类数据驱动思想而不是等 Pass 和实例对象多到没法收拾了才去重构。另外一定要给自己留一双“手工降级”的鞋子无论架构多先进最后线上出问题时你得有能力绕开所有花哨机制回到最原始的、能单步调试的路径上去排查。架构只是工具稳定交付才是目的。
返回列表