feat(facades): 0.1.4 — module kit, textured materials, theatre trim (#30)
Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim Co-authored-by: Zwitschi <zwitschi82@gmail.com>
This commit was merged in pull request #30.
This commit is contained in:
1 parent
d6d3d54810
commit
5e2c5e29c0
90 files changed
+2592
-413
No files matched your search
+223
-16
@@ -16,10 +16,13 @@
|
||||
# deterministically instead of stacking duplicates.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import unreal
|
||||
|
||||
MAP = "/Game/Hollywood/DemoMap"
|
||||
# Repository root, so the texture generator can be invoked by path.
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
RESULT = "/tmp/gigline/demo-map.txt"
|
||||
lines = []
|
||||
|
||||
@@ -50,7 +53,46 @@ def spawn(cls, loc, label, rot=None):
|
||||
return actor
|
||||
|
||||
|
||||
def ensure_material(mat_name, spec):
|
||||
TEXTURE_DIR = "/Game/Hollywood/Textures"
|
||||
|
||||
|
||||
def import_texture(png_path, asset_name):
|
||||
"""Import a generated PNG as a Texture2D (idempotent: replaces the asset)."""
|
||||
if not os.path.exists(png_path):
|
||||
log(f"texture missing: {png_path}")
|
||||
return None
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = png_path
|
||||
task.destination_path = TEXTURE_DIR
|
||||
task.destination_name = asset_name
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
ui = unreal.TextureImportUI() if hasattr(unreal, "TextureImportUI") else None
|
||||
if ui:
|
||||
task.options = ui
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
return unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{asset_name}")
|
||||
|
||||
|
||||
def ensure_textures():
|
||||
"""Generate/point at the facade maps and import them. Returns {name: asset}."""
|
||||
out_dir = "/tmp/gigline/textures"
|
||||
gen = os.path.join(ROOT, "Tools", "generate_facade_textures.py")
|
||||
if os.path.exists(gen) and not os.path.exists(os.path.join(out_dir, "T_StoneAshlar_BC.png")):
|
||||
import subprocess
|
||||
subprocess.run([sys.executable, gen, "--out", out_dir, "--size", "512"], check=False)
|
||||
imported = {}
|
||||
for name in ("T_StoneAshlar_BC", "T_StoneAshlar_R", "T_Stucco_BC", "T_Stucco_R",
|
||||
"T_Asphalt_BC", "T_Asphalt_R", "T_Concrete_BC", "T_Concrete_R"):
|
||||
asset = import_texture(os.path.join(out_dir, name + ".png"), name)
|
||||
if asset:
|
||||
imported[name] = asset
|
||||
log(f"textures imported: {len(imported)}/8")
|
||||
return imported
|
||||
|
||||
|
||||
def ensure_material(mat_name, spec, textures=None):
|
||||
"""Create/reuse a look-developed material under /Game/Hollywood/Site.
|
||||
|
||||
Blender's flat diffuse colours do not survive the FBX -> Unreal import (the
|
||||
@@ -65,6 +107,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 +137,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 +171,95 @@ 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
|
||||
|
||||
# If a texture set is supplied for this material, sample it world-aligned and
|
||||
# use it instead of the graph-only coursing: the map carries the coursing and
|
||||
# the grain, so overlaying both would double the pattern.
|
||||
tex = (textures or {}).get(spec.get("texture", ""))
|
||||
if tex:
|
||||
bc = (textures or {}).get(spec.get("texture", "") + "_BC")
|
||||
# world position (cm) -> UV: one texture tile every tile_cm centimetres
|
||||
tile_cm = float(spec.get("tile_cm", 300.0))
|
||||
uv_scale = node(unreal.MaterialExpressionMultiply)
|
||||
link(wp, uv_scale, "A")
|
||||
link(scalar(1.0 / tile_cm), uv_scale, "B")
|
||||
mask_xy = node(unreal.MaterialExpressionComponentMask)
|
||||
for pin in ("R", "G"):
|
||||
try:
|
||||
mask_xy.set_editor_property(pin, True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
mask_xy.set_editor_property("B", False)
|
||||
mask_xy.set_editor_property("A", False)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
link(uv_scale, mask_xy)
|
||||
if bc:
|
||||
sample = node(unreal.MaterialExpressionTextureSample)
|
||||
sample.set_editor_property("texture", bc)
|
||||
link(mask_xy, sample, "UVs")
|
||||
lib.connect_material_property(sample, "RGB",
|
||||
unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
base_color_expr = sample
|
||||
r_map = (textures or {}).get(spec.get("texture", "") + "_R")
|
||||
if r_map:
|
||||
r_sample = node(unreal.MaterialExpressionTextureSample)
|
||||
r_sample.set_editor_property("texture", r_map)
|
||||
link(mask_xy, r_sample, "UVs")
|
||||
lib.connect_material_property(r_sample, "R",
|
||||
unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
|
||||
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)
|
||||
@@ -269,19 +417,47 @@ def build():
|
||||
# Look-developed surface materials (see ensure_material for the graph).
|
||||
MATERIAL_SPECS = {
|
||||
"M_Asphalt": dict(dark=(0.040, 0.040, 0.044), bright=(0.072, 0.072, 0.078),
|
||||
roughness=0.92, specular=0.22, noise_scale=0.006),
|
||||
roughness=0.92, specular=0.22, noise_scale=0.006,
|
||||
texture="T_Asphalt", tile_cm=900.0),
|
||||
"M_Concrete": dict(dark=(0.44, 0.42, 0.39), bright=(0.56, 0.54, 0.50),
|
||||
roughness=0.88, specular=0.28, noise_scale=0.004),
|
||||
roughness=0.88, specular=0.28, noise_scale=0.004,
|
||||
texture="T_Concrete", tile_cm=250.0),
|
||||
"M_KerbConcrete": dict(dark=(0.47, 0.45, 0.42), bright=(0.60, 0.58, 0.54),
|
||||
roughness=0.85, specular=0.30, noise_scale=0.008),
|
||||
"M_Ground": dict(dark=(0.22, 0.20, 0.17), bright=(0.33, 0.30, 0.26),
|
||||
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,
|
||||
texture="T_StoneAshlar", tile_cm=440.0),
|
||||
# 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, texture="T_Stucco", tile_cm=260.0),
|
||||
# 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",
|
||||
@@ -292,9 +468,10 @@ def build():
|
||||
"Kerb_North": "M_KerbConcrete",
|
||||
"Crossing_Markings": "M_RoadPaint",
|
||||
}
|
||||
textures = ensure_textures()
|
||||
built = {}
|
||||
for mat_name, spec in MATERIAL_SPECS.items():
|
||||
built[mat_name] = ensure_material(mat_name, spec)
|
||||
built[mat_name] = ensure_material(mat_name, spec, textures)
|
||||
|
||||
imported = 0
|
||||
for name, mat_name in SURFACE_MATERIALS.items():
|
||||
@@ -337,15 +514,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 ---------------------------------------
|
||||
|
||||
Reference in new issue
Block a user