ARTICLE DETAIL

资讯详情

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

游戏引擎渲染系统架构:从Draw Call到RHI与Shader的完整链路

游戏引擎渲染系统架构:从Draw Call到RHI与Shader的完整链路 1. 从一次Draw Call异常说起渲染系统到底在管什么很多人第一次接触引擎渲染是从“为什么我的场景一多就掉帧”开始的。我印象很深的一次排查场景里两百多个独立模型帧率从一百二直接掉到三十几用性能分析工具一看Draw Call 数量高得离谱。当时我的第一反应是模型面数太多结果把模型减面之后几乎没变化真正的问题出在渲染系统这一层——每个模型都是独立材质、独立贴图渲染系统没法做任何合批只能老老实实一个一个提交给图形接口。这件事让我意识到渲染系统远不是“把模型画到屏幕上”这么简单。它更像一个城市的交通调度中心场景里的网格、材质、灯光、相机是等待通行的车辆渲染管线是道路网络而 RHIRender Hardware Interface渲染硬件接口就是最终和各个路口信号灯打交道的执行层。调度得好几百辆车顺畅通行调度得差路口全堵死。这一篇要聊的就是游戏引擎渲染系统的架构。我会从渲染系统的职责边界讲起拆解渲染管线从应用阶段到光栅化的完整链路重点说清楚 RHI 这层抽象为什么存在、Shader 在其中扮演什么角色再结合当下热门的 Mesh Shader、卡通渲染等话题聊聊现代渲染架构的演进方向。不管你是刚入行想搞懂引擎底层的新人还是已经写过 Shader 但没系统梳理过架构的老手这篇都能帮你把脑子里零散的知识点串成一条线。需要先说明的是渲染系统架构在不同引擎里实现差异很大我下面讲的是一套通用的、被主流引擎广泛采用的分层思路具体到某个引擎会有取舍和变体但核心逻辑是相通的。2. 渲染系统的职责边界它到底该管什么、不该管什么2.1 渲染系统不是“画图工具”而是资源与状态的调度者刚接触引擎的人容易有个误解觉得渲染系统就是负责“把东西画出来”。这个理解太窄了。真正成熟的渲染系统核心职责其实是三件事管理渲染资源、组织渲染流程、屏蔽硬件差异。管理渲染资源指的是纹理、网格、材质、Shader、渲染目标这些数据的生命周期管理。什么时候加载、什么时候上传到显存、什么时候释放都是渲染系统说了算。组织渲染流程是把场景里的可见物体按照一定规则排序、分组、剔除然后决定用哪条管线、哪个 Pass 去画。屏蔽硬件差异就是通过 RHI 这层抽象让上层逻辑不用关心底层是哪个图形接口、哪个厂商的显卡。我见过不少项目把渲染逻辑写得到处都是游戏逻辑里直接调图形接口结果换一个平台就要大改。这就是没有把渲染系统的职责边界划清楚。正确的做法是游戏逻辑只负责告诉渲染系统“我要画什么”至于“怎么画”“用什么画”全部交给渲染系统内部处理。2.2 可见性剔除渲染系统的第一道闸门渲染系统做的第一件有实际意义的事是可见性剔除。场景里可能有几万个物体但相机视野里能看到的可能只有几百个剩下的没必要进入后续流程。剔除分几个层次。最粗的是视锥剔除用相机的视锥体去和物体的包围盒做相交测试不在视锥内的直接排除。再细一点是遮挡剔除判断一个物体是不是被前面的物体完全挡住了。还有距离剔除和层剔除根据距离远近和渲染层设置来决定是否渲染。这里有个实操经验视锥剔除的包围盒一定要设置准确。我踩过的坑是美术导出的模型包围盒经常偏大导致很多其实已经出视野的物体还被判定为可见白白浪费了后续的排序和提交开销。后来我们在导入流程里加了一步包围盒重计算帧率提升很明显。剔除之后渲染系统会得到一个可见物体列表这个列表就是后续渲染流程的输入。列表里每个物体通常包含网格引用、材质引用、变换矩阵、渲染层等信息。2.3 排序与分组决定渲染效率的关键一步拿到可见列表之后渲染系统要做排序和分组。这一步直接决定了 Draw Call 的数量和状态切换的频率。排序的核心目标是减少状态切换。图形接口的状态切换换 Shader、换贴图、换渲染目标开销很大所以渲染系统会尽量把使用相同状态的物体排在一起。常见的排序策略是先按渲染队列不透明、透明、叠加分再按材质分再按距离分。不透明物体通常从前往后排序这样可以利用早期深度测试后面的像素如果被前面的挡住了就直接丢弃省下像素着色器的开销。透明物体则必须从后往前排序因为透明混合依赖绘制顺序顺序错了颜色就乱了。分组则是为了合批。如果多个物体用同一个材质、同一张贴图渲染系统可以把它们合并成一次 Draw Call。静态合批在构建时就把网格合并好动态合批在运行时处理GPU Instancing 则是一次提交多个实例。这几种方式各有适用场景选错了反而更慢。提示合批不是越多越好。静态合批会增加内存和构建时间动态合批对顶点数有限制GPU Instancing 要求材质和网格结构一致。实际项目里要根据物体数量和更新频率来权衡。3. 渲染管线的完整链路从应用阶段到像素上屏3.1 应用阶段CPU 在忙什么渲染管线通常被划分为几个阶段第一个是应用阶段这一阶段主要在 CPU 上执行。CPU 要做的事情包括更新场景图、做剔除、排序、准备渲染数据、提交 Draw Call。这个阶段最容易被忽视但它往往是性能瓶颈所在。GPU 再快如果 CPU 提交 Draw Call 的速度跟不上帧率照样上不去。我做过一个测试同样一个场景Draw Call 从 2000 降到 500帧率翻了一倍多GPU 占用率反而下降了。这说明瓶颈在 CPU 侧的提交环节。应用阶段还有一个重要任务是准备常量缓冲区。每个物体的变换矩阵、材质参数、光照参数都要打包成常量缓冲区数据传给 GPU。这部分数据的组织方式会影响传输效率比如把频繁变化的数据和不变的数据分开避免每帧重传整个缓冲区。3.2 几何阶段顶点如何变成图元几何阶段处理的是顶点数据。顶点着色器是这一阶段的核心它接收每个顶点的位置、法线、UV 等属性输出裁剪空间下的坐标。顶点着色器之后是图元装配把顶点组装成三角形。然后是裁剪把视锥体外的部分裁掉。接着是屏幕映射把裁剪空间的坐标转换到屏幕空间。这一阶段有个关键概念叫顶点缓存。GPU 会把最近用过的顶点数据缓存起来如果相邻三角形共享顶点就能命中缓存减少重复计算。这也是为什么网格的顶点顺序会影响性能——顺序合理的网格顶点缓存命中率高渲染更快。现在很多引擎支持Mesh Shader它把几何阶段做了重构。传统的顶点着色器是“一个顶点一个顶点”地处理Mesh Shader 则是以“网格块”为单位可以更灵活地做剔除和 LOD。PS5 等新一代主机对 Mesh Shader 的支持让这套流程有了更大的发挥空间。它的优势在于把剔除粒度从物体级细化到了网格块级对于高面数模型效果尤其明显。3.3 光栅化阶段三角形如何变成像素光栅化阶段把三角形转换成一个个像素片段。光栅化器会判断哪些像素被三角形覆盖然后为每个像素生成一个片段。接下来是片段着色器也就是常说的像素着色器它计算每个像素的最终颜色。这里会用到纹理采样、光照计算、阴影计算等。片段着色器是 Shader 编写的主战场也是性能开销最大的地方之一。片段着色器之后是逐片段操作包括深度测试、模板测试、混合。深度测试决定这个像素要不要画混合决定这个像素和已有颜色怎么融合。透明物体就是靠混合实现的。这一阶段有个优化技巧尽早深度测试。如果能在片段着色器之前就做深度测试被挡住的像素就不用跑片段着色器了能省下大量计算。很多引擎默认开启这个优化但前提是不透明物体从前往后排序。3.4 输出合并像素最终如何上屏最后一个阶段是输出合并把处理好的像素写入帧缓冲区。如果是多渲染目标MRT会同时写入多个缓冲区比如颜色、法线、深度这就是延迟渲染的基础。写入帧缓冲区之后还要经过后处理比如色调映射、抗锯齿、泛光、景深。后处理通常是在全屏范围内做一次或多次纹理采样和计算开销不小但效果提升明显。整个管线走下来从 CPU 提交数据到像素上屏中间经过了几十个环节。渲染系统架构的价值就是把这些环节组织得井井有条让数据流动顺畅让硬件资源被充分利用。4. RHI渲染系统里最容易被低估的一层抽象4.1 RHI 存在的意义一次编写多端运行RHI 是 Render Hardware Interface 的缩写直译就是渲染硬件接口。它的作用是在引擎的渲染逻辑和底层图形接口之间加一层抽象。为什么需要这层抽象因为底层图形接口不止一种不同平台、不同厂商的接口差异很大。如果没有 RHI引擎的渲染代码就要为每个接口写一套维护成本极高。有了 RHI上层只需要调用统一的接口具体用哪个底层接口由 RHI 在运行时决定。这就像你家里的插座。不管电器是哪个国家生产的只要插头符合标准插上就能用。RHI 就是那个标准插座底层图形接口就是不同国家的电网。RHI 的抽象层次要拿捏好。抽象太薄上层还是要关心底层细节起不到屏蔽作用抽象太厚又会损失性能和灵活性。主流引擎的做法是把资源创建、状态设置、绘制调用这些核心操作抽象出来但保留一定的底层访问能力让高级用户能直接操作底层接口。4.2 RHI 的核心对象资源、命令、队列RHI 这层抽象里最核心的是三类对象资源、命令、队列。资源包括纹理、缓冲区、渲染目标、Shader 程序等。RHI 负责资源的创建、销毁和状态管理。比如创建一张纹理上层只需要指定格式、尺寸、用途RHI 会根据当前平台选择合适的底层实现。命令是渲染操作的载体。上层把要做的操作记录成命令比如设置渲染目标、绑定纹理、发起绘制。命令可以被缓存和复用减少每帧的重复开销。队列是命令的执行通道。图形队列负责绘制计算队列负责通用计算拷贝队列负责数据传输。不同队列可以并行执行提高硬件利用率。这三类对象的设计直接决定了 RHI 的易用性和性能。我见过一些自研引擎的 RHI 设计得很别扭上层调用起来很繁琐结果大家都不愿意用绕过 RHI 直接调底层接口抽象层形同虚设。4.3 命令缓冲与多线程渲染现代渲染系统普遍支持多线程渲染而多线程渲染的基础就是命令缓冲。命令缓冲的思路是渲染线程不直接调用图形接口而是把渲染命令记录到一个缓冲区里然后由专门的提交线程统一提交。这样渲染逻辑可以并行化多个线程同时记录命令最后合并提交。这个机制对性能提升很明显。以前单线程渲染的时候CPU 经常在等 GPU或者 GPU 在等 CPU。多线程渲染之后CPU 侧的准备工作可以并行做GPU 的利用率上去了帧率也更稳定。不过多线程渲染也带来了复杂性。命令缓冲的同步、资源的跨线程访问、渲染状态的隔离都是容易出问题的地方。我的经验是多线程渲染不要一上来就全量铺开先从渲染线程和主线程分离开始稳定之后再逐步增加并行度。4.4 RHI 与 Shader 编译的配合RHI 还负责 Shader 的编译和管理。不同平台支持的 Shader 语言和编译方式不同RHI 要把上层写的 Shader 转换成目标平台能识别的形式。现在主流的做法是离线编译加运行时加载。在构建阶段就把 Shader 编译成各个平台的字节码运行时直接加载避免运行时编译的卡顿。但离线编译的问题是Shader 变体太多全量编译会占用大量时间和存储。所以引擎通常会做变体剔除只编译实际用到的变体。这就需要在构建阶段分析项目里实际使用了哪些 Shader 特性组合。这块做得好不好直接影响打包体积和加载速度。5. Shader 在渲染架构中的位置不只是写效果5.1 Shader 是渲染管线的可编程入口Shader 的本质是渲染管线暴露给开发者的可编程入口。管线的固定功能部分由硬件实现可编程部分由 Shader 决定。顶点着色器控制顶点怎么变换片段着色器控制像素怎么着色几何着色器可以增删图元计算着色器可以做通用计算。不同的 Shader 组合起来就能实现各种各样的渲染效果。理解 Shader 的关键是理解它在管线中的位置和它能拿到什么数据。顶点着色器能拿到顶点属性片段着色器能拿到插值后的顶点数据和纹理计算着色器能拿到任意缓冲区数据。知道每个阶段能拿到什么才能写出正确的 Shader。5.2 从固定管线到可编程管线为什么 Shader 这么重要早期的图形接口是固定管线光照、纹理、雾效都是硬件写死的开发者只能通过开关来调整。这种方式简单但灵活性极差想要一个特殊效果就得等硬件厂商支持。可编程管线的出现改变了这一切。开发者可以用 Shader 自由控制渲染的每个环节实现任何想要的效果。卡通渲染、次表面散射、体积光这些在固定管线时代想都不敢想的效果现在都能实现。Shader 的重要性还体现在它直接决定了渲染的性能上限。一个写得好的 Shader能在保证效果的同时把开销降到最低一个写得差的 Shader可能让整个场景卡成幻灯片。我见过一个片段着色器里做了几十次纹理采样结果在中端显卡上直接跑不动优化之后采样次数降到个位数帧率立刻回来了。5.3 卡通渲染与 NPRShader 如何实现风格化最近几年二次元风格的卡通渲染NPRNon-Photorealistic Rendering非常火。Unity 里做卡通渲染核心就是自定义 Shader。卡通渲染的关键在于光照的阶梯化。写实渲染里光照是连续变化的卡通渲染里光照被分成几个固定的色阶形成明显的明暗分界。实现方式是在片段着色器里计算光照强度然后通过阈值判断映射到不同的色阶。描边是另一个关键。常见的描边做法有背面外扩法、法线外扩法、屏幕空间边缘检测法。背面外扩法是把模型背面沿法线方向外扩一点用纯色渲染形成描边效果。这种方法简单高效但对法线不连续的模型效果不好。屏幕空间边缘检测法是在后处理阶段做效果更稳定但开销更大。卡通渲染的难点不在单个效果而在于整体风格的统一。头发、皮肤、衣服、特效每个部分的渲染逻辑都不一样要让它们看起来是一个整体需要大量的调试和打磨。我的经验是先把基础的光照阶梯和描边做扎实再逐步叠加高光、边缘光、阴影等细节不要一上来就追求复杂效果。5.4 Shader 变体管理一个容易被忽视的工程问题Shader 变体是渲染工程里一个很现实的问题。一个 Shader 可能有几十个开关每个开关组合就是一个变体变体数量会爆炸式增长。变体太多的直接后果是打包体积变大、加载变慢、内存占用增加。更麻烦的是有些变体可能根本用不到但编译器不知道还是会编译进去。管理变体的常见做法是用 Shader 变体集合Shader Variant Collection记录实际用到的变体构建时只编译这些变体。同时在 Shader 里用#pragma multi_compile和#pragma shader_feature区分变体类型前者用于运行时切换后者用于构建时剔除。这块的实操经验是定期检查变体数量清理不再使用的变体。我接手过一个项目Shader 变体有上万条打包出来光 Shader 就占了几十兆。后来梳理了一遍删掉大量废弃变体体积直接降了一半。6. 现代渲染架构的演进从延迟渲染到 Mesh Shader6.1 前向渲染与延迟渲染的架构差异渲染架构的一个核心分叉是前向渲染和延迟渲染。前向渲染是每个物体直接计算光照光照计算在片段着色器里完成。优点是简单、支持透明、抗锯齿方便。缺点是光源多了之后每个物体都要重复计算所有光源开销随光源数量线性增长。延迟渲染是先渲染几何信息到 G-Buffer再在屏幕空间统一计算光照。优点是光照计算和物体数量解耦支持大量光源。缺点是不支持透明、G-Buffer 占用显存大、抗锯齿麻烦。实际项目里很多引擎采用混合方案不透明物体走延迟渲染透明物体走前向渲染。这样兼顾了光照效率和透明支持。选择哪种架构取决于项目的具体需求。如果场景光源少、透明物体多前向渲染更合适如果光源多、场景复杂延迟渲染更有优势。没有绝对的好坏只有适不适合。6.2 Mesh Shader 带来的几何处理变革Mesh Shader 是近几年图形架构的一个重要演进。传统的几何处理是顶点着色器加图元装配Mesh Shader 把这两个阶段合并成了一个可编程的网格着色阶段。Mesh Shader 的核心优势是更细粒度的剔除和 LOD。传统管线里剔除是在物体级别做的一个物体要么全画要么全不画。Mesh Shader 可以在网格块级别做剔除一个物体里不可见的部分直接跳过可见的部分才进入光栅化。这对高面数模型特别有用。比如一个角色模型有几十万面传统管线要全部处理Mesh Shader 可以只处理可见的网格块效率提升明显。新一代主机对 Mesh Shader 的支持让这套流程有了更大的发挥空间。不过 Mesh Shader 也有门槛。它要求开发者自己管理网格块的划分和剔除逻辑比传统管线复杂得多。而且不是所有平台都支持跨平台项目要准备好回退方案。6.3 光线追踪与混合渲染架构光线追踪是另一个重要的演进方向。传统光栅化是“从相机出发判断哪些像素被覆盖”光线追踪是“从相机出发发射光线计算光线和场景的交点”。光线追踪的效果更真实反射、折射、阴影都能物理正确地计算。但纯光线追踪的开销太大目前还无法完全替代光栅化。所以主流的做法是混合渲染光栅化负责基础几何和着色光线追踪负责反射、阴影、全局光照等特定效果。混合渲染架构的难点在于两套管线的协调。光栅化的结果要传给光线追踪光线追踪的结果要合并回最终图像。这中间涉及大量的数据交换和同步对架构设计提出了更高要求。6.4 渲染架构的未来趋势更灵活、更并行、更智能从这几年的演进来看渲染架构有几个明显的趋势。更灵活可编程的程度越来越高固定功能越来越少。Mesh Shader、光线追踪都是这个趋势的体现。更并行多线程渲染、异步计算、多队列并行硬件利用率被不断压榨。更智能基于机器学习的超采样、降噪、材质生成开始进入渲染管线。这些技术能在不增加硬件开销的前提下提升画质。对开发者来说这意味着渲染系统的复杂度会继续上升但同时也带来了更多的可能性。掌握渲染架构的底层逻辑比记住某个具体 API 更重要因为 API 会变逻辑不会。7. 渲染系统调优的实战经验与常见误区7.1 先定位瓶颈再谈优化渲染优化最容易犯的错误是没定位瓶颈就动手优化。看到帧率低就减面、降分辨率、关特效结果可能根本没优化到点子上。正确的做法是先定位瓶颈在 CPU 还是 GPU。如果 CPU 占用高、GPU 占用低瓶颈在 CPU 侧可能是 Draw Call 太多、逻辑太复杂、物理计算太重。如果 GPU 占用高、CPU 占用低瓶颈在 GPU 侧可能是 Shader 太复杂、纹理太大、填充率不够。定位工具方面各平台都有自己的性能分析工具能看每帧的 CPU 和 GPU 耗时、Draw Call 数量、Shader 开销。这些数据是优化的依据不看数据就优化等于闭着眼睛开车。7.2 Draw Call 优化的几个实用手段Draw Call 是渲染优化里最常被提到的指标。降低 Draw Call 的手段主要有几个。合批静态合批、动态合批、GPU Instancing前面已经讲过。核心思路是把多个物体合并成一次提交。图集把多张小贴图合并成一张大图这样不同物体可以用同一个材质方便合批。UI 系统里图集用得最多3D 场景里也可以用在道具、植被等物体上。LOD远处物体用低模减少顶点数和 Draw Call。LOD 的切换距离要调好切换太频繁会看到明显的跳变。剔除视锥剔除、遮挡剔除、距离剔除把不需要画的物体排除掉。这些手段要组合使用单靠一种往往效果有限。而且要注意优化 Draw Call 的同时不要引入新的问题比如合批导致的内存增加、LOD 切换导致的视觉跳变。7.3 Shader 性能优化的常见坑Shader 优化有几个常见的坑。纹理采样太多每次纹理采样都有开销采样次数多了性能下降明显。优化方法是合并纹理通道、降低采样频率、用计算代替采样。分支太多GPU 是并行执行的分支会导致不同线程走不同路径降低并行效率。优化方法是尽量用数学运算代替分支或者把分支提到 Shader 外层。精度过高不是所有计算都需要高精度用半精度浮点数能省不少开销。移动平台上尤其明显。过度计算有些计算在顶点着色器里做一次就够了放到片段着色器里会重复计算每个像素。能提到顶点着色器的计算尽量提上去。7.4 跨平台渲染的适配经验跨平台项目在渲染上面临的挑战更大因为不同平台的图形接口、硬件能力、驱动行为都不一样。适配的核心思路是分级。把渲染效果分成几个等级高端平台开全部效果中端平台关掉部分效果低端平台用最简效果。分级的标准要提前定好不要等到适配的时候再临时决定。另一个经验是尽早做真机测试。模拟器和真机的表现可能差很多尤其是移动平台。我见过在编辑器里跑得很好的效果到真机上直接闪退原因是某个 Shader 特性在移动 GPU 上不支持。这种问题越早发现越好。还有一点是保留回退方案。新特性用不了的时候要有降级方案保证基本功能可用。比如 Mesh Shader 不支持就回退到传统管线光线追踪不支持就回退到屏幕空间反射。8. 写在最后渲染架构的学习路径聊了这么多最后说点个人体会。渲染系统架构这块知识光看书是不够的一定要动手。我的建议是先从写一个最简单的 Shader 开始理解顶点着色器和片段着色器在干什么。然后尝试自己搭一个最小的渲染流程从清屏、画三角形、贴纹理一步步加上光照、阴影、后处理。这个过程会让你对管线的每个环节有直观的感受。再往后可以去看主流引擎的渲染源码看看它们是怎么组织渲染流程、怎么管理资源、怎么做多平台适配的。看源码的时候不要贪多挑一个具体的模块深入进去比如阴影系统或者后处理系统把它的来龙去脉搞清楚。渲染架构的演进很快新的技术和架构不断出现。但底层的逻辑是相对稳定的怎么组织数据、怎么调度资源、怎么平衡效果和性能。抓住这些不变的东西再去学新的技术就会轻松很多。这个系列后面还会继续聊渲染系统的其他方面比如光照系统、阴影系统、后处理系统。渲染这块内容太多一篇讲不完慢慢来。
返回列表