1. 从一次Draw Call异常说起:渲染系统到底在管什么
很多人第一次接触引擎渲染,是从“为什么我的场景一多就掉帧”开始的。我印象很深的一次排查,场景里两百多个独立模型,帧率从一百二直接掉到三十几,用性能分析工具一看,Draw Call 数量高得离谱。当时我的第一反应是模型面数太多,结果把模型减面之后几乎没变化,真正的问题出在渲染系统这一层——每个模型都是独立材质、独立贴图,渲染系统没法做任何合批,只能老老实实一个一个提交给图形接口。
这件事让我意识到,渲染系统远不是“把模型画到屏幕上”这么简单。它更像一个城市的交通调度中心:场景里的网格、材质、灯光、相机是等待通行的车辆,渲染管线是道路网络,而 RHI(Render 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 卡通渲染与 NPR:Shader 如何实现风格化
最近几年,二次元风格的卡通渲染(NPR,Non-Photorealistic Rendering)非常火。Unity 里做卡通渲染,核心就是自定义 Shader。
卡通渲染的关键在于光照的阶梯化。写实渲染里,光照是连续变化的;卡通渲染里,光照被分成几个固定的色阶,形成明显的明暗分界。实现方式是在片段着色器里计算光照强度,然后通过阈值判断映射到不同的色阶。
描边是另一个关键。常见的描边做法有背面外扩法、法线外扩法、屏幕空间边缘检测法。背面外扩法是把模型背面沿法线方向外扩一点,用纯色渲染,形成描边效果。这种方法简单高效,但对法线不连续的模型效果不好。屏幕空间边缘检测法是在后处理阶段做,效果更稳定,但开销更大。
卡通渲染的难点不在单个效果,而在于整体风格的统一。头发、皮肤、衣服、特效,每个部分的渲染逻辑都不一样,要让它们看起来是一个整体,需要大量的调试和打磨。我的经验是,先把基础的光照阶梯和描边做扎实,再逐步叠加高光、边缘光、阴影等细节,不要一上来就追求复杂效果。
5.4 Shader 变体管理:一个容易被忽视的工程问题
Shader 变体是渲染工程里一个很现实的问题。一个 Shader 可能有几十个开关,每个开关组合就是一个变体,变体数量会爆炸式增长。
变体太多的直接后果是:打包体积变大、加载变慢、内存占用增加。更麻烦的是,有些变体可能根本用不到,但编译器不知道,还是会编译进去。
管理变体的常见做法是:用 Shader 变体集合(Shader Variant Collection)记录实际用到的变体,构建时只编译这些变体。同时,在 Shader 里用#pragma multi_compile和#pragma shader_feature区分变体类型,前者用于运行时切换,后者用于构建时剔除。
这块的实操经验是:定期检查变体数量,清理不再使用的变体。我接手过一个项目,Shader 变体有上万条,打包出来光 Shader 就占了几十兆。后来梳理了一遍,删掉大量废弃变体,体积直接降了一半。
6. 现代渲染架构的演进:从延迟渲染到 Mesh Shader
6.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 开始,理解顶点着色器和片段着色器在干什么。然后尝试自己搭一个最小的渲染流程,从清屏、画三角形、贴纹理,一步步加上光照、阴影、后处理。这个过程会让你对管线的每个环节有直观的感受。
再往后,可以去看主流引擎的渲染源码,看看它们是怎么组织渲染流程、怎么管理资源、怎么做多平台适配的。看源码的时候不要贪多,挑一个具体的模块深入进去,比如阴影系统或者后处理系统,把它的来龙去脉搞清楚。
渲染架构的演进很快,新的技术和架构不断出现。但底层的逻辑是相对稳定的:怎么组织数据、怎么调度资源、怎么平衡效果和性能。抓住这些不变的东西,再去学新的技术,就会轻松很多。
这个系列后面还会继续聊渲染系统的其他方面,比如光照系统、阴影系统、后处理系统。渲染这块内容太多,一篇讲不完,慢慢来。