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:
1 parent
d6d3d54810
commit
9a3784683f
32 files changed
+243
-57
No files matched your search
+135
-12
@@ -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 ---------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user