One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
227 lines
7.9 KiB
Python
227 lines
7.9 KiB
Python
#!/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/gen/generate_facade_textures.py # write to /tmp/gigline/textures
|
|
python3 Tools/gen/generate_facade_textures.py --size 1024
|
|
python3 Tools/gen/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())
|