十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Axmol引擎RHI重构:统一GPU Compute与跨平台渲染实践

Axmol引擎RHI重构:统一GPU Compute与跨平台渲染实践 最近我把 Axmol 引擎的渲染底层做了一次比较大的重构核心目标就是把之前散落在各个平台后端的渲染逻辑收拢到一个 RHIRender Hardware Interface层并且在这个 RHI 之上补上 GPU Compute 的完整支撑。简单说这次升级之后在 Axmol 里写 compute shader 做后处理、粒子更新、大规模数据并行计算不再需要去碰某个特定平台的后门接口而是有一套统一的调度、资源绑定和同步机制可以依赖。这篇文章我会把这次 RHI 升级的背景、架构设计、核心实现、落地案例和踩坑记录完整复盘一遍。想做引擎渲染层维护的同学、或者正在自己的项目里接入 GPU Compute 的开发者应该都能从中找到有用的东西。1. 项目背景旧渲染路径的痛点和升级动机1.1 旧架构下“加一个渲染特性”到底有多难受Axmol 本身是 Cocos2d-x 的社区延续版本早期渲染器保留了比较重的历史包袱所有绘制命令最终都要落到 OpenGL ES 或者 Metal 的具体 API 上。引擎内部虽然有Renderer、RenderCommand这一层抽象但真正到后端时代码会被#if (AX_TARGET_PLATFORM AX_PLATFORM_MAC)这类宏塞满。每加一个新特性比如半透明排序、纹理压缩、帧缓冲对象FBO的缓存复用都要把所有平台分支都改一遍。更痛苦的是 GPU Compute 这类能力。以前如果想在引擎里做计算着色器你得自己判断当前设备是 Metal 还是 GLES 3.1再分别用 Metal Performance Shaders 或者 OpenGL ES 的 compute shader 写两套实现。这带来的问题不只是工作量大而是团队里没有人能同时精通所有后端。我当时翻代码的时候发现之前有人为某个特定机型加过一个用 GLES 的 image load/store 做亮度直方图统计的模块结果在 Vulkan 环境下根本没走到那条分支代码等于白写。这就是旧架构的真正痛点不是不能做而是每做一个特性都要面向 N 套 API 重复劳动最后维护成本高到没人敢碰这些代码。1.2 为什么选择 RHI而不是继续打平台补丁有人可能会问为什么不直接基于某一个图形 API 来做比如全面转向 Vulkan这个讨论我们内部也进行过很多次。问题是移动端有大量中低端设备只支持 OpenGL ES 3.0甚至还有少量 WebGL 1 的存量全面倒向 Vulkan/Metal 等于直接放弃一部分用户。反过来继续打平台补丁又会回到老路。RHIRender Hardware Interface的思路是把 Metal、Vulkan、OpenGL ES 之间的差异集中到一个抽象层内部消化掉。引擎上层只需要面对rhi::CommandBuffer、rhi::Texture、rhi::Buffer这些统一对象。特性支持做到“能力分级”设备支持到什么程度引擎就暴露到什么程度。这个思路和很多商业引擎的底层设计一致社区里也有 Cocos2d-x 的分支项目实践过方向是验证过可行的。这次升级还有一个重要动机Axmol 自身的DeviceGraphics模块本来就有一层薄封装但封装得太浅只管了 texture、shader、program 的创建和绑定没有把CommandBuffer、PipelineState、资源屏障、计算调度这些现代 GPU 编程模型的核心概念纳入进来。所以这次不是推倒重来而是把这层薄封装升级成真正可承载现代渲染特性的 RHI。1.3 这次升级的具体范围在动手之前,我先明确了这一次升级的边界。必须做成的事情有四件统一 RHI 对象模型覆盖Texture、Buffer、Shader、PipelineState、CommandBuffer这些核心资源在 RHI 之上接入计算管线Compute Pipeline支持dispatch调度统一 shader 编译链路让一套 shader 源码能在不同后端生成对应的底层代码让旧的渲染路径平滑迁移也就是现有游戏逻辑不改、不破坏场景渲染。最后一条非常重要。RHI 升级如果不能保证“老游戏一行不改”那就不是升级而是重写风险完全失控。所以整个改造是渐进式的先把旧的RenderCommand和绘制调用逐步迁移到 RHI再扩展计算管线能力最后才把 compute 开放给上层使用。整个过程分了三个阶段前后花了大约一个季度。2. 整体架构设计RHI 层的位置与 GPU Compute 的接入方式2.1 RHI 在整个引擎中的层次定位经过重构后Axmol 的渲染相关模块大体分成四层层次主要模块职责引擎上层Scene、Sprite、Label、ParticleSystem游戏逻辑与游戏对象渲染流程层Renderer、RenderQueue、Camera收集渲染命令、排序、剔除、生成 draw callRHI 抽象层CommandBuffer、Pipeline、Texture、Buffer、Shader屏蔽后端 API 差异提供统一资源与命令接口平台后端MetalBackend、VulkanBackend、GLESBackend实际执行 GPU 命令RHI 抽象层直接构建在 C 层面不依赖任何第三方图形库。每个后端实现一个rhi::Device派生类和一个rhi::Queue派生类。上层拿到的永远是rhi::Device的实例实际执行时再分发到对应后端。这样做有个明显的好处特性能力检测可以集中写在Device初始化阶段。比如后端在创建时就把isComputeSupported、maxComputeWorkGroupSize、hasImageStoreLoad这些能力项填好上层不需要到处判断“当前是什么平台”只需要查询能力位即可。2.2 RHI 的核心对象模型设计这次抽象的对象模型基本对齐了现代 GPU 编程的共同特征。Metal、Vulkan、D3D12 虽然叫法不同但背后都是“命令缓冲区 管线状态 资源绑定 绘制/调度”的模式。所以 RHI 的核心对象并不难定义rhi::CommandBuffer命令录制容器记录绘制、计算、拷贝、屏障等命令rhi::RenderPipelineState渲染管线状态包含顶点着色器、片元着色器、混合模式、深度状态rhi::ComputePipelineState计算管线状态包含计算着色器和线程组维度配置rhi::Texture纹理资源涵盖二维纹理、立方体贴图、帧缓冲附件rhi::BufferGPU 缓冲区区分为顶点缓冲、索引缓冲、存储缓冲、上传缓冲、回读缓冲rhi::Shader着色器模块携带反射信息和参数布局rhi::Swapchain窗口表面与交换链。其中和 GPU Compute 关系最大的是Buffer的用途位Usage Flag设计。我们把StorageBuffer单独列出来因为 Metal 的 buffer、Vulkan 的 SSBO、GLES 3.1 的 SSBO本质上都是同一类资源向 compute shader 暴露一段可读写内存。统一成StorageBuffer之后上层写出来的代码在这三个后端上是完全一致的行为。2.3 GPU Compute 如何在 RHI 里落地计算管线和渲染管线最大的区别是不需要绑定 render target而是通过 dispatch 告诉 GPU“按某种线程组规模并行执行”。在我们的小粒子系统 demo 里一次 compute 调度大概长这样auto cmd rhi::CommandBuffer::create(); cmd-begin(); cmd-bindComputePipeline(m_computePipeline); cmd-bindStorageBuffer(0, m_particleBuffer); cmd-bindStorageBuffer(1, m_configBuffer); cmd-dispatch(m_particleCount / 64, 1, 1); cmd-end(); m_queue-submit(cmd);这段代码在后端的实际行为是Metal 里调用setComputePipelineStatedispatchThreadgroupsVulkan 里调用vkCmdBindPipelinevkCmdDispatchGLES 3.1 里调用glUseProgramglDispatchCompute。上层逻辑完全一致只有 RHI 后端实现不同。这里要特别提一个设计决定我们把计算调度和绘制指令放在同一个CommandBuffer里而不是单独搞一套计算队列。原因有两点一是很多移动 GPU 实际上只有一个命令队列强行分离不仅没性能收益还会增加同步负担二是计算和渲染指令混排天然保证了执行顺序方便后续做“先用 compute 处理数据再在同一帧里绘制出来”这类任务。2.4 跨后端的能力分级策略不是所有设备都支持 GPU Compute。这里我给 RHI 设计了一张能力分级表支持级别后端平台说明FullMetal、Vulkan、DX12完整的 compute pipeline、SSBO、image store/loadLimitedOpenGL ES 3.1 / WebGL2支持 compute但可能缺少部分扩展线程组上限和 image 格式有限制NoneOpenGL ES 2.0 / WebGL1不支持 compute引擎需要回退到 CPU 或非 compute 实现能力分级的价值在于上层功能模块可以依据特性表优雅降级。比如粒子系统在 Full 级别上用 GPU Compute 更新粒子在 Limited 级别上退回到 CPU 更新在 None 级别上不仅 CPU 更新还要减少粒子数量以保证帧率。这次升级之后这个降级逻辑被集中到了引擎的CapabilityCache里任何上层模块调用rhi::Device::getInstance()-getCapabilities().isGpuComputeSupported()就能拿到结论不用再写一堆平台判断。3. 核心实现细节从接口定义到后端落地的关键环节3.1 Shader 编译链路如何统一RHI 抽象了资源对象之后shader 成了最让人头疼的一环。Metal 用 Metal Shading LanguageVulkan 用 SPIR-VGLES 3.1 用 GLSL ES三者在语法上虽然接近但细节差异不少。我采用的方案是维护一套基于 GLSL 风格的 shader 源码编译时根据不同后端转换成对应语言。具体做法是在 shader 里用自定义 pragma 标注计算着色器的线程组大小#version 310 es // axmol: compute // axmol: local_size_x 16 // axmol: local_size_y 16 layout(set 0, binding 0, rgba16f) uniform readonly image2D uInput; layout(set 0, binding 1, rgba16f) uniform writeonly image2D uOutput; uniform int uWidth; uniform int uHeight; void main() { ivec2 pos ivec2(gl_GlobalInvocationID.xy); if (pos.x uWidth || pos.y uHeight) { return; } vec4 color imageLoad(uInput, pos); // 这里做实际处理 imageStore(uOutput, pos, color); }编译工具链会先解析这些 pragma生成平台无关的中间描述再根据目标后端输出 MetalSL 或 GLSL ES。Metal 后端不需要真正在运行时编译我们可以预先把这份源码转成.metal文件放入 Xcode 工程Vulkan 和 GLES 后端则在运行时交给驱动编译。这里最需要注意的一点是readonly和writeonly的标注。GLES 3.1 强制要求 image 变量必须声明访问方向Metal 侧没有这种强制要求但标注之后可以让编译器做更激进的优化。我见过不少新手写计算着色器不标readonly/writeonly结果在 PowerVR 驱动上性能直接掉 30%。3.2 Buffer 与 Texture 的内存布局问题GPU Compute 里最容易出问题的就是内存布局。StorageBuffer 的对齐规则和 CPU 侧不一样GLES 3.1 的 std430 布局里vec3会被对齐到 16 字节而不是 12 字节。如果你在 CPU 侧用一个struct { float x, y, z; }填充粒子数据传到 GPU 端后你会发现第二个粒子开始的所有字段全部错位。我这次在引擎头文件里写了一个明确的内存布局约定C 类型std430 对齐占用字节float, int, uint44vec2, ivec288vec3, ivec31616vec4, ivec41616mat41664所以 CPU 侧的粒子结构体最好写成struct ParticleData { float positionX, positionY, positionZ; float velocityX, velocityY, velocityZ; float life; float padding; // 凑满16字节 };不要觉得多一个 padding 浪费内存。在现代 GPU 上非对齐访问的代价比多占几个字节高得多尤其是移动端 GPUmisaligned buffer 甚至可能导致驱动进入慢速路径。纹理方面也有一个大坑同一张纹理不能在同一个 pass 里既采样又写入。这是 GPU 编程模型的基本限制Metal、Vulkan、GLES 都如此。很多人第一次写图像处理计算着色器时会试图把结果直接写回输入纹理结果就是花屏、驱动报错或者 undefined behavior。这里要么用两张纹理做 ping-pong要么就用不同的 mip level 或 image array slice 来避开别名但最安全、最通用的做法永远是“计算输出一律进新纹理”。3.3 Compute 调度与资源屏障的工程实现调度一个 compute shader 在 RHI 里非常简单但让调度的结果能正确被后续渲染使用需要处理好资源屏障。三个后端的行为差异很大Metal 在同一个 command encoder 里会对readwrite资源做部分自动同步但如果你在不同 encoder 之间共享数据或者执行的是跨帧的资源复用还是要显式memoryBarrier。Vulkan 则完全不同没有显式 pipeline barrier 的话上一阶段写入的数据在下一阶段读取时完全不做保证。GLES 3.1 需要调用glMemoryBarrier而且屏障范围必须精确多了少了都容易出问题。我在 RHI 层做了一件比较“笨”的事不依赖后端自动行为统一在计算和渲染之间的所有关键边界强制插入屏障。比如计算调度写完 StorageBuffer 后下一个 draw call 如果要用这个 buffer 作为顶点输入那么 RHI 会主动执行if (currentPass-isCompute() nextPass-isGraphics() resUsageFlags.hasStorage()) { cmd-barrier(BufferBarrier::fromStorageToVertex); }在 Metal 后端这是一个memoryBarrier在 Vulkan 后端是一个pipelineBarrier在 GLES 后端是glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT)。代价是同一帧内如果频繁在 compute 和 graphics 之间切换会多一些屏障指令。但实际测试下来对移动 GPU 的 shadow 不太大而且换来的是三个后端表现一致不会出现“Vulkan 跑出来是错的、Metal 碰巧是对的”这种令人抓狂的情况。3.4 回读机制GPU 数据怎么才能拿到 CPU计算出来的结果不只是用来渲染有时候还要拿回 CPU 做后续逻辑。这个需求实现起来隐蔽的坑很多。直接读取 GPU 显存里的资源是不可行的必须经过一块 staging buffer而且这块 buffer 在创建时就要标记成“可被 CPU 访问”Metal 里用storageModeShared的 bufferVulkan 里用VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BITGLES 里用GL_DYNAMIC_READglMapBufferRange。但真正要命的是同步问题。如果在 dispatch 之后立刻 map buffer 读取很有可能拿到的是 GPU 还没执行完的旧数据。我最后采用了帧延迟读回的方案dispatch 完成后并不马上读而是把这个 buffer 放进回读队列下一帧开始之前再读。代码逻辑大概是这样m_writeIndex (m_writeIndex 1) % 2; m_computeCmd-copyBuffer(m_computeBuf, m_readBackBuf[m_writeIndex], size); // 上一帧的回读结果现在可以安全读取 m_readBackBuf[m_writeIndex]-map(); memcpy(cpuData, m_readBackBuf[m_writeIndex]-getContents(), size); m_readBackBuf[m_writeIndex]-unmap();这个“晚一帧”的代价在大多数场景下不可感知比如粒子系统的包围盒同步、碰撞检测结果反馈哪怕晚个两三帧也无所谓。但换来的是性能稳定不会因为一次同步等待丢掉 10 帧。4. 实操落地给 Axmol 加一个 GPU 后处理模糊4.1 场景选择与目标设定理论讲了这么多不如上个实际案例。我在这次升级里选了一个特别典型的场景来做验证把一张渲染结果纹理做高斯模糊。选它的原因是计算量固定方便对比 CPU 和 GPU 的差异涉及纹理读写、线程组调度、资源绑定和后续渲染采样几乎覆盖了 GPU Compute 的所有关键环节可以在真实游戏画面里直接看到效果方便向非技术同事解释“这个升级有什么用”。这次改造的目标很简单给Renderer增加一个generateBlur(renderTarget, radius, passCount)的接口内部通过 compute shader 完成高斯模糊原场景的代码一行不用改只要在渲染场景之后调用一次这个接口就能拿到模糊后的纹理。4.2 计算着色器实现高斯模糊的计算思路是经典的 9-tap 双向模糊。先用水平方向计算一次中间结果再用竖直方向计算最终结果。这需要两张临时纹理来做 ping-pong。计算着色器只看单次方向的代码#version 310 es // axmol: compute // axmol: local_size_x 16 // axmol: local_size_y 16 layout(set 0, binding 0, rgba16f) uniform readonly image2D uInput; layout(set 0, binding 1, rgba16f) uniform writeonly image2D uOutput; uniform int uWidth; uniform int uHeight; uniform bool uHorizontal; const float weights[5] float[](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216); void main() { ivec2 pos ivec2(gl_GlobalInvocationID.xy * 2); // 先做半分辨率降采样 if (pos.x uWidth || pos.y uHeight) { return; } vec3 result imageLoad(uInput, pos).rgb * weights[0]; for (int i 1; i 5; i) { ivec2 offset uHorizontal ? ivec2(i, 0) : ivec2(0, i); result imageLoad(uInput, pos - offset).rgb * weights[i]; result imageLoad(uInput, pos offset).rgb * weights[i]; } imageStore(uOutput, pos, vec4(result, 1.0)); }这里有个细节我们在 compute shader 里故意做了一半分辨率的降采样。这样模糊的采样半径视觉上可以覆盖更大范围同时计算量直接降到原来的 1/4视觉效果还更好。这也是很多引擎实现后处理 bloom 的常见套路。4.3 在 RHI 上创建计算管线并调度引擎侧拿到这个 shader 之后需要创建一个ComputePipelineState然后提交一次 dispatch。伪代码如下auto device rhi::Device::getInstance(); auto shader device-createShader(shaders/fx_gaussian_blur.axm); auto pipelineDesc rhi::ComputePipelineDescriptor::create(); pipelineDesc-shader shader; auto pipeline device-createComputePipeline(pipelineDesc); auto cmd rhi::CommandBuffer::create(); cmd-begin(); cmd-bindComputePipeline(pipeline); cmd-bindImage(0, m_sourceTexture, rhi::Access::ReadOnly); cmd-bindImage(1, m_tempTexture, rhi::Access::WriteOnly); cmd-bindUniform(0, blurParams); cmd-dispatch((m_width 7) / 8, (m_height 7) / 8, 1); cmd-barrier(rhi::ImageBarrier::fromComputeWriteToComputeRead); cmd-bindImage(0, m_tempTexture, rhi::Access::ReadOnly); cmd-bindImage(1, m_destTexture, rhi::Access::WriteOnly); cmd-bindUniform(0, blurParamsV); cmd-dispatch((m_width 7) / 8, (m_height 7) / 8, 1); cmd-end(); device-getQueue()-submit(cmd);这里线程组大小是(8, 8, 1)每个线程组覆盖 8x8 像素但每个线程实际处理 2x2 的区域所以总线程组数是(width 15) / 16。计算时需要特别注意整数除法向上取整否则纹理边缘的像素不会被处理到图像边缘会留下明显的未模糊区域。单次 dispatch 的高度和宽度上限在各后端差异很大我写了一个辅助函数来做层级拆分uint32_t dispatchDimX (inputWidth groupSizeX - 1) / groupSizeX; uint32_t dispatchDimY (inputHeight groupSizeY - 1) / groupSizeY; uint32_t dispatchDimZ 1; cappedDispatch(cmd, pipeline, dispatchDimX, dispatchDimY, dispatchDimZ);cappedDispatch内部会读取device-getCapabilities().maxComputeWorkGroupCount如果超出上限就拆成多次 dispatch 并把结果写入临时 buffer下次 dispatch 时读回。虽然实际使用中很少碰到这个限制但一旦碰上就非常隐蔽值得提前兜住。4.4 性能表现与效果验证在测试设备上一台中端 Android 手机Adreno 系 GPUVulkan 后端开启 4x4 降采样高斯模糊处理一张 1280x720 的渲染目标GPU Compute 的平均耗时大约 0.15ms而同一个算法用 CPU 做Neon 优化后的 C 版本需要 2.3ms 左右。差距在 15 倍上下。另一组对比是粒子系统更新。我们用 2 万颗粒子每帧在 CPU 上更新位置和速度需要 1.8ms改成 GPU Compute 后耗时降到 0.05ms省下的 CPU 时间可以用来跑游戏逻辑。任务CPU 实现GPU Compute 实现说明1280x720 高斯模糊2.30ms0.15ms含降采样20000 粒子更新1.80ms0.05msAdreno 660 实测亮度直方图统计0.90ms0.04ms纹理回读延迟一帧当然GPU Compute 也不是没有代价。它需要占用 GPU 的 ALU 吞吐如果你的游戏本身已经是 GPU 重度负载比如同时开了大量实时阴影和 HDR bloom那么把粒子更新丢给 GPU 反而可能引发渲染瓶颈。所以我不建议“为了用 compute 而用 compute”一定要先做 CPU 侧的性能剖析确认瓶颈确实在 CPU 的并行计算上再做迁移。5. 实战中的坑与排查技巧5.1 Metal 自动同步 vs Vulkan 显式屏障的差异Metal 在同一个 command encoder 里的资源调度依赖一套自动同步机制很多时候你写得不规范它也能跑出正确结果。但这会让人产生“我的代码是对的”的错觉同一套逻辑拿到 Vulkan 上就彻底花屏、黑屏甚至设备报错。我一个下午的时间就耗在这个问题上面粒子 buffer 在 compute 阶段写入紧接着被渲染阶段当作顶点缓冲Metal 跑得非常正常Vulkan 上却出现整个粒子系统完全消失的情况。原因就是 Vulkan 需要显式 barrier而 Metal 自动处理了。最终解决方案就是 3.3 里的做法不依赖后端的自动同步RHI 层统一在所有跨阶段的资源访问边界插入 barrier。这样虽然会多几个冗余屏障但换来的是跨后端一致性和可预判的行为。5.2 纹理读写限制与双缓冲问题的系统化规避有次写完一个后处理特效在某些 Android 机型上会偶尔出现图像“撕裂”的视觉效果但另外一些机型完全正常。排查了很久才发现是 ping-pong 纹理复用的生命周期管理出了问题当m_tempTexture的内容还在被上一次 dispatch 写入时下一次 dispatch 就可能把它绑定为输入。现代 GPU 为了追求吞吐允许同一份资源被不同阶段并发访问但你需要确保没有写写冲突和读写溢出。我在引擎里给 Texture 加了一个lastUsagePassID字段每次绑定前检查该纹理是否已经在当前 pass 里被写入过如果是就自动切换下一张可用纹理。这个方法比较土但效果立竿见影。5.3 GLES 3.1 驱动带来的“假失败”Vulkan 和 Metal 后端的 feature 检测相对准确GLES 3.1 则是重灾区。某个中端 Android 设备声称支持 GLES 3.1但实际上它的 compute shader 支持是有残缺的具体表现是glDispatchCompute调用不报错但什么都不执行。我最后给能力检测加了一层更严格的兜底不仅在初始化时查扩展字符串还在第一次创建 compute pipeline 时执行一个“1x1 计算冒烟测试”——调度一个仅写入最小值的 compute shader然后用回读验证结果。这个验证不通过就认为 GPU Compute 不可用自动降级到 CPU 路径。这个冒烟测试只会在启动时执行一次开销可以忽略但它能从根上避免“设备支持但实际用不了”的尴尬情况。5.4 Readback 的性能陷阱把计算结果显示到屏幕上通常没有任何问题但一旦需要回读性能就会断崖式下跌。我在调一个 AI 寻路模块时想用 GPU 做一些网格分析然后每帧读取结果。一开始用同步 map 的方式每次回读要等待 GPU 完全执行完当前所有命令帧率从 60 直接掉到 20而且有明显的卡顿感。解决方式是前面提到的双缓冲 帧延迟读回。同时还要注意 mapping buffer 时最好使用MAP_WRITE/MAP_READ标志避免建立不必要的 write-combine 映射。移动 GPU 的 PCIe 带宽本来就不高大批量回读一定是性能杀手能少读就少读能用低分辨率数据就别用全分辨率。5.5 调试工具与断言最后强烈建议所有做 RHI 层开发的同事把调试工具用熟。Vulkan 有 validation layer能帮你抓出很多 barrier 错误Metal 有 GPU Frame Capture可以看每一道 command 的资源使用状态OpenGL ES 也可以用 RenderDoc 抓帧虽然支持度稍微差一点。但工具总归是事后的更应该在引擎里埋好断言。我在 RHI 层加了不少检查比如dispatch 的线程组数量是否超过设备上限同一纹理是否同时绑定了只读和只写staging buffer 是否在 map 之前有过未完成的 command 提交compute pipeline 是否绑定了不存在的 location 或 binding slot。这些断言在 release 模式可以全部关掉但 debug 模式下能帮你节省非常多排查时间。我印象最深的是有一次某个特效只在 release 版本里花屏debug 版本一打开立刻被断言拦下来问题 10 分钟就定位了。最后再说一点个人体会。这次 RHI 升级最难的其实不是设计那一层抽象而是让老游戏、老渲染路径在一行不改的情况下稳定跑通。项目进行到中段时我一度怀疑自己是不是应该直接换一个更现代化的引擎但坚持下来之后发现这层抽象带来的收益是实打实的新功能开发效率提升明显bug 排查范围大幅缩小跨平台表现也统一了很多。如果你也想在现有引擎里加 GPU Compute我的建议是先挑一两个场景从 compute 做起比如后处理模糊、粒子位置更新、骨骼动画蒙皮把能力检测、屏障管理、回读流程这些基础设施在真机上验证扎实再逐步铺开。千万别一上来就规划大规模 GPU 流体或者全局光照那会让调试复杂度爆炸。这次升级之后Axmol 的 RHI 还预留了不少扩展空间后续可以考虑把 GPU particles、蒙皮和部分物理查询都挪到 compute 路径上等这些验证完我再写一篇更深入的工程实践分享。
返回列表