271 lines
11 KiB
Python
Executable File
271 lines
11 KiB
Python
Executable File
#!/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()
|