#!/usr/bin/env python3 """Paints the high-resolution, hand-painted-style textures for the Brandling Safari 3D props (fences, gate, dock, tent, crates...). Output: assets/models/src/*.png. Same approach as the engine's Vibe-HD-2D/tools/scenery_textures.py: tileable FFT noise mapped through warm colour ramps, no baked lighting (the engine lights everything; ambient occlusion goes into the models' vertex colours). python3 tools/safari_textures.py (needs numpy + Pillow) Run tools/blender_safari.py afterwards to rebuild assets/models/safari.glb. """ import math from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "assets" / "models" / "src" # ----------------------------------------------------------------- helpers -- def fft_noise(h, w, power=2.0, stretch=(1.0, 1.0), seed=0): """Tileable fractal noise in [0, 1] (1/f^power spectrum); stretch[0] > 1 elongates features along y (vertical fibres).""" rng = np.random.default_rng(seed) fy = np.fft.fftfreq(h)[:, None] * stretch[0] fx = np.fft.fftfreq(w)[None, :] * stretch[1] f = np.sqrt(fx * fx + fy * fy) f[0, 0] = 1.0 spectrum = (rng.normal(size=(h, w)) + 1j * rng.normal(size=(h, w))) / f**power spectrum[0, 0] = 0 n = np.real(np.fft.ifft2(spectrum)) n -= n.min() return n / n.max() def lerp_colors(t, stops): """Maps t in [0,1] (array) through a list of (pos, (r,g,b)) stops.""" t = np.clip(t, 0.0, 1.0) out = np.zeros(t.shape + (3,)) pos = [p for p, _ in stops] for c in range(3): out[..., c] = np.interp(t, pos, [col[c] for _, col in stops]) return out def smoothstep(a, b, x): t = np.clip((x - a) / (b - a), 0.0, 1.0) return t * t * (3 - 2 * t) def rgb(col): return Image.fromarray(np.clip(col, 0, 255).astype(np.uint8), "RGB") def wrap_dist(a, b, period): d = np.abs(a - b) % period return np.minimum(d, period - d) # ------------------------------------------------------------------- wood -- WOOD_RAMP = [(0.0, (70, 44, 28)), (0.3, (112, 74, 46)), (0.6, (150, 106, 68)), (0.85, (182, 140, 96)), (1.0, (206, 170, 124))] def grain_field(h, w, seed, lines=18, warp=2.2): """Wood grain along v: thin, gently wavy, tileable dark grain lines in [0, 1] plus the warp noise used to drive them.""" xx = np.arange(w)[None, :] / w wn = fft_noise(h, w, power=2.6, stretch=(4.0, 1.0), seed=seed) phase = (xx * lines + (wn - 0.5) * warp) * math.tau s = np.sin(phase) fine = np.sin(phase * 3.0 + wn * 5.0) return np.clip(smoothstep(0.55, 0.98, s) + 0.45 * smoothstep(0.7, 1.0, fine), 0, 1), wn def add_knots(col, rng, count, h, w, size=(10, 18), dark=(70, 42, 26)): """Small elliptical knots with a few dark rings, wrapped so the tile stays seamless.""" yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) for _ in range(count): cx, cy = rng.uniform(0, w), rng.uniform(0, h) r = rng.uniform(*size) dx, dy = wrap_dist(xx, cx, w), wrap_dist(yy, cy, h) d = np.sqrt((dx / r) ** 2 + (dy / (r * 1.8)) ** 2) ring = smoothstep(0.55, 0.95, np.sin(d * 7.0))[..., None] * smoothstep(2.4, 1.0, d)[..., None] * 0.55 core = smoothstep(0.7, 0.3, d)[..., None] * 0.75 col = col * (1 - ring) + np.array(dark) * ring col = col * (1 - core) + np.array(dark) * core return col def wood(w=512, h=1024, seed=21): """Weathered, sun-warmed timber with the grain along v (posts, rails, beams, deck boards).""" rng = np.random.default_rng(seed) rings, wn = grain_field(h, w, seed, lines=11) fibre = fft_noise(h, w, power=1.0, stretch=(10.0, 1.0), seed=seed + 1) broad = fft_noise(h, w, power=2.2, seed=seed + 2) v = 0.5 + 0.2 * (broad - 0.5) * 2 + 0.16 * (fibre - 0.5) * 2 - 0.26 * rings col = lerp_colors(v, WOOD_RAMP) # Silvery sun-bleached streaks: weathered, not polished. bleach = smoothstep(0.62, 0.85, fft_noise(h, w, power=1.4, stretch=(6.0, 1.0), seed=seed + 3))[..., None] * 0.3 col = col * (1 - bleach) + np.array([176, 160, 136]) * bleach cracks = smoothstep(0.1, 0.0, fibre)[..., None] * 0.7 # long dark checks along the grain col = col * (1 - cracks * 0.6) col = add_knots(col, rng, 2, h, w) return rgb(col) def planks(size=1024, seed=31, boards=4, nails=True): """Boards running along v with dark gaps and nail heads (crates, barrel staves).""" rng = np.random.default_rng(seed) h = w = size rings, wn = grain_field(h, w, seed, lines=16) fibre = fft_noise(h, w, power=1.0, stretch=(10.0, 1.0), seed=seed + 1) broad = fft_noise(h, w, power=2.0, seed=seed + 2) xx = np.arange(w)[None, :].repeat(h, 0).astype(np.float32) bw = w / boards b = (xx // bw).astype(int) lx = xx - b * bw tone = rng.uniform(0.82, 1.12, boards)[b] v = (0.5 + 0.18 * (broad - 0.5) * 2 + 0.16 * (fibre - 0.5) * 2 - 0.24 * rings) * tone col = lerp_colors(v, WOOD_RAMP) edge = np.minimum(lx, bw - lx) gap = smoothstep(7.0, 2.5, edge)[..., None] col = col * (1 - gap) + np.array([34, 22, 14]) * gap rim = smoothstep(22.0, 6.0, edge)[..., None] * (1 - gap) col = col * (1 - 0.28 * rim) col = add_knots(col, rng, 3, h, w, size=(8, 14)) img = rgb(col) if nails: d = ImageDraw.Draw(img) for i in range(boards): for y in (0.08, 0.58): for k in (0.3, 0.7): cx = (i + k) * bw + rng.uniform(-6, 6) cy = (y + rng.uniform(-0.02, 0.02)) * h r = 7 d.ellipse([cx - r - 2, cy - r - 2, cx + r + 2, cy + r + 2], fill=(52, 36, 24)) d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(78, 70, 64)) d.ellipse([cx - r + 3, cy - r + 2, cx + 1, cy - 1], fill=(120, 108, 96)) return img def painted(size=1024, seed=41, paint=(222, 196, 140)): """Sun-faded painted boards with chipped paint showing the wood (sign boards).""" base = np.asarray(planks(size, seed, boards=3, nails=False)).astype(np.float64) brush = fft_noise(size, size, power=1.1, stretch=(1.0, 8.0), seed=seed + 1) # horizontal brush strokes fade = fft_noise(size, size, power=2.0, seed=seed + 2) pcol = np.array(paint) * (0.86 + 0.18 * brush[..., None] + 0.08 * (fade[..., None] - 0.5)) chip_n = fft_noise(size, size, power=1.5, seed=seed + 3) chip = smoothstep(0.66, 0.72, chip_n)[..., None] gaps = (base.mean(axis=2) < 50)[..., None] col = pcol * (1 - chip) + base * chip col = np.where(gaps, base, col) edge = smoothstep(0.62, 0.66, chip_n)[..., None] * (1 - chip) # dark lip around chips col = col * (1 - 0.25 * edge) return rgb(col) def log_bark(w=512, h=1024, seed=51): """Chunky warm bark plates for log pillars, benches and bollards (tiles horizontally).""" n = fft_noise(h, w, power=1.35, stretch=(5.0, 1.0), seed=seed) fine = fft_noise(h, w, power=0.9, stretch=(3.0, 1.0), seed=seed + 1) plates = fft_noise(h, w, power=1.8, stretch=(2.5, 1.0), seed=seed + 2) v = n * 0.55 + fine * 0.2 + plates * 0.25 col = lerp_colors(v, [(0.0, (40, 26, 18)), (0.35, (78, 52, 34)), (0.62, (118, 82, 54)), (1.0, (164, 124, 86))]) crev = smoothstep(0.36, 0.22, n)[..., None] col = col * (1 - 0.7 * crev) lichen = smoothstep(0.8, 0.9, fft_noise(h, w, power=1.2, seed=seed + 3))[..., None] * 0.3 col = col * (1 - lichen) + np.array([128, 140, 92]) * lichen return rgb(col) def end_grain(size=512, seed=61): """Sawn log end: growth rings around the centre, radial checks, bark rim at the edge. Mapped planar onto log caps with the circle touching the borders.""" yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) c = size / 2 dx, dy = (xx - c) / c, (yy - c) / c r = np.sqrt(dx * dx + dy * dy) ang = np.arctan2(dy, dx) wob = fft_noise(size, size, power=2.0, seed=seed) rr = r + (wob - 0.5) * 0.08 rings = 0.5 + 0.5 * np.sin(rr * 46.0) fine = fft_noise(size, size, power=0.9, seed=seed + 1) v = 0.62 - 0.22 * rings ** 2 + 0.18 * (fine - 0.5) - 0.12 * r col = lerp_colors(v, [(0.0, (92, 60, 36)), (0.4, (150, 108, 66)), (0.7, (198, 156, 104)), (1.0, (226, 192, 140))]) rng = np.random.default_rng(seed) for a in rng.uniform(-math.pi, math.pi, 5): # radial drying checks da = np.abs((ang - a + math.pi) % math.tau - math.pi) crack = smoothstep(0.035, 0.0, da * np.maximum(r, 0.05) * 4) * (r > 0.25) * (r < 0.85) col = col * (1 - 0.6 * crack[..., None]) pith = smoothstep(0.06, 0.0, r)[..., None] col = col * (1 - 0.4 * pith) bark = smoothstep(0.86, 0.92, rr)[..., None] col = col * (1 - bark) + np.array([62, 40, 26]) * bark return rgb(col) # ----------------------------------------------------------------- fabric -- def weave(size, seed, strength=0.08): yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) k = size / 4 # 256 threads per tile at 1024 t = 0.5 + 0.25 * np.sin(xx / size * k * math.tau) + 0.25 * np.sin(yy / size * k * math.tau) return 1.0 + (t - 0.5) * strength * 2 def canvas(size=1024, seed=71, color=(224, 206, 166), seams=4, dirt=(150, 124, 86)): """Cotton duck canvas: fine weave, stitched panel seams along v, soft stains.""" blot = fft_noise(size, size, power=2.0, seed=seed) mottle = fft_noise(size, size, power=1.2, seed=seed + 1) col = np.array(color) * (0.9 + 0.12 * blot[..., None] + 0.06 * (mottle[..., None] - 0.5)) col = col * weave(size, seed)[..., None] stain = smoothstep(0.7, 0.86, fft_noise(size, size, power=1.8, seed=seed + 2))[..., None] * 0.35 col = col * (1 - stain) + np.array(dirt) * stain img = rgb(col) d = ImageDraw.Draw(img) sw = size / seams for i in range(seams): x = i * sw d.rectangle([x - 5, 0, x + 5, size], fill=tuple(int(c * 0.8) for c in color)) # overlapped panel edge d.line([(x - 5, 0), (x - 5, size)], fill=tuple(int(c * 0.6) for c in color), width=2) for y in range(0, size, 18): # stitches d.line([(x + 1, y), (x + 1, y + 9)], fill=tuple(int(c * 0.55) for c in color), width=2) return img.filter(ImageFilter.SMOOTH) def canvas_stripes(size=1024, seed=81, colors=((224, 206, 166), (58, 112, 74)), stripes=4): """Awning canvas with broad vertical stripes (stripes along v).""" base = np.asarray(canvas(size, seed, color=(255, 255, 255), seams=1)).astype(np.float64) / 255.0 xx = np.arange(size)[None, :].repeat(size, 0) s = (xx // (size // (stripes * 2))) % 2 col = np.where(s[..., None] == 0, np.array(colors[0]), np.array(colors[1])) * base return rgb(col) def rope(size=256, seed=91): """Twisted three-strand rope, strands winding diagonally (length along v).""" yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) phase = (xx / size * 6 + yy / size * 12) * math.tau strand = 0.5 + 0.5 * np.sin(phase) fibre = fft_noise(size, size, power=0.8, stretch=(1.0, 3.0), seed=seed) v = 0.1 + 0.8 * strand ** 0.45 + 0.2 * (fibre - 0.5) col = lerp_colors(v, [(0.0, (60, 44, 26)), (0.45, (150, 120, 76)), (1.0, (216, 190, 138))]) return rgb(col) def hay(size=1024, seed=101, count=4200): """Packed golden straw: thousands of short strokes, mostly along u, wrapped at ±size so the tile is seamless.""" rng = np.random.default_rng(seed) base = fft_noise(size, size, power=1.6, seed=seed) col = lerp_colors(base, [(0.0, (120, 92, 38)), (0.5, (170, 138, 62)), (1.0, (204, 174, 92))]) img = rgb(col * 0.8) d = ImageDraw.Draw(img) pal = [(150, 116, 48), (188, 154, 70), (214, 184, 98), (232, 206, 128), (130, 104, 46), (176, 160, 84)] for _ in range(count): x, y = rng.uniform(0, size), rng.uniform(0, size) a = rng.normal(0, 0.35) L = rng.uniform(40, 140) c = pal[rng.integers(len(pal))] wdt = int(rng.integers(3, 7)) dx, dy = math.cos(a) * L, math.sin(a) * L for ox in (-size, 0, size): for oy in (-size, 0, size): p0 = (x + ox, y + oy) p1 = (x + ox + dx, y + oy + dy) d.line([p0, p1], fill=tuple(int(k * 0.78) for k in c), width=wdt + 2) d.line([p0, p1], fill=c, width=wdt) return img.filter(ImageFilter.SMOOTH) def stone(size=1024, seed=131): """Warm grey field stone for the boulders at the gate's feet (moss comes from vertex colour).""" base = fft_noise(size, size, power=1.8, seed=seed) fine = fft_noise(size, size, power=0.9, seed=seed + 1) ridge = 1.0 - np.abs(fft_noise(size, size, power=1.5, seed=seed + 2) * 2 - 1) v = np.clip((base * 0.6 + fine * 0.4 - 0.5) * 1.5 + 0.5, 0, 1) col = lerp_colors(v, [(0.0, (66, 60, 56)), (0.4, (118, 110, 100)), (0.7, (158, 148, 132)), (1.0, (202, 190, 170))]) cracks = smoothstep(0.96, 0.99, ridge)[..., None] col = col * (1 - 0.5 * cracks) return rgb(col) # ------------------------------------------------------------------ metal -- def iron(size=512, seed=111): v = fft_noise(size, size, power=1.3, seed=seed) col = lerp_colors(v, [(0.0, (26, 24, 24)), (0.6, (56, 52, 50)), (1.0, (96, 76, 58))]) # rust in highlights rust = smoothstep(0.7, 0.85, fft_noise(size, size, power=1.6, seed=seed + 1))[..., None] * 0.5 col = col * (1 - rust) + np.array([118, 64, 34]) * rust return rgb(col) def lantern_glass(size=256, seed=121): """Emissive lantern pane (mapped 0..1 per pane): hot flame-yellow centre fading to amber at the rim, with faint soot at the top.""" yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) / size dx, dy = (xx - 0.5) / 0.5, (yy - 0.55) / 0.6 r = np.sqrt(dx * dx + dy * dy) flick = fft_noise(size, size, power=2.0, seed=seed) v = np.clip(1.0 - r * 0.85 + (flick - 0.5) * 0.12, 0, 1) col = lerp_colors(v, [(0.0, (150, 60, 14)), (0.4, (232, 128, 38)), (0.75, (255, 196, 92)), (1.0, (255, 240, 190))]) soot = smoothstep(0.82, 1.0, 1 - yy)[..., None] * 0.5 # image row 0 = top of pane (v = 1) col = col * (1 - soot) return rgb(col) def main(): OUT.mkdir(parents=True, exist_ok=True) wood().save(OUT / "wood.png") planks().save(OUT / "planks.png") planks(seed=35, nails=False).save(OUT / "staves.png") painted().save(OUT / "painted_wood.png") log_bark().save(OUT / "log_bark.png") end_grain().save(OUT / "end_grain.png") canvas().save(OUT / "canvas.png") canvas(seed=75, color=(70, 112, 70), dirt=(52, 64, 40)).save(OUT / "canvas_green.png") canvas_stripes().save(OUT / "canvas_stripes.png") rope().save(OUT / "rope.png") hay().save(OUT / "hay.png") iron().save(OUT / "iron.png") stone().save(OUT / "stone.png") lantern_glass().save(OUT / "lantern_glass.png") print("wrote", ", ".join(sorted(p.name for p in OUT.glob("*.png")))) if __name__ == "__main__": main()