#!/usr/bin/env python3 """Paints the high-resolution, hand-painted-style textures used by the 3D scenery models (trees, bushes, rocks). Output: assets/models/src/*.png. These are deliberately *not* pixel art: in HD-2D, environment props are real 3D models with higher-resolution painterly textures, while characters and tiny props stay as pixel-art billboards. python3 tools/scenery_textures.py (needs numpy + Pillow) Run tools/blender_scenery.py afterwards to rebuild the .glb models. """ import math import random 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" 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).""" 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 leaf_polygon(cx, cy, angle, length, width, steps=10): pts_l, pts_r = [], [] ca, sa = math.cos(angle), math.sin(angle) for i in range(steps + 1): t = i / steps x = t * length y = width * math.sin(math.pi * t) ** 0.75 * (1.0 - 0.25 * t) for side, pts in ((1, pts_l), (-1, pts_r)): px, py = x, side * y pts.append((cx + px * ca - py * sa, cy + px * sa + py * ca)) return pts_l, pts_r def shade(col, k): return tuple(int(max(0, min(255, c * k))) for c in col) def draw_leaf(draw, cx, cy, angle, length, width, base): left, right = leaf_polygon(cx, cy, angle, length, width) tip = left[-1] # Two halves with different brightness read as a folded leaf. draw.polygon(left + right[::-1], fill=base + (255,)) draw.polygon(left + [tip, (cx, cy)], fill=shade(base, 1.18) + (255,)) draw.line([(cx, cy), tip], fill=shade(base, 0.72) + (255,), width=max(1, int(width * 0.12))) LEAF_PALETTE = [(34, 82, 30), (52, 112, 34), (78, 146, 40), (112, 176, 52), (150, 204, 74)] def leaf_cluster(size=1024, seed=1, count=320, palette=LEAF_PALETTE): """A round clump of overlapping leaves on transparency (canopy card).""" rng = random.Random(seed) S = size * 2 img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) d = ImageDraw.Draw(img) c = S / 2 leaves = [] for _ in range(count): r = (rng.random() ** 0.6) * S * 0.40 a = rng.random() * math.tau leaves.append((r, a)) leaves.sort(key=lambda la: -la[0] + rng.random() * S * 0.1) # outer first, centre on top for r, a in leaves: x, y = c + r * math.cos(a), c + r * math.sin(a) ang = a + rng.uniform(-0.9, 0.9) if r > S * 0.05 else rng.random() * math.tau length = S * rng.uniform(0.05, 0.085) depth = 1.0 - r / (S * 0.42) # centre leaves are on top and lighter idx = min(len(palette) - 1, max(0, int((depth * 0.7 + rng.random() * 0.5) * len(palette)))) draw_leaf(d, x, y, ang, length, length * 0.42, palette[idx]) img = img.resize((size, size), Image.LANCZOS) a = np.array(img) a[..., 3] = np.where(a[..., 3] > 110, 255, 0) # hard cutout return Image.fromarray(a) def leaf_dense(size=1024, seed=2, palette=LEAF_PALETTE): """Opaque tileable leaf mass for canopy cores (fills gaps between cards).""" rng = random.Random(seed) S = size * 2 img = Image.new("RGBA", (S, S), shade(palette[0], 0.8) + (255,)) d = ImageDraw.Draw(img) for _ in range(900): x, y = rng.random() * S, rng.random() * S ang = rng.random() * math.tau length = S * rng.uniform(0.04, 0.065) base = palette[min(len(palette) - 1, int(rng.random() ** 1.4 * len(palette)))] for ox in (-S, 0, S): # wrap so the texture tiles for oy in (-S, 0, S): if -length < x + ox < S + length and -length < y + oy < S + length: draw_leaf(d, x + ox, y + oy, ang, length, length * 0.42, base) return img.resize((size, size), Image.LANCZOS) NEEDLE_PALETTE = [(24, 70, 52), (36, 94, 66), (54, 122, 80), (84, 152, 96)] def pine_frond(w=1024, h=512, seed=3): """A drooping pine branch: stem along +u from the left edge, needles on both sides.""" rng = random.Random(seed) W, H = w * 2, h * 2 img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(img) cy = H * 0.5 def stem_y(x): return cy + (x / W) ** 2 * H * 0.08 for layer in range(3): for i in range(520): t = rng.random() x = t * W * 0.96 y = stem_y(x) env = math.sin(math.pi * min(1.0, t * 1.1)) ** 0.6 * (1.0 - 0.45 * t) # leaf-shaped outline side = rng.choice((-1, 1)) length = H * 0.5 * env * rng.uniform(0.6, 1.0) ang = side * rng.uniform(0.55, 1.05) - 0.25 * side * t ex, ey = x + math.cos(ang) * length * 0.55, y + math.sin(ang) * length col = NEEDLE_PALETTE[min(3, layer + int(rng.random() * 2))] d.line([(x, y), (ex, ey)], fill=col + (255,), width=int(rng.uniform(6, 10))) d.line([(x, y), (ex, ey)], fill=shade(col, 1.25) + (255,), width=2) d.line([(x0, stem_y(x0)) for x0 in range(0, int(W * 0.97), 8)], fill=(58, 70, 44, 255), width=4) img = img.resize((w, h), Image.LANCZOS) a = np.array(img) a[..., 3] = np.where(a[..., 3] > 110, 255, 0) return Image.fromarray(a) def bark(w=512, h=1024, seed=4): n = fft_noise(h, w, power=1.25, stretch=(6.0, 1.0), seed=seed) # long vertical fibres fine = fft_noise(h, w, power=0.9, stretch=(3.0, 1.0), seed=seed + 1) v = n * 0.75 + fine * 0.25 col = lerp_colors(v, [(0.0, (34, 22, 16)), (0.35, (66, 44, 30)), (0.6, (104, 72, 48)), (1.0, (150, 112, 78))]) crev = np.clip((0.33 - n) * 6.0, 0, 1)[..., None] col = col * (1 - 0.65 * crev) return Image.fromarray(col.astype(np.uint8), "RGB") def rock(size=1024, seed=5): 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.6 + 0.5, 0, 1) col = lerp_colors(v, [(0.0, (52, 52, 56)), (0.4, (104, 102, 98)), (0.7, (148, 144, 134)), (1.0, (196, 190, 176))]) cracks = np.clip((ridge - 0.965) * 18.0, 0, 1)[..., None] col = col * (1 - 0.55 * cracks) lichen = np.clip((fft_noise(size, size, power=1.4, seed=seed + 3) - 0.72) * 8.0, 0, 1)[..., None] col = col * (1 - lichen * 0.35) + np.array([150, 160, 110]) * lichen * 0.35 return Image.fromarray(col.astype(np.uint8), "RGB") def voronoi(size, count, seed): """Tileable Voronoi: (F1, F2, cell id) per pixel.""" rng = np.random.default_rng(seed) pts = rng.random((count, 2)) * size yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) f1 = np.full((size, size), 1e9, np.float32) f2 = np.full((size, size), 1e9, np.float32) ids = np.zeros((size, size), np.int32) for i, (px, py) in enumerate(pts): for ox in (-size, 0, size): for oy in (-size, 0, size): d = np.hypot(xx - (px + ox), yy - (py + oy)) closer = d < f1 f2 = np.where(closer, f1, np.minimum(f2, d)) ids = np.where(closer, i, ids) f1 = np.where(closer, d, f1) return f1, f2, ids def cobble(size=1024, seed=8, count=70): """Large worn flagstones with dark mortar (bridge deck).""" f1, f2, ids = voronoi(size, count, seed) rng = np.random.default_rng(seed + 1) tone = rng.uniform(0.75, 1.15, count)[ids] edge = f2 - f1 cell = size / math.sqrt(count) dome = np.clip(1.0 - f1 / (cell * 0.75), 0, 1) ** 0.5 # stones bulge slightly grain = fft_noise(size, size, power=1.1, seed=seed + 2) v = np.clip((0.45 + 0.35 * dome + 0.35 * (grain - 0.5)) * tone, 0, 1) col = lerp_colors(v, [(0.0, (58, 56, 54)), (0.45, (112, 108, 100)), (0.75, (150, 144, 132)), (1.0, (186, 178, 162))]) mortar = np.clip(1.0 - edge / 9.0, 0, 1)[..., None] col = col * (1 - mortar) + np.array([40, 38, 34]) * mortar bevel = np.clip(1.0 - (edge - 9.0) / 14.0, 0, 1)[..., None] * (1 - mortar) # darker stone rims col = col * (1 - 0.3 * bevel) moss_mask = np.clip((fft_noise(size, size, power=1.6, seed=seed + 3) - 0.68) * 6.0, 0, 1)[..., None] * mortar col = col * (1 - moss_mask) + np.array([70, 104, 40]) * moss_mask return Image.fromarray(col.astype(np.uint8), "RGB") def stone_blocks(size=1024, seed=9, rows=4, cols=3): """Staggered dressed stone blocks with mortar (bridge walls, pillars).""" rng = np.random.default_rng(seed) yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) row_h = size / rows r = (yy // row_h).astype(int) offset = (r % 2) * 0.5 * (size / cols) + rng.uniform(-20, 20, rows)[r] bw = size / cols xs = (xx + offset) % size c = (xs // bw).astype(int) lx, ly = xs - c * bw, yy - r * row_h edge = np.minimum(np.minimum(lx, bw - lx), np.minimum(ly, row_h - ly)) tone = rng.uniform(0.8, 1.15, (rows, cols + 1))[r, c] grain = fft_noise(size, size, power=1.2, seed=seed + 1) v = np.clip((0.55 + 0.4 * (grain - 0.5)) * tone, 0, 1) col = lerp_colors(v, [(0.0, (60, 58, 56)), (0.5, (126, 120, 110)), (1.0, (182, 174, 158))]) chip = np.clip(1.0 - edge / 16.0, 0, 1)[..., None] col = col * (1 - 0.35 * chip) mortar = (edge < 5.0)[..., None] col = np.where(mortar, np.array([46, 44, 40]), col) return Image.fromarray(col.astype(np.uint8), "RGB") def iron(size=512, seed=10): v = fft_noise(size, size, power=1.3, seed=seed) col = lerp_colors(v, [(0.0, (22, 20, 20)), (0.6, (48, 44, 42)), (1.0, (86, 70, 56))]) # rust in highlights return Image.fromarray(col.astype(np.uint8), "RGB") def coals(size=256, seed=11): f1, f2, ids = voronoi(size, 40, seed) edge = np.clip((f2 - f1) / 10.0, 0, 1) hot = np.random.default_rng(seed).uniform(0.4, 1.0, 40)[ids] * edge col = lerp_colors(hot, [(0.0, (20, 8, 4)), (0.5, (160, 50, 10)), (1.0, (255, 170, 60))]) return Image.fromarray(col.astype(np.uint8), "RGB") def main(): OUT.mkdir(parents=True, exist_ok=True) leaf_cluster(seed=1).save(OUT / "leaves_oak.png") leaf_cluster(seed=7, count=360, palette=[(30, 84, 40), (44, 116, 46), (70, 150, 52), (104, 182, 62), (150, 210, 86)]).save(OUT / "leaves_bush.png") leaf_dense().save(OUT / "leaves_dense.png") pine_frond().save(OUT / "pine_frond.png") bark().save(OUT / "bark.png") rock().save(OUT / "rock.png") cobble().save(OUT / "cobble.png") stone_blocks().save(OUT / "stone_blocks.png") iron().save(OUT / "iron.png") coals().save(OUT / "coals.png") print("wrote", ", ".join(sorted(p.name for p in OUT.glob("*.png")))) if __name__ == "__main__": main()