技术博客

compute shader 管线:用 RenderingDevice 写第一个 GPGPU 程序

第 43 / 149 篇 · 2025-04-29 · godot · 渲染GPGPU实战

RenderingDevice 不只给渲染器用,它对游戏代码开放了一套完整的 compute 接口——粒子、植被模拟、屏幕空间剔除都在用它。不走一遍完整的 GPGPU 流程,很难体会这套 API 把 Vulkan 的 compute 简化到了什么程度。下面用"把十万粒子的速度累加进一个缓冲"的最小例子走通它。

三步骨架

RD 的 compute 使用模型刻意压成三个阶段,和 Vulkan 原始接口一一对应但没了样板代码:

# 1. 编译:.glsl 文件 → RDShaderSPIRV → RID
var spirv: RDShaderSPIRV = shader_file.get_spirv()
var shader := rd.shader_create_from_spirv(spirv)

# 2. 资源绑定:uniform set 把缓冲挂到 set=0/binding=0
var uniform := RDUniform.new()
uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
uniform.binding = 0
uniform.add_id(buffer)
var uniform_set := rd.uniform_set_create([uniform], shader, 0)

# 3. dispatch:compute list 内一次或多次发起
var list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(list, pipeline)
rd.compute_list_bind_uniform_set(list, uniform_set, 0)
rd.compute_list_dispatch(list, x_groups, 1, 1)
rd.compute_list_end()

shader 源文件头部声明 set 和 binding,引擎的 shader 语言编译链把 .glsl 编成 SPIR-V(三-8 篇展开这条链):

#[compute]
#version 450
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;
layout(set = 0, binding = 0, std430) restrict buffer Sum {
    vec4 data[];
} sum_buf;
void main() {
    uint i = gl_GlobalInvocationID.x;
    sum_buf.data[i] = /* ... */;
}

workgroup 数学:dispatch 数怎么算

这是唯一容易错的地方。GLSL 里 local_size_x = 64 声明每个 workgroup 里 64 个线程;compute_list_dispatch(x_groups, 1, 1) 里的 x_groups 是发起多少个 workgroup,不是多少线程。总线程数 = $groups \times local\_size$,要让总线程数覆盖数据规模:

$$ groups = \left\lceil \frac{N}{local\_size} \right\rceil = \left\lceil \frac{100000}{64} \right\rceil = 1563 $$

最后一组只有 $100000 - 1562 \times 64 = 32$ 个有效线程,所以 shader 里必须有越界守卫(if (i >= N) return;)——否则越界线程会写穿缓冲或读到垃圾。这个"向上取整加守卫"的配对是 GPGPU 的固定套路,不是 Godot 特有的。

local_size 的选择有讲究:取 64 或 128 通常是 GPU wavefront/warp 粒度的整数倍,太小浪费调度、太大寄存器不够用编译器会 spill。改了 local_size,dispatch 数要跟着重算——两个数字来自同一份语义,手改一个必坏。

线程模型:没有独立 compute 队列

初学者常问:compute 在哪个线程跑、和渲染抢什么?答案是 Godot 不给你选——compute list 提交到和图形同一个 queue(初始化时 graphics/compute 合一的族,见三-3 篇),在同一个命令缓冲里按录制顺序执行。这意味着:

  • dispatch 和 draw 之间的顺序就是代码顺序,不需要额外同步;
  • 长 compute 会阻塞后续图形命令的执行,卡的是同一根管线;
  • 想用 transfer 队列做并行上传,得走 buffer_update 那类专用 API,compute 本身没有"后台跑"的选项。

同步仍然要管:compute 写完缓冲、下一帧 CPU 要读回时,得用 fence 或 sync() 家族等 GPU 完成,不然拿到的是上一帧数据。RD 把 barrier 藏了(三-7 篇),但执行完成性藏不了。

什么时候该用 compute

判断标准就一条:同一份操作是否能摊到几千个独立线程上且无写冲突。粒子积分、纹理生成、大数组规约是典型;带复杂分支、小数据量、需要全局顺序的逻辑上了 GPU 反而更慢(dispatch 本身的固定开销约几十微秒)。GDScript 里先用 Array 写对,profile 确认这步是热点,再下沉到 compute——RD 的 API 友好度值得这个下沉,但值得的前提是 hot path 真的在烧 CPU。

.glsl 源文件 set/binding 声明 SPIR-V 编译 shader_create_from_spirv pipeline + uniform set 缓冲绑定到 binding 0 compute list:begin → bind pipeline → bind set → dispatch(1563,1,1) → end 线程总数 = 1563 × 64 = 100032 ≥ 100000,最后一组 32 线程越界需守卫 与图形同队列按录制顺序执行,无独立 compute 线程

本文代码引自 Godot 4.2.2-stable 的 RenderingDevice 公开 GDScript API 与 shader 语言,compute list 路径经 servers/rendering/rendering_device.cpp。