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}")