AI agent skill · by @kevinpbuckley
Viewport
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.
Archived 2026-10-23 · Source updated 2026-10-07 · License: MIT. Check the original repository for the latest version.
SKILL.md instructions
# 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.