1. 这次升级到底解决什么问题:先看清 RHI 的能力边界
1.1 老 RHI 只管"画",不管"算"
三个月前,我在一个用 Axmol 引擎做的 2D 项目里碰了一鼻子灰:粒子数量上到八千之后,帧时间平白多出三四毫秒,而这三四毫秒几乎全烧在 CPU 侧的位置更新循环里。换机型、改数据结构、上多线程,都压不动——那条for (int i = 0; i < n; i++)跑在 CPU 上,本身就是物理上限。当时唯一看得见的路是把粒子模拟搬到 GPU 上,但 Axmol 的 RHI 层是从图形渲染抽象起步的,CommandBuffer里只有 draw、copy、blit 这类命令,没有 dispatch,没有可读写缓冲区的概念,更没有跨阶段同步的机制。换句话说,你想让 GPU 算完一笔数据再画出来,中间"算"这条管道是断的。
RHI(Rendering Hardware Interface)说白了就是把 Metal、Vulkan、D3D、OpenGL 这些图形 API 包成一套统一接口,上层写一遍渲染逻辑,底层切后端。Axmol 之前的 RHI 设计得很典型:Device负责创建资源,CommandBuffer负责记录命令,RenderPipeline描述固定功能状态和着色器组合,draw call 绑上顶点缓冲、索引缓冲、纹理,然后交给 GPU。这套抽象覆盖了"画三角形"的全部路径,但它有个隐含假设:GPU 干的事必须从绘制命令开始,以绘制命令结束。
这句话的信息量在于:管线是单向的。顶点着色器拿到的顶点数据,必须在 CPU 侧准备好;片元着色器输出的颜色,必须送进渲染目标。如果你想让 GPU 自己维护一份跨帧更新的数据,比如粒子位置、布料顶点、风场采样点,老 RHI 没有一条干净的通道让你写。你当然可以把数据伪装成纹理,在片元着色器里读出来再计算,但不是所有算法都适合这么拧着来,而且"同一张纹理在这帧被别人读、下帧被 compute 写"的同步语义,在多个后端上处理起来非常痛苦。所以这次升级的核心,不是简单加一个 dispatch 命令,而是把 RHI 从"只管画"扩展成"既能算又能画"。
1.2 2D 引擎里哪些"算"值得搬上 GPU
先说结论:不是所有计算都值得搬。搬运有成本,包括数据上传下载带宽、同步点、调试难度,这些后面会具体讲。但在 2D 游戏里,至少这几类负载是我实际遇到过的、适合 GPU Compute 的典型场景。
- 粒子系统:几千到几万个粒子,每帧只是"速度积分 + 位置更新 + 生命值递减",天然并行,一个线程处理一个粒子,连通信都不需要。
- 骨骼动画蒙皮:Spine、DragonBones 的网格变形,顶点数一多,CPU 侧一帧能吃掉 1 到 2 毫秒,而且每个顶点的变换逻辑完全相同。
- 后处理效果:Bloom、高斯模糊、辉光、马赛克过渡,本来就是像素级并行,用 compute 先算中间缓冲比在片元着色器里反复采样更直观。
- 程序化纹理:噪声、Voronoi 图、流体场,可以在初始化阶段用一次性 compute 生成,省掉 CPU 加载时间。
关键点是:2D 引擎的 CPU 预算非常紧张。和 3D 引擎不同,2D 的绘制提交本身相对廉价,玩家交互逻辑、游戏脚本、UI 布局才是大头,一个 Lua 层的粒子循环就能把主线程打满。把纯数据的并行计算挪到 GPU,让 CPU 专注逻辑,这笔账在移动端尤其好算——移动 GPU 的浮点能力比 CPU 通常高一个数量级,而且做并行计算时整体功耗往往更低。
当然,物理引擎不要盲目往 GPU 搬。刚体碰撞、关节约束这类算法强依赖顺序执行和随机访问,GPU 上写起来复杂,调试也困难,收益不稳定。我做这次升级时定了一条取舍线:能用一个 pass 算完、且每个线程只读写独立数据的问题,优先考虑 GPU Compute;需要跨线程反复交换中间结果的问题,除非算法本身并行友好,否则别碰。
1.3 为什么必须做进 RHI,而不是在引擎外自己调 API
这是我被问得最多的一个问题:既然现在 Metal 和 Vulkan 都能 dispatch compute,我直接在引擎外写一个 manager,绕开 RHI 不行吗?不行,而且不是风格问题。
RHI 是资源所有权的归属点。Axmol 的纹理、缓冲区从创建到销毁都由 RHI 管理,GPU 资源的状态——比如"当前能不能被顶点着色器读""当前能不能被 compute 写"——也理应在 RHI 里统一跟踪。如果 compute 路径单独写在引擎外,就会出现两套资源生命周期、两套同步状态,跨模块传递一个 buffer 时,要么重复记账,要么漏掉 barrier,然后就是那种"窗口期随机闪退、真机上必现"的经典 bug。
另一个理由是跨后端一致性。四个后端对 compute 的暴露方式差异极大:Metal 的 threadgroup、Vulkan 的 descriptor set、D3D12 的 root signature、GLES 的 SSBO,各有各的脾气。如果每个后端单独写一套 compute 封装,等于把同样的问题做四遍,测试量翻四倍,修一个同步 bug 得在四个地方同步修。放进 RHI 之后,上层渲染器拿到的是同一个资源对象、同一条命令流,不需要知道底下跑的是 Metal 还是 Vulkan。
2. 四种后端同步加料的取舍:Compute 接口是这样抽象出来的
2.1 四种 API 的 compute 语义差在哪
先摆一张对照表,这是我们在设计前把四个后端摸了一遍之后整理的底账:
| 后端 | 计算单元概念 | 调度命令 | 可写资源 | 同步手段 |
|---|---|---|---|---|
| Metal | compute kernel / threadgroup | dispatchThreadgroups:threadsPerThreadgroup | MTLBuffer、readWrite 纹理 | MTLBarrierScope(memory + buffer + texture) |
| Vulkan | workgroup | vkCmdDispatch | SSBO、storage image | vkCmdPipelineBarrier + memory barriers |
| D3D12 | 线程组 | Dispatch(x, y, z) | UAV buffer / texture | UAV barrier + resource transition |
| GLES 3.1 | workgroup | glDispatchCompute | SSBO、image load/store | glMemoryBarrier |
光看这张表就知道,抽象层真正难处理的是同步手段那一列。调度和可写资源的概念其实大同小异,都是"告诉 GPU 起多少个线程组、每个组里多少线程、然后去读写哪块内存"。但"compute 写完的数据怎么让后面的 draw 看到",四个后端给了四个答案,没有一个能直接映射到另一个。
Metal 的 barrier 是按"域"来声明的,你可以只 barrier 某一个 buffer,也可以全 barrier;Vulkan 要你把源阶段、目标阶段、资源、还有内存访问类型全部写清楚,写错一个 mask 就等着 flashing 或者崩;D3D12 把资源状态转换(transition)和 UAV 同步拆成两个概念;GLES 最省事,一个glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT)下去,驱动自己看着办,但也因此最不透明,很多老移动驱动会直接选择"把整个管线停顿一下"来保证正确性,性能损失肉眼可见。
2.2 求同存异:我们在最低公分母上做的接口设计
抽象层的第一原则,是不要试图暴露每个后端的全部能力,而是找到四个后端都支持、能满足绝大多数业务场景的子集。我们第一版只定了四件事:
- 计算管线状态对象:一个只有 compute shader、没有混合深度光栅化这些固定功能状态的轻量对象。
- 三种绑定类型:只读 uniform、可读写存储缓冲、可读写纹理。
- dispatch 命令:传入 x、y、z 三组线程数,后端各自换算成自己的线程组概念。
- 一个显式的 barrier 命令:参数是源阶段、目标阶段、受影响资源列表。
这里有一个典型取舍:Metal 有很实用的 threadgroupMemory,可以在线程组内共享一块快速内存;而 GLES 和 Vulkan 虽然也有shared内存和 workgroup memory 的概念,但语法细节差异很大。我们第一版没有把这层透传出去,只暴露了一个"每个线程组内共享内存大小"的声明,后端各自翻译,先保证能跑通,后续有高频需求再考虑统一语法。很多新加的抽象都该这么做:别一开始就想做成完美抽象,先让 90% 的场景能跑,剩下 10% 特殊需求留给后续迭代。
另一个心得是命令流的设计。compute 和 render 如果混在同一个CommandBuffer里,就会产生阶段依赖,所以我们在命令流里用submitBarrier显式切开阶段,而不是像 D3D11 那样每次绑定资源都隐式做状态推断。显式虽然在写代码时烦一点,但调试时能看到清晰的阶段边界,出问题也容易排查。
2.3 barrier 抽象:compute 和 render 之间那条看不见的线
barrier 是我花时间最长的地方,也是这次升级里最容易被低估的部分。很多人第一次写 compute 的直觉是"我 dispatch 完,下一帧 draw 不就行了",结果在桌面 GPU 上确实能行——因为桌面驱动的默认调度顺序刚好和提交顺序一致,但换到移动端,驱动为了省电会重排命令,compute 还没写完,draw 已经开始读旧数据,画面就"花"了。
我们最后定的是这样一层简化接口:
rhi::BarrierInfo barrier; barrier.srcStage = rhi::Stage::Compute; // 数据从计算阶段出来 barrier.dstStage = rhi::Stage::Vertex; // 要被顶点阶段消费 barrier.resources = { posBuffer, velBuffer }; cmd->submitBarrier(barrier);后端映射关系是:Vulkan 把它转成VK_ACCESS_SHADER_WRITE_BIT -> VK_ACCESS_SHADER_READ_BIT的 memory barrier;Metal 根据资源列表生成MTLBarrierScope;D3D12 同时补一个 UAV barrier 和 transition;GLES 则聚合成一次glMemoryBarrier,把需要的 bit 按资源类型枚举出来。
这里想提醒的是:barrier 不是越少越好,也不是越多越好。少了会出数据竞争,多了会把 GPU 流水线打断,性能断崖式下跌。我们实测过,在一个 10 万粒子的场景里,每帧多插三个不必要的全资源 barrier,帧时间能多出 1 毫秒以上。所以后面我们加了优化:barrier 命令的资源列表支持空数组,空数组时后端可以做最细粒度的阶段同步,而不是全屏障。
3. 新增计算管线的完整链路:从创建、绑定到调度
3.1 计算管线状态对象:比图形 PSO 简单一半
图形 PSO(Pipeline State Object)要描述的东西很多:顶点输入布局、混合模式、深度模板状态、光栅化状态、着色器组合,一个 PSO 就是一台"绘制机器"的完整配置。compute pipeline 没有这些,它只需要两样东西:一个 compute shader,以及声明线程组大小的编译参数。线程组大小一般写在 shader 里(GLSL 的layout(local_size_x = 64) in;、HLSL 的[numthreads(64,1,1)]、Metal 在 dispatch 时传入),所以创建设计时甚至不需要额外传参。
在 Axmol RHI 里,创建流程变得非常短:
auto* cs = rhi::Shader::createFromSource(rhi::ShaderStage::Compute, computeSource); rhi::ComputePipelineDesc desc; desc.shader = cs; auto* pipeline = device->createComputePipeline(desc);相比图形 PSO,这里少了所有 render target 格式描述和固定功能状态,创建成本也低很多。正因为对象轻,我们建议业务层可以按"shader 粒度"缓存 compute pipeline,不要每帧创建销毁,省掉不必要的驱动编译开销。
3.2 存储缓冲区和可读写纹理的绑定方式
算完的数据总得有个地方放,放的地方就是我们新加的"存储缓冲区"概念。它和普通顶点缓冲区最大的区别是:可以被 shader 随机读写。之前的缓冲区创建时只要告诉 RHI 用途是 vertex 还是 uniform,现在多了一个StorageReadWrite用法标志。比较微妙的是同一块缓冲区可以在不同阶段扮演不同角色:compute 阶段它是读写存储区,draw 阶段它又被当成顶点实例数据读。所以 RHI 对缓冲区的管理从"创建时定死类型"变成了"运行时跟踪当前状态",barrier 命令里除了阶段信息,还要带上这个状态转换。
绑定逻辑和图形管线类似:按 binding point 把 buffer 绑到 compute pipeline 上。我们设计了和图形阶段几乎一致的绑定接口,目的是降低上手成本:
cmd->bindComputePipeline(particlePipeline); cmd->bindBuffer(0, posBuffer, rhi::BufferBindPoint::StorageReadWrite); cmd->bindBuffer(1, velBuffer, rhi::BufferBindPoint::StorageReadWrite); cmd->bindUniformBuffer(2, uniformBuffer); // dt、重力、指针坐标 cmd->dispatch(groupX, 1, 1);可读写纹理主要给后处理用。这里要说明一下,各后端对可读写纹理的格式支持并不一致:RGBA8、RGBA16F、R32F 这类是共识,但个别老移动驱动对imageLoadStore的 R16F 支持有坑。我们第一版只承诺了这三种格式,其他格式留给后端填充。任何抽象层都要有"能力声明"的概念,RHI 加了一个queryComputeCapabilities()接口,跑在真机上先探一下格式支持,不支持的直接走 CPU 回退,别硬来。
3.3 dispatch 参数换算:线程数、线程组和边界处理
调度计算时最容易搞混的是"线程数"和"线程组数"这两层概念。以 GLSL 为例,layout(local_size_x = 64)声明的是每个线程组里有 64 个线程,你 dispatch 的时候传的是"起多少个线程组"。假设有 10000 个粒子,一个组 64 个线程,那组数就是ceil(10000 / 64) = 157,最后一组只有 16 个线程干活,其余空闲。所以 shader 里几乎每一段 compute 代码第一行都要做边界检查:
uint id = gl_GlobalInvocationID.x; if (id >= count) return;线程组大小怎么选?这是我们实测了几轮之后的一个参考区间:移动端 64 或 128 表现都不错,Apple GPU 的 threadgroup 上限通常是 512 或 1024(分设备),桌面 Vulkan 宽松一些。选 64 的好处是尾部浪费小,选 256 的好处是调度开销低,如果每帧都要 dispatch 且粒子数波动不大,建议选 64 或 128 这种中间值,再针对目标机型微调。这一步没有理论最优,只能拿着真实场景去跑,看GPU Frame Capture里的 dispatcher 时间。
还有一点容易被忽略:dispatch 的 x/y/z 三个维度都有上限,Vulkan 的maxComputeWorkGroupCount在移动端某些驱动上只有 65535。如果你的数据量需要超过这个组数,比如做一张 4096×4096 的全屏通用计算,必须拆成多次 dispatch 或者在 shader 里自己分块。我建议 RHI 的 dispatch 参数校验层就直接检查这个上限,超了就报错,而不是让驱动跑飞。
4. 实战:把粒子系统改成 GPU Compute 后发生了什么
4.1 CPU 侧准备:两个存储缓冲区加一个计算管线
以我们迁移的最典型的粒子效果为例:粒子在屏幕上受重力下落,碰到手指位置会被弹开,生命周期结束就在原点重生。这是所有粒子 demo 的 hello world,但用来验证 RHI 的新接口足够了。
CPU 侧初始化做了三件事。第一件,创建位置缓冲区和速度缓冲区,都带StorageReadWrite用法标志,大小按粒子上限分配,初始化数据可以在 CPU 侧写好,然后通过一次 upload 命令拷进去。第二件,创建计算管线和 uniform buffer,uniform 里每帧更新deltaTime、gravity、pointerX/Y。第三件,创建实例化绘制用的顶点缓冲——注意,这里我用的是同一块位置缓冲区,它在 compute 阶段是存储缓冲,在 draw 阶段直接作为每实例的 position 数据被顶点着色器读取。这样就免掉了"算完再拷给顶点缓冲"的拷贝,是最能体现 RHI 统一资源管理的做法。
每帧的命令序列非常直白:
- 更新 uniform buffer(CPU 写,16 字节对齐的数据块)。
cmd->beginComputePass(),绑定管线、绑定三块缓冲、dispatch。cmd->submitBarrier(),把位置和速度缓冲从 Compute 阶段切到 Vertex 阶段。cmd->beginRenderPass(),绑定实例化 quad 顶点缓冲和位置缓冲,draw instanced。- 结束 render pass,提交命令流。
完整跑下来,整个 compute 和 draw 之间没有任何 CPU 和 GPU 之间的数据回读,粒子数据从出生到消亡全待在 GPU 侧。
4.2 计算着色器的核心逻辑长什么样
下面这段是 GLSL ES 3.1 风格的伪代码,去掉了引擎封装,只保留最核心的更新逻辑:
#version 310 es layout(local_size_x = 64) in; layout(std430, binding = 0) buffer PositionBuffer { vec4 posAndLife[]; // xy 位置, z 生命, w 未用 }; layout(std430, binding = 1) buffer VelocityBuffer { vec4 velAndSeed[]; // xy 速度, z 未用, w 随机种子 }; uniform float uDeltaTime; uniform float uGravity; uniform vec2 uPointer; void main() { uint i = gl_GlobalInvocationID.x; if (i >= posAndLife.length()) return; vec2 pos = posAndLife[i].xy; vec2 vel = velAndSeed[i].xy; float life = posAndLife[i].z; float seed = velAndSeed[i].w; vel.y -= uGravity * uDeltaTime; vec2 delta = pos - uPointer; float dist = length(delta); if (dist > 0.001 && dist < 2.0) { vel += (delta / dist) * (10.0 / dist) * uDeltaTime; } pos += vel * uDeltaTime; life -= uDeltaTime; if (life <= 0.0) { pos = vec2(0.0); float angle = seed * 6.28318; vel = vec2(sin(angle), cos(angle)) * 8.0; life = 2.0 + seed; } posAndLife[i] = vec4(pos, life, 0.0); velAndSeed[i] = vec4(vel, 0.0, seed); }这个 shader 没有用到任何线程间通信,每个线程只读写自己的i索引,这是 GPU Compute 最省心也最高效的模式。注意std430布局:vec4数组每个元素严格占 16 字节,和后端的 buffer 对齐要求天然吻合,不会出现排列错位。
4.3 从 Dispatch 到 Draw 的衔接和实测数据
衔接的重点还是 barrier。以这个例子来说,compute 写完位置缓冲后,如果立刻让顶点着色器读同一块缓冲,必须有 barrier:
rhi::BarrierInfo barrier; barrier.srcStage = rhi::Stage::Compute; barrier.dstStage = rhi::Stage::Vertex; barrier.resources = { posBuffer }; cmd->submitBarrier(barrier);这里只 barrier 位置缓冲就够了,速度缓冲在 draw 阶段不被读,不需要参与。一开始我图省事把所有缓冲都塞进 resources,性能立刻掉了一截,后来才养成"只 barrier 必要资源"的习惯。
下面是我们在同一台设备上、同一套渲染路径下,CPU 更新和 GPU Compute 更新的耗时对比(工程机为某骁龙 8 Gen 2 Android 设备,数值仅供参考):
| 粒子数 | CPU 更新耗时 | GPU Compute 耗时 | 说明 |
|---|---|---|---|
| 1000 | 0.08 ms | 0.05 ms | 差距不明显,调度开销占大头 |
| 5000 | 0.42 ms | 0.08 ms | GPU 开始有明显优势 |
| 10000 | 0.91 ms | 0.12 ms | 手游典型负载区间 |
| 20000 | 1.87 ms | 0.21 ms | 带宽压力开始显现 |
| 50000 | 4.60 ms | 0.55 ms | 吞吐量接近带宽上限 |
在 10000 粒子这个典型负载下,GPU 方案快了将近 7 倍,而且 CPU 的那 0.9 毫秒是纯主线程占用,省下来正好能给游戏逻辑腾预算。到了 50000 粒子的极端场景,GPU 耗时开始明显爬升,瓶颈变成了内存带宽而不是计算能力——每帧读写位置+速度合共 6.4 MB,带宽不足时加再多的 ALU 也没用。所以不要盲目堆粒子数,2D 游戏里 1 到 2 万粒子已经是一个视觉上非常饱和的数量。
5. 三个月踩坑实录:对齐、驱动限界和生命周期
5.1 Metal 上 buffer 大小必须对齐到 16 字节
第一个坑出在 Apple 平台。compute shader 访问的 buffer,长度和 offset 都要求是 16 字节的倍数,否则编译链接阶段就报错,或者运行时不报错但数据读取完全是乱的。我们当时有个结构体刚好是float x, y, z, life,一共 16 字节,没什么问题,但另一个存放"粒子尺寸+旋转角"的缓冲用了float size; float rotation;,一个元素 8 字节,总长度不是 16 的倍数,Metal 后端直接给了个 link error,信息还特别隐晦。
解决办法是统一约定:所有会被 compute 读写的 buffer,创建时就把大小向上对齐到 16 字节,元素级别的数据结构也尽量设计成 16 字节的整数倍。RHI 的 buffer 创建接口内部做了这个兜底,上层不会感知,但这个心智一定要有——换后端调试时,数据"莫名其妙错位"的第一怀疑对象就是布局对齐。
5.2 移动端驱动对存储缓冲和本地内存的限界
第二个坑在移动驱动。GLES 3.1 的glDispatchCompute在 Mali 和 Adreno 上的实现差异很大。Mali 的驱动对 shader 里shared本地内存比较敏感,如果你在 compute shader 里声明一个大数组shared float tile[4096];,某些驱动会直接编译不过,因为它把每个线程组的本地内存上限压得很低;Adreno 的老驱动则对"非向量化的随机访问"表现很差,一个线程读自己索引之外的 vec4 分量,性能能掉两三倍。
我们后来归纳的规避清单有三条:第一,本地内存数组尽量控制在 1KB 以内,大的中间数据拆成多次 dispatch;第二,访问存储缓冲时尽量整块读写vec4,避免按标量跳来跳去;第三,在真机上用glGetIntegeri_v或 Vulkan 的 properties 把每线程组的最大存储大小查出来,运行时做能力门禁,支撑不住的设备走 CPU 回退路径。这套代码同样适用于 D3D12 和 Metal,它们只是相对宽松,不代表没有上限。
5.3 生命周期管理:GPU 还在读,CPU 就想写
这是我差点放弃的一个坑。初版实现跑起来之后,桌面端一切正常,换到 iPad 上运行几分钟就会随机闪退,没有任何 log。后来用 Metal 的 GPU Frame Capture 一看,典型的 CPU 和 GPU 竞争:同一帧里,CPU 已经把下一帧的 uniform 数据写进了 buffer,而 GPU 还在读取上一帧的位置缓冲去绘制。
解决方案是 RHI 层做了一整套 in-flight 管理:命令流提交时带上帧序号,缓冲区分成 2 到 3 份轮转,CPU 只能写入当前帧对应的那份;等 GPU 的 fence 回来,再把这些 buffer 放回可用池。这套机制在图形管线里通常只需要管顶点缓冲,但 compute 把"CPU 写、GPU 读、下一帧 GPU 再写"的节奏变得更快,生命周期出问题的概率高得多。如果你也在自家引擎里加 compute,我建议在第一天就把 in-flight 机制设计进去,而不是等出现随机闪退再补。
5.4 排查"compute 写了但 draw 没看到"的完整思路
最后分享一个典型 bug 的排查链路,这件事非常能说明 compute 调试和普通渲染调试的差异。现象是:粒子完全不动,画面上的粒子一直停在初始位置。我当时的第一反应是 shader 写错了,但换了最简单的赋值 shader 也一样。于是按下面顺序排查:
- 检查 buffer 的创建标志,发现位置缓冲创建时只带了
VertexBuffer用法,忘了加StorageReadWrite。在 Vulkan 和 Metal 上这会直接导致绑定失败,但在 GLES 部分驱动上不会报错,只是默默忽略写入,这是最隐蔽的一种错误。 - 检查 dispatch 的组数。粒子数 10000、线程组 64,应该传 157。当时传成了 156,最后一个线程组内的粒子永远不会更新,但因为大部分粒子在动,这个 bug 从视觉上几乎发现不了。
- 检查 barrier。确认在 compute 和 draw 之间插了 barrier,但发现只 barrier 了位置缓冲,速度缓冲在下一帧 compute 里还要被读,这里漏了一个阶段间的依赖,好在移动端驱动通常会兜住,桌面端 GPU 才会现形。
- 最终锁定在第一步,加上
StorageReadWrite后问题解决。
这个排查花了整整两天。事后复盘,最省时间的做法其实是先在桌面端开 Vulkan validation layer 或者 D3D12 debug layer 去查资源状态,这些工具会直接告诉你"buffer 在创建时没有声明可写访问",而不是靠肉眼盯画面。移动端渲染了一半的 bug,拿到桌面端验证层上多半一秒现原形。
6. 升级之后,这套 RHI 还能长出哪些能力
compute 管线落地之后,最直接的受益人其实是后处理和程序化内容,粒子只是个开场白。以 Axmol 这个体量的 2D 引擎来说,我看到的下一步有三个明确方向。
第一个是完整后处理链。以前做 Bloom 要在片元着色器里反复采样、用多个 render pass 模拟降采样,代码绕来绕去。现在可以用 compute 一次性把亮度提取、高斯模糊的水平和垂直 pass 都实现,中间结果直接放在可读写纹理里,不打断渲染管线。特别是移动端,这种写法还能让驱动更准确地做算子融合,功耗比同等的片元方案低。
第二个是骨骼动画和网格变形。2D 骨骼动画的顶点数虽然不多,但 Spine 和 DragonBones 重度使用自由变形,CPU 蒙皮在低端机上仍是明显热点。把蒙皮矩阵计算搬到 compute 里,一次 dispatch 处理一批顶点,CPU 只负责提交骨骼矩阵,可以在不改变上层接口的前提下把性能拉满。
第三个是程序化资产和全局效果。比如动态水波、流动的熔岩地面、屏幕空间噪点,都可以用 compute 实时生成或更新一张噪声纹理,再由现有 shader 采样。优势在于生成逻辑集中在 CPU 侧的几百行代码里,不用跑图工具预烘焙,迭代美术效果也快很多。
我个人接下去的计划是先用这套接口把后处理链完整跑通,把各后端最容易出问题的 barrier 参数集中到一个文件里管理。如果你也在给自家引擎做类似的升级,我最后想给的建议是:第一版接口尽量少暴露后端特性,宁可少提供能力也要保证行为一致;资源生命周期设计要前置,别等随机 bug 出现才补;调试工具链第一时间接上,Vulkan validation 和 Metal Frame Capture 不是可选项,是刚需。这套路走下来,compute 带来的不只是帧时间上的提升,更是整个引擎在"仿真和视觉结合"这件事上多出来的自由度。