tools: visual-review screenshot pipeline

- AGigLineShotActor (dev-only): walks a fixed camera list in -game mode,
  captures via FScreenshotRequest, quits. Test content only.
- ue_build_review_map.py builds /Game/Test/ReviewMap from current content
  (gray floor, key light, sky, MPFB character, 1 m scale cubes).
- render_screenshots.sh: build map -> run game -> collect PNGs into
  gitignored Screenshots/.
- Screenshots/README.md (committed) describes each capture with approval
  state; AGENTS.md gains the visual-review instructions.
This commit is contained in:
zwitschi committed 2026-11-07 09:52:10 +01:00
1 parent 774d810dab
commit bbb3e147b3
12 files changed
+525 -1

No files matched your search

+1 -1
View File
@@ -12,6 +12,7 @@ Repeatable import/export and processing helpers. Anything that was done twice by
## Helpers
- `render_screenshots.sh` — visual-review pipeline: rebuilds `/Game/Test/ReviewMap` from current content (`ue_build_review_map.py`), runs the game on it in `-game` mode, lets `AGigLineShotActor` (`Game/GigLine/Source/GigLine/`) capture the fixed camera list, and copies the PNGs into the gitignored `Screenshots/`. See `Screenshots/README.md`.
- `check-lfs.sh` — repository guard: fails if `git-lfs` or the `lfs` filter is missing, or if an LFS-tracked file was committed as a plain blob.
- `site_assembly.py` — assembles terrain + street + blockout into one file and raycasts the walking routes for holes and steps.
- `street_mesh.py` — road, kerbs, and sidewalks on the DEM, in the project frame; kerbs flush at the three crossings.
@@ -22,6 +23,5 @@ Repeatable import/export and processing helpers. Anything that was done twice by
## Planned helpers
- Blender export preset runner (named presets, metric scale assertion)
- Blender→Unreal scale test (export a known-size cube, verify 100× conversion)
- OSM extract fetch and manifest writer (records date, bounds, checksum)
- QGIS processing steps as batch scripts, so the geo cleanup is reproducible rather than GUI-only
+136
View File
@@ -0,0 +1,136 @@
# UE-side screenshot renderer for visual review (commandlet, real RHI).
#
# Builds a small review scene in the default world (floor, sky, key light,
# MPFB character, the three scale-test cubes) and renders three fixed camera
# angles synchronously via SceneCapture2D render targets, writing PNGs
# directly into <repo>/Screenshots/ (gitignored).
#
# Invoked by Tools/render_screenshots.sh (UnrealEditor-Cmd -run=pythonscript).
# NOTE: no -nullrhi — rendering needs the real Vulkan RHI. In 5.8 the
# commandlet swallows print(); results go to /tmp/ue-screenshots-result.txt.
# Do NOT load_map here: after a map load the commandlet's spawn context dies
# and every spawn_actor call returns None.
import os
import unreal
RESULT = "/tmp/ue-screenshots-result.txt"
lines = []
def log(msg):
lines.append(str(msg))
REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
OUT_DIR = os.path.join(REPO, "Screenshots")
WORLD = unreal.EditorLevelLibrary.get_editor_world()
def spawn(cls, loc, label=None, scale=None, rot=None):
a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
if a is None:
log(f"spawn {cls.__name__} returned None")
return None
if rot is not None:
a.set_actor_rotation(rot, False)
if scale is not None:
a.set_actor_scale3d(scale)
if label:
a.set_actor_label(label)
return a
def spawn_mesh(mesh, loc, label=None, scale=None):
a = unreal.EditorLevelLibrary.spawn_actor_from_object(mesh, loc)
if a is None:
log(f"spawn mesh {label} returned None")
return None
if scale is not None:
a.set_actor_scale3d(scale)
if label:
a.set_actor_label(label)
return a
# --- stage content -----------------------------------------------------------
try:
floor_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/ScaleTest/ScaleTest_A")
if floor_mesh:
spawn_mesh(floor_mesh, unreal.Vector(450, 450, -50.0), "ReviewFloor", scale=(45.0, 45.0, 0.1))
log("staged: floor (45 m plane from ScaleTest_A)")
dl = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 900), "RenderKeyLight",
rot=unreal.Rotator(pitch=-38.0, yaw=35.0, roll=0.0))
if dl:
log("staged: DirectionalLight")
if spawn(unreal.SkyLight, unreal.Vector(0, 0, 1200), "RenderFillSky"):
log("staged: SkyLight")
if spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "RenderSky"):
log("staged: SkyAtmosphere")
char_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/Character/character")
if char_mesh:
if spawn_mesh(char_mesh, unreal.Vector(0, 0, 0), "ReviewCharacter"):
log("staged: MPFB character at origin")
else:
log("MISSING: /Game/Test/Character/character")
for name, x, y in (("ScaleTest_A", 0.0, 0.0), ("ScaleTest_B", 400.0, 0.0), ("ScaleTest_C", 0.0, 400.0)):
m = unreal.EditorAssetLibrary.load_asset(f"/Game/Test/ScaleTest/{name}")
if m:
spawn_mesh(m, unreal.Vector(x, y, 50.0), f"Cube_{name}")
log(f"staged: {name} at ({x}, {y}, 50)")
except Exception as exc: # noqa
log(f"staging failed: {exc!r}")
# --- render ------------------------------------------------------------------
def render_shot(name, loc, target, fov=70.0):
rt = unreal.RenderingLibrary.create_render_target2d(
WORLD, 1920, 1080, unreal.TextureRenderTargetFormat.RTF_RGBA8_SRGB,
unreal.LinearColor(0.0, 0.0, 0.0, 1.0))
cap = spawn(unreal.SceneCapture2D, loc)
if cap is None:
log(f"{name}: no capture actor")
return
cap.set_actor_rotation(unreal.MathLibrary.find_look_at_rotation(loc, target), False)
comp = cap.get_component_by_class(unreal.SceneCaptureComponent2D)
comp.set_editor_property("texture_target", rt)
comp.set_editor_property("capture_source", unreal.SceneCaptureSource.SCS_FINAL_COLOR_LDR)
comp.set_editor_property("fov_angle", fov)
comp.set_editor_property("capture_every_frame", False)
comp.set_editor_property("capture_on_movement", False)
try:
pps = comp.get_editor_property("post_process_settings")
pps.set_editor_property("auto_exposure_method", unreal.AutomaticExposureMethod.AEM_MANUAL)
pps.set_editor_property("auto_exposure_bias", 1.0)
comp.set_editor_property("post_process_settings", pps)
except Exception as exc: # noqa
log(f"{name}: exposure control skipped ({exc!r})")
comp.capture_scene()
os.makedirs(OUT_DIR, exist_ok=True)
ok = unreal.RenderingLibrary.export_render_target(WORLD, rt, OUT_DIR, name)
out = os.path.join(OUT_DIR, f"{name}.png")
size = os.path.getsize(out) if os.path.exists(out) else 0
log(f"rendered {name}: export ok={ok}, {size} bytes")
unreal.EditorLevelLibrary.destroy_actor(cap)
SHOTS = [
("01-overview", unreal.Vector(900, -1100, 500), unreal.Vector(200, 100, 80), 70.0),
("02-character", unreal.Vector(180, -260, 160), unreal.Vector(0, 0, 85), 55.0),
("03-scale-cubes", unreal.Vector(550, -700, 150), unreal.Vector(200, 200, 50), 60.0),
]
for shot_name, loc, target, fov in SHOTS:
try:
render_shot(shot_name, loc, target, fov)
except Exception as exc: # noqa
log(f"shot {shot_name} failed: {exc!r}")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Render in-game screenshots for visual review into <repo>/Screenshots/ (gitignored).
#
# Usage: Tools/render_screenshots.sh
# Rebuilds /Game/Test/ReviewMap from current content, runs the game on it
# (the GigLineShotActor walks three cameras, captures, quits), then copies
# the PNGs into Screenshots/.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENGINE="${UE_ENGINE_DIR:-/run/media/system/DATA/Engines/UE_5.8.4}"
PROJ="$REPO/Game/GigLine/GigLine.uproject"
echo "== 1/3 build review map =="
cd "$ENGINE"
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \
-run=pythonscript -script="$REPO/Tools/ue_build_review_map.py" \
-nullrhi -unattended -nopause -stdout
cat /tmp/ue-review-map-result.txt
echo "== 2/3 run game on review map =="
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" /Game/Test/ReviewMap \
-game -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
echo "== 3/3 collect screenshots =="
mkdir -p "$REPO/Screenshots"
rm -f "$REPO"/Screenshots/review-*.png
found=0
for src in \
"$REPO/Game/GigLine/Saved/Screenshots"/*/review-*.png \
"$REPO/Game/GigLine/Saved/Screenshots"/*/*/review-*.png \
"$HOME/.config/Epic/GigLine/Saved/Screenshots"/*/review-*.png; do
[ -e "$src" ] || continue
cp -f "$src" "$REPO/Screenshots/"
found=1
done
[ "$found" = "1" ] || { echo "ERROR: no review-*.png found"; exit 1; }
ls -la "$REPO/Screenshots"
+128
View File
@@ -0,0 +1,128 @@
# Builds /Game/Test/ReviewMap — the visual-review scene — and saves it as a
# map asset. Run before the screenshot pass (Tools/render_screenshots.sh does
# this automatically).
#
# Scene: metric floor, key light, fill sky, sky atmosphere, MPFB character,
# the three 1 m scale cubes, and an AGigLineShotActor with the camera list.
#
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/ue_build_review_map.py \
# -nullrhi -unattended -nopause -stdout
#
# Do NOT load_map here: after a map load the commandlet's spawn context dies
# (every spawn_actor call returns None). Everything is spawned into the
# default world and saved as a new map asset.
import os
import unreal
RESULT = "/tmp/ue-review-map-result.txt"
MAP_PATH = "/Game/Test/ReviewMap"
lines = []
def log(msg):
lines.append(str(msg))
def spawn(cls, loc, label=None, scale=None, rot=None):
a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
if a is None:
log(f"spawn {cls.__name__} returned None")
return None
if rot is not None:
a.set_actor_rotation(rot, False)
if scale is not None:
a.set_actor_scale3d(scale)
if label:
a.set_actor_label(label)
return a
# --- scene -------------------------------------------------------------------
try:
# Neutral gray floor material (the scale-test mesh ships red)
floor_mat = None
if not unreal.EditorAssetLibrary.does_asset_exist("/Game/Test/M_ReviewFloor"):
floor_mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"M_ReviewFloor", "/Game/Test", unreal.Material, unreal.MaterialFactoryNew())
n = unreal.MaterialEditingLibrary.create_material_expression(
floor_mat, unreal.MaterialExpressionConstant3Vector)
n.set_editor_property("constant", unreal.LinearColor(0.35, 0.35, 0.35, 1.0))
unreal.MaterialEditingLibrary.connect_material_property(
n, "", unreal.MaterialProperty.MP_BASE_COLOR)
unreal.MaterialEditingLibrary.recompile_material(floor_mat)
unreal.EditorAssetLibrary.save_loaded_asset(floor_mat)
else:
floor_mat = unreal.EditorAssetLibrary.load_asset("/Game/Test/M_ReviewFloor")
floor_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/ScaleTest/ScaleTest_A")
if floor_mesh and floor_mat:
floor = spawn(unreal.StaticMeshActor, unreal.Vector(450, 450, -50.0), "ReviewFloor",
scale=(45.0, 45.0, 0.1))
if floor:
floor.static_mesh_component.set_static_mesh(floor_mesh)
floor.static_mesh_component.set_material(0, floor_mat)
log("staged: floor (45 m plane, gray)")
else:
log("floor spawn failed")
if spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 900), "RenderKeyLight",
rot=unreal.Rotator(pitch=-38.0, yaw=35.0, roll=0.0)):
log("staged: DirectionalLight")
if spawn(unreal.SkyLight, unreal.Vector(0, 0, 1200), "RenderFillSky"):
log("staged: SkyLight")
if spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "RenderSky"):
log("staged: SkyAtmosphere")
char_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/Character/character")
if char_mesh:
ch = spawn(unreal.SkeletalMeshActor, unreal.Vector(0, 0, 0), "ReviewCharacter",
rot=unreal.Rotator(pitch=0.0, yaw=180.0, roll=0.0))
if ch:
ch.skeletal_mesh_component.set_skinned_asset_and_update(char_mesh)
log("staged: MPFB character at origin, facing -Y")
else:
log("MISSING: /Game/Test/Character/character")
for name, x in (("ScaleTest_A", -400.0), ("ScaleTest_B", 0.0), ("ScaleTest_C", 400.0)):
m = unreal.EditorAssetLibrary.load_asset(f"/Game/Test/ScaleTest/{name}")
if m:
c = spawn(unreal.StaticMeshActor, unreal.Vector(x, 600.0, 50.0), f"Cube_{name}")
if c:
c.static_mesh_component.set_static_mesh(m)
log(f"staged: {name} at ({x}, 600, 50)")
shots = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
if shots:
shots.set_editor_property("shot_locations", [
unreal.Vector(900, -1100, 500),
unreal.Vector(200, -300, 160),
unreal.Vector(200, 100, 200),
])
shots.set_editor_property("shot_rotations", [
unreal.MathLibrary.find_look_at_rotation(
unreal.Vector(900, -1100, 500), unreal.Vector(100, 200, 80)),
unreal.MathLibrary.find_look_at_rotation(
unreal.Vector(200, -300, 160), unreal.Vector(0, 0, 85)),
unreal.MathLibrary.find_look_at_rotation(
unreal.Vector(200, 100, 200), unreal.Vector(0, 600, 50)),
])
shots.set_editor_property("shot_names", ["overview", "character", "scale-cubes"])
shots.set_editor_property("quit_when_done", True)
log("staged: ShotSequencer with 3 cameras")
except Exception as exc: # noqa
log(f"staging failed: {exc!r}")
# --- save --------------------------------------------------------------------
try:
world = unreal.EditorLevelLibrary.get_editor_world()
saved = unreal.EditorLoadingAndSavingUtils.save_map(world, MAP_PATH)
log(f"save_map {MAP_PATH}: {saved}")
except Exception as exc: # noqa
log(f"save_map failed: {exc!r}")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")