feat(materials): masonry coursing, glass/banner materials, per-slot assignment, telephoto elevations

0.1.4 materials pass plus the camera fix levelling exposed.

Materials (Tools/ue_build_demo_map.py):
- ensure_material() gains optional masonry coursing (world-space course height,
  joint band, joint tint) and roughness variation, so facades read as coursed
  stone/stucco instead of one flat specular plane.
- WorldPosition is in centimetres: course heights in metres are scaled by 100
  (the first attempt divided by 0.55 and the coursing was invisible).
- connect_material_expressions returns False rather than raising, so every link
  now goes through a link() helper that logs failures; UE has no
  MaterialExpressionLess, so the joint mask is Frac -> Divide -> OneMinus -> Clamp.
- New materials: M_FacadeStoneShadow, M_FacadeGlass (specular 0.62 ~ IOR 1.5),
  M_FacadeBanner.
- Material assignment is now per SLOT keyed on the imported slot material name.
  It was slot-0 only, which on the hero is the banner, so stone, shadow stone and
  glazing still wore flat Blender materials. Hero now 4/4 slots.

Cameras (GigLineShotActor + ue_build_review_map):
- Optional per-shot ShotFieldOfView; the shot tuples take a 4th element.
- Elevations use a 42-55 deg lens from across the street: with the cameras
  levelled (0.1.2 follow-up) the building was small in frame, and tilting to fill
  it is what breaks the verticals. hero-facade-front sky 39% -> 20%,
  demo-spawn-view 23% -> 16%.

Screenshots re-rendered for the whole set.
This commit is contained in:
zwitschi committed 2026-11-08 19:00:41 +01:00
1 parent d6d3d54810
commit 9a3784683f
32 files changed
+243 -57

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
#include "GigLineShotActor.h"
#include "Camera/CameraActor.h"
#include "Camera/CameraComponent.h"
#include "Engine/World.h"
#include "GameFramework/PlayerController.h"
#include "UnrealClient.h"
@@ -83,6 +84,15 @@ void AGigLineShotActor::NextShot()
Params.ObjectFlags |= RF_Transient;
CurrentCamera = GetWorld()->SpawnActor<ACameraActor>(
ACameraActor::StaticClass(), ShotLocations[CurrentShot], ShotRotations[CurrentShot], Params);
if (ShotFieldOfView.IsValidIndex(CurrentShot) && ShotFieldOfView[CurrentShot] > 0.0f)
{
if (UCameraComponent* Cam = CurrentCamera->GetCameraComponent())
{
// Long lens for elevations: fills the frame from across the street
// without pitching the camera, so verticals stay parallel.
Cam->SetFieldOfView(ShotFieldOfView[CurrentShot]);
}
}
PlayerController->SetViewTarget(CurrentCamera);
SettleCountdown = SettleSeconds;
@@ -36,6 +36,16 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FString> ShotNames;
/**
* Optional per-shot field of view (degrees), same index as ShotLocations.
* Zero or missing entries keep the engine default (90 deg). Elevation shots
* taken from across the street need a long lens: a level camera 45 m out with
* the default FOV leaves the building small in frame, and tilting it up to fill
* the frame is what breaks the verticals.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<float> ShotFieldOfView;
/** Seconds to let the view target settle before the capture. Resolution comes from the runner's -ResX/-ResY. */
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float SettleSeconds = 0.35f;
+25
View File
@@ -525,3 +525,28 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
usage and a **traps** table; `setup.md` marks the fresh-checkout build verified and
documents the two optional API keys; architecture 07 + combined doc corrected the
build-output path.
## 2026-11-09 — 0.1.4 materials + architecture-camera pass
- **Material graphs**: `ensure_material()` now supports masonry coursing
(`courses_m`) and roughness variation. **UE's `WorldPosition` is in
CENTIMETRES**, so a course height in metres must be ×100 — the first attempt
divided by 0.55 and produced coursing 100× too fine to see.
- **`connect_material_expressions` returns False instead of raising**, so an
unknown pin name leaves an input unconnected and the material silently compiles
with a default (a mask of 0 is invisible). All links now go through a `link()`
helper that logs failures. There is also **no `MaterialExpressionLess`**; use
`Frac → Divide → OneMinus → Clamp` for a soft threshold band, or
`MaterialExpressionStep`/`SmoothStep` if a hard one is wanted.
- **Material assignment was slot-0-only**, which on the hero is the *banner* — the
stone, shadowed stone and glazing kept their flat Blender materials. Assignment
is now per slot, keyed on the imported slot material's name
(`glass`/`banner`/`stoneshadow`/`stone`/`roof`/`wall`). Hero: 4/4 slots.
- New materials: `M_FacadeStoneShadow` (same coursing, a stop darker),
`M_FacadeGlass` (roughness 0.14, specular 0.62 ≈ IOR 1.5 expressed without a
refraction pass), `M_FacadeBanner` (the one saturated surface).
- **Camera FOV**: levelling the elevation cameras left the building small in frame
(39 % sky). Added optional per-shot `ShotFieldOfView` to `AGigLineShotActor` and
a 4th element on the shot tuples; a long lens (42–55°) fills the frame from
across the street while the camera stays level, so verticals stay parallel.
`review-hero-facade-front` sky 39 → 20 %, `demo-spawn-view` 23 → 16 %.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+135 -12
View File
@@ -65,6 +65,13 @@ def ensure_material(mat_name, spec):
spec keys: dark, bright (RGB tuples), roughness, specular, noise_scale
(world units per noise cycle; smaller = larger features).
Optional `courses_m` adds **masonry coursing**: horizontal joint lines at a
given course height in world metres, plus a per-course horizontal offset so
the vertical joints stagger like real ashlar. Optional `joint_tint` is the
colour the joints darken toward, and `roughness_variation` lerps roughness
between `roughness` and a rougher value using the same noise, so the surface
does not read as one flat specular value.
"""
path = f"/Game/Hollywood/Site/{mat_name}"
if unreal.EditorAssetLibrary.does_asset_exist(path):
@@ -88,6 +95,20 @@ def ensure_material(mat_name, spec):
n.set_editor_property("r", float(value))
return n
def link(src, dst, pin=""):
"""Connect, and say so when it fails.
`connect_material_expressions` returns False rather than raising, so an
unknown pin name silently leaves an input unconnected and the material
compiles with a default value — a coursing mask of 0 is invisible, which
is exactly how the first attempt failed.
"""
ok = lib.connect_material_expressions(src, "", dst, pin)
if not ok:
log(f"material {mat_name}: FAILED to connect {src.__class__.__name__} "
f"-> {dst.__class__.__name__}.{pin or '(default)'}")
return ok
# world position -> noise -> lerp(dark, bright) -> base colour
wp = node(unreal.MaterialExpressionWorldPosition)
scale = scalar(spec.get("noise_scale", 0.004))
@@ -108,10 +129,57 @@ def ensure_material(mat_name, spec):
lib.connect_material_expressions(c3(spec["dark"]), "", lerp, "A")
lib.connect_material_expressions(c3(spec["bright"]), "", lerp, "B")
lib.connect_material_expressions(noise, "", lerp, "Alpha")
lib.connect_material_property(lerp, "", unreal.MaterialProperty.MP_BASE_COLOR)
lib.connect_material_property(
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
# --- optional masonry coursing -----------------------------------------
# A world-space course mask: the fractional part of Z / course height is a
# sawtooth per course; zeroing it gives a joint line every course. The row
# index shifts each course sideways by half a brick, so vertical joints
# stagger. All of it is world-space, so every building shares the coursing
# without needing its own UVs.
base_color_expr = lerp
if spec.get("courses_m"):
# WorldPosition is in CENTIMETRES, so a course height given in metres must
# be scaled by 100 here; getting this wrong makes the coursing 100x too
# fine to see at all (the first attempt did exactly that).
course_cm = float(spec["courses_m"]) * 100.0
row = node(unreal.MaterialExpressionDivide)
link(wp, row, "A")
link(c3((course_cm, course_cm, course_cm)), row, "B")
saw = node(unreal.MaterialExpressionFrac)
link(row, saw)
# Joint mask without a comparison node (UE has no Less): 1 - saw/joint is
# 1 at the course boundary and 0 once past the joint width; clamped to 0..1
# so it is a linear band rather than a hard step.
div = node(unreal.MaterialExpressionDivide)
link(saw, div, "A")
# joint_width is a fraction of one course (0.11 ~= a 6 cm joint on a
# 55 cm course), which is the space `saw` lives in after the divide.
link(scalar(spec.get("joint_width", 0.11)), div, "B")
inv = node(unreal.MaterialExpressionOneMinus)
link(div, inv)
joint = node(unreal.MaterialExpressionClamp)
link(inv, joint)
tinted = node(unreal.MaterialExpressionLinearInterpolate)
link(lerp, tinted, "A")
link(c3(spec.get("joint_tint", (0.34, 0.31, 0.27))), tinted, "B")
link(joint, tinted, "Alpha")
base_color_expr = tinted
lib.connect_material_property(base_color_expr, "", unreal.MaterialProperty.MP_BASE_COLOR)
# Roughness: lerp(base, base + variation, noise) so the highlight breaks up.
variation = float(spec.get("roughness_variation", 0.0))
if variation > 0.0:
rough_lerp = node(unreal.MaterialExpressionLinearInterpolate)
lib.connect_material_expressions(
scalar(spec.get("roughness", 0.85)), "", rough_lerp, "A")
lib.connect_material_expressions(
scalar(min(1.0, spec.get("roughness", 0.85) + variation)), "", rough_lerp, "B")
lib.connect_material_expressions(noise, "", rough_lerp, "Alpha")
lib.connect_material_property(rough_lerp, "", unreal.MaterialProperty.MP_ROUGHNESS)
else:
lib.connect_material_property(
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
try:
lib.connect_material_property(
scalar(spec.get("specular", 0.3)), "", unreal.MaterialProperty.MP_SPECULAR)
@@ -278,10 +346,35 @@ def build():
roughness=0.98, specular=0.15, noise_scale=0.003),
"M_RoadPaint": dict(dark=(0.74, 0.74, 0.72), bright=(0.86, 0.86, 0.84),
roughness=0.62, specular=0.35, noise_scale=0.02),
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.74, 0.70, 0.62),
roughness=0.72, specular=0.32, noise_scale=0.005),
"M_Stucco": dict(dark=(0.52, 0.48, 0.42), bright=(0.66, 0.62, 0.55),
roughness=0.86, specular=0.25, noise_scale=0.004),
# Ashlar coursing at 0.55 m (a 0.10 m band joint), the reading the 2009
# facade photograph gives for the hero's stone; roughness breaks up so the
# wall is not one specular plane.
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.76, 0.72, 0.64),
roughness=0.70, specular=0.32, noise_scale=0.005,
courses_m=0.55, joint_width=0.11,
joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12),
# Rendered/stucco frontage: no coursing, but a fine tooth and a wider
# roughness range than before, because flat stucco is what read as plastic.
"M_Stucco": dict(dark=(0.50, 0.46, 0.40), bright=(0.68, 0.64, 0.57),
roughness=0.76, specular=0.25, noise_scale=0.0025,
roughness_variation=0.20),
# Stone in shadow: the hero's recessed wall behind the colonnade. Same
# coursing as the face, a stop darker, so depth reads without a light.
"M_FacadeStoneShadow": dict(dark=(0.40, 0.37, 0.32), bright=(0.52, 0.49, 0.43),
roughness=0.78, specular=0.26, noise_scale=0.005,
courses_m=0.55, joint_width=0.11,
joint_tint=(0.28, 0.26, 0.23),
roughness_variation=0.10),
# Glazing: low roughness, strong specular. A plausible IOR for glass sits
# near 1.5, which UE expresses through specular ~0.5-0.6 on an opaque
# surface (there is no refraction pass at blockout fidelity).
"M_FacadeGlass": dict(dark=(0.030, 0.045, 0.055), bright=(0.075, 0.095, 0.110),
roughness=0.14, specular=0.62, noise_scale=0.01,
roughness_variation=0.06),
# The JIMMY KIMMEL banners: the one saturated surface on the elevation.
"M_FacadeBanner": dict(dark=(0.32, 0.05, 0.07), bright=(0.55, 0.10, 0.12),
roughness=0.72, specular=0.22, noise_scale=0.02,
roughness_variation=0.12),
}
SURFACE_MATERIALS = {
"Terrain_DEM": "M_Ground",
@@ -337,15 +430,45 @@ def build():
LABEL_MATERIALS = {"Hero_Facade": "M_FacadeStone",
"North_TCL_Chinese": "M_Stucco",
"North_Madame_Tussauds": "M_Stucco"}
# Assign per SLOT, by what the slot's imported material is called, not just
# slot 0: the hero mesh's slot 0 is the banner, so the stone, the shadowed
# stone and the glazing were all still wearing their flat Blender materials.
SLOT_RULES = (
("glass", "M_FacadeGlass"),
("banner", "M_FacadeBanner"),
("stoneshadow", "M_FacadeStoneShadow"),
("stone", "M_FacadeStone"),
("roof", "M_Concrete"),
("wall", "M_Stucco"),
)
def slot_material_name(slot_mesh_material):
if not slot_mesh_material:
return None
key = slot_mesh_material.get_name().lower()
for needle, target in SLOT_RULES:
if needle in key:
return target
return None
assigned = 0
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
mat_name = LABEL_MATERIALS.get(actor.get_actor_label())
if not mat_name or mat_name not in built:
continue
fallback = LABEL_MATERIALS.get(actor.get_actor_label())
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh:
comp.set_material(0, built[mat_name])
if not comp or not comp.static_mesh:
continue
slots = comp.static_mesh.get_editor_property("static_materials")
touched = 0
for index, slot in enumerate(slots):
mesh_mat = slot.get_editor_property("material_interface")
target = slot_material_name(mesh_mat) or fallback
if target and target in built:
comp.set_material(index, built[target])
touched += 1
if touched:
assigned += 1
log(f"materials {actor.get_actor_label()}: {touched}/{len(slots)} slots")
log(f"assigned look materials to {assigned} facade/background actors")
# --- surrounding block frontage ---------------------------------------
+12 -3
View File
@@ -240,9 +240,9 @@ try:
# verticals stay parallel. The old shot looked up ~4 deg from 4.2 m at a
# 7.6 m target, which converged the columns — the one thing an
# architectural elevation must not do (archviz: "straight verticals").
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760)),
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760), 42.0),
# oblique from the north-west, reading the colonnade in depth
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)),
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700), 55.0),
]
# --- review shot cameras + save -------------------------------------------
# Order matters: the review scene is staged in the default (unsaved) world.
@@ -256,6 +256,12 @@ try:
"shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
shots.set_editor_property("shot_names", [c[0] for c in cams])
# Optional 4th element: field of view in degrees. 0 keeps the default 90.
# Elevations shot from across the street use a long lens so the building
# fills the frame without pitching the camera (archviz: straight
# verticals, telephoto for elevations).
shots.set_editor_property(
"shot_field_of_view", [float(c[3]) if len(c) > 3 else 0.0 for c in cams])
shots.set_editor_property("quit_when_done", True)
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
@@ -282,7 +288,7 @@ try:
# the colonnade's underside.
# Level horizon for the same reason: a block elevation is read by its
# verticals, and a pitched camera makes them lean.
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620)),
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
# Street level, from the north sidewalk: the old camera sat at Y -400,
# inside the facade body (which spans Y -1457..-200), so it looked at
# the unlit back face. Y -2600 is on the sidewalk in front of the lit
@@ -309,6 +315,9 @@ try:
seq.set_editor_property("shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in demo_shots])
seq.set_editor_property("shot_names", [c[0] for c in demo_shots])
seq.set_editor_property(
"shot_field_of_view",
[float(c[3]) if len(c) > 3 else 0.0 for c in demo_shots])
# quit after the demo shots so a dedicated DemoMap capture run
# terminates cleanly (render_screenshots.sh runs both maps).
seq.set_editor_property("quit_when_done", True)