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 )