feat: richer nebula sky and planet surface textures

Extend gen_nebula_sky.py with two more dust-lane layers at different
scales and subtle hue variation within the bright core. Add
gen_planet_surface.py (no prior generator existed) producing latitude
bands, storm vortices, and a lit/unlit terminator baked from the
planet mesh's actual UV convention against arena_02's directional
light, verified in-engine via screenshots.
This commit is contained in:
Josh Creek
2026-08-04 07:18:51 +01:00
parent 3257f5cbcc
commit 73fb83a0da
6 changed files with 223 additions and 1 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

+1 -1
View File
@@ -41,7 +41,7 @@ A subagent ran the game and critiqued arena_02 head-on against Rocket League 2 i
- [x] **Greeble/detail pass on the station + debris models** (Blender remodel — explicit models to redo). Rebuilt via a new committed generator, `tools/blender/gen_nebula.py` (`nebula_decoration.blend` had no prior script, unlike ship/ball — this brings it in line): the station gets panel-line inset/extrude greeble across three passes and 3 separate emissive window strips (was 1), and debris gets its own `Mat_DebrisRock`/`Mat_DebrisScorch` materials plus per-vertex jitter and impact-crater gouges instead of cloning the station's `Hull_Metal_Dark`. The full normal-map/AO bake was skipped as too fragile to script reliably (per this item's own fallback allowance) — detail is geometry + material-only, no trim texture needed on top.
Prompt: "Using the blender MCP, rebuild `nebula_station` (source in `Game/assets/blender_models/nebula_decoration.blend`) with actual surface detail: greebled panel-line insets via bmesh inset-and-extrude on selected faces (more scriptable and robust than chained boolean cuts — prefer this over booleans for the main detailing pass), and 2-3 emissive window/light strips distinct from the existing single accent strip. A full normal-map/AO bake pipeline (low-poly + high-poly pair, UV unwrap, bake settings) is the ideal AAA-style finish but is fragile to script end-to-end in one pass — attempt it, but if it proves too unreliable, fall back to the inset/extrude geometric detail alone plus a simple tiled trim-sheet-style texture rather than forcing a bake that doesn't work. Give `nebula_debris` chunks a rockier/damaged material distinct from the station's clean hull (darker, rougher, maybe scorch-mark variation) so they read as separate debris rather than clones of the station's material. Re-export both to `Game/assets/models/nebula_station.glb`/`nebula_debris.glb`, re-save the shared `.blend`, and re-verify placement/transforms in `arena_02.tscn` still look right (screenshot check)."
- [ ] **Richer nebula sky + planet surface textures** (extends the existing procedural generation — no new asset types). `sky_nebula.png` is essentially one noise-filter pass over a bright core; `planet_surface.png` is a flat gradient with a single vortex swirl and no bands, craters, or day/night terminator.
- [x] **Richer nebula sky + planet surface textures** (extends the existing procedural generation — no new asset types). `sky_nebula.png` is essentially one noise-filter pass over a bright core; `planet_surface.png` is a flat gradient with a single vortex swirl and no bands, craters, or day/night terminator. Done: `tools/textures/gen_nebula_sky.py` gained two more dust-lane layers at different scales plus subtle patchy hue variation within the bright core; `tools/textures/gen_planet_surface.py` is a new generator (no prior script existed, despite the note below) producing latitude bands, 3 storm vortices, and a lit/unlit terminator baked from the sphere's exact UV convention (reverse-engineered from `nebula_planet.glb`'s vertex data) dotted against `arena_02.tscn`'s actual `DirectionalLight3D` direction — verified in-engine via godot-mcp screenshots showing a clean crescent terminator; both `Game/assets/textures/planet_surface.png` and the live-feeding sidecar `Game/assets/models/nebula_planet_planet_surface.png` were regenerated and reimported.
Prompt: "Extend the nebula/planet texture generators (see `tools/textures/` once committed, per the star-sprite TODO item above) with more detail layers: for the sky, add a second/third dust-lane layer at a different scale plus subtler color variation within the bright core (real nebulae aren't one flat color); for the planet, add a proper lit/unlit terminator gradient (the side facing the arena's directional light should read brighter), more band variation at different latitudes, and a couple more storm-vortex features so it doesn't read as a single gradient with one twist. Regenerate both textures, reimport, and screenshot-verify."
- [ ] **Particle lighting response for the nebula dust ("weather") effect** (no models — particle material/shader only). The `NebulaDust` `GPUParticles3D` motes are generic soft glow sprites with no lighting interaction, no depth-based fade, and no secondary motion (no sparkle/color shift).
+33
View File
@@ -61,6 +61,14 @@ haze = bilinear_sample(fft_field(3.0, seed=21), xx + warp_x * 1.3, yy + warp_y *
ridged = 1.0 - np.abs(2.0 * bilinear_sample(fft_field(1.6, seed=31), xx + warp_x * 0.8, yy + warp_y * 0.8) - 1.0)
dust = np.clip((ridged - 0.72) / 0.28, 0.0, 1.0) ** 1.5
# Two more dust-lane layers at scales straddling the primary one above, so the sky
# reads as several overlapping filament systems instead of one repeated pattern.
ridged_fine = 1.0 - np.abs(2.0 * bilinear_sample(fft_field(1.0, seed=41), xx + warp_x * 1.6, yy + warp_y * 1.6) - 1.0)
dust_fine = np.clip((ridged_fine - 0.78) / 0.22, 0.0, 1.0) ** 1.2 # delicate, sparse filaments
ridged_wide = 1.0 - np.abs(2.0 * bilinear_sample(fft_field(2.2, seed=51), xx + warp_x * 0.4, yy + warp_y * 0.4) - 1.0)
dust_wide = np.clip((ridged_wide - 0.65) / 0.35, 0.0, 1.0) ** 1.5 # broad, soft secondary lane
# Confine the bright nebula body to an off-center round-ish region (like the reference's
# bright core), fading into the darker starfield toward the poles/edges.
cx, cy = W * 0.42, H * 0.46
@@ -104,6 +112,22 @@ def ramp(t):
color = ramp(density)
print("adding core color variation...")
# Real emission nebulae mix H-alpha (magenta/red) and OIII (faint teal/cyan) regions
# rather than being one flat hue at a given brightness -- nudge hue in slow-varying
# patches, confined to the bright body, on top of the brightness-only ramp above.
hue_field = bilinear_sample(fft_field(2.4, seed=61), xx + warp_x * 0.9, yy + warp_y * 0.9)
hue_field = (hue_field - 0.5) * 2.0 # -1..1
warm_tint = np.array([0.25, 0.02, -0.05])
cool_tint = np.array([-0.12, 0.10, 0.06])
hue_variation = np.where(
hue_field[..., None] > 0,
hue_field[..., None] * warm_tint[None, None, :],
-hue_field[..., None] * cool_tint[None, None, :],
)
core_variation_strength = (mask ** 0.8) * 0.22
color = np.clip(color + hue_variation * core_variation_strength[..., None], 0.0, 1.0)
# Blue-violet outer haze wash, additive, strongest away from the bright core.
haze_color = np.array([0.10, 0.11, 0.30])
haze_amount = (haze * 0.5 + 0.5) * (1.0 - mask) * 0.35
@@ -113,6 +137,15 @@ color += haze_amount[..., None] * haze_color[None, None, :]
dust_strength = (dust * mask * 0.85)[..., None]
color *= (1.0 - dust_strength * 0.9)
# Two more dust-lane layers at different scales, weaker than the primary lane. The
# wide lane is allowed to bleed slightly past the core edge (mask relaxed) since real
# dust lanes don't stop precisely at a nebula's bright-body boundary.
dust_fine_strength = (dust_fine * mask * 0.55)[..., None]
color *= (1.0 - dust_fine_strength * 0.55)
dust_wide_strength = (dust_wide * (mask * 0.7 + 0.15) * 0.5)[..., None]
color *= (1.0 - dust_wide_strength * 0.45)
color = np.clip(color, 0.0, 1.0)
print("adding stars...")
+189
View File
@@ -0,0 +1,189 @@
"""Generates Game/assets/textures/planet_surface.png (and the byte-identical
Game/assets/models/nebula_planet_planet_surface.png sidecar that actually feeds
the live nebula_planet.glb material -- Godot's glTF importer extracted the
embedded image there at import time, so the imported scene references that
external file, not the glb's internal binary): a 2048x1024 equirectangular
decorative planet with latitude bands, storm vortices, and a lit/unlit
terminator, via layered FFT/domain-warped noise in the same style as
gen_nebula_sky.py (helpers duplicated here to keep both scripts standalone).
"""
from pathlib import Path
import numpy as np
from PIL import Image
W, H = 2048, 1024
# Direction *toward* arena_02's DirectionalLight3D in world space (its basis Z-column;
# NebulaPlanet has no rotation, so mesh-local axes equal world axes and this can be used
# directly against the UV-derived normal below). A surface texel is lit when its normal
# points roughly toward this direction.
LIGHT_DIR = np.array([0.321394, 0.766044, 0.55667])
# Sphere's exact UV convention (extracted from Game/assets/models/nebula_planet.glb's
# vertex data: +X -> uv(0.5,0.5), -X -> uv(0.0,0.5), +Z -> uv(0.25,0.5),
# -Z -> uv(0.75,0.5), +Y -> uv(*, 0.0), -Y -> uv(*, 1.0)):
# theta = v*pi, phi = 2*pi*(u-0.5)
# x = sin(theta)*cos(phi), y = cos(theta), z = -sin(theta)*sin(phi)
def fft_field(power, seed, remove_dc=True):
r = np.random.default_rng(seed)
white = r.normal(size=(H, W))
F = np.fft.fft2(white)
fy = np.fft.fftfreq(H)[:, None]
fx = np.fft.fftfreq(W)[None, :]
freq = np.sqrt(fx ** 2 + fy ** 2)
freq[0, 0] = 1e-6
filt = 1.0 / (freq ** power)
if remove_dc:
filt[0, 0] = 0.0
field = np.fft.ifft2(F * filt).real
field -= field.min()
field /= (field.max() + 1e-9)
return field
def bilinear_sample(field, xs, ys):
x0 = np.floor(xs).astype(np.int64) % W
x1 = (x0 + 1) % W
y0 = np.clip(np.floor(ys).astype(np.int64), 0, H - 1)
y1 = np.clip(y0 + 1, 0, H - 1)
fx = xs - np.floor(xs)
fy = ys - np.floor(ys)
v00 = field[y0, x0]
v10 = field[y0, x1]
v01 = field[y1, x0]
v11 = field[y1, x1]
return v00 * (1 - fx) * (1 - fy) + v10 * fx * (1 - fy) + v01 * (1 - fx) * fy + v11 * fx * fy
def ramp(t, stops):
out = np.zeros(t.shape + (3,), dtype=np.float64)
for i in range(len(stops) - 1):
t0, c0 = stops[i]
t1, c1 = stops[i + 1]
seg = (t >= t0) & (t <= t1)
if not np.any(seg):
continue
local = (t[seg] - t0) / (t1 - t0)
for ch in range(3):
out[seg, ch] = c0[ch] + (c1[ch] - c0[ch]) * local
return out
yy, xx = np.mgrid[0:H, 0:W].astype(np.float64)
print("generating warp fields...")
warp_x = (fft_field(3.0, seed=101) - 0.5) * 160
warp_y = (fft_field(3.0, seed=102) - 0.5) * 80
print("generating storm vortices...")
# Each vortex spirals nearby coordinates around its center before bands/turbulence
# are sampled through them, so bands visibly swirl into the storm rather than sitting
# as flat stripes underneath a cosmetic overlay.
vortex_defs = [
{"cx": W * 0.50, "cy": H * 0.50, "radius": W * 0.09, "strength": 2.6, "dir": 1},
{"cx": W * 0.20, "cy": H * 0.30, "radius": W * 0.05, "strength": 1.8, "dir": -1},
{"cx": W * 0.78, "cy": H * 0.68, "radius": W * 0.045, "strength": -2.0, "dir": 1},
]
vx, vy = xx.copy(), yy.copy()
falloffs = []
for v in vortex_defs:
dxv = xx - v["cx"]
dxv -= W * np.round(dxv / W) # shortest signed horizontal wrap distance
dyv = yy - v["cy"]
rr = np.sqrt(dxv ** 2 + dyv ** 2)
falloff = np.exp(-((rr / v["radius"]) ** 2))
falloffs.append(falloff)
theta = np.arctan2(dyv, dxv) + v["dir"] * v["strength"] * falloff
vx = np.where(falloff > 0.01, v["cx"] + rr * np.cos(theta), vx)
vy = np.where(falloff > 0.01, v["cy"] + rr * np.sin(theta), vy)
print("generating latitude bands...")
band_count = 8
lat_norm = vy / H
lat_wobble = bilinear_sample(fft_field(2.2, seed=111), vx * 0.5 + warp_x * 1.4, vy + warp_y * 1.4)
warped_lat = lat_norm + (lat_wobble - 0.5) * 0.10
band_id = np.floor(warped_lat * band_count)
band_frac = warped_lat * band_count - band_id
# Wide, soft transition zone (most of each band's width) so bands blend into each
# other like atmospheric flow rather than reading as flat-shaded stripes.
band_edge = np.clip(np.minimum(band_frac, 1 - band_frac) / 0.42, 0.0, 1.0)
band_edge = band_edge * band_edge * (3 - 2 * band_edge) # smoothstep
rng_bands = np.random.default_rng(112)
band_shade = rng_bands.uniform(-1, 1, band_count + 2)
band_shade_field = band_shade[np.clip(band_id.astype(np.int64), 0, band_count + 1)]
turbulence = bilinear_sample(fft_field(1.4, seed=121), vx + warp_x * 0.6, vy + warp_y * 0.6)
value = np.clip(0.5 + band_shade_field * 0.13 * (0.4 + 0.6 * band_edge) + (turbulence - 0.5) * 0.20, 0.0, 1.0)
print("color grading...")
# Palette-matched to the current bake (dark navy/purple -> dusty pink/mauve) and to
# gen_nebula_sky.py's Orion-esque family, for thematic consistency between the two.
stops = [
(0.00, (0.05, 0.03, 0.12)),
(0.30, (0.16, 0.07, 0.24)),
(0.55, (0.38, 0.14, 0.36)),
(0.75, (0.62, 0.28, 0.46)),
(1.00, (0.86, 0.62, 0.72)),
]
color = ramp(value, stops)
print("adding hue variation...")
hue_field = bilinear_sample(fft_field(2.6, seed=131), vx * 0.7 + warp_x, vy * 0.7 + warp_y)
hue_field = (hue_field - 0.5) * 2.0
warm_tint = np.array([0.22, 0.03, -0.04])
cool_tint = np.array([-0.10, 0.08, 0.05])
hue_variation = np.where(
hue_field[..., None] > 0,
hue_field[..., None] * warm_tint[None, None, :],
-hue_field[..., None] * cool_tint[None, None, :],
)
color = np.clip(color + hue_variation * 0.16, 0.0, 1.0)
print("adding storm highlights...")
for v, falloff in zip(vortex_defs, falloffs):
hi = np.clip((falloff - 0.55) / 0.45, 0.0, 1.0) ** 1.3
tint = np.array([0.35, 0.22, 0.18]) * (0.6 if v["strength"] < 0 else 1.0)
color += hi[..., None] * tint[None, None, :]
color = np.clip(color, 0.0, 1.0)
print("baking terminator...")
u = xx / W
v = yy / H
theta = v * np.pi
phi = 2.0 * np.pi * (u - 0.5)
nx = np.sin(theta) * np.cos(phi)
ny = np.cos(theta)
nz = -np.sin(theta) * np.sin(phi)
lit_raw = nx * LIGHT_DIR[0] + ny * LIGHT_DIR[1] + nz * LIGHT_DIR[2] # -1..1
# Soft-edged but asymmetric terminator band (favors more of the sphere reading lit,
# since a fully half-dark planet reads badly from most camera angles as a background
# decoration). Smoothstep between two dot-product thresholds.
edge0, edge1 = -0.45, 0.35
t = np.clip((lit_raw - edge0) / (edge1 - edge0), 0.0, 1.0)
lit = t * t * (3.0 - 2.0 * t)
# Pushed harder than physically correct: Planet_Surface's emissiveFactor is flat and
# uniform, which washes out real-time per-pixel lighting almost entirely in-engine, so
# the day/night contrast has to be baked directly into the diffuse texture instead.
color = color * (0.28 + 0.85 * lit)[..., None]
color = color + (lit[..., None] ** 2) * np.array([0.07, 0.03, -0.02])[None, None, :] * 0.6
color = np.clip(color, 0.0, 1.0)
print("saving planet surface...")
root = Path(__file__).resolve().parents[2]
out_paths = [
root / "Game" / "assets" / "textures" / "planet_surface.png",
root / "Game" / "assets" / "models" / "nebula_planet_planet_surface.png",
]
img = Image.fromarray((color * 255).astype(np.uint8), "RGB")
for p in out_paths:
img.save(p)
print("saved:", p)
print("done")