# Godot 4 — Viewport Patterns

> **Purpose**: Reference document for Claude Code. SubViewport use cases, stretch mode decision tree, split screen setup, pixel art rendering pipeline, and 3D-in-UI techniques. The marinade — not Viewport API basics.

---

## 1. The Display Stack

Godot renders through a hierarchy of Viewports. Understanding this stack prevents the #1 confusion: "why is my SubViewport invisible?"

```
Window (root)                    ← The OS window, always exists
└── Root Viewport                ← Implicit, renders the main scene
    ├── Your Game Scene
    ├── CanvasLayer (HUD)        ← Fixed UI layer, unaffected by camera
    └── SubViewportContainer     ← Displays a SubViewport as a texture
        └── SubViewport          ← Renders to texture, NOT to screen
            ├── Camera2D/3D
            └── Scene content
```

**The critical insight**: a SubViewport renders to a texture in memory. It is invisible by itself. It needs either:
- A `SubViewportContainer` parent (for UI integration)
- A `ViewportTexture` applied to a mesh/sprite (for in-world display)

Without one of these, the SubViewport computes frames that nobody sees.

---

## 2. Stretch Mode Decision Tree

The single most impactful display setting. Found in `Project Settings > Display > Window > Stretch`.

### Decision Tree

```
What's your art direction?
│
├── Pixel art (low-res, crispy pixels)
│   └── Mode: viewport
│       Aspect: keep
│       Resolution: your pixel grid (320x180, 384x216, 480x270)
│       Result: everything renders at native res, upscaled with nearest-neighbor
│       UI: also pixelated (intentional)
│
├── HD / vector / hand-drawn
│   └── Mode: canvas_items
│       Aspect: expand
│       Resolution: 1920x1080 base
│       Result: UI stays sharp at any resolution, game shows more/less area
│       UI: crisp at 4K, scales smoothly
│
└── Pixel art game + crisp HD UI (best of both)
    └── Mode: canvas_items
        Aspect: keep or expand
        Resolution: 1920x1080 base
        Game renders in a SubViewport at pixel resolution
        UI overlays at native screen resolution
        Result: crunchy game, sharp menus
```

### Mode Comparison

| Setting | `viewport` | `canvas_items` | `disabled` |
|---------|-----------|----------------|-----------|
| **Rendering** | Fixed resolution, stretched to window | Adapts to window size | No scaling |
| **UI sharpness** | Pixelated (same as game) | Always sharp | Native |
| **Game area** | Fixed — same view at all resolutions | Variable — wider screens see more | No adjustment |
| **Best for** | Pixel art (uniform look) | HD games, mobile | Editor tools only |

### Aspect Options

| Setting | Behavior | Use case |
|---------|----------|----------|
| `keep` | Black bars to preserve ratio | Competitive games (no advantage from ultrawide) |
| `expand` | Show more game area on wider screens | Exploration, RPGs, anything non-competitive |
| `keep_width` | Vertical area adjusts | Platformers (horizontal consistency matters) |
| `keep_height` | Horizontal area adjusts | Top-down games (vertical consistency matters) |

### Via MCP

```
godot_project_settings(action="set", key="display/window/stretch/mode", value="canvas_items")
godot_project_settings(action="set", key="display/window/stretch/aspect", value="expand")
godot_project_settings(action="set", key="display/window/size/viewport_width", value=1920)
godot_project_settings(action="set", key="display/window/size/viewport_height", value=1080)
```

---

## 3. SubViewport — Render to Texture

### 3A. 3D in UI (Portrait, Item Preview, Inventory)

The premium detail: a 3D model rendered live inside a 2D interface panel.

**Scene structure:**
```
CanvasLayer (layer=1, HUD)
└── PanelContainer "CharacterPanel"
    └── SubViewportContainer
        ├── size = Vector2(256, 256)
        └── SubViewport
            ├── size = Vector2i(256, 256)
            ├── transparent_bg = true
            ├── render_target_update_mode = UPDATE_ALWAYS
            ├── Camera3D
            │   ├── position = Vector3(0, 0.8, 1.5)
            │   ├── rotation_degrees = Vector3(-15, 0, 0)
            │   └── fov = 40 (tight, less distortion)
            ├── DirectionalLight3D
            │   └── rotation_degrees = Vector3(-30, 30, 0)
            └── CharacterModel (MeshInstance3D / imported scene)
```

**Key values:**
- Camera FOV 35-45 for portraits (less perspective distortion on faces)
- Camera distance 1.2-2.0 units for full body, 0.5-0.8 for bust
- `transparent_bg = true` so the PanelContainer background shows through
- `render_target_update_mode = UPDATE_ALWAYS` for spinning models, `UPDATE_ONCE` for static previews (saves GPU)

**Slow spin script:**
```gdscript
# rotate_preview.gd — attach to the 3D model root
extends Node3D

@export var spin_speed: float = 30.0  # degrees/sec

func _process(delta: float) -> void:
    rotate_y(deg_to_rad(spin_speed * delta))
```

**Performance tip**: for inventory grids with 20+ items, use `UPDATE_ONCE` and call `SubViewport.render_target_update_mode = SubViewport.UPDATE_ONCE` only when the item changes. Don't render 20 spinning models at 60fps.

### 3B. Minimap

A camera looking down at the game world, rendered as a small UI element.

**Scene structure:**
```
CanvasLayer (layer=1, HUD)
└── Control "MinimapFrame" (anchors: top-right, custom_minimum_size 200x200)
    └── SubViewportContainer
        ├── stretch = true
        └── SubViewport
            ├── size = Vector2i(200, 200)
            ├── world_2d = [same as main scene]  ← CRITICAL
            └── Camera2D "MinimapCamera"
                ├── zoom = Vector2(0.15, 0.15)   ← zoomed out
                └── [follows player position]
```

**The critical step**: the minimap SubViewport must share the main scene's `World2D`. Without this, it renders an empty world.

```gdscript
# minimap.gd
extends SubViewport

@onready var minimap_cam: Camera2D = $MinimapCamera

func _ready() -> void:
    # Share the main viewport's world so we see the same nodes
    world_2d = get_tree().root.world_2d

func _process(_delta: float) -> void:
    # Follow the player
    var player = get_tree().get_first_node_in_group("player")
    if player:
        minimap_cam.global_position = player.global_position
```

**Minimap-only visuals**: use `CanvasLayer` with a specific layer number inside the SubViewport for minimap-only icons (player dot, enemy dots) that don't appear in the main view.

### 3C. Pixel Art Game + HD UI (Hybrid Rendering)

The game renders at pixel resolution (320x180), UI renders at screen resolution (1920x1080). Best of both worlds.

**Scene structure:**
```
Root
├── SubViewportContainer "GameViewport"
│   ├── anchors = full rect
│   ├── stretch = true
│   ├── texture_filter = TEXTURE_FILTER_NEAREST  ← crispy pixels
│   └── SubViewport
│       ├── size = Vector2i(320, 180)            ← pixel grid
│       ├── canvas_item_default_texture_filter = TEXTURE_FILTER_NEAREST
│       ├── Camera2D
│       └── [all game content here]
├── CanvasLayer "HUD" (layer=1)
│   └── [UI at native resolution — sharp text, smooth buttons]
└── CanvasLayer "PauseMenu" (layer=10)
    └── [also at native resolution]
```

**Project settings for this setup:**
```
display/window/stretch/mode = "canvas_items"  ← NOT viewport
display/window/size/viewport_width = 1920
display/window/size/viewport_height = 1080
```

The SubViewport handles the pixel-perfect rendering. The main viewport handles the crisp UI. Don't use `viewport` stretch mode here — that would pixelate the UI too.

### Via MCP — Building a SubViewport Setup

The most common Golem workflow: adding a SubViewport to an existing scene (minimap, preview panel, pixel art viewport). **Order matters** — build bottom-up, then configure via execute_script.

```
# 1. Open or create the target scene
godot_open_scene("res://scenes/main.tscn")

# 2. Add the SubViewport hierarchy — bottom-up in one batch
godot_add_nodes(parent=".", nodes=[
    {"name": "SubViewportContainer", "type": "SubViewportContainer", "children": [
        {"name": "SubViewport", "type": "SubViewport", "children": [
            {"name": "Camera2D", "type": "Camera2D"}
        ]}
    ]}
])

# 3. Configure via execute_script — add_nodes can't set enums/special types
godot_execute_script(code="""
var container = get_editor_interface().get_edited_scene_root().get_node("SubViewportContainer")
container.stretch = true
container.custom_minimum_size = Vector2(256, 256)

var vp = container.get_node("SubViewport")
vp.size = Vector2i(256, 256)
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
# vp.transparent_bg = true  # uncomment for 3D portraits

# CRITICAL: center the camera — origin is top-left, NOT center
var cam = vp.get_node("Camera2D")
cam.position = Vector2(vp.size) / 2.0

return "SubViewport ready: %s, camera at %s" % [vp.size, cam.position]
""")

# 4. Save and verify
godot_save_scene()
godot_get_editor_screenshot()  # ALWAYS verify — catch origin/positioning issues
```

**Why execute_script after add_nodes**: `add_nodes` handles node creation and simple properties (strings, numbers, booleans), but enum values (`UPDATE_ALWAYS`), `Vector2i`, and `stretch = true` need GDScript context. The two-step pattern (add_nodes → execute_script) is reliable.

**Shared world (minimap/split screen)** — add after both viewports exist:
```
godot_execute_script(code="""
var vp2 = get_editor_interface().get_edited_scene_root().get_node("MinimapContainer/SubViewport")
vp2.world_2d = get_editor_interface().get_edited_scene_root().get_node("GameContainer/SubViewport").world_2d
return "World2D shared"
""")
```

---

## 4. Split Screen

Two players, two cameras, one shared world.

### Horizontal Split (Top/Bottom)

```
Root
├── HBoxContainer (or VBoxContainer for vertical split)
│   ├── SubViewportContainer "Player1View"
│   │   ├── size_flags_horizontal = SIZE_EXPAND_FILL
│   │   ├── stretch = true
│   │   └── SubViewport
│   │       ├── world_2d = [shared]
│   │       └── Camera2D (follows player 1)
│   └── SubViewportContainer "Player2View"
│       ├── size_flags_horizontal = SIZE_EXPAND_FILL
│       ├── stretch = true
│       └── SubViewport
│           ├── world_2d = [shared]
│           └── Camera2D (follows player 2)
└── CanvasLayer "SharedHUD" (layer=1)
    └── [shared UI: timer, score]
```

**Critical**: both SubViewports must share the same `World2D`:
```gdscript
func _ready() -> void:
    $Player2View/SubViewport.world_2d = $Player1View/SubViewport.world_2d
```

**Audio**: only one SubViewport should be the audio listener. Set `audio_listener_enable_2d = true` on one, `false` on the other (or use a separate AudioListener2D).

### Dynamic Split (Players Close = Single Camera)

When players are close, use one camera. When they drift apart, split.

```gdscript
const SPLIT_DISTANCE := 600.0
const MERGE_DISTANCE := 400.0  # hysteresis to avoid flickering

var _is_split: bool = false

func _process(_delta: float) -> void:
    var dist: float = player1.global_position.distance_to(player2.global_position)
    if not _is_split and dist > SPLIT_DISTANCE:
        _activate_split()
    elif _is_split and dist < MERGE_DISTANCE:
        _deactivate_split()
```

Use hysteresis (different thresholds for split/merge) — without it, players hovering at the threshold cause rapid flickering.

---

## 5. Window Node (OS Windows)

Less common but powerful for tool development and debug UIs.

| Feature | Window | SubViewport |
|---------|--------|-------------|
| Creates OS window | Yes | No |
| Can be dragged outside game | Yes (if not embedded) | No |
| Renders to texture | No | Yes |
| Use case | Debug panels, tool plugins, multi-monitor | Minimap, 3D preview, split screen |

**"Embed Subwindows" setting** (`Project Settings > Display > Window`):
- `true` (default): Window nodes render inside the game window as floating panels
- `false`: Window nodes create real OS windows (can be dragged to second monitor)

Most games keep this `true`. Set to `false` only for editor tools or multi-monitor setups.

---

## 6. Pitfalls

### Viewport origin = top-left, NOT center (critical for MCP workflows)
**Symptom**: Golem creates a scene, adds a Camera2D at (0,0), takes a screenshot — sees nothing or only the bottom-right quadrant of the content. Starts a futile debug loop.
**Cause**: a Viewport's coordinate origin is its **top-left corner**. A Camera2D at (0,0) shows the area around (0,0) — meaning the top-left corner of the world. Content placed at (0,0) appears at the edge, not the center.
**Why it bites hard**: unlike Control nodes (which use anchors/containers for layout), Node2D children in a Viewport have no automatic centering. A Camera2D "centered" at (0,0) actually frames the top-left corner.
**Fix**:
- For Camera2D in a SubViewport: position at `SubViewport.size / 2` to center the view, or ensure game content is laid out relative to the viewport center
- For Node2D content: place at `Vector2(viewport.size) / 2.0` if it should appear centered
- **Never manually position nodes on the canvas** — use containers (for Control nodes) or compute positions relative to viewport size (for Node2D)
```gdscript
# Center a Camera2D in a SubViewport
func _ready() -> void:
    camera.position = Vector2(sub_viewport.size) / 2.0

# Or: place content knowing (0,0) = top-left
# A sprite "centered" in a 320x180 SubViewport:
sprite.position = Vector2(160, 90)
```
**General rule**: when working with Viewports via MCP, always account for the top-left origin. After placing nodes, verify with `godot_get_editor_screenshot` before continuing.

### SubViewport invisible (most common)
**Symptom**: SubViewport runs (you can verify with print in `_process`) but nothing shows.
**Cause**: No SubViewportContainer parent, or container has zero size.
**Fix**: add SubViewportContainer parent with `stretch = true` and a `custom_minimum_size` or proper anchor/container layout.

### ViewportTexture path syntax
**Symptom**: `Invalid ViewportTexture path` error.
**Cause**: ViewportTexture paths are relative to the scene root, not file paths.
**Fix**: in the inspector, the path is like `SubViewportContainer/SubViewport`. In code: `var tex = $SubViewportContainer/SubViewport.get_texture()`.

### SubViewport performance drain
**Symptom**: FPS drops with multiple SubViewports.
**Cause**: each SubViewport is a full render pass. 4 SubViewports = 5x rendering cost.
**Fix**:
- `render_target_update_mode = UPDATE_ONCE` for static content
- Reduce SubViewport `size` (128x128 for icons, 200x200 for minimap)
- `UPDATE_WHEN_VISIBLE` for off-screen viewports

### Pixel art SubViewport blurry
**Symptom**: pixel art looks smoothed/interpolated.
**Cause**: SubViewportContainer uses linear filtering by default.
**Fix**: on the SubViewportContainer, set `texture_filter = TEXTURE_FILTER_NEAREST`. Also set `canvas_item_default_texture_filter = NEAREST` on the SubViewport itself.

### World2D not shared (split screen / minimap)
**Symptom**: second viewport shows empty scene.
**Cause**: each SubViewport creates its own World2D by default.
**Fix**: `viewport2.world_2d = viewport1.world_2d` in `_ready()`. For 3D: `viewport2.world_3d = viewport1.world_3d`.

### Input in SubViewports
**Symptom**: buttons/controls inside SubViewport don't receive clicks.
**Cause**: input routing. SubViewportContainer must have `mouse_filter = MOUSE_FILTER_STOP` and `stretch = true`.
**Fix**: SubViewportContainer handles input routing automatically when `stretch = true`. For manual setups, push input events via `SubViewport.push_input(event)`.

### 3D preview SubViewport + transparency
**Symptom**: 3D model has black background instead of transparent.
**Cause**: `transparent_bg` not enabled on SubViewport.
**Fix**: `SubViewport.transparent_bg = true`. Also ensure the environment has no background (or clear color with alpha 0).

---

## 7. Stack Combinations

### Portrait Panel (3D Character in 2D UI)
```
SubViewport (256x256, transparent_bg, UPDATE_ALWAYS)
+ Camera3D (fov=40, close-up)
+ DirectionalLight3D (soft shadows)
+ CharacterModel (slow spin 30deg/s)
→ in SubViewportContainer inside PanelContainer
→ PanelContainer has StyleBoxFlat with rounded corners
Result: AAA-feeling character portrait in a 2D panel
```

### Security Camera (In-World Screen)
```
SubViewport (share main world_2d, 320x240)
+ Camera2D (positioned at camera location, no smoothing)
→ get_texture() applied to Sprite2D in world
→ Sprite2D has slight emission shader for "screen glow"
Result: a monitor in the game world showing another area live
```

### Scope / Sniper Zoom
```
SubViewport (256x256, share main world)
+ Camera2D (zoom = Vector2(3, 3), follows crosshair position)
→ in SubViewportContainer with circular mask (shader or TextureRect)
Result: magnified view of where the player aims
```

---

## See also

| Skill | When |
|-------|------|
| `.claude/skills/godot-composition.md` | Composition — containers, anchors, tokens (layout that holds the viewports) |
| `.claude/skills/godot-camera.md` | Camera follow, zoom, limits (cameras inside SubViewports follow the same rules) |
| `.claude/skills/godot-scene.md` | Scene construction — SubViewport nodes are added like any other |

| Recipe | Content |
|--------|---------|
| `recipes/screen_transitions.md` | Transitions between scenes (fade happens on CanvasLayer above everything, including SubViewports) |
| `recipes/shader_cookbook_2d.md` | Shaders for viewport effects (CRT, pixelate, vignette applied to SubViewportContainer) |
