ARTICLE DETAIL

资讯详情

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

游戏引擎渲染系统架构设计:分层解耦、RHI抽象与管线优化实战

游戏引擎渲染系统架构设计:分层解耦、RHI抽象与管线优化实战 1. 渲染系统在引擎里到底扮演什么角色聊游戏引擎架构渲染系统永远是那个最显眼、也最容易被误解的部分。很多人一提到渲染脑子里第一反应就是写Shader觉得只要会写几段光照代码就算懂渲染了。但真正在引擎层面做过事的人都知道Shader只是冰山露出水面的那一角水面之下是整套管线组织、资源管理、硬件抽象和跨平台适配的庞大工程。这一篇我就顺着上一章引擎整体架构往下走专门把渲染系统这一块拆开讲透。先把定位说清楚渲染系统是引擎里负责把场景数据变成屏幕上像素的子系统。它向上要接场景管理、材质系统、动画系统向下要对接图形API和GPU硬件。它解决的问题不是怎么画一个三角形而是如何在上千个物体、几十种材质、多套硬件配置下稳定地把每一帧画出来并且让开发者不用关心底层用的是哪家API。适合阅读这篇的人包括正在自研引擎的开发者、想深入理解Unity或Unreal渲染层的技术美术、以及准备面试引擎岗位的同学。我见过太多项目前期渲染层设计偷懒直接在每个业务逻辑里裸调图形API结果到了中期要换平台、要加后处理、要做多线程整个渲染代码推倒重来。所以这一篇不只是讲原理更想讲清楚为什么引擎要这么设计以及这些设计决策背后踩过的坑。2. 渲染系统的整体分层设计思路2.1 为什么渲染系统一定要分层渲染系统最核心的设计思想就是分层解耦。你可以把它想象成一家餐厅前台负责接单场景数据后厨负责做菜GPU渲染中间有个传菜员RHI负责把订单翻译成后厨听得懂的话。如果前台直接冲进后厨自己炒菜那这家餐厅一旦换个大厨换图形API整个流程就崩了。具体来说渲染系统通常分成这么几层。最上面是渲染管线层负责组织一帧的渲染流程决定先画什么后画什么比如先做阴影Pass再做主Pass最后做后处理。中间是渲染抽象层也就是常说的RHIRender Hardware Interface它把不同图形API的差异抹平向上提供统一的接口。最下面是平台后端层针对具体API做实现比如DirectX、Vulkan、Metal各写一套。这么分层的好处非常直接。第一业务代码只依赖RHI换平台时上层几乎不用动。第二渲染管线可以独立演进今天用前向渲染明天想加延迟渲染不用动底层。第三方便做多线程因为层与层之间接口清晰可以把命令录制和提交拆到不同线程。注意分层不是越多越好。我见过有团队为了架构优雅硬生生拆出七八层结果一个DrawCall要穿过六层虚函数调用性能全耗在间接跳转上了。分层要服务于解耦不是服务于好看。2.2 渲染管线层的核心职责渲染管线层是开发者最常打交道的地方。它的核心职责可以概括成三件事组织Pass、管理渲染状态、调度渲染命令。组织Pass指的是把一帧拆成若干个渲染阶段。一个典型的前向渲染管线大概是这样先做深度预Pass可选然后做阴影贴图Pass接着做主光照Pass最后做后处理和UI。每个Pass有自己的渲染目标、自己的输入输出。为什么要拆Pass因为不同Pass对渲染目标的要求不一样阴影Pass只需要深度主Pass需要颜色和深度后处理需要全屏纹理。拆开之后每个Pass可以独立优化。管理渲染状态是件很琐碎但极其影响性能的事。渲染状态包括混合模式、深度测试、剔除模式、模板测试等等。GPU切换状态是有开销的尤其是混合模式和渲染目标切换。所以管线层要做状态排序把相同状态的物体尽量排在一起画。这就是为什么引擎里会有材质排序和渲染队列的概念。调度渲染命令则是把场景里的物体转换成实际的绘制调用。这里涉及视锥剔除、遮挡剔除、LOD选择等一系列优化。管线层要决定哪些物体这一帧需要画用哪个LOD然后生成对应的DrawCall。2.3 RHI层的抽象艺术RHI是渲染系统里技术含量最高的部分之一。它的目标是用一套接口覆盖所有主流图形API同时尽量不损失性能。这件事说起来简单做起来极难因为不同API的设计哲学差异巨大。举个最典型的例子资源绑定方式。老式API像DirectX 11和OpenGL用的是绑定即生效的模型你把纹理绑到某个槽位后续绘制就用这个纹理。而新一代API像Vulkan、DirectX 12、Metal用的是描述符集模型你需要提前把资源打包成描述符集绘制时绑定整个集合。这两种模型差异太大RHI必须做一层转换。再比如命令缓冲。老式API是立即模式你调用绘制命令就立即提交。新式API需要先录制命令到命令缓冲再统一提交。RHI要统一这两种模式通常的做法是全部按命令缓冲模型来设计在老式API上做一层模拟。// 一个简化的RHI接口示意 class IRHIDevice { public: virtual IRHICommandList* CreateCommandList() 0; virtual IRHITexture* CreateTexture(const TextureDesc desc) 0; virtual IRHIBuffer* CreateBuffer(const BufferDesc desc) 0; virtual IRHIPipelineState* CreatePipelineState(const PipelineStateDesc desc) 0; virtual void SubmitCommandList(IRHICommandList* cmdList) 0; };这个接口看起来简单但每个方法的实现背后都是一堆平台差异处理。比如CreatePipelineState在Vulkan里要创建VkPipeline在DirectX 12里要创建PSO在Metal里要创建RenderPipelineState参数映射和生命周期管理都得仔细处理。3. 渲染管线的核心细节与实操要点3.1 前向渲染与延迟渲染的取舍这是渲染管线设计里第一个要做的重大决策。前向渲染是画一个物体算一次光照延迟渲染是先把所有物体的几何信息写到G-Buffer再统一算光照。两者各有优劣选错了后期很难改。前向渲染的优点是显存占用低、支持MSAA、透明物体处理简单。缺点是光源多了之后每个物体都要重复计算所有光源性能急剧下降。所以前向渲染适合光源少、材质复杂的场景比如大部分手游和风格化游戏。延迟渲染的优点是光照计算和物体数量解耦几百个光源也不怕。缺点是G-Buffer显存占用大、MSAA支持差、透明物体要单独用前向渲染处理。所以延迟渲染适合光源多、场景复杂的3A大作。实际项目中很多引擎会做混合方案。比如先用延迟渲染画不透明物体再用前向渲染画透明物体最后合并。Unreal就是这么干的。这种混合方案兼顾了两者优点但管线复杂度会上升不少。实操心得如果你的项目目标是移动端别一上来就上延迟渲染。移动端GPU的带宽极其宝贵G-Buffer的读写开销可能直接让你的帧率腰斩。我见过一个项目在手机上硬上延迟渲染结果G-Buffer就吃掉了大半带宽最后不得不回退到前向渲染重做。3.2 Shader系统的组织方式Shader是渲染系统的灵魂但引擎层面的Shader管理和手写Shader完全是两回事。引擎要解决的核心问题是如何让美术和TA用起来方便同时保证运行效率。主流引擎的做法是Shader变体加材质系统。Shader本身写成模板通过宏定义和参数生成不同的变体。比如一个标准PBR Shader可能有是否开启法线贴图是否开启阴影是否开启雾效等开关组合起来就是几十上百个变体。材质系统则负责把美术调的参数颜色、贴图、粗糙度打包成常量缓冲运行时绑定给Shader。这里最大的坑是变体爆炸。一个复杂Shader如果有10个开关理论上就是1024个变体。如果每个变体都编译编译时间会爆炸包体也会爆炸。所以引擎要做变体剔除只编译实际用到的组合。Unity的Shader变体收集和Unreal的Shader编译管线都是干这个的。// 一个简化的Shader变体示意 #pragma multi_compile _ _NORMALMAP #pragma multi_compile _ _SHADOWS_ON half4 frag(Varyings input) : SV_Target { half3 albedo SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv).rgb; #ifdef _NORMALMAP half3 normal UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv)); #else half3 normal half3(0, 0, 1); #endif // ... 光照计算 }3.3 渲染状态的排序与批处理批处理是渲染优化的基本功但很多人只知道合批能减少DrawCall不知道背后的原理和限制。DrawCall的开销主要来自CPU端的命令提交和状态切换。每次DrawCallCPU都要准备渲染状态、绑定资源、提交命令。如果两个物体用同样的材质和同样的网格理论上可以合并成一个DrawCall。这就是静态合批和动态合批的原理。静态合批是把不会动的物体预先合并成一个大的网格运行时一次画完。优点是效率高缺点是显存占用大而且物体不能单独移动。动态合批是把每帧动态地把小物体合并优点是灵活缺点是CPU开销大而且只适合顶点数很少的物体。实例化渲染是另一种批处理方式适合大量相同网格不同参数的物体比如草地、树木。它把每个实例的参数位置、旋转、颜色放到一个缓冲里GPU一次画完所有实例。批处理方式适用场景优点缺点静态合批静态场景物体效率高显存大不可移动动态合批小顶点数动态物体灵活CPU开销大GPU实例化大量相同网格效率极高需要硬件支持SRP Batcher同Shader不同材质减少状态切换依赖管线设计注意批处理不是万能的。如果你的物体材质各不相同合批反而会增加开销。我见过有人为了合批把所有材质合并成一张大图集结果纹理采样精度下降画面糊成一片。合批要权衡不能盲目。4. 实操过程与核心环节实现4.1 从零搭建一个最小渲染管线的步骤假设你要在一个自研引擎里搭一套最小可用的渲染管线我按实际做过的顺序给你捋一遍。第一步是确定渲染目标和交换链。你需要创建至少一个颜色缓冲和一个深度缓冲尺寸和窗口一致。如果是多Pass管线可能还需要额外的中间渲染目标。交换链负责把最终画面呈现到屏幕上这里要注意双缓冲还是三缓冲的选择双缓冲延迟低但可能撕裂三缓冲更平滑但延迟高。第二步是实现基础的RHI封装。先支持一个平台比如DirectX 11或OpenGL把设备创建、资源创建、命令提交这几个核心接口跑通。不要一上来就想着支持所有平台先把一个平台做扎实。第三步是搭建渲染管线框架。定义Pass的基类每个Pass有Setup、Execute、Cleanup三个阶段。Setup负责准备渲染目标和状态Execute负责实际的绘制调用Cleanup负责释放临时资源。class RenderPass { public: virtual void Setup(RHICommandList* cmdList) 0; virtual void Execute(RHICommandList* cmdList, const SceneView view) 0; virtual void Cleanup() 0; }; class ShadowPass : public RenderPass { public: void Setup(RHICommandList* cmdList) override { cmdList-SetRenderTarget(m_ShadowMap); cmdList-ClearDepth(1.0f); } void Execute(RHICommandList* cmdList, const SceneView view) override { for (auto obj : view.shadowCasters) { cmdList-DrawMesh(obj.mesh, obj.material); } } };第四步是接入场景数据。把场景里的网格、材质、变换矩阵转换成渲染命令。这一步要处理视锥剔除把不在相机视野内的物体过滤掉。第五步是加入光照和材质。先实现一个最简单的Lambert光照跑通之后再逐步加入PBR、阴影、后处理。4.2 视锥剔除的实现细节视锥剔除是渲染优化的第一道关卡做得好能省掉大量无效绘制。原理很简单把相机的视锥体用六个平面表示然后判断每个物体的包围盒是否和这六个平面相交。具体实现时先把视锥体的六个平面方程算出来。对于透视投影可以通过投影矩阵和视图矩阵的乘积反推出平面方程。然后对每个物体的AABB包围盒测试它是否在六个平面的同一侧之外。如果在就剔除。bool FrustumCull(const Frustum frustum, const AABB aabb) { for (int i 0; i 6; i) { // 找到AABB在平面法线方向上的最远点 Vec3 positive aabb.min; if (frustum.planes[i].normal.x 0) positive.x aabb.max.x; if (frustum.planes[i].normal.y 0) positive.y aabb.max.y; if (frustum.planes[i].normal.z 0) positive.z aabb.max.z; // 如果最远点都在平面外侧整个AABB都在外侧 if (Dot(frustum.planes[i].normal, positive) frustum.planes[i].d 0) { return true; // 剔除 } } return false; // 保留 }这个算法看起来简单但有几个坑。第一包围盒要留一点余量否则物体边缘可能被误剔除。第二对于蒙皮网格包围盒要按动画后的范围算不能用绑定姿态的。第三剔除要在合适的粒度做太细粒度CPU开销大太粗粒度剔除效果差。4.3 阴影贴图的渲染流程阴影是渲染管线里最复杂的部分之一。主流方案是阴影贴图核心思路是从光源视角渲染一遍场景把深度存到一张纹理里然后在主Pass里用这张纹理判断像素是否在阴影中。具体步骤是这样的。首先创建一个深度纹理作为阴影贴图尺寸通常是1024或2048。然后从光源位置构建一个正交投影矩阵平行光或透视投影矩阵点光源把场景渲染到阴影贴图里。接着在主Pass里把世界坐标变换到光源空间采样阴影贴图的深度和当前像素深度比较判断是否在阴影中。// 阴影采样示意 float SampleShadow(float4 shadowCoord, float depth) { float shadowMapDepth SAMPLE_TEXTURE2D(_ShadowMap, sampler_ShadowMap, shadowCoord.xy).r; float bias max(0.05 * (1.0 - dot(normal, lightDir)), 0.005); return shadowMapDepth bias depth ? 0.0 : 1.0; }阴影的坑非常多。最常见的是阴影痤疮就是物体表面出现条纹状的自阴影。原因是深度精度不够物体自己遮挡自己。解决办法是加深度偏移或者用法线偏移。另一个坑是阴影锯齿因为阴影贴图分辨率有限。解决办法是用PCF滤波采样周围多个像素做平均。实操心得阴影贴图的分辨率不是越高越好。2048x2048的阴影贴图在移动端可能吃掉几MB显存而且采样开销也大。我一般会做级联阴影近处用高分辨率远处用低分辨率兼顾质量和性能。5. 常见问题与排查技巧实录5.1 画面闪烁和撕裂怎么排查画面闪烁和撕裂是渲染里最常见的问题但原因可能有很多种。我整理了一个排查顺序按这个顺序走基本能定位。先看是不是垂直同步的问题。如果没开垂直同步画面撕裂是正常的因为GPU在屏幕刷新中途提交了新帧。开启垂直同步能解决撕裂但会引入输入延迟。如果开了垂直同步还撕裂那可能是交换链配置有问题。再看是不是深度冲突。两个物体靠得很近时深度值精度不够会导致闪烁。解决办法是调整近远裁剪面让近裁剪面尽量远远裁剪面尽量近把深度精度集中在需要的范围内。最后看是不是多线程同步的问题。如果渲染线程和逻辑线程共享数据没加锁可能导致一帧用了上一帧的数据画面就会跳。这种问题比较隐蔽需要用帧调试工具抓帧分析。5.2 DrawCall过高怎么优化DrawCall过高是性能问题的头号杀手。排查思路是先定位是哪些物体贡献了最多的DrawCall然后针对性优化。用引擎自带的性能分析工具或者RenderDoc这类抓帧工具能看到每一帧的DrawCall列表。按材质、按网格、按渲染队列排序找出重复最多的。如果是大量相同网格用实例化渲染。如果是大量小物体考虑合并网格。如果是材质切换频繁考虑合并材质或使用纹理图集。问题现象可能原因排查方法解决方案DrawCall突然飙升某个系统批量生成物体抓帧看调用栈加对象池或合批同网格重复绘制未开启实例化检查渲染路径启用GPU实例化材质切换频繁材质未排序看状态切换次数按材质排序渲染阴影Pass开销大阴影投射体过多单独统计阴影Pass缩小阴影范围5.3 Shader编译慢和变体爆炸Shader编译慢是大型项目的通病。一个复杂项目可能有几千个Shader变体全量编译要几个小时。优化思路有几个。第一是变体剔除。只编译实际用到的变体把没用的组合去掉。Unity的Shader变体收集工具就是干这个的。第二是异步编译。把Shader编译放到后台线程不阻塞主线程。第三是Shader缓存。把编译好的Shader二进制缓存起来下次直接加载。变体爆炸的根源是宏定义组合太多。解决办法是合并宏把一些不常用的功能做成运行时分支而不是编译期分支。虽然运行时分支有一点性能开销但比起变体爆炸带来的编译和包体问题这点开销是值得的。// 不推荐每个功能一个宏变体爆炸 #pragma multi_compile _ _FEATURE_A #pragma multi_compile _ _FEATURE_B #pragma multi_compile _ _FEATURE_C // 推荐合并成质量等级 #pragma multi_compile _QUALITY_LOW _QUALITY_MEDIUM _QUALITY_HIGH5.4 移动端渲染的特殊坑移动端渲染和PC端差异巨大很多在PC上跑得好好的方案到手机上直接崩。我踩过的坑主要有这几个。带宽是最大瓶颈。移动端GPU的显存带宽远不如PCG-Buffer读写、大纹理采样、多重渲染目标都会吃带宽。所以移动端要尽量用前向渲染减少渲染目标切换纹理压缩要用ASTC或ETC2。精度问题。移动端GPU对浮点精度支持不如PChalf精度在某些设备上只有10位。所以Shader里要小心精度声明位置计算用float颜色和UV可以用half。发热和降频。手机长时间高负载会发热降频帧率会突然掉。所以移动端渲染要留性能余量不能把GPU跑满。我一般会把GPU占用控制在70%以下给发热留缓冲。6. 渲染系统后续可以怎么扩展渲染系统搭好基础框架之后扩展方向其实很多。我按优先级给你排一下。第一优先是后处理系统。Bloom、色调映射、抗锯齿这些是提升画面质感最明显的。后处理框架要支持多个效果串联每个效果有自己的渲染目标和材质。第二优先是光照系统升级。从简单的方向光扩展到点光源、聚光灯、IBL环境光照。如果光源多可以考虑Clustered Forward Rendering把屏幕分成网格每个网格只计算影响它的光源。第三优先是材质系统完善。支持材质实例、材质参数动画、材质函数复用。这部分直接影响TA和美术的工作效率。第四优先是GPU Driven Rendering。把剔除、LOD选择、DrawCall生成都放到GPU上做CPU只负责提交少量命令。这是新一代引擎的方向能大幅降低CPU开销。// GPU Driven Rendering的简化思路 // 1. 把场景物体数据上传到GPU缓冲 // 2. Compute Shader做视锥剔除和LOD选择 // 3. 用Indirect Draw提交可见物体 // 4. CPU只提交一个Dispatch和几个DrawIndirect这套方案听起来很美但实现复杂度很高而且对硬件有要求。我建议先把传统管线做扎实等团队和项目都成熟了再考虑上GPU Driven。最后分享一个我在实际项目里的体会渲染系统的架构设计最重要的不是追求技术先进而是匹配项目需求。一个休闲手游不需要延迟渲染和GPU Driven一个3A大作也不能用最简陋的前向管线。架构是为产品服务的脱离产品谈架构就是耍流氓。我见过太多团队为了技术领先上了复杂方案结果项目进度被拖垮最后不得不回退。先把需求想清楚再决定渲染系统怎么做这个顺序不能反。
返回列表