ARTICLE DETAIL

资讯详情

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

游戏引擎渲染系统架构深度解析:从场景管理到GPU提交

游戏引擎渲染系统架构深度解析:从场景管理到GPU提交 渲染系统是游戏引擎里最能体现工程功底的一块。很多刚入行的同学觉得渲染就是写Shader、调材质、往屏幕上画三角形但真正到了项目里你会发现写出一个能跑的Demo容易写一个能撑住大世界、多人同屏、大量动态物体的渲染系统难度完全不在一个量级上。我见过不少项目前期花了很多精力在特效和美术表现上结果一到压测就掉到十几帧定位了半天发现不是某个Shader复杂而是渲染系统架构没搭对。这篇是《游戏引擎架构深度解析》的第二篇专门聊渲染系统的架构。上一篇我们讨论过引擎整体骨架这次就到最热闹、最复杂的渲染环节了。适合正在做引擎、或者想从应用层往底层走的同学也适合项目里负责性能优化、需要理解渲染链路的技术美术和客户端程序员。渲染系统的架构简单说就是一件事把业务逻辑里“有什么东西”变成GPU能执行的“画这些像素”的指令并且尽可能高效、稳定、可维护。听起来直接但中间隔着场景管理、资源管理、线程调度、显存同步、API抽象、驱动适配一堆关卡。你想画一个角色背后是从Transform、Mesh、Material到GPU资源上传再到Draw Call提交、渲染状态切换的一整条流水线。任何一个环节设计得不好都会在特定场景下炸给你看。1. 渲染系统的架构定位它到底在解什么题1.1 渲染系统的职责边界先画个边界。渲染系统不是“画图系统”它不负责产生美术内容也不负责物理模拟它拿到的输入是“场景里应该显示哪些对象、它们是什么材质、在什么位置”要负责的输出是“一帧图像”。中间的所有环节才是渲染系统的核心地盘。实际项目里这个地盘可以拆成四大块。第一场景数据管理。游戏里的场景通常由玩家、怪物、建筑、植被、粒子、灯光组成但这些对象不是都归渲染系统管。渲染系统一般通过组件、接口或者反射等方式从上层拿到“可渲染对象”集合。这里需要解决的问题是场景里的对象这么多怎么快速找出当前相机看得见的那一部分。第二渲染资源管理。Mesh、Texture、Shader、Material、Pipeline State这些资源从加载、上传、缓存、更新到销毁是一个完整的生命周期问题。谁在什么时候把模型数据送进显存纹理什么时候异步上传Shader编译要在什么线程做这些细节直接影响加载速度和运行时的流畅度。第三绘制流程组织。这就是管道Pipeline的概念一帧里面先画什么、后画什么哪些物体走前向渲染哪些走延迟渲染阴影贴图、反射探针、后处理、半透明排序、UI叠加这些Pass和子Pass必须由渲染系统统一调度。第四硬件API封装与驱动。现在常见的图形API有DX12、Vulkan、Metal也有还在大量使用的老旧API。渲染系统不能把业务代码绑死在某一个API上所以在引擎内部一定会有一层抽象把底层API差异吃掉。好的架构里业务代码甚至不会感知到自己跑在DX12还是Vulkan上。1.2 渲染系统的两个评价维度正确性与效率渲染系统最后能不能用就看两件事第一画得对不对第二够不够快。画得对不对指的是渲染结果的确定性。同一帧的画面在相同输入下必须稳定复现不能在A机器上正常、在B机器上出现黑块、贴图闪烁、延迟一帧的错误。这个“确定性”往往是渲染架构最大的隐性成本来源。你会发现排查一个花屏问题最后可能追到一个资源生命周期的问题某张贴图在帧提交后才被释放导致GPU读取到一半数据没了。够不够快则要看整个渲染系统能否在16毫秒60fps内完成一帧的准备和提交。时间预算通常要拆得很细场景剔除多少毫秒、资源准备多少、命令编码多少、等待GPU多少。我曾见过一个项目的帧时间分配里光一个Resource Upload就吃掉4ms最后改成本帧延迟提交加预分配缓存直接降到0.5ms。方案本身不难难的是架构上给这种优化留了空间你才知道问题出在哪一层。说得直白一点渲染系统架构的任务就是用一套清晰的分层和数据结构让正确性可保证、让性能瓶颈可定位。做不到这两点其它都是空中楼阁。2. 分层架构设计渲染系统为什么必须拆成多层2.1 一个典型的四层渲染架构如果给一个成熟的商业引擎或自研引擎画剖面图渲染系统从上到下大致是四层。第一层是引擎应用层也叫业务接入层。这一层负责从游戏逻辑中收集需要渲染的对象常见的做法是组件系统里挂一个RenderableComponent逻辑帧更新时把Transform、可见性、材质引用同步到渲染系统。这个层的核心原则是不直接碰GPU API也不关心GPU资源怎么排布。第二层是场景与剔除层。这一层维护渲染场景的加速结构接收应用层注册进来的渲染对象提供射线检测、视锥剔除、遮挡剔除、距离剔除等服务。世界大的时候这里还需要处理对象的增减和动态分区比如四叉树、八叉树、双层网格或BVH。第三层是渲染图与Pass调度层。这是现代引擎渲染架构的核心典型代表就是FrameGraph或RenderGraph。它把一帧的渲染过程拆成一个个Pass每个Pass声明自己读哪些资源、写哪些资源资源依赖关系连成一张有向无环图。调度器再根据这张图决定执行顺序、资源生命周期、同步点甚至异步计算重叠。没有这层抽象你要手工管理RT切换、资源屏障、生命周期项目一复杂就是灾难。第四层是驱动抽象层也就是RHIRender Hardware Interface。这一层封装具体的图形API向上提供统一的设备、队列、命令缓冲、资源对象、渲染状态抽象向下对接DX12、Vulkan、Metal或老旧的GL。现代API很多概念是一致的比如CommandBuffer、PipelineState、DescriptorSet封装起来语义很清晰。值得提醒的是RHI提供的抽象粒度不能太细否则它本身会成为性能瓶颈。2.2 分层解决的实际问题很多人以为分层是“工程师洁癖”好像只是为了代码好看。实际上这几个层解决的是非常现实的问题。第一个问题是可替换性。硬件API在变老旧API迟早要淘汰引擎需要支持主机、PC、移动端不同平台。如果没有RHI这一层全工程散布着API调用更换底层API等于重写一遍引擎。有这一层新增平台只需要写一套RHI实现上层代码不动。第二个问题是可测试性。渲染逻辑一旦和GPU紧耦合单元测试几乎无从下手。分层的意义在于场景剔除和Pass调度可以脱离真实GPU跑测试你用软件模拟的深度信息也能验证遮挡剔除逻辑对不对。我见过一些团队会把剔除结果跑在CPU端做验证构建一个纯逻辑测试环境稳定性明显提高。第三个问题是并行优化空间。没有分层你想把“场景准备”和“命令编码”放到不同线程上去会发现职责纠缠在一起根本拆不开。分层的边界本身就是天然的并行切分点。应用层收集对象、剔除层计算可见性、调度层生成RenderPass这三者在帧间可以流水线化执行。3. 渲染核心子系统的内部拆解场景管理、资源管理和GPU提交3.1 场景管理别把整个场景交给GPU场景管理在整个渲染系统里常被低估但它决定了渲染系统能撑多大复杂度。核心问题是一个几平方公里的大世界可能有几十万个物体你不能每一帧把所有网格都提交给GPU。业界通用的做法是引入空间加速结构。室内场景常用BSP或遮挡体开放大世界常用四叉树、八叉树或双层网格配合动态加载。每帧开始后渲染线程拿到相机位置和视锥体从加速结构里取出候选集合做一次精确视锥剔除再做遮挡剔除和距离剔除。这一步做完几十万物体通常只剩几千。这里有一个我在实际项目里踩过的坑剔除要求在CPU上维护一份覆盖全场景的数据但世界加载和卸载很频繁如果空间结构更新策略写得太简单会出现“删除对象还留在渲染队列里”的悬空引用画面表现为死亡模型闪现。建议做法是空间结构里存的是对象ID或共享句柄而不是裸指针删除走延迟回收等当帧完成之后再清理。这个问题在架构层面就能规避不要等出了Bug再去打补丁。3.2 渲染资源管理生命周期和上传策略决定成败渲染资源管理相比普通内存管理有更多约束。首先资源要跨CPU/GPU边界传输其次资源可能要被多个渲染Pass引用再者底层API对资源的创建、上传、销毁时机有严格规定尤其Vulkan和DX12要求显式管理资源屏障和生命周期。Mesh、纹理、着色器是三类主要资源。Mesh通常是静态数据加载后上传到GPU之后基本不动纹理则可能是流式加载地形、贴图集、虚拟纹理都有各自的流送策略着色器更麻烦源码要编译成平台相关的字节码还涉及热更新、管线状态缓存。实际架构上我建议搞一个“资源上传队列”。当你从磁盘加载完模型不是立刻上传到显存而是交给后台上传线程它把上传专用缓冲区里的数据拷到GPU资源并在合适时机发信号。帧逻辑不必等待上传完成只有当该资源真正被当前帧绘制引用时才需要同步等待。生命周期方面GPU使用的资源必须确保在GPU执行完相关命令之前不被释放。很多引擎用“渲染帧延迟计数”来解决Main线程提交第N帧命令后第N-K帧的命令可能还在GPU中执行因此资源释放要推迟至少K帧。这个K的大小等于CPU领先GPU的帧数。3.3 命令提交从描述性命令到GPU可执行指令这是渲染架构里最像编译流程的部分。上层的RenderGraph是一张逻辑依赖图但它不能直接被GPU执行。调度器要做三件事。第一优化Pass顺序。根据资源依赖和屏障需求排序尽量减少RenderTarget切换和资源屏障数量。同一个RenderTarget的Pass尽量挨在一起避免反复切换。第二生成命令缓冲。每个Pass展开为一系列命令设置管线状态、绑定描述符、设置顶点缓冲、索引缓冲、DrawCall。这个展开过程可以并行因为不同RenderPass的命令之间没有数据依赖。第三提交执行。现代API一般支持多队列图形队列、计算队列、拷贝队列可以并发执行。架构上应该在RHI层暴露队列抽象让资源上传走拷贝队列、纯计算任务走异步计算队列、主渲染走图形队列把GPU的并行能力吃满。我见过不少团队的架构在命令提交这层做得很“接地气”就是CPU端一堆DrawCall挨个调用看起来代码没多少抽象但一上DX12就卡得没法看。原因就是老式命令模式不知道资源屏障在哪让驱动拼命猜。RenderGraph式结构的杀手锏就是把“屏障”变成可计算的依赖信息让调度器提前布局而不是让驱动在运行时猜。4. 多线程与同步渲染架构的高性能关键4.1 渲染线程模型从单线程到帧内多线程游戏引擎的渲染线程模型经历过明显的演化。早期引擎就是主线程直接调用图形API逻辑、物理、渲染都在一条线程上简单但帧率上限低。后来拆出专门的渲染线程主线程负责游戏逻辑渲染线程负责生成并提交命令两个线程之间通过帧数据队列同步。这个模型现在仍是很多引擎的主流。再往上有两个演进方向一是复用多个工作线程并行做剔除、裁剪、生成命令二是把整个帧的渲染数据从逻辑帧中彻底解耦让渲染线程可以提前最多两帧开始工作。比如用Job System把可见性结果分成若干区块每个区块一个线程去做精确剔除、LOD筛选、实例化合并最后汇总到命令生成阶段。这里要特别强调帧数据的拷贝问题。渲染线程不能直接读游戏逻辑的数据否则会出现“一边写一边读”的数据竞争。架构上通常把跟渲染相关的数据打包成一个不可变的FrameData结构逻辑线程每帧生成一份快照渲染线程只读快照。这个设计看起来有点浪费内存但换来的是确定性而且快照创建可以在逻辑线程并行处理。4.2 帧同步与延迟CPU领先GPU几帧最好CPU和GPU执行速度不同如果CPU每帧提交完指令后立刻等GPU执行完帧率就完全被GPU拖死如果CPU无限制地领先GPU又会导致操作延迟和内存堆积。所以架构必须设计一个有限长度的帧环形队列。经典做法是维持一个“帧保险丝”CPU提交完第N帧命令后不等待直接计算下一帧但如果CPU已经比GPU领先了比如三帧就主动阻塞等GPU消费完一帧再继续。这个数字一般从2到4不等视API和平台而定。太大会增加操作延迟太小会让CPU频繁空等。同步原语方面现代API使用Fence和Semaphore。Fence用于CPU等待GPU完成某个队列操作Semaphore用于GPU队列之间的先后依赖。架构上有一个很容易踩的坑滥用CPU侧的wait造成管线空闲。比如每帧都强制“上传完成后再渲染”就会彻底破坏流水线。正确做法是把上传、渲染放到独立队列用Fence只在真正需要数据的那个Pass之前等待。我个人的建议是把“等待”作为显式的、罕见的操作来设计。如果一个系统里到处都是wait说明数据流设计出了问题。健康状态下的渲染架构绝大多数帧应该无阻塞提交只有资源首次加载或跨帧同步点才出现等待。5. 我在实际项目中遇到的渲染架构问题与排查技巧5.1 花屏、黑闪和资源竞争八成是生命周期问题渲染系统的问题有个特点表象在屏幕上根子往往在数据管理上。黑闪、花屏、贴图发紫莫名其妙消失我排查下来大半都是资源生命周期和帧同步出了问题。最典型的场景是美术加载了某张角色贴图玩家快速切场景贴图被旧引用释放但还在GPU命令队列里被引用于是这一帧出现花屏眼睛还没来得及看清下一帧又恢复正常。这种“一分钟闪现一次”的问题最难复现需要抓帧对比还得在架构层面看看资源释放是不是做了帧延迟。排查顺序我通常是这样先看崩溃或花屏是否伴随资源ID失效再查资源引用计数和释放时机最后用渲染调试工具抓一帧看缺失的纹理、网格和管线状态有没有在加载列表里。抓帧工具各平台都有PC上RenderDoc主机各有厂商工具移动端也有类似方案。5.2 帧率瓶颈先判断CPU Bound还是GPU Bound调性能时最容易犯的错误是闭眼优化细节。正确的做法是先定位瓶颈是CPU还是GPU。方法很简单降低分辨率如果帧率提升了就是GPU Bound如果帧率没变那就是CPU Bound。CPU Bound常见原因有剔除算法低效、命令生成线程有串行阻塞、场景对象数量过大、GC或资源加载线程卡了帧。GPU Bound则常见于Overdraw过多、后端MSAA、复杂后处理、带宽不足。架构设计里对CPU Bound的优化空间更大因为你可以并行化、可以提前剔除、可以合并DrawCall。如果是GPU Bound多半是渲染策略问题比如后处理链条过长、分辨率策略不对。我还习惯在引擎里做一套“定时器层级”每个层级别单独计耗时比如剔除多少、可见性回收多少、命令生成多少、提交多少。这样性能问题一出现直接看报表就知道卡在哪层不用靠猜。5.3 加载卡顿流式加载不能挡住主帧最后说一个非常影响体验的问题加载。很多项目建模阶段不觉得一到大地图就露馅。玩家走着走着卡一下鬼知道是加载贴图、编译Shader还是同步上传闹的。架构上要对“加载”做等级划分紧急加载走同步必须立刻用预加载走后台提前几秒开始流式加载更进一步以区块为单位按相机移动方向提前触发。而Shader编译最好离线处理或首次运行时生成缓存千万不要在战斗中间临时编译否则卡死没商量。上传这块我强烈建议采用命令缓冲后台提交在主帧看不到上传引起的阻塞。你可以在调试模式里加一条规则超过N毫秒的同步上传一律报警。有了报警这类卡顿就会变成可追踪的性能问题而不是玄学。写到最后的一点体会不算总结就说一句实在话渲染系统架构的难度不在某个算法而在你看不到的地方——数据流、生命周期、同步。Shader写错了还能靠眼睛调架构写歪了项目到后期会天天出奇怪Bug问题还很难稳定复现。如果你要做一个新引擎或者重构现有渲染模块先把场景管理和资源生命周期这两块想清楚再去追求高级的渲染特性和华丽的后处理。地基不稳画得再漂亮也经不住多人同屏的一轮压测。希望这篇拆解对你自己的架构设计有点帮助有不同见解欢迎一起聊。
返回列表