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>
512 lines
21 KiB
Python
512 lines
21 KiB
Python
#!/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()
|