nodes/colour.py

Part of Procedural Planets.

  1"""Biome colour ramp generator: port of Lague's ColourGenerator.
  2
  3Builds a 100×N RGBA texture (numpy array) where:
  4    - Y = biome index (0..N-1)
  5    - X = elevation percent (0..1) within that biome
  6    - Left half (x in 0..49) = ocean gradient, right half (x in 50..99) = land gradient
  7
  8The texture is sampled at runtime by the Material's albedo map, with UV
  9coordinates supplied by the per-vertex (biome%, elevation%) we wrote in
 10TerrainFace.build_face_mesh.
 11"""
 12
 13from __future__ import annotations
 14
 15from dataclasses import dataclass, field
 16
 17import numpy as np
 18
 19from simvx.core.noise import FastNoiseLite, NoiseType
 20
 21# Module-level biome-noise instance: re-used to break the latitude band so
 22# biomes don't appear in perfect horizontal strips. Cheap (single 3-D Perlin
 23# eval per vertex) and matches upstream's optional ColourSettings.noise.
 24_BIOME_NOISE = FastNoiseLite(seed=1337, noise_type=NoiseType.PERLIN, frequency=0.55)
 25
 26TEXTURE_HALF = 50  # ocean (0..49) and land (50..99): total width 100
 27TEXTURE_WIDTH = TEXTURE_HALF * 2
 28
 29
 30@dataclass
 31class GradientStop:
 32    """One colour stop in a gradient: t is in [0, 1]."""
 33
 34    t: float
 35    rgb: tuple[float, float, float]
 36
 37
 38@dataclass
 39class Biome:
 40    """A single biome row: start_height triggers it, gradient paints it."""
 41
 42    start_height: float = 0.0
 43    tint: tuple[float, float, float] = (1.0, 1.0, 1.0)
 44    tint_percent: float = 0.0
 45    gradient: list[GradientStop] = field(default_factory=list)
 46
 47
 48@dataclass
 49class BiomeColourSettings:
 50    biomes: list[Biome] = field(default_factory=list)
 51    blend_amount: float = 0.0
 52    noise_strength: float = 0.0
 53    noise_offset: float = 0.0
 54
 55
 56@dataclass
 57class ColourSettings:
 58    ocean_gradient: list[GradientStop] = field(default_factory=list)
 59    biome: BiomeColourSettings = field(default_factory=BiomeColourSettings)
 60
 61
 62# ---------------------------------------------------------------------------
 63# Gradient sampling: vectorised numpy
 64# ---------------------------------------------------------------------------
 65
 66
 67def _sample_gradient(stops: list[GradientStop], ts: np.ndarray) -> np.ndarray:
 68    """Sample a gradient at parameter `ts` (each in [0,1]). Returns (N, 3) RGB."""
 69    if not stops:
 70        return np.tile(np.array([1.0, 1.0, 1.0], dtype=np.float32), (ts.shape[0], 1))
 71    if len(stops) == 1:
 72        return np.tile(np.asarray(stops[0].rgb, dtype=np.float32), (ts.shape[0], 1))
 73
 74    sorted_stops = sorted(stops, key=lambda s: s.t)
 75    times = np.array([s.t for s in sorted_stops], dtype=np.float32)
 76    cols = np.array([s.rgb for s in sorted_stops], dtype=np.float32)
 77
 78    out = np.empty((ts.shape[0], 3), dtype=np.float32)
 79    # Clamp before the first stop and after the last.
 80    before = ts <= times[0]
 81    after = ts >= times[-1]
 82    out[before] = cols[0]
 83    out[after] = cols[-1]
 84    middle = ~before & ~after
 85    if middle.any():
 86        idx = np.searchsorted(times, ts[middle]) - 1
 87        idx = np.clip(idx, 0, len(times) - 2)
 88        t0 = times[idx]
 89        t1 = times[idx + 1]
 90        c0 = cols[idx]
 91        c1 = cols[idx + 1]
 92        frac = ((ts[middle] - t0) / np.maximum(t1 - t0, 1e-6)).reshape(-1, 1).astype(np.float32)
 93        out[middle] = c0 * (1.0 - frac) + c1 * frac
 94    return out
 95
 96
 97def build_ramp_texture(settings: ColourSettings) -> np.ndarray:
 98    """Build the (height, width, 4) RGBA uint8 texture sampled by the planet shader.
 99
100    Width is fixed (100). Height = number of biomes. If there are no biomes,
101    a single neutral-grey row is returned so the Material always has a valid
102    texture to bind.
103    """
104    biomes = settings.biome.biomes or [
105        Biome(
106            start_height=0.0,
107            gradient=[
108                GradientStop(0.0, (0.6, 0.6, 0.6)),
109                GradientStop(1.0, (0.9, 0.9, 0.9)),
110            ],
111        )
112    ]
113    height = len(biomes)
114    width = TEXTURE_WIDTH
115
116    # Per-half parameter arrays.
117    ocean_t = np.linspace(0.0, 1.0, TEXTURE_HALF, dtype=np.float32)
118    land_t = np.linspace(0.0, 1.0, TEXTURE_HALF, dtype=np.float32)
119
120    rgba = np.empty((height, width, 4), dtype=np.float32)
121    rgba[..., 3] = 1.0
122    ocean_rgb = _sample_gradient(settings.ocean_gradient, ocean_t) if settings.ocean_gradient else None
123    for row, biome in enumerate(biomes):
124        if ocean_rgb is None:
125            # No ocean gradient configured: fall back to deep blue → cyan.
126            ocean_rgb = _sample_gradient(
127                [
128                    GradientStop(0.0, (0.02, 0.05, 0.18)),
129                    GradientStop(1.0, (0.20, 0.45, 0.65)),
130                ],
131                ocean_t,
132            )
133        land_rgb = _sample_gradient(biome.gradient, land_t)
134        # tint blend
135        tint = np.asarray(biome.tint, dtype=np.float32)
136        ocean_blend = ocean_rgb * (1.0 - biome.tint_percent) + tint * biome.tint_percent
137        land_blend = land_rgb * (1.0 - biome.tint_percent) + tint * biome.tint_percent
138        rgba[row, :TEXTURE_HALF, :3] = ocean_blend
139        rgba[row, TEXTURE_HALF:, :3] = land_blend
140
141    rgba_u8 = np.clip(rgba * 255.0, 0.0, 255.0).astype(np.uint8)
142    return rgba_u8
143
144
145# ---------------------------------------------------------------------------
146# Biome percent: port of ColourGenerator.BiomePercentFromPoint
147# ---------------------------------------------------------------------------
148
149
150def biome_percent_array(
151    points_on_unit_sphere: np.ndarray,
152    settings: BiomeColourSettings,
153) -> np.ndarray:
154    """Vectorised biome %% sampler. Returns (N,) float32 in [0, 1]."""
155    if not settings.biomes:
156        return np.zeros(points_on_unit_sphere.shape[0], dtype=np.float32)
157
158    # Y coordinate gives the latitude-ish bias (matches upstream).
159    height_pct = (points_on_unit_sphere[:, 1] + 1.0) * 0.5
160    # Add a noise jitter to break the otherwise-perfect latitude bands. We
161    # offset the height %% by a Perlin sample so biome boundaries swirl
162    # around instead of slicing the planet horizontally, matches upstream's
163    # noiseStrength / noiseOffset semantics.
164    if settings.noise_strength != 0.0:
165        n = _BIOME_NOISE.get_noise_3d_array(
166            points_on_unit_sphere[:, 0],
167            points_on_unit_sphere[:, 1],
168            points_on_unit_sphere[:, 2],
169        )
170        height_pct = height_pct + (n - settings.noise_offset) * settings.noise_strength
171    biome_index = np.zeros_like(height_pct)
172    blend_range = max(settings.blend_amount * 0.5 + 1e-3, 1e-3)
173    num_biomes = len(settings.biomes)
174    for i, biome in enumerate(settings.biomes):
175        dst = height_pct - biome.start_height
176        weight = np.clip((dst + blend_range) / (2.0 * blend_range), 0.0, 1.0)
177        biome_index = biome_index * (1.0 - weight) + i * weight
178    return (biome_index / max(1, num_biomes - 1)).astype(np.float32)
179
180
181# ---------------------------------------------------------------------------
182# Default preset: earth-like 5-biome ramp
183# ---------------------------------------------------------------------------
184
185
186def default_colour_settings() -> ColourSettings:
187    """Five biomes: deep ocean → tropical → temperate → arid → polar."""
188    ocean = [
189        GradientStop(0.0, (0.02, 0.05, 0.18)),  # abyss
190        GradientStop(0.5, (0.05, 0.18, 0.42)),  # mid
191        GradientStop(1.0, (0.20, 0.55, 0.70)),  # coast
192    ]
193    biomes = [
194        # Tropical (low latitude, warm)
195        Biome(
196            start_height=0.0,
197            tint=(1.0, 0.9, 0.6),
198            tint_percent=0.10,
199            gradient=[
200                GradientStop(0.0, (0.86, 0.78, 0.45)),  # beach
201                GradientStop(0.20, (0.30, 0.65, 0.18)),  # jungle
202                GradientStop(0.55, (0.18, 0.45, 0.12)),  # rainforest
203                GradientStop(0.85, (0.50, 0.42, 0.30)),  # rock
204                GradientStop(1.0, (1.0, 1.0, 1.0)),  # snow cap
205            ],
206        ),
207        # Temperate
208        Biome(
209            start_height=0.30,
210            tint=(0.85, 0.95, 0.85),
211            tint_percent=0.05,
212            gradient=[
213                GradientStop(0.0, (0.78, 0.72, 0.45)),
214                GradientStop(0.25, (0.42, 0.62, 0.25)),
215                GradientStop(0.60, (0.25, 0.45, 0.18)),
216                GradientStop(0.85, (0.55, 0.46, 0.34)),
217                GradientStop(1.0, (1.0, 1.0, 1.0)),
218            ],
219        ),
220        # Arid
221        Biome(
222            start_height=0.55,
223            tint=(1.0, 0.85, 0.55),
224            tint_percent=0.20,
225            gradient=[
226                GradientStop(0.0, (0.90, 0.78, 0.45)),
227                GradientStop(0.35, (0.78, 0.62, 0.30)),
228                GradientStop(0.70, (0.55, 0.40, 0.25)),
229                GradientStop(1.0, (0.95, 0.95, 0.95)),
230            ],
231        ),
232        # Polar
233        Biome(
234            start_height=0.80,
235            tint=(0.85, 0.92, 1.0),
236            tint_percent=0.30,
237            gradient=[
238                GradientStop(0.0, (0.78, 0.82, 0.85)),
239                GradientStop(0.5, (0.92, 0.94, 0.96)),
240                GradientStop(1.0, (1.0, 1.0, 1.0)),
241            ],
242        ),
243    ]
244    return ColourSettings(
245        ocean_gradient=ocean,
246        biome=BiomeColourSettings(
247            biomes=biomes,
248            blend_amount=0.20,
249            noise_strength=0.18,
250            noise_offset=0.0,
251        ),
252    )