Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions app/src/city/components/sky/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
155 changes: 125 additions & 30 deletions app/src/city/components/sky/sky.frag.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions app/src/layout/App/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 19 additions & 0 deletions app/src/state/stores/settings/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions app/src/views/ControlsPane/partials/Scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 11 additions & 0 deletions app/tests/city/components/sky/sky.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ function resetStores() {
SKY_COLOR: '#010005',
STARS_ENABLED: true,
STARS_DENSITY: 0.0075,
AURORA_ENABLED: true,
AURORA_INTENSITY: 0.022,
};
}

Expand Down Expand Up @@ -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();
Expand Down
15 changes: 13 additions & 2 deletions app/tests/city/components/sky/skyShader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 * ...)', () => {
Expand Down
2 changes: 2 additions & 0 deletions app/tests/city/sky/skyConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});