"""Textures for the tidepool, all seamless in both directions so they can tile and slide: sand.png (rippled seabed), caustic-a.png / caustic-b.png (light webs on transparent, two seeds), stone.png (weathered rock with lichen). Claude Opus 5.5, 2026-10-10. python tidepool_tex.py OUTDIR """ import sys from pathlib import Path import numpy as np from PIL import Image S = 512 def fnoise(rng, beta=2.0, size=S): """Periodic fractal noise via a power-law spectrum (FFT noise tiles by construction). Range 0..1.""" f = np.fft.fftfreq(size) fx, fy = np.meshgrid(f, f) r = np.hypot(fx, fy) r[0, 0] = 1 spec = (rng.normal(size=(size, size)) + 1j * rng.normal(size=(size, size))) / r ** (beta / 2) spec[0, 0] = 0 n = np.real(np.fft.ifft2(spec)) n -= n.min() return n / n.max() def torus_voronoi_gap(rng, n_pts, size=S): """d2 - d1 over a torus: Voronoi edge distance that tiles in x and y.""" pts = rng.uniform(0, size, (n_pts, 2)) ys, xs = np.mgrid[0:size, 0:size].astype(np.float32) d1 = np.full((size, size), np.inf, np.float32) d2 = np.full((size, size), np.inf, np.float32) for px, py in pts: dx = np.abs(xs - px) dx = np.minimum(dx, size - dx) dy = np.abs(ys - py) dy = np.minimum(dy, size - dy) d = np.hypot(dx, dy) closer = d < d1 d2 = np.where(closer, d1, np.minimum(d2, d)) d1 = np.where(closer, d, d1) return d2 - d1 def warp(field, rng, amp): """Periodic domain warp (integer pixel shifts from periodic noise).""" size = field.shape[0] ys, xs = np.mgrid[0:size, 0:size] wx = ((fnoise(rng, 3.0) - 0.5) * amp).astype(int) wy = ((fnoise(rng, 3.0) - 0.5) * amp).astype(int) return field[(ys + wy) % size, (xs + wx) % size] def caustic(seed): rng = np.random.default_rng(seed) gap = warp(torus_voronoi_gap(rng, 70), rng, 30) width = 1.0 + 1.6 * fnoise(rng, 2.5) ** 2 line = np.clip((width - gap / 2) / 1.3, 0, 1) glow = np.clip((width * 3.5 - gap / 2) / (width * 3.5), 0, 1) ** 3 * 0.4 a = np.clip((line + glow) * (0.25 + 0.75 * fnoise(rng, 2.0) ** 1.5), 0, 1) rgb = np.empty((S, S, 3), np.float32) rgb[:] = [225, 252, 244] return Image.fromarray(np.dstack([rgb, a * 255]).astype(np.uint8), "RGBA") def sand(seed): rng = np.random.default_rng(seed) ys, xs = np.mgrid[0:S, 0:S].astype(np.float32) phase = (fnoise(rng, 3.2) - 0.5) * 9 ripple = 0.5 + 0.5 * np.sin(2 * np.pi * (xs * 5 / S + ys * 2 / S) + phase) # integer cycles: tiles ripple = ripple ** 1.6 grain = rng.random((S, S)) * 0.5 + fnoise(rng, 1.0) * 0.5 mottle = fnoise(rng, 2.6) base = np.array([196, 178, 140], np.float32) shade = 0.78 + 0.16 * ripple + 0.08 * (grain - 0.5) + 0.12 * (mottle - 0.5) rgb = base * shade[..., None] specks = rng.random((S, S)) > 0.985 rgb[specks] *= 0.6 return Image.fromarray(rgb.clip(0, 255).astype(np.uint8), "RGB") def stone(seed): rng = np.random.default_rng(seed) big = fnoise(rng, 2.4) fine = fnoise(rng, 1.2) cracks = np.clip(1 - torus_voronoi_gap(rng, 24) / 3.0, 0, 1) ** 2 lichen = np.clip((fnoise(rng, 2.8) - 0.62) * 5, 0, 1) base = np.array([176, 172, 160], np.float32) pits = (rng.random((S, S)) > 0.97) * 0.12 + (rng.random((S, S)) > 0.995) * 0.15 shade = 0.74 + 0.45 * (big - 0.5) + 0.22 * (fine - 0.5) - 0.07 * cracks - pits rgb = base * shade[..., None] moss = np.array([118, 128, 84], np.float32) rgb = rgb * (1 - lichen[..., None] * 0.7) + moss * lichen[..., None] * 0.7 return Image.fromarray(rgb.clip(0, 255).astype(np.uint8), "RGB") def main(): out = Path(sys.argv[1]) out.mkdir(parents=True, exist_ok=True) sand(1).save(out / "sand.png") caustic(2).save(out / "caustic-a.png") caustic(3).save(out / "caustic-b.png") stone(4).save(out / "stone.png") # preview: sand under both caustic layers, 2x2 tiled to check seams s = sand(1).convert("RGBA") for c in ("caustic-a.png", "caustic-b.png"): s = Image.alpha_composite(s, Image.open(out / c)) tiled = Image.new("RGBA", (S * 2, S * 2)) for i in range(2): for j in range(2): tiled.paste(s, (i * S, j * S)) tiled.convert("RGB").resize((512, 512)).save(out / "preview-floor.jpg", quality=85) st = Image.open(out / "stone.png") tiled = Image.new("RGB", (S * 2, S * 2)) for i in range(2): for j in range(2): tiled.paste(st, (i * S, j * S)) tiled.resize((512, 512)).save(out / "preview-stone.jpg", quality=85) print("ok") if __name__ == "__main__": main()