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

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