--- 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.