afterglow/assets/textures.pyΒΆ
Part of Afterglow.
1"""Procedural placeholder PBR textures for the Afterglow diorama view.
2
3PLACEHOLDER ART: generated in code; swap these for real PNGs later without
4touching gameplay. The logical sim never imports this module: only the 3D view
5layer consumes these maps. Every function returns a tileable RGBA uint8 ndarray
6of shape (H, W, 4), the same contract as
7``simvx.core.port_helpers.procedural_textures``.
8
9Each material exposes three maps:
10 albedo : base colour (RGBA, A=255)
11 normal : tangent-space normal packed to RGB (B up), A=255
12 emissive : emitted light (RGB; A used as emission strength mask)
13
14Tileability: noise is sampled at integer frequencies over the [0, 1) domain so
15the left/right and top/bottom edges line up seamlessly (period == 1.0).
16
17Palettes per world (gameplay-neutral, view-only):
18 glade : warm greens (overgrown light-glade)
19 caverns : blue / violet (deep resonant caves)
20 spire : gold / black (the final ascent)
21"""
22
23from __future__ import annotations
24
25from functools import cache
26
27import numpy as np
28
29from simvx.core.noise import FastNoiseLite, FractalType, NoiseType
30from simvx.core.port_helpers.procedural_textures import solid
31
32RGB = tuple[int, int, int]
33
34# --------------------------------------------------------------------------
35# Per-world palettes. Keys are stable; the view picks by Room.palette / world id.
36# --------------------------------------------------------------------------
37PALETTES: dict[str, dict[str, RGB]] = {
38 # VIBRANT & PUNCHY: each world is saturated with a wide low->high stone
39 # contrast so the relief reads crisply (not a flat grey-green wash) and the
40 # crystal/metal hues stay distinct from the stone. Crystal colours are kept
41 # bright + pure so they bloom cleanly.
42 "glade": {
43 "stone_low": (30, 58, 24), # deep mossy green (clear shadow)
44 "stone_high": (120, 196, 78), # bright lit fern-green (saturated)
45 "crystal": (110, 255, 150), # vivid emerald glow
46 "metal": (96, 140, 96), # mossed bronze-green
47 "accent": (212, 255, 150), # golden-lime highlight
48 },
49 "caverns": {
50 "stone_low": (18, 22, 56), # near-black indigo (deep dark)
51 "stone_high": (78, 96, 200), # saturated electric blue (lit faces)
52 "crystal": (120, 150, 255), # intense glowing cyan-violet
53 "metal": (60, 74, 130), # cold steel-blue
54 "accent": (150, 220, 255), # icy cyan highlight
55 },
56 "spire": {
57 "stone_low": (26, 19, 11), # near-black warm shadow
58 "stone_high": (140, 102, 40), # rich glowing bronze (lit faces)
59 "crystal": (255, 196, 70), # hot molten gold
60 "metal": (205, 162, 60), # polished brass
61 "accent": (255, 232, 140), # bright gold highlight
62 },
63}
64
65DEFAULT_SIZE = 64
66
67
68# --------------------------------------------------------------------------
69# Internal helpers
70# --------------------------------------------------------------------------
71def _tileable_noise(size: int, *, seed: int, period: int, octaves: int, ntype: NoiseType) -> np.ndarray:
72 """Seamless [0, 1] noise field of shape (size, size).
73
74 Sampling at frequency == period / size over pixel coords gives an integer
75 number of feature cells across the image, so opposite edges match.
76 """
77 n = FastNoiseLite(seed=seed, noise_type=ntype, frequency=period / size)
78 n.fractal_type = FractalType.FBM
79 n.fractal_octaves = octaves
80 img = n.get_image(size, size, scale=1.0) # (size, size) in ~[-1, 1]
81 return (img * 0.5 + 0.5).astype(np.float32)
82
83
84def _height_to_normal(height: np.ndarray, strength: float = 2.0) -> np.ndarray:
85 """Pack a height field (HxW, [0,1]) into a tangent-space RGBA normal map."""
86 gx = np.roll(height, -1, axis=1) - np.roll(height, 1, axis=1)
87 gy = np.roll(height, -1, axis=0) - np.roll(height, 1, axis=0)
88 nx = -gx * strength
89 ny = -gy * strength
90 nz = np.ones_like(height)
91 inv = 1.0 / np.sqrt(nx * nx + ny * ny + nz * nz)
92 nx, ny, nz = nx * inv, ny * inv, nz * inv
93 out = np.empty((*height.shape, 4), dtype=np.uint8)
94 out[..., 0] = np.clip((nx * 0.5 + 0.5) * 255, 0, 255)
95 out[..., 1] = np.clip((ny * 0.5 + 0.5) * 255, 0, 255)
96 out[..., 2] = np.clip((nz * 0.5 + 0.5) * 255, 0, 255)
97 out[..., 3] = 255
98 return out
99
100
101def _contrast(field: np.ndarray, power: float) -> np.ndarray:
102 """Re-curve a [0,1] field around its midpoint to widen low->high contrast.
103
104 ``power > 1`` deepens the dark end and brightens the light end about 0.5, so
105 a tinted albedo spans more of its low->high palette range (crisper relief).
106 """
107 return np.clip((field - 0.5) * power + 0.5, 0.0, 1.0)
108
109
110def _tint(height: np.ndarray, low: RGB, high: RGB) -> np.ndarray:
111 """Lerp two colours by a [0,1] field into an opaque RGBA albedo."""
112 lo = np.array(low, dtype=np.float32)
113 hi = np.array(high, dtype=np.float32)
114 rgb = lo[None, None, :] + height[..., None] * (hi - lo)[None, None, :]
115 out = np.empty((*height.shape, 4), dtype=np.uint8)
116 out[..., :3] = np.clip(rgb, 0, 255).astype(np.uint8)
117 out[..., 3] = 255
118 return out
119
120
121# --------------------------------------------------------------------------
122# Material generators (cached: textures are pure functions of their inputs)
123# --------------------------------------------------------------------------
124@cache
125def stone(world: str, size: int = DEFAULT_SIZE) -> dict[str, np.ndarray]:
126 """Rough solid-tile rock for a world. Returns {albedo, normal, emissive}."""
127 pal = PALETTES[world]
128 h = _tileable_noise(size, seed=11 + hash(world) % 64, period=4, octaves=4, ntype=NoiseType.PERLIN)
129 # Push the low->high contrast so lit and shadowed rock read distinctly under
130 # the three-point rig (gamma < 1 lifts midtones; the >1 power deepens it).
131 h = _contrast(h, 1.6)
132 return {
133 "albedo": _tint(h, pal["stone_low"], pal["stone_high"]),
134 "normal": _height_to_normal(h, strength=4.5),
135 "emissive": solid((size, size), (0, 0, 0, 0)),
136 }
137
138
139@cache
140def crystal(world: str, size: int = DEFAULT_SIZE) -> dict[str, np.ndarray]:
141 """Emissive resonance crystal. Cellular facets + bright glow core."""
142 pal = PALETTES[world]
143 n = FastNoiseLite(seed=29 + hash(world) % 64, noise_type=NoiseType.CELLULAR, frequency=3 / size)
144 n.cellular_return_type = "distance2"
145 facets = (n.get_image(size, size, scale=1.0) * 0.5 + 0.5).astype(np.float32)
146 albedo = _tint(facets, pal["crystal"], pal["accent"])
147 glow = np.array(pal["crystal"], dtype=np.float32)
148 emissive = np.empty((size, size, 4), dtype=np.uint8)
149 emissive[..., :3] = np.clip(glow[None, None, :] * (0.4 + 0.6 * facets[..., None]), 0, 255).astype(np.uint8)
150 emissive[..., 3] = np.clip(facets * 255, 0, 255).astype(np.uint8)
151 return {"albedo": albedo, "normal": _height_to_normal(facets, strength=4.0), "emissive": emissive}
152
153
154@cache
155def metal(world: str, size: int = DEFAULT_SIZE) -> dict[str, np.ndarray]:
156 """Brushed metal for light-gates. Anisotropic streaks via value noise."""
157 pal = PALETTES[world]
158 n = FastNoiseLite(seed=53 + hash(world) % 64, noise_type=NoiseType.VALUE, frequency=1 / size)
159 # Stretch vertically for a brushed look: sample at high x freq, low y freq.
160 iy, ix = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
161 streak = n.get_noise_2d_array(ix.ravel() * 12.0, iy.ravel() * 1.0).reshape(size, size)
162 h = (streak * 0.5 + 0.5).astype(np.float32)
163 h = _contrast(h, 1.4)
164 return {
165 "albedo": _tint(h, pal["metal"], pal["accent"]),
166 "normal": _height_to_normal(h, strength=2.2),
167 "emissive": solid((size, size), (0, 0, 0, 0)),
168 }
169
170
171def world_palette(world: str) -> dict[str, RGB]:
172 """Return the colour palette dict for a world id."""
173 return PALETTES[world]
174
175
176def all_materials(world: str, size: int = DEFAULT_SIZE) -> dict[str, dict[str, np.ndarray]]:
177 """Convenience: every material for a world, keyed by material name.
178
179 The diorama only maps ``stone`` onto geometry today and asks for it directly;
180 ``crystal`` and ``metal`` are swap-in placeholders for crystal / gate meshes,
181 so build the whole set only when you actually want all three.
182 """
183 return {"stone": stone(world, size), "crystal": crystal(world, size), "metal": metal(world, size)}
184
185
186if __name__ == "__main__":
187 for w in PALETTES:
188 mats = all_materials(w)
189 print(f"world={w!r} palette_keys={list(world_palette(w))}")
190 for name, maps in mats.items():
191 shapes = {k: (v.shape, str(v.dtype)) for k, v in maps.items()}
192 print(f" {name}: {shapes}")