Lry722 的个人博客
2233 字
11 分钟
Godot Compute Shader学习心得
2024-04-12

最近又开始做 Godot 体素插件,产生了各种各样的并行计算需求,比如生成地形的 Mesh、材质等,因此这两天学习了一下 Compute Shader,记录一下学到的东西。

什么是 Compute Shader#

Compute Shader 是一种特殊的 Shader,它不是用来渲染,而是用来进行通用计算,充分利用显卡的并行计算能力。

通过 Compute Shader,可以编写跨平台、跨硬件的通用 GPU 计算代码。

我使用的游戏引擎是 Godot,因此接下来均的讨论均基于 Godot 的 Compute Shader。

由于就刚学了两天,因此以下内容均为个人理解,难免有错,请自行分辨(

要学的东西#

Godot 中要使用 Compute Shader,需要学两个方面的内容。

一部分是如何编写具体的 GLSL 代码,实现所需的功能。

另一部分是如何在 Godot 中加载写好 GLSL 代码,初始化所需的各种 gpu 资源,构建 compute list,最终提交 compute list,开始计算。

Godot 的官方示例中有一个相关项目,可以用于学习完整的流程。

接下来基于这个项目来细说一下怎么使用 Compute Shader。

GLSL 代码分析#

首先来看 water_compute.glsl 这个文件,这是实际的执行计算的代码。

#[compute]
#version 450

第一行表示这是一个 Compute Shader,第二行表示使用的 GLSL 版本号为 450,即该着色器代码遵循 GLSL 4.50 规范。

layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

这行是每个 Compute Shader 都必须有的,这里的 in 是个关键字,不能叫别的东西。local_size_xlocal_size_ylocal_size_z 表示这个工作组会负责多大范围的计算。

我刚看到这时很奇怪,为什么要用 x, y ,z 来表示工作组的大小,而不是直接用一个 size 来表示呢?

实际上这里的 (8, 8, 1)表示每个工作组负责 8x8x1 的范围,由于这个案例中处理的目标是以像素为单位的,因此表示的其实是 8x8 个像素。


什么是工作组#

工作组是 Compute Shader 并行工作的核心概念,接下来的内容非常关键。

每个 Compute Shader 在执行时会分为若干个工作组,每个工作组中又有若干个线程,每个线程中执行的代码都是一样的。这里的 layout 指定的其实就是每个工作组的大小,即工作组中线程的数量。

每个工作组中的每个线程都有一个 Local ID,是一个三维向量。每个工作组本身又有一个 Group ID,也是个三维向量。在线程中将 Group ID 和 Local ID 相加,即可得到该线程的 Global ID。因此可以看出来,使用三维向量来分配线程,可以很方便地对任务进行抽象。

以上面的代码为例,如果你要处理一幅大小为 (w, h) 的图片,可以将其分为 (w/8, h/8) 个像素块,每个像素块中又分为 8x8 个像素,这样每个像素块就对应一个工作组,每个像素块中的 8x8 个像素就对应 8x8 个线程。在线程中,直接获取线程的 Global ID 的 x 和 y,即可得到该线程在图片中的位置。

既然 ID 都是三维向量,如果需要处理的东西是二维怎么办?只需像此处一样将 z 设为 1 即可。当然,你也可以将 y 设为 1,然后获取 Global ID 的 x 和 z 作为像素的坐标。

如果进一步抽象,其实处理的东西不一定要是图像,只要可以以小于三维的形式储存即可。

工作组的形式还有一些好处,例如一个工作组中的线程可以访问一块共享储存空间,以及可以通过 barrier 进行同步。这部分内容示例中没有用到,我也还不了解,这里就不展开讨论了。


接下来继续看代码

layout(r32f, set = 0, binding = 0) uniform restrict readonly image2D current_image;
layout(r32f, set = 1, binding = 0) uniform restrict readonly image2D previous_image;
layout(r32f, set = 2, binding = 0) uniform restrict writeonly image2D output_image;

这里声明了三个 uniform 变量,并将它们绑定到了三个 uniform set 的 0 号位置上。uniform set 是通过一种叫做 Descriptor Sets 的方式管理 uniform 变量的方法。个人认为,可以大致的把每个 set 看成 array,binding 其实就是该 uniform 变量在 array 中的下标。

r32f 表示该 image 的每个像素是一个 32 位的浮点数,这是因为此案例中我们在处理的其实是水波的高度图,而不是颜色。

layout(push_constant, std430) uniform Params {
	vec4 add_wave_point;
	vec2 texture_size;
	float damp;
	float res2;
} params;

这里声明了一个 uniform 块,遵循 std430 内存布局规则,并将其指定为 push constant。push constant 会在传递时直接进入 gpu 的寄存器,而不是显存,因此访问速度极快。


接下来的 main 函数中是实际的计算代码,我们不关心具体的计算内容,毕竟这次目的是学怎么使用 Compute Shader 而不是怎么模拟流体,因此只挑其中有关的几点进行说明。

ivec2 uv = ivec2(gl_GlobalInvocationID.xy);

if ((uv.x > size.x) || (uv.y > size.y)) {
    return;
}

这里获取 uv 的方式就用到了之前提到的工作组的坐标的概念,通过获取 Global ID 即可得知正在处理的是哪个像素。

之所以要判断范围,是因为如果图片大小不是 8 的整倍数,会有线程被分配给不存在的像素,需要跳过。

imageStore(output_image, uv, result);

最终当前像素的结果计算出来后,将其存到 image 对应 uv 坐标的像素中。

Godot 调用 GLSL#

写好 GLSL 代码后,要在 Godot 中进行实际的计算,还需要一些额外的工作。在这个案例中,相关内容均在 water_plane.gd 这个文件中,接下来就对这个文件进行分析。

首先看 _initialize_compute_code 函数,在 _ready 函数中会调用一次该函数。

rd = RenderingServer.get_rendering_device()

var shader_file = load("res://water_plane/water_compute.glsl")
var shader_spirv: RDShaderSPIRV = shader_file.get_spirv()
shader = rd.shader_create_from_spirv(shader_spirv)
pipeline = rd.compute_pipeline_create(shader)

这里先获取了 RenderingServer 的全局实例,然后加载了 GLSL 文件,将其转换为 SPIR-V 格式,再转换为 Shader。SPIR-V 是 Shader 的标准化中间表示,作为 Shader 代码和底层驱动间的桥梁。

然后创建了一个 compute pipeline,用于后续构建 compute list。

var tf : RDTextureFormat = RDTextureFormat.new()
tf.format = RenderingDevice.DATA_FORMAT_R32_SFLOAT
tf.texture_type = RenderingDevice.TEXTURE_TYPE_2D
tf.width = init_with_texture_size.x
tf.height = init_with_texture_size.y
tf.depth = 1
tf.array_layers = 1
tf.mipmaps = 1
tf.usage_bits = RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT + RenderingDevice.TEXTURE_USAGE_COLOR_ATTACHMENT_BIT + RenderingDevice.TEXTURE_USAGE_STORAGE_BIT + RenderingDevice.TEXTURE_USAGE_CAN_UPDATE_BIT + RenderingDevice.TEXTURE_USAGE_CAN_COPY_TO_BIT

这里就是指定 Texture 格式的一堆属性,由于该案例中所有纹理格式都形同,因此只需要指定这一个格式。

tf.format = RenderingDevice.DATA_FORMAT_R32_SFLOAT 这句对应的是 GLSL 文件中 layout 中指定的 r32f。

for i in range(3):
    texture_rds[i] = rd.texture_create(tf, RDTextureView.new(), [])
    rd.texture_clear(texture_rds[i], Color(0, 0, 0, 0), 0, 1, 0, 1)
    texture_sets[i] = _create_uniform_set(texture_rds[i])

由于所需的三个 texture 均相同,因此使用一个循环批量创建。


然后是 _render_process 函数,在每次调用 _process 时也会调用一次该函数。

var push_constant : PackedFloat32Array = PackedFloat32Array()
push_constant.push_back(wave_point.x)
push_constant.push_back(wave_point.y)
push_constant.push_back(wave_point.z)
push_constant.push_back(wave_point.w)

push_constant.push_back(tex_size.x)
push_constant.push_back(tex_size.y)
push_constant.push_back(damp)
push_constant.push_back(0.0)

首先填充 push constant 的内容。

var x_groups = (tex_size.x - 1) / 8 + 1
var y_groups = (tex_size.y - 1) / 8 + 1

然后计算 x 和 y 方向上工作组的数量,因为每个像素块的大小是 8x8,因此工作组的数量自然为 size / 8。之所以还有额外的 -1 和 +1,是为了确保当 tex_size 不是 8 的整倍数时,能覆盖到所有像素。

var next_set = texture_sets[with_next_texture]
var current_set = texture_sets[(with_next_texture - 1) % 3]
var previous_set = texture_sets[(with_next_texture - 2) % 3]

这几行和具体的计算内容相关,不是重点,总之就是指定三个 uniform set 的顺序。

var compute_list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
rd.compute_list_bind_uniform_set(compute_list, current_set, 0)
rd.compute_list_bind_uniform_set(compute_list, previous_set, 1)
rd.compute_list_bind_uniform_set(compute_list, next_set, 2)
rd.compute_list_set_push_constant(compute_list, push_constant.to_byte_array(), push_constant.size() * 4)
rd.compute_list_dispatch(compute_list, x_groups, y_groups, 1)
rd.compute_list_end()

这里开始构建 compute list。构建步骤中用到的函数均为 compute_list_xxx 的形式。

首先调用 compute_list_begin,获取到一个整数值。

之后绑定 pipeline 、 uniform set ,并传递 push constant。

最后调用 dispatch 指定工作组数量,其中 x_groups 和 y_groups 是之前计算出来的,因为处理的是二维图像,因此 z_groups 直接设为 1。

最后调用 compute_list_end 结束 compute list 的构建,pipeline 会根据 compute list 中的内容开始计算。

注意所有 compute_list_xxx 均需要传入 compute_list_begin 所返回的整数。


以上就是完整的使用 Compute Shader 的流程,最终的结果是水面的高度图,会轮流存在三个 Texture 中(因为每帧需要用到前两帧的数据进行计算)。

_process 中有这么两行:

if texture:
    texture.texture_rd_rid = texture_rds[next_texture]

这两行指定了当前帧中, water_shader.gdshader 应当读取哪个 Texture 作为输入。最终 water_shader.gdshader 会依据 Texture 中的高度信息计算法线,实现水面的涟漪效果。

Godot Compute Shader学习心得
https://lry722.github.io/posts/godot-compute-shader学习心得/
作者
Lry722
发布于
2024-04-12
许可协议
CC BY-NC-SA 4.0