SubViewport: a separate camera and live texture
Display a separate 3D world in the UI and control frame updates.
Run a live render texture
Download the project. Requires Reckfell 1.0.0 with the C# SDK and Forward+/Vulkan. Extract the archive, open project.reckfell, build C# and run Main.rscene. All models and textures are included: Poly Haven’s CC0 Pawn Shop interior, with materials and lighting adapted for Reckfell.
Set root Preview=Original before running. This example actually renders the 3D room with a separate camera inside a SubViewport; TextureRect displays its current texture. It is not a saved photograph.
Separate rendering from display
| Object | Responsibility |
|---|---|
| SourceView: SubViewport | its own World3D, room camera, render size and update policy |
| SourceView.GetTexture() | live ViewportTexture containing SourceView’s result |
| Display: TextureRect | where and how large to display that texture in the UI |
| TonePass, Copy, InvertPass | processing the displayed image; explained in the next guide |
A SubViewport does not create an on-screen rectangle by itself. A TextureRect, material or other texture consumer must use its output. The example’s camera belongs to SourceView. An explicit World3D separates the room from the parent viewport’s world. Without an assigned World3D, FindWorld3D searches the viewport itself and its ancestors for an available world.
SourceView.Size is measured in pixels. Display.Size controls presentation and does not change the rendering resolution. Both are 2560×1440 here, but a SubViewport does not require a 2:1 aspect ratio. Resizing Size updates the dimensions reported by the existing ViewportTexture; Display.Texture does not need rebinding.
Use the complete controller
ViewportDisplay.cs is the root Control. PawnShop3D.cs and Assets are already included in the archive. GetTexture returns a resource for display; GetTexture().GetImage() obtains a separate image for CPU readback. A live display does not require GetImage or PNG saving every frame.
using Reckfell;
public partial class ViewportDisplay : Control
{
public enum Mode { Original, Grayscale, Chained }
[Export] public Mode Preview { get; set; } = Mode.Grayscale;
public SubViewport SourceView { get; private set; } = null!;
public PawnShop3D Room { get; private set; } = null!;
public TextureRect Display { get; private set; } = null!;
public ColorRect TonePass { get; private set; } = null!;
public ColorRect InvertPass { get; private set; } = null!;
public BackBufferCopy Copy { get; private set; } = null!;
public ShaderMaterial ToneMaterial { get; private set; } = null!;
public override void _Ready()
{
var size=new Vector2I(2560,1440);
SourceView=new SubViewport { Size=size,World3D=new World3D(),
RenderTargetUpdateMode=SubViewport.UpdateMode.Always,
Msaa3D=Viewport.Msaa.Msaa8X,UseTaa=true,Scaling3DScale=1.5f,
PositionalShadowAtlasSize=4096 };
AddChild(SourceView);
Room=new PawnShop3D();SourceView.AddChild(Room);
// Bind the viewport's live texture; do not replace it with GetImage().
Display=new TextureRect { Texture=SourceView.GetTexture(),Size=size,
ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode=TextureRect.StretchModeEnum.Scale,MouseFilter=MouseFilterEnum.Ignore };
AddChild(Display);
ToneMaterial=new ShaderMaterial { Shader=ResourceLoader.Load<Shader>("asset://ScreenTone.reckfellshader") };
TonePass=new ColorRect { Size=size,Material=ToneMaterial,MouseFilter=MouseFilterEnum.Ignore };
AddChild(TonePass);
// Draw order is important: display -> tone -> copy -> invert.
Copy=new BackBufferCopy { CopyMode=BackBufferCopy.CopyModeEnum.Disabled };AddChild(Copy);
InvertPass=new ColorRect { Size=size,MouseFilter=MouseFilterEnum.Ignore,
Material=new ShaderMaterial { Shader=ResourceLoader.Load<Shader>("asset://ScreenInvert.reckfellshader") } };
AddChild(InvertPass);
SetPreview(Preview);
}
public void SetPreview(Mode mode)
{
TonePass.Position=Vector2.Zero;TonePass.Size=new Vector2(2560,1440);
InvertPass.Position=Vector2.Zero;InvertPass.Size=TonePass.Size;
TonePass.Visible=mode!=Mode.Original;
ToneMaterial.SetShaderParameter("desaturate",1.0f);
Copy.CopyMode=mode==Mode.Chained?BackBufferCopy.CopyModeEnum.Viewport:BackBufferCopy.CopyModeEnum.Disabled;
InvertPass.Visible=mode==Mode.Chained;
}
// Disable only drawing. Scripts and physics do not pause with the render target.
public void Freeze()=>SourceView.RenderTargetUpdateMode=SubViewport.UpdateMode.Disabled;
public void RefreshOnce()=>SourceView.RenderTargetUpdateMode=SubViewport.UpdateMode.Once;
public void Resume()=>SourceView.RenderTargetUpdateMode=SubViewport.UpdateMode.Always;
}Update only when needed
Call these methods after _Ready:
| Action | Example method | Behavior |
|---|---|---|
| render continuously | Resume() | RenderTargetUpdateMode=Always |
| keep the last frame | Freeze() | Disabled stops redrawing |
| obtain one more frame | RefreshOnce() | Once requests one draw |
Freeze stops render-target updates only. It does not pause the EntityTree, scripts or physics. Disabling a viewport before its first draw leaves no completed frame to display.
In the current build, the RenderTargetUpdateMode getter still returns Once after the requested draw. RenderingServer stops the rendering internally. Do not wait for the property to become Disabled as a completion condition. Assigning Once again requests another draw even if the getter already returned Once.
Wait for RenderingServer.FramePostDraw before reading an image; in C# use ToSignal(RenderingServer.Singleton, RenderingServer.SignalName.FramePostDraw). This signal means a frame has finished, not that GI has fully converged or every external asynchronous resource is ready.
Diagnose the result
| Symptom | Check |
|---|---|
| empty rectangle | SourceView is in the tree, Camera3D is active, the model loaded and a draw completed |
| wrong world appears | camera viewport ownership and an explicit World3D |
| stretched or blurry output | compare source Size, consumer Size and TextureRect.StretchMode separately |
| moving the camera has no effect | Disabled may still be active; assign Once again for another single update |
| readback is too early | read after FramePostDraw, not immediately after AddChild |
| mouse input does not reach the scene | TextureRect displays a texture; this example does not forward input into the SubViewport |
Frames: Forward+/Vulkan, 2560×1440, internal 3D scale 1.5, MSAA 8× and TAA. This is a heavy illustration profile. Input forwarding, application export, every UpdateMode and running this example in Compatibility are not accepted here.