ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

游戏引擎渲染系统架构设计与RHI跨平台实现要点

游戏引擎渲染系统架构设计与RHI跨平台实现要点 1. 渲染系统在游戏引擎中的定位与整体设计聊渲染系统之前得先把它在引擎里的位置摆正。很多人一上来就扎进 Shader 代码或者管线状态里结果写了半年还是搞不清楚一帧画面到底是怎么从一堆顶点数据变成屏幕上那几百万个像素的。我见过太多项目渲染代码写得跟意大利面一样改一个材质效果要动五个文件根因就是一开始没把渲染系统的分层想明白。渲染系统本质上是游戏引擎的视觉输出子系统它要解决的问题就一个把场景里所有的可见信息模型、材质、光照、特效、UI按照正确的顺序和正确的数学转换成 GPU 能执行的命令流最终输出到显示设备。听起来简单但这里面牵扯的东西极多——资源管理、状态排序、多线程提交、跨平台抽象、性能预算分配每一项都能单独写一本书。1.1 渲染系统的核心职责拆解从工程角度看一个成熟的渲染系统至少要承担以下职责场景数据接收与筛选从场景图或 ECS 中拿到所有可渲染对象做视锥剔除、遮挡剔除、LOD 选择把不需要画的东西尽早扔掉。这一步做得好不好直接决定 Draw Call 数量。渲染管线编排决定这一帧要跑哪些 Pass每个 Pass 的输入输出是什么依赖关系怎么排。比如阴影 Pass 必须在主光照 Pass 之前完成后处理必须在所有几何渲染之后。GPU 资源管理纹理、Buffer、RenderTarget、Pipeline State Object 的创建、复用、释放。这里最容易出内存泄漏和显存碎片。命令提交与同步把渲染命令录制到 Command Buffer管理 CPU-GPU 之间的同步点处理多帧并行。跨平台抽象屏蔽 D3D11、D3D12、Vulkan、Metal、OpenGL 之间的差异让上层渲染逻辑不用关心底层 API。这五件事里渲染管线编排和跨平台抽象是最能体现一个引擎架构水平的。前者决定了渲染效果的灵活性和性能上限后者决定了引擎能不能低成本地移植到新平台。1.2 为什么分层架构如此重要我参与过一个从零搭建的移动端渲染器项目最初为了赶进度把资源创建、状态设置、Draw Call 提交全写在一个 Renderer 类里。第一个月跑得挺欢第二个月要加阴影就出问题了——阴影 Pass 需要切换 RenderTarget但原来的代码假设只有一个默认帧缓冲改起来牵一发动全身。第三个月要支持 Vulkan 后端发现所有 D3D11 的调用都散落在业务逻辑里根本没法抽象。后来我们花了整整三周重构把渲染系统拆成了四层层级职责典型模块应用层定义渲染流程、材质逻辑、特效组合RenderPipeline、MaterialSystem场景层可见性剔除、渲染队列组织、排序CullingSystem、RenderQueue抽象层统一 GPU 资源与命令接口RHI、RenderDevice、CommandList后端层具体图形 API 实现D3D12Backend、VulkanBackend这个分层不是拍脑袋定的它对应的是变化频率——应用层每个项目都在改场景层每个游戏类型可能不同抽象层相对稳定后端层只在加新平台时动。把变化频率不同的东西分开是架构设计的第一性原理。注意分层不等于过度抽象。我见过有的引擎把 RHI 抽象到连一个 Draw Call 都要经过五层虚函数调用结果 CPU 端开销比 GPU 还大。抽象要有度热路径上的抽象要尽量做到零开销或者可内联。1.3 渲染管线的基本骨架不管什么引擎渲染管线的骨架都大同小异。一个典型的前向渲染管线长这样剔除阶段视锥剔除、遮挡剔除、距离剔除产出可见对象列表。排序阶段按材质、按深度、按渲染队列排序减少状态切换。阴影 Pass从光源视角渲染深度图。深度预 Pass可选先写一遍深度减少后续 Overdraw。主光照 Pass渲染所有不透明物体计算光照。透明 Pass按从远到近排序渲染半透明物体。后处理Bloom、Tone Mapping、抗锯齿、颜色分级。UI Pass渲染界面元素通常不参与光照计算。延迟渲染的骨架不同它把几何信息和材质信息写到 G-Buffer然后在屏幕空间做光照计算。选择前向还是延迟取决于场景的光源数量和材质复杂度。移动端因为带宽限制通常还是前向渲染为主配合少量的延迟或 Cluster 方案。2. 渲染硬件接口 RHI 的设计与实现要点RHIRender Hardware Interface是渲染系统里最容易被低估的部分。很多自研引擎在这块偷懒直接暴露 D3D11 的接口给上层结果就是绑死在 Windows 平台上。等到要移植主机或者移动端的时候才发现要改的地方比想象的多得多。2.1 RHI 到底该抽象什么RHI 的核心目标是用一套统一的接口描述 GPU 操作让上层代码不依赖具体图形 API。但抽象的范围需要仔细权衡。抽象太少起不到屏蔽作用抽象太多性能和灵活性都受损。我的经验是RHI 应该抽象以下几类对象设备与队列Device、CommandQueue、SwapChain资源Buffer、Texture、Sampler、RenderTarget管线状态Shader、PipelineState、DescriptorSet命令CommandList、CommandBuffer、Fence不应该抽象的东西具体的 Shader 语言、具体的资源布局优化、具体的同步原语。这些应该由后端层自己处理上层只需要知道我要一个纹理和我要画这个。2.2 句柄式资源管理的优势现代 RHI 普遍采用**句柄Handle**而不是裸指针来引用 GPU 资源。原因有三第一句柄可以跨后端统一。D3D12 的 ID3D12Resource* 和 Vulkan 的 VkImage 是完全不同的类型但上层只看到一个TextureHandle里面就是一个 uint64。第二句柄便于做延迟释放。GPU 资源不能立即销毁必须等 GPU 用完当前帧才能释放。用句柄的话可以在 CPU 端维护一个引用计数表等 Fence 信号到了再统一回收。第三句柄可以做校验。Debug 模式下可以在句柄里塞一个 magic number用错资源的时候直接断言比等到 GPU 崩溃再查要快得多。// 简化的句柄定义 struct TextureHandle { uint32_t index; uint32_t generation; bool IsValid() const { return generation ! 0; } }; // 资源池管理 class TexturePool { std::vectorTextureResource m_textures; std::vectoruint32_t m_freeList; public: TextureHandle Create(const TextureDesc desc); void Destroy(TextureHandle handle); TextureResource* Get(TextureHandle handle); };generation字段的作用是防止悬空句柄——资源释放后 generation 加一旧句柄的 generation 对不上就判定为无效。这个技巧在资源频繁创建销毁的场景下特别有用。2.3 命令录制的多线程策略多线程命令录制是提升 CPU 端渲染性能的关键手段。D3D12 和 Vulkan 都支持多线程录制 Command List但怎么划分工作负载有讲究。常见的策略有三种按渲染 Pass 划分每个 Pass 一个线程比如阴影 Pass 一个线程、主 Pass 一个线程。这种划分简单但负载不均衡阴影 Pass 通常比主 Pass 轻很多。按对象批次划分把可见对象分成 N 份每份一个线程录制。这种划分负载均衡好但需要处理跨线程的资源依赖。混合划分大 Pass 内部按对象划分小 Pass 整体分配。这是实际项目里最常用的方案。我实测下来在 8 核 CPU 上多线程录制能把 CPU 端渲染耗时从 8ms 降到 3ms 左右。但要注意Command List 的分配和回收本身也有开销如果每帧创建销毁几百个 Command List反而会拖慢性能。正确做法是维护一个 Command List 池循环复用。提示多线程录制时所有被多个线程访问的资源比如全局常量缓冲必须做同步。我踩过的坑是多个线程同时往一个 Upload Buffer 里写数据结果数据错乱画面闪烁。后来改成每个线程独立的 Upload Buffer问题才解决。2.4 跨平台适配的实战经验跨平台适配最麻烦的不是 API 差异而是特性差异。比如D3D11 的 Feature Level 11.0 不支持 Compute Shader 的某些高级特性但 D3D12 支持。Vulkan 的 Render Pass 需要显式声明依赖D3D12 的 Barrier 更灵活但更容易写错。Metal 的 Argument Buffer 和 D3D12 的 Root Signature 概念相似但细节不同。我的做法是在 RHI 之上再加一层能力查询接口上层代码通过Device::SupportsFeature(Feature::ComputeShader)来判断当前平台是否支持某个特性不支持就走降级路径。这样同一套渲染代码可以在高端 PC 和低端移动端上跑只是效果有差异。3. 渲染管线的核心环节与 Shader 管理渲染管线是渲染系统的骨架Shader 是血肉。这两块配合不好要么效果出不来要么性能上不去。3.1 渲染队列的组织与排序渲染队列是连接场景数据和渲染管线的桥梁。它的核心任务是把可见对象按正确的顺序排列让渲染管线按序执行。一个典型的渲染队列包含以下信息struct RenderItem { MeshHandle mesh; MaterialHandle material; Matrix4x4 transform; uint32_t queueType; // Opaque / Transparent / UI float depth; // 用于排序 uint32_t sortKey; // 打包的排序键 };排序键的设计很关键。我通常把排序键打包成一个 uint64高 32 位放队列类型和材质 ID低 32 位放深度。这样一次排序就能同时满足先按队列、再按材质、最后按深度的需求。不透明物体通常按材质优先排序因为切换材质的开销比切换深度大。透明物体必须按深度从远到近排序否则混合结果会出错。UI 通常按层级排序不参与深度测试。3.2 Shader 编译与变体管理Shader 变体是渲染系统里最容易被忽视的性能杀手。一个材质可能因为不同的光照模式、不同的阴影质量、不同的平台产生几十个变体。如果每个变体都单独编译编译时间会爆炸。我的做法是按需编译 缓存启动时只编译最常用的变体比如 PC 平台的高质量变体。运行时遇到未编译的变体异步编译先用一个默认变体顶上。编译好的变体缓存到磁盘下次启动直接加载。变体的 key 通常由一组宏定义组成比如LIGHTING_MODE1, SHADOW_QUALITY2, PLATFORMPC。用哈希表管理这些 key查找和插入都是 O(1)。struct ShaderVariantKey { uint64_t hash; static ShaderVariantKey FromMacros(const std::vectorShaderMacro macros); }; class ShaderCache { std::unordered_mapuint64_t, ShaderHandle m_variants; public: ShaderHandle GetOrCompile(const ShaderVariantKey key); };注意Shader 编译是 CPU 密集型操作不要在渲染线程上做。我见过有的引擎在主线程上同步编译 Shader结果一进新场景就卡顿几秒。正确做法是开一个专门的编译线程池编译完成后通过回调通知渲染线程。3.3 材质系统的设计取舍材质系统的核心问题是如何让美术方便地调效果同时让程序方便地优化性能。我倾向于把材质分成两层材质模板Material Template定义这个材质用哪些 Shader、哪些参数、哪些渲染状态。由程序创建美术不能改。材质实例Material Instance基于模板创建只改参数值颜色、纹理、数值。美术可以自由创建和修改。这种设计的好处是同一个模板下的所有实例共享 Shader 和渲染状态只有参数不同。渲染时可以把相同模板的实例合并批次减少状态切换。材质的参数通常分几类标量、向量、纹理、开关。开关类参数要特别小心因为它会触发 Shader 变体切换。如果一个材质有 10 个开关理论上就有 1024 个变体。实际项目中要限制开关的数量或者把开关组合成枚举减少变体爆炸。3.4 后处理链的搭建后处理是渲染管线的最后一道工序也是效果提升最明显的地方。一个完整的后处理链通常包含Bloom提取高亮区域模糊后叠加回原图。Tone Mapping把 HDR 颜色映射到 LDR 显示范围。颜色分级调整对比度、饱和度、色温。抗锯齿FXAA、TAA 或 MSAA。景深模拟相机焦距效果。运动模糊根据速度缓冲模糊。后处理的性能开销主要来自带宽。每个后处理 Pass 都要读写一张全屏纹理1080p 下就是 8MB 的读写量。如果后处理链有 6 个 Pass带宽消耗就是 48MB 每帧60 帧就是 2.8GB 每秒。移动端根本扛不住。优化手段有几种合并 Pass、降低分辨率、使用 Compute Shader 做 Tile-Based 处理。我通常会把 Bloom 和 Tone Mapping 合并成一个 Pass把颜色分级和抗锯齿合并这样能把 Pass 数量从 6 个降到 3 个。4. 常见问题排查与性能优化实录渲染系统的问题排查是最考验经验的环节。GPU 不像 CPU 那样可以单步调试出了问题往往只能靠现象反推原因。下面是我这些年踩过的坑和总结的排查方法。4.1 画面异常的排查思路画面异常大致分几类黑屏、花屏、闪烁、错位、颜色不对。每类的排查路径不同。黑屏是最常见的。排查顺序检查 SwapChain 是否创建成功Present 是否被调用。检查渲染目标是否绑定正确Viewport 是否设置。检查 Shader 是否编译成功常量缓冲是否上传。检查深度测试是否把所有东西都剔除了。花屏通常是资源问题纹理格式是否匹配比如把 R8G8B8A8 当成 R16G16B16A16 用。Buffer 是否越界读写特别是常量缓冲的大小对齐。多线程录制时是否有资源竞争。闪烁通常是同步问题双缓冲/三缓冲的 Present 时机是否正确。资源是否在 GPU 还在用时就被释放或改写。Fence 信号是否等待正确。我一般会在引擎里内置一个Debug 渲染模式可以单独显示深度、法线、UV、光照等中间结果。这样出问题的时候能快速定位是哪个环节出错。4.2 性能瓶颈的定位方法渲染性能瓶颈分 CPU 端和 GPU 端。定位方法不同。CPU 端瓶颈的特征是GPU 利用率低帧率上不去Profiler 显示 CPU 渲染耗时高。常见原因Draw Call 太多状态切换频繁。剔除做得不好渲染了大量不可见对象。多线程录制没有用好单线程成为瓶颈。GPU 端瓶颈的特征是GPU 利用率高降低分辨率帧率提升明显。常见原因Overdraw 严重透明物体太多。Shader 太复杂ALU 或纹理采样成为瓶颈。带宽不够后处理 Pass 太多。定位工具方面PC 上我常用 RenderDoc 和 PIX主机上用平台自带的 Profiler移动端用 Snapdragon Profiler 或 Xcode GPU Capture。这些工具能看到每个 Draw Call 的耗时、每个 Pass 的带宽消耗非常直观。4.3 常见问题速查表现象可能原因排查方法黑屏SwapChain 未创建、Viewport 未设置、深度测试全剔除逐步检查渲染流程用 Debug 模式显示中间结果花屏纹理格式不匹配、Buffer 越界、资源竞争检查资源创建参数开启 Debug Layer闪烁同步问题、资源提前释放检查 Fence 等待用单帧模式调试帧率低Draw Call 多、Overdraw 严重、Shader 复杂用 Profiler 定位 CPU/GPU 瓶颈内存泄漏资源未释放、句柄未回收用资源池的统计接口检查每帧创建销毁数量Shader 编译卡顿同步编译、变体爆炸异步编译限制变体数量4.4 独家避坑经验坑一常量缓冲的对齐问题。D3D11 要求常量缓冲的大小是 16 字节对齐D3D12 要求 256 字节对齐。我见过一个项目在 D3D11 上跑得好好的移植到 D3D12 就花屏查了两天才发现是常量缓冲没对齐。坑二纹理的 sRGB 问题。颜色纹理要用 sRGB 格式数据纹理法线、粗糙度要用线性格式。搞反了会导致颜色偏暗或偏亮而且很难通过调参数修正。坑三多线程录制的资源生命周期。Command List 录制时引用的资源必须保证在 GPU 执行完之前不被释放。我通常用引用计数 延迟释放队列来管理每帧结束时把释放请求加入队列等 Fence 信号到了再真正释放。坑四移动端的 Tile-Based 渲染。移动 GPU 普遍采用 Tile-Based 架构RenderTarget 的切换开销很大。优化方法是尽量把渲染到同一个 RenderTarget 的 Pass 合并减少 Load/Store 操作。坑五Shader 变体的编译缓存。不同驱动版本的 Shader 编译结果可能不同缓存的时候要把驱动版本号也作为 key 的一部分否则升级驱动后可能加载到不兼容的缓存。5. 渲染系统的扩展方向与个人实践体会渲染系统不是搭好就一劳永逸的随着项目需求变化它需要不断扩展。我这些年经历过的扩展方向主要有几个。5.1 从单线程到多线程的演进早期项目通常单线程录制就够了因为 Draw Call 少。但随着场景复杂度提升CPU 端渲染耗时越来越长就必须上多线程。演进路径一般是先把剔除和排序放到工作线程。再把 Command List 录制分到多个线程。最后把资源上传也异步化。每一步都要做同步和依赖管理不能一步到位。我建议是先测量再优化用 Profiler 确认 CPU 端确实是瓶颈再动手改。5.2 从固定管线到可编程管线的演进有些项目一开始用固定的渲染流程后来要支持自定义效果就需要把管线可编程化。做法是把每个 Pass 抽象成一个节点节点之间用依赖关系连接形成一个 DAG。引擎根据 DAG 自动调度 Pass 的执行顺序。这种设计灵活但复杂适合中大型项目。小项目用固定的管线函数就够了没必要过度设计。5.3 个人实践体会渲染系统这块我最大的体会是不要过早优化但也不要留下无法优化的架构。什么意思呢就是一开始可以写得简单但接口要留好扩展点。比如资源创建接口一开始可以只支持静态资源但参数里要预留动态资源的标志位。这样后面要加动态资源的时候不用改接口。另一个体会是多写测试用例。渲染系统的 bug 往往很隐蔽靠肉眼看不出来。我通常会写一些自动化测试比如渲染一个已知颜色的三角形然后读回像素验证颜色是否正确。这种测试能在重构的时候快速发现问题。最后一个体会是多看开源引擎的代码。Unreal、Unity、Godot、bgfx 这些引擎的渲染系统各有千秋看它们的代码能学到很多设计思路。但不要照搬因为每个引擎的定位不同适合它的不一定适合你。理解背后的设计意图比记住具体实现更重要。渲染系统是个深坑但也是个有意思的坑。每次解决一个渲染 bug看到画面终于正确显示的时候那种成就感是别的模块给不了的。希望这些经验能帮到正在这条路上摸索的朋友。
返回列表