diff --git a/.gitignore b/.gitignore index 7d9ba3f8..7817b09d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ training/__pycache__/ # Exported training binary: a regenerable build artifact (rebuilt by # export_linux.sh / run_training.sh), not a training result. training/build/ + +# Texture generator scripts: throwaway env, not the scripts themselves. +tools/textures/.venv/ diff --git a/Game/assets/textures/sky_nebula.png b/Game/assets/textures/sky_nebula.png index 6c5e9f61..6eebb33a 100644 Binary files a/Game/assets/textures/sky_nebula.png and b/Game/assets/textures/sky_nebula.png differ diff --git a/TODO.md b/TODO.md index bddf3d35..c446554f 100644 --- a/TODO.md +++ b/TODO.md @@ -29,10 +29,10 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend A subagent ran the game and critiqued arena_02 head-on against Rocket League 2 in Unreal Engine 5: verdict was "a flat-shaded, unlit-looking blockout dressed up with two nice noise textures" — not fundamentally unfixable in Godot (SDFGI/Nanite-tier GI and hero-sculpted geometry aside), but several concrete gaps. Work through these one at a time, in this order (cheapest/highest-impact first). Each item has a ready-to-use prompt — paste it into a fresh session to tackle just that piece. The texture-generation Python scripts used for `sky_nebula.png`/`planet_surface.png` currently only exist in an ephemeral scratchpad, not the repo — the first item that touches them should commit a copy into the repo (e.g. `tools/textures/`) so they're reproducible. -- [ ] **Post-processing pipeline** (no models needed — cheapest, highest-impact item on the list). Nothing currently sits on top of the raw render: no bloom/glow, no AA, no color grading, no vignette beyond the floor's baked fade. +- [x] **Post-processing pipeline** (no models needed — cheapest, highest-impact item on the list). Nothing currently sits on top of the raw render: no bloom/glow, no AA, no color grading, no vignette beyond the floor's baked fade. Prompt: "In Cosmic Clash (Godot 4.7), the arena Environment resources (start with `Game/scenes/arena_02.tscn`'s `Environment_nebula`, then apply consistently across `arena_01`/`arena_03` too) currently have no post-processing. Enable and tune Glow (bloom) so the nebula core, emissive accent strips on the station model, and bright stars actually bleed light; enable Adjustments (`adjustment_enabled`) for a subtle contrast/saturation grade. Godot's `Environment` has no built-in vignette property — if that's still wanted, it needs a small custom full-screen shader (a `CompositorEffect` or a screen-space `ColorRect` overlay), not a toggle; treat it as a separate, smaller sub-task rather than assuming it's free. Also enable FXAA and/or TAA via `project.godot`'s `rendering/anti_aliasing/quality/*` settings project-wide to fix the aliased hard edges visible on ship geometry. Motion blur and depth-of-field are deliberately out of scope here — Godot has no built-in equivalent, and faking either well needs a custom `CompositorEffect`, which is a much bigger task than this pass. Verify with before/after screenshots via godot-mcp (`run_project` on `free_play.tscn` with the Nebula arena selected) — don't just eyeball the editor, actually run the game." -- [ ] **De-duplicate the nebula sky's star sprites**. Every bright star in `sky_nebula.png` is the exact same diffraction-spike stamp at the same size/brightness, just relocated — called out as "the single most amateur-looking tell in the whole scene" once you look for more than a second. +- [x] **De-duplicate the nebula sky's star sprites**. Every bright star in `sky_nebula.png` is the exact same diffraction-spike stamp at the same size/brightness, just relocated — called out as "the single most amateur-looking tell in the whole scene" once you look for more than a second. Fixed: the generator is now committed at `tools/textures/gen_nebula_sky.py`; the hero-star loop randomizes rotation, arm count (4 or 8), spike length, and brightness per star, and `sky_nebula.png` was regenerated/reimported and confirmed via in-game screenshot. Prompt: "Commit the nebula sky texture generator (currently only in an ephemeral scratchpad — recreate it if needed: numpy/Pillow script generating a 4096x2048 equirect nebula via layered FFT/domain-warped noise, color-graded, with drawn hero stars) into the repo at `tools/textures/gen_nebula_sky.py`. Fix the hero-star drawing loop so each star's diffraction-spike stamp gets randomized rotation, spike length, and brightness instead of reusing one identical stamp at every location. Regenerate `Game/assets/textures/sky_nebula.png`, reimport, and confirm via an in-game screenshot that the repeated-stamp tell is gone." - [ ] **Real PBR lighting + materials, fresnel glass boundary** (no new models — material/lighting setup only). Everything is flat-shaded/unshaded with a single directional light: no GI, no reflections, no bounce/AO, and the "glass" boundary is a flat unshaded tinted overlay with no fresnel rim or reflection. diff --git a/tools/textures/gen_nebula_sky.py b/tools/textures/gen_nebula_sky.py new file mode 100644 index 00000000..6191a9df --- /dev/null +++ b/tools/textures/gen_nebula_sky.py @@ -0,0 +1,180 @@ +"""Generates Game/assets/textures/sky_nebula.png: a 4096x2048 equirectangular +nebula skybox via layered FFT/domain-warped noise, color-graded, with drawn +hero stars (bright stars get a diffraction-spike stamp). +""" + +import math +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +W, H = 4096, 2048 + + +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 + + +yy, xx = np.mgrid[0:H, 0:W].astype(np.float64) + +print("generating warp fields...") +warp_x = (fft_field(3.2, seed=1) - 0.5) * 320 +warp_y = (fft_field(3.2, seed=2) - 0.5) * 160 + +print("generating density octaves...") +oct_a = bilinear_sample(fft_field(2.6, seed=11), xx + warp_x, yy + warp_y) +oct_b = bilinear_sample(fft_field(1.9, seed=12), xx + warp_x * 0.6, yy + warp_y * 0.6) +oct_c = bilinear_sample(fft_field(1.3, seed=13), xx + warp_x * 0.3, yy + warp_y * 0.3) +density = oct_a * 0.55 + oct_b * 0.30 + oct_c * 0.15 +density = (density - density.min()) / (density.max() - density.min()) + +print("generating haze + dust lanes...") +haze = bilinear_sample(fft_field(3.0, seed=21), xx + warp_x * 1.3, yy + warp_y * 1.3) +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 + +# 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 +dx = (xx - cx) +dx = np.minimum(np.abs(dx), W - np.abs(dx)) # wrap horizontally +dy = (yy - cy) +r_warp = 1.0 + (oct_b - 0.5) * 0.55 + (oct_c - 0.5) * 0.35 +r = np.sqrt((dx / (W * 0.30)) ** 2 + (dy / (H * 0.34)) ** 2) * r_warp +mask = np.clip(1.0 - r, 0.0, 1.0) ** 1.4 + +brightness_bias = mask ** 1.3 * 0.88 +density = np.clip(density * (0.45 + 0.25 * mask) + brightness_bias, 0.0, 1.0) + +print("color grading...") +# Colour ramp control points (t, RGB) sampling the Orion-nebula palette: dark void -> +# blue-violet haze -> magenta wisps -> vivid pink body -> bright pink-white core. +stops = [ + (0.00, (0.010, 0.008, 0.018)), + (0.18, (0.045, 0.035, 0.095)), + (0.35, (0.16, 0.09, 0.28)), + (0.55, (0.42, 0.14, 0.42)), + (0.72, (0.75, 0.22, 0.48)), + (0.87, (0.93, 0.45, 0.60)), + (1.00, (0.99, 0.86, 0.88)), +] + + +def ramp(t): + 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 + + +color = ramp(density) + +# 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 +color += haze_amount[..., None] * haze_color[None, None, :] + +# Dust lanes: dark silhouette streaks multiplied into the bright body only. +dust_strength = (dust * mask * 0.85)[..., None] +color *= (1.0 - dust_strength * 0.9) + +color = np.clip(color, 0.0, 1.0) + +print("adding stars...") +rng_stars = np.random.default_rng(777) +n_stars = 5500 +star_x = rng_stars.uniform(0, W, n_stars) +star_y_lat = rng_stars.uniform(-1, 1, n_stars) +star_y = (np.arcsin(star_y_lat) / np.pi + 0.5) * H # denser near equator, thin near poles +brightness = rng_stars.power(2.2, n_stars) # mostly dim, few bright + +star_layer = np.zeros((H, W), dtype=np.float64) +xi = star_x.astype(np.int64) % W +yi = np.clip(star_y.astype(np.int64), 0, H - 1) +star_layer[yi, xi] = np.maximum(star_layer[yi, xi], brightness) + +star_img = Image.fromarray((np.clip(star_layer, 0, 1) * 255).astype(np.uint8), "L") +star_img = star_img.filter(ImageFilter.GaussianBlur(0.6)) + +star_rgb = np.stack([np.array(star_img)] * 3, axis=-1).astype(np.float64) / 255.0 +warm = np.array([1.0, 0.97, 0.90]) +cool = np.array([0.85, 0.92, 1.0]) +tint = rng_stars.uniform(0, 1, (H, W)) +tint_color = warm[None, None, :] * (1 - tint[..., None]) + cool[None, None, :] * tint[..., None] +color = np.clip(color + star_rgb * tint_color, 0.0, 1.0) + +print("drawing hero stars with diffraction spikes...") +draw_img = Image.fromarray((color * 255).astype(np.uint8), "RGB").convert("RGBA") +spike_layer = Image.new("RGBA", (W, H), (0, 0, 0, 0)) +draw = ImageDraw.Draw(spike_layer) +hero_idx = np.argsort(-brightness)[:14] +# Each hero star gets its own randomized diffraction-spike stamp -- rotation, spike +# length, and brightness all jittered per star -- instead of one fixed shape reused at +# every location (that repetition was the single most obvious "amateur" tell up close). +rng_spike = np.random.default_rng(888) +for i in hero_idx: + sx, sy = float(star_x[i]), float(star_y[i]) + b = float(brightness[i]) + n_spikes = int(rng_spike.choice([2, 4])) # 4-arm cross or 8-arm starburst + base_angle = rng_spike.uniform(0.0, math.pi) + length = (26 + b * 70) * rng_spike.uniform(0.7, 1.3) + core_r = (2.5 + b * 4.5) * rng_spike.uniform(0.85, 1.15) + alpha = int(np.clip((180 + 70 * b) * rng_spike.uniform(0.8, 1.15), 0, 255)) + col = (255, 250, 245, alpha) + for k in range(n_spikes): + angle = base_angle + k * (math.pi / n_spikes) + ax, ay = math.cos(angle) * length, math.sin(angle) * length + draw.line([(sx - ax, sy - ay), (sx + ax, sy + ay)], fill=col, width=1) + draw.ellipse([sx - core_r, sy - core_r, sx + core_r, sy + core_r], fill=(255, 253, 250, 255)) +spike_layer = spike_layer.filter(ImageFilter.GaussianBlur(0.8)) +draw_img = Image.alpha_composite(draw_img, spike_layer).convert("RGB") + +print("baking bloom...") +arr = np.array(draw_img).astype(np.float64) / 255.0 +luma = arr[..., 0] * 0.3 + arr[..., 1] * 0.59 + arr[..., 2] * 0.11 +bright_mask = np.clip((luma - 0.55) / 0.45, 0, 1) ** 1.5 +bright_img = Image.fromarray((arr * bright_mask[..., None] * 255).astype(np.uint8), "RGB") +bloom = bright_img.filter(ImageFilter.GaussianBlur(10)) +bloom_arr = np.array(bloom).astype(np.float64) / 255.0 +final = 1.0 - (1.0 - arr) * (1.0 - bloom_arr * 0.9) # screen blend +final = np.clip(final, 0.0, 1.0) + +print("saving final nebula sky...") +out_path = Path(__file__).resolve().parents[2] / "Game" / "assets" / "textures" / "sky_nebula.png" +Image.fromarray((final * 255).astype(np.uint8), "RGB").save(out_path) +print("done:", out_path)