feat(materials): generated facade texture maps, sampled world-aligned

New Tools/generate_facade_textures.py: deterministic, stdlib-only PNG generator for
four facade surface sets (ashlar stone, stucco, asphalt, concrete), each as base
colour (_BC) plus roughness (_R). Pure zlib encoder and hash-based value noise, so
re-running produces byte-identical files (verified by md5sum); ~14 s for eight 512²
maps.

ue_build_demo_map.py imports them into /Game/Hollywood/Textures and samples them
world-aligned: WorldPosition (cm) * 1/tile_cm -> ComponentMask(R,G) -> TextureSample
UVs, with per-material tile sizes (stone 440, stucco 260, asphalt 900, concrete
250 cm). A textured material skips the graph coursing deliberately - overlaying both
would double the course pattern.

Textures imported 8/8, hero 4/4 slots assigned, map saved; screenshots re-rendered.
This commit is contained in:
zwitschi committed 2026-11-08 19:04:03 +01:00
1 parent 0f928531df
commit 73e535a24b
39 files changed
+401 -48

No files matched your search

+90 -6
View File
@@ -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
@@ -165,6 +207,44 @@ def ensure_material(mat_name, spec):
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.
@@ -337,9 +417,11 @@ 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),
@@ -352,12 +434,13 @@ def build():
"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),
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),
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),
@@ -385,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():