arctan2(hz, hx) wrapped longitude counter-clockwise, mirroring east and west on the globe. Changed to arctan2(hx, hz) which increases eastward (right on screen). Added +0.5 offset to center the view on 0° longitude, keeping the dateline seam on the back. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
974 lines
41 KiB
Python
974 lines
41 KiB
Python
"""
|
||
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
|
||
# arctan2(hx, hz) so longitude increases eastward (right on screen).
|
||
# +0.5 offset centers the view on 0° longitude (Greenwich) instead of
|
||
# 180° (dateline), keeping the seam on the back of the sphere.
|
||
u = (np.arctan2(hx, hz) / (2.0 * math.pi) + 0.5) % 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.")
|