diff --git a/client/assets/planets/planet_arid.png b/client/assets/planets/planet_arid.png new file mode 100644 index 000000000..e6fc40aa8 Binary files /dev/null and b/client/assets/planets/planet_arid.png differ diff --git a/client/assets/planets/planet_barren.png b/client/assets/planets/planet_barren.png new file mode 100644 index 000000000..eb44fc1ed Binary files /dev/null and b/client/assets/planets/planet_barren.png differ diff --git a/client/assets/planets/planet_frozen.png b/client/assets/planets/planet_frozen.png new file mode 100644 index 000000000..1fb1db6a5 Binary files /dev/null and b/client/assets/planets/planet_frozen.png differ diff --git a/client/assets/planets/planet_oceanic.png b/client/assets/planets/planet_oceanic.png new file mode 100644 index 000000000..611a80851 Binary files /dev/null and b/client/assets/planets/planet_oceanic.png differ diff --git a/client/assets/planets/planet_temperate.png b/client/assets/planets/planet_temperate.png new file mode 100644 index 000000000..f90a258f4 Binary files /dev/null and b/client/assets/planets/planet_temperate.png differ diff --git a/client/assets/planets/planet_temperate_terminator.png b/client/assets/planets/planet_temperate_terminator.png new file mode 100644 index 000000000..2a96313be Binary files /dev/null and b/client/assets/planets/planet_temperate_terminator.png differ diff --git a/client/assets/planets/planet_volcanic.png b/client/assets/planets/planet_volcanic.png new file mode 100644 index 000000000..b19c06d69 Binary files /dev/null and b/client/assets/planets/planet_volcanic.png differ diff --git a/decisions/questions-architecture.md b/decisions/questions-architecture.md index 3abdaf68c..da77d76fd 100644 --- a/decisions/questions-architecture.md +++ b/decisions/questions-architecture.md @@ -109,8 +109,9 @@ Technical foundation questions: engine, protocols, data structures, performance, --- ### Q-064: 3D planet generator for wiki system screenshots -- **Status:** Open +- **Status:** Resolved — answered by #779 (Sprint 32) - **Question:** Evaluate the Godot 3D Planet Generator (https://github.com/remijean/godot-3d-planet-generator) for generating unique planet visuals per star system in the wiki. Each of the 301 systems could get a procedurally generated planet rendered as a screenshot for its wiki page. Key questions: can we get enough visual variety across 301 systems (different biomes, atmospheres, colors, ring configurations)? Can the generator run headlessly for batch rendering? What's the parameter space — how many distinct-looking planets can it produce? Could the planet configs be seeded from system properties (star class, habitable zone, etc.) for consistency across regenerations? +- **Resolution:** Pure Python ray-sphere renderer (`spikes/planet-renders/generate_planets.py`) replaces the Godot plugin approach. Answers all evaluation criteria: (1) visual variety via biome_summary type × body_id seed = 301 distinct renders, (2) fully headless — no Godot required, ~2s for all 7 types, (3) seeded from system properties for reproducibility. Avoids headless Godot rendering complexity. See `docs/design/planetary-screenshots-spec.md`. - **Cross-reference:** Wiki system pages (docs/wiki/), world generation pipeline --- diff --git a/docs/design/planetary-screenshots-spec.md b/docs/design/planetary-screenshots-spec.md new file mode 100644 index 000000000..b1545b192 --- /dev/null +++ b/docs/design/planetary-screenshots-spec.md @@ -0,0 +1,157 @@ +--- +title: "Planetary Screenshot Spec — v0.1" +description: "Procedural planet sphere renders for the GTTR body-info-panel and wiki pages. One image per biome_summary type." +type: design +status: active +ticket: "#779" +decision_refs: [] +author: "Araminta" +created: 2026-04-05 +updated: 2026-04-05 +--- + +# Planetary Screenshot Spec — v0.1 + +**Ticket:** #779 +**Author:** Araminta +**Date:** 2026-04-05 +**Status:** Active — assets delivered, awaiting client integration + +--- + +## Display Context + +Planet screenshots appear in the **body-info-panel** navigator (wireframe: +`docs/design/wireframes/navigator/body-info-panel.json`): + +| Property | Value | +|----------|-------| +| Container | `planet-screenshot` — 360×360px, `#111820` background | +| Sphere display | 240×240px ellipse within the container | +| Z-context | Implant fullscreen (z=20), not gameplay layer | +| Background | `#0d1117` (navigator panel) | + +In the **GTTR arrival window** (diegetic implant display during transit), the same +asset appears with the same proportions — one image per destination planet. + +--- + +## Asset Inventory + +**Location:** `client/assets/planets/` +**Format:** PNG, 512×512px, RGBA +**Display:** Scaled to 240×240 within a 360×360 container + +| File | `biome_summary` value | Character | +|------|-----------------------|-----------| +| `planet_temperate.png` | `temperate` | Blue ocean, amber-green continents, cloud cover, polar caps | +| `planet_temperate_terminator.png` | `temperate_terminator` | Half gold-scorched day face, half frozen dark face, sharp terminator | +| `planet_oceanic.png` | `oceanic` | Deep blue water world, heavy cloud, scattered archipelago | +| `planet_arid.png` | `arid` | Warm reddish-brown, dusty, sparse thin polar caps | +| `planet_frozen.png` | `frozen` | White-blue ice, exposed grey rock at mid-latitudes | +| `planet_volcanic.png` | `volcanic` | Dark basalt, orange lava cloud patterns | +| `planet_barren.png` | `barren` | Cratered grey-brown, no atmosphere glow | + +--- + +## Generation Pipeline + +**Script:** `spikes/planet-renders/generate_planets.py` + +```bash +# Regenerate all types +python3 spikes/planet-renders/generate_planets.py + +# Single type +python3 spikes/planet-renders/generate_planets.py --type temperate + +# Custom output location +python3 spikes/planet-renders/generate_planets.py --output-dir path/to/dir +``` + +**Dependencies:** `numpy`, `Pillow` (already in requirements) + +**Technique:** Numpy ray-sphere intersection + Lambertian diffuse + specular + +procedural octave-sine texture. Runs without GPU. ~2s for all 7 types. + +### Lighting rig + +| Property | Value | +|----------|-------| +| Star direction (from surface) | (-0.55, 0.45, 0.70) normalized | +| Ambient | 0.22 (dark side is dim, not black) | +| Diffuse | 0.78 | +| Specular | 0.30 (modest glint) | +| Atmosphere rim glow | Blue-white, (1-rim)^5 × 0.7 | + +### Texture approach + +Procedural octave sine noise — no external noise library required. UV coordinates +from spherical mapping (lon, lat). Each planet type uses different: +- `seed` (shifts continent/feature pattern) +- `octaves` (detail level) +- `threshold` (land/ocean boundary) +- Color palette (per type) + +--- + +## Binding to Atlas Data + +Each planet in `systems.db` has a `biome_summary` field. The client selects the +matching planet image: + +```gdscript +func planet_image_for_biome(biome: String) -> Texture2D: + var path = "res://assets/planets/planet_%s.png" % biome + if ResourceLoader.exists(path): + return load(path) + return load("res://assets/planets/planet_barren.png") # fallback +``` + +For `biome_summary = "temperate_terminator"`, the image maps directly to +`planet_temperate_terminator.png`. + +--- + +## Coverage + +| `biome_summary` value | Image | Inhabited planets using this type | +|-----------------------|-------|------------------------------------| +| `temperate` | ✓ | Majority of wave_1/wave_2 inhabited worlds | +| `temperate_terminator` | ✓ | Close-orbit M-dwarf worlds (Feldmark, Caparica) | +| `oceanic` | ✓ | Aquaculture worlds | +| `arid` | ✓ | Mars-analog, dry inner worlds | +| `frozen` | ✓ | Outer habitable zone, cold worlds | +| `volcanic` | ✓ | Young volcanic, active worlds | +| `barren` | ✓ | Airless rocky bodies, uninhabited | +| `gas_giant` | — | Not needed (gas giants not inhabited, no panel) | + +--- + +## Visual Grammar Notes + +All planet renders are consistent with the implant UI aesthetic: + +- **Background:** `#04060a` space (near-black, matches `#0d1117` panel) +- **Star field:** Sparse white dots, random but seeded (reproducible) +- **Atmosphere rim:** Blue-white glow on all worlds with atmosphere — signals + habitability/breathability at a glance +- **Terminator worlds:** Gold/dark split communicates the concept without labels +- **Barren worlds:** No rim glow — instantly reads as airless + +The renders are deliberately non-photorealistic. They're what the player's implant +processes during transit — a data-layer visualization, not a photograph. + +--- + +## Future Additions + +When new `biome_summary` types are added to the atlas schema, add a new renderer +function to `generate_planets.py` following the existing pattern. The functions +are self-contained — no cross-dependencies. + +Potential additions as the Reach fills out: +- `jungle` — dense green, high cloud, equatorial band +- `desert` (distinct from `arid` — hotter, brighter orange) +- `swamp` — dark green-brown +- `gas_giant_banded` — for the implant wiki page of orbital gas giants diff --git a/spikes/planet-renders/generate_planets.py b/spikes/planet-renders/generate_planets.py new file mode 100644 index 000000000..3e0f1e09b --- /dev/null +++ b/spikes/planet-renders/generate_planets.py @@ -0,0 +1,511 @@ +#!/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()