From 1df5ed60d7efb619f827ee10ae517bac461c6d29 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:08:12 -0400 Subject: [PATCH 1/8] feat(sky): first-pass domain-warped aurora bands Port LandingBackdrop's fbm domain warp into the sky fragment shader, using the world view direction (dome-projected) as the domain so gem-hued bands drift up from the horizon. Hardcoded, subtle, tuned to sit under the bloom threshold. Theme uniforms + settings come after a look is approved. Co-Authored-By: Claude Fable 5 --- app/src/city/components/sky/sky.frag.glsl | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/app/src/city/components/sky/sky.frag.glsl b/app/src/city/components/sky/sky.frag.glsl index 893dfe5e..e420cca8 100644 --- a/app/src/city/components/sky/sky.frag.glsl +++ b/app/src/city/components/sky/sky.frag.glsl @@ -43,6 +43,27 @@ float hash21(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } +// --- Aurora (domain-warped gem bands) --- +// Ported from LandingBackdrop's fbm domain warp, but the domain is the +// world view direction instead of screen UV, so the bands wrap the sky. +float auroraHash(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} +float auroraNoise(vec2 p) { + vec2 i = floor(p), f = fract(p); + float a = auroraHash(i), b = auroraHash(i + vec2(1.0, 0.0)); + float c = auroraHash(i + vec2(0.0, 1.0)), d = auroraHash(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} +float auroraFbm(vec2 p) { + float v = 0.0, a = 0.5; + for (int i = 0; i < 5; i++) { v += a * auroraNoise(p); p = p * 2.0 + 11.3; a *= 0.5; } + return v; +} + // Equirectangular (longitude, latitude) projection. Longitude wraps over // [-π, π], latitude over [-π/2, π/2]. Near the poles the projection // distorts star density slightly — acceptable for a starfield where @@ -57,6 +78,49 @@ void main() { // ----- Sky base color ----- vec3 color = uSkyColor; + // ----- Aurora (gem-hued bands drifting up from the horizon) ----- + // Dome projection of the view direction: dir.xz stretched by a small + // horizon bias so the domain is seamless over the upper hemisphere and + // detail concentrates near the horizon (denominator shrinks there). + // First-pass values are hardcoded and subtle; gems become theme uniforms later. + { + const vec3 GEM_CYAN = vec3(0.149, 0.898, 1.000); + const vec3 GEM_PURPLE = vec3(0.600, 0.251, 1.000); + const vec3 GEM_MAGENTA = vec3(1.000, 0.200, 0.549); + const vec3 GEM_LIME = vec3(0.749, 1.000, 0.200); + + const float SCALE = 1.6; // domain frequency: lower = broader curtains + const float INTENSITY = 0.32; // peak add-on, kept under bloom threshold (0.5) + + vec2 p = dir.xz / max(dir.y + 0.12, 0.12) * SCALE; + float t = uTime * 0.03; // slow horizontal drift + + // Inigo Quilez domain warp — bends the hue ramp into curved ribbons. + vec2 q = vec2( + auroraFbm(p + vec2(t, 0.0)), + auroraFbm(p + vec2(5.2, 1.3) - vec2(t, 0.0)) + ); + vec2 r = vec2( + auroraFbm(p + 2.0 * q + vec2(1.7, 9.2) + 0.5 * t), + auroraFbm(p + 2.0 * q + vec2(8.3, 2.8)) + ); + float f = auroraFbm(p + 2.0 * r); + + // Hue sweep through the gems, bent by the warp: cyan->purple->magenta->lime. + float h = clamp(0.5 + 1.0 * (r.x - 0.5) + 0.6 * (q.y - 0.5), 0.0, 1.0); + vec3 gem = GEM_CYAN; + gem = mix(gem, GEM_PURPLE, smoothstep(0.12, 0.40, h)); + gem = mix(gem, GEM_MAGENTA, smoothstep(0.40, 0.66, h)); + gem = mix(gem, GEM_LIME, smoothstep(0.66, 0.90, h)); + + // Ridge mask so it reads as discrete curtains, not a flat wash. + float energy = smoothstep(0.42, 0.85, f); + // Vertical confinement: fade in just above the horizon, out before zenith. + float band = smoothstep(0.0, 0.14, dir.y) * (1.0 - smoothstep(0.42, 0.85, dir.y)); + + color += gem * (energy * band * INTENSITY); + } + // ----- Stars (full sphere) ----- if (uStarsEnabled > 0.5) { // Cell scale in radians^-1: ~100 cells per radian gives roughly From 00cf360c1fd009f67ea753948bf887f6b76212a3 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:10:02 -0400 Subject: [PATCH 2/8] feat(sky): full-sphere 3D nebula instead of horizon band Swap the aurora's 2D dome projection for 3D fbm over the world view direction so the nebula wraps the whole sky seamlessly, and drop the horizon band mask. The island floats in space, so the lower hemisphere is visible and the aurora should surround it. Co-Authored-By: Claude Fable 5 --- app/src/city/components/sky/sky.frag.glsl | 63 ++++++++++++----------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/app/src/city/components/sky/sky.frag.glsl b/app/src/city/components/sky/sky.frag.glsl index e420cca8..8af587d8 100644 --- a/app/src/city/components/sky/sky.frag.glsl +++ b/app/src/city/components/sky/sky.frag.glsl @@ -43,22 +43,24 @@ float hash21(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } -// --- Aurora (domain-warped gem bands) --- -// Ported from LandingBackdrop's fbm domain warp, but the domain is the -// world view direction instead of screen UV, so the bands wrap the sky. -float auroraHash(vec2 p) { - p = fract(p * vec2(123.34, 456.21)); - p += dot(p, p + 45.32); - return fract(p.x * p.y); +// --- Aurora (domain-warped gem nebula) --- +// Ported from LandingBackdrop's fbm domain warp, but 3D over the world +// view direction so the nebula wraps the full sphere seamlessly (the +// island floats in space, so the lower hemisphere is visible too). +float auroraHash(vec3 p) { + p = fract(p * 0.3183099 + 0.1); + p *= 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); } -float auroraNoise(vec2 p) { - vec2 i = floor(p), f = fract(p); - float a = auroraHash(i), b = auroraHash(i + vec2(1.0, 0.0)); - float c = auroraHash(i + vec2(0.0, 1.0)), d = auroraHash(i + vec2(1.0, 1.0)); - vec2 u = f * f * (3.0 - 2.0 * f); - return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +float auroraNoise(vec3 x) { + vec3 i = floor(x), f = fract(x); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(mix(auroraHash(i + vec3(0.0, 0.0, 0.0)), auroraHash(i + vec3(1.0, 0.0, 0.0)), f.x), + mix(auroraHash(i + vec3(0.0, 1.0, 0.0)), auroraHash(i + vec3(1.0, 1.0, 0.0)), f.x), f.y), + mix(mix(auroraHash(i + vec3(0.0, 0.0, 1.0)), auroraHash(i + vec3(1.0, 0.0, 1.0)), f.x), + mix(auroraHash(i + vec3(0.0, 1.0, 1.0)), auroraHash(i + vec3(1.0, 1.0, 1.0)), f.x), f.y), f.z); } -float auroraFbm(vec2 p) { +float auroraFbm(vec3 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * auroraNoise(p); p = p * 2.0 + 11.3; a *= 0.5; } return v; @@ -78,10 +80,9 @@ void main() { // ----- Sky base color ----- vec3 color = uSkyColor; - // ----- Aurora (gem-hued bands drifting up from the horizon) ----- - // Dome projection of the view direction: dir.xz stretched by a small - // horizon bias so the domain is seamless over the upper hemisphere and - // detail concentrates near the horizon (denominator shrinks there). + // ----- Aurora (gem-hued nebula across the full sphere) ----- + // The domain IS the world view direction, so the nebula wraps the whole + // sky with no seam or pole distortion — right for a city floating in space. // First-pass values are hardcoded and subtle; gems become theme uniforms later. { const vec3 GEM_CYAN = vec3(0.149, 0.898, 1.000); @@ -89,20 +90,22 @@ void main() { const vec3 GEM_MAGENTA = vec3(1.000, 0.200, 0.549); const vec3 GEM_LIME = vec3(0.749, 1.000, 0.200); - const float SCALE = 1.6; // domain frequency: lower = broader curtains + const float SCALE = 1.6; // domain frequency: lower = broader forms const float INTENSITY = 0.32; // peak add-on, kept under bloom threshold (0.5) - vec2 p = dir.xz / max(dir.y + 0.12, 0.12) * SCALE; - float t = uTime * 0.03; // slow horizontal drift + vec3 p = dir * SCALE; + float t = uTime * 0.03; // slow drift // Inigo Quilez domain warp — bends the hue ramp into curved ribbons. - vec2 q = vec2( - auroraFbm(p + vec2(t, 0.0)), - auroraFbm(p + vec2(5.2, 1.3) - vec2(t, 0.0)) + vec3 q = vec3( + auroraFbm(p + vec3(0.0, t, 0.0)), + auroraFbm(p + vec3(5.2, 1.3, 2.8) - vec3(0.0, t, 0.0)), + auroraFbm(p + vec3(1.7, 9.2, 4.4)) ); - vec2 r = vec2( - auroraFbm(p + 2.0 * q + vec2(1.7, 9.2) + 0.5 * t), - auroraFbm(p + 2.0 * q + vec2(8.3, 2.8)) + vec3 r = vec3( + auroraFbm(p + 2.0 * q + vec3(1.7, 9.2, 0.0) + 0.5 * t), + auroraFbm(p + 2.0 * q + vec3(8.3, 2.8, 5.1)), + auroraFbm(p + 2.0 * q + vec3(2.9, 6.3, 1.2)) ); float f = auroraFbm(p + 2.0 * r); @@ -113,12 +116,10 @@ void main() { gem = mix(gem, GEM_MAGENTA, smoothstep(0.40, 0.66, h)); gem = mix(gem, GEM_LIME, smoothstep(0.66, 0.90, h)); - // Ridge mask so it reads as discrete curtains, not a flat wash. + // Ridge mask so it reads as discrete wisps, not a flat wash. float energy = smoothstep(0.42, 0.85, f); - // Vertical confinement: fade in just above the horizon, out before zenith. - float band = smoothstep(0.0, 0.14, dir.y) * (1.0 - smoothstep(0.42, 0.85, dir.y)); - color += gem * (energy * band * INTENSITY); + color += gem * (energy * INTENSITY); } // ----- Stars (full sphere) ----- From 6942a11855cb537dfe628d14f23eb61a1cfc56f1 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:10:59 -0400 Subject: [PATCH 3/8] feat(sky): slow the nebula drift and dim its color Drift 0.03 -> 0.006 (5x slower) and peak intensity 0.32 -> 0.16 so the nebula reads as a calm, subtle backdrop rather than fast and saturated. Co-Authored-By: Claude Fable 5 --- app/src/city/components/sky/sky.frag.glsl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/city/components/sky/sky.frag.glsl b/app/src/city/components/sky/sky.frag.glsl index 8af587d8..9995d281 100644 --- a/app/src/city/components/sky/sky.frag.glsl +++ b/app/src/city/components/sky/sky.frag.glsl @@ -91,10 +91,10 @@ void main() { const vec3 GEM_LIME = vec3(0.749, 1.000, 0.200); const float SCALE = 1.6; // domain frequency: lower = broader forms - const float INTENSITY = 0.32; // peak add-on, kept under bloom threshold (0.5) + const float INTENSITY = 0.16; // peak add-on, kept under bloom threshold (0.5) vec3 p = dir * SCALE; - float t = uTime * 0.03; // slow drift + float t = uTime * 0.006; // very slow drift // Inigo Quilez domain warp — bends the hue ramp into curved ribbons. vec3 q = vec3( From 25e55c256cc1c0507ef89bb8e4ebf72b842b954f Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:14:09 -0400 Subject: [PATCH 4/8] fix(a11y): drop focus ring on the app-body focus catcher #app-body is tabIndex=-1: the skip-link target and post-dialog focus catcher, not an interactive control. The global :focus-visible ring drew a 2px accent outline around the whole content region (read as a ring around the city) whenever focus landed there. Suppress it since focus only passes through on its way to the real content. Co-Authored-By: Claude Fable 5 --- app/src/layout/App/App.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/layout/App/App.css b/app/src/layout/App/App.css index 95359881..d51e6369 100644 --- a/app/src/layout/App/App.css +++ b/app/src/layout/App/App.css @@ -33,6 +33,14 @@ body { align-items: stretch; } +/* tabIndex=-1 makes this the skip-link / post-dialog focus catcher, not an + interactive control. The global :focus-visible ring would draw a 2px accent + outline around the whole content region (reads as a ring around the city); + suppress it here since focus only passes through on its way to the content. */ +#app-body:focus-visible { + outline: none; +} + /* Switcher showcase: with the switcher open over a loaded city, hide the chrome so the canvas fills the viewport as a backdrop. #app-body then grows to the full column and the canvas auto-fits (ResizeObserver + per-frame size guard). From 978f1edecb6ad3ac4e3a16e6914d991d4cf38cba Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:31:18 -0400 Subject: [PATCH 5/8] feat(sky): even star field with per-star brightness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the star field reading as lines/repeats: - Replace the equirectangular (lon,lat) grid with a cube-face projection, which has no polar singularity, so stars no longer crowd into meridian lines when looking straight up or down. - Replace the fract(sin(dot(...))) hash with a hash-without-sine (Hoskins), keyed on (cell, face); the sin hash banded into visible moiré, worse at the larger coordinates the face offset produced. - Give each star a random baseline brightness biased toward the dim end, so the field has depth (a few bright stars among many faint ones) and each star twinkles relative to its own baseline. Co-Authored-By: Claude Fable 5 --- app/src/city/components/sky/sky.frag.glsl | 85 +++++++++++++++-------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/app/src/city/components/sky/sky.frag.glsl b/app/src/city/components/sky/sky.frag.glsl index 9995d281..32c8e4f9 100644 --- a/app/src/city/components/sky/sky.frag.glsl +++ b/app/src/city/components/sky/sky.frag.glsl @@ -36,11 +36,21 @@ uniform float uTwinkleSpeed; uniform float uTwinkleAmplitude; // 0=no twinkle, 1=full on/off uniform float uTime; // seconds; advanced once per frame -// Standard sin-fract pseudo-random — same as building.frag.glsl's hash21. -// Deterministic per (cell.x, cell.y) so a given sky direction always -// hashes to the same star presence + phase across frames. -float hash21(vec2 p) { - return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +// Hash-without-sine (Dave Hoskins). The classic fract(sin(dot(...))) hash +// bands into visible moiré at large coordinates; these stay well-distributed +// across the whole cell grid, so the star field reads as pure noise with no +// recurring pattern. Keyed on vec3 = (cell.x, cell.y, cubeFace) so the six +// cube faces are fully decorrelated. hash13 → one value (star presence); +// hash33 → three values at once (star center xy + twinkle phase). +float hash13(vec3 p3) { + p3 = fract(p3 * 0.1031); + p3 += dot(p3, p3.zyx + 31.32); + return fract((p3.x + p3.y) * p3.z); +} +vec3 hash33(vec3 p3) { + p3 = fract(p3 * vec3(0.1031, 0.1030, 0.0973)); + p3 += dot(p3, p3.yxz + 33.33); + return fract((p3.xxy + p3.yxx) * p3.zyx); } // --- Aurora (domain-warped gem nebula) --- @@ -66,12 +76,20 @@ float auroraFbm(vec3 p) { return v; } -// Equirectangular (longitude, latitude) projection. Longitude wraps over -// [-π, π], latitude over [-π/2, π/2]. Near the poles the projection -// distorts star density slightly — acceptable for a starfield where -// the eye doesn't measure spacing. -vec2 starUV(vec3 dir) { - return vec2(atan(dir.z, dir.x), asin(clamp(dir.y, -1.0, 1.0))); +// Cube-face projection of the view direction into a flat cell grid. An +// equirectangular (lon,lat) grid crowds cells toward the poles, so stars +// there line up along the meridians and read as radiating "lines" (visible +// looking straight up or down). A cube grid has no polar singularity — its +// only distortion is a mild, non-directional size change toward face corners. +// Returns cell-space coords in [0, cells]; `face` (0..5) lets the caller +// offset the grid so the six faces never share a hashed cell. +vec2 starCubeUV(vec3 d, float cells, out float face) { + vec3 a = abs(d); + vec2 uv; + if (a.x >= a.y && a.x >= a.z) { uv = d.yz / a.x; face = d.x > 0.0 ? 0.0 : 1.0; } + else if (a.y >= a.z) { uv = d.xz / a.y; face = d.y > 0.0 ? 2.0 : 3.0; } + else { uv = d.xy / a.z; face = d.z > 0.0 ? 4.0 : 5.0; } + return (uv * 0.5 + 0.5) * cells; } void main() { @@ -91,7 +109,7 @@ void main() { const vec3 GEM_LIME = vec3(0.749, 1.000, 0.200); const float SCALE = 1.6; // domain frequency: lower = broader forms - const float INTENSITY = 0.16; // peak add-on, kept under bloom threshold (0.5) + const float INTENSITY = 0.16; // peak add-on, kept well under bloom threshold (0.5) vec3 p = dir * SCALE; float t = uTime * 0.006; // very slow drift @@ -124,33 +142,40 @@ void main() { // ----- Stars (full sphere) ----- if (uStarsEnabled > 0.5) { - // Cell scale in radians^-1: ~100 cells per radian gives roughly - // 0.57° cells, coarse enough to keep neighbouring cells visually - // distinct. Stars render as a small circular dot anchored at a - // random point within the cell — the cell itself is just the - // candidate domain, not the visible star. - vec2 sv = starUV(dir) * 100.0; - vec2 cell = floor(sv); + // Cells per cube-face edge: 160 gives ~0.56° cells, coarse enough to keep + // neighbouring cells visually distinct. Stars render as a small circular + // dot anchored at a random point within the cell — the cell itself is just + // the candidate domain, not the visible star. + const float STAR_CELLS = 160.0; + float face; + vec2 sv = starCubeUV(dir, STAR_CELLS, face); + // (cell.x, cell.y, face) seeds every per-cell hash: the face component + // keeps the six cube faces from sharing a star without inflating the + // coordinates the hash sees. + vec3 seed = vec3(floor(sv), face); vec2 inCell = fract(sv); // [0, 1] position within the cell - float h = hash21(cell); + float h = hash13(seed); // hash > 1 - DENSITY ⇒ this cell holds a star. if (h > 1.0 - uStarDensity) { - // Random center within the cell so stars don't snap to a grid. - vec2 starCenter = vec2( - hash21(cell + vec2(7.0, 0.0)), - hash21(cell + vec2(0.0, 13.0)) - ); + // One hash → three decorrelated randoms: star center xy + twinkle phase. + vec3 rnd = hash33(seed); + vec2 starCenter = rnd.xy; // random center within the cell so stars don't snap to a grid float distToCenter = length(inCell - starCenter); // Circular falloff: solid inside the inner half of uStarSize, // smooth fade out to the full uStarSize radius. float r = max(uStarSize, 1e-4); float circle = 1.0 - smoothstep(r * 0.5, r, distToCenter); if (circle > 0.0) { - // Per-star phase: a second hash on the cell shifts when this - // star peaks. Combined with uTime each star twinkles - // independently. - float phase = hash21(cell + vec2(31.4, 17.7)); - float starAmt = uStarBrightness * circle; + // Per-star phase shifts when this star peaks, so combined with uTime + // each star twinkles independently. + float phase = rnd.z; + // Per-star baseline brightness (decorrelated from presence/center/phase): + // most stars sit faint, a few near full, so the field has natural depth + // and each star twinkles relative to its own baseline. Squaring the + // random biases the distribution toward the dim end. + float br = hash13(seed + vec3(19.7, 4.3, 7.1)); + float baseBright = mix(0.08, 0.6, br * br); + float starAmt = uStarBrightness * circle * baseBright; if (uTwinkleEnabled > 0.5 && uTwinkleAmplitude > 0.0) { // PHASE_SPEED_BIAS: per-star speed multiplier in // [PHASE_SPEED_BIAS, PHASE_SPEED_BIAS+1] so two adjacent From 634b5ab30fd1210118ecdb1d328c0a1a6652f11a Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:31:29 -0400 Subject: [PATCH 6/8] feat(sky): settle the nebula to a faint tint INTENSITY 0.16 -> 0.022 so the aurora barely tints the sky and sits well under the bloom threshold. Co-Authored-By: Claude Fable 5 --- app/src/city/components/sky/sky.frag.glsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/city/components/sky/sky.frag.glsl b/app/src/city/components/sky/sky.frag.glsl index 32c8e4f9..06241f49 100644 --- a/app/src/city/components/sky/sky.frag.glsl +++ b/app/src/city/components/sky/sky.frag.glsl @@ -109,7 +109,7 @@ void main() { const vec3 GEM_LIME = vec3(0.749, 1.000, 0.200); const float SCALE = 1.6; // domain frequency: lower = broader forms - const float INTENSITY = 0.16; // peak add-on, kept well under bloom threshold (0.5) + const float INTENSITY = 0.022; // peak add-on, kept well under bloom threshold (0.5) vec3 p = dir * SCALE; float t = uTime * 0.006; // very slow drift From 1f05445e3ae8cc646aa8417a66570b18411813be Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:38:37 -0400 Subject: [PATCH 7/8] test(sky): assert the hash-without-sine star scatter Update the star-scatter guard to match the new shader: hash13/hash33 over a cube-face grid instead of the removed fract(sin(dot(...))) hash. Co-Authored-By: Claude Fable 5 --- app/tests/city/components/sky/skyShader.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/tests/city/components/sky/skyShader.test.ts b/app/tests/city/components/sky/skyShader.test.ts index 5e8d8716..f006dd53 100644 --- a/app/tests/city/components/sky/skyShader.test.ts +++ b/app/tests/city/components/sky/skyShader.test.ts @@ -61,8 +61,14 @@ describe('sky.frag.glsl', () => { } }); - it('uses a hash to scatter stars (deterministic per direction)', () => { - expect(src).toMatch(/sin\(\s*dot\([^)]*,\s*vec2\s*\(\s*12\.9898/); + it('scatters stars with a hash-without-sine over a cube-face grid', () => { + // Star presence + placement come from hash13/hash33 (Dave Hoskins), + // replacing the old fract(sin(dot(...))) hash that banded into moiré, and + // the cube-face projection replaces the equirectangular grid that crowded + // stars into meridian lines at the poles. + expect(src).toMatch(/hash13\s*\(/); + expect(src).toMatch(/hash33\s*\(/); + expect(src).toMatch(/starCubeUV\s*\(/); }); it('drives twinkle through sin(uTime * ...)', () => { From 71ad11d35f020245b9280815d485d73483216241 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Thu, 16 Jul 2026 23:46:50 -0400 Subject: [PATCH 8/8] feat(sky): SCENE controls for the aurora (enable + intensity) Add AURORA_ENABLED + AURORA_INTENSITY to the SCENE store and an Aurora group in the Scene controls, wired through to uAuroraEnabled / uAuroraIntensity uniforms (pushed by the existing settings effect, no rebuild). The shader gates the nebula on the toggle and uses the intensity uniform as its peak add-on in place of the hardcoded value. Co-Authored-By: Claude Fable 5 --- app/src/city/components/sky/index.ts | 21 ++++++++++++------- app/src/city/components/sky/sky.frag.glsl | 17 +++++++++------ app/src/state/stores/settings/scene.ts | 19 +++++++++++++++++ app/src/views/ControlsPane/partials/Scene.ts | 5 +++++ app/tests/city/components/sky/sky.test.ts | 11 ++++++++++ .../city/components/sky/skyShader.test.ts | 5 +++++ app/tests/city/sky/skyConfig.test.ts | 2 ++ 7 files changed, 66 insertions(+), 14 deletions(-) diff --git a/app/src/city/components/sky/index.ts b/app/src/city/components/sky/index.ts index 3f03017e..8ca7f64b 100644 --- a/app/src/city/components/sky/index.ts +++ b/app/src/city/components/sky/index.ts @@ -17,10 +17,10 @@ // sky.tick(dt, frameCtx); // each frame, LAST before render // sky.dispose(); // on teardown // -// The settings effect reads SCENE (SKY_COLOR, STARS_ENABLED, STARS_DENSITY) -// and pushes fresh values into the material uniforms with no rebuild, re-running -// on every SCENE Save. It runs once at construction (idempotently -// re-applying the same values the constructor baked). +// The settings effect reads SCENE (SKY_COLOR, STARS_ENABLED, STARS_DENSITY, +// AURORA_ENABLED, AURORA_INTENSITY) and pushes fresh values into the material +// uniforms with no rebuild, re-running on every SCENE Save. It runs once at +// construction (idempotently re-applying the same values the constructor baked). // // Construction-time bridge: the sky is // built by createCity BEFORE the picker/camera/renderer exist. The @@ -114,6 +114,9 @@ export function createSky(ctx: SceneContext): Sky { uTwinkleEnabled: { value: TWINKLE_ENABLED }, uTwinkleSpeed: { value: TWINKLE_SPEED }, uTwinkleAmplitude: { value: TWINKLE_AMPLITUDE }, + + uAuroraEnabled: { value: cfg.AURORA_ENABLED ? 1.0 : 0.0 }, + uAuroraIntensity: { value: cfg.AURORA_INTENSITY }, }, }); setColorFromHex(material.uniforms.uSkyColor.value as THREE.Color, cfg.SKY_COLOR); @@ -123,15 +126,17 @@ export function createSky(ctx: SceneContext): Sky { group.frustumCulled = false; group.userData.cyberpunkValley = 'sky'; - // Settings effect — reacts to SCENE changes (Save). Reads only SKY_COLOR / - // STARS_ENABLED / STARS_DENSITY and pushes them into the uniforms. Runs once - // at construction, re-applying the same values the constructor baked - // (idempotent). + // Settings effect — reacts to SCENE changes (Save). Reads SKY_COLOR / + // STARS_ENABLED / STARS_DENSITY / AURORA_ENABLED / AURORA_INTENSITY and + // pushes them into the uniforms. Runs once at construction, re-applying the + // same values the constructor baked (idempotent). const stopEffect = onSettings(SCENE, () => { const c = SCENE.value; setColorFromHex(material.uniforms.uSkyColor.value as THREE.Color, c.SKY_COLOR); material.uniforms.uStarsEnabled.value = c.STARS_ENABLED ? 1.0 : 0.0; material.uniforms.uStarDensity.value = c.STARS_DENSITY; + material.uniforms.uAuroraEnabled.value = c.AURORA_ENABLED ? 1.0 : 0.0; + material.uniforms.uAuroraIntensity.value = c.AURORA_INTENSITY; // Scene clear color (the RenderPass background behind the sky sphere) = // SKY_COLOR. Sky owns this — runs at construction + on every SCENE Save, so // applyManifest no longer needs to set it. diff --git a/app/src/city/components/sky/sky.frag.glsl b/app/src/city/components/sky/sky.frag.glsl index 06241f49..37e41e26 100644 --- a/app/src/city/components/sky/sky.frag.glsl +++ b/app/src/city/components/sky/sky.frag.glsl @@ -8,7 +8,9 @@ // 1. Solid uSkyColor everywhere. The world floor mesh handles the // real ground; past its edge the camera sees the sky directly // and the plane reads as floating in space. -// 2. Hashed point-star field with per-star sine twinkle driven by +// 2. A domain-warped gem-hued aurora nebula, faded so it tints the +// full sphere without crossing the bloom threshold. +// 3. Hashed point-star field with per-star sine twinkle driven by // uTime, painted across the FULL sphere — stars surround the // camera in every direction, including below the horizon line. // @@ -36,6 +38,10 @@ uniform float uTwinkleSpeed; uniform float uTwinkleAmplitude; // 0=no twinkle, 1=full on/off uniform float uTime; // seconds; advanced once per frame +// --- Aurora --- +uniform float uAuroraEnabled; +uniform float uAuroraIntensity; // peak add-on; low so it stays under bloom + // Hash-without-sine (Dave Hoskins). The classic fract(sin(dot(...))) hash // bands into visible moiré at large coordinates; these stay well-distributed // across the whole cell grid, so the star field reads as pure noise with no @@ -101,15 +107,14 @@ void main() { // ----- Aurora (gem-hued nebula across the full sphere) ----- // The domain IS the world view direction, so the nebula wraps the whole // sky with no seam or pole distortion — right for a city floating in space. - // First-pass values are hardcoded and subtle; gems become theme uniforms later. - { + // Gems are hardcoded; enable + peak intensity come from SCENE. + if (uAuroraEnabled > 0.5 && uAuroraIntensity > 0.0) { const vec3 GEM_CYAN = vec3(0.149, 0.898, 1.000); const vec3 GEM_PURPLE = vec3(0.600, 0.251, 1.000); const vec3 GEM_MAGENTA = vec3(1.000, 0.200, 0.549); const vec3 GEM_LIME = vec3(0.749, 1.000, 0.200); - const float SCALE = 1.6; // domain frequency: lower = broader forms - const float INTENSITY = 0.022; // peak add-on, kept well under bloom threshold (0.5) + const float SCALE = 1.6; // domain frequency: lower = broader forms vec3 p = dir * SCALE; float t = uTime * 0.006; // very slow drift @@ -137,7 +142,7 @@ void main() { // Ridge mask so it reads as discrete wisps, not a flat wash. float energy = smoothstep(0.42, 0.85, f); - color += gem * (energy * INTENSITY); + color += gem * (energy * uAuroraIntensity); } // ----- Stars (full sphere) ----- diff --git a/app/src/state/stores/settings/scene.ts b/app/src/state/stores/settings/scene.ts index b900f0ba..d545d2cc 100644 --- a/app/src/state/stores/settings/scene.ts +++ b/app/src/state/stores/settings/scene.ts @@ -44,6 +44,25 @@ const SCENE_FIELDS = { tip: 'Higher values paint more stars. Above ~0.01 the sky reads as a noise field.', }, + // ── Aurora ── + AURORA_ENABLED: { + route: ChangeRoute.Refresh, + kind: FieldKind.Toggle, + default: true, + label: 'Enabled', + tip: 'When off, no aurora appears.', + }, + AURORA_INTENSITY: { + route: ChangeRoute.Refresh, + kind: FieldKind.Slider, + default: 0.022, + min: 0, + max: 0.15, + step: 0.002, + label: 'Intensity', + tip: 'Peak brightness of the aurora bands. Kept low so they read as a faint nebula and stay under the bloom threshold.', + }, + // ── Ground haze (fog) ── FOG_ENABLED: { route: ChangeRoute.Refresh, diff --git a/app/src/views/ControlsPane/partials/Scene.ts b/app/src/views/ControlsPane/partials/Scene.ts index 207bedfd..a58ebb72 100644 --- a/app/src/views/ControlsPane/partials/Scene.ts +++ b/app/src/views/ControlsPane/partials/Scene.ts @@ -16,6 +16,11 @@ export const SCENE_SECTION: SectionNode = { label: 'Stars', children: [field(SCENE, 'STARS_ENABLED'), field(SCENE, 'STARS_DENSITY')], }, + { + key: 'aurora', + label: 'Aurora', + children: [field(SCENE, 'AURORA_ENABLED'), field(SCENE, 'AURORA_INTENSITY')], + }, { key: 'ground-sizing', label: 'Ground sizing', diff --git a/app/tests/city/components/sky/sky.test.ts b/app/tests/city/components/sky/sky.test.ts index 92b2cd5d..cfd7f526 100644 --- a/app/tests/city/components/sky/sky.test.ts +++ b/app/tests/city/components/sky/sky.test.ts @@ -20,6 +20,8 @@ function resetStores() { SKY_COLOR: '#010005', STARS_ENABLED: true, STARS_DENSITY: 0.0075, + AURORA_ENABLED: true, + AURORA_INTENSITY: 0.022, }; } @@ -94,6 +96,15 @@ describe('createSky()', () => { expect(mat.uniforms.uStarsEnabled.value).toBe(1.0); }); + it('settings effect reflects AURORA_ENABLED / AURORA_INTENSITY into uniforms', () => { + const mat = sky.group.material as THREE.ShaderMaterial; + SCENE.value = { ...SCENE.value, AURORA_ENABLED: false, AURORA_INTENSITY: 0.05 }; + expect(mat.uniforms.uAuroraEnabled.value).toBe(0.0); + expect(mat.uniforms.uAuroraIntensity.value).toBeCloseTo(0.05); + SCENE.value = { ...SCENE.value, AURORA_ENABLED: true }; + expect(mat.uniforms.uAuroraEnabled.value).toBe(1.0); + }); + it('tick() advances uTime AND copies the camera position into group.position', () => { const mat = sky.group.material as THREE.ShaderMaterial; const camera = new THREE.PerspectiveCamera(); diff --git a/app/tests/city/components/sky/skyShader.test.ts b/app/tests/city/components/sky/skyShader.test.ts index f006dd53..473bfd8c 100644 --- a/app/tests/city/components/sky/skyShader.test.ts +++ b/app/tests/city/components/sky/skyShader.test.ts @@ -61,6 +61,11 @@ describe('sky.frag.glsl', () => { } }); + it('declares the aurora enable + intensity uniforms', () => { + expect(src).toContain('uAuroraEnabled'); + expect(src).toContain('uAuroraIntensity'); + }); + it('scatters stars with a hash-without-sine over a cube-face grid', () => { // Star presence + placement come from hash13/hash33 (Dave Hoskins), // replacing the old fract(sin(dot(...))) hash that banded into moiré, and diff --git a/app/tests/city/sky/skyConfig.test.ts b/app/tests/city/sky/skyConfig.test.ts index 9273f28c..1fc0209c 100644 --- a/app/tests/city/sky/skyConfig.test.ts +++ b/app/tests/city/sky/skyConfig.test.ts @@ -12,5 +12,7 @@ describe('SCENE sky/stars keys', () => { expect(v.SKY_COLOR).toMatch(/^#[0-9a-f]{6}$/i); expect(typeof v.STARS_ENABLED).toBe('boolean'); expect(typeof v.STARS_DENSITY).toBe('number'); + expect(typeof v.AURORA_ENABLED).toBe('boolean'); + expect(typeof v.AURORA_INTENSITY).toBe('number'); }); });