mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
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:
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 |
@@ -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).
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user