---
name: viewport
display_name: Viewport Control
description: Control the Unreal Editor level viewport — camera type/position, view mode, FOV, exposure, layout, and rendering settings (ViewportService). Use when the user asks to move the editor camera, change the view mode (Lit/Unlit/Wireframe), set FOV/exposure, switch viewport layout, or frame the level for a screenshot.
vibeue_classes:
  - ViewportService
unreal_classes:
  - LevelEditorSubsystem
keywords:
  - viewport
  - camera
  - perspective
  - orthographic
  - top
  - front
  - wireframe
  - fov
  - exposure
  - quad
  - layout
  - game view
  - realtime
---

# Viewport Control Skill

## Methods

| Method | Description |
|--------|-------------|
| `get_viewport_info()` | Get full viewport state (type, camera, FOV, exposure, layout) |
| `set_viewport_type(type)` | Switch perspective/orthographic views |
| `get_viewport_type()` | Get current view type as string |
| `set_view_mode(mode)` | Switch rendering mode (lit, wireframe, unlit, etc.) |
| `get_view_mode()` | Get current rendering mode |
| `set_fov(degrees)` | Set field of view (5-170, default 90) |
| `get_fov()` | Get current FOV |
| `set_near_clip_plane(distance)` | Set near clipping plane (-1 = engine default) |
| `set_far_clip_plane(distance)` | Set far clipping plane (0 = infinity) |
| `set_exposure(fixed, ev100)` | Set fixed/auto exposure with EV100 value |
| `set_exposure_game_settings()` | Reset exposure to auto (game settings) |
| `set_game_view(enable)` | Toggle Game View (hides editor icons) |
| `set_allow_cinematic_control(allow)` | Allow Sequencer to control viewport camera |
| `set_realtime(enable)` | Toggle realtime rendering |
| `set_camera_speed(speed)` | Set camera movement speed (1-8) |
| `set_viewport_layout(name)` | Switch viewport layout (single, quad, etc.) |
| `get_viewport_layout()` | Get current layout name |
| `capture_scene(location, rotation, width, height, out_png, ortho_width=0, fov=90, manual_ev100=0)` | Synchronous SceneCapture2D → PNG that works while the editor is backgrounded (see below) |

---

> 🔀 **Camera get/set moved to the engine.** `ViewportService` no longer has
> `set_camera_location` / `set_camera_rotation`. Reading and positioning the viewport camera is
> now Unreal 5.8's native **`EditorAppToolset`** (`GetCameraTransform` / `SetCameraTransform`, plus
> `FocusOnActors` to frame actors) — reach it with `call_tool` (run `describe_toolset` on
> `EditorAppToolset` for exact action names/params). `ViewportService` still owns view type, view
> mode, FOV, clip planes, exposure, game view, layout, realtime, and camera speed.

## Critical Rules

### ⚠️ A black capture usually means no lighting, not a broken mesh

The most common cause of an all-black (or blown-out white) capture is the **level**, not the camera
and not the asset you just built. Two things to check before you start debugging geometry:

1. **A level made with `new_level()` has no lighting at all** — no sun, no sky, no atmosphere. Use
   `new_level_from_template(path, "/Engine/Maps/Templates/Template_Default")` instead (see the
   `level-actors` skill). If you must light an empty level by hand you need a DirectionalLight that
   genuinely points **down** — `unreal.Rotator(roll=0, pitch=-48, yaw=125)`, and note the argument
   order is (roll, pitch, yaw), so a positionally-passed pitch aims the sun at the sky and the scene
   stays black — plus a SkyLight with `real_time_capture`, and a SkyAtmosphere.
2. **Auto-exposure lies about colour.** With adaptation on, a dark material renders near-white and a
   bright one renders grey, so you cannot judge a material from a capture. Add an unbound
   PostProcessVolume with `auto_exposure_method = AEM_MANUAL`; then `auto_exposure_bias` is a
   brightness stop — **higher is brighter**, and the usable window is narrow (0 was pitch black and
   13.5 blown out in one scene; 7.5 was correct). Tune it by capturing, not by reasoning.

Keep the volume out of frame (move it far below the set) — an unbound volume still applies globally
but its bounds box draws in the editor viewport.

### ⚠️ Valid View Types for `set_viewport_type`

Only these exact strings are accepted (case-insensitive):

| String | View |
|--------|------|
| `"perspective"` | Perspective (3D) |
| `"top"` | Top-down (XY) |
| `"bottom"` | Bottom-up (-XY) |
| `"left"` | Left side (-XZ) |
| `"right"` | Right side (XZ) |
| `"front"` | Front (-YZ) |
| `"back"` | Back (YZ) |

```python
# ✅ CORRECT
unreal.ViewportService.set_viewport_type("perspective")
unreal.ViewportService.set_viewport_type("top")

# ❌ WRONG — these are not valid strings
unreal.ViewportService.set_viewport_type("ortho")
unreal.ViewportService.set_viewport_type("iso")
```

### ⚠️ Valid View Modes for `set_view_mode`

| String | Rendering Mode |
|--------|----------------|
| `"lit"` | Default fully lit |
| `"unlit"` | No lighting |
| `"wireframe"` | Wireframe overlay |
| `"detaillighting"` | Detail lighting |
| `"lightingonly"` | Lighting only (no textures) |
| `"lightcomplexity"` | Light complexity heatmap |
| `"shadercomplexity"` | Shader complexity heatmap |
| `"pathtracing"` | Path tracing |
| `"clay"` | Clay rendering |

### ⚠️ Valid Layout Names for `set_viewport_layout`

| Name | Layout |
|------|--------|
| `"OnePane"` | Single viewport (default) |
| `"TwoPanesHoriz"` | Two side-by-side |
| `"TwoPanesVert"` | Two stacked |
| `"ThreePanesLeft"` | Large left + 2 right |
| `"ThreePanesRight"` | Large right + 2 left |
| `"ThreePanesTop"` | Large top + 2 bottom |
| `"ThreePanesBottom"` | Large bottom + 2 top |
| `"FourPanesLeft"` | Large left + 3 |
| `"FourPanesRight"` | Large right + 3 |
| `"FourPanesTop"` | Large top + 3 |
| `"FourPanesBottom"` | Large bottom + 3 |
| `"FourPanes2x2"` | Quad view (2x2 grid) |
| `"Quad"` | Alias for FourPanes2x2 |

### 🚨 FOV Only Works in Perspective Mode

Setting FOV has no effect in orthographic views. Always switch to perspective first:

```python
unreal.ViewportService.set_viewport_type("perspective")
unreal.ViewportService.set_fov(75.0)
```

### 🚨 Realtime Mode vs On-Demand

When `set_realtime(False)`, the viewport only repaints on interaction. All ViewportService methods force a redraw after changes, so this is transparent to Python callers — but be aware users won't see continuous animation/particles until realtime is re-enabled.

> ⚠️ **Multi-pane read-back caveat:** `get_viewport_info().is_realtime` reflects the *active* pane.
> In a multi-pane layout (e.g. `FourPanes2x2`) where the active pane isn't the primary, `set_realtime(True)`
> can succeed yet `is_realtime` reads back `False`. Verify realtime in `OnePane` layout, or don't rely on
> the read-back to gate logic when in a split layout. (Other fields like view type / FOV / camera read back
> correctly across layouts.)

### 📸 Capture that works while backgrounded

`CaptureViewport` / `CaptureEditorImage` (and `HighResShot`) return a **stale frame** when the
MCP-driven editor is minimised or backgrounded — the level viewport does not pump frames. The
reliable path is **`ViewportService.capture_scene(...)`**: it spawns a transient `ASceneCapture2D`,
renders one frame synchronously with `CaptureScene()`, reads the pixels back and writes the PNG
in-call. No viewport pumping, no `editor_invalidate_viewports()` + sleep dance.

```python
import unreal

# Perspective grab of a spot in the level (works even when the editor window is hidden)
res = unreal.ViewportService.capture_scene(
    unreal.Vector(1200, -800, 900),          # camera location
    unreal.Rotator(pitch=-20, yaw=45, roll=0),  # kwargs — Rotator positional order is (roll,pitch,yaw)
    1280, 720,                                # width, height (px)
    "C:/temp/shot.png",                       # absolute path, or relative → Saved/VibeUE/Captures
    manual_ev100=-6.0)                        # 0 = auto (black when hidden); negative EV = brighter fixed exposure — see below
if res.b_success:
    print("wrote", res.output_path, res.file_size_bytes, "bytes")
else:
    print("failed:", res.error_message)
```

**Baked-in facts (measured — these are why the hand-rolled recipe existed):**

- **Alpha.** The capture source is `SCS_FINAL_COLOR_LDR`, which yields **alpha 255**. `SCS_BASE_COLOR`
  writes **alpha 0**, producing a PNG that renders as a **blank white page** in most viewers even
  though the file is a full-size capture. `capture_scene` forces the exported alpha to 255 regardless,
  so its PNGs are always opaque. (Check the file *size*, not just the preview — a truly empty PNG is a
  few KB.)
- **Format.** The render target is `RTF_RGBA8`. The engine default (`RTF_RGBA16f`, a float format)
  writes non-PNG bytes; `capture_scene` never uses it.
- **Exposure.** Leave `manual_ev100=0` (default) to keep the engine's **automatic** exposure — fine
  for a foreground / PIE window, but **black** when the editor is hidden (no converged eye adaptation).
  A backgrounded editor needs a **fixed** exposure: pass `manual_ev100` != 0 and `capture_scene`
  switches to a manual exposure **decoupled from the physical camera**, where the value is the exposure
  **TARGET in EV100** — exactly like a camera's metered EV: **higher EV100 is DARKER** (it assumes a
  brighter scene and stops down), lower/negative is brighter. Measured on a daylit scene from a
  backgrounded editor (mean RGB luminance): `0` (auto) → 16.4, `+4` → 4.3, `-4` → 43.8, `-6` → 63.4,
  `-8` → 86.0. **A daylit backgrounded scene reads well around `-6` to `-8`; try `-4` first for bright
  scenes.** Tune by capturing.
  > Note: the engine reads its underlying `AutoExposureBias` as a `pow(2,bias)` exposure *compensation*
  > (so its docs say "positive brightens"), but in this decoupled-Manual SceneCapture path the observed,
  > repeatable behaviour is the opposite — higher = darker — so treat `manual_ev100` as a camera EV
  > target. Do **not** carry over any old "bias ~10–14 to brighten" advice; that was from a build that
  > left the physical camera coupled, and here a large positive value is near-black.
- **Cleanup.** The transient capture actor and its render target are destroyed inside the call — no
  stray actors are left in the level.

**Minimap / top-down map recipe (orthographic, north-up):**

```python
import unreal
# Ortho capture centred over the map, looking straight down, north-up.
# ortho_width = the world-space span you want to cover (e.g. the landscape size in uu).
map_size = 500000.0  # uu across
res = unreal.ViewportService.capture_scene(
    unreal.Vector(0, 0, 100000),                    # high above centre; Z only needs to clear geometry
    unreal.Rotator(pitch=-90, yaw=-90, roll=0),     # pitch=-90 looks down; yaw=-90 makes +X point up = north-up
    2048, 2048,
    "minimap.png",                                  # → Saved/VibeUE/Captures/minimap.png
    ortho_width=map_size,
    manual_ev100=-6.0)   # fixed exposure for a hidden editor; LOWER (more negative) = brighter
```

- `ortho_width > 0` selects **orthographic** projection (and `fov` is ignored); `ortho_width == 0`
  (default) is **perspective** using `fov`.
- The rotation `(pitch=-90, yaw=-90, roll=0)` yields a **north-up** image (world +X points to the top).
  This is a convention of the caller's rotation, not something the API forces — pass a different yaw to
  rotate the map.
- For flat map labels, a `TextRenderActor` at rotation `(roll=0, pitch=90, yaw=90)` reads correctly in
  this north-up view.

---

## Workflows

### Inspect Current Viewport State

```python
import unreal
info = unreal.ViewportService.get_viewport_info()
view_mode = unreal.ViewportService.get_view_mode()
print(f"Type: {info.viewport_type}")
print(f"View Mode: {view_mode}")
print(f"Location: {info.location}")
print(f"Rotation: {info.rotation}")
print(f"FOV: {info.fov}")
print(f"Layout: {info.layout}")
print(f"Realtime: {info.is_realtime}")
print(f"Game View: {info.is_game_view}")
```

### Cycle Through Orthographic Views

```python
import unreal
for view in ["top", "front", "right", "perspective"]:
    unreal.ViewportService.set_viewport_type(view)
```

### Switch to Quad View and Back

```python
import unreal
# Switch to quad (2x2) layout
unreal.ViewportService.set_viewport_layout("Quad")

# Switch back to single pane
unreal.ViewportService.set_viewport_layout("OnePane")
```

### Set Up Architecture Review Camera

```python
import unreal
# Perspective with narrow FOV for minimal distortion
unreal.ViewportService.set_viewport_type("perspective")
unreal.ViewportService.set_fov(60.0)
unreal.ViewportService.set_game_view(True)
unreal.ViewportService.set_exposure(True, 1.0)  # Fixed exposure
unreal.ViewportService.set_realtime(True)
```

### Wireframe Debugging

```python
import unreal
# Switch to wireframe to inspect mesh topology
unreal.ViewportService.set_view_mode("wireframe")

# Return to normal lit view
unreal.ViewportService.set_view_mode("lit")
```

### Position Camera at Specific Location

Camera placement now lives in the engine **`EditorAppToolset`** (`SetCameraTransform`), called via
`call_tool`. From Python you can still drive it through the editor subsystem:

```python
import unreal
# Move camera to a bird's-eye view (location, rotation)
# ⚠️ Rotator positional order is (Roll, Pitch, Yaw) — always use kwargs
unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).set_level_viewport_camera_info(
    unreal.Vector(0, 0, 5000), unreal.Rotator(pitch=-90))
```

To frame specific actors instead of guessing coordinates, prefer the engine
`EditorAppToolset.FocusOnActors` action (or load the `level-actors` skill for
`ActorService.get_actor_view_camera`, which auto-computes a framing position from bounds).

### Configure Clipping Planes

```python
import unreal
# Tighten near clip for close-up work
unreal.ViewportService.set_near_clip_plane(1.0)

# Set far clip to avoid rendering distant objects
unreal.ViewportService.set_far_clip_plane(50000.0)

# Reset to defaults
unreal.ViewportService.set_near_clip_plane(-1)  # Engine default
unreal.ViewportService.set_far_clip_plane(0)     # Infinity
```

### Exposure Control

```python
import unreal
# Fix exposure for consistent lighting review
unreal.ViewportService.set_exposure(True, 1.0)

# Adjust to bright/dark scene
unreal.ViewportService.set_exposure(True, -2.0)  # Darker
unreal.ViewportService.set_exposure(True, 4.0)   # Brighter

# Return to auto exposure (game settings)
unreal.ViewportService.set_exposure_game_settings()
```

## Sample scripts (run via `execute_python_code`)

- **`scripts/set_camera.txt`** — position the editor camera and set the view mode.

## Additional gotchas

- `CaptureViewport` through `call_tool` needs BOTH optional params present (`{"captureTransform":{}, "annotations":{}}`); `captureTransform` is ignored (it always frames the world origin), and the ~1 MB base64 result must be decoded to a file before it can be viewed.
- Outside PIE the MCP-driven viewport does not pump frames, so captures are stale — static actors render but dynamic FX are absent and `HighResShot` never fires. Call `PerformanceService.set_background_throttling(False)` first; asset thumbnails still render fine while backgrounded.
- `CaptureEditorImage` returns the previous frame unless you call `editor_invalidate_viewports()` and wait briefly first.
- Use `capture_scene` for a real off-screen render instead of hand-building a `SceneCapture2D`; a backgrounded editor has no converged eye adaptation, so fix the exposure (`AEM_MANUAL`, bias ~10-14) or the shot comes out black.
- Never A/B a capture against one taken earlier in the session — streaming, LOD and lighting drift between moments; after a material change, recompile and discard one warm-up capture or the thumbnail shows the default checker.
