ARTICLE DETAIL

资讯详情

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

Axmol引擎RHI升级:引入Compute Pass解锁GPU计算,2D游戏也能高性能

Axmol引擎RHI升级:引入Compute Pass解锁GPU计算,2D游戏也能高性能 1. 这次升级补上的是什么RHI里的一等公民Compute Pass1.1 为什么一个2D引擎也要跟GPU Compute较劲先说结论Axmol这次RHI升级最核心的变化不是把某个图形渲染效果调得更炫而是让引擎有能力在GPU上做“图形渲染之外的计算”。过去RHIRender Hardware Interface渲染硬件接口只负责一件事——把三角形、纹理、着色器组织起来画到屏幕上。它管理的所有资源、状态、命令都围绕绘制调用转。而这次升级之后RHI里多了一类可以被调度、被同步、被调试的“计算任务”。可能有人会问Axmol是2D引擎2D游戏要GPU Compute干吗这个问题的答案得从2D游戏这几年的实际需求说起。过去2D游戏几千个粒子、几十张图集同步滚动CPU完全扛得住。但现在很多项目不是纯轻量休闲了玩法里开始出现流体、布料、大量动态光斑、屏幕扭曲、体积雾这类偏“特效向”的需求。这些东西用传统方式在CPU上算数据要上传GPUGPU渲染完再读回来来回一趟带宽和延迟都受不了。真正靠谱的做法是把粒子位置更新、纹理扰动、后处理参数这些计算直接丢给GPU让GPU算完自己拿去用CPU只在提交层面做轻量管理。Axmol作为Cocos2d-x体系里更偏现代C的持续维护分支一直想在保持轻量的前提下让开发者能接触更底层、更高效的GPU能力。这次RHI升级就是把“计算”和“渲染”统一到同一套抽象层里。简单说以前RHI只有绘制管线现在多了计算管线而且两者共享同一套资源视图、同一套同步机制。1.2 RHI从“渲染专用”到“通用计算”的架构变化如果你翻过Axmol早期的渲染后端代码会发现它的抽象是围绕GraphicsPipeline来设计的。创建PipelineState时需要指定顶点着色器、片段着色器、混合模式、深度模板状态然后绑定顶点缓冲、纹理、Uniform最后DrawCall提交。这套设计没问题但它默认了“GPU的工作方式是渲染”。一旦要支持GPU Compute原来的抽象就缺了几块一个是Pipeline层面。计算任务没有顶点和片段阶段只有Compute Shader所以需要独立的管线状态对象。这两个对象在硬件上对应不同的底层管线不能混用。另一个是资源读写属性。渲染用纹理和Buffer通常GPU只读、CPU负责写但计算任务要求GPU也能写还要让同一个Buffer既能被计算Shader写、又能被顶点Shader读资源在生命周期里的状态切换就复杂了。还有同步机制。同一个Buffer被compute pass写入后再被graphics pass读取中间必须有明确的依赖关系。很多崩溃和花屏就出在这里。所以这次RHI升级的核心工作就是在抽象层上引入了“计算管线”这个一等公民同时把资源和同步管理改造成“不区分管线类型”的统一模型。1.3 我能用这批能力做什么实际玩法先说几个我实际在测试项目里跑过、也推荐读者上手的场景这样你对升级的价值会有更直观的感受。第一是GPU粒子系统。一万个粒子每个粒子有位置、速度、生命周期、颜色。传统写法是CPU每帧遍历这一万个粒子更新完把数据重新提交GPU。现在这些粒子属性直接放在GPU Buffer里Compute Shader每帧更新一次渲染Pass直接读取。CPU每帧只需要提交一个Dispatch和一个DrawCall粒子数量上去也不慌。第二是屏幕后处理。比如全屏模糊、径向模糊、描边、像素化这些操作的中间结果往往需要在多张纹理之间来回读写。过去只能通过多次RenderTarget切换实现效率不高。有了Compute Pass可以直接把纹理读进来、算完写到另一张纹理再用一个全屏三角形把它画出来流程清晰性能也可控。第三是动态合图、动态纹理生成。比如角色受伤时生成实时遮罩纹理、物品根据玩家染色方案生成贴图这些不需要美术提前出图而是运行时根据参数在GPU上算出来。以前这类需求大多数靠CPU操作像素、再上传纹理效率很低Compute Pass能直接解决。这些场景不是纸上谈兵。我后面会拿GPU粒子更新和全屏后处理两个示例把从资源创建到RenderPass提交的完整链路走一遍。2. 核心架构拆解渲染和计算在RHI里如何共用一套资源2.1 管线状态对象Graphics与Compute的合流RHI升级后引擎的管线状态对象分成了两条图形绘制管线Graphics Pipeline和计算管线Compute Pipeline。两者的创建入口类似但配置内容完全不同。图形绘制管线需要顶点着色器、片段着色器、顶点输入布局、光栅化状态、混合状态、深度模板状态。计算管线只需要一个Compute Shader以及它的线程组布局配置。你可别小看“线程组布局”这一项它决定了Shader里SV_GroupID、SV_GroupThreadID这些语义怎么映射到硬件执行单元直接关系性能。在Axmol RHI的实践里我们建议把Compute管线也做成可缓存的状态对象避免每帧重复创建。底层API里创建PipelineState本身是有开销的尤其在Vulkan和Metal上管线编译可能触发驱动后端编译反复创建会造成卡顿。正确做法是在加载阶段预创建、运行时按需切换。代码层面我简化成这样的结构// 伪代码示例说明计算管线对象的结构 RHIPipelineState* pso rhi-createComputePipelineState({ .computeShader shader, .threadsPerGroup { 256, 1, 1 } }); rhi-bindComputePipelineState(pso); rhi-dispatch(4096 / 256, 1, 1);这个API形状看起来简单但背后要保证的事情不少Compute Shader的Uniform布局要能适配不同后端、线程组大小在Metal和Vulkan之间的语义转换要一致、某些设备对线程组大小有上限要求RHI内部得做校验和兼容。2.2 Buffer的内存域从“被读”变成“可读写”图形渲染时代Buffer基本是这么用的CPU填充顶点数据GPU读取CPU填充UniformGPU读取。也就是说Buffer对GPU是“只读”的对CPU是“只写”的。但GPU Compute要求Buffer同时具备“GPU可写”能力而且这个写的结果还要能被后续渲染Pass读取。这就带来一个内存属性设计问题。在Metal里Buffer有Shared、Managed、Private几种存储模式Vulkan里对应不同类型的内存类型OpenGL ES 3.1虽然藏得比较好但Shader Storage Buffer的使用也有明确的读写约束。RHI这次升级做了一个统一抽象所有Buffer创建时都带一个StorageMode明确CPU和GPU各自能读还是能写。我把参与计算任务的资源模式分成三类图形任务专用CPU写、GPU读比如顶点缓冲、Uniform缓冲。这个模式最保守兼容性最好。计算任务专用GPU写、偶尔CPU读回比如模拟结果回读、调试数据导出。这类Buffer用Private内存最合适因为GPU读写带宽最大CPU读回是低频操作。图形与计算混用GPU计算写、GPU渲染读这是最常用的场景粒子位置缓冲就属于这种。它的内存布局要保证计算Pass写入之后渲染Pass不需要额外拷贝就能直接采样。这三个模式在RHI里都有明确的枚举创建资源时要按照使用场景选不能图省事全用Shared模式。Shared内存虽然CPU和GPU都能访问但在部分移动GPU上等于放弃了硬件对内存访问的带宽优化性能会差一截。2.3 Dispatch、Barrier和Fence的封装计算Pass和图形Pass交错的帧流程里最大的坑不是调度本身而是同步问题。一个Buffer先被Compute Pass写入接着被图形Pass读取如果显卡还在执行写入就直接触发读取轻则拿到上一帧的数据重则设备报错、花屏、崩溃。不同API的同步方式差异很大Metal有MTLFence和BarrierVulkan需要手动设置Pipeline Barrier区分Stage和AccessOpenGL ES则隐藏了一部分同步可能让开发者在移动设备上产生“看起来没问题”的错觉换到桌面GPU就问题频出。Axmol RHI这次做的是把同步也收进RHI层。引擎内部维护一张“资源状态表”标记每个Buffer/Texture当前是被哪个Pass使用、处于读还是写状态。当新的Pass要使用某个资源时RHI自动插入需要的Barrier或Fence。开发者不再需要理解每个后端底层的同步细节只要按“计算Pass写入渲染Pass读取”的流程写就行了。当然RHI的自动同步是有代价的它会为了保险起见插入比实际需要更多的同步点。对性能极其敏感的项目未来可以再暴露更细粒度的手动控制。现阶段自动同步对大多数项目是划算的毕竟程序挂掉比多一两个Barrier更难以接受。3. 上手实测在Axmol里用RHI跑GPU粒子与后处理3.1 准备数据创建RHI Buffer并灌入初始坐标我实际测试的项目是一个2D动态粒子场景满屏飘浮的发光粒子数量设在8000粒子受一个不规则力场影响。传统做法CPU遍历8000个粒子更新速度、位置每帧把所有数据重新上传到GPU。用RHI的Compute改造后步骤变成这样创建粒子属性Buffer格式是一个结构体数组每个结构体包含位置、速度、生命值、颜色。这个Buffer的存储模式要选“GPU Compute Write Graphics Read”那一类保证GPU写完渲染阶段直接能用。创建时需要在CPU端填充初始数据。注意这一步不需要与渲染循环同时发生是加载阶段一次性操作。创建用于计算Shuffle的额外Buffer。实际算法里粒子位置更新需要读取上一帧的数据、写入新数据为了避免同时读写一个Buffer我用双缓冲策略两个Buffer轮流作为输入和输出。这样避免了复杂的原地更新约束写起来也直观。下面是伪代码演示创建和初始化struct Particle { float2 position; float2 velocity; float life; float maxLife; }; RHIBufferDescriptor desc{}; desc.size sizeof(Particle) * particleCount; desc.storageMode StorageMode::Private; // GPU只读写CPU不可直接访问 desc.usage BufferUsage::Storage | BufferUsage::Vertex; RHIBufferRef particleBufferA rhi-createBuffer(desc); // 用临时SharedBuffer填充初始数据再上传到PrivateBuffer rhi-uploadBufferData(particleBufferA, initialParticles.data(), desc.size);这里有个细节Private模式Buffer不能直接map写入我会先用一个CPU可写的临时Buffer填充数据再通过RHI的上传接口拷到目标Buffer。这样虽然多一步但它把“运行时高频更新”和“加载一次性初始化”分开性能模型清晰。3.2 写一个compute shader更新粒子位置粒子更新逻辑的Compute Shader我用类HLSL风格写清楚核心部分RWStructuredBufferParticle particlesIn : register(u0); RWStructuredBufferParticle particlesOut : register(u1); cbuffer UpdateParams : register(b0) { float2 forceFieldCenter; float deltaTime; float pad; }; [numthreads(256, 1, 1)] void CSMain(uint3 id : SV_DispatchThreadID) { uint index id.x; if (index particleCount) return; Particle p particlesIn[index]; p.life - deltaTime; if (p.life 0.0f) { // 重置到随机位置这里用伪随机函数 p.position rand2(); p.life p.maxLife; p.velocity 0; } else { float2 dir normalize(forceFieldCenter - p.position); p.velocity dir * 0.5f * deltaTime; p.position p.velocity * deltaTime; } particlesOut[index] p; }这个Shader做的事情很简单但它代表了典型的计算模式读取结构体、按算法更新、写回结构体。真正的效果可以复杂得多但基础链路就是读-算-写。为什么用双Buffer而不是原地写因为在GPU上一个Buffer同一个Pass内既做读又做写虽然很多API允许但需要更严格的同步标注而且不能保证所有驱动都高效处理。双Buffer让读写天然分离驱动可以更激进地调度保证带宽利用率。3.3 把计算Pass输出的数据交给渲染PassCompute Shader更新完粒子位置后下一步是把计算结果用于渲染。这里有一段RHI自动同步发挥作用的关键流程计算Pass绑定particleBufferA作为输入、particleBufferB作为输出Dispatch N/256个线程组。自动同步RHI检测到particleBufferB接下来会被图形Pass当作顶点缓冲使用自动在两者之间插入一个BufferBarrier或MTLFence。渲染Pass绑定particleBufferB作为顶点缓冲正常执行绘制调用。整个过程里我没有手写任何同步代码。但如果要自己写得注意底层API里“计算后立即读取”必须显式声明写入完成的依赖。这段流程跑通后帧率的表现很直观。之前CPU更新8000粒子每帧要几毫秒部分低端机直接掉帧改成GPU Compute后CPU每帧只负责两次提交粒子数量哪怕翻到16000帧时间也稳得住。3.4 与后处理结合做一个全屏模糊的compute版本粒子只是计算能力的一个应用场景。另一个常用的场景是全屏后处理我用它验证了RHI里纹理读写交换的可靠性。实现思路把一张渲染好的场景纹理作为输入Compute Shader对纹理做横向模糊结果写到另一张纹理再把这张纹理画到屏幕上。流程拆开就是场景渲染到离屏纹理A。计算Pass绑定纹理A的只读视图、纹理B的可写视图dispatch整屏的线程组。计算Pass内部每个线程处理一个像素采样周围像素求平均写入B。图形Pass全屏三角形绘制片段Shader采样B并输出到屏幕。这里面需要重点说明的是纹理视图的概念。同一个RHITexture在计算Pass里读、渲染Pass里读绑定方式不同。RHI为纹理提供了SRV读视图和UAV读写视图两种视图。创建纹理时必须提前声明它将来会被用作UAV。如果你忘了建UAV后面所有要拿它当计算输出的尝试都会失败。在代码里看起来大概是RHITextureDescriptor texDesc{}; texDesc.width screenWidth; texDesc.height screenHeight; texDesc.format PixelFormat::RGBA8; texDesc.usage TextureUsage::RenderTarget | TextureUsage::Storage; // 允许UAV访问 RHITextureRef sceneTex rhi-createTexture(texDesc);加了Storage标记后这张纹理才能被Compute Shader作为写目标。这也提醒大家RHI升级后创建纹理时要想清楚它的完整生命周期别等要绑定UAV了才发现底层资源当初没开权限。4. 跨后端适配GL、Metal、Vulkan三端的计算管线差异4.1 Metal与Vulkan的线程模型对比Axmol是多后端引擎桌面和移动端都要支持。Compute Shader在不同后端上有不同的调度模型RHI要做的是把它们统一到一个概念下。Metal里计算调度用dispatchThreadgroups每组是threadgroup在Shader里用[[thread_position_in_grid]]获得全局线程ID。Vulkan则用vkCmdDispatch调用GLSL里的gl_GlobalInvocationID。两者的语义高度相似只是命名和边界处理细节不同。真正要小心的是线程组大小上限。Metal的threadExecutionWidth一般是32或64Vulkan的maxComputeWorkGroupSize通常是128/128/64但也有老设备更小。RHI在这上面做了一个校验如果开发者指定的线程组大小超过最低硬件上限就自动拆小、同时调整Dispatch数量。我建议计算Shader的线程组大小统一用256并且把逻辑写成“一个线程处理一个元素、通过global id来判断是否越界”这样无论设备上限是128还是1024都能工作。4.2 OpenGL ES 3.1下的限制与降级移动端最大的兼容性问题是OpenGL ES版本。GLES 3.0很常见但不支持Compute ShaderGLES 3.1开始才有glDispatchCompute和Shader Storage Buffer。所以跨后端适配里必须做能力检测。RHI升级后我在引擎运行时查询当前设备是否支持ComputePass。对于不支持的老设备有两种降级方案回到CPU更新方案逻辑不变只是粒子更新函数跑到CPU上Buffer上传模式也用回CPU可写。使用自定义后处理降级比如模糊效果改用多次采样方式实现不清空“计算能力”相关UI。实际项目里如果目标玩家的设备普遍是老Android机我建议别把核心玩法押在GPU Compute上。更合理的做法是把它当作“高端机增强效果”老设备走CPU兼容路径。4.3 多后端Shader引用的编写策略计算Shader的跨后端编译也是这次升级里比较花精力的一部分。Axmol的Shader体系基于GLSL和MSL两套源码以前只编译顶点和片段阶段现在要额外处理Compute Shader的跨平台差异。GLSL的布局修饰符和Metal的buffer绑定索引是两套体系。RHI在Shader编译阶段做了一层“资源自动映射”开发者在Shader里声明了register(b0)或buffer(0)RHI根据后端自动绑定到正确的资源槽位。这样一来同一个计算效果在Metal和GL上各写一遍代码RHI负责衔接绑定关系。虽然引擎能自动映射但我还是建议Shader里对资源槽位的声明保持固定顺序不要编写“Shader里顺序随意、靠绑定表修正”的代码。自动映射能解决90%的索引问题剩下10%的健康检查成本不高但踩一次坑就要花不少时间排查。5. 性能数据与踩坑清单5.1 一组实测数据CPU时间、带宽与功耗我把这次升级后的实际测试数据整理出来给准备迁移的团队一个参照。测试机型是骁龙8系手机和一台支持Metal的MacBook场景就是前面提到的8000粒子加一个模糊后处理。移动端方面CPU每帧提交时间从约6.2ms降到约1.8ms下降主要来自粒子更新循环从CPU搬到了GPU。帧率从39帧提升到58帧接近满帧。MacBook上虽然没有那么夸张的瓶颈但同样发现GPU Compute模式下CPU时间少了约70%。性能改善的根源不是GPU算得比CPU快多少而是省掉了“CPU算完再上传”这条来回通路。数据从产生到使用自始至终留在GPU显存里CPU和GPU之间的数据搬运次数降到最低。这一点在移动平台上尤其重要移动平台总线带宽是稀缺资源能少走一趟就少走一趟。5.2 高频踩坑记录与排查思路第一坑Buffer创建时没有声明Storage用途导致Compute Shader绑定失败。排查方法查看每个后端控制台输出Metal会直接报“buffer is not bound for write access”。解决给Buffer设置明确的Usage标志尤其不能缺少Storage位。第二坑Dispatch线程数计算错误导致某些粒子没有被更新。一个典型的例子是8000个粒子、每组256线程Dispatch数量按30组来发结果只能覆盖7680个粒子最后320个粒子永远原地不动。解决Dispatch数量向上取整并在Shader里做越界判断。第三坑同步依赖缺失导致数据帧延迟。特征表现为粒子渲染出现“拖影”或“上一帧残留”在Vulkan上尤其明显。解决确认RHI是否自动插入Barrier如果自己管理同步记得在Compute写入和Graphics读取之间放置Release/Acquire操作。第四坑移动端GLES 3.0设备直接崩溃。这个没有好的运行时处理办法只能靠能力检测提前分流不要让电脑配置不支持Compute的设备走进这段代码。第五坑调试时读回数据导致性能骤降。为了验证计算结果我最初每帧把粒子Buffer读回CPU检查帧率直接掉到个位数。解决只在Debug构建里、且通过开关控制读回频率比如每隔120帧读一次做抽查。5.3 后续扩展方向GPU剔除、动态合图、自定义后处理链这次RHI升级完成后我个人的下一步探索重点会放在三个方向。第一个是GPU剔除和遮挡查询。2D游戏的渲染对象数量通常不算大但有些场景用几千个Sprite做密集布局CPU端逐个裁剪也是一笔开销。把裁剪逻辑做成计算Pass在GPU端处理全部对象只把可见对象索引交给渲染PassCPU负担还能再降一截。第二个是动态合图。很多特效需要运行时把多张小图动态合到一张大图上算好所有精灵在合图里的UV偏移。过去这活是CPU做的偶尔会卡顿。放到Compute Pass里做一次渲染多个小图到一张纹理目标比逐张绘制性能更好。第三个是自定义后处理链。围绕RHI把模糊、光晕、色散、描边这些效果做成通用Pass让2D项目可以在不安装复杂Shader插件的情况下直接用。这对做休闲游戏、独立游戏的团队价值很大省掉了自己反复试验时间和踩坑成本。不过这些方向都还处于技能验证阶段直接说“已经做到引擎里”不太准确。我计划在之后的版本里把其中一两个沉淀成可复用组件等真正稳定了再出来写第二篇分享。最后分享一个我自己的实操习惯任何新能力不要直接在项目主分支里大规模使用先在Demo场景里跑通一个最小链路确认同步、资源生命周期、兼容性都没问题再复制到业务代码里。GPU Compute看起来只多了一个Pass但它改变的是渲染循环里数据流动的基本假设一旦出问题排查的成本一定比你写新代码的成本高得多。RHI升级是好事但也要带着敬畏心来用。
返回列表