Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

使用计算着色器

本教程会带领你创建一个最简单的计算着色器。但首先需要先介绍一下计算着色器的背景以及在 Godot 中的工作原理。

备注

本教程假定你已大致熟悉着色器。如果你是着色器新手,请先阅读《着色器简介》和《你的第一个着色器》,然后再继续本教程。

计算着色器是一种着重于通用编程特殊的着色器。换句话说,它们相比于节点和片段着色器更加灵活,因为它们没有固定的用途(节点变换或图片着色)。不同于节点和片段着色器,计算着色器的幕后工作非常少。GPU 运行的代码就是你编写的代码,此外几乎没有其他内容。因此,计算着色器在将繁重计算转移到 GPU 上时非常有用。

现在我们以一个简短的计算着色器入手。

首先,用你选用的 外部 编辑器,在项目文件夹中创建一个命名为 compute_example.glsl 的新文件。Godot 的计算着色器直接使用 GLSL 代码。Godot 着色器语言基于 GLSL,如果你对 Godot 正常着色器熟悉,那么对以下语法也会比较熟悉。

备注

计算着色器只能在基于 RenderingDevice 的渲染器(Forward+ 或 Mobile)中使用。想要按照本教程操作的话,请确保你使用的是 Forward+ 或 Mobile 渲染器。相关设置位于编辑器的右上角。

请注意,虽然理论上移动设备支持计算着色器,但(由于驱动器问题)这一支持通常较差。

我们把它调成蓝色:

#[compute]
#version 450

// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;

// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
    float data[];
}
my_data_buffer;

// The code we want to execute in each invocation
void main() {
    // gl_GlobalInvocationID.x uniquely identifies this invocation across all work groups
    my_data_buffer.data[gl_GlobalInvocationID.x] *= 2.0;
}

这段代码接受一个 float 数组,将其中的每个元素和 2 相乘,并将结果存储回数组中。现在,我们来逐行观察这段代码。

#[compute]
#version 450

这两行文本传达了以下的两件事:

  1. 如下的代码为计算着色器。这是 Godot 特有的提示文本,编辑器需要此文本才能正确导入着色器文件。

  2. 代码使用的是 GLSL 450。

在编写计算着色器时,你应当永远以这两行作为文件的开头。

// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;

接下来我们要传达每个工作组所使用的调用次数。“调用”指的是同一个工作组中运行的着色器实例。从 CPU 启动计算着色器时,我们会告诉它需要运行多少个工作组。工作组之间是并行执行的。运行时,一个工作组无法访问另一个工作组中的信息。不过同一个工作组中的不同调用可以相互进行有限的访问。

你可以将工作组和调用想象成巨型的嵌套 for 循环。

for (int x = 0; x < workgroup_size_x; x++) {
  for (int y = 0; y < workgroup_size_y; y++) {
     for (int z = 0; z < workgroup_size_z; z++) {
        // Each workgroup runs independently and in parallel.
        for (int local_x = 0; local_x < invocation_size_x; local_x++) {
           for (int local_y = 0; local_y < invocation_size_y; local_y++) {
              for (int local_z = 0; local_z < invocation_size_z; local_z++) {
                 // Compute shader runs here.
              }
           }
        }
     }
  }
}

工作组与调用属于高阶内容。目前请需要记住我们在每个工作组中运行了两个调用。

// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
    float data[];
}
my_data_buffer;

这里我们提供的是与计算着色器所能访问的内存相关的信息。我们可以通过 layout 属性告诉着色器去哪里寻找缓冲,稍后我们需要在 CPU 一侧匹配这些 setbinding 的位置。

关键字 restrict 能够告诉着色器该缓冲只会在这个着色器中的某个单一位置进行访问。换句话说,我们不会将该缓冲绑定到其他 setbinding 索引。这一点非常重要,着色器编译器就能够借此对着色器代码进行优化。能使用 restrict 时请一定要使用。

这是一个未指明大小的缓冲,也就是说可以是任意大小。因此我们需要注意不要让用来读取的索引超过缓冲的大小。

// The code we want to execute in each invocation
void main() {
    // gl_GlobalInvocationID.x uniquely identifies this invocation across all work groups
    my_data_buffer.data[gl_GlobalInvocationID.x] *= 2.0;
}

最后,我们来编写 main 函数,所有的逻辑运算都是在这里面发生的。我们使用 gl_GlobalInvocationID 这个内置变量来访问存储缓冲区(storage buffer)中的特定位置。 gl_GlobalInvocationID 能为你提供当前这一次调用(invocation)的全局唯一 ID。

接下来,请把上面的代码写进你刚刚新建的 compute_example.glsl 文件里。

创建局部 RenderingDevice

要想与计算着色器进行交互并执行它,我们需要用到一个脚本。请用你熟悉的编程语言新建一个脚本,然后把它挂载到场景中的任意一个节点(Node)上。

现在要执行我们的着色器,我们需要一个本地的 RenderingDevice (渲染设备),它可以通过 RenderingServer (渲染服务器)来创建:

# Create a local rendering device.
var rd := RenderingServer.create_local_rendering_device()

在那之后,我们就可以加载刚刚创建好的着色器文件 compute_example.glsl ,并用下面这行代码来生成它的预编译版本了:

# Load GLSL shader
var shader_file := load("res://compute_example.glsl")
var shader_spirv: RDShaderSPIRV = shader_file.get_spirv()
var shader := rd.shader_create_from_spirv(shader_spirv)

警告

本地渲染设备(Local RenderingDevices)无法使用像 RenderDoc 这样的工具来进行调试。

提供输入数据

你可能还记得,我们想将一个输入数组传递给着色器,将每个元素乘以 2 然后获取结果。

我们需要创建一个缓冲区来将值传递给计算着色器。我们处理的是一个浮点数数组,所以在这个示例中我们将使用存储缓冲区。存储缓冲区接收一个字节数组,能够在 CPU 与 GPU 之间进行数据传输。

让我们初始化一个浮点数数组并创建一个存储缓冲区:

# Prepare our data. We use floats in the shader, so we need 32 bit.
var input := PackedFloat32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
var input_bytes := input.to_byte_array()

# Create a storage buffer that can hold our float values.
# Each float has 4 bytes (32 bit) so 10 x 4 = 40 bytes
var buffer := rd.storage_buffer_create(input_bytes.size(), input_bytes)

有了缓冲区后,我们需要让渲染设备来使用这个缓冲区。为此,我们需要创建一个 uniform(和普通着色器中一样)并将其分配给一个 uniform 集,稍后我们可以将其传递给着色器。

# Create a uniform to assign the buffer to the rendering device
var uniform := RDUniform.new()
uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
uniform.binding = 0 # this needs to match the "binding" in our shader file
uniform.add_id(buffer)
var uniform_set := rd.uniform_set_create([uniform], shader, 0) # the last parameter (the 0) needs to match the "set" in our shader file

定义计算管线

下一步需要创建一套 GPU 可以运行的指令。为此我们需要一个管线和一个计算列表。

需要执行以下步骤才能够得到计算结果:

  1. 新建管线。

  2. 开启需要让 GPU 执行的指令列表。

  3. 将计算列表绑定至管线

  4. 将缓冲区 uniform 绑定至管线

  5. 指定要使用的工作组数量

  6. 关闭指令列表

# Create a compute pipeline
var pipeline := rd.compute_pipeline_create(shader)
var compute_list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)
rd.compute_list_dispatch(compute_list, 5, 1, 1)
rd.compute_list_end()

请注意,我们派发(dispatch)计算着色器时,在 X 轴上派发了 5 个工作组(work groups),而在其他轴上各派发了 1 个。由于我们在着色器里指定了 X 轴上有 2 个本地调用(local invocations,即每个工作组包含 2 个线程),所以总共会启动 10 个计算着色器调用。如果你读取或写入的索引超出了缓冲区的范围,就可能会访问到着色器控制范围之外的内存,或者覆盖到其他变量的数据,这可能会导致某些硬件出现问题。

执行计算着色器

经过上面这一通操作,我们其实还没完全搞定,因为还需要去真正执行我们的管线(pipeline)。到目前为止,我们仅仅是‘记录’下了希望 GPU 去做的事情,但并没有真正去运行那个着色器程序。

要执行我们的计算着色器,我们需要先将管线(pipeline)提交给 GPU,然后等待它执行完毕:

# Submit to GPU and wait for sync
rd.submit()
rd.sync()

理想情况下,你不应该直接调用 sync() 来立刻同步 RenderingDevice(渲染设备),因为这会强制让 CPU 停下来,干等着 GPU 把活儿干完。在我们的例子里,之所以选择立刻同步,是因为我们需要马上就能读取到处理好的数据。但在常规开发中,你最好 至少 等上 2 到 3 帧再去执行同步操作,这样才能让 GPU 和 CPU 保持并行工作,发挥最大的性能。

警告

长时间的运算可能会导致 Windows 显卡驱动 "崩溃" ,这是因为触发了 Windows 系统的 TDR 机制。这是一种保护机制:当显卡驱动在一段时间内(通常是 5 到 10 秒)没有任何活动响应时,系统就会强制重新初始化显卡驱动。

如果你的计算着色器(compute shader)运行时间过长,你可能需要把它拆分成多次派发(dispatches)来执行,以此缩短单次派发的耗时,从而降低触发 TDR(超时检测与恢复)的几率。因为 TDR 本质上是一个基于时间限制的问题,所以在运行同一个计算着色器时,性能较慢的显卡可能会比高性能显卡更容易触发 TDR 崩溃。

获取结果

你可能已经注意到了,在刚才的着色器示例中,我们直接修改了存储缓冲区(storage buffer)里的内容。换句话说,着色器从我们的数组中读取了数据,然后把处理后的结果又重新存回了同一个数组里,所以我们的计算结果其实已经在那儿了。接下来,就让我们把这些数据提取出来,并打印到控制台里看看吧。

# Read back the data from the buffer
var output_bytes := rd.buffer_get_data(buffer)
var output := output_bytes.to_float32_array()
print("Input: ", input)
print("Output: ", output)

释放内存

我们一直使用的 buffer (缓冲区)、 pipeline (管线)和 uniform_set (统一变量集)这几个变量,每一个都是 RID 。因为 RenderingDevice 的设计初衷是作为一个底层的 API,所以 RID 并不会被自动释放。这意味着,当你用完 buffer 或者任何其他 RID 之后,你需要负责手动释放它们占用的内存,具体做法就是调用 RenderingDevice 的 free_rid() 方法。

有了这些,你就已经掌握了上手使用计算着色器(compute shaders)所需的一切知识。

参见

官方演示项目的仓库里包含了一个 Compute Shader Heightmap demo 。这个项目分别使用 CPU 和 GPU 来生成高度图图像,让你可以直观地对比同一个算法用两种不同方式实现的效果(在绝大多数情况下,GPU 的实现方式会快得多)。