diff --git a/compute/post_shader/README.md b/compute/post_shader/README.md new file mode 100644 index 00000000..536cebe2 --- /dev/null +++ b/compute/post_shader/README.md @@ -0,0 +1,39 @@ +# Compositor Effects (Post-Processing) + +This demo shows how to use compositor effects to create a post process. +This functionality only works in render device based renderers such as the Forward+ renderer. + +Language: GDScript + +Renderer: Forward+ + +> Note: this demo requires Godot 4.3 or later + +## Screenshots + +![Screenshot](screenshots/post_process_shader.webp) + +## Technical description + +This demo shows the use of the new compositor effect system to add a compute shader based post process. +A compositor effect needs to first be implemented as a subclass of the `CompositorEffect` resource. +An instance of this resource can then be added to the `Compositor` +either as part of a `WorldEnvironment` node or as part of a `Camera3D` node. + +During rendering of a viewport the `_render_callback` on this resource will be called +at the configured stage and additional rendering commands can be submitted. + +The two examples in this project both add a compute call to apply a full screen effect. +Both are designed as tool scripts so they work both in editor and in runtime. + +`post_process_shader.gd` shows an example where a template shader is used into which user code +is injected. The user code is stored in a property of the compositor effect. +This approach is able to recompile the shader as the property changes in runtime. +This approach is not able to make efficient use of shader caching and may not be supported on certain +platforms, such as certain consoles, that require precompiling of shaders. + +`post_process_grayscale.gd` show an example where the shader code is stored in a file, +namely `post_process_grayscale.glsl` and is compiled on initialisation. +For editing a project this means that the shader is compiled once when the effect is loaded. +Making changes to the `glsl` file will require reloading the scene. +The advantage of this approach is that Godot can precompile the `glsl` file. diff --git a/compute/post_shader/icon.svg b/compute/post_shader/icon.svg new file mode 100644 index 00000000..3fe4f4ae --- /dev/null +++ b/compute/post_shader/icon.svg @@ -0,0 +1 @@ + diff --git a/compute/post_shader/icon.svg.import b/compute/post_shader/icon.svg.import new file mode 100644 index 00000000..acb9daf3 --- /dev/null +++ b/compute/post_shader/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckgggpfd707sy" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/compute/post_shader/main.gd b/compute/post_shader/main.gd new file mode 100644 index 00000000..27f333b3 --- /dev/null +++ b/compute/post_shader/main.gd @@ -0,0 +1,23 @@ +extends Node3D + +@onready var compositor: Compositor = $WorldEnvironment.compositor + + +func _input(event: InputEvent) -> void: + if event.is_action_pressed(&"toggle_grayscale_effect"): + compositor.compositor_effects[0].enabled = not compositor.compositor_effects[0].enabled + update_info_text() + + if event.is_action_pressed(&"toggle_shader_effect"): + compositor.compositor_effects[1].enabled = not compositor.compositor_effects[1].enabled + update_info_text() + + +func update_info_text() -> void: + $Info.text = """Grayscale effect: %s +Shader effect: %s +""" % [ + "Enabled" if compositor.compositor_effects[0].enabled else "Disabled", + "Enabled" if compositor.compositor_effects[1].enabled else "Disabled", +] + diff --git a/compute/post_shader/main.tscn b/compute/post_shader/main.tscn new file mode 100644 index 00000000..a0d16aa6 --- /dev/null +++ b/compute/post_shader/main.tscn @@ -0,0 +1,121 @@ +[gd_scene load_steps=17 format=3 uid="uid://bpfg1l8j4i08u"] + +[ext_resource type="Script" path="res://main.gd" id="1_o0pyp"] +[ext_resource type="Texture2D" uid="uid://br4k6sn2rvgj" path="res://pattern.png" id="1_r22bv"] +[ext_resource type="Script" path="res://post_process_shader.gd" id="1_rkpno"] +[ext_resource type="Script" path="res://post_process_grayscale.gd" id="2_pwabc"] + +[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_lnmx8"] +sky_horizon_color = Color(0.64625, 0.65575, 0.67075, 1) +ground_horizon_color = Color(0.64625, 0.65575, 0.67075, 1) + +[sub_resource type="Sky" id="Sky_guc0r"] +sky_material = SubResource("ProceduralSkyMaterial_lnmx8") + +[sub_resource type="Environment" id="Environment_fjaix"] +background_mode = 2 +sky = SubResource("Sky_guc0r") +tonemap_mode = 2 +glow_enabled = true + +[sub_resource type="CompositorEffect" id="CompositorEffect_d6jju"] +resource_local_to_scene = false +resource_name = "" +enabled = true +effect_callback_type = 4 +needs_motion_vectors = false +needs_normal_roughness = false +script = ExtResource("2_pwabc") + +[sub_resource type="CompositorEffect" id="CompositorEffect_ek4c3"] +resource_local_to_scene = false +resource_name = "" +enabled = false +effect_callback_type = 4 +needs_motion_vectors = false +needs_normal_roughness = false +script = ExtResource("1_rkpno") +shader_code = " // Invert color. + color.rgb = vec3(1.0 - color.r, 1.0 - color.g, 1.0 - color.b); +" + +[sub_resource type="Compositor" id="Compositor_xxhi4"] +compositor_effects = Array[CompositorEffect]([SubResource("CompositorEffect_d6jju"), SubResource("CompositorEffect_ek4c3")]) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_xlpoj"] +albedo_color = Color(0, 0.684707, 0.148281, 1) +albedo_texture = ExtResource("1_r22bv") +texture_filter = 5 + +[sub_resource type="PlaneMesh" id="PlaneMesh_82vj7"] +material = SubResource("StandardMaterial3D_xlpoj") +size = Vector2(10, 10) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_aqyxc"] +albedo_color = Color(0.946837, 0.315651, 0.66999, 1) +albedo_texture = ExtResource("1_r22bv") +texture_filter = 5 + +[sub_resource type="SphereMesh" id="SphereMesh_iuyuf"] +material = SubResource("StandardMaterial3D_aqyxc") + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_188mc"] +albedo_color = Color(0.436357, 0.305476, 0.999959, 1) +albedo_texture = ExtResource("1_r22bv") +texture_filter = 5 + +[sub_resource type="BoxMesh" id="BoxMesh_h605a"] +material = SubResource("StandardMaterial3D_188mc") + +[node name="Main" type="Node3D"] +script = ExtResource("1_o0pyp") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(-0.866025, -0.433013, 0.25, 0, 0.5, 0.866025, -0.5, 0.75, -0.433013, 0, 0, 0) +shadow_enabled = true +shadow_bias = 0.04 +directional_shadow_mode = 0 +directional_shadow_fade_start = 1.0 +directional_shadow_max_distance = 15.0 + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_fjaix") +compositor = SubResource("Compositor_xxhi4") + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(0.866025, -0.129409, 0.482963, -1.54268e-08, 0.965926, 0.258819, -0.5, -0.224144, 0.836516, 1, 1.2, 2) +fov = 60.0 + +[node name="Ground" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -3.01202) +mesh = SubResource("PlaneMesh_82vj7") + +[node name="Sphere" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, -0.796) +mesh = SubResource("SphereMesh_iuyuf") + +[node name="Box" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.333, 0.5, -0.392) +mesh = SubResource("BoxMesh_h605a") + +[node name="Info" type="Label" parent="."] +offset_left = 24.0 +offset_top = 24.0 +offset_right = 64.0 +offset_bottom = 47.0 +theme_override_constants/outline_size = 4 +text = "Grayscale effect: Enabled +Shader effect: Disabled" + +[node name="Help" type="Label" parent="."] +anchors_preset = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24.0 +offset_top = -47.0 +offset_right = 175.0 +offset_bottom = -24.0 +grow_vertical = 0 +theme_override_constants/outline_size = 4 +text = "G: Toggle grayscale effect +S: Toggle shader effect" diff --git a/compute/post_shader/pattern.png b/compute/post_shader/pattern.png new file mode 100644 index 00000000..8bf420b0 Binary files /dev/null and b/compute/post_shader/pattern.png differ diff --git a/compute/post_shader/pattern.png.import b/compute/post_shader/pattern.png.import new file mode 100644 index 00000000..65c3b4c9 --- /dev/null +++ b/compute/post_shader/pattern.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://br4k6sn2rvgj" +path="res://.godot/imported/pattern.png-888ea151ee9fa7a079d3252596260765.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://pattern.png" +dest_files=["res://.godot/imported/pattern.png-888ea151ee9fa7a079d3252596260765.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/compute/post_shader/post_process_grayscale.gd b/compute/post_shader/post_process_grayscale.gd new file mode 100644 index 00000000..f3189d0b --- /dev/null +++ b/compute/post_shader/post_process_grayscale.gd @@ -0,0 +1,89 @@ +@tool +extends CompositorEffect +class_name PostProcessGrayScale + +var rd: RenderingDevice +var shader: RID +var pipeline: RID + + +func _init() -> void: + effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT + rd = RenderingServer.get_rendering_device() + RenderingServer.call_on_render_thread(_initialize_compute) + + +# System notifications, we want to react on the notification that +# alerts us we are about to be destroyed. +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + if shader.is_valid(): + # Freeing our shader will also free any dependents such as the pipeline! + RenderingServer.free_rid(shader) + + +#region Code in this region runs on the rendering thread. +# Compile our shader at initialization. +func _initialize_compute() -> void: + rd = RenderingServer.get_rendering_device() + if not rd: + return + + # Compile our shader. + var shader_file := load("res://post_process_grayscale.glsl") + var shader_spirv: RDShaderSPIRV = shader_file.get_spirv() + + shader = rd.shader_create_from_spirv(shader_spirv) + if shader.is_valid(): + pipeline = rd.compute_pipeline_create(shader) + + +# Called by the rendering thread every frame. +func _render_callback(p_effect_callback_type: EffectCallbackType, p_render_data: RenderData) -> void: + if rd and p_effect_callback_type == EFFECT_CALLBACK_TYPE_POST_TRANSPARENT and pipeline.is_valid(): + # 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 := p_render_data.get_render_scene_buffers() + if render_scene_buffers: + # Get our render size, this is the 3D render resolution! + var size: Vector2i = render_scene_buffers.get_internal_size() + if size.x == 0 and size.y == 0: + return + + # We can use a compute shader here. + @warning_ignore("integer_division") + var x_groups := (size.x - 1) / 8 + 1 + @warning_ignore("integer_division") + var y_groups := (size.y - 1) / 8 + 1 + var z_groups := 1 + + # Create push constant. + # Must be aligned to 16 bytes and be in the same order as defined in the shader. + var push_constant := PackedFloat32Array([ + size.x, + size.y, + 0.0, + 0.0, + ]) + + # Loop through views just in case we're doing stereo rendering. No extra cost if this is mono. + var view_count: int = render_scene_buffers.get_view_count() + for view in view_count: + # Get the RID for our color image, we will be reading from and writing to it. + var input_image: RID = render_scene_buffers.get_color_layer(view) + + # Create a uniform set, this will be cached, the cache will be cleared if our viewports configuration is changed. + var uniform := 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() +#endregion diff --git a/compute/post_shader/post_process_grayscale.glsl b/compute/post_shader/post_process_grayscale.glsl new file mode 100644 index 00000000..119d47a9 --- /dev/null +++ b/compute/post_shader/post_process_grayscale.glsl @@ -0,0 +1,34 @@ +#[compute] +#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); + + // Prevent reading/writing out of bounds. + if (uv.x >= size.x || uv.y >= size.y) { + return; + } + + // Read from our color buffer. + vec4 color = imageLoad(color_image, uv); + + // Apply our changes. + float gray = color.r * 0.2125 + color.g * 0.7154 + color.b * 0.0721; + color.rgb = vec3(gray); + + // Write back to our color buffer. + imageStore(color_image, uv, color); +} diff --git a/compute/post_shader/post_process_grayscale.glsl.import b/compute/post_shader/post_process_grayscale.glsl.import new file mode 100644 index 00000000..653ea822 --- /dev/null +++ b/compute/post_shader/post_process_grayscale.glsl.import @@ -0,0 +1,14 @@ +[remap] + +importer="glsl" +type="RDShaderFile" +uid="uid://y08qi2a3t16m" +path="res://.godot/imported/post_process_grayscale.glsl-74f080e1b01e56b39260b8709956a520.res" + +[deps] + +source_file="res://post_process_grayscale.glsl" +dest_files=["res://.godot/imported/post_process_grayscale.glsl-74f080e1b01e56b39260b8709956a520.res"] + +[params] + diff --git a/compute/post_shader/post_process_shader.gd b/compute/post_shader/post_process_shader.gd new file mode 100644 index 00000000..be639638 --- /dev/null +++ b/compute/post_shader/post_process_shader.gd @@ -0,0 +1,160 @@ +@tool +extends CompositorEffect +class_name PostProcessShader + +const template_shader := """#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. +// Must be aligned to 16 bytes, just like the push constant we passed from the script. +layout(push_constant, std430) uniform Params { + vec2 raster_size; + vec2 pad; +} 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); +}""" + +@export_multiline var shader_code := "": + 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.new() +var shader_is_dirty := true + + +func _init() -> void: + effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT + rd = RenderingServer.get_rendering_device() + + +# System notifications, we want to react on the notification that +# alerts us we are about to be destroyed. +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + if shader.is_valid(): + # Freeing our shader will also free any dependents such as the pipeline! + RenderingServer.free_rid(shader) + + +#region Code in this region runs on the rendering thread. +# Check if our shader has changed and needs to be recompiled. +func _check_shader() -> bool: + if not rd: + return false + + var new_shader_code := "" + + # 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.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() + + +# Called by the rendering thread every frame. +func _render_callback(p_effect_callback_type: EffectCallbackType, p_render_data: RenderData) -> void: + 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 := p_render_data.get_render_scene_buffers() + if render_scene_buffers: + # Get our render size, this is the 3D render resolution! + var size: Vector2i = render_scene_buffers.get_internal_size() + if size.x == 0 and size.y == 0: + return + + # We can use a compute shader here. + @warning_ignore("integer_division") + var x_groups := (size.x - 1) / 8 + 1 + @warning_ignore("integer_division") + var y_groups := (size.y - 1) / 8 + 1 + var z_groups := 1 + + # Create push constant. + # Must be aligned to 16 bytes and be in the same order as defined in the shader. + var push_constant := PackedFloat32Array([ + size.x, + size.y, + 0.0, + 0.0, + ]) + + # Loop through views just in case we're doing stereo rendering. No extra cost if this is mono. + var view_count: int = render_scene_buffers.get_view_count() + for view in view_count: + # Get the RID for our color image, we will be reading from and writing to it. + var input_image: RID = render_scene_buffers.get_color_layer(view) + + # Create a uniform set, this will be cached, the cache will be cleared if our viewports configuration is changed. + var uniform := 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() +#endregion diff --git a/compute/post_shader/project.godot b/compute/post_shader/project.godot new file mode 100644 index 00000000..2d9540b8 --- /dev/null +++ b/compute/post_shader/project.godot @@ -0,0 +1,44 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Compositor Effects (Post-Processing)" +run/main_scene="res://main.tscn" +config/features=PackedStringArray("4.3", "Forward Plus") +config/icon="res://icon.svg" + +[debug] + +gdscript/warnings/untyped_declaration=1 + +[display] + +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" + +[input] + +toggle_grayscale_effect={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null) +] +} +toggle_shader_effect={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":83,"physical_keycode":0,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) +] +} + +[rendering] + +lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3 +textures/default_filters/anisotropic_filtering_level=4 +anti_aliasing/quality/msaa_3d=2 diff --git a/compute/post_shader/screenshots/.gdignore b/compute/post_shader/screenshots/.gdignore new file mode 100644 index 00000000..e69de29b diff --git a/compute/post_shader/screenshots/post_process_shader.webp b/compute/post_shader/screenshots/post_process_shader.webp new file mode 100644 index 00000000..11a4dde4 Binary files /dev/null and b/compute/post_shader/screenshots/post_process_shader.webp differ