#!/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())