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 893dfe5e..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,19 +38,64 @@ 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); +// --- 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 +// 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); } -// 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))); +// --- 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(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(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; +} + +// 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() { @@ -57,35 +104,83 @@ void main() { // ----- Sky base color ----- vec3 color = uSkyColor; + // ----- 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. + // 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 + + vec3 p = dir * SCALE; + float t = uTime * 0.006; // very slow drift + + // Inigo Quilez domain warp — bends the hue ramp into curved ribbons. + 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)) + ); + 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); + + // 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 wisps, not a flat wash. + float energy = smoothstep(0.42, 0.85, f); + + color += gem * (energy * uAuroraIntensity); + } + // ----- 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 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). 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 5e8d8716..473bfd8c 100644 --- a/app/tests/city/components/sky/skyShader.test.ts +++ b/app/tests/city/components/sky/skyShader.test.ts @@ -61,8 +61,19 @@ 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('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 + // 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 * ...)', () => { 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'); }); });