Композитор

Композитор — это новая функция Godot 4, которая позволяет управлять конвейером рендеринга при рендеринге содержимого Viewport.

Его можно настроить на узле WorldEnvironment, где он применяется ко всем видовым областям, или его можно настроить на узле Camera3D и применить только к видовой области, использующей эту камеру.

Ресурс Compositor используется для настройки компоновщика. Для начала создайте новый компоновщик на соответствующем узле:

../../_images/new_compositor.webp

Примечание

В настоящее время функция компоновщика поддерживается только рендерами Mobile и Forward+.

Эффекты композитора

Эффекты Compositor позволяют добавлять дополнительную логику в конвейер рендеринга на разных этапах. Это сложная функция, требующая глубокого понимания принципов работы конвейера рендеринга для её эффективного использования.

Поскольку основная логика эффекта компоновщика вызывается из конвейера рендеринга, важно отметить, что эта логика будет выполняться в том же потоке, в котором происходит рендеринг. Необходимо соблюдать осторожность, чтобы избежать проблем с потоками.

Чтобы продемонстрировать использование эффектов компоновщика, мы создадим простой эффект постобработки, который позволит вам написать собственный код шейдера и применить этот полноэкранный режим через вычислительный шейдер. Готовый демонстрационный проект можно найти здесь:.

Начнём с создания нового скрипта под названием 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);
}
"""

Более подробную информацию о работе вычислительных шейдеров см. в разделе: Using compute shaders.

Важным моментом здесь является то, что для каждого пикселя на нашем экране выполняется наша 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)

Обратите внимание, что мы не используем здесь мьютекс (mutex), хотя создаём шейдер внутри потока рендеринга. Методы на нашем сервере рендеринга потокобезопасны, и 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()

В начале этого метода мы снова используем мьютекс для защиты доступа к коду пользовательского шейдера и флаг "dirty". Мы создаём локальную копию кода пользовательского шейдера, если он "dirty".

Если у нас нет нового фрагмента кода, мы возвращаем 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, однако пользователь может изменить её в пользовательском интерфейсе.

Наш параметр p_render_data предоставляет нам доступ к объекту, содержащему данные, относящиеся к текущему кадру, который мы рендерим. В настоящее время нас интересуют только буферы сцены рендеринга, которые предоставляют доступ ко всем внутренним буферам, используемым движком рендеринга. Обратите внимание, что мы приводим этот параметр к RenderSceneBuffersRD, чтобы предоставить полный API для работы с этими данными.

Затем мы получаем наш internal size, который представляет собой разрешение наших буферов 3D-рендеринга до их масштабирования (если применимо), масштабирование происходит после завершения наших постпроцессов.

На основе нашего внутреннего размера мы рассчитываем размер нашей группы, смотрите наш локальный размер в нашем шаблонном шейдере.

Мы также заполняем нашу константу push, чтобы шейдер знал наш размер. Godot пока не поддерживает структуры, поэтому мы используем PackedFloat32Array для хранения этих данных. Обратите внимание, что нам нужно дополнить этот массив выравниванием по 16 байт. Другими словами, длина нашего массива должна быть кратна 4.

Теперь мы циклически проходим по нашим представлениям. Это на случай, если мы используем многовидовой рендеринг, который применим для стереорендеринга (XR). В большинстве случаев у нас будет только одно представление.

Примечание

В данном случае использование нескольких представлений для постобработки не дает никаких преимуществ в производительности, однако раздельная обработка представлений, подобная этой, все равно позволит графическому процессору использовать параллелизм, если это выгодно.

Затем мы получаем цветовой буфер для этого вида. Это буфер, в который была отрендерена наша 3D-сцена.

Затем мы подготавливаем единый набор, чтобы иметь возможность передать цветовой буфер нашему шейдеру.

Обратите внимание на использование нашего кэша UniformSetCacheRD, который гарантирует проверку нашего набора юниформ в каждом кадре. Поскольку буфер цветов может меняться от кадра к кадру, а кэш юниформ автоматически очищает наборы юниформ при освобождении буферов, это безопасный способ избежать утечки памяти или использования устаревшего набора.

Наконец, мы создаем наш список вычислений, привязывая наш конвейер, привязывая наш унифицированный набор, отправляя наши постоянные данные и вызывая диспетчеризацию для наших групп.

После завершения нашего эффекта композитора нам нужно добавить его в наш композитор.

В нашем композиторе раскрываем свойство эффектов композитора и нажимаем 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

Примечание

Для более продвинутого примера постэффектов ознакомьтесь с примером проекта Радиальное размытие на основе небесных лучей, созданным Бастианом Олием.