我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

游戏引擎渲染系统架构解析:从线程模型到渲染图

游戏引擎渲染系统架构解析:从线程模型到渲染图 渲染系统大概是游戏引擎里最复杂的一块。它不只是把三角形画出来那么简单还要考虑场景数据怎么流到 GPU、线程之间怎么协作、资源和内存怎么反复利用以及渲染特性越来越多时怎么保持架构不崩。这篇作为系列第二篇专门聊渲染系统架构。我会从实际工程的角度拆解整体框架、线程模型、资源管理、材质系统、渲染图和调试工具重点讲清楚每个设计背后的“为什么”而不是只列名词。适合正在写渲染器、或者想深入理解商业引擎渲染架构的同学。1. 渲染系统在大框架里的位置一帧是怎么被“加工”出来的1.1 渲染发生在引擎流水线的哪一环整个帧循环里渲染系统不是孤立存在的。逻辑系统脚本、物理、动画、网络更新场景状态渲染系统把这些状态收集起来整理成 GPU 能理解的数据最后提交给硬件。架构上渲染系统对其他系统应该是“只读”的它不修改游戏对象只读取变换、网格、材质等数据。这个解耦决定了后面所有事情能不能做好。如果渲染系统直接操作游戏对象或者在渲染线程里写业务状态多线程保护就是灾难。成熟的引擎普遍会引入“渲染代理”的概念每个可渲染对象在进入渲染器时被注册成一个轻量代理渲染器看到的是一份扁平化数据而不是一棵复杂的场景树。物理、动画再怎么改游戏对象渲染器只需要在一帧开始时同步最新状态。记住一句话渲染系统是纯粹的数据消费者。这句话是架构红线谁越线谁负责背锅。1.2 渲染器内部的三层结构一个设计良好的渲染器通常分三层底层是硬件抽象层封装图形 API 的差异提供资源创建、命令录制、提交能力。中层是场景组织和渲染流程剔除、排序、渲染队列、渲染图都在这一层它决定“这一帧画什么、按什么顺序画”。上层是渲染特性层光照、阴影、雾、ToneMapping 这些效果通过组合中层的 Pass 实现。三层之间靠稳定的接口通信。底层接口一旦定下来就不能随便改否则阴影系统一换图形后端就得重写。中层的渲染队列和 Pass 系统要保持通用不绑定具体效果。我在实际项目里见过反面教材所有特效代码塞进一个巨大的 Renderer 类结果每次加雾都要碰阴影代码改一处崩三处。1.3 决定渲染架构的两条主线渲染架构始终有两条线贯穿一条是数据流从 CPU 侧可读的场景数据到驱动能理解的描述符和命令再到 GPU 显存里的几何与纹理。这条线考察的是数据格式转换和同步策略。另一条是时间线一帧里 CPU 在准备第 N1 帧数据的同时GPU 可能在处理第 N 帧的命令。这条线考察的是并行和同步。抓住这两条主线很多设计都能归位线程模型在解决时间线问题资源管理在解决数据流和时间线交叉的冲突渲染图则把 Pass 之间的数据依赖显式化。后面每个主题其实都在围绕这两条线展开。2. 线程模型与命令提交多线程渲染的架构骨架2.1 为什么渲染线程不能直接调 API先回答一个常见疑问既然图形 API 最终在渲染线程执行能不能让游戏逻辑线程直接调答案是不能原因有两个。第一驱动调用是重操作包含大量校验和状态管理。逻辑线程每帧调用几千甚至上万次 Draw Call逻辑帧率会立刻被拖垮。第二逻辑线程和渲染线程如果同时调用同一个驱动对象驱动内部状态会互相污染必须加锁加了锁多线程的优势又全没了等于回到单线程。更合适的做法是“命令化”逻辑线程把“我要画这个网格、用这个材质”写成一条条命令塞进命令缓冲区渲染线程再翻译成真正的 API 调用。打个比方这就像服务员把客人点的菜写成小票递给后厨而不是带客人冲进厨房自己炒。2.2 命令缓冲区的设计要点命令缓冲区本质上是一块字节数组里面按顺序排布着命令头和参数。常见设计是分段连续内存加环形回写避免每帧分配堆内存。每条命令是一个结构体包含操作类型和参数比如 DrawMesh 命令可能携带网格 ID、材质 ID、变换索引。实现时要注意对齐。CPU 结构体的对齐规则和 GPU 描述符需求经常不一致我在这上面吃过亏命令里塞了个 bool 数组导致后面参数对齐错位驱动直接报错。建议把所有命令设计成 16 字节对齐的紧凑结构枚举字段一律用 uint32_t别省那几个字节。帧循环的经典时序是逻辑线程写第 N 帧命令渲染线程等 GPU 处理完第 N-2 帧后提交第 N-1 帧命令中间用 2 到 3 帧的缓冲窗口分摊压力。缓冲帧数太少逻辑线程会被阻塞太多输入延迟就上去了。2.3 现代图形 API 对线程模型的影响传统图形 API 多少会帮你做提交同步而现代显式 API 不一样它要求应用自己管理命令录制、资源状态和同步。这对架构的影响有两个方向。于是很多渲染器从“单线程录制”走向“多线程并行录制”。逻辑线程把场景数据按区块切分给工作线程每个工作线程录自己的命令列表主渲染线程最后合并提交。这里有个取舍并行录制能利用多核但命令列表数量多了驱动合并的开销也变大。一般建议在 6 到 8 个录制线程之间做实验找平衡点。维度传统模型现代显式模型命令录制单线程安全录制多线程并行录制同步驱动自动处理较多应用显式处理 Barrier控制力弱但简单强但容易出错调试驱动帮你兜底错误直接崩溃再补充一个容易被忽略的点上传队列要单独开。纹理和网格的初始上传不应该和渲染命令混在同一个队列否则加载场景时的大块数据上传会阻塞渲染。单独的上传队列配合异步复制是架构里非常实用的一招做开放世界流送场景时收益尤其明显。3. 场景数据组织与剔除流程让 GPU 只处理看得见的东西3.1 渲染代理与场景数据的扁平化说到渲染器只读数据那数据怎么组织常见做法是每个可渲染对象注册一个渲染代理代理持有指向网格、材质、包围盒、变换的引用。渲染器把代理按需组织成各种数组比如“所有带阴影的代理列表”“所有需要动态合批的代理列表”。这里有个经典坑Transform 同步。游戏对象的 Transform 在世界坐标里更新但渲染需要的是 LocalToWorld 矩阵和上几帧的运动向量。架构上应该让变换系统写一份专用缓冲区渲染器按帧读取而不是渲染器主动去遍历场景树。我在某项目里见过渲染线程直接遍历场景树取 Transform一开多线程就卡死后来改成脏标记加专用缓冲区才解决。3.2 剔除的层次与算法GPU 资源有限所以剔除要分层做。第一层是粗粒度剔除。场景管理器为每个区块维护包围体先把完全在视锥外的区块划掉再对区块内的代理做细粒度视锥剔除。第二层是距离剔除和小物体剔除设定最大可见距离或者按屏幕占比筛掉过小物体。第三层是遮挡剔除用上一帧渲染出的深度信息生成层级遮挡缓冲CPU 端查询哪些包围体被挡住。遮挡剔除的架构值得多说一句HZB层级遮挡缓冲生成在 GPU 上但查询在 CPU 上所以存在一帧延迟。一帧延迟对多数游戏是可接受的因为遮挡结果变化不会太剧烈。但如果你做的是角色贴身转圈这种极端视角变化可能出现物体忽隐忽现这时候需要为关键物体准备一个强制保留列表由策划或系统手动管理。3.3 排序与渲染队列剔除完之后是排序。渲染顺序不全是“画完场景再画 UI”这么简单队列一般分为不透明、天空盒、不透明特效、透明、UI 等。不透明物体要尽量减少状态切换通常按材质、Shader、深度来排序。同材质同 Shader 的物体聚在一起驱动能更好地合并状态同深度的物体聚在一起可以减少过度绘制。透明物体的排序又不一样透明混合与绘制顺序强相关一般按距离从远到近也就是画家算法。这个排序对 CPU 压力不小尤其是大量透明粒子。架构上要允许分队列一部分透明物体用距离排序另一部分用固定顺序。比如依赖深度写入来近似混合的粒子可以不排序直接用深度测试控制可见性。没有万能排序架构上要留可配置的口子。4. 资源生命周期与 GPU 内存管理帧间复用和延迟回收4.1 资源 ID 与底层对象分离渲染器内部不应该到处直接持有底层 API 的 Image 或 Buffer 对象而是用一个资源管理器统一分配 ID所有上层系统通过 ID 访问。好处至少有资源释放统一管理调试时能按 ID 追踪来源底层对象真正销毁的时机可以延迟到 GPU 不再使用之后。实现上资源管理器维护一张从 ID 到资源描述的哈希表外加一个待释放队列。创建资源时先生成 ID再交给后台线程或上传队列准备数据。销毁时先从可访问表移除把底层对象放进延迟释放队列等安全帧再真正销毁。“安全帧”怎么算最简单是用帧计数加一个偏移比如资源必须等待 GPU 两帧不再引用后才能销毁。4.2 帧资源与环形缓冲这是架构里最容易被忽略也最容易出问题的地方。CPU 可能提前一帧准备数据GPU 可能还在读上一帧的数据这两份数据不能共用同一块内存。典型解法是环形缓冲一圈固定大小的内存按帧推进写入指针。比如每帧的骨骼矩阵、动态光照参数放在一个动态缓冲里帧 N 用偏移 0帧 N1 用偏移 1写满一圈就回到起点前提是剩下的帧数足够让 GPU 读走旧数据。我遇到过把动态 Uniform 缓冲做成单份的跑起来偶尔画面闪烁深究原因就是上一帧数据被当前帧覆盖了。换成环形缓冲之后立刻稳定。4.3 异步资源创建与流送资源创建绝不能全部放在主线程同步完成否则场景切换会卡得无法忍受。架构上要支持异步加载IO 线程读取文件数据上传队列把数据处理成显存里的 GPU 对象等渲染器确认安全后再挂到资源表里。这里有个细节纹理上传时最好直接从 CPU 读纹理数据转成 GPU 友好的布局。某些现代 API 支持把数据直接拷进显存格式转换由驱动做比 CPU 端做转置快得多。流送场景则要用引用计数模型被卸载时引用计数归零不代表立即释放而是交给延迟回收机制。宁可多留两帧也不要让正在 GPU 里使用的资源被释放否则驱动崩溃非常难排查。5. 材质系统与渲染特性层从美术参数到最终像素5.1 材质是数据Shader 是程序材质系统和 Shader 系统经常被混为一谈架构上应该分开Shader 是一段编译好的 GPU 程序材质是绑定到 Shader 输入的一组数据。材质包含哪些参数、怎么传给 GPU由 Shader 的接口声明决定。具体实现时材质系统会保存参数名到位置的映射。比如 Shader 声明了 BaseColor 颜色和 MainTex 纹理材质把这两个参数绑定到特定注册槽。这套绑定如果每次绘制都查一遍名字CPU 开销巨大。架构上应该预计算绑定快照Shader 加载时生成参数表材质实例化时把参数整理成语义 ID 到值的有序数组绘制时直接按 ID 提交。5.2 Shader 变体的管理与组合爆炸Shader 变体是渲染架构里的一个难点。一个特性开关比如雾、方向光阴影、透明度测试往往对应一组宏几个特性组合起来就是 2 的 N 次方种变体。编译时间、包体积、首次加载时间都会爆炸。我见过最夸张的情况是一个项目刚起步就开了 12 个特性开关理论变体四千多个编辑器每次编译十分钟。治理办法有几个一是变体裁剪编辑器里收集当前项目实际用到的关键字集合把用不到的变体剪掉二是按需编译加载到某个材质时才编译对应变体三是运行时用关键字切换而不是一上来全量编译。方案优点缺点全量预编译运行时零编译编译慢、包体大按需编译启动快、包体小首次使用卡顿变体裁剪两者兼顾需要工具链支持实际项目往往是三者组合发布包预编译常用变体裁剪死变体稀有变体按需编译再用后台编译缓解卡顿。5.3 Shader 跨后端编译与平台处理Shader 要跑的图形后端不止一个。比较成熟的思路是写一份高层级着色语言离线编译成每个后端需要的中间表示再转成各目标平台的最终格式。这样架构上就有一个编译工具链独立于运行时。跨后端处理的典型差异很多常量缓冲的命名规则不同某些后端不允许循环里有动态次数纹理采样坐标的翻转约定不同。这些差异不要散落在渲染代码里应该集中在材质和编译管线表单中。我习惯在 Shader 外层维护一张平台适配表比如某个后端需要翻转 UV就只在一个统一函数里做全局只改一处。6. 渲染图与资源调度把 Pass 依赖变成一张有向无环图6.1 为什么现代渲染器需要渲染图延迟渲染和后处理链路一旦复杂起来Pass 之间的依赖关系就很乱。比如阴影 Pass 的输出要喂给光照 Pass光照 Pass 的结果要进 BloomBloom 的结果要到 ToneMapping。每个 Pass 可能需要多个临时纹理手动管理就容易出错忘了释放、重用冲突、Barrier 位置不对。渲染图的思想是把每个 Pass 声明成节点资源声明成边资源从哪个 Pass 生成、被哪些 Pass 读取形成一张有向无环图。渲染器遍历这张图自动确定资源生命周期和同步点。第一次接触会觉得绕但它解决的问题非常实在再也不用自己算“这个临时纹理第几个 Pass 可以回收”。6.2 资源别名与同步自动化带来的收益渲染图的第一个收益是资源别名。多个临时纹理生命周期不重叠时可以共用同一块显存。比如 SceneColor 在延迟光照之后不再保留Bloom 输入和 ToneMapping 输出就可以复用同一块内存。这一下能把后处理临时资源从几百兆降到几十兆对显存受限的平台尤其重要。同步自动化也更安全。传统写法里 Barrier 要自己插漏一个就花屏或者性能回退。渲染图知道每条边的读写依赖在 Pass 边界自动插入需要的状态转换和同步相当于把驱动底层要求的细节从业务代码中抽走。我接入后的第一周就把原来好几处多年没定位到的花屏问题解决了。6.3 渲染图在实践中的取舍但渲染图不是银弹。它要求整个渲染流程尽量可静态描述遇到动态分支比如“有反射探针才渲染反射 Pass”会变得别扭。实践中的做法是让渲染器支持多个子渲染图动态条件走另一个子图而不是试图在一个图里塞满所有可能性。另外过度依赖渲染图自动同步有时候会抑制驱动本身的优化。有些驱动在连续 Pass 之间能自己调整资源布局自动同步插入太多反而可能削弱这种优化。所以渲染器里一般会提供一个“同步策略”开关默认自动高级模式允许手写 Pass 边界。我用下来的经验是先用渲染图建立基线正确的工程再针对热点 Pass 做手工微调不要一开始就手写所有同步。7. 调试与性能分析渲染架构落地的最后一块拼图7.1 基础设施命名、标记和崩溃排查一个渲染架构好不好用调试设施占很大比重。第一件事是为每个 Pass、每个资源命名把底层 API 对象挂上可读字符串这样帧调试工具捕获时能直接看清整个 Pass 顺序。第二件事是区域标记用调试标记把一段录制命令标记为 ShadowPass、GbufferPass性能分析器里就能一眼定位哪段耗时高。GPU 崩溃设备丢失是渲染调试里最头疼的问题通常是资源使用方式不对或者被提前释放。遇到这种情况常规做法是打开图形 API 的验证层把日志留下来再配合帧捕获回放逐 Pass 排查。我有一个很管用的习惯在提交命令之前对关键资源做有效性断言断言失败就暂停录制并输出资源 ID。配合资源管理器很快能定位是谁借走了内存没还。7.2 瓶颈定位CPU Bound 还是 GPU Bound判断渲染器瓶颈在 CPU 还是 GPU有个一分钟办法把分辨率或渲染目标尺寸调低帧率几乎不动说明瓶颈在 CPU帧率明显提升说明瓶颈在 GPU。这个粗筛能确定后续优化方向。再往下就要用性能分析器细分。CPU 侧看命令录制耗时、剔除耗时、排序耗时GPU 侧看每个 Pass 的 GPU 耗时、顶点吞吐量、像素填充率。Draw Call 过多时CPU 侧命令录制时间会涨顶点过多时GPU 顶点阶段会涨过度绘制时像素阶段会涨。架构上要保证这些数据能定位到具体 Pass而不是一团浆糊。7.3 架构迭代时期的现实建议渲染架构不可能一步到位重构时最怕功能被破坏。我的建议是先保证现有功能稳定跑通写一个简单的前向管线验证架构再往上叠渲染图、延迟光照等特性。重构渲染器时留一层薄的兼容转接层把旧的画调用映射到新架构上让上层特性逐步迁移而不是一次重写所有东西。另一个经验是给渲染器写一份帧流程文档把每一帧的 Pass 依赖、使用到的资源、期望时序写清楚。这份文档是重构和接手的最好参考。我见过很多团队一讨论渲染架构就吵其实是大家对帧流程的理解不一致。把帧流程文档和实际采集的帧数据对照着看很多歧义会自然消失。最后再分享一个实际体会。渲染架构的每一次调整都不要只看单帧指标要观察持续十分钟以上的稳定性尤其关注资源泄漏和设备丢失这类偶发问题。先确保正确性和稳定性再去抠那几微秒的优化否则架构基础不稳后面所有特性都会叠在沙子上。
返回列表