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:
1 parent
0f928531df
commit
73e535a24b
39 files changed
+401
-48
No files matched your search
@@ -16,6 +16,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob |
|
||||
| `generate_facade_textures.py` | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. |
|
||||
| `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. |
|
||||
| `fbx_export_preset.py` | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. |
|
||||
| `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing |
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the facade texture maps the demo materials sample.
|
||||
|
||||
The materials started as procedural graphs only (noise + coursing), which reads
|
||||
as tinted stone but has no grain. These maps add the grain: an ashlar stone
|
||||
course pattern, a rendered stucco tooth, asphalt aggregate and concrete pores,
|
||||
each as base colour plus a roughness map so the specular breaks up.
|
||||
|
||||
Standard library only — the PNG encoder is the same pure-zlib writer used for the
|
||||
MPFB skin, so this runs on the host without numpy or Pillow.
|
||||
|
||||
python3 Tools/generate_facade_textures.py # write to /tmp/gigline/textures
|
||||
python3 Tools/generate_facade_textures.py --size 1024
|
||||
python3 Tools/generate_facade_textures.py --out Art/blender/textures
|
||||
|
||||
Output names match the Unreal import destination `/Game/Hollywood/Textures`:
|
||||
|
||||
T_StoneAshlar_BC.png T_StoneAshlar_R.png
|
||||
T_Stucco_BC.png T_Stucco_R.png
|
||||
T_Asphalt_BC.png T_Asphalt_R.png
|
||||
T_Concrete_BC.png T_Concrete_R.png
|
||||
|
||||
Determinism: every pattern is a deterministic hash of the pixel coordinate, so a
|
||||
re-run reproduces the same files byte for byte (no RNG state, no seam drift).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
# --- PNG -------------------------------------------------------------------
|
||||
|
||||
|
||||
def write_png(path, width, height, rgb_rows):
|
||||
"""Write 8-bit RGB rows (bytes) to a PNG. `rgb_rows` is one bytes per row."""
|
||||
raw = bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0) # filter type 0
|
||||
raw += rgb_rows[y]
|
||||
|
||||
def chunk(tag, data):
|
||||
c = struct.pack(">I", len(data)) + tag + data
|
||||
c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||
return c
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # colour type 2 = RGB
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
|
||||
png += chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(png)
|
||||
return path
|
||||
|
||||
|
||||
# --- deterministic value noise --------------------------------------------
|
||||
|
||||
|
||||
def _hash2(x, y, seed=0):
|
||||
"""Deterministic 0..1 hash of an integer cell. No RNG, fully reproducible."""
|
||||
n = (x * 374761393 + y * 668265263 + seed * 1442695040888963407) & 0xFFFFFFFF
|
||||
n = (n ^ (n >> 13)) * 1274126177 & 0xFFFFFFFF
|
||||
return ((n ^ (n >> 16)) & 0xFFFF) / 65535.0
|
||||
|
||||
|
||||
def value_noise(x, y, seed=0):
|
||||
"""Smooth bilinear value noise at continuous (x, y) in cell units."""
|
||||
xi, yi = math.floor(x), math.floor(y)
|
||||
fx, fy = x - xi, y - yi
|
||||
# smoothstep weights so cells blend without visible lattice lines
|
||||
sx = fx * fx * (3.0 - 2.0 * fx)
|
||||
sy = fy * fy * (3.0 - 2.0 * fy)
|
||||
n00 = _hash2(xi, yi, seed)
|
||||
n10 = _hash2(xi + 1, yi, seed)
|
||||
n01 = _hash2(xi, yi + 1, seed)
|
||||
n11 = _hash2(xi + 1, yi + 1, seed)
|
||||
return (n00 * (1 - sx) + n10 * sx) * (1 - sy) + (n01 * (1 - sx) + n11 * sx) * sy
|
||||
|
||||
|
||||
def fractal(x, y, octaves=4, seed=0):
|
||||
"""Fractal sum of value noise, normalised to 0..1."""
|
||||
total, amp, freq, norm = 0.0, 1.0, 1.0, 0.0
|
||||
for _ in range(octaves):
|
||||
total += value_noise(x * freq, y * freq, seed) * amp
|
||||
norm += amp
|
||||
amp *= 0.5
|
||||
freq *= 2.0
|
||||
return total / norm
|
||||
|
||||
|
||||
def clamp8(value):
|
||||
return max(0, min(255, int(value * 255.0 + 0.5)))
|
||||
|
||||
|
||||
# --- patterns --------------------------------------------------------------
|
||||
# Each returns (base_rgb_function, roughness_function) over u, v in 0..1.
|
||||
|
||||
|
||||
def stone_ashlar(courses=8, per_course=4):
|
||||
"""Coursed ashlar: horizontal beds, staggered vertical joints, stone grain."""
|
||||
course_h = 1.0 / courses
|
||||
|
||||
def block_uv(u, v):
|
||||
row = int(v / course_h)
|
||||
shift = 0.5 * (row % 2) / per_course # stagger alternate courses
|
||||
col = int(((u + shift) % 1.0) * per_course)
|
||||
return row, col, (v % course_h) / course_h, ((u + shift) % 1.0) * per_course - col
|
||||
|
||||
def base(u, v, px, py):
|
||||
row, col, fj, fi = block_uv(u, v)
|
||||
tone = 0.62 + 0.10 * _hash2(row, col, 11) # each block a little different
|
||||
grain = 0.06 * (fractal(px * 0.15, py * 0.15, 5, 3) - 0.5) * 2.0
|
||||
# joint: a dark band along the bed and the head of each block
|
||||
joint = 0.0
|
||||
if fj < 0.055 or fi < 0.055:
|
||||
joint = -0.22
|
||||
value = tone + grain + joint
|
||||
return (value * 1.02, value, value * 0.94) # faintly warm stone
|
||||
|
||||
def rough(u, v, px, py):
|
||||
row, col, fj, fi = block_uv(u, v)
|
||||
base_r = 0.66 + 0.10 * _hash2(row, col, 29)
|
||||
if fj < 0.055 or fi < 0.055:
|
||||
base_r = 0.86 # joints are matte
|
||||
return base_r + 0.06 * fractal(px * 0.2, py * 0.2, 3, 7)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
def stucco():
|
||||
def base(u, v, px, py):
|
||||
blot = fractal(px * 0.03, py * 0.03, 4, 21)
|
||||
tooth = fractal(px * 0.9, py * 0.9, 3, 22)
|
||||
value = 0.58 + 0.12 * (blot - 0.5) + 0.05 * (tooth - 0.5)
|
||||
return (value * 1.03, value, value * 0.96)
|
||||
|
||||
def rough(u, v, px, py):
|
||||
return 0.74 + 0.18 * fractal(px * 0.06, py * 0.06, 3, 31)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
def asphalt():
|
||||
def base(u, v, px, py):
|
||||
agg = fractal(px * 1.4, py * 1.4, 3, 41) # fine aggregate
|
||||
patch = fractal(px * 0.02, py * 0.02, 3, 42) # repair patches
|
||||
value = 0.055 + 0.05 * (patch - 0.5) + 0.06 * (agg - 0.5)
|
||||
return (value, value, value * 1.05) # cool bitumen
|
||||
|
||||
def rough(u, v, px, py):
|
||||
return 0.90 + 0.08 * fractal(px * 0.5, py * 0.5, 2, 43)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
def concrete():
|
||||
def base(u, v, px, py):
|
||||
pores = fractal(px * 0.8, py * 0.8, 3, 51)
|
||||
tone = fractal(px * 0.04, py * 0.04, 4, 52)
|
||||
value = 0.50 + 0.10 * (tone - 0.5) + 0.05 * (pores - 0.5)
|
||||
return (value * 1.01, value, value * 0.97)
|
||||
|
||||
def rough(u, v, px, py):
|
||||
return 0.84 + 0.10 * fractal(px * 0.15, py * 0.15, 3, 53)
|
||||
|
||||
return base, rough
|
||||
|
||||
|
||||
TEXTURES = {
|
||||
"T_StoneAshlar": stone_ashlar(),
|
||||
"T_Stucco": stucco(),
|
||||
"T_Asphalt": asphalt(),
|
||||
"T_Concrete": concrete(),
|
||||
}
|
||||
|
||||
|
||||
def render(name, pattern, size):
|
||||
"""Render one texture; returns {suffix: path} for _BC and _R."""
|
||||
base_fn, rough_fn = pattern
|
||||
paths = {}
|
||||
for suffix, fn in (("BC", base_fn), ("R", rough_fn)):
|
||||
# Wrap in a function so the inner loop stays tight.
|
||||
rows = []
|
||||
for y in range(size):
|
||||
v = y / size
|
||||
row = bytearray()
|
||||
for x in range(size):
|
||||
u = x / size
|
||||
value = fn(u, v, x / size * size / 64.0, y / size * size / 64.0)
|
||||
if isinstance(value, tuple):
|
||||
row += bytes((clamp8(value[0]), clamp8(value[1]), clamp8(value[2])))
|
||||
else:
|
||||
grey = clamp8(value)
|
||||
row += bytes((grey, grey, grey))
|
||||
rows.append(bytes(row))
|
||||
path = os.path.join(OUT_DIR, f"{name}_{suffix}.png")
|
||||
write_png(path, size, size, rows)
|
||||
paths[suffix] = path
|
||||
return paths
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
global OUT_DIR
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default="/tmp/gigline/textures",
|
||||
help="output directory (default /tmp/gigline/textures)")
|
||||
ap.add_argument("--size", type=int, default=512, help="edge length in pixels")
|
||||
args = ap.parse_args(argv)
|
||||
OUT_DIR = args.out
|
||||
|
||||
print(f"writing {len(TEXTURES) * 2} maps at {args.size}² to {OUT_DIR}")
|
||||
for name, pattern in TEXTURES.items():
|
||||
paths = render(name, pattern, args.size)
|
||||
for suffix, path in paths.items():
|
||||
print(f" {os.path.basename(path):28} {os.path.getsize(path):>8} bytes")
|
||||
return 0
|
||||
|
||||
|
||||
OUT_DIR = "/tmp/gigline/textures"
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user