合成器

合成器是 Godot 4 中的新功能,能够用来控制 Viewport 渲染内容时所使用的渲染管线。

它可以在 WorldEnvironment 节点上进行配置,并应用于所有视口;也可以在 Camera3D 上进行配置,并仅应用于使用该相机的视口。

The Compositor resource is used to configure the compositor. To get started, create a new compositor on the appropriate node:

../../_images/new_compositor.webp

备注

目前只有移动渲染器和 Forward+ 渲染器支持合成器功能。

合成器效果

合成器效果允许你在渲染管线的各个阶段插入额外逻辑。这是一项高级功能,需要对渲染管线有很高的理解才能充分利用它。

由于合成器效果的核心逻辑是从渲染管线调用的,因此需要注意的是,该逻辑将在渲染发生的线程内运行。务必小心,以确保我们不会遇到线程问题。

为了说明如何使用合成器效果,我们将创建一个简单的后期处理效果,让你可以编写自己的着色器代码并通过计算着色器应用该全屏。你可以在这里找到完成的演示项目。

首先创建一个名为 post_process_shader.gd 的新脚本。我们将把它作为一个工具脚本,这样就可以在编辑器中看到合成器效果的工作情况。我们需要从 CompositorEffect 扩展我们的节点。还必须为脚本指定一个类名。

post_process_shader.gd
@tool
extends CompositorEffect
class_name PostProcessShader

接下来,我们将为着色器模板代码定义一个常量。这是使计算着色器工作的样板代码。

const template_shader: String = """
#version 450

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

layout(rgba16f, set = 0, binding = 0) uniform image2D color_image;

// Our push constant
layout(push_constant, std430) uniform Params {
    vec2 raster_size;
    vec2 reserved;
} params;

// The code we want to execute in each invocation
void main() {
    ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
    ivec2 size = ivec2(params.raster_size);

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

    vec4 color = imageLoad(color_image, uv);

    #COMPUTE_CODE

    imageStore(color_image, uv, color);
}
"""

有关计算着色器如何工作的更多信息,请查看《使用计算着色器》。

这里重要的一点是,对于屏幕上的每个像素,我们的 main 函数都会被执行,在其中我们加载像素的当前颜色值,执行用户代码,并将修改后的颜色写回到彩色图像中。

#COMPUTE_CODE 应被我们的用户代码替换掉。

为了用户代码,我们需要一个导出变量。我们还将定义一些将使用的脚本变量:

@export_multiline var shader_code: String = "":
    set(value):
        mutex.lock()
        shader_code = value
        shader_is_dirty = true
        mutex.unlock()

var rd: RenderingDevice
var shader: RID
var pipeline: RID

var mutex: Mutex = Mutex.new()
var shader_is_dirty: bool = true

请注意我们代码中 Mutex 的使用。我们的大多数实现都是从渲染引擎调用的,因此需在我们的渲染线程中运行。

我们需要确保设置新的着色器代码,并将着色器代码标记为脏,同时渲染线程不会访问这些数据。

接下来初始化我们的效果。

# Called when this resource is constructed.
func _init():
    effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
    rd = RenderingServer.get_rendering_device()

这里最重要的是设置我们的 effect_callback_type,它告诉渲染引擎在渲染管线的哪个阶段调用我们的代码。

备注

目前我们只能访问 3D 渲染管线的各个阶段!

我们还获得了对渲染设备的引用,这将非常方便。

我们还需要自己进行清理,为此我们对 NOTIFICATION_PREDELETE 通知做出反应:

# System notifications, we want to react on the notification that
# alerts us we are about to be destroyed.
func _notification(what):
    if what == NOTIFICATION_PREDELETE:
        if shader.is_valid():
            # Freeing our shader will also free any dependents such as the pipeline!
            rd.free_rid(shader)

请注意,即使我们在渲染线程内创建了着色器,我们也不会在此处使用互斥锁。我们的渲染服务器上的方法是线程安全的,并且 free_rid 将推迟清理着色器,将其推迟到当前正在渲染的所有帧都完成之后。

还要注意,我们无需释放管线。渲染设备会进行依赖跟踪,由于管线依赖于着色器,因此当着色器被销毁时,管线会被自动释放。

从此刻起,我们的代码将在渲染线程上运行。

我们的下一步是一个辅助函数,它将在用户代码发生更改时重新编译着色器。

# Check if our shader has changed and needs to be recompiled.
func _check_shader() -> bool:
    if not rd:
        return false

    var new_shader_code: String = ""

    # Check if our shader is dirty.
    mutex.lock()
    if shader_is_dirty:
        new_shader_code = shader_code
        shader_is_dirty = false
    mutex.unlock()

    # We don't have a (new) shader?
    if new_shader_code.is_empty():
        return pipeline.is_valid()

    # Apply template.
    new_shader_code = template_shader.replace("#COMPUTE_CODE", new_shader_code);

    # Out with the old.
    if shader.is_valid():
        rd.free_rid(shader)
        shader = RID()
        pipeline = RID()

    # In with the new.
    var shader_source: RDShaderSource = RDShaderSource.new()
    shader_source.language = RenderingDevice.SHADER_LANGUAGE_GLSL
    shader_source.source_compute = new_shader_code
    var shader_spirv: RDShaderSPIRV = rd.shader_compile_spirv_from_source(shader_source)

    if shader_spirv.compile_error_compute != "":
        push_error(shader_spirv.compile_error_compute)
        push_error("In: " + new_shader_code)
        return false

    shader = rd.shader_create_from_spirv(shader_spirv)
    if not shader.is_valid():
        return false

    pipeline = rd.compute_pipeline_create(shader)
    return pipeline.is_valid()

在这个方法的顶部,我们再次使用互斥锁来保护对用户着色器代码和脏标记的访问。如果我们的用户着色器代码脏了,我们会在本地线程中复制用户着色器代码。

如果我们没有新的代码片段,并且我们已经有一个有效的管线,我们就返回 true。

如果我们确实有新的代码片段,我们会将其嵌入到我们的模板代码中,然后进行编译。

警告

此处显示的代码将在运行时中编译我们的新代码。这对于原型设计非常有用,因为我们可以立即看到更改后的着色器的效果。

这可以防止预编译和缓存此着色器,这在类似主机的某些平台上可能是一个问题。请注意,演示项目附带了一个替代示例,其中 glsl 文件包含整个计算着色器,并且使用它。Godot 能够使用此方法预编译和缓存着色器。

最后我们需要实现我们的效果回调,渲染引擎将在渲染的正确阶段调用它。

# Called by the rendering thread every frame.
func _render_callback(p_effect_callback_type, p_render_data):
    if rd and p_effect_callback_type == EFFECT_CALLBACK_TYPE_POST_TRANSPARENT and _check_shader():
        # Get our render scene buffers object, this gives us access to our render buffers.
        # Note that implementation differs per renderer hence the need for the cast.
        var render_scene_buffers: RenderSceneBuffersRD = p_render_data.get_render_scene_buffers()
        if render_scene_buffers:
            # Get our render size, this is the 3D render resolution!
            var size = render_scene_buffers.get_internal_size()
            if size.x == 0 and size.y == 0:
                return

            # We can use a compute shader here.
            var x_groups = (size.x - 1) / 8 + 1
            var y_groups = (size.y - 1) / 8 + 1
            var z_groups = 1

            # Push constant.
            var push_constant: PackedFloat32Array = PackedFloat32Array()
            push_constant.push_back(size.x)
            push_constant.push_back(size.y)
            push_constant.push_back(0.0)
            push_constant.push_back(0.0)

            # Loop through views just in case we're doing stereo rendering. No extra cost if this is mono.
            var view_count = render_scene_buffers.get_view_count()
            for view in range(view_count):
                # Get the RID for our color image, we will be reading from and writing to it.
                var input_image = render_scene_buffers.get_color_layer(view)

                # Create a uniform set.
                # This will be cached; the cache will be cleared if our viewport's configuration is changed.
                var uniform: RDUniform = RDUniform.new()
                uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
                uniform.binding = 0
                uniform.add_id(input_image)
                var uniform_set = UniformSetCacheRD.get_cache(shader, 0, [ uniform ])

                # Run our compute 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_set_push_constant(compute_list, push_constant.to_byte_array(), push_constant.size() * 4)
                rd.compute_list_dispatch(compute_list, x_groups, y_groups, z_groups)
                rd.compute_list_end()

在这个方法开始时,我们检查是否有渲染设备,回调类型是否正确,以及检查是否有着色器。

备注

检查效果类型只是一种安全机制。我们在 _init 函数中设置了它,但用户可以在 UI 中更改它。

我们的 p_render_data 参数使我们能够访问一个对象,该对象保存了当前正在渲染的帧的特定数据。我们目前只对我们的渲染场景缓冲区感兴趣,它使我们能够访问渲染引擎使用的所有内部缓冲区。请注意,我们将其转换为 RenderSceneBuffersRD 以公开此数据的完整 API。

接下来,我们获得我们的内部尺寸,即我们的 3D 渲染缓冲区在放大之前的分辨率(如果适用),放大发生在我们的后期处理运行之后。

根据我们的内部大小,我们计算出我们的分组大小,在我们的模板着色器中查看我们的局部大小。

我们还填充了推送常量,以便着色器知道大小。Godot 暂时支持结构体,因此我们使用 PackedFloat32Array 来存储这些数据。请注意,我们必须用 16 字节对齐填充该数组。换句话说,我们的数组长度需要是 4 的倍数。

现在我们循环遍历视图,以防我们使用适用于立体渲染(XR)的多视图渲染。大多数情况下,我们只有一个视图。

备注

此处使用多视图进行后处理并没有性能优势,像这样单独处理视图仍然可以使 GPU 在有利的情况下使用并行性。

接下来我们获取该视图的颜色缓冲区。这是我们的 3D 场景被渲染到的缓冲区。

然后我们准备一个统一的集合,以便我们可以将颜色缓冲区传递给我们的着色器。

请注意我们使用 UniformSetCacheRD 缓存,以确保我们可以每帧检查 uniform 集。由于我们的颜色缓冲区可以逐帧更改,并且我们的 uniform 缓存会在缓冲区释放时自动清理 uniform 集,因此这是确保我们不泄漏内存或使用过时集的安全方法。

最后,我们通过绑定管线、绑定 uniform 集、推送推送常量数据、以及为我们的组调用调度,来构建我们的计算列表。

合成器效果完成后,我们现在需要将其添加到合成器中。

在合成器上,我们扩展合成器效果属性并按 Add Element

现在我们可以添加合成器效果:

../../_images/add_compositor_effect.webp

选择 PostProcessShader 后,我们需要设置你的用户着色器代码:

float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721;
color.rgb = vec3(gray);

完成这一切后,我们的输出是灰度的。

../../_images/post_process_shader.webp

备注

有关后期效果的更高级示例,请查看由 Bastiaan Olij 创建的基于径向模糊的天空光线示例项目。