Files
settled-reach/tooling/planet-gen/planet_renderer.py
T
jpmschweitzerandClaude Opus 4.6 f84275edf3 feat(assets): planet generator pipeline — heightmap + globe from wiki data
Full terrain-to-render pipeline at tooling/planet-gen/:
- body_definition_parser: reads system index.md → body definitions
- planet_simulation: FBM + Voronoi ridges, Whittaker biome classification,
  D8 river routing, physics-driven craters (atmo × tectonics scaling)
- render_heightmap: 4096×2048 cartographic maps with hillshade
- planet_renderer: 512×512 globe with terrain UV mapping, clouds, rings
- biomes.toml: externalized color/classification tables (single source)
- scaffold_bodies: creates per-body index.md with YAML frontmatter
- batch: unattended processing with error handling, resume, determinism check

Supports all body types: temperate, arid, frozen, volcanic, barren,
oceanic, gas giant (banded + ringed), and moons (half-size, grey, cratered).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:54:15 +02:00

971 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
planet_renderer.py
------------------
Renders a 2048×2048 planet globe PNG from a body definition dict.
Supported planet_class values:
temperate, oceanic, forest — terrestrial, biome-colored surface
arid, martian — dry terrestrial, dust haze
frozen — ice world, cold-tinted
barren — rocky, no atmosphere
volcanic — dark rock, lava highlight pass
moon — barren + crater density from 'age'
gas_giant — band renderer, no UV wrap
gas_giant_ringed — gas_giant + ring plane composite
Lighting model (terrestrial):
diffuse — Lambert with sharpened terminator
specular — Phong, ocean cells only (masked by surface_water grid)
terminator — warm scatter band at dot(N,L) ≈ 0
rim glow — atmosphere color at grazing angle, lit + dark side
night side — faint ambient scatter, no city lights
clouds — moisture-driven opacity, rendered above surface
Outputs:
PIL Image (RGBA, 2048×2048) — caller saves as PNG
Usage:
from planet_renderer import render_globe
img = render_globe(body_def, terrain=None)
img.save("myplanet.png")
# With terrain data:
img = render_globe(body_def, terrain={
"elevation": np.ndarray (H, W) float32 [0,1],
"temperature": np.ndarray (H, W) float32 [0,1],
"moisture": np.ndarray (H, W) float32 [0,1],
"biome": np.ndarray (H, W) int8 [0..N],
"surface_water":np.ndarray (H, W) bool,
})
"""
import math
import numpy as np
from PIL import Image
from biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
STAR_TINTS as _STAR_TINTS_CFG,
ATMO_COLORS as _ATMO_COLORS_CFG,
GAS_PALETTES as _GAS_PALETTES_CFG,
MAX_BIOME_ID,
)
# ---------------------------------------------------------------------------
# Output resolution
# ---------------------------------------------------------------------------
GLOBE_SIZE = 2048
SPHERE_R = 0.90 # sphere radius in [-1,1] NDC — leaves margin for ring/glow
# ---------------------------------------------------------------------------
# Star color temperature → RGB tint for lighting
# ---------------------------------------------------------------------------
# Star tints loaded from biomes.toml
STAR_TINTS = _STAR_TINTS_CFG
# ---------------------------------------------------------------------------
# Biome palette (index matches Whittaker classification order)
# Colours are float RGB [0,1]
# ---------------------------------------------------------------------------
BIOME_COLORS = np.array([
[0.12, 0.20, 0.38], # 0 ocean deep
[0.16, 0.30, 0.52], # 1 ocean mid
[0.22, 0.42, 0.58], # 2 ocean shallow
[0.50, 0.62, 0.45], # 3 coast / beach
[0.38, 0.52, 0.30], # 4 subtropical dry forest
[0.25, 0.48, 0.22], # 5 tropical rainforest
[0.42, 0.56, 0.28], # 6 tropical seasonal forest
[0.55, 0.60, 0.32], # 7 savanna / grassland
[0.62, 0.58, 0.38], # 8 temperate grassland
[0.30, 0.50, 0.28], # 9 temperate deciduous forest
[0.22, 0.40, 0.25], # 10 temperate rainforest
[0.20, 0.35, 0.22], # 11 boreal / taiga
[0.72, 0.68, 0.58], # 12 shrubland / chaparral
[0.78, 0.70, 0.50], # 13 temperate desert
[0.82, 0.72, 0.52], # 14 subtropical desert
[0.85, 0.78, 0.62], # 15 hot desert
[0.88, 0.88, 0.92], # 16 tundra
[0.92, 0.94, 0.97], # 17 ice / snow
[0.55, 0.50, 0.45], # 18 mountain rock
[0.38, 0.32, 0.28], # 19 volcanic / lava field
], dtype=np.float32)
# Photographic biome colors loaded from biomes.toml via biome_config.
_EXTENDED_BIOME_COLORS = np.zeros((MAX_BIOME_ID + 1, 3), dtype=np.float32)
for _cid, _val in _BIOME_PALETTE_CFG.items():
_EXTENDED_BIOME_COLORS[_cid] = np.array(_val["photographic"], dtype=np.float32) / 255.0
del _cid, _val
# Gas giant palettes and atmosphere colors loaded from biomes.toml
GAS_PALETTES = _GAS_PALETTES_CFG
ATMO_COLORS = _ATMO_COLORS_CFG
# ---------------------------------------------------------------------------
# Noise helpers — pure numpy, no external deps
# ---------------------------------------------------------------------------
def _hash2(x: np.ndarray, y: np.ndarray, seed: int) -> np.ndarray:
"""Deterministic pseudo-random float in [0,1] from integer x,y coords."""
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _value_noise_octave(u, v, freq, seed):
"""Single octave value noise via bilinear grid interpolation. No sine waves."""
uf = u * freq; vf = v * freq
x0 = np.floor(uf).astype(np.int32); y0 = np.floor(vf).astype(np.int32)
x1 = x0 + 1; y1 = y0 + 1
tx = uf - x0; ty = vf - y0
tx = tx * tx * (3.0 - 2.0 * tx) # smoothstep
ty = ty * ty * (3.0 - 2.0 * ty)
v00 = _hash2(x0, y0, seed); v10 = _hash2(x1, y0, seed)
v01 = _hash2(x0, y1, seed); v11 = _hash2(x1, y1, seed)
return (v00*(1-tx)*(1-ty) + v10*tx*(1-ty) +
v01*(1-tx)*ty + v11*tx*ty).astype(np.float32)
def fbm(u: np.ndarray, v: np.ndarray, seed: int,
octaves: int = 7, lacunarity: float = 2.0,
gain: float = 0.50) -> np.ndarray:
"""FBM using value noise (bilinear grid). Returns [0,1] float32. No hatching."""
result = np.zeros_like(u, dtype=np.float32)
amplitude = 1.0; frequency = 2.0; total = 0.0
rng = np.random.default_rng(seed)
for i in range(octaves):
oct_seed = int(rng.integers(0, 0x7FFFFFFF))
result += amplitude * _value_noise_octave(u, v, frequency, oct_seed)
total += amplitude
amplitude *= gain; frequency *= lacunarity
return result / (total + 1e-9)
# ---------------------------------------------------------------------------
# Ray-sphere intersection — vectorised over full image
# ---------------------------------------------------------------------------
def _raytrace(size: int, r: float = 1.0, oblateness: float = 0.0):
"""
Camera at (0, 0, 3) looking at origin.
oblateness flattens the sphere along Y (gas giants).
Returns: hit(bool), nx, ny, nz, u, v — all (size, size) float32
"""
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px, py = np.meshgrid(lin, -lin) # y flipped: top = +y
oz = 3.0
rdx = px.copy()
rdy = py.copy()
rdz = np.full((size, size), -oz, dtype=np.float32)
mag = np.sqrt(rdx**2 + rdy**2 + rdz**2)
rdx /= mag; rdy /= mag; rdz /= mag
# Scale Y for oblate spheroid
rdy_s = rdy / (1.0 - oblateness + 1e-9)
b = 2.0 * oz * rdz
c = oz**2 - r**2
disc = b**2 - 4.0 * c
hit = disc >= 0.0
safe = np.maximum(disc, 0.0)
t = np.where(hit, (-b - np.sqrt(safe)) / 2.0, np.inf)
hx = rdx * t
hy = rdy * t
hz = oz + rdz * t
# Surface normal — account for oblate scaling
nx = hx
ny = hy / (1.0 - oblateness + 1e-9)**2
nz = hz
nm = np.where(hit, np.sqrt(nx**2 + ny**2 + nz**2), 1.0)
nx /= nm; ny /= nm; nz /= nm
# UV from undistorted hit point
u = (np.arctan2(hz, hx) / (2.0 * math.pi)) % 1.0
v = np.arcsin(np.clip(hy / np.where(hit, np.sqrt(hx**2 + hy**2 + hz**2), 1.0), -1.0, 1.0)) / math.pi + 0.5
return hit, nx.astype(np.float32), ny.astype(np.float32), nz.astype(np.float32), u.astype(np.float32), v.astype(np.float32)
# ---------------------------------------------------------------------------
# Lighting helpers
# ---------------------------------------------------------------------------
def _star_light_dir(angle_deg: float):
"""
Light direction vector from star.
angle_deg: 90 = directly to the right (classic terminator).
~110 gives dramatic 3/4 lit look.
"""
a = math.radians(angle_deg)
lx = math.cos(a)
ly = math.sin(a) * 0.25 # slight vertical offset
lz = 0.55
m = math.sqrt(lx**2 + ly**2 + lz**2)
return lx/m, ly/m, lz/m
def _apply_lighting(
rgb: np.ndarray, # (H,W,3) float32 surface color [0,1]
hit: np.ndarray, # (H,W) bool
nx, ny, nz: np.ndarray, # surface normals
surface_water: np.ndarray, # (H,W) bool — specular mask
atmo_color, # (3,) float or None
body_def: dict,
) -> np.ndarray:
"""
Full lighting pass. Returns (H,W,3) float32 lit RGB.
"""
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
night_amb= render.get("night_side_ambient", 0.02)
do_spec = render.get("specular_ocean", True)
lx, ly, lz = _star_light_dir(langle)
star_type = body_def.get("star", {}).get("type", "G")
star_tint = np.array(STAR_TINTS.get(star_type, (1,1,1)), dtype=np.float32)
# View direction (camera at 0,0,3, looking at origin)
vz = -1.0 # simplified: view dir is ~(0,0,-1) at pixel center
# Dot products
NdotL = nx * lx + ny * ly + nz * lz # (H,W)
NdotV = np.abs(nz) # grazing = 0, face-on = 1
# --- Diffuse (sharpened Lambert) ---
# Smoothstep-stretched terminator: spreads the lit→dark transition
# across a wider band than physical Lambert. More cinematic, less harsh.
t_raw = np.clip(NdotL * 1.4 + 0.15, 0.0, 1.0) # shift+scale to widen zone
diff = t_raw * t_raw * (3.0 - 2.0 * t_raw) # smoothstep
ambient = 0.06
lit_rgb = rgb * (ambient + (1.0 - ambient) * diff[..., np.newaxis] * star_tint)
# --- Night side ambient scatter ---
dark_mask = (NdotL < 0.0)
night_rgb = rgb * (night_amb * star_tint)
lit_rgb = np.where(dark_mask[..., np.newaxis], night_rgb, lit_rgb)
# --- Terminator warm scatter band ---
term = np.abs(NdotL)
term_band = np.clip(1.0 - term / 0.10, 0.0, 1.0) ** 2 # 0-10° around terminator
term_color = np.array([1.0, 0.62, 0.28], dtype=np.float32) * star_tint
lit_rgb = lit_rgb + term_band[..., np.newaxis] * term_color * 0.35 * np.clip(NdotL + 0.10, 0, 1)[..., np.newaxis]
# --- Ocean specular ---
if do_spec and surface_water is not None:
rx = -lx + 2.0 * NdotL * nx
ry = -ly + 2.0 * NdotL * ny
rz = -lz + 2.0 * NdotL * nz
spec = np.clip(-rz, 0.0, 1.0) ** 70 # tight highlight
spec *= surface_water.astype(np.float32)
spec *= (NdotL > 0.0).astype(np.float32)
lit_rgb += spec[..., np.newaxis] * star_tint * 0.80
# --- Atmospheric rim glow ---
if atmo_color is not None:
ac = np.array(atmo_color, dtype=np.float32)
rim = (1.0 - NdotV) ** 5
# Lit side: bright rim
rim_lit = rim * np.clip(NdotL + 0.30, 0.0, 1.0)
# Dark side: fainter rim (scatter from beyond terminator)
rim_dark = rim * np.clip(-NdotL + 0.15, 0.0, 1.0) * 0.35
lit_rgb += (rim_lit + rim_dark)[..., np.newaxis] * ac * 0.60
return np.clip(lit_rgb, 0.0, 1.0)
# ---------------------------------------------------------------------------
# Star field background
# ---------------------------------------------------------------------------
def _make_starfield(size: int, seed: int) -> np.ndarray:
"""Returns (size, size, 3) float32 star field background."""
rng = np.random.default_rng(seed + 9999)
field = np.zeros((size, size, 3), dtype=np.float32)
n_stars = int(size * size * 0.0018)
ys = rng.integers(0, size, n_stars)
xs = rng.integers(0, size, n_stars)
bri = rng.uniform(0.25, 1.0, n_stars).astype(np.float32)
# Slight color variation
cr = rng.uniform(0.85, 1.00, n_stars).astype(np.float32)
cg = rng.uniform(0.88, 1.00, n_stars).astype(np.float32)
cb = rng.uniform(0.90, 1.00, n_stars).astype(np.float32)
field[ys, xs, 0] = bri * cr
field[ys, xs, 1] = bri * cg
field[ys, xs, 2] = bri * cb
return field
# ---------------------------------------------------------------------------
# Surface color from terrain data OR procedural fallback
# ---------------------------------------------------------------------------
def _surface_color_terrestrial(
u: np.ndarray, v: np.ndarray,
terrain, body_def: dict, seed: int
) -> tuple:
"""
Returns (rgb (H,W,3) float32, surface_water (H,W) bool).
If terrain is None, generates a plausible procedural surface.
"""
planet_class = body_def.get("planet_class", "temperate")
H, W = u.shape
if terrain is not None and "biome" in terrain:
# Sample terrain grids by UV coordinates (equirectangular projection).
# u = longitude [0,1], v = latitude [0,1] where 0=south pole, 1=north pole.
# Terrain grid: row 0 = north pole, row H-1 = south pole.
tH, tW = terrain["biome"].shape
# Map UV to terrain grid indices
col_idx = np.clip((u * tW).astype(np.int32), 0, tW - 1)
row_idx = np.clip(((1.0 - v) * tH).astype(np.int32), 0, tH - 1)
biome = terrain["biome"][row_idx, col_idx]
col = _EXTENDED_BIOME_COLORS[np.clip(biome, 0, len(_EXTENDED_BIOME_COLORS)-1)]
water_grid = terrain.get("surface_water", terrain["biome"] <= 2)
water = water_grid[row_idx, col_idx]
# Elevation shading — skip for ice/snow classes (17, 26) which
# should stay bright. The hillshade in the lighting pass provides
# enough depth cue on ice surfaces.
if "elevation" in terrain:
elev = terrain["elevation"][row_idx, col_idx]
shade = 0.82 + 0.18 * elev
is_ice = (biome == 17) | (biome == 26)
shade = np.where(is_ice, 1.0, shade)
col = np.clip(col * shade[..., np.newaxis], 0, 1)
# Terrain relief on rocky/dry worlds: hillshade drives surface
# contrast since biome color is uniform. Stronger on cratered
# bodies where rims catching light is the primary visual feature.
if "hillshade" in terrain:
hs = terrain["hillshade"][row_idx, col_idx]
is_rock = ((biome == 18) | (biome == 27) | (biome == 28) | (biome == 29)
| (biome == 30) | (biome == 31) | (biome == 32) | (biome == 33))
rock_variation = 0.55 + 0.45 * hs
col = np.where(is_rock[..., np.newaxis],
np.clip(col * rock_variation[..., np.newaxis], 0, 1),
col)
return col.astype(np.float32), water
# --- Procedural fallback ---
rng = np.random.default_rng(seed)
# Continent mask — low-freq noise, threshold to land_fraction
lf = body_def.get("terrain", {}).get("land_fraction", 0.35)
cont_noise = fbm(u * 3, v * 2, seed, octaves=5, gain=0.55)
# Normalise to [0,1]
cont = (cont_noise - cont_noise.min()) / (cont_noise.max() - cont_noise.min() + 1e-9)
land = cont > (1.0 - lf)
# Detail texture
detail = fbm(u * 8, v * 6, seed + 1, octaves=4, gain=0.5)
detail = (detail - detail.min()) / (detail.max() - detail.min() + 1e-9)
# Base colors by planet class
water_col = np.array([0.12, 0.25, 0.50], np.float32)
shore_col = np.array([0.45, 0.55, 0.35], np.float32)
class_land = {
"temperate": (np.array([0.28, 0.50, 0.22], np.float32),
np.array([0.50, 0.62, 0.32], np.float32)),
"forest": (np.array([0.18, 0.40, 0.18], np.float32),
np.array([0.30, 0.52, 0.24], np.float32)),
"oceanic": (np.array([0.22, 0.45, 0.20], np.float32),
np.array([0.08, 0.18, 0.42], np.float32)),
"arid": (np.array([0.70, 0.60, 0.40], np.float32),
np.array([0.82, 0.72, 0.52], np.float32)),
"martian": (np.array([0.62, 0.38, 0.25], np.float32),
np.array([0.72, 0.48, 0.32], np.float32)),
"frozen": (np.array([0.82, 0.88, 0.95], np.float32),
np.array([0.90, 0.94, 0.98], np.float32)),
"barren": (np.array([0.38, 0.35, 0.32], np.float32),
np.array([0.52, 0.48, 0.44], np.float32)),
"volcanic": (np.array([0.22, 0.18, 0.16], np.float32),
np.array([0.70, 0.30, 0.10], np.float32)),
}
dark_l, light_l = class_land.get(planet_class,
class_land["temperate"])
land_col = dark_l[np.newaxis, np.newaxis, :] * (1 - detail[..., np.newaxis]) + \
light_l[np.newaxis, np.newaxis, :] * detail[..., np.newaxis]
# Polar ice caps
lat_abs = np.abs(v - 0.5) * 2.0
ice_thresh = body_def.get("terrain", {}).get("polar_ice_lat", 0.80)
ice_blend = np.clip((lat_abs - ice_thresh) / (1.0 - ice_thresh + 0.05), 0, 1)
ice_color = np.array([0.92, 0.95, 0.98], np.float32)
land_col = land_col * (1 - ice_blend[..., np.newaxis]) + \
ice_color * ice_blend[..., np.newaxis]
# Ocean depth shading
ocean_depth = 1.0 - cont
oc = water_col[np.newaxis, np.newaxis, :] * (0.6 + 0.4 * ocean_depth[..., np.newaxis])
# Shallow coast transition
coast_blend = np.clip((cont - (1 - lf)) / 0.06, 0, 1)
land_col_c = land_col * (1 - coast_blend[..., np.newaxis]) * 0.0 + \
shore_col * (1 - coast_blend[..., np.newaxis]) + \
land_col * coast_blend[..., np.newaxis]
rgb = np.where(land[..., np.newaxis], land_col_c, oc)
# Volcanic lava cracks
if planet_class == "volcanic":
lava_noise = fbm(u * 15, v * 12, seed + 7, octaves=3)
lava = np.clip((lava_noise + 0.15) * 8.0, 0, 1)
lava_col = np.array([0.92, 0.40, 0.05], np.float32)
lava_mask = land & (lava > 0.85)
rgb = np.where(lava_mask[..., np.newaxis], lava_col, rgb)
water_mask = ~land
return rgb.astype(np.float32), water_mask
# ---------------------------------------------------------------------------
# Cloud layer
# ---------------------------------------------------------------------------
def _cloud_layer(
u: np.ndarray, v: np.ndarray,
terrain, body_def: dict, seed: int,
nx, ny, nz: np.ndarray,
NdotL: np.ndarray,
star_tint: np.ndarray,
atmo_color,
) -> np.ndarray:
"""
Returns (H,W,3) float32 additive cloud RGB.
Moisture-driven if terrain provided, else procedural.
"""
cloud_cfg = body_def.get("clouds", {})
coverage = cloud_cfg.get("coverage_base", 0.40)
planet_class= body_def.get("planet_class", "temperate")
if planet_class in ("barren", "moon", "gas_giant", "gas_giant_ringed"):
return np.zeros((*u.shape, 3), dtype=np.float32)
# Cloud opacity — procedural shapes weighted by moisture.
# Moisture influences density, not shape — otherwise clouds just
# blanket the oceans where moisture is highest.
cloud_shape = fbm(u * 4, v * 3, seed + 42, octaves=5, gain=0.58)
cloud_shape = (cloud_shape - cloud_shape.min()) / (cloud_shape.max() - cloud_shape.min() + 1e-9)
if terrain is not None and "moisture" in terrain:
tH, tW = terrain["moisture"].shape
col_idx = np.clip((u * tW).astype(np.int32), 0, tW - 1)
row_idx = np.clip(((1.0 - v) * tH).astype(np.int32), 0, tH - 1)
moist = terrain["moisture"][row_idx, col_idx]
# Moisture boosts cloud density where it's wet, but the shape
# comes from the noise field — clouds can exist over land too.
raw_cld = cloud_shape * (0.5 + 0.5 * moist)
else:
raw_cld = fbm(u * 4, v * 3, seed + 42, octaves=5, gain=0.58)
raw_cld = (raw_cld - raw_cld.min()) / (raw_cld.max() - raw_cld.min() + 1e-9)
# Threshold to target coverage
thresh = np.percentile(raw_cld, (1.0 - coverage) * 100)
alpha = np.clip((raw_cld - thresh) / (raw_cld.max() - thresh + 1e-9), 0, 1)
alpha = alpha ** 0.70 # soften edges
# Gaussian blur on cloud alpha to eliminate any residual noise texture
from scipy.ndimage import gaussian_filter
alpha = gaussian_filter(alpha, sigma=2.5).astype(np.float32)
alpha = np.clip(alpha, 0, 1)
# Cloud color — lit side bright, dark side very dim
diff = np.clip(NdotL, 0.0, 1.0)
amb = 0.08
cld_bri = (amb + (1 - amb) * diff)[..., np.newaxis] * star_tint[np.newaxis, np.newaxis, :]
cld_rgb = cld_bri * 0.96 # slightly warm white
# Rim darkening on clouds at grazing angle
NdotV = np.abs(nz)
rim = (1.0 - NdotV) ** 3 * 0.25
cld_rgb = cld_rgb * (1.0 - rim[..., np.newaxis])
return cld_rgb, alpha
# ---------------------------------------------------------------------------
# Gas giant renderer
# ---------------------------------------------------------------------------
def _render_gas_giant(
hit: np.ndarray,
nx, ny, nz: np.ndarray,
u: np.ndarray, v: np.ndarray,
body_def: dict, seed: int,
) -> np.ndarray:
"""
Returns (H,W,3) float32 lit gas giant surface color.
No UV-wrap needed — surface is procedural bands.
"""
gg_cfg = body_def.get("gas_giant", {})
palette_name = gg_cfg.get("band_palette", "jovian")
storm_count = gg_cfg.get("storm_count", 2)
storm_size = gg_cfg.get("storm_max_size", 0.10)
palette = np.array(GAS_PALETTES.get(palette_name, GAS_PALETTES["jovian"]),
dtype=np.float32)
n_bands = len(palette)
rng = np.random.default_rng(seed)
# Latitude with domain warp for natural band wobble
warp = fbm(u * 2, v * 4, seed + 100, octaves=4, gain=0.50) * 0.08
lat_warped = np.clip(v + warp, 0.0, 1.0)
# Band index from warped latitude
band_raw = lat_warped * n_bands * 2.5
band_idx = np.floor(band_raw).astype(np.int32) % n_bands
# Detail noise within bands
detail = fbm(u * 6, v * 8, seed + 200, octaves=3, gain=0.45)
detail = (detail - detail.min()) / (detail.max() - detail.min() + 1e-9)
# Base band color
rgb = palette[band_idx]
# Subtle lightening/darkening from detail
rgb = rgb * (0.88 + 0.24 * detail[..., np.newaxis])
# Storm ovals
storm_lats = rng.uniform(0.20, 0.80, storm_count)
storm_lons = rng.uniform(0.05, 0.95, storm_count)
storm_sizes = rng.uniform(storm_size * 0.5, storm_size, storm_count)
storm_cols = palette[rng.integers(0, n_bands, storm_count)]
for i in range(storm_count):
du = (u - storm_lons[i] + 0.5) % 1.0 - 0.5
dv = v - storm_lats[i]
# Distance from storm center (oval: wider than tall)
sz = storm_sizes[i]
dist = np.sqrt((du / (sz * 2.0))**2 + (dv / sz)**2)
# Spiral swirl: rotate the band pattern around the storm center.
# Angle increases toward center → spiral arms.
angle = np.arctan2(dv, du)
swirl_strength = np.clip(1.0 - dist / 1.2, 0, 1) ** 1.5
swirl_angle = swirl_strength * 3.5 # ~1 full rotation at center
# Distort the band noise by rotating UV around storm
swirl_u = du * np.cos(swirl_angle) - dv * np.sin(swirl_angle)
swirl_detail = np.sin(swirl_u * 40.0 + angle * 2.0) * 0.08
# Storm color: base + swirl texture
storm_alpha = np.clip(1.0 - dist / 0.8, 0, 1) ** 2
storm_rgb = storm_cols[i] * (1.0 + swirl_detail[..., np.newaxis])
rgb = rgb * (1 - storm_alpha[..., np.newaxis]) + \
storm_rgb * storm_alpha[..., np.newaxis]
rgb = np.clip(rgb, 0.0, 1.0)
# Lighting — diffuse only (no specular, slight rim)
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
star_type= body_def.get("star", {}).get("type", "G")
star_tint= np.array(STAR_TINTS.get(star_type, (1,1,1)), np.float32)
NdotL = nx * lx + ny * ly + nz * lz
t_raw = np.clip(NdotL * 1.4 + 0.15, 0.0, 1.0)
diff = t_raw * t_raw * (3.0 - 2.0 * t_raw)
amb = 0.08
night_amb = render.get("night_side_ambient", 0.025)
dark = NdotL < 0
lit_rgb = rgb * (amb + (1 - amb) * diff[..., np.newaxis] * star_tint)
lit_rgb = np.where(dark[..., np.newaxis],
rgb * night_amb,
lit_rgb)
# Atmosphere/rim glow using band palette mid color
mid_col = palette[n_bands // 2] * 0.7 + np.array([0.5, 0.5, 0.6], np.float32) * 0.3
NdotV = np.abs(nz)
rim = (1.0 - NdotV) ** 5
rim_lit = rim * np.clip(NdotL + 0.30, 0, 1)
lit_rgb += rim_lit[..., np.newaxis] * mid_col * 0.50
return np.clip(lit_rgb, 0, 1)
# ---------------------------------------------------------------------------
# Ring plane compositor
# ---------------------------------------------------------------------------
def _composite_rings(
canvas: np.ndarray,
hit: np.ndarray,
body_def: dict, seed: int,
effective_r: float = SPHERE_R,
) -> np.ndarray:
"""
Equatorial ring plane viewed from 5° above.
The ring lies in the planet's equatorial plane (horizontal).
Viewed from 5° elevation, the projection is an ellipse where:
- X axis = full ring radius (unchanged by elevation angle)
- Y axis = ring_radius * sin(ELEV) — very flat, only 8.7% of X
- Centre = planet screen centre (cx, cy) — no offset
- Near side = bottom half of ellipse (ys_g > 0) — crosses in front
- Far side = top half of ellipse (ys_g <= 0) — behind planet
"""
ELEV = math.radians(5) # camera elevation above ring plane
sin_elev = math.sin(ELEV) # 0.0872 — Y compression factor
cos_elev = math.cos(ELEV) # 0.9962 — used for lighting normal
ring_cfg = body_def.get("rings", {})
r_inner = ring_cfg.get("inner_radius_factor", 1.12)
r_outer = ring_cfg.get("outer_radius_factor", 2.65)
base_opa = ring_cfg.get("opacity_base", 0.62)
palette_name = body_def.get("gas_giant", {}).get("band_palette", "jovian")
palette = np.array(GAS_PALETTES.get(palette_name, GAS_PALETTES["jovian"]),
dtype=np.float32)
if "ring_color" in ring_cfg:
ring_col = np.array(ring_cfg["ring_color"], dtype=np.float32)
else:
ring_base = palette[0]*0.4 + palette[2]*0.4 + palette[4]*0.2
ring_col = np.clip(ring_base * 1.15, 0, 1)
H, W = canvas.shape[:2]
cx, cy = W / 2.0, H / 2.0
planet_px = (effective_r / 2.0) * W # sphere radius in pixels
# Pixel offsets from planet centre — ellipse is centred here, no shift
ys_arr = np.arange(H, dtype=np.float32) - cy
xs_arr = np.arange(W, dtype=np.float32) - cx
xs_g, ys_g = np.meshgrid(xs_arr, ys_arr)
# Ellipse axes: X = full radius, Y = radius * sin(elevation)
rx_o = r_outer * planet_px
ry_o = r_outer * planet_px * sin_elev # very flat
rx_i = r_inner * planet_px
ry_i = r_inner * planet_px * sin_elev
# Annular ring mask
e_outer = (xs_g / rx_o)**2 + (ys_g / ry_o)**2
e_inner = (xs_g / rx_i)**2 + (ys_g / ry_i)**2
in_ring = (e_outer <= 1.0) & (e_inner >= 1.0)
# Radial opacity variation
t_ring = np.clip(
(np.sqrt(e_outer) - r_inner/r_outer) / (1.0 - r_inner/r_outer + 1e-9),
0, 1)
gap = np.clip(1.0 - np.abs(t_ring - 0.55) / 0.06, 0, 1) ** 2
r_px = np.sqrt((xs_g/rx_o)**2 + (ys_g/ry_o)**2)
density = np.sin(r_px * 55.0) * 0.10 + 0.90
opa = np.clip(base_opa * density * (1.0 - gap*0.75) * in_ring, 0, 1)
# Lighting — ring plane normal is (0, sin_elev, -cos_elev) for equatorial plane
# at 5° elevation. Ring faces mostly upward so boost ambient significantly.
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
ring_light = abs(sin_elev * ly + (-cos_elev) * lz) * 0.40 + 0.72
lit_ring = np.clip(ring_col * ring_light, 0, 1)
result = canvas.copy()
# Far side: top half of ellipse (ys_g <= 0) — draw behind planet only
far = in_ring & (ys_g <= 0) & ~hit
result[far] = (result[far] * (1 - opa[far, np.newaxis]) +
lit_ring * opa[far, np.newaxis])
# Near side: bottom half of ellipse (ys_g > 0) — draw in front of everything
near = in_ring & (ys_g > 0)
near_on = near & hit
near_off = near & ~hit
result[near_off] = (result[near_off] * (1 - opa[near_off, np.newaxis]) +
lit_ring * opa[near_off, np.newaxis])
shadow = 1.0 - opa[near_on, np.newaxis] * 0.30
result[near_on] = (result[near_on] * shadow * (1 - opa[near_on, np.newaxis]) +
lit_ring * opa[near_on, np.newaxis])
return np.clip(result, 0, 1)
def render_globe(
body_def: dict,
terrain: dict = None,
size: int = GLOBE_SIZE,
) -> Image.Image:
"""
Render a planet globe.
Parameters
----------
body_def : dict
Body definition (see module docstring for schema).
terrain : dict or None
Geographic data grids. If None, procedural surface is used.
size : int
Output image size (default 2048).
Returns
-------
PIL.Image.Image RGBA, size×size
"""
seed = body_def.get("seed", 42)
planet_class = body_def.get("planet_class", "temperate")
oblateness = body_def.get("physical", {}).get("oblateness", 0.0)
body_scale = body_def.get("body_scale", "planet") # "planet" or "moon"
# Inflate oblateness for gas giants
if planet_class in ("gas_giant", "gas_giant_ringed"):
oblateness = max(oblateness,
body_def.get("physical", {}).get("oblateness", 0.065))
# Effective sphere radius in NDC [-1,1]:
# - ringed bodies: shrink so outer ring fits within 0.84 NDC margin
# - moons: 2/3 scale of planet for visual distinction in grids
if planet_class == "gas_giant_ringed":
# Fit outer ring within 82% of half-image width.
# outer_ring_px = r_outer * (effective_r/2) * W = 0.82 * (W/2)
# => effective_r = 0.82 / r_outer
r_outer_fit = body_def.get("rings", {}).get("outer_radius_factor", 2.65)
effective_r = 0.82 / r_outer_fit
else:
effective_r = SPHERE_R # default 0.90
if body_scale in ("moon", "dwarf") or body_def.get("body_type") == "moon":
effective_r *= 0.50
# -- Ray trace --------------------------------------------------------
hit, nx, ny, nz, u, v = _raytrace(size, effective_r, oblateness)
# Zero out normals on miss pixels to avoid NaN propagation
nx = np.where(hit, nx, 0.0)
ny = np.where(hit, ny, 0.0)
nz = np.where(hit, nz, 1.0)
u = np.where(hit, u, 0.0)
v = np.where(hit, v, 0.5)
# -- Background -------------------------------------------------------
canvas = _make_starfield(size, seed)
# Terrain grids stay at their native resolution (256×512 equirectangular).
# Surface and cloud functions sample by UV coordinates, not pixel alignment.
# -- Surface color ----------------------------------------------------
is_gas = planet_class in ("gas_giant", "gas_giant_ringed")
if is_gas:
surface_rgb = _render_gas_giant(hit, nx, ny, nz, u, v, body_def, seed)
surface_water = None
else:
surface_rgb, surface_water = _surface_color_terrestrial(
u, v, terrain, body_def, seed)
# -- Lighting ---------------------------------------------------------
atmo_color = ATMO_COLORS.get(planet_class)
if is_gas:
lit_rgb = surface_rgb # gas giant handles own lighting internally
else:
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
star_type = body_def.get("star", {}).get("type", "G")
star_tint = np.array(STAR_TINTS.get(star_type, (1,1,1)), np.float32)
lit_rgb = _apply_lighting(
surface_rgb, hit, nx, ny, nz,
surface_water, atmo_color, body_def)
# -- Clouds -------------------------------------------------------
NdotL = nx * lx + ny * ly + nz * lz
cld_cfg = body_def.get("clouds", {})
if cld_cfg.get("enabled", False):
cld_rgb, cld_alpha = _cloud_layer(u, v, terrain, body_def, seed,
nx, ny, nz, NdotL, star_tint, atmo_color)
# Alpha-blend: clouds occlude surface, not just add brightness
a = cld_alpha[..., np.newaxis]
lit_rgb = lit_rgb * (1.0 - a) + cld_rgb * a
lit_rgb = np.clip(lit_rgb, 0, 1)
# -- Composite onto canvas --------------------------------------------
canvas[hit] = lit_rgb[hit]
# -- Ring plane -------------------------------------------------------
if planet_class == "gas_giant_ringed":
canvas = _composite_rings(canvas, hit, body_def, seed, effective_r)
# -- Atmosphere glow halo (outside sphere edge) ----------------------
if atmo_color is not None:
ac = np.array(atmo_color, dtype=np.float32)
# Distance from pixel to sphere center
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px2, py2 = np.meshgrid(lin, -lin)
dist_c = np.sqrt(px2**2 + py2**2)
halo = np.clip((effective_r + 0.045 - dist_c) / 0.045, 0, 1)
halo *= (~hit).astype(np.float32)
# Light-side bias
langle = body_def.get("render", {}).get("globe_light_angle_deg", 125)
la = math.radians(langle)
halo_bias = np.clip(px2 * math.cos(la) * 0.5 + 0.5, 0.2, 1.0)
halo *= halo_bias
canvas = canvas + halo[..., np.newaxis] * ac * 0.40
canvas = np.clip(canvas, 0, 1)
# -- Convert to PIL ---------------------------------------------------
canvas_uint8 = (canvas * 255.0).clip(0, 255).astype(np.uint8)
# Alpha: opaque on hit pixels; ring pixels get opacity from their blend weight
alpha = np.where(hit, 255, 0).astype(np.uint8)
# For ringed bodies, mark ring pixels as opaque too
if planet_class == "gas_giant_ringed":
# Alpha for ring pixels — same equatorial geometry as _composite_rings
ELEV_A = math.radians(5)
ring_cfg = body_def.get("rings", {})
r_inner_a = ring_cfg.get("inner_radius_factor", 1.12)
r_outer_a = ring_cfg.get("outer_radius_factor", 2.65)
base_opa_a = ring_cfg.get("opacity_base", 0.62)
planet_px_a = (effective_r / 2.0) * size
sin_elev_a = math.sin(ELEV_A)
ys_a = np.arange(size, dtype=np.float32) - size / 2.0
xs_a = np.arange(size, dtype=np.float32) - size / 2.0
xs_ga, ys_ga = np.meshgrid(xs_a, ys_a)
rx_oa = r_outer_a * planet_px_a
ry_oa = r_outer_a * planet_px_a * sin_elev_a
rx_ia = r_inner_a * planet_px_a
ry_ia = r_inner_a * planet_px_a * sin_elev_a
e_oa = (xs_ga/rx_oa)**2 + (ys_ga/ry_oa)**2
e_ia = (xs_ga/rx_ia)**2 + (ys_ga/ry_ia)**2
ring_px = (e_oa <= 1.0) & (e_ia >= 1.0)
r_na = np.sqrt(e_oa)
den_a = np.sin(r_na * 55.0) * 0.10 + 0.90
gt_a = np.clip((r_na - r_inner_a/r_outer_a)/(1.0 - r_inner_a/r_outer_a + 1e-9), 0, 1)
gap_a = np.clip(1.0 - np.abs(gt_a - 0.55)/0.06, 0, 1)**2
opa_a = np.clip(base_opa_a * den_a * (1-gap_a*0.75) * ring_px, 0, 1)
alpha = np.maximum(alpha, (opa_a * 255).astype(np.uint8))
# Partial alpha on halo
if atmo_color is not None:
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px2, py2 = np.meshgrid(lin, -lin)
dist_c = np.sqrt(px2**2 + py2**2)
halo_a = np.clip((effective_r + 0.045 - dist_c) / 0.045, 0, 1)
halo_a *= (~hit).astype(np.float32)
alpha = np.maximum(alpha, (halo_a * 200).astype(np.uint8))
rgba = np.dstack([canvas_uint8, alpha])
return Image.fromarray(rgba, mode="RGBA")
# ---------------------------------------------------------------------------
# CLI test — renders one body of each class for visual QA
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys, os, time
TEST_BODIES = [
{
"id": "test_temperate", "name": "Test Temperate",
"planet_class": "temperate", "seed": 144042,
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 1.0, "axial_tilt_deg": 23},
"physical": {"gravity_g": 1.0, "oblateness": 0.003},
"terrain": {"land_fraction": 0.40, "polar_ice_lat": 0.78},
"clouds": {"enabled": True, "coverage_base": 0.45},
"render": {"globe_light_angle_deg": 125, "specular_ocean": True,
"night_side_ambient": 0.025},
},
{
"id": "test_arid", "name": "Test Arid",
"planet_class": "arid", "seed": 55001,
"star": {"type": "G", "luminosity_solar": 1.1},
"orbit": {"distance_au": 1.3, "axial_tilt_deg": 5},
"physical": {"gravity_g": 0.85, "oblateness": 0.002},
"terrain": {"land_fraction": 0.70, "polar_ice_lat": 0.92},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.015},
},
{
"id": "test_frozen", "name": "Test Frozen",
"planet_class": "frozen", "seed": 88800,
"star": {"type": "K", "luminosity_solar": 0.4},
"orbit": {"distance_au": 0.6, "axial_tilt_deg": 45},
"physical": {"gravity_g": 0.90, "oblateness": 0.002},
"terrain": {"land_fraction": 0.30, "polar_ice_lat": 0.30},
"clouds": {"enabled": True, "coverage_base": 0.30},
"render": {"globe_light_angle_deg": 125, "specular_ocean": True,
"night_side_ambient": 0.018},
},
{
"id": "test_barren", "name": "Test Barren",
"planet_class": "barren", "seed": 31415,
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 0.5, "axial_tilt_deg": 2},
"physical": {"gravity_g": 0.40, "oblateness": 0.001},
"terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.98},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.005},
},
{
"id": "test_volcanic", "name": "Test Volcanic",
"planet_class": "volcanic", "seed": 66666,
"star": {"type": "M", "luminosity_solar": 0.08},
"orbit": {"distance_au": 0.15, "axial_tilt_deg": 10},
"physical": {"gravity_g": 1.1, "oblateness": 0.004},
"terrain": {"land_fraction": 0.85, "polar_ice_lat": 0.99},
"clouds": {"enabled": True, "coverage_base": 0.70},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.040},
},
{
"id": "test_gas_giant", "name": "Test Gas Giant",
"planet_class": "gas_giant", "seed": 20001,
"star": {"type": "G", "luminosity_solar": 1.0},
"physical": {"oblateness": 0.065},
"gas_giant": {"band_palette": "jovian", "storm_count": 3,
"storm_max_size": 0.10},
"render": {"globe_light_angle_deg": 125, "night_side_ambient": 0.025},
},
{
"id": "test_moon", "name": "Test Moon",
"planet_class": "barren", "seed": 99001,
"body_scale": "moon",
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 1.0, "axial_tilt_deg": 5},
"physical": {"gravity_g": 0.16, "oblateness": 0.001},
"terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.99},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.005},
},
{
"id": "test_gas_giant_ringed", "name": "Test Ringed Giant",
"planet_class": "gas_giant_ringed", "seed": 77777,
"star": {"type": "G", "luminosity_solar": 1.0},
"physical": {"oblateness": 0.070},
"gas_giant": {"band_palette": "neptunian", "storm_count": 2,
"storm_max_size": 0.08},
"rings": {"enabled": True, "inner_radius_factor": 1.12,
"outer_radius_factor": 2.65, "opacity_base": 0.62,
"ring_color": [0.72, 0.82, 0.95]},
"render": {"globe_light_angle_deg": 125, "night_side_ambient": 0.020},
},
]
out_dir = "/mnt/user-data/outputs"
os.makedirs(out_dir, exist_ok=True)
# Use 512 for fast QA render; change to 2048 for final
qa_size = int(sys.argv[1]) if len(sys.argv) > 1 else 512
paths = []
for bd in TEST_BODIES:
t0 = time.time()
img = render_globe(bd, terrain=None, size=qa_size)
out = os.path.join(out_dir, f"{bd['id']}.png")
img.save(out, format="PNG")
dt = time.time() - t0
print(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
paths.append(out)
print(f"\nDone. {len(paths)} planets rendered at {qa_size}px.")