ARTICLE DETAIL

资讯详情

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

游戏引擎渲染架构核心解析:线程模型、剔除与GPU-Driven

游戏引擎渲染架构核心解析:线程模型、剔除与GPU-Driven 这两年我和团队在做一款自研引擎的渲染系统重构期间踩了不少坑也把Unreal、Unity、CryEngine那套渲染架构来回翻了好几遍。说实话网上聊渲染管线的教程很多但大部分都停在API层面——怎么调DrawCall、怎么写Shader、怎么用ComputeShader真正讲清楚渲染系统在引擎里到底以什么骨架在跑的文章其实很少。这篇《游戏引擎架构深度解析二》想聊的就是这个骨架渲染系统架构。如果你正准备写自己的渲染器、或者在工作中要动引擎渲染模块的架构这篇内容应该能帮你省掉不少折腾。我不会一步步带你调Shader也不会贴一大段Build Pass列表而是从数据流、线程模型、场景剔除、资源管理、架构演进这几个维度把一套现代渲染系统拆开给你看。每一步我都会讲清楚为什么这么设计以及在真实项目里会遇到什么问题。毕竟渲染系统的架构设计本质上是在回答一个问题《一个3A场景动辄上百万三角面、几千个物体你要怎么在16毫秒内把它们全部画出来》1. 渲染系统在整个引擎里的真实角色一次帧的完整旅程很多刚入门的人会把渲染系统理解成调用图形API画东西的代码这个理解不能说错但太窄了。渲染系统真正的职责是从游戏逻辑层拿到世界当前长什么样的描述然后把它变成屏幕上那一帧像素。这个过程横跨了引擎的物理、动画、逻辑、资源、渲染等多个模块渲染系统只是其中一环——但它负责收官。1.1 一帧数据从哪来场景描述与渲染对象的解耦假设你正在玩一个开放世界游戏角色站在山顶远处有村庄近处有野草天空有云。此时游戏逻辑层知道的是角色的位置、NPC的AI状态、物理引擎算出来的刚体位置、动画系统播到第几帧。这些东西通通不是渲染系统能直接用得上的格式。因为逻辑层关心的是规则渲染层关心的是样子。引擎需要在两者之间做一层翻译这层翻译就是渲染系统输入端的核心工作。实际工作中渲染系统面向的是经过抽象的场景图Scene Graph或ECS实体组件系统中的渲染组件。每个可渲染的对象最终会被收敛成一条渲染数据通常包含几类信息变换信息世界坐标、旋转、缩放或者一个预计算好的变换矩阵。几何信息引用哪个网格Mesh、哪些顶点缓冲、索引缓冲。材质信息Shader的引用、贴图资源、参数列表颜色、粗糙度、金属度。语义信息物体类别、可见性标记、是否参与阴影投射或接收。额外状态比如LOD级别、动画蒙皮矩阵列表、GPU实例化分组键。这些数据不会散乱地塞给渲染循环而是会进入一个渲染场景Render Scene的中间层。一个成熟的架构里渲染系统会维护自己这份场景数据的双缓冲一份供游戏逻辑侧异步写入一份供渲染循环侧只读消费两边通过命令或同步点交换。这么设计的原因很简单——物理和逻辑更新通常跑在帧率的一倍或多倍频率比如你的游戏跑60FPS但物理可能跑120Hz如果渲染侧直接去读逻辑侧共享的可变数据读一半数据被改了就会撕裂花了半天画出一帧错乱的东西Debug时痛点十足。1.2 渲染侧产出的结果去向后处理、合成与显示链路渲染系统运行完一整轮后产出通常不止是一张屏幕图。现代渲染器在GPU上会维护一组Render Target渲染目标包括场景主颜色缓冲区HDR格式居多比如R16G16B16A16_FLOATG-Buffer延迟渲染时的位置、法线、颜色、材质属性等多张缓冲深度缓冲区Depth/Stencil Buffer各类中间缓冲阴影贴图、环境光遮蔽图、反射探针图最终的屏幕图是这些缓冲经过后处理合成出来的。合成阶段跑着一串有序Pass色调映射Tone Mapping把HDR压到LDR范围、泛光Bloom提亮高光、抗锯齿TAA或MSAA平滑边缘、色彩分级做风格化调整最后再叠上UI层、调试文本或调试可视化比如渲染用的光照热力图。这里有一个很多新项目容易忽略的架构点整个渲染结果的输出要经过一个平台差异收口层。D3D11、D3D12、Vulkan、Metal这四家API在呈现Present机制上差异比较大有些要求回读刚提交的帧有些有交换链Swap Chain的宽高与旋转信息。引擎渲染架构如果不做这层收口后面移植主机或换API时会很痛苦。我自己习惯的做法是在渲染系统最末端抽象一个BackBuffer Composer接口它负责接收最终合成纹理、处理SwapChain尺寸变化、对应垂直同步策略。上层渲染代码永远不去碰具体平台API的呈现细节。1.3 帧时间预算渲染架构设计的隐形约束聊架构不能不看预算。渲染系统的最高指挥其实是时间一帧16.6毫秒60FPS或33.3毫秒30FPS。从这个总预算里渲染侧通常只能分到一半甚至更少因为逻辑、物理、动画、加载、网络同步都要占用CPU时间GPU本身也有自己的执行时间。具体来说一个典型的PC 3A项目在1080P下渲染线程的CPU时间预算大概在68毫秒剩下要给其他系统。而移动端更紧张可能整个渲染加逻辑只有16毫秒总预算。这意味着渲染系统架构里做的每一步设计——多线程、剔除、合批、资源流式加载——本质上都是从时间的夹缝里抢效率。理解了这一层你再看那些看似复杂的架构决策理由就都清晰了。2. 线程模型是渲染系统的大脑三线程协作与帧同步机制渲染系统架构里最影响整体性能的不是用了什么高级渲染算法而是线程模型怎么搭。很多引擎的渲染瓶颈不在GPU不够快而在CPU侧的命令生成太慢——都在等同一个线程一个一个地提交DrawCall。把线程模型做好是渲染架构的第一基建。2.1 经典三线程结构游戏线程、渲染线程、工作线程现代商业引擎普遍采用多线程渲染模型最典型的结构是游戏线程Game Thread也叫逻辑线程负责处理玩家的输入、游戏规则、AI、动画状态机。它产出的是世界状态。渲染线程Render Thread独立于游戏线程专门负责把世界状态转成API命令、进行剔除、排序、生成DrawCall并提交给GPU。工作线程Worker Threads一个或多个承接动画计算、蒙皮、粒子模拟、剔除等可并行任务为游戏线程和渲染线程减负。这套结构下游戏线程和渲染线程不是互相等待的。游戏线程在第N帧更新逻辑渲染线程同时在第N-1帧或者更早的帧生成命令。两者之间通过命令缓冲和同步标记衔接达到流水线并行的效果。不过要注意渲染线程独立不等于渲染线程什么时候都不卡。如果某帧场景物体暴涨、或者某个Pass特别重即使游戏线程给了足够的提前量渲染线程照样可能因为来不及消费帧数据而拖后帧。Unreal引擎里有个概念叫Game Thread Bound和Render Thread Bound就是区分瓶颈到底卡在哪条线程。排查性能问题时第一步就应该是看Profiler里Game Thread和Render Thread各自的耗时如果你连这个都没拉出来看后面的优化都是瞎猜。2.2 帧同步机制环形缓冲与命令提交模式的取舍渲染线程怎么拿到游戏线程产出的数据早期引擎的做法是彻彻底底的等待游戏线程更新完世界状态锁住场景渲染线程开始工作游戏线程等到渲染线程完成再更新下一帧。这种同步方式简单但两个线程的耗时是串行相加的性能天花板很低。现代引擎的做法普遍是帧间流水线数据拷贝。游戏线程完成第N帧逻辑后把渲染相关的数据组织成不可变的结构丢进一个环形缓冲Ring Buffer里渲染线程从缓冲消费第N-1帧或第N-2帧的数据两者之间只有极短的锁或原子操作。这个缓冲空间一般支持2到3帧延迟。还有一个关键决策是命令提交方式。传统做法是渲染线程直接调用图形APIglDrawElements、DrawIndexedInstanced等但直接调用会造成严重的CPU阻塞。更好的做法是引擎内部维护一个命令列表的编码器Command List / Command Recorder视API而定在D3D11和OpenGL里你没法真正预录命令只能用粗粒度的Deferred Context或扩展机制比如OpenGL的Display List虽然后者基本被业界抛弃。在D3D12和Vulkan里API原生支持Command List在CPU侧多线程录制这给了引擎很大的自由度渲染线程可以派生多个工作线程同时录命令最后统一提交到GPU队列。我们引擎选择的是在底层封装一个RenderCommandStream上层渲染逻辑把绘制这个Mesh、绑定这个Shader、设置这组常量的指令往里写由提交器批量转成底层API资源绑定和DrawCall。这样上层不用关心Vertex Buffer的具体Layout也不用关心当前Pipeline State的缓存匹配性能和维护性都能兼顾。2.3 线程同步的三个坑位资源版本化、延迟销毁与fence管理多线程渲染里最容易出Bug的地方是资源和对象生命周期。逻辑线程在帧N创建了一个纹理渲染线程在帧N-1还没消费完——如果你立刻删除这个纹理渲染线程就会用到一个悬空引用轻则花屏重则驱动崩溃。业内通用的解法是延迟销毁Deferred Destruction任何渲染资源要销毁先注册进渲染线程的待销毁队列渲染线程确认命令队列里不再引用它后再到它真正过完安全帧数后释放GPU内存。这本身不难难的是确定安全帧数是多少。保守做法是延迟3到5帧激进做法是通过GPU Fence查询某帧命令是否执行完毕再释放。Fence查询对移动端和主机的兼容性差异较大经验是PC上放心用主机上注意提交队列深度移动端尽量别每帧同步WaitIdle否则性能血崩。另一个坑是数据竞争。游戏线程在写渲染对象的Transform渲染线程在读同一个Transform加锁解决不了性能问题反而会引起渲染线程卡顿。更好的方案是让数据所有权清晰游戏线程在帧结束时把该帧所有渲染对象的变换矩阵批量拷进一个帧级缓冲区渲染线程只读这个缓冲区。拷贝代价很低——几千个物体也不过几百KB内存跟一次同步等待的代价完全不在一个量级。3. 场景数据组织的艺术剔除、排序与渲染队列的形成当渲染系统拿到一堆要画的物体不能直接傻乎乎地按提交顺序画。GPU在几何阶段虽然能裁剪但每个顶点进来还是要走一遍顶点着色器对远处十万个不可见三角形毫无必要。正确的姿势是把可见性判定的活放在CPU侧尽量少地向GPU交付无效几何体。这一节讲的就是渲染系统第二个层次的核心业务把世界的物体组织成一批高效的渲染指令。3.1 可见性剔除矩阵视锥剔除、遮挡剔除与距离剔除可见性剔除通常分好几道工序视锥剔除Frustum Culling最快的一道判断物体的包围球或包围盒是否与视锥体的6个平面相交。不相交的直接淘汰。现代引擎里包围体常是AABBAxis-Aligned Bounding Box或Sphere具体数据结构可以放进场景空间划分树里加速。遮挡剔除Occlusion Culling做了视锥剔除后物体可能仍在视锥里但被山体或墙壁完全挡住。早期方案用CPU做遮挡查询如D3D的OcclusionQuery逐个渲染包围盒测试像素可见性会有1帧延迟现代方案流行在GPU上做层次Z缓冲Hierarchical Z-Buffer或者用软件栅格化的粗网格快速生成遮挡深度再用它批量测物体的AABB。主机平台上这种软件遮挡剔除相当常见。距离剔除Distance Culling按物体与相机的距离淘汰。这个通常和LOD策略联动近处用高模、远处用低模、再远干脆不画。小物体剔除屏幕上小于某个像素面积比如0.1平方像素的物体直接不画因为画了也看不见细分差异。这一步在密集场景一堆花花草草、满地碎石里能省下海量DrawCall。这些剔除必须放入一个统一结构里不能各做各的。我见过一些项目视锥剔除和遮挡剔除分属两个模块结果执行顺序不对导致背面物体又被提了一遍。合理的设计是把剔除链做成流水线每个物体依次经过距离、视锥、遮挡三道淘汰中途任何一道被剔除就短路退出不再进入后续排序阶段。3.2 空间加速结构的选择八叉树、BVH、网格还是四叉树做剔除不能每次遍历整个世界几万个物体。引擎需要一份空间加速结构来快速拿到相机附近有哪些物体。主流选项有四叉树/八叉树适合地形、植被这类分布不均匀的大世界。八叉树对动态物体更新麻烦但适合静态场景的快速查找。BVH包围体层次现代自研引擎偏爱它来做遮挡剔除和射线检测对动态物体更友好更新时增量重建比八叉树容易。均匀网格适合对象密度均匀的场景比如室内、竞技场。实现简单但处理稀疏大世界时内存浪费多。Tiled-Based结构近年有人用GPU侧的Tile结构管理小物件CPU只负责粗粒度区域管理。这个对超大流式场景更有利。实际项目里没人只用一种。我们引擎是远景用自定义低分辨率网格索引近景用BVH距离越远网格越粗物体会按Tile级别批量合批近景再走精确BVH做逐物体剔除。这套组合在1080P下对3万物体场景的CPU剔除耗时大约0.6毫秒远优于单层结构。3.3 渲染队列排序从材质切换到前后序依赖剔除完了剩下的物体依然不能无脑画。因为GPU状态切换切换Shader、切换纹理绑定非常昂贵现代GPU渲染相同材质物体的开销远低于频繁切换材质。排序策略要考虑几个优先级不透明物体优先按材质/Shader/纹理分组尽可能减少状态切换。同材质内再按距离从前到后方便Early-Z剔除。透明物体必须按从后到前排序绘制因为透明混合依赖深度排序画错了半透明效果就完全不对。特殊Pass阴影Pass的物体排序和主Pass不同通常按光源视锥剔除后直接画不纠结材质切换因为阴影深度Pass不涉及纹理贴合太深。可批处理分组能GPU实例化的物体相同Mesh、相同材质优先聚在一起一次Instance Draw代替几百次独立Draw。排序的实现通常是给每个物体打一个排序键键由材质ID的高位和距离的低位组成。排序键仅需一次64位整数排序效率很高。这块是CPU侧比GPU侧更能影响帧率的隐藏大头只有做过主机优化的团队才能真正体会卖你个三千块钱显卡玩游戏不卡但画面复杂一点合批没做好照样能让你CPU针扎似的刺痛。4. 资源管理与GPU带宽纹理、网格与Shader的架构级处理渲染系统除了逻辑调度和绘制命令生成还要管理海量GPU资源。一个大型项目的资源动辄几十GB不可能全部置身显存里。资源作为渲染系统的第三产业怎么存储、怎么上传、怎么复用直接影响加载时间、峰值显存和画面卡顿。4.1 资源生命周期加载、上传、引用计数与流式加载资源管理的第一基础是生命周期模型。游戏运行时资源要被动态加载和卸载比如玩家走进一个城镇程序要加载几百个网格和贴图离开后要卸载释放显存。这套机制的核心是引用计数资源被场景里的对象引用时计数加1不再引用时计数减1减到0就进入延迟销毁流程清理。但光引用计数不够。现代引擎还要支持流式加载Streaming即资源数据不一次性全部进显存而是按距相机的远近、纹理Mip等级逐步加载。实现上通常依赖底层IO线程异步读盘后台解压再上传到GPU。渲染系统需要能接受上一秒还是模糊贴图下一秒高清贴图加载完成这种渐进替换。我印象很深的一个问题是纹理上传带宽很容易成为隐藏瓶颈。你可能做好了加载流程但忘了——从CPU内存到GPU显存的带宽是PCIe哪怕PCIe 4.0 x16也就约32GB/s而场景一次切换大小几百MB的数据要用掉十几次带宽预算这直接造成转场景时的卡顿。所以架构上要强调上传合并比如使用Staging Buffer把多张小纹理批量拷进一块内存再一次性map/unmap上传而不是每张纹理单独上传。4.2 网格数据格式与GPU友好的顶点布局网格Mesh在渲染系统里的地位自不用多说。但架构级的问题在于网格数据存多少份怎么包装成GPU友好的顶点布局常见的设计是CPU侧保留一份可编辑的原始数据用于物理、CPU蒙皮、顶点动画GPU侧另建一份经过优化的顶点缓冲和索引缓冲。两份数据在修改后要保持同步维护成本写起来很烦。顶点布局的优化方向很明确按用途分Stream。有些引擎使用单独的顶点流存放位置、法线、UV、切线、颜色等由材质决定实际绑定哪几个流。好处是减少顶点带宽浪费——如果一个Shader只需要位置和法线那就不必从纹理坐标流里读没用的数据。移动端尤其吃这套因为GPU内存带宽比PC紧张得多能省一点就多一分流畅。索引格式也要注意8位、16位、32位索引。一个顶点数超过65535的网格必须用32位索引但32位索引会让内存翻倍。架构上要自动判定并按需压缩到16位。最简单的办法是支持网格分块让每块顶点数低于65535内在用16位索引复杂度高很多但对于大批量小物件场景节省的索引带宽非常可观。4.3 Shader体系与变体管理渲染架构的暗面比网格和纹理更棘手的是Shader管理。现代渲染器里Shader不是孤立的可执行文件而是一套包含预编译变体、关键词开关、平台兼容的复杂体系。一个材质可能是PBR材质它支持是否使用法线贴图、是否使用遮挡贴图、是否开了视差映射、是否支持双面渲染、是静态还是骨骼动画等。这些选项乘起来一个材质可能生成几十个Shader变体Variant。渲染架构必须有机制管理这些变体否则会出现两个常见灾难运行时编译卡顿某个变体没用预编译第一帧碰上时才发现画面直接卡死半秒。变体爆炸几十个材质互相组合预编译时间暴增项目构建一次等半天。业界主流方案是给每个材质的关键词组合做哈希用资源系统统一管理变体预编译列表并且使用着色器库Shader Library模式让运行时从库中快速匹配变体避免动态编译。这个机制必须和材质资产的编辑锁定勾连美术在工具里改一个关键词自动触发对应变体的预编译登记。我自己的经验是Shader变体管理必须从引擎最早期就纳入设计而不是等项目做到中期再救火。因为牵涉美术资产、平台编译、运行时热更改起来成本极高。见过好几个项目把渲染功能写在单体Shader里一个几千行的超级Shader维护到后期基本是灾难。5. 现代引擎的渲染架构演进从固定管线的直写DrawCall到Frame Graph与GPU-Driven渲染架构不是一成不变的这几年行业里发生了一次比较大的范式转移从CPU逐个提交DrawCall、按Pass列表执行走向GPU驱动、自动依赖分析的模式。理解这个演进能帮你判断未来一两年你要用的引擎架构会朝哪个方向走。5.1 传统立即模式渲染简单但瓶颈明显早期引擎包括很多手游引擎还在用立即模式Immediate ModeCPU在每一帧把每个物体的DrawCall直接提交到API写完一个Pass再写下一个Pass。架构简单、调试直观、新手友好但瓶颈非常清晰每一帧CPU要做大量状态绑定和校验。每个DrawCall的CPU开销很高PC上DX11单DrawCall可能100200个CPU周期几万DrawCall就上两三毫秒。多Pass渲染时Pass间没有任何依赖分析开发人员得手动保证顺序漏了就是渲染错误。这个模式下团队的所有努力都在减少无效DrawCall上细节到材质切换顺序都影响帧率代码里到处都是手写的排序器和批量合并逻辑。能跑但产品一旦进入大世界或高密度场景改造是早晚的事。5.2 Frame Graph框架自动同步、资源复用与Pass依赖分析现代引擎如Frostbite、Unreal的RDG、Unity的SRP普遍走向Frame Graph。它的核心思路是每一帧开始时渲染系统建立起一张图节点是各Pass比如BasePass、Shadow、PostProcess节点之间有边表示资源依赖前者写纹理后者读纹理。有了这张图引擎可以自动做几件事自动推导Pass执行顺序开发者不用手写先阴影再主颜色再后处理的顺序改成声明式描述。自动生命周期管理某中间纹理只在Pass A到Pass C之间存活Frame Graph可以复用这块显存给别的Pass峰值显存下降明显。自动插入屏障Barrier在GPU上不同Pass之间可能改动同一块资源的状态需要同步。Frame Graph能算出在最合适的时机插入屏障指令避免频繁来回同步导致GPU停滞。对我来说Frame Graph最大的价值是消灭了整类Bug以前翻看代码很难找到某个渲染资源在哪被意外复用现在资源的所有权和有效期都被系统级托管了渲染代码可以写得像声明我要什么、产生什么、被谁用架构自检能拦截大部分谬误。5.3 GPU-Driven Rendering与可见性上卷顺着Frame Graph方向再迈一步就是GPU-Driven RenderingGDR。传统做法里剔除在CPU做生成DrawCall也在CPU做GDR则把物体列表当作GPU缓冲区数据剔除逻辑放在Compute Shader里跑GPU直接生成DrawCall命令甚至可以做间接绘制Indirect Drawing。这样做的好处是可见性计算比例完全由GPU的并行能力承担CPU侧DIPDrawIndexedPrimitive提交量迅猛下降可以支撑几十万级别的物体数量。Indirect Drawing三大件是物体列表缓冲、可见性标志缓冲、DrawCommand缓冲。Compute Shader根据视锥与深度做剔除后把可见物体的绘制命令写到一个缓存渲染管线用API的间接绘制命令DrawIndexedInstancedIndirect直接消费该缓存CPU全程不参与单个物体的DrawCall提交。我在自研引擎里用GDR框架重写场景管理后空地的后备物体从5万个降到了GPU自己管理帧时间CPU侧几乎不再随物体数量变化曲线增长——非常夸张的改善。但代价是调试难度上升因为原来可以打断点逐物体排查现在整个绘制过程被封装成一个黑盒。折中方案是仍保留一份CPU侧的调试视图只在开发者构建里开启。5.4 移动端与低端平台的架构适配问题不是所有平台都适合用高贵的Frame GraphGDR。移动端GPU在并行计算能力、显存带宽、驱动优化几个维度和桌面端差异巨大某些技术甚至反效果。移动端架构更适合的路线是保持传统Pass有序执行减少复杂屏障自动推导因为移动端驱动和GPU命令处理器较简单过多的Barrier反而制造停顿。强调合批和纹理压缩比如ASTC、ETC2。架构上要将压缩格式的选择与资产导入流程绑定而不是运行时临时转换。避免Compute Shader大规模前处理因为移动端的通用计算单元和渲染单元有时是同一批跑太重的Compute会挤压渲染能力。保留Tiled-Based Renderer的FrameBuffer优化不要在移动端疯狂开多个渲染目标MRT否则局部显存会溢出强制Tile切换伤超带宽。所以架构设计从来不是追求最新而是评估最合适。做消费级PC游戏可以放心上GDR做大规模移动网游还是老老实实打磨合批和管理内存来得实在。这个道理在设计初期就要想透。6. 架构取舍与实战经验延迟渲染选型、Draw Call优化与踩坑笔记最后一部分聊实战。渲染系统架构落地时最常被问到的几个决策和失误集中说一遍。就算你在用的引擎是Unity或Unreal了解底下这套架构取舍依然能帮你做更准确的性能判断和插拔式修改。6.1 延迟渲染 vs 前向渲染场景规模决定架构渲染架构最先要定的一件事是光照管线的形式。前向渲染Forward逻辑简单物体材质里写清光照计算逐灯逐物体叠加。延迟渲染Deferred则利用G-Buffer把光照计算推迟到屏幕空间适合几百个动态光源的大场景但代价是内存与带宽消耗高、MSAA困难、透明物体还得另行处理。如果你做小场景室内或低配手游前向渲染完全够用架构简单易调试抗锯齿友好。如果你做开放世界或密集光照场景延迟渲染或改进型的Tile-Based/Clustered Deferred更合适。Clustered Deferred还能把点光源聚合成簇用Compute Shader按屏幕区域光照。很多引擎做了一个折中主场景用延迟渲染但透明物体、UI、粒子、水体表面用前向Pass重新跑一遍。这个混合模式要处理好两个Pass之间深度纹理的共享否则后画的前向物体会被你自己的G-Buffer的深度测试挡错位置。6.2 从Draw Call数量到批处理策略为什么Instance和动态合批不一样DrawCall优化是渲染架构最接地气的部分。优化手段要分清层次GPU Instancing同Mesh、同材质、不同变换矩阵的物体一次绘制调用批量提交CPU每实例只多传一份矩阵数组。适合大量重复物体比如森林、草丛、石子。静态网格合批将同材质的多张不重叠Mesh合并成一张大VertexBuffer减少DrawCall。适合不会动的建筑、地形护栏。动态合批运行时将小Mesh临时拼合但在CPU侧每帧都要重建合并缓冲开销极大仅适合少量对象。帧率提升有限就别用优先考虑用GPU Instancing替代。Virtual Texture批量流式将大地形分成多个虚拟页超大幅贴图按需加载CPU/GUI上其实降低了显存峰值和带宽压力也让物体能共享同一种巨型纹理页模式来合批。这几招的优先级先说能不做就不做CPU侧合批是最容易杀性能的败笔。先保证提交批次尽量少比合批更彻底的做法是上文讲的GPU-Driven——你连合批都不用写直接让GPU知道某个Mesh重复若干次就够了。6.3 帧率分歧的排查渲染线程与GPU的瓶颈判定性能问题排查的方法论值得固化成一个固定套路。很多团队调渲染性能像碰运气这里调一个参数那里关一个功能最后也不知道到底管什么用。建议按四步排查开Profiler看三类数值游戏线程耗时、渲染线程耗时、GPU耗时。判定瓶颈如果渲染线程耗时接近或大于游戏线程优先优化DrawCall数量、状态切换、合批策略。如果是GPU耗时高优先看是否过度绘制Overdraw、着色器复杂度、纹理带宽。看DrawCall分布用API抓帧工具如RenderDoc看某一帧哪些Pass占用最多CPU提交时间哪一个物体群最贵。高基数就是优化重心。逐Pass试关临时禁掉某个Pass看帧率变化幅度影响最大的Pass就是主嫌疑。这里遇到最多的情况是CPU侧渲染线程耗时高但Profiler显示DrawCall不多——那通常不是DrawCall的问题而是状态切换太多。绑定不同的Pipeline State、不同的纹理绑定组都会产生隐藏成本。优化方向改为按状态排序、按绑定组缓存。6.4 有人说架构过度设计适度抽象和泥潭边界最后聊一个几乎所有引擎团队都会吵的话题渲染系统要做到多少层抽象。层数太少代码直接怼API好处是性能透明、调试方便但换来后期添加新特性疯狂改底层。层数太多引入的抽象基类、虚接口、命令编码器层层包装性能消耗先不谈光是追踪一个问题要跳好几层就让你崩溃。我的个人标准是底层封装到资源类型命令提交两级不搞花活。上层业务逻辑不能直接拿裸Texture、裸VertexBuffer到处传要经资源句柄间接访问。平台差异收口在唯一一组接口实现里禁止业务代码用宏去分平台。当然如果项目只有十几万字渲染代码确实不需要照着3A引擎去做三层抽象。但有一点经验是通用的渲染系统的抽象一旦落成改动成本极高。与其前期贪快少写抽象不如花几天把资源层和命令层的接口设计到够用且好改的程度。吃了两年的教训我现在宁愿前期多花时间也不愿在一堆平铺直叙的渲染代码里改得痛不欲生。再分享一个小细节早期我特别迷信性能最高的写法比如总是想着把每一个Pass绑定一次Pipeline State、绝不重复绑定。结果经常出现一种怪Bug——因为优化写得太聪明GPU状态来回切换DrawCall数量爆炸式增长而优化手段本身把合批逻辑搅乱了最后性能反而更差。后来团队立了规矩先把功能跑对、数据摆正再考虑逐帧性能牺牲优化前必留一份基准帧的数据对照。慢一点稳一些架构才能长期健壮地演进。
返回列表