So here’s the setup. Runs smooth as butter in the editor. I excluded all boilerplate error checks and thread safety stuff for brevity. Once you get it running you can bring all that back in if needed.
Editor:
Runtime:
Scene:
Script in scene root (updates camera transform/fov and SubViewport texture rid in compositor effect):
@tool
extends Node3D
func _process(dt) :
var vp_main: Viewport
if Engine.is_editor_hint():
vp_main = EditorInterface.get_editor_viewport_3d()
else:
vp_main = get_tree().get_root()
var cam_main: Camera3D = vp_main.get_camera_3d()
%vp.size = vp_main.size
%cam_vp.global_transform = cam_main.global_transform
%cam_vp.fov = cam_main.fov
# needs to be done on startup and vp resize but we do it every frame for simplicity
var ce: CompositorEffect = %env.compositor.compositor_effects[0]
ce.vp_tex = RenderingServer.texture_get_rd_texture(%vp.get_texture());
Compositor effect:
@tool
class_name Effect extends CompositorEffect
var shader_file = preload("effect.glsl")
var rd: RenderingDevice
var shader: RID
var pipeline: RID
var vp_tex: RID
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)
func _render_callback(callback_type, render_data):
# get frame buffers
var render_scene_buffers: RenderSceneBuffersRD = render_data.get_render_scene_buffers()
# 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_IMAGE
u_vp.binding = 1
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(0.0)
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()
Compute shader:
#[compute]
#version 450
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(rgba16f, set = 0, binding = 0) uniform image2D main_image;
layout(rgba8, set = 0, binding = 1) uniform image2D vp_image;
layout(push_constant) uniform Params {
vec2 image_size;
vec2 padding;
} params;
void main() {
ivec2 uv = ivec2(gl_GlobalInvocationID.xy);
if(uv.x >= params.image_size.x || uv.y >= params.image_size.y){
return;
}
vec3 main = imageLoad(main_image, uv).rgb;
vec3 vp = imageLoad(vp_image, uv).rgb;
float mix_fac = step(params.image_size.x / 2, uv.x);
vec4 out_color = vec4(mix(main, vp, mix_fac), 1.0);
imageStore(main_image, uv, out_color);
}
To avoid dealing with individual cameras, the Compositor is attached to the WorldEnvironment node, but since that applies the effect to all viewports including the SubViewport, there’s a dummy Compositor assigned to SubViewport’s camera to avoid “infinite loop” feedback over frames (which causes cool looking ghosting, try it out). You also might want to enable SubViewport’s use_hdr_2d flag to keep everything linear.


