Need advice on Compositor workflow

Good news! I got a compositor effect that succesfully renders the texture of a viewport created via RenderingServer.create_viewport()!

Godot_Compositor_ViewportEffect

As you can see, the texture provides the correct color space. However, there is an issue with the pixel alignment which causes a minor artefact especially visible on edges. I’m not sure if this is a result of incorrect usage of UVs, or if it has to do with how the texture is sampled. It does look like the viewport texture is offset by half a pixel in the y-direction – not sure.


While this is good news, I could not get the compositor effect to run in the editor. Whenever I would set the viewport to be active (i.e. invoking RenderingServer.viewport_set_active()), Godot would just crash. I’m not sure why. If you have an idea, please let me know.

The effect runs solely using the scripts seen below; no specific node tree setup is required beyond a Camera3D with the below compositor effect applied to its compositor. Objects are rendered to either viewport based on their visibility layers (layer 1 is the default, and layer 2 is used by the new viewport’s camera).

Compositor Effect Script
extends CompositorEffect
class_name ViewportEffect

var shader_file = preload("res://shaders/viewport_shader.glsl")
var rd: RenderingDevice
var shader: RID
var pipeline: RID

var vp_tex: RID
var linear_sampler: RID
var nearest_sampler: RID

var viewport: RID
var vp_cam: RID

@export_range(0.0, 1.0, 0.0) var cutoff_point: float = 0.5

var root: Viewport:
	get:
		if (Engine.is_editor_hint()):
			return EditorInterface.get_editor_viewport_3d()
		else:
			var tree: SceneTree = Engine.get_main_loop() as SceneTree
			
			print("Main Loop is tree: %s" % (Engine.get_main_loop() is SceneTree))
			print("Root is valid: %s" % (tree.root != null))
			print("Root viewport is valid: %s" % (tree.root.get_viewport() != null))
			
			return tree.root.get_viewport()

func _init():
	effect_callback_type = CompositorEffect.EFFECT_CALLBACK_TYPE_POST_SKY
	rd = RenderingServer.get_rendering_device()
	shader = rd.shader_create_from_spirv(shader_file.get_spirv())
	pipeline = rd.compute_pipeline_create(shader)
	
	# Create samplers
	var sampler_state = RDSamplerState.new()
	sampler_state.min_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
	sampler_state.mag_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
	linear_sampler = rd.sampler_create(sampler_state)
	sampler_state.min_filter = RenderingDevice.SAMPLER_FILTER_NEAREST
	sampler_state.mag_filter = RenderingDevice.SAMPLER_FILTER_NEAREST
	nearest_sampler = rd.sampler_create(sampler_state)
	
	# Defer viewport setup to make sure the scene tree has been created and initialized.
	call_deferred("setup_viewport")

func setup_viewport():
	
	vp_cam = RenderingServer.camera_create()
	viewport = RenderingServer.viewport_create()
	
	# Configure camera
	var cam_main = root.get_camera_3d()
	RenderingServer.camera_set_transform(vp_cam, cam_main.global_transform)
	RenderingServer.camera_set_perspective(vp_cam, cam_main.fov, cam_main.near, cam_main.far)
	RenderingServer.camera_set_cull_mask(vp_cam, 1 << 1)
	
	# Configure viewport
	RenderingServer.viewport_attach_camera(viewport, vp_cam)
	RenderingServer.viewport_set_update_mode(viewport, RenderingServer.VIEWPORT_UPDATE_ALWAYS)
	RenderingServer.viewport_set_clear_mode(viewport, RenderingServer.VIEWPORT_CLEAR_ALWAYS)
	# Failing to set this crashes the game at runtime.
	RenderingServer.viewport_set_scenario(viewport, root.world_3d.scenario)
	# Setting this crashes the editor (i.e. when this script is a @tool script).
	RenderingServer.viewport_set_active(viewport, true)
	#RenderingServer.viewport_set_use_hdr_2d(viewport, true)
	#RenderingServer.viewport_set_parent_viewport(viewport, root)
	
	update_viewport_size(root.size)
	
func update_viewport_size(size: Vector2i):
	print("Updating viewport size (new: %s)" % size)
	#mutex.lock()
	RenderingServer.viewport_set_size(viewport, size.x, size.y)
	#mutex.unlock()
	
	if (vp_tex.is_valid()):
		RenderingServer.free_rid(vp_tex)
	
	vp_tex = RenderingServer.texture_get_rd_texture(RenderingServer.viewport_get_texture(viewport), true)

func _render_callback(callback_type, render_data):
	# get frame buffers
	var render_scene_buffers: RenderSceneBuffersRD = render_data.get_render_scene_buffers()

	var tf = rd.texture_get_format(vp_tex)
	#print("Viewport texture format: %s" % (tf.format))
	#print("Viewport texture size: %s" % Vector2i(tf.width, tf.height))
	var intern_size = render_scene_buffers.get_internal_size()
	if (tf.width != intern_size.x || tf.height != intern_size.y):
		update_viewport_size(intern_size)

	# main render image uniform
	var u_main: RDUniform = RDUniform.new()
	u_main.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
	u_main.binding = 0
	u_main.add_id(render_scene_buffers.get_color_layer(0))
	# vp render image uniform
	var u_vp: RDUniform = RDUniform.new()
	u_vp.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
	u_vp.binding = 1
	u_vp.add_id(nearest_sampler)
	u_vp.add_id(vp_tex)
	# uniform set
	var uniform_set = UniformSetCacheRD.get_cache(shader, 0, [u_main, u_vp])

	# calculate number of workgroups
	var image_size = render_scene_buffers.get_internal_size()
	var wgroups_count_x = (image_size.x - 1) / 8 + 1
	var wgroups_count_y = (image_size.y - 1) / 8 + 1

	# size uniform (for split screen mixing and invocation "crop")
	var push_constant: PackedFloat32Array = PackedFloat32Array()
	push_constant.push_back(image_size.x)
	push_constant.push_back(image_size.y)
	push_constant.push_back(cutoff_point)
	push_constant.push_back(0.0)

	# execute 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, wgroups_count_x, wgroups_count_y, 1)
	rd.compute_list_end()

GLSL Shader
#[compute]
#version 460

layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(rgba16f, set = 0, binding = 0) uniform image2D screen;
layout(set = 0, binding = 1) uniform sampler2D viewport;

layout(push_constant) uniform Params {
	vec2 image_size;
	vec2 offset;    // ...and padding (only x-value is used)
} params;

void main()
{
    ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
    vec2 uv_norm = uv / params.image_size;
    
    // SCREEN and VIEWPORT texture
    vec4 color = imageLoad(screen, uv);
    vec4 vp = texture(viewport, uv_norm);
    
    // SCREEN/VIEWPORT blending
    float t = step(params.offset.x * params.image_size.x, uv.x);
    vec4 newColor = vp * t + color * (1.0 - t);
    
    // Threshold line
    const float lineWidth = 10.0;
    float lineMask = clamp(abs((params.offset.x * params.image_size.x) - uv.x) / lineWidth, 0.0, 1.0);
    
    imageStore(screen, uv, newColor * lineMask);
}
Control Script (for controlling the effect)
extends Camera3D

func _process(delta: float) -> void:
	
	if (Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)):
		var norm_mouse_pos = get_viewport().get_mouse_position() / Vector2(get_viewport().size)
		if (compositor.compositor_effects[0] != null):
			compositor.compositor_effects[0].cutoff_point = norm_mouse_pos.x