ARTICLE DETAIL

资讯详情

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

RHI升级解析:图形渲染如何自然延伸到GPU Compute

RHI升级解析:图形渲染如何自然延伸到GPU Compute CPU想算的东西丢给GPU并行跑——图形渲染之外的GPU Compute在Axmol里一直比较拧巴。作为从Cocos2d-x延续下来的引擎Axmol最初的设计重心是稳定的2D/3D渲染开发者大多只和Sprite、Node、Texture打交道很少去碰底层的RHIRender Hardware Interface。直到最近这次RHI升级局面才算真正改变渲染层不再只是draw call的搬运工而是一套能同时容纳图形流水线和GPU Compute的统一抽象。这篇文章按我的理解把这次升级涉及的设计动机、关键技术点和踩坑实录完整梳理一遍内容包括RHI为什么要做、图形渲染如何自然延伸到Compute、在Axmol里跑通一次Dispatch的全过程以及大家最容易栽进去的同步和资源状态问题。无论你是想给引擎加自定义后处理还是想把粒子、蒙皮、剔除这类高开销逻辑挪到GPU上这篇文章的思路都能直接参考。1. 先搞懂Axmol 这次 RHI 升级到底动了什么1.1 老渲染管线卡在哪Axmol 和它上一代的 Cocos2d-x 有一个共性问题底层渲染接口绑定得太死。老版本里你写一个自定义Shader多半是直接面对 OpenGL ES 2.0 的 glUniform节点树里渲染一个Sprite底层就是绑定纹理、设置MVP、提交顶点、画三角形。这套流程在2D时代够用但到了需要后处理、阴影、复杂光照、GPU粒子的时候问题就全部暴露出来了。我总结了三个核心痛点。第一是API碎片化严重。同一段渲染代码iOS上要走MetalAndroid设备又要切OpenGL ES想在Windows桌面上跑又得接D3D或Vulkan每个后端一套规范Shader要维护多份调色、调整参数要一个一个平台重复验证维护成本呈指数上升。第二是状态管理分散。老引擎里“设置渲染状态”这件事散布在Renderer、Node、GLStateCache各个地方一旦要做RenderPass级别的优化你很难看清某个绘制调用到底依赖了哪些纹理和缓冲。第三是计算能力基本为零。OpenGL ES 3.0虽然能勉强通过transform feedback做一些偏门的并行计算但可控性差、可读性差移动端驱动兼容性更是五花八门根本撑不起正经的GPU通用计算。这三个痛点叠加在一起升级已经不只是“想不想”的问题而是继续拖下去某些新功能根本做不进去。而RHI的意义就是在这里从根本上解决问题。1.2 RHI 抽象层的价值RHI全称Render Hardware Interface本质上就是一个面向引擎上层的“硬件驱动适配层”。它的目标很直白让引擎上层只跟一套统一的渲染API打交道而这套API背后可以对接Vulkan、Metal、D3D12甚至保留OpenGL ES作为兼容性兜底。打个比方。以前的渲染代码像一把钥匙开一把锁你在iOS写的Metal代码没法直接跑到Android上换平台就得换钥匙。RHI做的是把事情变成标准插座加转接头上层统一用两脚插头至于插孔是美标还是欧标交给转接头去处理。上层写一次底层适配。这样一来跨平台工作量被压缩到RHI那一层上层只管概念清晰、可预测的接口。具体来说RHI至少承担四件事设备管理创建图形设备、查询硬件能力、命令录制CommandBuffer、RenderPass、资源管理Buffer、Texture生命周期、管线状态管理Shader、PipelineState。这四个能力凑齐以后引擎的上层其实不用再关心底层到底是哪个API。而图形渲染和GPU Compute能够被统一抽象正是建立在这四件事之上。1.3 升级影响的面积很多人以为RHI升级只是一个“渲染模块内部重构”改完以后函数调用换了个名字而已。实际不是。这次升级的影响面积相当大我接触下来至少有四个模块被牵连Renderer原来按场景节点顺序直接提交DrawCall现在要先走一次CommandBuffer编码再提交给Queue。Texture/FrameBuffer原来一个纹理对象背后就是一张GL纹理现在还涉及存储格式、采样视图、读写视图、生命周期等概念。Shader原来一份GLSL打天下现在要经过编译、转译、字节码缓存还要处理PushConstant、DescriptorSet、UniformBuffer等新概念。资源上传原来glTexImage2D一把梭现在上传前的布局切换、上传后的同步都变成显式操作。这四个模块一联动凡是在引擎上做过自定义渲染的团队基本都要把渲染相关代码重写一遍。这也是为什么很多引擎在做RHI重构时特别谨慎——不是技术难而是牵一发动全身。好处是一旦熬过这个阶段后面再接入Metal、Vulkan、D3D12这些新特性成本会低非常多。2. 为什么图形渲染要通往 GPU Compute2.1 图形渲染与通用计算的本质差异要理解这次升级为什么要特意强调GPU Compute先得把“图形渲染”和“通用计算”这两件事拆开。图形渲染的本质是“把三维场景变成二维像素”整个流程被硬件固定成一条流水线顶点着色器、几何处理、光栅化、片元着色器、混合输出。程序员一旦进入这条流水线只能按固定的顺序做事想在光栅化阶段插入一段“计算”能力是受限的。GPU Compute保留了GPU海量线程并行执行的天赋但不再强制你走图形流水线。你把数据放到Buffer或纹理里定义线程组布局写一个计算着色器GPU就会按你指定的规模并行执行。没有顶点没有光栅化没有输出合并一切由开发者自己设计。放到设备上理解就更容易现代GPU基本都采用统一的着色器硬件同一个计算单元既能跑顶点着色器也能跑片元着色器也能跑计算着色器。区别只在于API层暴露给开发者的是哪一套组织方式。图形渲染只是GPU能力的一种应用场景而Compute把它“通用处理器”的另一面彻底打开了。2.2 没有Compute时想在渲染里做计算的笨办法在GPU Compute没普及之前想在渲染流程里做“并行计算”其实也有办法只是很绕。最常见的方案是把数据编码进纹理用一张全屏三角形跑Fragment Shader把“计算结果”写到输出纹理上。早期不少后处理效果比如高斯模糊、边缘检测、Bloom都是这么做的。这个方案能跑但问题很多。首先是数据表达受限纹理是像素格式想把任意结构体数组塞进去得自己打包解包精度还可能丢。其次是灵活性差Fragment Shader的线程布局由光栅化规则决定你想让一个线程处理一个物体、或者一个线程组做一次归约几乎不可能。第三是同步困难一次draw call只对应一个输出想做中间结果缓存、多阶段流水线代码结构会非常扭曲。打个比方这就像你手里明明有一把电钻却非要用螺丝刀手动拧孔。电钻就是GPU的计算能力螺丝刀就是Fragment Shader这套曲线救国的方案。能用但效率、可控性、扩展性都差得远。2.3 GPU Compute 在游戏引擎里的实际用武之地那GPU Compute到底能干什么它适用的任务通常有三个特征数据量大、计算逻辑一致、对延迟不太敏感。放到游戏引擎里我列几个最有代表性的场景场景核心工作为什么适合ComputeGPU粒子系统粒子的位置、速度、生命周期更新上万粒子计算逻辑相同天然并行骨骼动画与蒙皮顶点乘骨骼权重矩阵每个顶点独立计算数据规模大后处理链HDR转换、景深、模糊、抗锯齿像素级操作可打破全屏三角形限制可见性剔除包围盒视锥剔除、遮挡剔除并行做交集判断回读结果给CPU布料/流体模拟碰撞检测、积分更新空间网格和线程组结构可以对应这些场景在传统引擎里不是做不了而是实现得特别别扭。RHI升级把Compute纳入统一抽象正是要解决后面这些“想做但设计上不支持”的问题。2.4 从架构层面看这对引擎意味着什么图形渲染和GPU Compute走向统一不只是“多了一种Shader类型”它动摇了引擎底层的很多假设。以前引擎的资源系统为“采样用纹理”设计绑定的是纹理单元现在资源可能要被“写入”语义完全不同。以前引擎的状态管理围绕固定图形流水线制定现在一个Pass可能是计算Pass、下一个Pass才是图形Pass两者的衔接需要明确的同步。以前同一块缓冲区要么给顶点数据用要么给统一变量用现在它可能要被Compute写入、被下一帧的VertexShader读取。简单说GPU Compute把GPU从“绘图设备”变成了“并行计算设备”而RHI必须把这个变化封装好。Axmol这次升级最核心的价值就在于此不是多了一个调用入口而是让引擎的资源管理、命令录制、渲染调度全面适应“计算与渲染混合”的工作方式。3. 核心细节解析RHI 里的 Compute 链路怎么设计3.1 设备、队列、命令缓冲三件套不管是Vulkan、Metal还是D3D12现代图形API的基础结构都可以归纳成三件套设备、队列、命令缓冲。设备是抽象出来的GPU本体提供创建资源、创建管线的能力队列是设备执行命令的通道命令缓冲是CPU侧录制的指令集录制完成后整体提交给队列执行。Axmol的RHI在设计上遵循了这套思路上层拿到一个CommandBuffer往里面录RenderPass、录ComputePass、录资源拷贝最后统一提交。这个设计的最大好处是接口和实际GPU行为贴合转译到Vulkan、Metal或者D3D12时概念映射非常直接不会出现“明明底层API做不了上层接口硬造一个”的尴尬。常见误区是以为CommandBuffer提交了就是马上执行。真实情况是CPU把命令丢给驱动后立刻返回GPU在后台慢慢执行。如果CPU下一帧又要读取GPU计算结果必须等同步对象Fence/Semaphore通知。3.2 Compute Pipeline 与 Shader 编译要在RHI里跑计算任务第一步不是直接调用dispatch而是先把“计算管线”创建好。计算管线至少包含这些信息计算着色器字节码、资源绑定布局。和图形管线相比少了顶点输入布局、光栅化状态这些概念反而更简单。但Shader这块有个比较大的工程问题一份着色器代码要为多个底层API服务。Axmol的RHI层采用的做法是把上层着色器统一编译成中间表示再通过各后端的编译器转成目标字节码。比如GLSL可以先用glslang编译成SPIR-V在Vulkan上直接用在Metal上通过SPIRV-Cross转成MSL在D3D12上再转成DXIL。层与层之间会做缓存避免每次都重新编译。这意味着你在Axmol层写好一个Compute Shader它最终要能同时跑在Vulkan、Metal、D3D12三个后端上。这很考验生态工具的成熟度。如果遇到某个平台Shader编译失败先看SPIRV-Cross的转换日志比一股脑去改Shader源码效率高得多。3.3 资源绑定与读写权限Compute Shader和Fragment Shader最大的不同之一是资源可以“写”。图形渲染里纹理通常是采样读取Compute里纹理和Buffer都可以被绑定成可写资源也就是我们常说的UAVUnordered Access View或Storage Image/Buffer。这个语义变化直接影响了资源在API层的描述方式。在OpenGL时代你只要glBindTexture绑定一个纹理具体它是可读还是可写驱动自己看着办。但在Vulkan里你要明确告诉驱动这个资源在这个Pass里是只读采样还是允许乱序写入还是先写后读。每个用途对应不同的Descriptor类型和不同的资源状态绑定错了轻则性能下降重则直接报错。以Vulkan为例一个纹理至少涉及五种角色角色典型用途注意点采样输入Shader只读采样不需要Storage usage存储读写Compute/Shader写入必须带Storage usage颜色附件RenderPass输出通常用于后处理目标拷贝目标从缓冲区拷贝到纹理一般由透传API完成深度附件深度测试/写入和颜色用法分开RHI层要把这五种用途规范成统一的描述方式让上层开发者声明“我要把这个纹理当可读可写资源用”然后底层自动换算成对应API的布局状态。渲染层的代码因此清爽很多但如果你要自己深挖性能仍然得理解背后发生了什么。3.4 同步机制最容易被忽视的一环引擎里加入Compute以后最麻烦的不是怎么调Shader而是同步。图形Pass和Compute Pass在GPU上执行时可能是并行或者乱序的如果出现“一个Pass读资源、另一个Pass同时写资源”的情况结果就是未定义说白了就是偶发性花屏、黑块、错误数据。不同API的同步模型差异很大。Metal有比较自动的hazard tracking很多情况下驱动能自动识别资源依赖并插入屏障Vulkan和D3D12则把责任交给开发者要求显式声明barrier或者用Fence、Semaphore做跨队列同步。Axmol的RHI选择把这两种模式统一成一套“资源状态自动管理”的内部机制上层只要按照“先录制写入Pass再录制读取Pass”的顺序操作RHI会在提交前自动计算依赖并插入对应的内存屏障。省事得多但代价是有些极端场景下可能产生冗余屏障性能表现需要额外验证。这里给一条经验如果你在Axmol里做多Pass的Compute链最好把同一帧的所有Compute任务集中在图形渲染开始前录制而不是穿插在图形Pass中间。原因是不同Pass之间的资源状态切换是开销只有集中在一起RHI才能在提交层面做优化把不必要的状态切换合并掉。4. 实操在 Axmol 里跑通一次 Compute Dispatch4.1 环境准备确认打开了新RHI路径用Axmol做开发默认构建可能还是走旧渲染路径。想体验RHI第一件事是确认CMake配置。以Android上用Vulkan后端为例CMake配置里需要打开对应开关cmake -S . -B build-android \ -DANDROID_PLATFORMandroid-24 \ -DUSE_RHION \ -DAXMOL_VULKAN_BACKENDON \ -DCMAKE_BUILD_TYPERelease在iOS/macOS上则把Vulkan换成Metalcmake -S . -B build-apple \ -DUSE_RHION \ -DAXMOL_METAL_BACKENDON \ -DCMAKE_BUILD_TYPERelease注意USE_RHI这个开关它是关键。开了之后引擎内所有渲染路径都会切换到新抽象层老的Renderer适配代码会被新的RHI路径替代。如果项目里还有老接口的老代码大概率编译不过这时候不要慌属于正常的迁移期问题。不同版本的开关名可能有细微出入以当前仓库文档为准但核心思路一致。4.2 编写一个计算着色器画面灰度化先写个实际能跑的例子把一张图片转成灰度图。这是最典型的Compute入门案例逻辑简单又能验证完整链路。我按GLSL风格写Axmol的着色器生态和GLSL比较接近#version 450 layout(local_size_x 16, local_size_y 16, local_size_z 1) in; layout(binding 0, rgba8) uniform readonly image2D uInputImage; layout(binding 1, rgba8) uniform writeonly image2D uOutputImage; void main() { ivec2 uv ivec2(gl_GlobalInvocationID.xy); ivec2 size imageSize(uInputImage); if (uv.x size.x || uv.y size.y) return; vec4 color imageLoad(uInputImage, uv); float gray dot(color.rgb, vec3(0.299f, 0.587f, 0.114f)); imageStore(uOutputImage, uv, vec4(gray, gray, gray, 1.0f)); }这段shader里有两个细节值得注意。local_size_x/y/z这个layout决定了每个线程组里有多少个线程我这里是16x16意味着每个线程组处理256个像素gl_GlobalInvocationID是全局线程ID从(0,0)开始一直覆盖到我们后面dispatch时指定的总线程数。写Compute Shader时一定要加越界判断尤其是图片宽高不能被16整除的时候最后一批线程组会跑到纹理外面不加return就是越界访问轻则读错数据重则设备端崩溃。4.3 C 侧创建 ComputePipeline 与资源接下来是C侧的准备。我按Axmol RHI常见的接口习惯写代码// 假设已经通过Device创建了rhiDevice指针 auto* device rhiDevice; // 1. 创建输入输出纹理格式必须和shader里一致都是RGBA8 auto inputTex device-createTexture({ .width 512, .height 512, .format TextureFormat::RGBA8, .usage TextureUsage::Sampled | TextureUsage::Storage, // 关键允许存储读写 }); auto outputTex device-createTexture({ .width 512, .height 512, .format TextureFormat::RGBA8, .usage TextureUsage::Sampled | TextureUsage::Storage, }); // 2. 创建计算管线 auto shader device-createShader(ShaderStage::Compute, grayCsSource); auto pipeline device-createComputePipeline({ .computeShader shader, }); // 3. 为Compute阶段创建资源绑定 auto bindGroup device-createBindGroup({ .pipelineLayout pipeline-getLayout(), .resources { { .binding 0, .resource inputTex-getStorageView() }, { .binding 1, .resource outputTex-getStorageView() }, }, });创建纹理时有个容易被忽略的点usage要同时加上Storage。老渲染代码里创建纹理只关心能不能被采样不会声明“我要拿它当计算存储用”。但到了Vulkan这层你在compute pass里能用什么角色访问纹理完全取决于创建纹理时的usage标记。忘了加Storage后面dispatch时绑定资源就会失败而且报错信息经常出现在驱动层让人一头雾水。4.4 提交 Dispatch 任务资源准备好了开始录制命令auto cmd device-beginCommandBuffer(); cmd-beginDebugLabel(grayscale_compute); cmd-setComputePipeline(pipeline); cmd-setBindGroup(0, bindGroup); const uint32_t groupCountX (512 15) / 16; const uint32_t groupCountY (512 15) / 16; cmd-dispatch(groupCountX, groupCountY, 1); cmd-endDebugLabel(); device-submit(cmd);线程组数量这里要展开说一下。我Shader里local_size_x16纹理宽高是512所以理想情况下线程组数量是512/1632。但宽高不一定是组的整数倍所以要用“(width localSize - 1) / localSize”向上取整。我之前见过不少新手直接512/1632结果把纹理换成513宽最右边一列像素就没人处理画面出现一条竖线。处理方式有两种要么dispatch时向上取整然后在Shader内部做越界判断就是4.2里那个if要么CPU侧保证纹理尺寸一定是线程组的整数倍。前者通用性更强是推荐做法。dispatch调用本身只是一个“记录动作”它不会马上在GPU上执行要等到submit后命令进入硬件队列才会真正执行。这也是理解整条Compute链路的关键CPU侧录制命令是异步的和GPU执行之间存在一个“时差”所有依赖关系都必须通过资源状态和同步对象来管理。4.5 把 Compute 结果接进渲染主循环灰度图算完以后不能只是存在outputTex里不管通常下一步是把它当成采样纹理送给后续的渲染Pass显示到屏幕上。这个串联逻辑要在渲染主循环里做。我建议的做法是引擎每帧在主RenderPass之前先提交一个ComputePass再把outputTex传给渲染代码作为输入。伪代码大概长这样// 每帧执行 void onFrame() { auto cmd device-beginCommandBuffer(); // 阶段1Compute pass把inputTex转成灰度并写入outputTex cmd-setComputePipeline(grayPipeline); cmd-setBindGroup(0, grayBindGroup); cmd-dispatch(groupX, groupY, 1); // 阶段2图形pass把outputTex采样到屏幕 auto renderPass cmd-beginRenderPass(screenPassDesc); renderPass-setPipeline(screenPipeline); renderPass-setBindGroup(0, screenBindGroup); // 里面绑了outputTex的采样视图 renderPass-draw(...); renderPass-endRenderPass(); device-submit(cmd); }这里就体现出了RHI统一抽象的好处。compute pass和render pass写在同一个命令缓冲里底层驱动知道它们的执行前后关系RHI层只需要根据资源状态变化自动加屏障开发者不用去背Vulkan的屏障语法或者Metal的线程组调度规则。但代价是你的渲染主循环结构要跟着调整以前一帧就是一个RenderPass现在可能是“ComputePass RenderPass”的复合体凡是围绕RenderPass做过多线程提交的项目迁移时都要重新设计提交顺序。5. 常见问题与排查技巧实录5.1 跑出来花屏或黑屏先查这四件事代码写对了但跑出来的画面花屏、黑屏或者有一片位置没更新这是我被问得最多的一类问题。排查顺序我建议固定下来线程组数量有没有算对宽高是不是向上取整了Shader里的越界判断写没写尤其是非整除尺寸纹理的usage里有没有加上Storage/读写权限dispatch和后面的采样Pass之间资源状态切换到底有没有生效。前三个是资源与调度问题第四个是同步问题。如果前三个都检查过仍然花屏基本可以断定是Comptue Pass和Render Pass之间的barrier没插到位。这时可以临时在submit之后加一个强制同步在开发期可以干线上别这么做确认问题确实是同步引起再回RHI层去看状态管理逻辑。5.2 Metal 和 Vulkan 行为表现完全不同我踩过的比较典型的一次坑是同一份Compute代码在iOS Metal上跑得完全正常换到Android Vulkan设备上就花屏。问题根源就是Metal和Vulkan的同步策略不一样。Metal有自动的hazard tracking很多时候你连barrier都不写驱动也能记住最近一次写操作并自动插入等待Vulkan则完全信任开发者你没写屏障驱动就默认没有依赖读写并发执行结果自然出错。所以开发时不要只在一个平台验证。至少要在Metal和Vulkan各跑一次两边都正常才能说明同步逻辑比较稳妥。反过来也有一个细节Vulkan上没问题、Metal上性能骤降。原因是Metal自动插屏障的策略比较保守它无法区分“显式barrier”和“依赖关键点”一些冗余的状态切换也会被当成依赖串行化。遇到这种情况需要在上层尽量复用同一个资源状态而不是频繁切换读写模式减少给Metal驱动的“误判”机会。5.3 数据回读太慢怎么办GPU算完结果CPU要拿回来做碰撞判断或者调试最常见的方式是把结果Buffer映射回CPU。但很多人会遇到一个问题结果回读慢到爆炸帧率直接从60掉到20。回读慢的核心原因多半是Buffer的内存类型选错了。在Vulkan里GPU经常会把Buffer放在Device Local显存里CPU访问不到要先拷贝到一个Host Visible的staging buffer再映射。如果你把staging放在一个很慢的内存堆每一帧都做全量拷贝开销自然大。优化方向有三个第一staging buffer用读写速度更优的Host Cached堆第二不要每帧全量回读只在需要结果的那一帧回读第三用Fence做精确等待取代一上来就Map的粗暴做法把CPU等待压到最短。另一个实战技巧是如果只是要“确认结果对不对”优先用调试工具直接看GPU内容不要写CPU回读代码。比如RenderDoc里可以直接dump出Buffer的内容Metal Debugger能看纹理每一帧的状态这些工具比自己在代码里回读快得多。5.4 CPU 与 GPU 的负载平衡用了GPU Compute以后CPU确实轻松了但GPU负载会变高。移动端尤其明显场景里跑着GPU粒子还要开后处理Compute再加上正常渲染GPU可能成为新的瓶颈。这时候不能只看CPU耗时要把帧时间拆成CPU时间、GPU时间、等待时间三块看。如果GPU时间已经接近帧预算优先减少dispatch次数把连续多个小Compute任务合并成一个大任务减少启动开销如果CPU时间还高就去排查上传和回读路径看是否多做了同步等待。经验法则是Compute任务尽量批量避免一帧几十次小dispatch每次dispatch都有固定开销累积起来很可观。我在项目里把几十个后处理节点合并成3到4个大Compute pass之后帧时间直接下降了10%。5.5 开发期调试工具怎么选最后说一句调试工具。桌面端推荐RenderDoc它现在对Vulkan和D3D12的支持非常成熟抓帧后能看到所有Compute Pass的线程组范围、资源绑定内容、GPU端报错。移动端如果是iOS用Xcode的Metal Debugger能查到底层MSL源码和每帧GPU资源状态Android上可以用Android GPU Inspector对高通和Mali设备的支持都比较完整。工具用途适用平台RenderDoc抓帧、查看Compute资源Windows/Linux/Vulkan/D3D12Xcode Metal DebuggerMetal着色器调试iOS/macOSAndroid GPU Inspector移动GPU性能分析AndroidNsight GraphicsD3D12/Vulkan性能分析Windows还有一点容易被忽视RHI层通常提供了DebugMarker接口我在代码里会给每个pass加名字这样抓帧时一眼能看到“grayscale_compute”、“main_render_pass”这些标签在时间轴上的顺序排查问题效率翻倍。我个人在接入GPU Compute这段时间最大的体会是静态地看一套RHI代码你只会觉得它是一堆抽象接口的排列真正把一次dispatch接进现有渲染循环才会理解资源状态、同步、异步提交这些机制为什么要存在。特别是遇到那种“代码逻辑完全正确但画面不对”的诡异问题十有八九都是资源读写时机出了偏差。另外还有个小心得开发期多花几分钟把每个compute pass的设置单独整理成一个可开关的配置项出了性能问题可以逐项二分定位比一上来就优化Shader效率高得多。这次Axmol的RHI升级带给开发者的不只是多了一个Compute入口更是把引擎从“画图工具”向“GPU并行计算平台”推进了一大步。后续我打算把遮挡剔除和GPU粒子两个模块完整迁移到这条链路上等有了更完整的性能数据再回来和大家分享。
返回列表