Files
settled-reach/spikes/planet-renders/generate_planets.py
T
jpmschweitzerandClaude Sonnet 4.6 1cffc83dd7 feat(assets): procedural planet renders for wiki/GTTR — #779
7 planet type PNGs (512×512px RGBA) covering all biome_summary values:
temperate, temperate_terminator, oceanic, arid, frozen, volcanic, barren.

Pure Python ray-sphere renderer (spikes/planet-renders/generate_planets.py)
— numpy/PIL only, no Godot dependency, ~2s for all 7 types. Seeded from
body_id for reproducibility. Resolves Q-064 (Godot 3D planet plugin
evaluation — superseded by headless Python approach).

Assets at client/assets/planets/, 512×512 RGBA, displayed at 240×240 in
the body-info-panel navigator and GTTR arrival window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:19:06 +02:00

512 lines
21 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.
#!/usr/bin/env python3
"""
Planetary Screenshot Generator
Produces procedurally-rendered sphere images for each planet type
in the Settled Reach. Output used in:
- wiki body-info-panel (360×360 container, sphere ~240px)
- GTTR arrival window (diegetic implant UI, same asset)
Approach: numpy ray-sphere intersection + Lambertian shading + specular
+ procedural texture (octave-summed sine waves approximating noise).
No GPU required. Runs offline. Output is PNG at 512×512 (downscaled
to 360×360 for the panel; kept large for quality).
Usage:
python3 generate_planets.py [--output-dir path]
Output:
planet_temperate.png — Earth-like: continents, ocean, clouds
planet_temperate_terminator.png — Tidally locked: bright stripe, dark back
planet_oceanic.png — Water world: blue, archipelago dots
planet_arid.png — Desert/Mars: reddish-orange, dust storms
planet_frozen.png — Ice world: white, blue cracks
planet_volcanic.png — Volcanic: dark basalt, orange lava
planet_barren.png — Airless rocky: cratered grey
"""
import argparse
import math
import os
import numpy as np
from PIL import Image
SIZE = 512 # output pixel dimensions (square)
SPHERE_R = 0.92 # sphere radius in [-1, 1] space (slightly smaller than 1 = padding)
# ─────────────────────────────────────────────────────────────────────────────
# Procedural noise — octave sum of sine waves
# (approximates value noise without a noise library)
# ─────────────────────────────────────────────────────────────────────────────
def proc_noise(u: np.ndarray, v: np.ndarray, seed: float, octaves: int = 5) -> np.ndarray:
"""
Returns values roughly in [-1, 1].
u, v are 2D arrays of coordinates (e.g. lon/lat on sphere surface).
seed shifts the pattern.
"""
result = np.zeros_like(u, dtype=np.float32)
amplitude = 1.0
frequency = 1.0
total_amp = 0.0
for i in range(octaves):
ph = seed + i * 3.7
result += amplitude * (
np.sin(frequency * u * 13.7 + ph) * np.cos(frequency * v * 8.1 + ph * 0.7) +
np.cos(frequency * u * 7.3 - ph * 0.4) * np.sin(frequency * v * 11.3 + ph * 1.3)
)
total_amp += 2 * amplitude
amplitude *= 0.5
frequency *= 2.0
return result / total_amp
# ─────────────────────────────────────────────────────────────────────────────
# Ray-sphere intersection
# ─────────────────────────────────────────────────────────────────────────────
def raytrace_sphere(size: int, r: float = 1.0):
"""
Returns (hit_mask, nx, ny, nz, u, v) arrays of shape (size, size).
- hit_mask: bool, True where the ray hits the sphere
- nx/ny/nz: surface normals at hit points (unit length)
- u/v: spherical UV coordinates in [0, 1]
Camera at (0, 0, 3), looking toward origin. Sphere at origin.
"""
# Pixel coordinates mapped to [-1, 1] square
lin = np.linspace(-1, 1, size, dtype=np.float32)
px, py = np.meshgrid(lin, -lin) # y flipped so top = 1
# Ray direction from camera
oz = 3.0
rdx, rdy, rdz = px, py, -oz * np.ones((size, size), dtype=np.float32)
mag = np.sqrt(rdx**2 + rdy**2 + rdz**2)
rdx /= mag; rdy /= mag; rdz /= mag
# Ray-sphere: t^2 + 2t(o·d) + |o|^2 - r^2 = 0
# o = (0, 0, oz), sphere center = (0, 0, 0)
b = 2 * (oz * rdz) # ox=oy=0
c = oz**2 - r**2
disc = b**2 - 4 * c
hit = disc >= 0.0
t_arr = np.where(hit, (-b - np.sqrt(np.maximum(disc, 0.0))) / 2.0, np.inf)
# Hit position
hx = rdx * t_arr
hy = rdy * t_arr
hz = oz + rdz * t_arr
# Normals (outward) — normalise hit position since sphere at origin radius r
norm = np.sqrt(hx**2 + hy**2 + hz**2)
norm = np.where(hit, norm, 1.0) # avoid /0 in miss pixels
nx, ny, nz = hx / norm, hy / norm, hz / norm
# Spherical UV: u = lon / 2π, v = lat / π + 0.5
u_coord = (np.arctan2(nz, nx) / (2 * math.pi)) % 1.0
v_coord = np.arcsin(np.clip(ny, -1, 1)) / math.pi + 0.5
return hit, nx, ny, nz, u_coord, v_coord
# ─────────────────────────────────────────────────────────────────────────────
# Lighting
# ─────────────────────────────────────────────────────────────────────────────
def light(nx, ny, nz, lx=-0.6, ly=0.5, lz=-0.4, shininess=20.0):
"""
Diffuse + specular from a fixed star direction.
Returns diffuse (float array) and specular (float array).
"""
lmag = math.sqrt(lx**2 + ly**2 + lz**2)
lx /= lmag; ly /= lmag; lz /= lmag
diffuse = np.clip(nx * lx + ny * ly + nz * lz, 0.0, 1.0)
# Reflect: r = 2(n·l)n - l
ndotl = nx * lx + ny * ly + nz * lz
rx = 2 * ndotl * nx - lx
ry = 2 * ndotl * ny - ly
rz = 2 * ndotl * nz - lz
# View direction: toward camera at (0,0,3) — for normalized normals ~(0,0,1) approx
vz = 1.0
spec = np.clip(rx * 0 + ry * 0 + rz * vz, 0.0, 1.0) ** shininess
return diffuse, spec
def lerp_color(a, b, t):
"""Blend between two RGB tuples by t (0-1 scalar or array)."""
t = np.clip(t, 0, 1)
if hasattr(t, '__len__'):
t = t[..., np.newaxis]
return np.array(a) * (1 - t) + np.array(b) * t
# ─────────────────────────────────────────────────────────────────────────────
# Planet type renderers
# ─────────────────────────────────────────────────────────────────────────────
def render_planet(hit, nx, ny, nz, u, v, surface_fn, cloud_fn=None,
bg=(4, 6, 10), star_lx=-0.55, star_ly=0.45, star_lz=0.70,
has_atmosphere=True):
"""
Core render loop. surface_fn(u, v, nx, ny, nz) → RGB float [0..1].
cloud_fn(u, v) → alpha float [0..1] or None.
Returns PIL Image (RGBA).
"""
H, W = hit.shape
rgb = np.zeros((H, W, 4), dtype=np.float32)
# Background (space)
rgb[..., 0] = bg[0] / 255.0
rgb[..., 1] = bg[1] / 255.0
rgb[..., 2] = bg[2] / 255.0
rgb[..., 3] = 1.0
# Star field
rng = np.random.default_rng(42)
star_mask = rng.random((H, W)) < 0.002
star_bright = rng.uniform(0.4, 1.0, (H, W))
rgb[~hit & star_mask, 0] = star_bright[~hit & star_mask]
rgb[~hit & star_mask, 1] = star_bright[~hit & star_mask]
rgb[~hit & star_mask, 2] = star_bright[~hit & star_mask]
if not hit.any():
arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
return Image.fromarray(arr)
# Work only on hit pixels to avoid NaN propagation from miss areas
h_idx = np.where(hit)
nx_h = nx[h_idx]; ny_h = ny[h_idx]; nz_h = nz[h_idx]
u_h = u[h_idx]; v_h = v[h_idx]
# Surface color (compute on hit pixels)
surf_full = surface_fn(u, v, nx, ny, nz) # full grid for simplicity
surf_h = surf_full[h_idx] # (N, 3)
# Lighting on hit pixels only
lmag = math.sqrt(star_lx**2 + star_ly**2 + star_lz**2)
lx = star_lx / lmag; ly = star_ly / lmag; lz = star_lz / lmag
diff_h = np.clip(nx_h * lx + ny_h * ly + nz_h * lz, 0.0, 1.0)
ndotl = nx_h * lx + ny_h * ly + nz_h * lz
rx_h = 2 * ndotl * nx_h - lx
ry_h = 2 * ndotl * ny_h - ly
rz_h = 2 * ndotl * nz_h - lz
spec_h = np.clip(rz_h, 0.0, 1.0) ** 25 # view dir = +z approx
# Ambient + diffuse + spec — generous ambient for readability (dark side not black)
ambient = 0.22
lit_h = surf_h * (ambient + 0.78 * diff_h[:, np.newaxis]) + spec_h[:, np.newaxis] * 0.30
# Cloud layer
if cloud_fn is not None:
cloud_full = cloud_fn(u, v)
cloud_h = cloud_full[h_idx]
cloud_alpha_h = np.clip(cloud_h * 0.9, 0, 1)
cloud_rgb_h = np.ones((len(h_idx[0]), 3)) * 0.96
cloud_lit_h = cloud_rgb_h * (ambient + 0.85 * diff_h[:, np.newaxis])
lit_h = lit_h * (1 - cloud_alpha_h[:, np.newaxis]) + cloud_lit_h * cloud_alpha_h[:, np.newaxis]
# Atmosphere rim glow (blue edge)
if has_atmosphere:
rim_h = np.abs(nz_h) # dot with view +z
rim_glow_h = (1 - rim_h) ** 5 * 0.7
atmo_c = np.array([0.35, 0.60, 1.0])
lit_h = lit_h + rim_glow_h[:, np.newaxis] * atmo_c * 0.35
lit_h = np.clip(lit_h, 0, 1)
out = np.zeros((H * W, 3), dtype=np.float32)
out[np.ravel_multi_index(h_idx, (H, W))] = lit_h
out = out.reshape(H, W, 3)
rgb[hit, 0] = out[hit, 0]
rgb[hit, 1] = out[hit, 1]
rgb[hit, 2] = out[hit, 2]
rgb[hit, 3] = 1.0
arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
return Image.fromarray(arr)
# ─────────────────────────────────────────────────────────────────────────────
# Planet type definitions
# ─────────────────────────────────────────────────────────────────────────────
def planet_temperate(out_dir):
"""
Temperate / Earth-like. Continents (amber-brown), ocean (blue), polar caps.
Character: "amber continental shelves" per Kallast wiki.
"""
hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
n = proc_noise(u * 2, v * 3, seed=1.1)
n2 = proc_noise(u * 5, v * 7, seed=2.3, octaves=3)
def surface(u, v, nx, ny, nz):
h = n * 0.7 + n2 * 0.3
lat = (v - 0.5) * 2 # [-1, 1]
polar = np.abs(lat) ** 3
land = h > 0.08
# Ocean: rich blue, deepens with depth
ocean_t = np.clip((h + 0.6) * 0.9, 0, 1)
ocean_c = lerp_color((8, 28, 80), (35, 100, 175), ocean_t) / 255.0
# Land: amber grain belt → hillside → highland
land_t = np.clip((h - 0.08) / 0.55, 0, 1)
land_c = lerp_color((155, 165, 75), (105, 90, 65), land_t) / 255.0
# Forest band at mid elevation
forest_band = np.clip((land_t - 0.2) * 5, 0, 1) * np.clip((0.6 - land_t) * 5, 0, 1)
forest_c = np.array([55, 100, 50]) / 255.0
land_c = land_c * (1 - forest_band[..., np.newaxis]) + forest_c * forest_band[..., np.newaxis]
base = np.where(land[..., np.newaxis], land_c, ocean_c)
# Polar ice caps
ice_c = np.array([0.88, 0.91, 0.97])
polar_blend = np.clip((polar - 0.55) * 5, 0, 1)
base = base * (1 - polar_blend[..., np.newaxis]) + ice_c * polar_blend[..., np.newaxis]
return base
def clouds(u, v):
c1 = proc_noise(u * 3, v * 2, seed=9.1, octaves=4)
return np.clip((c1 + 0.2) * 1.5, 0, 1) * 0.5
img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
img.save(os.path.join(out_dir, "planet_temperate.png"))
print(f" planet_temperate.png")
def planet_temperate_terminator(out_dir):
"""
Tidally locked. One face scorched, one face frozen, habitable terminator band.
Character: "terminator-band settlement" per Feldmark, Caparica wikis.
"""
hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
n = proc_noise(u * 2, v * 2, seed=3.3)
def surface(u, v, nx, ny, nz):
# Longitude position: 0=day face, 0.5=night face
lon = (u - 0.25) % 1.0 # shift so day face is center
day = np.clip(1 - lon * 2, 0, 1)
night = np.clip(lon * 2 - 1, 0, 1)
term = 1 - day - night
h = n * 0.5
day_c = np.array([0.80, 0.55, 0.25]) # scorched orange-gold
night_c = np.array([0.15, 0.20, 0.30]) # frozen dark blue
term_c = lerp_color((85, 130, 90), (130, 160, 100), np.clip(h, 0, 1)) / 255.0
base = (day_c * day[..., np.newaxis] +
night_c * night[..., np.newaxis] +
term_c * term[..., np.newaxis])
return np.clip(base, 0, 1)
def clouds(u, v):
c = proc_noise(u * 2.5, v * 4, seed=11.1, octaves=3)
lon = (u - 0.25) % 1.0
term_weight = np.clip(1 - np.abs(lon - 0.5) * 4, 0, 1)
return np.clip((c + 0.3) * 0.6, 0, 1) * term_weight
img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
img.save(os.path.join(out_dir, "planet_temperate_terminator.png"))
print(f" planet_temperate_terminator.png")
def planet_oceanic(out_dir):
"""
Ocean world. Mostly water, scattered archipelagos.
Character: Caparica — aquaculture domes visible, terminator farming.
"""
hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
n = proc_noise(u * 4, v * 5, seed=5.5, octaves=4)
def surface(u, v, nx, ny, nz):
h = n
land = h > 0.55 # very little land — archipelago only
ocean_deep_c = np.array([10, 30, 70]) / 255.0
ocean_shallow_c = np.array([30, 80, 130]) / 255.0
ocean_t = np.clip((h + 0.5) * 0.8, 0, 1)
ocean_c = ocean_deep_c * (1 - ocean_t[..., np.newaxis]) + ocean_shallow_c * ocean_t[..., np.newaxis]
land_c = np.array([80, 120, 70]) / 255.0
base = np.where(land[..., np.newaxis], land_c, ocean_c)
# Polar ice
lat = np.abs(v - 0.5) * 2
ice = np.clip((lat - 0.7) * 5, 0, 1)
base = base * (1 - ice[..., np.newaxis]) + np.array([0.85, 0.90, 0.95]) * ice[..., np.newaxis]
return base
def clouds(u, v):
c = proc_noise(u * 2, v * 3, seed=22.2, octaves=4)
return np.clip((c + 0.3) * 0.7, 0, 1) * 0.7
img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
img.save(os.path.join(out_dir, "planet_oceanic.png"))
print(f" planet_oceanic.png")
def planet_arid(out_dir):
"""
Arid / desert / Mars-analog. Reddish-orange dust, no permanent surface water.
"""
hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
n = proc_noise(u * 2, v * 3, seed=7.7, octaves=4)
n2 = proc_noise(u * 8, v * 10, seed=8.2, octaves=2)
def surface(u, v, nx, ny, nz):
h = n * 0.6 + n2 * 0.4
base_t = np.clip((h + 0.5) * 0.9, 0, 1)
dark_c = np.array([100, 50, 35]) / 255.0
light_c = np.array([190, 130, 80]) / 255.0
base = dark_c * (1 - base_t[..., np.newaxis]) + light_c * base_t[..., np.newaxis]
# Dust storm wisps
storm = proc_noise(u * 6, v * 2, seed=13.1, octaves=3)
storm_alpha = np.clip((storm + 0.6) * 0.3, 0, 1)
dust_c = np.array([0.78, 0.60, 0.45])
base = base * (1 - storm_alpha[..., np.newaxis]) + dust_c * storm_alpha[..., np.newaxis]
# Thin polar cap
lat = np.abs(v - 0.5) * 2
ice = np.clip((lat - 0.85) * 8, 0, 1)
base = base * (1 - ice[..., np.newaxis]) + np.array([0.90, 0.88, 0.88]) * ice[..., np.newaxis]
return np.clip(base, 0, 1)
# No cloud layer (thin atmosphere)
img = render_planet(hit, nx, ny, nz, u, v, surface, cloud_fn=None)
img.save(os.path.join(out_dir, "planet_arid.png"))
print(f" planet_arid.png")
def planet_frozen(out_dir):
"""
Ice world. White/blue, glacial features, ice caps extend to equator.
"""
hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
n = proc_noise(u * 3, v * 4, seed=11.1, octaves=5)
n2 = proc_noise(u * 7, v * 9, seed=12.3, octaves=3)
def surface(u, v, nx, ny, nz):
h = n * 0.6 + n2 * 0.4
# Ice everywhere — variation between white and blue-grey
base_t = np.clip((h + 0.3) * 0.9, 0, 1)
deep_ice = np.array([0.55, 0.65, 0.80])
snow_c = np.array([0.90, 0.92, 0.96])
base = deep_ice * (1 - base_t[..., np.newaxis]) + snow_c * base_t[..., np.newaxis]
# Exposed rock patches at mid-latitude
lat = np.abs(v - 0.5) * 2
rock_zone = np.clip((0.4 - lat) * 3, 0, 1) * np.clip(n2 + 0.1, 0, 1)
rock_c = np.array([0.35, 0.32, 0.30])
base = base * (1 - rock_zone[..., np.newaxis]) + rock_c * rock_zone[..., np.newaxis]
return base
def clouds(u, v):
c = proc_noise(u * 2, v * 2, seed=30.0, octaves=3)
return np.clip((c + 0.4) * 0.4, 0, 1) * 0.3
img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
img.save(os.path.join(out_dir, "planet_frozen.png"))
print(f" planet_frozen.png")
def planet_volcanic(out_dir):
"""
Active volcanic world. Dark basalt surface with orange/red lava flows.
"""
hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
n = proc_noise(u * 3, v * 4, seed=15.5, octaves=4)
n2 = proc_noise(u * 10, v * 12, seed=16.7, octaves=3)
def surface(u, v, nx, ny, nz):
h = n * 0.7 + n2 * 0.3
# Dark basalt base
basalt_t = np.clip((h + 0.5) * 0.6, 0, 1)
dark_c = np.array([25, 22, 20]) / 255.0
mid_c = np.array([65, 55, 50]) / 255.0
base = dark_c * (1 - basalt_t[..., np.newaxis]) + mid_c * basalt_t[..., np.newaxis]
# Lava flows: bright orange channels in low-elevation areas
lava_t = np.clip((-h - 0.1) * 3, 0, 1)
lava_c = np.array([0.95, 0.45, 0.10])
base = base + lava_c * lava_t[..., np.newaxis]
# Volcano glow spots
g = proc_noise(u * 15, v * 15, seed=17.3, octaves=2)
glow = np.clip((g - 0.7) * 5, 0, 1)
glow_c = np.array([1.0, 0.6, 0.2])
base = base + glow_c * glow[..., np.newaxis] * 0.5
return np.clip(base, 0, 1)
def clouds(u, v):
# Volcanic haze — yellowish-brown sulfur clouds
c = proc_noise(u * 4, v * 3, seed=55.0, octaves=3)
return np.clip((c + 0.5) * 0.6, 0, 1) * 0.4
# Warmer star (volcanic world might be inner orbit — stronger illumination)
img = render_planet(hit, nx, ny, nz, u, v, surface, clouds,
star_lx=-0.55, star_ly=0.40, star_lz=0.73)
img.save(os.path.join(out_dir, "planet_volcanic.png"))
print(f" planet_volcanic.png")
def planet_barren(out_dir):
"""
Airless barren world. Cratered grey-brown, no atmosphere glow.
"""
hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
n = proc_noise(u * 3, v * 4, seed=20.0, octaves=4)
n2 = proc_noise(u * 12, v * 14, seed=21.3, octaves=2)
def surface(u, v, nx, ny, nz):
h = n * 0.6 + n2 * 0.4
t = np.clip((h + 0.5) * 0.8, 0, 1)
dark_c = np.array([50, 45, 42]) / 255.0
light_c = np.array([140, 130, 120]) / 255.0
base = dark_c * (1 - t[..., np.newaxis]) + light_c * t[..., np.newaxis]
return base
# No clouds, no atmosphere glow — hard terminator
img = render_planet(hit, nx, ny, nz, u, v, surface, cloud_fn=None,
has_atmosphere=False)
img.save(os.path.join(out_dir, "planet_barren.png"))
print(f" planet_barren.png")
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
PLANET_TYPES = [
("temperate", planet_temperate),
("temperate_terminator", planet_temperate_terminator),
("oceanic", planet_oceanic),
("arid", planet_arid),
("frozen", planet_frozen),
("volcanic", planet_volcanic),
("barren", planet_barren),
]
def main():
parser = argparse.ArgumentParser(description="Generate procedural planet screenshots")
parser.add_argument("--output-dir", default="client/assets/planets",
help="Directory for output PNG files")
parser.add_argument("--type", choices=[p[0] for p in PLANET_TYPES],
help="Render only one planet type")
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
print(f"Output dir: {args.output_dir}")
print(f"Rendering {SIZE}×{SIZE}px spheres…\n")
targets = PLANET_TYPES if not args.type else [(t, fn) for t, fn in PLANET_TYPES if t == args.type]
for ptype, fn in targets:
fn(args.output_dir)
print(f"\nDone. {len(targets)} planet type(s) written to {args.output_dir}/")
print("For wiki/GTTR display: scale to 240×240 within the 360×360 panel container.")
if __name__ == "__main__":
main()