mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
401d882ff0
assets/textures/planet_surface.png was byte-identical to and unused in favor of assets/models/nebula_planet_planet_surface.png, the sidecar Godot's glTF importer actually extracted from nebula_planet.glb and uses at runtime. Deleted the orphan (+ its .import) and stopped gen_planet_surface.py from writing it. Also measured nebula_dust.gdshader's per-fragment depth-texture sample cost (~0.07ms/frame at 500 particles, within noise) -- negligible, so no budgeting concern for further particle work.
186 lines
7.2 KiB
Python
186 lines
7.2 KiB
Python
"""Generates Game/assets/models/nebula_planet_planet_surface.png, the 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_path = root / "Game" / "assets" / "models" / "nebula_planet_planet_surface.png"
|
|
img = Image.fromarray((color * 255).astype(np.uint8), "RGB")
|
|
img.save(out_path)
|
|
print("saved:", out_path)
|
|
print("done")
|