diff --git a/harness/scene/README.md b/harness/scene/README.md index ef735d2e..624d4b0b 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -6,6 +6,7 @@ Chromium. Not shipped, not run by CI. node bundle.cjs # esbuild bundle of entry.ts plus an index.html node drive.cjs compile # compile and link each stage, JSON report node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node gradAxis.cjs # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms diff --git a/harness/scene/gradAxis.cjs b/harness/scene/gradAxis.cjs new file mode 100644 index 00000000..48b96fa6 --- /dev/null +++ b/harness/scene/gradAxis.cjs @@ -0,0 +1,30 @@ +// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value), +// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point. +// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs /src/app/features/scene/shaders +const { chromium } = require('playwright'); const fs=require('fs'); +const S=process.argv[2]; +const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8'); +const fs_=`#version 300 es +precision highp float; precision highp int; +${cm} +${ph} +out vec4 o; +const float F=1.0/520.0; +float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;} +float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;} +void main(){ + vec2 p=vec2(1234.5,-777.7); float e=0.5; + int k=int(gl_FragCoord.x); + if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z + else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);} + else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);} + else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);} +}`; +(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']}); +const pg=await b.newPage(); +const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float'); +const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x}; +const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p); +const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3); +const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500)); +console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})(); diff --git a/scripts/bake-sky.mjs b/scripts/bake-sky.mjs index 4e9d015a..827110c2 100644 --- a/scripts/bake-sky.mjs +++ b/scripts/bake-sky.mjs @@ -1,10 +1,16 @@ -// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts. -// Run `npm run bake:sky` after changing sun.js, skyTokens.js or a moment. +// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the +// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`. +// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment. import { writeFileSync } from 'node:fs'; import { bakeSky } from '../src/sky/skyTokens.js'; +import { bakeScatter } from '../src/sky/scatter.js'; const out = new URL('../src/sky/baked.ts', import.meta.url); -const baked = { midnight: bakeSky('midnight'), noonday: bakeSky('noonday') }; +const baked = { + midnight: bakeSky('midnight'), + noonday: bakeSky('noonday'), + scatter: bakeScatter(), +}; writeFileSync( out, `// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` + diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 8b382878..dbd50049 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,17 +1,20 @@ // Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json -// in the repo stays 'off'. With --demo it also switches on the pretend homeserver (src/preview-demo): -// previewDemo true, and the pretend server as the only homeserver. Usage: -// node scripts/preview-config.js [tier] [--demo] +// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch +// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it +// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend +// server as the only homeserver. Usage: +// node scripts/preview-config.js [tier] [style] [--demo] import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const args = process.argv.slice(2); const demo = args.includes('--demo'); -const tier = args.find((a) => !a.startsWith('--')) ?? 'low'; +const [tier = 'low', style] = args.filter((a) => !a.startsWith('--')); const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); const config = JSON.parse(fs.readFileSync(file, 'utf8')); -config.backgroundScene = { ...config.backgroundScene, tier }; +config.hashRouter = { enabled: true, basename: '/' }; +config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) }; if (demo) { config.previewDemo = true; config.defaultHomeserver = 0; @@ -25,4 +28,6 @@ if (demo) { }; } fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); -console.log(`${file}: backgroundScene.tier = ${tier}${demo ? ', previewDemo on' : ''}`); +console.log( + `${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}${demo ? ', previewDemo on' : ''}` +); diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 473a92a1..35075536 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -38,6 +38,36 @@ Integration (a canvas under the app, `startBackgroundScene`, `login()` when the are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure callbacks and the creature sizing rule. Nothing has drawn a creature. +## Terrain scene (`style: 'terrain'`): the baked sky + +`terrain/` draws a Phacelle-displaced grid under a sky read from Void's bake (`src/sky/baked.ts`, layout in +`src/sky/README.md`); nothing is vendored. `terrainSky.ts` reads each moment's `sun.{elevation_deg, azimuth_deg, +color_linear}`, `ambient_linear` and `horizon_hex`; `terrainScatter.ts` decodes `baked.scatter` and uploads `sky_view` as an +RGB16F 3D texture (the transmittance table stays on the CPU, for the sun's colour at an elevation the bake does not hold). +Both fragment shaders sample it: `SKY_FRAG` for the sky, `GRID_FRAG` for the haze, which is the sky's colour at the horizon in the +fragment's azimuth, so far ground fades into the sky behind it at dusk too. + +Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az=` puts the sun anywhere between them +(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as +the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`. + +Without WebGL2 (`createTerrainScene` returns null) the door mounts `terrain/TerrainSky2D.ts` on the same canvas: the sky alone, painted once on a +96 px wide 2D canvas from `skyColour` (the CPU twin of `SKY_FRAG`, same `?sky=` `?sun=` `?az=` choice via `chooseSky`), the star layer on +top when the sky is dark, nothing moving, no sun disc. Below the horizon the image continues in the horizon's colour. A canvas that already +holds a GL context gives no 2D one, so a GL failure after context creation (compile, framebuffer) still ends in the CSS ground. + +- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a + sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce, + no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange, + where the real one keeps a blue-violet zenith. Within `HORIZON_FLOOR` (1.5 degrees) of the horizon the sky takes one flat colour, + because the table dips toward the ground there and would draw a seam against the haze. +- Stars: faded by the sun's elevation, `starGain` 1 below -12 degrees (where the table goes dark), 0 above -6, smooth between; + the field is placed against the midnight gradient and no longer gated on the sky being dark. At a baked sky that is the same as + before (midnight shows, noonday does not). +- Changed numbers: haze colour (was the gradient's horizon stop; now the scattered sky), ambient (was the gradient's zenith stop + x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than + the lifted one). `HAZE_K` (0.0004 per metre) is unchanged. + ## Entry points - `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index a5b2de7d..794ddc11 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -12,7 +12,8 @@ export type StartOptions = Omit Scene | null; try { - createScene = - config?.style === 'raymarch' - ? (await import('./Scene')).createScene - : (await import('./ridge/Ridge')).createRidgeScene; + if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const scene = createTerrainScene({ ...options, tier }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, tier }); + } else createScene = (await import('./ridge/Ridge')).createRidgeScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index bb35209e..3aecad12 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -15,7 +15,8 @@ import { scatterAlpha, transmittance, } from './ridgeWorld'; -import { skyColours } from './skyColour'; +import { skyColours, SkyColours } from './skyColour'; +import { makeStars } from './stars'; import { startBackgroundScene } from '../index'; describe('noise', () => { @@ -245,6 +246,15 @@ function fakeEl(tag = 'div') { }; return el; } +// A day sky has no stars, so the scene tests below see only the ridge loop; the dusk default +// carries stars and a twinkle overlay, tested in 'the starfield in the scene'. +const DAY: SkyColours = { + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], + ground: [7, 17, 26], + sunAzimuth: 0.3, +}; const draws: string[] = []; function ctx(withFilter = false) { const c: any = { @@ -257,7 +267,19 @@ function ctx(withFilter = false) { moveTo() {}, lineTo() {}, closePath() {}, - fill: () => draws.push('fill'), + // Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's. + clearRect() { + c.overlay = true; + draws.push('clear'); + }, + arc() { + draws.push(c.overlay ? 'twinkle' : 'star'); + c.arcing = true; + }, + fill: () => { + if (c.arcing) c.arcing = false; + else draws.push('fill'); + }, drawImage: () => draws.push('flatten'), set fillStyle(_v: unknown) {}, }; @@ -312,7 +334,8 @@ const runFrames = (dt: number, n: number, from = 0) => { let t = from; for (let i = 0; i < n && rafQueue.length; i += 1) { t += dt; - rafQueue.shift()!(t); + // One display frame: every callback queued for it sees the same timestamp. + rafQueue.splice(0).forEach((cb) => cb(t)); } return t; }; @@ -327,12 +350,12 @@ describe('createRidgeScene', () => { it('returns null when 2D context is missing, so the host keeps its CSS ground', () => { const { canvas } = host(); canvas.getContext = vi.fn(() => null); - expect(createRidgeScene({ canvas, tier: 'full' })).toBeNull(); + expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull(); }); it('bakes the sky once and paints four layers at integer sizes, farthest first', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; expect(s).not.toBeNull(); const layers = parent.children.slice(1); expect(layers).toHaveLength(LAYERS.length); @@ -348,7 +371,7 @@ describe('createRidgeScene', () => { it('after the layers are painted, a smooth run draws nothing more and ends its rAF loop', () => { const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const before = draws.length; runFrames(16.7, 400); expect(draws.length).toBe(before); @@ -359,7 +382,7 @@ describe('createRidgeScene', () => { it('p95 over 33 ms across 2 s flattens to a still image and removes the layers', () => { const { parent, canvas } = host(); const onProbe = vi.fn(); - const s = createRidgeScene({ canvas, tier: 'full', onProbe })!; + const s = createRidgeScene({ canvas, tier: 'full', onProbe, sky: DAY })!; runFrames(100, 100); expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); expect(parent.children).toEqual([canvas]); @@ -379,7 +402,7 @@ describe('createRidgeScene', () => { it('prefers-reduced-motion: layers are built but never played, and nothing is sampled', () => { motion.matches = true; const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims)); expect(anims.length).toBe(LAYERS.length); anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled()); @@ -389,7 +412,7 @@ describe('createRidgeScene', () => { it('pauses the drift when the tab is hidden and resumes when it is visible', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = parent.children.slice(1).flatMap((l: any) => l.anims); anims.forEach((a: any) => a.pause.mockClear()); doc.visibilityState = 'hidden'; @@ -405,7 +428,7 @@ describe('createRidgeScene', () => { it('?probe=1 keeps the rAF loop alive and shows median and p95', () => { search = '?probe=1'; const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const readout = parent.children[parent.children.length - 1]; runFrames(16.7, 300); expect(rafQueue.length).toBe(1); // still sampling @@ -416,7 +439,7 @@ describe('createRidgeScene', () => { it('dispose cancels the loop, the animations and the layers', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = parent.children.slice(1).flatMap((l: any) => l.anims); s.dispose(); anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled()); @@ -456,7 +479,7 @@ describe('fbm', () => { describe('aerial perspective in the scene', () => { it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => { const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; expect(draws.filter((d) => d === 'scatter')).toHaveLength(LAYERS.length - 1); const before = draws.length; runFrames(16.7, 400); @@ -469,7 +492,7 @@ describe('aerial perspective in the scene', () => { const s = createRidgeScene({ canvas, tier: 'full', - sky: { ...skyColours(), sunAzimuth: undefined }, + sky: { ...DAY, sunAzimuth: undefined }, })!; expect(draws.filter((d) => d === 'scatter')).toHaveLength(0); s.dispose(); @@ -495,7 +518,7 @@ describe('aerial perspective in the scene', () => { }); return el; }; - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const blurs = filters.filter((f) => f !== 'none'); expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2 @@ -511,3 +534,122 @@ describe('aerial perspective in the scene', () => { expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); }); }); + +describe('the starfield in the scene', () => { + const count = (kind: string) => draws.filter((d) => d === kind).length; + const night = { + ...DAY, + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], + } as SkyColours; + const N = makeStars(night, 400, 800).length; + const hear = (name: string) => { + const calls = motion.addEventListener.mock.calls.filter((c: any[]) => c[0] === name); + return calls[calls.length - 1][1] as () => void; + }; + // Everything but the overlay's own marks: the bake and the ridges. + const baked = () => draws.filter((d) => d !== 'twinkle' && d !== 'clear').length; + + it('bakes every star once into the sky; per frame only the overlay is touched', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(count('star')).toBe(N); + expect(count('sky')).toBe(1); + expect(parent.children.indexOf(parent.children[1])).toBe(1); + expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges + expect(parent.children).toHaveLength(2 + LAYERS.length); + const before = baked(); + runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps + expect(baked()).toBe(before); + expect(count('clear')).toBeGreaterThan(60); + expect(count('clear')).toBeLessThanOrEqual(101); + // each pass paints at most SLOTS stars + expect(count('twinkle')).toBeLessThanOrEqual(count('clear') * 32); + expect(count('twinkle')).toBeGreaterThan(0); + s.dispose(); + }); + + it('a day sky has no stars and no overlay layer', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(count('star')).toBe(0); + expect(parent.children).toHaveLength(1 + LAYERS.length); + runFrames(16.7, 60); + expect(count('clear')).toBe(0); + s.dispose(); + }); + + it("tier 'still' bakes the stars and has no overlay or loop", () => { + const { parent, canvas } = host(); + createRidgeScene({ canvas, tier: 'still', sky: night }); + expect(count('star')).toBe(N); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); + + it('stops twinkling under reduced motion, live, and resumes when it is lifted', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + expect(count('twinkle')).toBeGreaterThan(0); + motion.matches = true; + hear('change')(); + t = runFrames(16.7, 5, t); + const stopped = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(stopped); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + hear('change')(); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(stopped); + s.dispose(); + }); + + it('starts steady, with nothing painted over the stars, when reduced motion is already on', () => { + motion.matches = true; + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(rafQueue).toHaveLength(0); + runFrames(16.7, 60); + expect(count('twinkle')).toBe(0); + s.dispose(); + }); + + it('stops while the tab is hidden and resumes when it is visible', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + const hidden = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(hidden); + expect(rafQueue).toHaveLength(0); + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(hidden); + s.dispose(); + }); + + it('flattening to a still (slow frames) drops the overlay and leaves every star baked under the ridges', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + runFrames(100, 100); + expect(parent.children).toEqual([canvas]); + expect(count('star')).toBe(N); + expect(draws.indexOf('flatten')).toBeGreaterThan(draws.lastIndexOf('star')); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('dispose removes the overlay and stops the loop', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + s.dispose(); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 218d7c65..eae0df08 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -1,6 +1,7 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from './probe'; import { skyColours, SkyColours, Srgb } from './skyColour'; +import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; import { LAYERS, LayerSpec, @@ -130,8 +131,13 @@ function paintLayer( interface Built { layers: HTMLCanvasElement[]; anims: Animation[]; + /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ + twinkle?: HTMLCanvasElement; } +/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */ +const TWINKLE_FRAME_MS = 50; + /** * The ridge scene: same contract as `createScene` ({ login, dispose }, null for "keep the CSS * ground"), no WebGL. The layers are painted once on the CPU; per frame the only work is the @@ -149,6 +155,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); let built: Built | undefined; + let field: Star[] = []; + let twRaf = 0; + let twLast = 0; let still = tier === 'still'; let disposed = false; let raf = 0; @@ -165,6 +174,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const teardownLayers = () => { built?.anims.forEach((a) => a.cancel()); built?.layers.forEach((l) => l.remove()); + built?.twinkle?.remove(); + cancelAnimationFrame(twRaf); + twRaf = 0; built = undefined; }; @@ -177,10 +189,33 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { canvas.height = Math.round(h * scale); if (!paintSky(canvas, sky)) return false; const layers: HTMLCanvasElement[] = []; + // Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame + // pass clears and paints over. 'still' has no overlay. + const stars = makeStars(sky, w, h); + const skyCtx = context2d(canvas); + if (skyCtx) drawStars(skyCtx, stars, canvas.width, canvas.height, scale); + let twinkle: HTMLCanvasElement | undefined; + if (stars.length && tier !== 'still') { + twinkle = document.createElement('canvas'); + twinkle.width = canvas.width; + twinkle.height = canvas.height; + twinkle.setAttribute('aria-hidden', 'true'); + Object.assign(twinkle.style, { + position: 'absolute', + left: '0', + top: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }); + parent.appendChild(twinkle); + } + field = stars; for (const spec of LAYERS) { const el = paintLayer(spec, sky, size, canvas.height, scale); if (!el) { layers.forEach((l) => l.remove()); + twinkle?.remove(); return false; } el.setAttribute('aria-hidden', 'true'); @@ -208,7 +243,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { ) ) .filter((a): a is Animation => !!a); - built = { layers, anims }; + built = { layers, anims, twinkle }; syncPlayState(); return true; }; @@ -219,8 +254,37 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { !(reducedMotion?.matches ?? false) && document.visibilityState === 'visible'; + /** Paint the overlay at t seconds (stars away from base), or just clear it with no t (all at base). */ + const drawTwinkle = (t?: number) => { + const el = built?.twinkle; + const ctx = el && context2d(el); + if (!el || !ctx) return; + const active = t === undefined ? [] : activeAt(field.length, t); + paintTwinkle(ctx, field, active, el.width, el.height, viewSize().scale); + }; + + // The same guard as the drift: not still, not disposed, not reduced motion, tab visible. Not moving + // stops the loop and leaves the stars steady at base brightness. + const twTick = (now: number) => { + twRaf = 0; + if (!moving() || !built?.twinkle) return; + if (now - twLast >= TWINKLE_FRAME_MS) { + twLast = now; + drawTwinkle(now / 1000); + } + twRaf = requestAnimationFrame(twTick); + }; + function syncPlayState() { built?.anims.forEach((a) => (moving() ? a.play() : a.pause())); + if (!built?.twinkle) return; + if (moving()) { + if (!twRaf) twRaf = requestAnimationFrame(twTick); + } else { + cancelAnimationFrame(twRaf); + twRaf = 0; + drawTwinkle(); + } } /** Flatten the sky and layers into the host canvas and drop the layers: a still image, no compositor layers left. */ diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts new file mode 100644 index 00000000..38b4e7e8 --- /dev/null +++ b/src/app/features/scene/ridge/stars.test.ts @@ -0,0 +1,185 @@ +/* eslint-disable no-restricted-syntax, no-continue -- test loops over sample times */ +import { describe, expect, it } from 'vitest'; +import { skyColours } from './skyColour'; +import { + MAX_STARS, + NIGHT_LUMA, + SLOTS, + TWINKLE_GAIN, + TWINKLE_PERIOD, + activeAt, + makeStars, + skyLuma, + slotCount, + slotOffset, + slotPeriod, + starCount, + twinkleGain, +} from './stars'; + +const DAY = { + ...skyColours(), + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], +} as const; +const NIGHT = { + ...skyColours(), + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], +} as const; +const N = makeStars(NIGHT, 390, 844).length; + +function hash2(a: number, b: number) { + return ((a * 7919 + b * 104729) % 1000) / 1000; +} + +describe('star placement', () => { + it('is deterministic: the same sky and size give the identical field', () => { + expect(makeStars(NIGHT, 390, 844)).toEqual(makeStars(NIGHT, 390, 844)); + expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844)); + }); + + it('draws none in a day sky, and the dusk default is dark enough for some', () => { + expect(makeStars(DAY, 390, 844)).toHaveLength(0); + expect(makeStars(skyColours(), 390, 844).length).toBeGreaterThan(50); + }); + + it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => { + for (const [w, h] of [ + [390, 844], + [1920, 1080], + [100, 100], + ] as const) { + expect(makeStars(NIGHT, w, h).length).toBeLessThanOrEqual(starCount(w, h)); + expect(starCount(w, h)).toBeLessThanOrEqual(MAX_STARS); + } + expect(N).toBeGreaterThan(150); + }); + + it('thins toward the horizon: more than twice the stars in the top third than the bottom third of the sky', () => { + const all = makeStars(NIGHT, 390, 844); + expect(all.filter((s) => s.y < 0.26).length).toBeGreaterThan( + 2 * all.filter((s) => s.y > 0.52).length + ); + }); + + it('fades with the sky: every star sits where the sky is dark, alpha within bounds', () => { + for (const s of makeStars(NIGHT, 390, 844)) { + expect(skyLuma(NIGHT, s.y)).toBeLessThan(NIGHT_LUMA); + expect(s.alpha).toBeLessThanOrEqual(0.9); + expect(s.alpha).toBeGreaterThanOrEqual(0.05); + } + }); + + it('varies in size and brightness', () => { + const all = makeStars(NIGHT, 390, 844); + expect(new Set(all.map((s) => s.r.toFixed(2))).size).toBeGreaterThan(50); + expect( + Math.max(...all.map((s) => s.alpha)) - Math.min(...all.map((s) => s.alpha)) + ).toBeGreaterThan(0.3); + }); +}); + +describe('twinkle schedule', () => { + const times = Array.from({ length: 400 }, (_, i) => i * 0.37); + + it('is a pure function of (seed, time): same t, same set and values', () => { + for (const t of [0, 0.4, 13.7, 3599.9]) expect(activeAt(N, t)).toEqual(activeAt(N, t)); + }); + + it('the set is SLOTS stars at every sampled time (inside 24..48), never more than the field has', () => { + expect(SLOTS).toBeGreaterThanOrEqual(24); + expect(SLOTS).toBeLessThanOrEqual(48); + for (const t of times) expect(activeAt(N, t)).toHaveLength(SLOTS); + expect(activeAt(10, 5)).toHaveLength(10); + expect(slotCount(10)).toBe(10); + expect(activeAt(0, 5)).toHaveLength(0); + }); + + it('no star is twice in the set at one instant, and every index is a real star', () => { + for (const t of times) { + const ids = activeAt(N, t).map((a) => a.star); + expect(new Set(ids).size).toBe(ids.length); + ids.forEach((i) => expect(i >= 0 && i < N).toBe(true)); + } + }); + + it("periods are each slot's own, within 2..7 s, and the offsets differ", () => { + for (let j = 0; j < SLOTS; j += 1) { + expect(slotPeriod(j)).toBeGreaterThanOrEqual(TWINKLE_PERIOD.min); + expect(slotPeriod(j)).toBeLessThanOrEqual(TWINKLE_PERIOD.max); + } + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotPeriod(j))).size).toBe(SLOTS); + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotOffset(j))).size).toBe(SLOTS); + for (const a of activeAt(N, 100)) expect(a.period).toBeCloseTo(slotPeriod(a.slot), 12); + }); + + it('over an hour every star twinkles at least once', () => { + const seen = new Set(); + for (let t = 0; t < 3600; t += 1) for (const a of activeAt(N, t + 0.5)) seen.add(a.star); + expect(seen.size).toBe(N); + }); + + it('a star joins at its base brightness and leaves at exactly base, with no pop', () => { + const seed = { flip: false, ripple: 3, phase: 1 }; + expect(twinkleGain(0, seed)).toBe(1); + expect(twinkleGain(1, seed)).toBe(1); + for (const flip of [true, false]) + for (const e of [1e-4, 1 - 1e-4]) + expect(Math.abs(twinkleGain(e, { ...seed, flip }) - 1)).toBeLessThan(0.01); + // through the schedule: the instant a twinkle ends the slot is on a new star's first instant, at gain 1 + for (let j = 0; j < SLOTS; j += 1) { + const end = slotOffset(j) + 40 * slotPeriod(j); + const at = activeAt(N, end).find((a) => a.slot === j)!; + expect(at.gain).toBeCloseTo(1, 9); + } + }); + + it('the star that joins is never the one that just left', () => { + for (let j = 0; j < SLOTS; j += 1) { + let prev = -1; + for (let k = 0; k < 300; k += 1) { + const t = slotOffset(j) + (k + 0.5) * slotPeriod(j); + const { star } = activeAt(N, t).find((a) => a.slot === j)!; + expect(star).not.toBe(prev); + prev = star; + } + } + }); + + it('joins never coincide: every (slot, cycle) start over an hour is distinct', () => { + const starts: number[] = []; + for (let j = 0; j < SLOTS; j += 1) + for (let k = 0; slotOffset(j) + k * slotPeriod(j) < 3600; k += 1) + starts.push(slotOffset(j) + k * slotPeriod(j)); + starts.sort((a, b) => a - b); + let min = Infinity; + for (let i = 1; i < starts.length; i += 1) min = Math.min(min, starts[i] - starts[i - 1]); + expect(min).toBeGreaterThan(0); + console.log(`joins/hour ${starts.length}, min gap between joins ${(min * 1000).toFixed(3)} ms`); + }); + + it('dims deeper than it brightens, within 0.35x..1.15x', () => { + let lo = Infinity; + let hi = -Infinity; + for (let j = 0; j < 20; j += 1) + for (let k = 0; k < 20; k += 1) { + const seed = { flip: hash2(j, k) < 0.5, ripple: 2 + (k % 2), phase: j + k }; + for (let i = 0; i <= 200; i += 1) { + const g = twinkleGain(i / 200, seed); + lo = Math.min(lo, g); + hi = Math.max(hi, g); + } + } + expect(lo).toBeGreaterThanOrEqual(TWINKLE_GAIN.dim - 1e-9); + expect(hi).toBeLessThanOrEqual(TWINKLE_GAIN.bright + 1e-9); + expect(1 - lo).toBeGreaterThan(2 * (hi - 1)); + }); + + it('stars outside the set are steady: only the active ones have a gain to apply', () => { + const inSet = new Set(activeAt(N, 50).map((a) => a.star)); + expect(inSet.size).toBeLessThan(N / 4); + }); +}); diff --git a/src/app/features/scene/ridge/stars.ts b/src/app/features/scene/ridge/stars.ts new file mode 100644 index 00000000..0e6d2628 --- /dev/null +++ b/src/app/features/scene/ridge/stars.ts @@ -0,0 +1,249 @@ +// The Midnight starfield: placed once from a seeded PRNG and baked once into the sky canvas. A +// rolling window of them twinkles on an overlay canvas: SLOTS slots, each running back-to-back +// twinkles of one star at a time, so the active set is always SLOTS stars and every star gets its turn. +// The schedule is a pure function of (seed, time): no state accumulates and no Math.random runs +// after the field is built, so any instant maps straight to the active set and its brightnesses. + +/* eslint-disable no-bitwise -- the seeded PRNG and hash are 32-bit integer arithmetic */ +import type { SkyColours, Srgb } from './skyColour'; + +/** + * Stars show when the sky behind them is darker than this sRGB luma (0..255). The ridge scene has + * no night flag (the sky theme's Midnight/Noonday is a body class chosen by the app, not seen here), + * so night is the sky's own darkness: a star's alpha is 1 - luma/NIGHT_LUMA at its height in the + * gradient, and a sky whose zenith is lighter than this gets no stars at all. + */ +export const NIGHT_LUMA = 110; +/** The sky gradient runs from the top (0) through mid (0.5) to the horizon glow (0.78 and below). */ +export const SKY_STOPS = { mid: 0.5, horizon: 0.78 }; +export const MAX_STARS = 400; +/** Twinkling stars at any instant (fewer only if the field has fewer stars). */ +export const SLOTS = 32; +export const TWINKLE_PERIOD = { min: 2, max: 7 } as const; +/** Gain at the dimmest and brightest swing: dimming goes deeper than brightening. */ +export const TWINKLE_GAIN = { dim: 0.35, bright: 1.15 } as const; +const SEED = 0x57a45eed; + +export interface Star { + /** Position as fractions of the sky canvas width and height. */ + x: number; + y: number; + /** Radius in CSS px. */ + r: number; + /** Base alpha 0..0.9 (brightness times how dark the sky is there). */ + alpha: number; + /** CSS colour, white tinted blue or warm. */ + fill: string; + /** The sky gradient's colour at this height: dimming paints it over the baked star. */ + cover: string; +} + +const luma = (c: Srgb) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** The baked gradient's colour at fraction t of the canvas height (the canvas interpolates in sRGB). */ +function skyAt(sky: SkyColours, t: number): Srgb { + const mix = (a: Srgb, b: Srgb, k: number): Srgb => [ + Math.round(lerp(a[0], b[0], k)), + Math.round(lerp(a[1], b[1], k)), + Math.round(lerp(a[2], b[2], k)), + ]; + if (t <= SKY_STOPS.mid) return mix(sky.top, sky.mid, t / SKY_STOPS.mid); + if (t <= SKY_STOPS.horizon) + return mix(sky.mid, sky.horizon, (t - SKY_STOPS.mid) / (SKY_STOPS.horizon - SKY_STOPS.mid)); + return sky.horizon; +} + +export const skyLuma = (sky: SkyColours, t: number): number => luma(skyAt(sky, t)); +export const isNight = (sky: SkyColours): boolean => luma(sky.top) < NIGHT_LUMA; + +/** mulberry32: 32-bit seeded PRNG, one float in [0, 1) per call. */ +function prng(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Stateless seeded hash of up to three integers to [0, 1). */ +export function hash(a: number, b = 0, c = 0): number { + let h = Math.imul(a | 0, 0x9e3779b1) ^ SEED; + h = Math.imul(h ^ (h >>> 15) ^ Math.imul(b | 0, 0x85ebca6b), 0xc2b2ae35); + h = Math.imul(h ^ (h >>> 13) ^ Math.imul(c | 0, 0x27d4eb2f), 0x165667b1); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +/** How many stars a viewport carries: about one per 1100 CSS px squared, 150..MAX_STARS. */ +export const starCount = (cssW: number, cssH: number): number => + Math.min(MAX_STARS, Math.max(150, Math.round((cssW * cssH) / 1100))); + +/** + * Seeded placement. Each candidate draws all its random numbers whether it is kept or not, so a + * given seed and sky always give the same field and the prefix does not shift with the count. + * Heights are squeezed toward the zenith (y = 0.78 u^1.7) and alpha falls with the sky's luma, so + * the stars thin out and fade into the horizon glow. None for a day sky. + */ +export function makeStars(sky: SkyColours, cssW: number, cssH: number): Star[] { + if (!isNight(sky)) return []; + const rnd = prng(SEED); + const n = starCount(cssW, cssH); + // Every candidate draws all its numbers, kept or not, so the prefix is stable. + const candidates = Array.from({ length: MAX_STARS }, () => ({ + x: rnd(), + y: SKY_STOPS.horizon * rnd() ** 1.7, + r: 0.35 + 1.0 * rnd() ** 4, + bright: 0.35 + 0.65 * rnd() ** 1.5, + tint: rnd(), + })).slice(0, n); + return candidates.flatMap(({ x, y, r, bright, tint }) => { + const alpha = Math.min(0.9, bright * Math.max(0, 1 - skyLuma(sky, y) / NIGHT_LUMA)); + if (alpha < 0.05) return []; + // White, pulled toward blue or toward warm by the tint draw. + const to: Srgb = tint < 0.5 ? [165, 195, 255] : [255, 222, 185]; + const k = 0.55 * Math.abs(tint - 0.5) * 2; + const c = [255, 255, 255].map((v, j) => Math.round(lerp(v, to[j], k))); + const s = skyAt(sky, y); + return [ + { + x, + y, + r, + alpha, + fill: `rgb(${c[0]},${c[1]},${c[2]})`, + cover: `rgb(${s[0]},${s[1]},${s[2]})`, + }, + ]; + }); +} + +// ---- The schedule --------------------------------------------------------------------------- + +export const slotCount = (stars: number): number => Math.min(SLOTS, stars); +/** A slot's twinkle period in seconds: its own, in TWINKLE_PERIOD. */ +export const slotPeriod = (j: number): number => + TWINKLE_PERIOD.min + (TWINKLE_PERIOD.max - TWINKLE_PERIOD.min) * hash(j, 1); +/** A slot's phase: seconds into its first cycle at t = 0. */ +export const slotOffset = (j: number): number => hash(j, 2) * slotPeriod(j); + +/** Stars in slot j are those with index = j (mod slots), `m` of them; no star is in two slots. */ +const partitionSize = (stars: number, slots: number, j: number): number => + Math.floor((stars - 1 - j) / slots) + 1; + +/** The k-th star slot j twinkles: epochs of a seeded shuffle of its partition, never repeating across the seam. */ +function partitionPick(stars: number, slots: number, j: number, k: number): number { + const m = partitionSize(stars, slots, j); + const shuffled = (e: number) => { + const order = Array.from({ length: m }, (_, q) => q).sort( + (a, b) => hash(j, e, a + 10) - hash(j, e, b + 10) || a - b + ); + return order; + }; + const e = Math.floor(k / m); + const order = shuffled(e); + if (m > 1 && order[0] === shuffled(e - 1)[m - 1]) [order[0], order[1]] = [order[1], order[0]]; + return j + slots * order[k - e * m]; +} + +/** + * Brightness multiplier at fraction u of one twinkle (0..1): exactly 1 at both ends, so a star + * joins and leaves at its base brightness. One swing, a little unrelated shimmer, and the + * asymmetric map below: dimming goes to TWINKLE_GAIN.dim, brightening only to .bright. + */ +export function twinkleGain( + u: number, + seed: { flip: boolean; ripple: number; phase: number } +): number { + if (u <= 0 || u >= 1) return 1; + const main = Math.sin(2 * Math.PI * u) * (seed.flip ? -1 : 1); + const shimmer = Math.sin(2 * Math.PI * seed.ripple * u + seed.phase) * Math.sin(Math.PI * u); + const s = Math.max(-1, Math.min(1, 0.85 * main + 0.15 * shimmer)); + return s >= 0 ? 1 + (TWINKLE_GAIN.bright - 1) * s : 1 + (1 - TWINKLE_GAIN.dim) * s; +} + +export interface Twinkling { + slot: number; + star: number; + /** Seconds since this twinkle started, and its length. */ + start: number; + period: number; + gain: number; +} + +/** The active set at time `t` seconds for a field of `stars` stars, in slot order. */ +export function activeAt(stars: number, t: number): Twinkling[] { + const slots = slotCount(stars); + return Array.from({ length: slots }, (_, j) => { + const period = slotPeriod(j); + const off = slotOffset(j); + const k = Math.floor((t - off) / period); + const start = off + k * period; + const seed = { + flip: hash(j, k, 3) < 0.5, + ripple: 2 + Math.floor(hash(j, k, 4) * 2), + phase: 2 * Math.PI * hash(j, k, 5), + }; + return { + slot: j, + star: partitionPick(stars, slots, j, k), + start, + period, + gain: twinkleGain((t - start) / period, seed), + }; + }); +} + +// ---- Drawing -------------------------------------------------------------------------------- + +/** Bake `stars` into `ctx` (canvas `w` x `h` device px, `scale` device px per CSS px) at base alpha. */ +export function drawStars( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + w: number, + h: number, + scale: number +) { + stars.forEach((s) => { + ctx.globalAlpha = s.alpha; + ctx.fillStyle = s.fill; + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} + +/** + * The overlay pass: clear, then for each active star whose gain is off base, paint over the baked + * star. Dimming covers it with the sky's own colour at (1 - gain), which leaves exactly + * alpha * gain of the star; brightening adds a second star on top. A star at base draws nothing. + */ +export function drawTwinkle( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + active: readonly Twinkling[], + w: number, + h: number, + scale: number +) { + ctx.clearRect(0, 0, w, h); + active.forEach((a) => { + const s = stars[a.star]; + if (!s || Math.abs(a.gain - 1) < 0.01) return; + if (a.gain < 1) { + ctx.globalAlpha = 1 - a.gain; + ctx.fillStyle = s.cover; + } else { + ctx.globalAlpha = Math.min(1, ((a.gain - 1) * s.alpha) / (1 - s.alpha)); + ctx.fillStyle = s.fill; + } + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale + (a.gain < 1 ? 1 : 0), 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl index 95e96bc2..fd5e6363 100644 --- a/src/app/features/scene/shaders/sdf.glsl +++ b/src/app/features/scene/shaders/sdf.glsl @@ -44,10 +44,10 @@ vec3 terrainNormal(vec2 xz) vec2 dir = r > 0.0 ? xz / r : vec2(0.0); vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; + vec2 g = m * W_AMP * W_FREQ * fg.xy + dm * W_AMP * f * dir; #if W_ERODE_ENABLED CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xy + dm * W_ERODE_AMP * ph.cos.w * dir; #endif return normalize(vec3(-g.x, 1.0, -g.y)); } diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts new file mode 100644 index 00000000..01f2df11 --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -0,0 +1,357 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-empty-function -- fake GL and DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainScene } from './Terrain'; +import { GRID, triangleCount } from './terrainWorld'; +import { groundLight, SKY, skyAtSun } from './terrainSky'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; +import { SKY_FRAG } from './terrainShaders'; +import { startBackgroundScene } from '../index'; + +/** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ +function fakeGl(over: Record = {}) { + const calls: Record = {}; + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name in over) return over[name]; + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'getAttribLocation' || name === 'getUniformLocation') return () => 1; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return (...args: unknown[]) => { + (calls[name] ??= []).push(args); + return {}; + }; + }, + } + ); + return { gl, calls }; +} + +let rafQueue: Array<(t: number) => void>; +let visibility: string; +let search: string; +beforeEach(() => { + rafQueue = []; + visibility = 'visible'; + search = ''; + vi.stubGlobal('document', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get visibilityState() { + return visibility; + }, + createElement: () => ({ style: {}, remove() {} }), + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get location() { + return { search }; + }, + setTimeout, + }); + vi.stubGlobal('performance', { now: () => 0 }); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + rafQueue = []; + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function canvasWith(gl: unknown) { + return { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: vi.fn() }, + getContext: vi.fn(() => gl), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any; +} + +describe('createTerrainScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null without WebGL2, so the host keeps its CSS ground', () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'full' })).toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + }); + + it('returns null with no vertex texture units, or an incomplete bake target', () => { + expect( + createTerrainScene({ canvas: canvasWith(fakeGl({ getParameter: () => 0 }).gl), tier: 'full' }) + ).toBeNull(); + const { gl, calls } = fakeGl({ checkFramebufferStatus: () => 0 }); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })).toBeNull(); + expect(calls.deleteProgram).toHaveLength(3); + }); + + it('reports a shader that does not compile to onCompileFail and returns null', () => { + const { gl } = fakeGl({ getShaderParameter: () => false, getShaderInfoLog: () => 'bad' }); + const onCompileFail = vi.fn(); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onCompileFail })).toBeNull(); + expect(onCompileFail).toHaveBeenCalledOnce(); + expect(onCompileFail.mock.calls[0][0].log).toContain('terrain height'); + }); + + it('bakes the height map once, then draws sky and grid each frame and keeps turning', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'low' })!; + expect(scene).not.toBeNull(); + const bakes = calls.drawArrays.length; + expect(bakes).toBe(1); + rafQueue.shift()!(16); + rafQueue.shift()!(32); + expect(calls.drawArrays).toHaveLength(bakes + 2); // one sky draw per frame + expect(calls.drawElements).toHaveLength(2); + expect(calls.drawElements[0][1]).toBe(triangleCount(GRID.low.grid) * 3); + expect(calls.texImage2D).toHaveLength(1); // the baked map is never re-uploaded + expect(rafQueue).toHaveLength(1); + scene.dispose(); + }); + + it("'still' draws one frame and queues nothing", () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!; + expect(calls.drawElements).toHaveLength(1); + expect(rafQueue).toHaveLength(0); + scene.dispose(); + }); + + it('lights with the sky chosen: option, then ?sky=, then the night default', () => { + const uploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + // every vec3 uniform draw() sets: right, up, forward, sun, sunColour, glow, ambient, camera, soil... + return calls.uniform3fv + .filter((a) => (a[1] as ArrayLike).length === 3) + .map((a) => Array.from(a[1] as ArrayLike)); + }; + const night = uploaded({}); + const day = uploaded({ sky: 'noonday' }); + const nightA = Array.from(groundLight(SKY.midnight).ambient); + const dayA = Array.from(groundLight(SKY.noonday).ambient); + const has = (all: number[][], v: number[]) => + all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(night, nightA)).toBe(true); + expect(has(night, dayA)).toBe(false); + expect(has(day, dayA)).toBe(true); + search = '?sky=noonday'; + expect(has(uploaded({}), dayA)).toBe(true); + search = '?sky=garbage'; + expect(has(uploaded({}), nightA)).toBe(true); + }); + + it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { + const ambients = (opts: Record) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + return calls.uniform3fv.map((a) => Array.from(a[1] as ArrayLike)); + }; + const dusk = skyAtSun(-1, 300); + const has = (all: number[][], v: readonly number[]) => + all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), groundLight(dusk).ambient)).toBe( + true + ); + search = '?sky=noonday&sun=-1&az=300'; + expect(has(ambients({}), groundLight(dusk).ambient)).toBe(true); + search = '?sun=garbage'; + expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); + }); + + it('uploads the baked sky table and the aerial perspective volume once each, as half floats, to 3D textures', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.texImage3D).toHaveLength(2); + const a = calls.texImage3D[0]; + expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); + expect(a[9]).toEqual(skyViewWords()); + const ap = calls.texImage3D[1]; + expect([ap[3], ap[4], ap[5]]).toEqual([AP_VOLUME.width, AP_VOLUME.height, AP_VOLUME.depth]); + expect(ap[9]).toEqual(apVolumeWords()); + expect(calls.deleteTexture).toHaveLength(3); + }); + + it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { + expect(SKY_FRAG).toContain('- 0.5'); + expect(SKY_FRAG).toContain('dither / 255.0'); + }); + + it('falls back to a still frame when the measured p95 is over 33 ms', () => { + const { gl, calls } = fakeGl(); + const onProbe = vi.fn(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onProbe })!; + let t = 0; + for (let i = 0; i < 400 && rafQueue.length; i += 1) rafQueue.shift()!((t += 50)); + expect(rafQueue).toHaveLength(0); + expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true })); + expect(calls.drawElements.length).toBeLessThan(400); + scene.dispose(); + }); + + it('does not draw while the tab is hidden, or under reduced motion', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!; + const before = (calls.drawElements ?? []).length; + visibility = 'hidden'; + rafQueue.shift()!(16); + expect((calls.drawElements ?? []).length).toBe(before); + scene.dispose(); + vi.stubGlobal('window', { + ...(window as object), + matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }), + }); + visibility = 'visible'; + const g2 = fakeGl(); + const still = createTerrainScene({ canvas: canvasWith(g2.gl), tier: 'full' })!; + expect(rafQueue).toHaveLength(0); + expect(g2.calls.drawElements).toHaveLength(1); // one frame, then still + still.dispose(); + }); + + it('a lost context stops the scene and tells the host', () => { + const { gl, calls } = fakeGl(); + const canvas = canvasWith(gl); + const onContextLost = vi.fn(); + createTerrainScene({ canvas, tier: 'full', onContextLost })!; + const handler = canvas.addEventListener.mock.calls.find( + (c: unknown[]) => c[0] === 'webglcontextlost' + )[1]; + handler({ preventDefault() {} }); + expect(onContextLost).toHaveBeenCalledOnce(); + const n = (calls.drawElements ?? []).length; + rafQueue.splice(0).forEach((f) => f(16)); + expect((calls.drawElements ?? []).length).toBe(n); + }); + + it('dispose frees its programs, buffers and texture and queues nothing more', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.deleteProgram).toHaveLength(3); + expect(calls.deleteBuffer).toHaveLength(2); + expect(calls.deleteTexture).toHaveLength(3); + expect(rafQueue).toHaveLength(0); + }); +}); + +describe("startBackgroundScene style 'terrain'", () => { + it('requests webgl2 and returns a scene once the tier is on', async () => { + const { gl } = fakeGl(); + const canvas = canvasWith(gl); + const scene = await startBackgroundScene({ tier: 'still', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + scene!.dispose(); + }); + + it('stays off by default, and the default style is still the ridge (no WebGL2)', async () => { + const canvas = canvasWith(null); + expect(await startBackgroundScene({ style: 'terrain' }, {}, { canvas })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + const { gl } = fakeGl(); + const c2 = { ...canvasWith(gl), getContext: vi.fn(() => null) }; + await startBackgroundScene({ tier: 'still' }, {}, { canvas: c2 }); + expect(c2.getContext).not.toHaveBeenCalledWith('webgl2', expect.anything()); + }); +}); + +describe('stars in the terrain scene', () => { + let overlays: any[]; + let arcs: number; + const withOverlays = () => { + overlays = []; + arcs = 0; + (document as any).createElement = () => { + const el: any = { + style: {}, + remove: vi.fn(), + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => (arcs += 1), + }), + }; + overlays.push(el); + return el; + }; + }; + const scene = (sky: 'midnight' | 'noonday', tier: 'still' | 'low' = 'low') => { + withOverlays(); + const canvas = canvasWith(fakeGl().gl); + const s = createTerrainScene({ canvas, tier, sky })!; + return { s, canvas }; + }; + + it('a midnight sky gets one star overlay beside the canvas, painted with the terrain', () => { + const { s, canvas } = scene('midnight'); + expect(overlays).toHaveLength(1); + expect(canvas.parentElement.appendChild).toHaveBeenCalledWith(overlays[0]); + rafQueue.shift()!(16); + expect(arcs).toBeGreaterThan(0); + s.dispose(); + expect(overlays[0].remove).toHaveBeenCalledOnce(); + }); + + it('a noonday sky gets no overlay and paints no stars', () => { + const { s } = scene('noonday'); + expect(overlays).toHaveLength(0); + expect(arcs).toBe(0); + s.dispose(); + }); + + it("'still' paints the stars once at base and queues nothing", () => { + const { s } = scene('midnight', 'still'); + expect(arcs).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('a hidden tab or reduced motion paints no more stars than the first frame', () => { + visibility = 'hidden'; + const { s } = scene('midnight'); + const n = arcs; + expect(n).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); // the terrain loop never starts, so nothing repaints the stars + s.dispose(); + visibility = 'visible'; + (window as any).matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + const r = scene('midnight'); + expect(rafQueue).toHaveLength(0); + expect(arcs).toBe(n); + r.s.dispose(); + }); + + it('stars turn with the terrain: a later frame is painted at a later heading', () => { + let clock = 0; + vi.stubGlobal('performance', { now: () => (clock += 1000) }); + const { s } = scene('midnight'); + rafQueue.shift()!(16); + const first = arcs; + rafQueue.shift()!(1000); + expect(arcs).toBeGreaterThan(first); + s.dispose(); + }); +}); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts new file mode 100644 index 00000000..fdc53926 --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.ts @@ -0,0 +1,431 @@ +import type { Scene, SceneOptions } from '../Scene'; +import { FrameSampler, FrameStats } from '../ridge/probe'; +import { albedoPalette } from './terrainAlbedo'; +import { sunIndex } from '../../../../sky/scatter.js'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; +import { + nightness, + glowColour, + groundLight, + chooseSky, + SkyName, + START_TURNS, + sunDirection, +} from './terrainSky'; +import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { + FULLSCREEN_VERT, + GRID_FRAG, + GRID_VERT, + heightFragmentSource, + SKY_FRAG, +} from './terrainShaders'; +import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; + +export type TerrainOptions = Pick< + SceneOptions, + 'canvas' | 'tier' | 'onCompileFail' | 'onContextLost' +> & { + /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ + sky?: SkyName; + /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ + sun?: { elevation: number; azimuth?: number }; + /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ + seed?: number; + /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ + probe?: boolean; + onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void; +}; + +/** Same shape as Scene.ts's ShaderCompileError, redeclared so this chunk does not import the ray-march scene. */ +class ShaderCompileError extends Error { + constructor(readonly stage: 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: string): WebGLProgram { + const make = (type: number, src: string, which: string) => { + const s = gl.createShader(type); + if (!s) throw new Error('createShader failed'); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(s) ?? ''; + gl.deleteShader(s); + throw new ShaderCompileError('vertex', `terrain ${name} ${which} shader: ${log}`); + } + return s; + }; + const v = make(gl.VERTEX_SHADER, vs, 'vertex'); + const f = make(gl.FRAGMENT_SHADER, fs, 'fragment'); + const p = gl.createProgram(); + if (!p) throw new Error('createProgram failed'); + gl.attachShader(p, v); + gl.attachShader(p, f); + gl.linkProgram(p); + gl.deleteShader(v); + gl.deleteShader(f); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(p) ?? ''; + gl.deleteProgram(p); + throw new ShaderCompileError('vertex', `terrain ${name} link: ${log}`); + } + return p; +} + +/** + * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the + * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an + * RGBA8 texture; each frame is two draws: the sky from the baked scattering table, then a displaced grid lit by the + * baked sun and ambient and hazed toward that sky at the horizon. Cadence guard, pauses and the `?probe=1` readout match the + * ridge scene. + */ +export function createTerrainScene(opts: TerrainOptions): Scene | null { + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const { canvas } = opts; + const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1'; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + }) as WebGL2RenderingContext | null; + if (!gl) return null; + const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); + if (typeof units === 'number' && units < 1) return null; + + const sky = chooseSky(opts); + const spec = GRID[tier]; + + let skyProg: WebGLProgram; + let gridProg: WebGLProgram; + let bakeProg: WebGLProgram; + try { + bakeProg = program(gl, 'height', FULLSCREEN_VERT, heightFragmentSource()); + skyProg = program(gl, 'sky', FULLSCREEN_VERT, SKY_FRAG); + gridProg = program(gl, 'grid', GRID_VERT, GRID_FRAG); + } catch (e) { + opts.onCompileFail?.(e as ShaderCompileError); + return null; + } + + // The height map, baked once. + const heightTex = gl.createTexture(); + const fbo = gl.createFramebuffer(); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + // The baked scattering table, RGB16F, uploaded as it is stored (half floats); the shader divides by the store scale. + const skyTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGB16F, + SKY_VIEW.width, + SKY_VIEW.height, + SKY_VIEW.depth, + 0, + gl.RGB, + gl.HALF_FLOAT, + skyViewWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); + // The aerial perspective volume, RGBA16F, half floats as stored (src/sky/scatter.js). + const apTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGBA16F, + AP_VOLUME.width, + AP_VOLUME.height, + AP_VOLUME.depth, + 0, + gl.RGBA, + gl.HALF_FLOAT, + apVolumeWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); + const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; + const free: Array<() => void> = []; + const release = () => { + free.forEach((f) => f()); + gl.deleteTexture(heightTex); + gl.deleteTexture(skyTex); + gl.deleteTexture(apTex); + gl.deleteFramebuffer(fbo); + gl.deleteProgram(bakeProg); + gl.deleteProgram(skyProg); + gl.deleteProgram(gridProg); + }; + if (!complete) { + release(); + return null; + } + gl.viewport(0, 0, spec.tex, spec.tex); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(bakeProg); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uSpan'), TERRAIN.span); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uTexels'), spec.tex); + gl.uniform2fv(gl.getUniformLocation(bakeProg, 'uSeed'), seedOffset(opts.seed ?? 0)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // The displaced grid. + const g = buildGrid(spec.grid); + const vao = gl.createVertexArray(); + const vbuf = gl.createBuffer(); + const ibuf = gl.createBuffer(); + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, vbuf); + gl.bufferData(gl.ARRAY_BUFFER, g.uv, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, g.indices, gl.STATIC_DRAW); + gl.bindVertexArray(null); + free.push( + () => gl.deleteVertexArray(vao), + () => gl.deleteBuffer(vbuf), + () => gl.deleteBuffer(ibuf) + ); + + const sun = [...sunDirection(sky)]; + const sunLight = [...sky.direct]; + const ground = groundLight(sky); + const ambient = [...ground.ambient]; + const heading = START_TURNS; + const glow = [...glowColour(sky)]; + const sunTexel = sunIndex(sky.elevation); + const night = nightness(sky.elevation); + const moonTexel = sunIndex(Math.abs(sky.elevation)); + const palette = albedoPalette(sky); + const triangles = triangleCount(spec.grid); + + const stars = createTerrainStars(canvas, sky); + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const sampler = new FrameSampler(); + let still = tier === 'still'; + let disposed = false; + let lost = false; + let raf = 0; + let readout: HTMLElement | undefined; + let resizeTimer = 0; + let turnsAtPause = 0; + let playedFrom: number | undefined; + + /** Size the canvas: low renders at 0.75 of the CSS size; full at device pixel ratio capped to 2. */ + const size = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2) * (tier === 'low' ? 0.75 : 1); + canvas.width = Math.max(1, Math.round((canvas.clientWidth || window.innerWidth) * dpr)); + canvas.height = Math.max(1, Math.round((canvas.clientHeight || window.innerHeight) * dpr)); + }; + + const draw = (turns: number) => { + if (lost) return; + gl.viewport(0, 0, canvas.width, canvas.height); + const v = view(canvas.width / canvas.height, (turns + heading) % 1); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(skyProg); + const us = (n: string) => gl.getUniformLocation(skyProg, n); + gl.uniform3fv(us('uSun'), sun); + gl.uniform3fv(us('uSunColour'), sunLight); + gl.uniform3fv(us('uGlow'), glow); + gl.uniform1f(us('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(us('uSky'), 1); + gl.uniform3fv(us('uRight'), v.right); + gl.uniform3fv(us('uUp'), v.up); + gl.uniform3fv(us('uForward'), v.forward); + gl.uniform2f(us('uSize'), canvas.width, canvas.height); + gl.uniform1f(us('uTanHalf'), Math.tan(CAMERA.fov / 2)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + + gl.enable(gl.DEPTH_TEST); + gl.clear(gl.DEPTH_BUFFER_BIT); + gl.useProgram(gridProg); + const u = (n: string) => gl.getUniformLocation(gridProg, n); + gl.uniformMatrix4fv(u('uViewProj'), false, v.viewProj); + gl.uniform3fv(u('uCamera'), v.eye); + gl.uniform1f(u('uSpan'), TERRAIN.span); + gl.uniform1i(u('uTex'), spec.tex); + gl.uniform3fv(u('uSun'), sun); + gl.uniform3fv(u('uLightDir'), ground.dir); + gl.uniform3fv(u('uLight'), ground.colour); + gl.uniform3fv(u('uAmbient'), ambient); + gl.uniform3fv(u('uGlow'), glow); + gl.uniform1f(u('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(u('uSky'), 1); + gl.uniform1f(u('uNight'), night); + gl.uniform1f(u('uMoonIndex'), moonTexel); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.uniform1i(u('uAP'), 2); + gl.uniform3fv(u('uSoil'), palette.soil); + gl.uniform3fv(u('uScree'), palette.scree); + gl.uniform3fv(u('uRock'), palette.rock); + gl.uniform3fv(u('uSnow'), palette.snow); + gl.uniform1f(u('uSnowLine'), palette.snowLine); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.uniform1i(u('uHeight'), 0); + gl.bindVertexArray(vao); + gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); + gl.bindVertexArray(null); + stars?.draw((turns + heading) % 1, moving() ? performance.now() / 1000 : undefined); + }; + + const moving = () => + !still && + !disposed && + !lost && + !(reducedMotion?.matches ?? false) && + document.visibilityState === 'visible'; + + const turnsNow = (now: number) => + turnsAtPause + (playedFrom === undefined ? 0 : (now - playedFrom) / (CAMERA.seconds * 1000)); + + const report = (stillNow: boolean) => { + const s = { ...sampler.stats(), still: stillNow, triangles }; + opts.onProbe?.(s); + if (readout) { + readout.textContent = `terrain · ${s.triangles} tris · n ${s.n} · median ${s.medianMs.toFixed( + 1 + )} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`; + } + }; + + const settleToStill = () => { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + still = true; + cancelAnimationFrame(raf); + raf = 0; + draw(turnsAtPause); + report(true); + }; + + let sinceReport = 0; + // The first window is measured (guard against a slow device); after it the loop only draws. + let measuring = true; + const frame = (now: number) => { + raf = 0; + if (!moving()) return; + draw(turnsNow(now)); + if (!measuring) { + raf = requestAnimationFrame(frame); + return; + } + const full = sampler.push(now); + if (full) { + measuring = false; + if (sampler.slow()) { + settleToStill(); + return; + } + if (probe) report(false); + } else if (probe && now - sinceReport > 500) { + sinceReport = now; + report(false); + } + raf = requestAnimationFrame(frame); + }; + const wake = () => { + if (!moving()) { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + return; + } + if (raf) return; + playedFrom = performance.now(); + if (measuring) sampler.reset(); + raf = requestAnimationFrame(frame); + }; + + const onLost = (e: Event) => { + e.preventDefault(); + lost = true; + cancelAnimationFrame(raf); + raf = 0; + opts.onContextLost?.(); + }; + canvas.addEventListener('webglcontextlost', onLost); + + size(); + if (probe && canvas.parentElement) { + readout = document.createElement('div'); + Object.assign(readout.style, { + position: 'absolute', + left: '8px', + top: '8px', + font: '12px monospace', + color: '#fff', + background: 'rgba(0,0,0,.55)', + padding: '2px 6px', + borderRadius: '3px', + pointerEvents: 'none', + }); + canvas.parentElement.appendChild(readout); + } + const removeLabel = showTierLabel(canvas, tier); + if (still) settleToStill(); + else wake(); + if (!still && !moving()) draw(0); + + const onVisibility = () => { + sampler.reset(); + wake(); + }; + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed || lost) return; + size(); + stars?.resize(); + draw(turnsNow(performance.now())); + sampler.reset(); + }, 250); + }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + reducedMotion?.addEventListener?.('change', onVisibility); + + return { + login() { + // The camera is the same before and after. + }, + dispose() { + if (disposed) return; + disposed = true; + cancelAnimationFrame(raf); + clearTimeout(resizeTimer); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + reducedMotion?.removeEventListener?.('change', onVisibility); + canvas.removeEventListener('webglcontextlost', onLost); + readout?.remove(); + removeLabel?.(); + stars?.dispose(); + release(); + }, + }; +} diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts new file mode 100644 index 00000000..c3eb1e3d --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -0,0 +1,220 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; +import { startBackgroundScene } from '../index'; + +let search: string; +let arcs: number; +let overlay: { remove: ReturnType }; +let windowListeners: Record void>; +beforeEach(() => { + search = ''; + arcs = 0; + windowListeners = {}; + vi.stubGlobal('document', { + createElement: () => { + overlay = { + remove: vi.fn(), + style: {}, + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => { + arcs += 1; + }, + }), + } as any; + return overlay; + }, + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + addEventListener: (n: string, f: () => void) => { + windowListeners[n] = f; + }, + removeEventListener: (n: string) => { + delete windowListeners[n]; + }, + get location() { + return { search }; + }, + setTimeout, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */ +function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) { + const painted: { data: Uint8ClampedArray; width: number; height: number }[] = []; + const ctx = { + createImageData: (width: number, height: number) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }), + putImageData: (image: any) => painted.push(image), + }; + const parentAppend = vi.fn(); + const canvas: any = { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: parentAppend }, + getContext: vi.fn((kind: string) => { + if (kind !== '2d') return opts.gl ?? null; + return opts.twoD === false ? null : ctx; + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + return { canvas, painted, parentAppend }; +} + +const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) => + Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3)); +const mean = (img: { data: Uint8ClampedArray }) => { + let s = 0; + for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2]; + return s / (img.data.length / 4) / 3; +}; + +describe('createTerrainSkyScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const { canvas } = canvasWith(); + expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => { + const { canvas } = canvasWith({ twoD: false }); + expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull(); + }); + + it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => { + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(painted).toHaveLength(1); + const img = painted[0]; + expect(img.width).toBe(SKY_WIDTH); + expect(canvas.width).toBe(SKY_WIDTH); + expect(canvas.height).toBe(img.height); + for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255); + const x = Math.floor(SKY_WIDTH / 2); + expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1)); + expect(mean(img)).toBeGreaterThan(20); + s.dispose(); + }); + + it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => { + const noon = canvasWith(); + createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + const night = canvasWith(); + createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose(); + expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5); + + const low = canvasWith(); + createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose(); + const down = canvasWith(); + createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose(); + expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0])); + + search = '?sun=30&az=0'; + const q0 = canvasWith(); + createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose(); + search = '?sun=30&az=180'; + const q1 = canvasWith(); + createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose(); + expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data)); + }); + + it('stars only on a dark sky', () => { + const night = canvasWith(); + const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!; + expect(night.parentAppend).toHaveBeenCalledTimes(1); + expect(arcs).toBeGreaterThan(0); + a.dispose(); + expect(overlay.remove).toHaveBeenCalled(); + arcs = 0; + const day = canvasWith(); + createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + expect(day.parentAppend).not.toHaveBeenCalled(); + expect(arcs).toBe(0); + }); + + it('repaints on resize, and dispose stops listening', () => { + vi.useFakeTimers(); + vi.stubGlobal('window', { + ...(window as any), + setTimeout, + addEventListener: (window as any).addEventListener, + }); + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(windowListeners.resize).toBeDefined(); + windowListeners.resize(); + vi.advanceTimersByTime(300); + expect(painted).toHaveLength(2); + s.dispose(); + expect(windowListeners.resize).toBeUndefined(); + vi.useRealTimers(); + }); +}); + +describe('startBackgroundScene, terrain style', () => { + it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { + const { canvas, painted } = canvasWith({ gl: null }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']); + expect(painted).toHaveLength(1); + scene!.dispose(); + }); + + it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => { + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return () => ({}); + }, + } + ); + Object.assign(document, { + addEventListener() {}, + removeEventListener() {}, + visibilityState: 'visible', + }); + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal('performance', { now: () => 0 }); + (window as any).matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + const { canvas, painted } = canvasWith({ gl }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']); + expect(painted).toHaveLength(0); + scene!.dispose(); + }); + + it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => { + const { canvas } = canvasWith({ gl: null, twoD: false }); + expect( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }) + ).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts new file mode 100644 index 00000000..0c19ec97 --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -0,0 +1,87 @@ +import type { Scene } from '../Scene'; +import type { TerrainOptions } from './Terrain'; +import { chooseSky, skyColour, START_TURNS, Vec3 } from './terrainSky'; +import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; +import { CAMERA, view } from './terrainWorld'; + +/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ +export const SKY_WIDTH = 96; + +/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ +export type TerrainSkyOptions = Pick; + +/** + * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the + * same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so + * a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so + * the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already + * holds a WebGL context and gives no 2D one. + */ +export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { + if ((opts.tier ?? 'full') === 'off') return null; + const { canvas } = opts; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const sky = chooseSky(opts); + const stars = createTerrainStars(canvas, sky); + const heading = START_TURNS; + const tanHalf = Math.tan(CAMERA.fov / 2); + let disposed = false; + let resizeTimer = 0; + + const paint = () => { + const w = SKY_WIDTH; + const cw = canvas.clientWidth || window.innerWidth; + const ch = canvas.clientHeight || window.innerHeight; + const h = Math.max(1, Math.round((w * ch) / cw)); + canvas.width = w; + canvas.height = h; + const v = view(cw / ch, heading); + const image = ctx.createImageData(w, h); + for (let y = 0; y < h; y += 1) { + const ny = 1 - ((y + 0.5) / h) * 2; + for (let x = 0; x < w; x += 1) { + const nx = ((x + 0.5) / w) * 2 - 1; + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i] + ); + const len = Math.hypot(d[0], d[1], d[2]); + const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]); + const at = (y * w + x) * 4; + for (let i = 0; i < 3; i += 1) { + image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2)); + } + image.data[at + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + stars?.draw(heading); + }; + + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed) return; + stars?.resize(); + paint(); + }, 250); + }; + window.addEventListener('resize', onResize); + paint(); + const removeLabel = showTierLabel(canvas, 'sky-only'); + + return { + login() { + // Nothing moves, so there is no pull-back to start. + }, + dispose() { + if (disposed) return; + disposed = true; + clearTimeout(resizeTimer); + window.removeEventListener('resize', onResize); + removeLabel?.(); + stars?.dispose(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts new file mode 100644 index 00000000..17363282 --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -0,0 +1,224 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ +import { describe, expect, it } from 'vitest'; +import { SKY } from './terrainSky'; +import { + ALBEDO, + albedoAt, + albedoPalette, + bilinearHeight, + CAVITY, + cavityAt, + curvatureAt, + grainAt, + triWeights, + wobble, + wobbleAt, +} from './terrainAlbedo'; +import { GRID_FRAG } from './terrainShaders'; + +const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => + a.every((v, i) => Math.abs(v - b[i]) < eps); +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +// A point where the wobble is zero, so band edges sit exactly where the palette says. +const FLAT_X = Math.PI / 0.043; +// A normal for the xz plane alone (all the weight on it), carrying `ny` for the slope bands. +const facing = (ny: number) => [0, ny, 0] as const; +const at = (sky: keyof typeof SKY, h: number, ny: number) => + albedoAt(albedoPalette(SKY[sky]), h, facing(ny), FLAT_X, 0); + +describe('albedo by slope and height', () => { + it('the test point really has no wobble', () => { + expect(Math.abs(wobble(FLAT_X, 0))).toBeLessThan(1e-9); + }); + + it('steep faces are rock at every height, gentle low ones are soil, gentle high ones are snow', () => { + const p = albedoPalette(SKY.noonday); + for (const h of [10, 90, 160, 220]) expect(near(at('noonday', h, 0.3), p.rock)).toBe(true); + expect(near(at('noonday', 10, 1), p.soil)).toBe(true); + expect(near(at('noonday', 220, 1), p.snow)).toBe(true); + }); + + it('soil grades to scree by height on gentle ground, halfway at the middle of the band', () => { + const p = albedoPalette(SKY.noonday); + const half = p.soil.map((v, i) => (v + p.scree[i]) / 2); + expect(near(at('noonday', (ALBEDO.screeFrom + ALBEDO.screeTo) / 2, 1), half)).toBe(true); + }); + + it('snow is shed as the face steepens: the same height goes snow, then mixed, then rock', () => { + const l = [1, 0.8, 0.5].map((ny) => luma(at('noonday', 220, ny))); + expect(l[0]).toBeGreaterThan(l[1]); + expect(l[1]).toBeGreaterThan(l[2]); + }); + + it('is continuous: a small step in height or slope never jumps a channel by more than a tenth', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = 0; h < 250; h += 1) + for (let ny = 0.3; ny <= 1; ny += 0.05) { + const a = at(sky, h, ny); + const b = at(sky, h + 1, ny + 0.01); + a.forEach((v, i) => expect(Math.abs(v - b[i])).toBeLessThan(0.1)); + } + }); + + it('never returns a negative or a super-white channel', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = -20; h < 300; h += 7) + for (let ny = 0; ny <= 1; ny += 0.1) + for (const v of albedoAt(albedoPalette(SKY[sky]), h, facing(ny), h * 3, ny * 91)) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + }); + + it('the wobble moves band edges but stays inside its stated amplitude', () => { + for (let i = 0; i < 400; i += 1) + expect(Math.abs(wobble(i * 13.7, i * -9.1))).toBeLessThanOrEqual(1); + }); +}); + +describe('per-sky palette', () => { + const mid = albedoPalette(SKY.midnight); + const noon = albedoPalette(SKY.noonday); + + it('midnight snows lower than noonday', () => { + expect(mid.snowLine).toBeLessThan(noon.snowLine); + }); + + it('midnight is tinted toward the cool horizon haze, noonday toward the warm sun', () => { + // blue share of the same material is higher at night + const blueShare = (c: readonly number[]) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(mid.scree)).toBeGreaterThan(blueShare(noon.scree)); + expect(blueShare(mid.snow)).toBeGreaterThan(blueShare(noon.snow)); + }); + + it('a sun at the horizon lands between the two palettes', () => { + const dusk = albedoPalette({ ...SKY.noonday, elevation: 2 }); + expect(dusk.snowLine).toBeLessThan(noon.snowLine); + expect(dusk.snowLine).toBeGreaterThan(mid.snowLine); + }); + + it('keeps every material in 0..1', () => { + for (const p of [mid, noon]) + for (const c of [p.soil, p.scree, p.rock, p.snow]) + c.forEach((v) => { + expect(v).toBeGreaterThan(0); + expect(v).toBeLessThanOrEqual(1); + }); + }); +}); + +describe('shader agrees with the pure function on its numbers', () => { + it('takes every band number from ALBEDO, and reads no texture for colour', () => { + expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); + expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); + expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); + expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); + expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); + // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's + expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); + }); +}); + +const UP = [0, 1, 0] as const; +const EAST = [1, 0, 0] as const; + +describe('grain', () => { + const samples = (rock: number, distance: number) => + Array.from({ length: 400 }, (_, i) => + grainAt(i * 1.7, 100 + i * 0.3, i * 0.9, UP, rock, distance) + ); + const spread = (v: number[]) => Math.max(...v) - Math.min(...v); + + it('varies the surface near the eye, never goes negative, and averages about 1', () => { + const v = samples(0, 50); + expect(spread(v)).toBeGreaterThan(0.4); + expect(Math.min(...v)).toBeGreaterThanOrEqual(0); + expect(v.reduce((a, b) => a + b, 0) / v.length).toBeGreaterThan(0.85); + }); + + it('drops its fine octave with distance, so far ground does not shimmer', () => { + const rangeOf = (d: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, 100, i * 0.21, UP, 0, d))); + expect(rangeOf(900)).toBeLessThan(rangeOf(50)); + }); + + it('layers the rock: strata show only where the face is rock', () => { + const height = (rock: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 100 + i * 0.05, 0, UP, rock, 2000))); + expect(height(1)).toBeGreaterThan(0.2); + expect(height(0)).toBeLessThan(1e-9); + }); +}); + +describe('triplanar projection', () => { + it('weights sum to 1 and go to the plane the face looks along', () => { + for (const n of [UP, EAST, [0.6, 0.6, 0.529], [0, 0, 1]] as const) { + const w = triWeights(n); + expect(w[0] + w[1] + w[2]).toBeCloseTo(1, 9); + } + expect(triWeights(UP)).toEqual([0, 1, 0]); + expect(triWeights(EAST)).toEqual([1, 0, 0]); + expect(triWeights([0.8, 0.5, 0.33])[0]).toBeGreaterThan(0.7); + }); + + it('a cliff facing east takes its pattern over (z, y), so it varies with height and not with x: no vertical smear', () => { + const col = (x: number, y: number) => grainAt(x, y, 40, EAST, 0, 50); + const alongY = Array.from({ length: 200 }, (_, i) => col(10, 50 + i * 0.7)); + const alongX = Array.from({ length: 200 }, (_, i) => col(i * 0.7, 50)); + expect(Math.max(...alongY) - Math.min(...alongY)).toBeGreaterThan(0.3); + expect(Math.max(...alongX) - Math.min(...alongX)).toBeLessThan(1e-9); + expect(wobbleAt(5, 80, 40, EAST)).toBeCloseTo(wobbleAt(500, 80, 40, EAST), 12); + expect(wobbleAt(5, 80, 40, EAST)).not.toBeCloseTo(wobbleAt(5, 120, 40, EAST), 3); + }); + + it('level ground takes its pattern over (x, z) alone, as before', () => { + expect(grainAt(12, 10, 30, UP, 0, 50)).toBeCloseTo(grainAt(12, 900, 30, UP, 0, 50), 12); + expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); + }); +}); + +describe('cavity shading', () => { + // A texel grid of heights with one bump; `hAt` is the nearest texel, `bilinearAt` the continuous reading. + const bump = (i: number, j: number) => 6 * Math.exp(-((i - 20) ** 2 + (j - 20) ** 2) / 40); + const nearestAt = (px: number, pz: number) => bump(Math.floor(px + 0.5), Math.floor(pz + 0.5)); + // Largest change in curvature between neighbouring sample positions a hundredth of a texel apart, over a few texels. + const worstStep = (read: (px: number, pz: number) => number) => { + let worst = 0; + for (let px = 14; px < 26; px += 0.01) + worst = Math.max( + worst, + Math.abs(curvatureAt(read, px + 0.01, 20.3, 4) - curvatureAt(read, px, 20.3, 4)) + ); + return worst; + }; + + it('curvature is the mean of four neighbours at the radius minus the centre', () => { + expect(curvatureAt((px, pz) => px * 2 + pz, 7, 9, 3)).toBeCloseTo(0, 12); + expect(curvatureAt((px, pz) => (px - 5) ** 2 + (pz - 5) ** 2, 5, 5, 2)).toBeCloseTo(4, 12); + }); + + it('bilinear height reads the texel exactly at a texel centre and the mean at the middle of four', () => { + const t = (i: number, j: number) => i * 10 + j * 100; + expect(bilinearHeight(t, 3, 4)).toBe(430); + expect(bilinearHeight(t, 3.5, 4.5)).toBeCloseTo(485, 9); + }); + + it('shading from a bilinear read changes smoothly across texel borders; from the nearest texel it jumps', () => { + const smooth = worstStep((px, pz) => bilinearHeight(bump, px, pz)); + const blocky = worstStep(nearestAt); + expect(blocky).toBeGreaterThan(0.1); + expect(smooth).toBeLessThan(0.02); + }); + + it('the cavity factor darkens a hollow, lights a rise and leaves flat ground alone', () => { + expect(cavityAt(0)).toBe(1); + expect(cavityAt(CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + expect(cavityAt(-CAVITY.range)).toBeCloseTo(1 + CAVITY.light, 12); + expect(cavityAt(10 * CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + }); + + it('the grid shader reads the cavity through the bilinear height, the same constants as CAVITY', () => { + expect(GRID_FRAG).toContain(`#define CAV_RANGE ${CAVITY.range.toFixed(1)}`); + expect(GRID_FRAG).not.toMatch(/curv[^;]*hAt\(/); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts new file mode 100644 index 00000000..fef204ff --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -0,0 +1,197 @@ +// Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader +// (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. +// Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky +// part reads the baked sky (./terrainSky.ts): the snow line drops and every material is tinted toward the sky's colour +// as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. +import { coldness, hazeColour, SkyData, Vec3 } from './terrainSky'; + +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; +const mix = (a: number, b: number, t: number) => a + (b - a) * t; + +/** Every number the shader shares: band edges in metres, slope edges as the normal's y (cosine of the slope angle). */ +export const ALBEDO = { + /** soil fades to scree across these heights. */ + screeFrom: 80, + screeTo: 160, + /** half-width in metres of the snow line's fade. */ + snowBand: 22, + /** n.y edges: faces with n.y below `rockLo` are all rock, above `rockHi` none. */ + rockLo: 0.78, + rockHi: 0.95, + /** n.y edges: snow is shed entirely below `snowLo` and settles fully above `snowHi`. */ + snowLo: 0.74, + snowHi: 0.93, + /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ + wobbleHeight: 14, + wobbleSlope: 0.05, + /** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */ + grainCoarse: 0.35, + grainFine: 0.3, + /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ + fineNear: 250, + fineFar: 700, + strata: 0.22, +} as const; + +export interface AlbedoPalette { + soil: Vec3; + scree: Vec3; + rock: Vec3; + snow: Vec3; + /** Metres where snow is half in. */ + snowLine: number; +} + +const BASE = { + soil: [0.045, 0.08, 0.02], + scree: [0.3, 0.235, 0.16], + rock: [0.075, 0.062, 0.06], + snow: [0.72, 0.75, 0.8], +} as const; + +const SNOW_LINE_DAY = 165; +const SNOW_LINE_NIGHT = 135; + +const unit = (c: Vec3): Vec3 => { + const m = Math.max(c[0], c[1], c[2], 1e-6); + return [c[0] / m, c[1] / m, c[2] / m]; +}; + +/** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ +export function albedoPalette(d: SkyData): AlbedoPalette { + const cold = coldness(d); + const sun = unit(d.direct); + const haze = unit(hazeColour(d)); + const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); + const k = mix(0.15, 0.5, cold); + const tinted = (c: Vec3): Vec3 => + [0, 1, 2].map((i) => c[i] * mix(1, tint[i], k)) as unknown as Vec3; + return { + soil: tinted(BASE.soil), + scree: tinted(BASE.scree), + rock: tinted(BASE.rock), + snow: tinted(BASE.snow), + snowLine: mix(SNOW_LINE_DAY, SNOW_LINE_NIGHT, cold), + }; +} + +/** Cheap two-sine value in -1..1 from a 2D world position; stands in for noise so no texture or hash is read. */ +export const wobble = (x: number, z: number): number => + Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); + +/** + * How much each axis-aligned projection plane counts at a surface with unit normal `n`: weights for (yz, xz, xy), summing + * to 1, sharply favouring the plane the face looks along. Procedural patterns are evaluated on all three and blended by + * these (triplanar), so a cliff is not the floor pattern smeared down it. + */ +export function triWeights(n: Vec3): Vec3 { + const w = n.map((c) => Math.abs(c) ** 4); + const sum = w[0] + w[1] + w[2] || 1; + return [w[0] / sum, w[1] / sum, w[2] / sum]; +} + +/** `wobble` blended over the three projection planes, at a surface point with unit normal `n`. */ +export function wobbleAt(x: number, y: number, z: number, n: Vec3): number { + const w = triWeights(n); + return w[0] * wobble(z, y) + w[1] * wobble(x, z) + w[2] * wobble(x, y); +} + +/** + * Linear albedo at a surface point. `y` the height in metres, `n` the unit normal, `x`/`z` world metres. + * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow + * line, thinning to nothing as the face steepens. + */ +export function albedoAt(p: AlbedoPalette, y: number, n: Vec3, x: number, z: number): Vec3 { + const w = wobbleAt(x, y, z, n); + const h = y + ALBEDO.wobbleHeight * w; + const flat = n[1] + ALBEDO.wobbleSlope * w; + const ground = (i: number) => + mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); + const snow = + smooth(p.snowLine - ALBEDO.snowBand, p.snowLine + ALBEDO.snowBand, h) * + smooth(ALBEDO.snowLo, ALBEDO.snowHi, flat); + const rock = 1 - smooth(ALBEDO.rockLo, ALBEDO.rockHi, flat); + return [0, 1, 2].map((i) => + mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) + ) as unknown as Vec3; +} + +const COARSE = [ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], +] as const; +const FINE = [ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], +] as const; +const waves = (k: ReadonlyArray, a: number, b: number) => + k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; + +/** + * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit + * normal `n`, `rock` (0..1, how much of the face is rock) and the distance from the eye. Grain is triplanar like the + * wobble. The shader's `ALB_GRAIN_*` block does the same arithmetic. The cavity term beside it in the shader reads the + * height map and has no twin here. + */ +export function grainAt( + x: number, + y: number, + z: number, + n: Vec3, + rock: number, + distance: number +): number { + const w = triWeights(n); + const over = (k: ReadonlyArray) => + w[0] * waves(k, z, y) + w[1] * waves(k, x, z) + w[2] * waves(k, x, y); + const coarse = over(COARSE); + const fine = over(FINE) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); + return Math.max( + 0, + 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers + ); +} + +/** The cavity term's numbers: curvature (metres) at which a hollow is fully shaded / a rise fully lit, and by how much. */ +export const CAVITY = { radius: 12, range: 12, dark: 0.55, light: 0.3 } as const; + +/** Height at a continuous texel position, bilinear over the four texels around it (the shader's `hBil`). */ +export function bilinearHeight( + texel: (i: number, j: number) => number, + px: number, + pz: number +): number { + const i = Math.floor(px); + const j = Math.floor(pz); + const fx = px - i; + const fz = pz - j; + return mix( + mix(texel(i, j), texel(i + 1, j), fx), + mix(texel(i, j + 1), texel(i + 1, j + 1), fx), + fz + ); +} + +/** Mean of the four neighbours `r` texels away minus the centre: positive in a hollow, negative on a rise. */ +export function curvatureAt( + height: (px: number, pz: number) => number, + px: number, + pz: number, + r: number +): number { + return ( + 0.25 * (height(px + r, pz) + height(px - r, pz) + height(px, pz + r) + height(px, pz - r)) - + height(px, pz) + ); +} + +/** The multiplier on lit colour for a curvature: a hollow sits in shade, a rise catches light (the shader's `cavity`). */ +export const cavityAt = (curv: number): number => + 1 - CAVITY.dark * clamp01(curv / CAVITY.range) + CAVITY.light * clamp01(-curv / CAVITY.range); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts new file mode 100644 index 00000000..c153eb5f --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -0,0 +1,141 @@ +/* eslint-disable no-restricted-syntax -- tests walk table texels */ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../../sky/baked'; +import { + aerialRay, + AP_ELEVATIONS, + AP_SLICES, + apElevation, + apSliceKm, + decode, + SUN_ELEVATIONS_DEG, + viewElevation, +} from '../../../../sky/scatter.js'; +import { + AERIAL_GLSL, + aerialAt, + AP_VOLUME, + apVolumeWords, + SCATTER_GLSL, + scatterRadiance, + skyViewWords, + SKY_VIEW, + tableWords, + transmittanceAt, +} from './terrainScatter'; + +const S = baked.scatter.sky_view; + +describe('the table upload', () => { + it('decodes the baked base64 to one half-float word per channel per texel', () => { + const w = skyViewWords(); + expect(w.length).toBe(S.width * S.height * S.depth * S.channels); + expect(SKY_VIEW).toEqual({ width: S.width, height: S.height, depth: S.depth }); + expect(tableWords(baked.scatter.transmittance.data)).toHaveLength(32 * 3); + }); + + it('words are little-endian: the base64 bytes lo, hi make a word hi << 8 | lo', () => { + expect(Array.from(tableWords(btoa(String.fromCharCode(0x34, 0x12, 0xff, 0x7b))))).toEqual([ + 0x1234, 0x7bff, + ]); + }); +}); + +describe('scatterRadiance (the CPU twin of the sampler)', () => { + it('returns the stored texel exactly at a texel centre: layout x azimuth, y view, z sun', () => { + const w = skyViewWords(); + for (const [z, y, x] of [ + [10, 31, 0], + [8, 5, 3], + [5, 20, 6], + [11, 0, 2], + ]) { + const base = ((z * S.height + y) * S.width + x) * 3; + const got = scatterRadiance( + S.sun_elevations_deg[z], + viewElevation(y), + x * S.azimuth_step_deg + ); + got.forEach((v, c) => expect(v).toBeCloseTo(decode(w[base + c]), 9)); + } + }); + + it('interpolates between texels and clamps beyond the table', () => { + const a = scatterRadiance(25, 10, 0)[2]; + const b = scatterRadiance(45, 10, 0)[2]; + const mid = scatterRadiance(35, 10, 0)[2]; // 35 is halfway between 25 and 45 on the sun axis + expect(mid).toBeCloseTo((a + b) / 2, 9); + expect(scatterRadiance(500, 10, 400)).toEqual(scatterRadiance(90, 10, 180)); + expect(scatterRadiance(30, 10, -90)).toEqual(scatterRadiance(30, 10, 90)); + }); + + it('is black under the planet’s shadow limit and blue at a high noon zenith', () => { + scatterRadiance(-18, 30, 0).forEach((v) => expect(v).toBeLessThan(1e-6)); + const noon = scatterRadiance(45, 85, 90); + expect(noon[2]).toBeGreaterThan(1.5 * noon[0]); + }); +}); + +describe('transmittanceAt', () => { + it('is the zenith transmittance exp(-TAU) overhead and redder at the horizon', () => { + const z = transmittanceAt(90); + [0.06, 0.12, 0.26].forEach((tau, i) => expect(z[i]).toBeCloseTo(Math.exp(-tau), 2)); + const h = transmittanceAt(2); + expect(h[2] / h[0]).toBeLessThan(0.5 * (z[2] / z[0])); + }); + + it('is zero where the ground is in the way', () => { + expect(transmittanceAt(-4)).toEqual([0, 0, 0]); + }); +}); + +describe('the GLSL sampler', () => { + it('uses the layout the README documents: texel-centre coordinates over a 7 x 32 x 12 texture, decoded by the store scale', () => { + expect(SCATTER_GLSL).toContain('(x + 0.5) / 7.0'); + expect(SCATTER_GLSL).toContain('(y + 0.5) / 32.0'); + expect(SCATTER_GLSL).toContain('(uSunIndex + 0.5) / 12.0'); + expect(SCATTER_GLSL).toContain('/ 64.0'); + expect(SCATTER_GLSL).toContain('sqrt(clamp((el - (-4.0)) / 94.0, 0.0, 1.0)) * 31.0'); + expect(SCATTER_GLSL).toContain('* 6.0'); + }); +}); + +describe('the aerial perspective volume as the terrain reads it', () => { + it('uploads RGBA half floats in the baked layout', () => { + const a = baked.scatter.aerial_perspective; + expect(apVolumeWords()).toHaveLength(a.width * a.height * a.depth * 4); + expect(AP_VOLUME).toEqual({ width: a.width, height: a.height, depth: a.depth }); + }); + + it('on a texel centre the CPU twin returns the baked ray exactly (to half-float precision)', () => { + const sunI = 8; + const ei = 5; + const slice = 6; + const want = aerialRay(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90)[slice]; + const got = aerialAt(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90, apSliceKm(slice)); + for (let c = 0; c < 3; c += 1) expect(got.L[c]).toBeCloseTo(want[c], 3); + expect(got.alpha).toBeCloseTo(want[3], 3); + }); + + it('between texels it is near the model: the filtering error is small against the signal', () => { + const want = aerialRay(10, -1.3, 75)[AP_SLICES - 2]; + const got = aerialAt(10, -1.3, 75, apSliceKm(AP_SLICES - 2)); + expect(Math.abs(got.alpha - want[3])).toBeLessThan(0.03); + expect(got.L[0] / want[0]).toBeGreaterThan(0.6); + expect(got.L[0] / want[0]).toBeLessThan(1.6); + }); + + it('fades to nothing at the observer, and clamps past the last slice', () => { + expect(aerialAt(45, -2, 90, 0)).toEqual({ L: [0, 0, 0], alpha: 0 }); + const last = aerialAt(45, -2, 90, apSliceKm(AP_SLICES - 1)); + const beyond = aerialAt(45, -2, 90, 1e6); + expect(beyond.alpha).toBeCloseTo(last.alpha, 9); + }); + + it('the shader lookup carries the same constants as the table', () => { + const a = baked.scatter.aerial_perspective; + expect(AERIAL_GLSL).toContain(`/ ${a.height}.0`); + expect(AERIAL_GLSL).toContain(`${a.max_km.toFixed(1)}`); + expect(AERIAL_GLSL).toContain(`${AP_ELEVATIONS}.0 +`); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts new file mode 100644 index 00000000..72ebfb5c --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -0,0 +1,179 @@ +// The baked scattering tables (src/sky/baked.ts `scatter`, layout in src/sky/README.md) as the terrain scene +// uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and +// for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. +import { baked } from '../../../../sky/baked'; +import { + AP_DISTANCE_SCALE, + AP_ELEVATIONS, + AP_SLICES, + apElevationIndex, + apSliceIndex, + decode, + sunIndex, + viewIndex, +} from '../../../../sky/scatter.js'; +import type { Vec3 } from './terrainSky'; + +const S = baked.scatter; +const AP = S.aerial_perspective; +const SKY = S.sky_view; +const VIEW = S.view_elevation_deg; + +/** Raw little-endian half-float words of a base64 table, ready for `texImage3D(..., HALF_FLOAT, words)`. */ +export function tableWords(b64: string): Uint16Array { + const bytes = atob(b64); + return Uint16Array.from( + { length: bytes.length / 2 }, + (_, i) => bytes.charCodeAt(2 * i) + 256 * bytes.charCodeAt(2 * i + 1) + ); +} + +export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth } as const; +export const STORE_SCALE = S.store_scale; +export const skyViewWords = (): Uint16Array => tableWords(SKY.data); + +/** The aerial perspective volume (layout in src/sky/scatter.js): RGBA16F, x azimuth from the light, y slice-major block of elevations, z light elevation. */ +export const AP_VOLUME = { width: AP.width, height: AP.height, depth: AP.depth } as const; +export const apVolumeWords = (): Uint16Array => tableWords(AP.data); +export { AP_DISTANCE_SCALE }; + +/** Decode a stored table to values, once. */ +const decoded = (data: string) => { + let values: Float32Array | undefined; + return (): Float32Array => { + if (!values) values = Float32Array.from(tableWords(data), decode); + return values; + }; +}; +const transmittanceTable = decoded(S.transmittance.data); +const skyTable = decoded(SKY.data); +const apTable = decoded(AP.data); + +/** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ +export function transmittanceAt(elevationDeg: number): Vec3 { + const t = transmittanceTable(); + const f = Math.min(viewIndex(elevationDeg), VIEW.samples - 1); + const i = Math.min(Math.floor(f), VIEW.samples - 2); + const k = f - i; + return [0, 1, 2].map((c) => t[i * 3 + c] * (1 - k) + t[(i + 1) * 3 + c] * k) as unknown as Vec3; +} + +/** + * Sky radiance per steradian for a sun of unit irradiance, trilinear in (azimuth from the sun, view + * elevation, sun elevation) on texel centres, clamped at the edges: what the GPU's LINEAR sampler returns. + */ +export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDeg: number): Vec3 { + const t = skyTable(); + const at = [ + Math.min(Math.abs(azFromSunDeg) / 180, 1) * (SKY.width - 1), + viewIndex(viewElDeg), + sunIndex(sunElDeg), + ]; + const lo = at.map((v, a) => Math.min(Math.floor(v), [SKY.width, SKY.height, SKY.depth][a] - 2)); + const f = at.map((v, a) => v - lo[a]); + const out = [0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = (((lo[2] + dz) * SKY.height + lo[1] + dy) * SKY.width + lo[0] + dx) * 3; + for (let c = 0; c < 3; c += 1) out[c] += w * t[base + c]; + } + return [out[0], out[1], out[2]]; +} + +/** + * Aerial perspective along a view ray: in-scatter luminance per channel (for a light of unit irradiance) and opacity + * (1 - mean transmittance), for a light at `lightElDeg`, a ray `viewElDeg` (0 and below) and `azDeg` from the light, + * `km` of baked path along it. Trilinear in (azimuth, elevation, light elevation) and linear between slices, the arithmetic the + * shader's `aerial()` does on the GPU; below the first slice it fades to nothing at the observer (Hillaire's near-slice fade). + */ +export function aerialAt( + lightElDeg: number, + viewElDeg: number, + azDeg: number, + km: number +): { L: Vec3; alpha: number } { + const t = apTable(); + const x = Math.min(Math.abs(azDeg) / 180, 1) * (AP.width - 1); + const e = apElevationIndex(viewElDeg); + const z = sunIndex(lightElDeg); + const texel = (slice: number): number[] => { + const lo = [ + Math.min(Math.floor(x), AP.width - 2), + Math.min(Math.floor(e), AP_ELEVATIONS - 2), + Math.min(Math.floor(z), AP.depth - 2), + ]; + const f = [x - lo[0], e - lo[1], z - lo[2]]; + const out = [0, 0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = + (((lo[2] + dz) * AP.height + slice * AP_ELEVATIONS + lo[1] + dy) * AP.width + lo[0] + dx) * + 4; + for (let c = 0; c < 4; c += 1) out[c] += w * t[base + c]; + } + return out; + }; + const f = apSliceIndex(km); + const fs = Math.min(Math.max(f, 0), AP_SLICES - 1); + const k = Math.min(Math.floor(fs), AP_SLICES - 2); + const a = texel(k); + const b = texel(k + 1); + const fade = Math.min(Math.max(f + 1, 0), 1); + const v = [0, 1, 2, 3].map((c) => (a[c] + (b[c] - a[c]) * (fs - k)) * fade); + return { L: [v[0], v[1], v[2]], alpha: v[3] }; +} + +/** + * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree + * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the + * terrain's haze both take the colour here, flat to the horizon, and meet without a seam. + */ +export const HORIZON_FLOOR = 1.5; + +/** The sky shader's lookup, in GLSL: `skyScatter(viewDirection)` is unit-sun radiance, needs uSky, uSunIndex, uSun. */ +export const SCATTER_GLSL = ` +uniform highp sampler3D uSky; +uniform float uSunIndex; +uniform vec3 uSun; +vec3 skyScatter(vec3 d) { + float el = max(degrees(asin(clamp(d.y, -1.0, 1.0))), ${HORIZON_FLOOR.toFixed(1)}); + vec2 h = d.xz, s = uSun.xz; + float az = (length(h) < 1e-4 || length(s) < 1e-4) ? 0.0 + : degrees(acos(clamp(dot(normalize(h), normalize(s)), -1.0, 1.0))); + float y = sqrt(clamp((el - (${VIEW.min.toFixed(1)})) / ${(VIEW.max - VIEW.min).toFixed( + 1 +)}, 0.0, 1.0)) * ${(VIEW.samples - 1).toFixed(1)}; + float x = clamp(az / 180.0, 0.0, 1.0) * ${(SKY.width - 1).toFixed(1)}; + return texture(uSky, vec3((x + 0.5) / ${SKY.width}.0, (y + 0.5) / ${ + SKY.height +}.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; +}`; + +/** + * The aerial perspective lookup, in GLSL: `aerial(lightIndex, viewElDeg, azDeg, km)` is `aerialAt` on the GPU. Needs uAP. + * Each slice is one hardware-filtered fetch, kept inside its block of elevation rows so the filter never reads a + * neighbouring slice; two fetches and a lerp per call. + */ +export const AERIAL_GLSL = ` +uniform highp sampler3D uAP; +vec4 apSlice(float k, float e, float x, float z) { + return texture(uAP, vec3((x + 0.5) / ${AP.width}.0, (k * ${AP.elevations}.0 + e + 0.5) / ${ + AP.height +}.0, (z + 0.5) / ${AP.depth}.0)) / ${S.store_scale.toFixed(1)}; +} +vec4 aerial(float lightIndex, float viewElDeg, float azDeg, float km) { + float e = ${(AP.elevations - 1).toFixed( + 1 + )} * (1.0 - sqrt(clamp(-viewElDeg / ${(-AP.elevation_min_deg).toFixed(1)}, 0.0, 1.0))); + float x = clamp(abs(azDeg) / 180.0, 0.0, 1.0) * ${(AP.width - 1).toFixed(1)}; + float f = ${AP.slices}.0 * sqrt(max(km, 0.0) / ${AP.max_km.toFixed(1)}) - 1.0; + float fs = clamp(f, 0.0, ${(AP.slices - 1).toFixed(1)}); + float k = min(floor(fs), ${(AP.slices - 2).toFixed(1)}); + return mix(apSlice(k, e, x, lightIndex), apSlice(k + 1.0, e, x, lightIndex), fs - k) * clamp(f + 1.0, 0.0, 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts new file mode 100644 index 00000000..8e270d83 --- /dev/null +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -0,0 +1,237 @@ +// GLSL ES 3.00. The height map is baked once by CultMath's Phacelle noise (vendored, unedited, MPL-2.0: Rune +// Skovbo Johansen's Phacelle noise, https://blog.runevision.com/2026/01/) composed into a slope-directed gully +// filter after the description in his March 2026 erosion-filter post. The composition is ours, written from +// that description; it is not his filter and has not been compared against it (see harness terrainDemo.ts on +// the terrain-tier-spike branch, from which this is promoted). The height is packed in two bytes of an RGBA8 +// target, so no float colour-buffer extension is needed. +import cultmath from '../shaders/CultMath.glsl?raw'; +import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; +import { trimGlsl } from '../cultmathTrim'; +import { ALBEDO, CAVITY } from './terrainAlbedo'; +import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; +import { TERRAIN } from './terrainWorld'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; +const f1 = (x: number) => x.toFixed(1); +const lit = (x: number) => (Number.isInteger(x) ? x.toFixed(1) : String(x)); + +export const FULLSCREEN_VERT = `${HEADER} +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}`; + +const HEIGHT_STAGE = ` +uniform float uSpan; +uniform vec2 uSeed; +uniform float uTexels; +out vec4 o; + +const float BASE_AMP = 60.0; +const float BASE_FREQ = 1.0 / 520.0; +const float BASE_LIFT = 110.0; +const float CELL0 = 160.0; +const float SIDE_LEN = 3.0; +const float NORM = 0.5; +const float SLOPE_REF = 0.55; +const float DETAIL = 1.5; +const float AMP0 = 11.0; +const float HLO = 20.0, HHI = 200.0; + +float easeOut(float t) { t = clamp(t, 0.0, 1.0); return 1.0 - (1.0 - t) * (1.0 - t); } +float powInv(float t, float p) { return 1.0 - pow(1.0 - t, p); } + +float height(vec2 xz) { + vec4 fg = cultmath_fbm_grad(vec3(xz * BASE_FREQ, 0.0), 4, 2.0, 0.45); + float h = BASE_LIFT + BASE_AMP * fg.w; + vec2 g = BASE_AMP * BASE_FREQ * fg.xy; + float target = clamp((h - HLO) / (HHI - HLO) * 2.0 - 1.0, -1.0, 1.0); + float combi = 1.0; + float amp = AMP0; + float cell = CELL0; + for (int i = 0; i < OCTAVES; i++) { + float slope = length(g); + vec2 downhill = slope > 1e-4 ? -g / slope : vec2(1.0, 0.0); + vec2 across = vec2(-downhill.y, downhill.x); + CultPhasor ph = cultmath_phacelle(vec3(xz / cell, 0.5), vec3(across * SIDE_LEN, 0.0), 0.0, NORM); + float mask = easeOut(slope / SLOPE_REF) * combi; + float faded = mix(target, ph.cos.w, mask); + h += amp * faded; + g += amp * mask * ph.cos.xy / cell; + combi = powInv(combi, DETAIL) * (1.0 - abs(ph.sin.w)); + target = faded; + amp *= 0.5; + cell *= 0.5; + } + return h; +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(uTexels); + float h = clamp(height((uv - 0.5) * uSpan + uSeed) / ${f1(TERRAIN.hmax)}, 0.0, 1.0); + float q = floor(h * 65535.0 + 0.5); + o = vec4(floor(q / 256.0) / 255.0, mod(q, 256.0) / 255.0, 0.0, 1.0); +}`; + +/** The height-bake fragment shader: header, the part of CultMath that Phacelle and the stage reach, Phacelle, the stage. */ +export function heightFragmentSource(): string { + const math = trimGlsl(cultmath, [phacelle, HEIGHT_STAGE]); + return `${HEADER}#define OCTAVES 4\n${math}\n${phacelle}\n${HEIGHT_STAGE}`; +} + +const FETCH = ` +uniform sampler2D uHeight; +uniform int uTex; +float hAt(ivec2 t) { + t = clamp(t, ivec2(0), ivec2(uTex - 1)); + vec4 s = texelFetch(uHeight, t, 0); + return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( + TERRAIN.hmax + )}; +} +// The height at a point in texel units, bilinear over the four texels around it (the packed texture cannot be filtered by the GPU). +float hBil(vec2 p) { + ivec2 i = ivec2(floor(p)); + vec2 f = fract(p); + return mix(mix(hAt(i), hAt(i + ivec2(1, 0)), f.x), mix(hAt(i + ivec2(0, 1)), hAt(i + ivec2(1, 1)), f.x), f.y); +}`; + +export const GRID_VERT = `${HEADER} +layout(location = 0) in vec2 a_uv; +uniform mat4 uViewProj; +uniform float uSpan; +out vec2 v_uv; +out vec3 v_world; +${FETCH} +void main() { + // The grid is packed toward the centre, where the camera stands (terrainWorld.warpUv): s = 2u - 1, 0.5 + 0.5 (0.5 s + 0.5 s |s|). + vec2 s = a_uv * 2.0 - 1.0; + vec2 uv = 0.5 + 0.5 * (0.5 * s + 0.5 * s * abs(s)); + // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. + float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(uv.x - 0.5), abs(uv.y - 0.5))); + float h = hBil(uv * float(uTex - 1)) * rim; + v_uv = uv; + v_world = vec3((uv.x - 0.5) * uSpan, h, (uv.y - 0.5) * uSpan); + gl_Position = uViewProj * vec4(v_world, 1.0); +}`; + +// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient; hazed toward the +// scattered sky at the horizon in the ray's azimuth (SKY_COLOUR_GLSL). +// The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. +const ALBEDO_DEFINES = ` +#define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} +#define ALB_SCREE_TO ${lit(ALBEDO.screeTo)} +#define ALB_SNOW_BAND ${lit(ALBEDO.snowBand)} +#define ALB_ROCK_HI ${lit(ALBEDO.rockHi)} +#define ALB_ROCK_LO ${lit(ALBEDO.rockLo)} +#define ALB_SNOW_HI ${lit(ALBEDO.snowHi)} +#define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} +#define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} +#define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} +#define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} +#define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} +#define ALB_STRATA ${lit(ALBEDO.strata)} +#define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} +#define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} +#define CAV_RADIUS ${lit(CAVITY.radius)} +#define CAV_RANGE ${lit(CAVITY.range)} +#define CAV_DARK ${lit(CAVITY.dark)} +#define CAV_LIGHT ${lit(CAVITY.light)} +`; +// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The +// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. +const SKY_COLOUR_GLSL = `${SCATTER_GLSL} +uniform vec3 uGlow; +vec3 skyColour(vec3 d) { + return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( + GLOW_ZENITH +)}, sqrt(clamp(d.y, 0.0, 1.0))); +}`; + +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} +in vec2 v_uv; +in vec3 v_world; +uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; +uniform float uSpan, uSnowLine, uNight, uMoonIndex; +out vec4 o; +${FETCH} +// Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). +float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); } +float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; } +void main() { + // Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders. + vec2 p = v_uv * float(uTex - 1); + float texel = uSpan / float(uTex - 1); + float dx = hBil(p + vec2(1.5, 0.0)) - hBil(p - vec2(1.5, 0.0)); + float dz = hBil(p + vec2(0.0, 1.5)) - hBil(p - vec2(0.0, 1.5)); + vec3 n = normalize(vec3(-dx, 3.0 * texel, -dz)); + vec3 tw = pow(abs(n), vec3(4.0)); + tw /= tw.x + tw.y + tw.z; + vec2 qx = v_world.zy, qy = v_world.xz, qz = v_world.xy; + // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). + float w = tw.x * wob2(qx) + tw.y * wob2(qy) + tw.z * wob2(qz); + float h = v_world.y + ALB_WOBBLE_H * w; + float flat_ = n.y + ALB_WOBBLE_S * w; + vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); + float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); + float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); + vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); + // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). + float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)); + float fine = tw.x * waves3(qx, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(qy, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(qz, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); + float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); + float layers = sin(v_world.y * 0.9 + w * 3.0); + albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light (terrainAlbedo.cavityAt). Shader-only, from the height map, + // read bilinear at a continuous position so it does not snap to a texel (that made the shading blocks of one texel, 3 m). + float cr = CAV_RADIUS / texel; + float curv = 0.25 * (hBil(p + vec2(cr, 0.0)) + hBil(p - vec2(cr, 0.0)) + hBil(p + vec2(0.0, cr)) + hBil(p - vec2(0.0, cr))) - hBil(p); + float cavity = 1.0 - CAV_DARK * clamp(curv / CAV_RANGE, 0.0, 1.0) + CAV_LIGHT * clamp(-curv / CAV_RANGE, 0.0, 1.0); + vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); + vec3 toSurface = v_world - uCamera; + float dist = length(toSurface); + // Aerial perspective (terrainSky.aerialPerspective): the baked volume by this ray's elevation, azimuth from the light and path. + vec3 ray = toSurface / dist; + float el = min(degrees(asin(clamp(ray.y, -1.0, 1.0))), 0.0); + vec2 hz = ray.xz, sz = uSun.xz; + float az = (length(hz) < 1e-4 || length(sz) < 1e-4) ? 0.0 : degrees(acos(clamp(dot(normalize(hz), normalize(sz)), -1.0, 1.0))); + float km = dist * ${lit(AP_DISTANCE_SCALE / 1000)}; + vec4 ap = aerial(uSunIndex, el, az, km); + vec3 L = ap.rgb; + // The moon lights the haze from where the sun is mirrored above the horizon, sharing its azimuth; a starlight floor stays. + if (uNight > 0.0) L += uNight * vec3(${MOON_HAZE.map(lit).join( + ', ' + )}) * aerial(uMoonIndex, el, az, km).rgb; + vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( + lit +).join(', ')}); + // Past SKY_FADE the surface is the sky at the horizon in this ray's azimuth, the colour the sky pass draws there (terrainSky.aerialPerspective). + float toSky = smoothstep(${lit(SKY_FADE[0])}, ${lit(SKY_FADE[1])}, dist); + vec3 horizon = skyColour(vec3(ray.x, 0.0, ray.z)); + o = vec4(pow(mix(lit * (1.0 - ap.a) + haze, horizon, toSky), vec3(1.0 / 2.2)), 1.0); +}`; + +// The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a +// dither of +-0.5/255 on the 8-bit output so the dusk glow does not band. +const DISC = { + inner: Math.cos((0.5 * Math.PI) / 180), + outer: Math.cos((1.1 * Math.PI) / 180), + gain: 6.0, +}; +export const SKY_FRAG = `${HEADER}${SKY_COLOUR_GLSL} +uniform vec3 uSunColour; +uniform vec3 uRight, uUp, uForward; +uniform vec2 uSize; +uniform float uTanHalf; +out vec4 o; +void main() { + vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; + vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); + vec3 c = skyColour(d) + uSunColour * ${lit(DISC.gain)} * smoothstep(${DISC.outer}, ${ + DISC.inner +}, dot(d, uSun)); + float dither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5; + o = vec4(pow(c, vec3(1.0 / 2.2)) + dither / 255.0, 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts new file mode 100644 index 00000000..016ee1ce --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -0,0 +1,300 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { AP_DISTANCE_SCALE } from './terrainScatter'; +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../../sky/baked'; +import { + coldness, + glowColour, + hazeColour, + hexToLinear, + nightness, + shade, + SKY, + skyAtSun, + skyColour, + skyNameFor, + sunDirection, + aerialPerspective, + SKY_FADE, + metresToKm, + groundLight, + HAZE_K, + HAZE_POWER, + START_TURNS, + Vec3, +} from './terrainSky'; +import { skyColours } from '../ridge/skyColour'; + +const UP = [0, 1, 0] as const; +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +/** Unit view direction at an elevation (degrees) and an azimuth clockwise from north. */ +const look = (el: number, az: number): Vec3 => { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; +}; + +describe('the baked sky is read, not copied', () => { + it('SKY carries Void’s numbers: sun, ambient, horizon', () => { + for (const name of ['midnight', 'noonday'] as const) { + const b = baked[name]; + expect(SKY[name].elevation).toBe(b.sun.elevation_deg); + expect(SKY[name].azimuth).toBe(b.sun.azimuth_deg); + expect(SKY[name].direct).toEqual(b.sun.color_linear); + expect(SKY[name].ambient).toEqual(b.ambient_linear); + expect(SKY[name].horizon).toEqual(hexToLinear(b.horizon_hex)); + } + }); + + it('keeps the two moments apart: night sun below the horizon, noon sun well above', () => { + expect(SKY.midnight.elevation).toBeLessThan(0); + expect(SKY.noonday.elevation).toBeGreaterThan(30); + }); + + it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { + for (const d of Object.values(SKY)) { + const s = sunDirection(d); + expect(Math.hypot(...s)).toBeCloseTo(1, 9); + expect(s[1]).toBeCloseTo(Math.sin((d.elevation * Math.PI) / 180), 9); + } + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 0 })[2]).toBeCloseTo(-1, 9); + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 180 })[2]).toBeCloseTo(1, 9); + }); + + it('hexToLinear decodes sRGB', () => { + expect(hexToLinear('#000000')).toEqual([0, 0, 0]); + expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); + expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3); + }); + + it('the default sky from skyColours() is a night one, as the stars see it', () => { + expect(skyNameFor(skyColours())).toBe('midnight'); + expect(skyNameFor({ ...skyColours(), top: [150, 198, 255] })).toBe('noonday'); + }); +}); + +describe('a sun at any elevation (skyAtSun)', () => { + it('lights nothing under the horizon and fades in across the last degrees, as sun.js does', () => { + expect(skyAtSun(-2).direct).toEqual([0, 0, 0]); + expect(skyAtSun(-30).direct).toEqual([0, 0, 0]); + const at = (e: number) => luma(skyAtSun(e).direct); + expect(at(0.3)).toBeGreaterThan(0); + expect(at(0.3)).toBeLessThan(at(1.5)); + expect(at(1.5)).toBeLessThan(at(60)); + }); + + it('agrees with the baked noon sun colour where they overlap, to a couple of percent', () => { + const want = SKY.noonday.direct; + const got = skyAtSun(SKY.noonday.elevation).direct; + got.forEach((v, i) => expect(Math.abs(v / want[i] - 1)).toBeLessThan(0.02)); + }); + + it('the sun goes red as it sets: less blue left in the direct light at 2 degrees than at 45', () => { + const lo = skyAtSun(2).direct; + const hi = skyAtSun(45).direct; + expect(lo[2] / lo[0]).toBeLessThan(0.6 * (hi[2] / hi[0])); + }); + + it('ambient and horizon run from the midnight bake to the noonday one as the sun climbs', () => { + expect(luma(skyAtSun(-30).ambient)).toBeCloseTo(luma(SKY.midnight.ambient), 9); + expect(luma(skyAtSun(60).ambient)).toBeCloseTo(luma(SKY.noonday.ambient), 9); + expect(luma(skyAtSun(5).ambient)).toBeGreaterThan(luma(SKY.midnight.ambient)); + expect(luma(skyAtSun(5).ambient)).toBeLessThan(luma(SKY.noonday.ambient)); + expect(skyAtSun(10, 270).azimuth).toBe(270); + }); +}); + +describe('lighting', () => { + it('noonday lights an upward face with the sun; midnight has none', () => { + expect(luma(SKY.noonday.direct)).toBeGreaterThan(0.5); + expect(SKY.midnight.direct).toEqual([0, 0, 0]); + }); + + it('shade never goes negative on any normal at either sky, at any distance', () => { + for (const d of [...Object.values(SKY), skyAtSun(-3), skyAtSun(1)]) + for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) + for (const dist of [0, 100, 5000]) + for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist, look(-5, 40))) + expect(ch).toBeGreaterThanOrEqual(0); + }); + + it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { + const toSun = sunDirection(SKY.noonday); + const away = [-toSun[0], 0, -toSun[2]] as const; + const view = look(-5, 0); + const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0, view)); + const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0, view)); + expect(lit).toBeGreaterThan(3 * dark); + const m = (n: readonly [number, number, number]) => + shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); + expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black + expect(luma(m(UP))).toBeLessThan(0.12); + }); + + it('noon is much brighter than midnight, ambient included', () => { + expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const view = look(-5, 0); + expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( + 3 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) + ); + }); + + it('the cold tint (albedo) is 0 with the sun up and 1 once it is below -6', () => { + expect(coldness(SKY.noonday)).toBe(0); + expect(coldness(SKY.midnight)).toBe(1); + expect(hazeColour(SKY.noonday)).toEqual(SKY.noonday.horizon); + }); +}); + +describe('the scattered sky', () => { + it('noon is blue overhead: more blue than red, much brighter than the night glow', () => { + const zenith = skyColour(SKY.noonday, [0, 1, 0]); + expect(zenith[2]).toBeGreaterThan(1.5 * zenith[0]); + expect(luma(zenith)).toBeGreaterThan(5 * luma(skyColour(SKY.midnight, [0, 1, 0]))); + zenith.forEach((v) => expect(v).toBeLessThan(1)); + }); + + it('a sun at the horizon reddens the sky toward it and darkens it away', () => { + const dusk = skyAtSun(-1, 270); + const toward = skyColour(dusk, look(3, 270)); + const away = skyColour(dusk, look(3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + }); + + it('under a deep night sun the tables are black and only the glow remains, brightest at the horizon', () => { + const d = SKY.midnight; + const horizon = skyColour(d, look(0, 180)); + const zenith = skyColour(d, [0, 1, 0]); + horizon.forEach((v, i) => expect(v).toBeCloseTo(d.horizon[i], 6)); + expect(luma(zenith)).toBeLessThan(luma(horizon)); + expect(luma(zenith)).toBeGreaterThan(0.5 * luma(horizon)); + }); + + it('the night glow fades out as the sun reaches the horizon', () => { + expect(nightness(-10)).toBe(1); + expect(nightness(5)).toBe(0); + expect(glowColour(SKY.noonday)).toEqual([0, 0, 0]); + expect(glowColour(SKY.midnight)).toEqual(SKY.midnight.horizon); + expect(luma(glowColour(skyAtSun(-2)))).toBeLessThan(luma(glowColour(skyAtSun(-6)))); + }); + + it('is continuous in the sun’s elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(skyColour(skyAtSun(e, 270), look(10, 200))); + for (let e = -20; e < 30; e += 0.5) { + expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.05); + } + }); +}); + +describe('aerial perspective', () => { + const ray = (el: number, az: number): Vec3 => look(el, az); + const along = (d: typeof SKY.midnight, el: number, az: number, m: number) => + aerialPerspective(d, ray(el, az), m); + + it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { + let prev = along(SKY.noonday, -2, 200, 0).alpha; + expect(prev).toBe(0); + for (const m of [10, 100, 400, 800, 1000, 1300]) { + const a = along(SKY.noonday, -2, 200, m).alpha; + expect(a).toBeGreaterThan(prev); + expect(a).toBeLessThanOrEqual(1); + prev = a; + } + }); + + it('leaves the near ground its own colour and takes the far ridges', () => { + expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); + expect(along(SKY.noonday, -2, 200, SKY_FADE[0]).alpha).toBeGreaterThan(0.1); + }); + + it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { + const far = along(SKY.noonday, -2, 90, SKY_FADE[0]).colour; + expect(far[2]).toBeGreaterThan(far[0]); + }); + + it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { + const dusk = skyAtSun(2, 270); + const toward = along(dusk, -1, 270, SKY_FADE[0]).colour; + const away = along(dusk, -1, 90, SKY_FADE[0]).colour; + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + expect(toward[0]).toBeGreaterThan(toward[2]); + }); + + it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { + const c = along(SKY.midnight, -2, 0, SKY_FADE[0]).colour; + expect(luma(c)).toBeGreaterThan(0.0003); + expect(luma(c)).toBeLessThan(0.08); + expect(c[2]).toBeGreaterThan(2.5 * c[0]); + expect(luma(along(SKY.noonday, -2, 0, SKY_FADE[0]).colour)).toBeGreaterThan(5 * luma(c)); + }); + + it('moonlight lights the night haze toward the moon more than away from it', () => { + const moon = groundLight(SKY.midnight).dir; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], SKY_FADE[0]).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], SKY_FADE[0]).colour; + expect(luma(toward)).toBeGreaterThan(luma(away)); + }); + + it('with distance a dark surface rises toward the haze and its own colour is lost', () => { + const d = SKY.noonday; + const view = ray(-4, 200); + const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); + expect(dark(SKY_FADE[0])).toBeGreaterThan(dark(100)); + const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; + expect(own(SKY_FADE[0])).toBeLessThan(0.9 * own(100)); + }); + + it('is continuous in the sun elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, SKY_FADE[0]).colour); + for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); + }); + + it('far terrain is the sky: past the fade it is the horizon sky colour in its azimuth, by day, dusk and night, whatever it was', () => { + for (const [d, az] of [ + [SKY.midnight, 0], + [skyAtSun(-1, 90), 90], + [skyAtSun(2, 270), 200], + [SKY.noonday, 120], + ] as const) { + const view = ray(-0.5, az); + const hz = skyColour(d, [view[0], 0, view[2]]); + const far = aerialPerspective(d, view, SKY_FADE[1]); + expect(far.alpha).toBe(1); + for (let i = 0; i < 3; i += 1) expect(far.colour[i]).toBeCloseTo(hz[i], 9); + expect(shade(d, [0.5, 0.5, 0.5], UP, SKY_FADE[1] + 500, view)).toEqual(far.colour); + } + }); + + it('the metres-to-km law is the scene metres times the baked distance scale', () => { + expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); + expect(metresToKm(-5)).toBe(0); + }); +}); + +describe('ground light and haze', () => { + it("at night the ground has a moon above the horizon on the sun's mirrored side, and a lifted ambient; by day it is the sun", () => { + const night = groundLight(SKY.midnight); + expect(night.dir[1]).toBeGreaterThan(0.5); + expect(luma(night.colour)).toBeGreaterThan(0.03); + expect(luma(night.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const day = groundLight(SKY.noonday); + expect(Array.from(day.dir)).toEqual(Array.from(sunDirection(SKY.noonday))); + expect(Array.from(day.colour)).toEqual(Array.from(SKY.noonday.direct)); + expect(Array.from(day.ambient)).toEqual(Array.from(SKY.noonday.ambient)); + }); + + it('the ground light is continuous through sunset', () => { + const at = (e: number) => luma(groundLight(skyAtSun(e)).colour); + for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); + }); + + it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { + const yaw = 2 * Math.PI * START_TURNS; + const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; + const sun = sunDirection(SKY.noonday); + const along = (forward[0] * sun[0] + forward[2] * sun[2]) / Math.hypot(sun[0], sun[2]); + expect(Math.abs(along)).toBeLessThan(0.4); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts new file mode 100644 index 00000000..4034fd93 --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -0,0 +1,246 @@ +// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's +// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. +import { baked } from '../../../../sky/baked'; +import { isNight } from '../ridge/stars'; +import { skyColours } from '../ridge/skyColour'; +import type { SkyColours } from '../ridge/skyColour'; +import { + AP_DISTANCE_SCALE, + aerialAt, + HORIZON_FLOOR, + scatterRadiance, + transmittanceAt, +} from './terrainScatter'; + +export type SkyName = 'midnight' | 'noonday'; +export type Vec3 = readonly [number, number, number]; + +export interface SkyData { + /** Degrees above the horizon, no refraction. Negative: the sun is down. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ + direct: Vec3; + /** Sky ambient on an upward surface, linear RGB; at night a moonlit floor, not black. */ + ambient: Vec3; + /** The horizon, linear RGB: the baked `horizon_hex`. */ + horizon: Vec3; +} + +const RAD = Math.PI / 180; +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; +const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => + [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; + +/** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ +export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); + +export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday'; + +/** sRGB hex `#rrggbb` to linear RGB 0..1. */ +export function hexToLinear(hex: string): Vec3 { + const ch = (at: number) => { + const s = parseInt(hex.slice(at, at + 2), 16) / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return [ch(1), ch(3), ch(5)]; +} + +const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ + elevation: b.sun.elevation_deg, + azimuth: b.sun.azimuth_deg, + direct: b.sun.color_linear, + ambient: b.ambient_linear, + horizon: hexToLinear(b.horizon_hex), +}); + +export const SKY: Record = { + midnight: fromBaked(baked.midnight), + noonday: fromBaked(baked.noonday), +}; + +export const skyData = (name: SkyName): SkyData => SKY[name]; + +/** + * A sky for a sun at any elevation, between the two baked ones. The sun's colour is the baked transmittance at + * that elevation, faded out across the disc's last degrees the way sun.js does (gone at -0.83, whole by 1.5); + * ambient and horizon blend from the midnight bake to the noonday one as the sun climbs from -8 to 25 degrees. + */ +export function skyAtSun(elevation: number, azimuth: number = SKY.noonday.azimuth): SkyData { + const day = smooth(-8, 25, elevation); + const fade = smooth(-0.83, 1.5, elevation); + const t = transmittanceAt(Math.max(elevation, -4)); + return { + elevation, + azimuth, + direct: [t[0] * fade, t[1] * fade, t[2] * fade], + ambient: mixVec(SKY.midnight.ambient, SKY.noonday.ambient, day), + horizon: mixVec(SKY.midnight.horizon, SKY.noonday.horizon, day), + }; +} + +/** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ +export function sunDirection(d: SkyData): Vec3 { + const el = d.elevation * RAD; + const az = d.azimuth * RAD; + return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; +} + +/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette shares. */ +export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); + +/** The colour distance fades to when the air has nothing to scatter: the baked horizon. Albedo tints toward it at night. */ +export const hazeColour = (d: SkyData): Vec3 => d.horizon; + +/** Sky radiance (see the sun's `skyScatter` in terrainScatter.ts) is for unit sun irradiance; this lifts it to the image. EYEBALLED. */ +export const SKY_EXPOSURE = 45; +/** How far the night glow dims from the horizon to the zenith (1 at the horizon). EYEBALLED. */ +export const GLOW_ZENITH = 0.75; + +/** 1 with the sun below -4 degrees, 0 above 4: where the midnight horizon colour stands in for light the single-scattering tables lack. */ +export const nightness = (elevation: number): number => 1 - smooth(-4, 4, elevation); + +/** The night glow colour: the midnight bake's horizon, fading out as the sun nears the horizon (whatever horizon the day blend gives `d`). */ +export const glowColour = (d: SkyData): Vec3 => { + const k = nightness(d.elevation); + const h = SKY.midnight.horizon; + return [h[0] * k, h[1] * k, h[2] * k]; +}; + +/** + * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering + * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits + * brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same. + */ +export function skyColour(d: SkyData, dir: Vec3): Vec3 { + const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const r = scatterRadiance(d.elevation, el, az); + const glow = glowColour(d); + const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); + return [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape + ) as unknown as Vec3; +} + +/** + * Aerial perspective: the baked volume of src/sky/scatter.js (after Hillaire 2020, see there for what follows the paper + * and what does not), looked up by the view ray relative to the light and the metres to the surface. The surface keeps + * (1 - alpha) of its own colour (alpha = 1 - mean transmittance, a grey transmittance as the paper) and gains the in-scatter L, + * lifted by the sky's exposure and tone-mapped the way the sky is, so far ground meets the sky's colour. SHORTCUT: the path is + * the scene's metres times AP_DISTANCE_SCALE (a real atmosphere is nearly clear over 3 km). + */ +export const metresToKm = (metres: number): number => + (Math.max(0, metres) / 1000) * AP_DISTANCE_SCALE; + +/** Moonlight's irradiance on the haze, relative to the sun's 1, at full night. EYEBALLED, far above a real moon (about 3e-6) as the sky exposure is. */ +export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; +/** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ +export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; + +/** + * Metres from the eye over which the terrain gives itself up to the sky: past the far end a surface is the sky's own + * colour at the horizon in its azimuth (`skyColour` flat along the horizon, night glow included), so the patch's rim + * meets the sky above and beyond it with no seam. The volume alone is only ~40% opaque by then. EYEBALLED. + */ +export const SKY_FADE: readonly [number, number] = [600, 1350]; + +/** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ +export function aerialPerspective( + d: SkyData, + dir: Vec3, + distance: number +): { colour: Vec3; alpha: number } { + const el = Math.min(0, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const km = metresToKm(distance); + const night = nightness(d.elevation); + const day = aerialAt(d.elevation, el, az, km); + const L = [...day.L]; + if (night > 0) { + // The moon stands where the sun is mirrored above the horizon (groundLight), so it shares the sun's azimuth. + const moon = aerialAt(Math.abs(d.elevation), el, az, km); + for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; + } + const horizon = skyColour(d, [dir[0], 0, dir[2]]); + const f = smooth(SKY_FADE[0], SKY_FADE[1], distance); + const colour = [0, 1, 2].map( + (i) => + (1 - f) * (1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha) + + f * horizon[i] + ) as unknown as Vec3; + return { colour, alpha: (1 - f) * day.alpha + f }; +} + +/** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ +export const NIGHT_AMBIENT_LIFT = 3; +/** Cool light from the mirror of the sun's below-horizon position, linear, scaled by `nightness`: gives the terrain a lit side at night. */ +export const MOON_COLOUR: Vec3 = [0.05, 0.07, 0.13]; + +/** The light on the ground: the sun, or at night the moon standing where the sun is mirrored above the horizon. Continuous through sunset. */ +export function groundLight(d: SkyData): { dir: Vec3; colour: Vec3; ambient: Vec3 } { + const sun = sunDirection(d); + const k = nightness(d.elevation); + return { + dir: [sun[0], Math.abs(sun[1]), sun[2]], + colour: [0, 1, 2].map((i) => d.direct[i] + MOON_COLOUR[i] * k) as unknown as Vec3, + ambient: [0, 1, 2].map((i) => d.ambient[i] * (1 + NIGHT_AMBIENT_LIFT * k)) as unknown as Vec3, + }; +} + +/** + * The heading the camera starts at, in turns from north: the noonday sun a hundred degrees to its right, so the light + * rakes across the relief instead of flattening it from behind. Fixed, so `?az=` still moves the sun in the picture. + */ +export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; + +/** + * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from + * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * then aerial perspective (`aerialPerspective`). `normal` is a unit vector. Never negative. + */ +export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { + const light = groundLight(d); + const sun = light.dir; + const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); + const hemi = 0.5 + 0.5 * normal[1]; + const haze = aerialPerspective(d, view, distance); + return [0, 1, 2].map((i) => { + const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); + return lit * (1 - haze.alpha) + haze.colour[i]; + }) as unknown as Vec3; +} + +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +export function chooseSky(opts: { + sky?: SkyName; + sun?: { elevation: number; azimuth?: number }; +}): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); +} diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts new file mode 100644 index 00000000..c1231bd8 --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -0,0 +1,241 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SKY, skyAtSun } from './terrainSky'; +import { + BRIGHTEN, + createTerrainStars, + elevationOf, + ELEVATION_TOP, + FRAME_MS, + horizonY, + PERIOD, + nightColours, + project, + starGain, +} from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; +import { isNight, makeStars } from '../ridge/stars'; + +/** A 2D context that only counts: arcs painted and clears since the last reset. */ +function fakeCtx() { + const c = { + arcs: [] as Array<[number, number, number]>, + clears: 0, + alpha: 1, + alphas: [] as number[], + }; + const ctx: any = { + clearRect: () => { + c.clears += 1; + c.arcs = []; + c.alphas = []; + }, + beginPath() {}, + fill() { + c.alphas.push(ctx.globalAlpha); + }, + arc: (x: number, y: number, r: number) => c.arcs.push([x, y, r]), + globalAlpha: 1, + fillStyle: '', + }; + return { c, ctx }; +} + +let overlay: any; +let ctx2d: ReturnType; +let parent: any; +beforeEach(() => { + ctx2d = fakeCtx(); + overlay = { + style: {}, + width: 0, + height: 0, + setAttribute: vi.fn(), + getContext: vi.fn(() => ctx2d.ctx), + remove: vi.fn(), + }; + parent = { appendChild: vi.fn() }; + vi.stubGlobal('document', { createElement: () => overlay }); + vi.stubGlobal('window', { devicePixelRatio: 1, innerWidth: 390, innerHeight: 844 }); +}); +afterEach(() => vi.unstubAllGlobals()); + +const glCanvas = (w = 390, h = 844) => + ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); + +describe('which skies get stars', () => { + it('the field is placed against the baked night gradient, which the ridge module calls dark', () => { + expect(isNight(nightColours())).toBe(true); + }); + + it('starGain: full below -12 degrees, none above -6, smooth and monotone between', () => { + expect(starGain(-44.6)).toBe(1); + expect(starGain(-12)).toBe(1); + expect(starGain(-6)).toBe(0); + expect(starGain(45.2)).toBe(0); + expect(starGain(-9)).toBeCloseTo(0.5, 9); + let prev = 1; + for (let e = -12; e <= -6; e += 0.25) { + expect(starGain(e)).toBeLessThanOrEqual(prev); + prev = starGain(e); + } + }); + + it('noonday gets no layer and no element', () => { + expect(createTerrainStars(glCanvas(), SKY.noonday)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); + + it('twilight is a fade, not a gate: a sun at -9 degrees paints the stars at half alpha', () => { + createTerrainStars(glCanvas(), skyAtSun(-9))?.draw(0); + const half = Math.max(...ctx2d.c.alphas); + ctx2d.c.alphas = []; + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + expect(half).toBeGreaterThan(0); + expect(half / Math.max(...ctx2d.c.alphas)).toBeCloseTo(0.5, 6); + expect(createTerrainStars(glCanvas(), skyAtSun(-6.5))).not.toBeNull(); + expect(createTerrainStars(glCanvas(), skyAtSun(-6))).toBeNull(); + }); + + it('midnight adds one overlay to the canvas parent; dispose removes it', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + expect(parent.appendChild).toHaveBeenCalledWith(overlay); + s.dispose(); + expect(overlay.remove).toHaveBeenCalledOnce(); + }); + + it('is absent without a parent or a 2D context', () => { + expect( + createTerrainStars({ ...glCanvas(), parentElement: null } as any, SKY.midnight) + ).toBeNull(); + overlay.getContext = () => null; + expect(createTerrainStars(glCanvas(), SKY.midnight)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); +}); + +describe('sky only', () => { + it('the overlay is as tall as the horizon line is low, never the whole screen', () => { + createTerrainStars(glCanvas(), SKY.midnight); + expect(overlay.height).toBe(Math.ceil(horizonY(844))); + expect(overlay.height).toBeLessThan(844 / 2); + expect(overlay.style.pointerEvents).toBe('none'); + }); + + it('a star at the horizon lands on the horizon line, one above it lands above', () => { + for (const aspect of [0.46, 1.78]) { + const h = 800; + const w = h * aspect; + const onLine = project(0.2, 0, w, h)!; + expect(onLine.y).toBeCloseTo(horizonY(h), 5); + expect(project(0.2, 0.05, w, h)!.y).toBeLessThan(horizonY(h)); + } + }); + + it('the camera eye clears the peaks, so the horizon line bounds the terrain', () => { + expect(CAMERA.eye).toBeGreaterThan(256); + }); + + it('every painted star is above the horizon line, at any heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + for (let k = 0; k < 40; k += 1) { + s.draw(k / 40); + for (const [, y] of ctx2d.c.arcs) expect(y).toBeLessThanOrEqual(overlay.height); + } + }); + + it('agrees with the camera the terrain draws: the projection matches view()', () => { + const w = 390; + const h = 844; + const turns = 0.137; + const v = view(w / h, turns); + const yaw = 2 * Math.PI * turns; + const az = yaw + 0.1; + const e = 0.15; + const d = [Math.sin(az) * Math.cos(e), Math.sin(e), -Math.cos(az) * Math.cos(e)]; + const dot = (a: number[], b: readonly number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const t = Math.tan(CAMERA.fov / 2); + const f = dot(d, v.forward); + const nx = dot(d, v.right) / f / (t * (w / h)); + const ny = dot(d, v.up) / f / t; + const at = project(0.1, e, w, h)!; + expect(at.x).toBeCloseTo(((nx + 1) / 2) * w, 6); + expect(at.y).toBeCloseTo(((1 - ny) / 2) * h, 6); + }); +}); + +describe('turning with the camera', () => { + it('stars are on screen, and slide sideways as the camera turns', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0); + const first = ctx2d.c.arcs.slice(); + expect(first.length).toBeGreaterThan(10); + s.draw(0.002); + const later = ctx2d.c.arcs; + expect(later).not.toEqual(first); + const m = (a: number[][]) => a.reduce((t, p) => t + p[0], 0) / a.length; + expect(m(later)).not.toBeCloseTo(m(first), 1); + }); + + it('repeats every PERIOD of heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.1); + const a = ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3))); + s.draw(0.1 + PERIOD / (2 * Math.PI)); + expect(ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3)))).toEqual(a); + }); + + it('the field fills the screen top and thins toward the horizon, inside the elevation band', () => { + expect(elevationOf(0)).toBeCloseTo(ELEVATION_TOP, 9); + expect(elevationOf(0.78)).toBeCloseTo(0, 9); + }); +}); + +describe('brightness', () => { + it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + const want = makeStars(nightColours(), 390, 844).map((st) => Math.min(1, st.alpha * BRIGHTEN)); + ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); + // and it is brighter than the ridge field's own top alpha + const top = Math.max(...makeStars(nightColours(), 390, 844).map((st) => st.alpha)); + expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); + }); +}); + +describe('motion', () => { + it('with no clock every star is at base and a repeat draw repaints; no twinkle', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const alphas = ctx2d.c.alphas.slice(); + s.draw(0.3); + expect(ctx2d.c.alphas).toEqual(alphas); + }); + + it('with the clock the twinklers swing away from base, and the repaint is throttled', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const still = ctx2d.c.alphas.slice(); + let moved = false; + for (let t = 1; t < 60 && !moved; t += 1.7) { + s.draw(0.3, t); + moved = ctx2d.c.alphas.some((a, i) => a !== still[i]); + } + expect(moved).toBe(true); + const clears = ctx2d.c.clears; + s.draw(0.3, 100); + s.draw(0.3, 100 + FRAME_MS / 4000); + expect(ctx2d.c.clears).toBe(clears + 1); + }); + + it('resize refits the canvas and the next draw repaints at once', () => { + const c = glCanvas(); + const s = createTerrainStars(c, SKY.midnight)!; + s.draw(0.1, 5); + c.clientHeight = 400; + s.resize(); + expect(overlay.height).toBe(Math.ceil(horizonY(400))); + const clears = ctx2d.c.clears; + s.draw(0.1, 5.001); + expect(ctx2d.c.clears).toBe(clears + 1); + }); +}); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts new file mode 100644 index 00000000..a7275062 --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -0,0 +1,173 @@ +// The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the +// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, faded by the sun's elevation (starGain). +// +// The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the +// horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak +// (TERRAIN.hmax), so terrain never rises above that line and a star above the horizon is never over terrain. +// +// Stars turn with the camera, as sky does: the ridge field's x becomes azimuth, repeated every PERIOD (so 400 +// stars fill a sky that only ever shows 55 deg to ~120 deg of it), and y becomes elevation from the horizon +// up to ELEVATION_TOP, the top of the screen. Placement is projected with the terrain's own camera (CAMERA, +// the yaw the terrain draws at), nothing is baked, and the whole overlay is redrawn at most every FRAME_MS: a +// visible star is a few arcs on a canvas a quarter of the screen tall. The stars stop when the terrain stops: +// the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) +// paints every star at base brightness. + +import { baked } from '../../../../sky/baked'; +import type { SkyColours, Srgb } from '../ridge/skyColour'; +import { activeAt, makeStars, SKY_STOPS, Star } from '../ridge/stars'; +import { SkyData } from './terrainSky'; +import { CAMERA } from './terrainWorld'; + +/** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ +export const PERIOD = (2 * Math.PI) / 3; +/** Elevation of the field's top, radians: a little above the top edge of the screen at the camera's pitch. */ +export const ELEVATION_TOP = (16 * Math.PI) / 180; +/** The overlay repaints at most this often while the terrain turns (12 px/s on a phone: under half a pixel a step). */ +export const FRAME_MS = 33; +/** The twinkle schedule is re-read this often; the swings are 2 to 7 s. */ +const TWINKLE_MS = 50; + +const hex = (h: string): Srgb => + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; + +/** + * How much of the starfield shows with the sun at `elevation` degrees: all of it below -12 (where the baked + * scattering goes dark: the planet's shadow covers every sunlit air path), none above -6 (civil twilight), a smooth + * fade between. Not a dark/day gate: a sun at -9 shows the stars at half. + */ +export function starGain(elevation: number): number { + const t = Math.min(1, Math.max(0, (elevation + 12) / 6)); + return 1 - t * t * (3 - 2 * t); +} + +/** The ridge star module's view of the night sky it places stars against: the baked midnight gradient's zenith, middle and horizon stops. */ +export function nightColours(): SkyColours { + const g = baked.midnight.gradient; + return { + top: hex(g[0]), + mid: hex(g[Math.floor(g.length / 2)]), + horizon: hex(g[g.length - 1]), + ground: [0, 0, 0], + }; +} + +/** Screen height in px below which there is no sky: where the horizon plane meets the image, a straight line. */ +export const horizonY = (height: number): number => + height * (0.5 - Math.tan(CAMERA.pitch) / Math.tan(CAMERA.fov / 2) / 2); + +export interface Placed { + x: number; + y: number; +} + +/** + * Where a star at azimuth `da` from the camera's heading and elevation `e` (radians) lands in a `w` x `h` px + * viewport, or null if it is behind or off the screen. The same camera as `view()` in terrainWorld.ts + * (forward, right and up are its basis written out for a camera with yaw and pitch only). + */ +export function project(da: number, e: number, w: number, h: number): Placed | null { + const p = CAMERA.pitch; + const ce = Math.cos(e); + const front = ce * Math.cos(p) * Math.cos(da) - Math.sin(e) * Math.sin(p); + if (front <= 0.01) return null; + const tanHalf = Math.tan(CAMERA.fov / 2); + const nx = (ce * Math.sin(da)) / front / (tanHalf * (w / h)); + const ny = (ce * Math.sin(p) * Math.cos(da) + Math.sin(e) * Math.cos(p)) / front / tanHalf; + if (Math.abs(nx) > 1.05) return null; + return { x: ((nx + 1) / 2) * w, y: ((1 - ny) / 2) * h }; +} + +/** Elevation in radians for a ridge-field height fraction y (0 zenith side .. SKY_STOPS.horizon at the horizon). */ +export const elevationOf = (y: number): number => ELEVATION_TOP * (1 - y / SKY_STOPS.horizon); + +const wrap = (a: number): number => a - PERIOD * Math.round(a / PERIOD); + +/** The terrain overlay draws the ridge field's stars brighter and a little larger: a phone's small, hazy sky needs it to read. */ +export const BRIGHTEN = 2.4; +export const GROW = 1.3; + +export interface TerrainStars { + /** Paint for the camera `turns` into its turn (0..1). `seconds` is the clock while the terrain moves; absent: every star at base. */ + draw(turns: number, seconds?: number): void; + /** Re-fit the overlay to the canvas's current size; the next draw repaints. */ + resize(): void; + dispose(): void; +} + +/** The layer for `canvas`'s parent, or null: the sun is too high for any star, no parent, no 2D context. */ +export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { + const colours = nightColours(); + const gain = starGain(sky.elevation); + const parent = canvas.parentElement; + if (!parent || gain <= 0) return null; + const el = document.createElement('canvas'); + const ctx = el.getContext?.('2d'); + if (!ctx) return null; + el.setAttribute?.('aria-hidden', 'true'); + Object.assign(el.style, { position: 'absolute', left: '0', top: '0', pointerEvents: 'none' }); + parent.appendChild(el); + + let w = 1; + let h = 1; + let scale = 1; + let field: Star[] = []; + let lit: Array<{ star: Star; az: number; e: number }> = []; + let gains = new Map(); + let gainsAt = -Infinity; + let last = -Infinity; + + const fit = () => { + w = canvas.clientWidth || window.innerWidth; + h = canvas.clientHeight || window.innerHeight; + scale = Math.min(window.devicePixelRatio || 1, 2); + const sky = Math.max(1, Math.ceil(horizonY(h))); + el.width = Math.max(1, Math.round(w * scale)); + el.height = Math.max(1, Math.round(sky * scale)); + el.style.width = `${w}px`; + el.style.height = `${sky}px`; + field = makeStars(colours, w, h); + lit = field.map((star) => ({ star, az: star.x * PERIOD, e: elevationOf(star.y) })); + gains = new Map(); + gainsAt = -Infinity; + }; + + const paint = (turns: number, seconds: number | undefined) => { + ctx.clearRect(0, 0, el.width, el.height); + if (seconds !== undefined && seconds * 1000 - gainsAt >= TWINKLE_MS) { + gains = new Map(activeAt(field.length, seconds).map((a) => [a.star, a.gain])); + gainsAt = seconds * 1000; + } + const yaw = 2 * Math.PI * turns; + lit.forEach(({ star, az, e }, i) => { + const da = wrap(az - yaw); + const twinkle = seconds === undefined ? 1 : gains.get(i) ?? 1; + ctx.globalAlpha = Math.min(1, star.alpha * twinkle * BRIGHTEN) * gain; + ctx.fillStyle = star.fill; + [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { + const at = project(d, e, w, h); + if (!at || at.y < 0 || at.y > el.height / scale) return; + ctx.beginPath(); + ctx.arc(at.x * scale, at.y * scale, star.r * GROW * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + }); + ctx.globalAlpha = 1; + }; + + fit(); + return { + draw(turns, seconds) { + if (seconds !== undefined && seconds * 1000 - last < FRAME_MS) return; + last = seconds === undefined ? -Infinity : seconds * 1000; + paint(turns, seconds); + }, + resize() { + fit(); + last = -Infinity; + }, + dispose() { + el.remove(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts new file mode 100644 index 00000000..78ab2700 --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -0,0 +1,135 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { describe, expect, it } from 'vitest'; +import { + buildGrid, + CAMERA, + GRID, + seedOffset, + TERRAIN, + triangleCount, + view, + warpUv, +} from './terrainWorld'; +import { heightFragmentSource } from './terrainShaders'; + +describe('seed', () => { + it('same seed, same offset, so the baked height map is the same; a different seed moves it', () => { + expect(seedOffset(7)).toEqual(seedOffset(7)); + expect(seedOffset(0)).toEqual([0, 0]); + expect(seedOffset(1)).not.toEqual(seedOffset(2)); + }); + + it('the height-bake source is a pure function of nothing but the world constants', () => { + expect(heightFragmentSource()).toBe(heightFragmentSource()); + const src = heightFragmentSource(); + expect(src.startsWith('#version 300 es')).toBe(true); + expect(src).toContain('cultmath_phacelle'); + expect(src).toContain(`/ ${TERRAIN.hmax.toFixed(1)}`); + expect(src).toContain('uSeed'); + }); +}); + +describe('grid', () => { + it('has (n+1)^2 vertices in 0..1 and 2n^2 triangles that index them all in range', () => { + const n = 8; + const g = buildGrid(n); + expect(g.uv.length).toBe((n + 1) * (n + 1) * 2); + expect(g.indices.length / 3).toBe(triangleCount(n)); + expect(Math.max(...g.indices)).toBe((n + 1) * (n + 1) - 1); + expect(Math.min(...g.uv)).toBe(0); + expect(Math.max(...g.uv)).toBe(1); + }); + + it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { + // 205k at 320 quads: a guess at what a phone carries (SwiftShader only, no phone frame time), kept as a ceiling so a bigger grid is a deliberate change. + expect(triangleCount(GRID.low.grid)).toBeLessThan(210000); + expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); + expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); + }); +}); + +describe('camera', () => { + const project = (m: Float32Array, p: number[]) => { + const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]; + const y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]; + const z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]; + const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; + return { x: x / w, y: y / w, z: z / w, w }; + }; + + it('a point straight ahead lands in the middle of the screen, in front, inside the depth range', () => { + for (const turns of [0, 0.25, 0.6]) { + const v = view(0.46, turns); + const p = v.eye.map((e, i) => e + 800 * v.forward[i]); + const s = project(v.viewProj, p); + expect(s.w).toBeGreaterThan(0); + expect(s.x).toBeCloseTo(0, 5); + expect(s.y).toBeCloseTo(0, 5); + expect(Math.abs(s.z)).toBeLessThan(1); + } + }); + + it('looks down by the pitch, and one full turn comes back to the same heading', () => { + const a = view(1, 0); + const b = view(1, 1); + expect(a.forward[1]).toBeCloseTo(-Math.sin(CAMERA.pitch), 9); + for (let i = 0; i < 3; i += 1) expect(a.forward[i]).toBeCloseTo(b.forward[i], 9); + }); + + it('sits above the highest possible terrain, so the eye is never inside a hill', () => { + expect(CAMERA.eye).toBeGreaterThan(TERRAIN.hmax); + }); + + it('the basis is orthonormal', () => { + const v = view(0.5, 0.37); + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot(v.right, v.up)).toBeCloseTo(0, 9); + expect(dot(v.right, v.forward)).toBeCloseTo(0, 9); + expect(dot(v.up, v.forward)).toBeCloseTo(0, 9); + expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); + }); +}); + +describe('grid resolution', () => { + // The mesh must carry what the height map shows: where a quad is wider than a couple of texels the shading (read from the + // height map) has gullies the polygons cannot, and the near ground reads as hard-edged facets (the 'terrain · low' phone + // shots, 2026-11-09). Metres from the patch centre to the nearest ground the camera can see: the bottom view ray meets + // the highest terrain, hmax below the eye, at (eye - hmax) / tan(pitch + fov / 2). + const nearest = (CAMERA.eye - TERRAIN.hmax) / Math.tan(CAMERA.pitch + CAMERA.fov / 2); + const spacingAt = (grid: number, metres: number) => { + for (let i = 0; i < grid; i += 1) { + const x = (warpUv((i + 1) / grid) - 0.5) * TERRAIN.span; + if (x >= metres) return (warpUv((i + 1) / grid) - warpUv(i / grid)) * TERRAIN.span; + } + throw new Error('beyond the patch'); + }; + + it.each(['still', 'low', 'full'] as const)( + 'the %s tier has a quad no wider than two height texels at the nearest ground the camera can see', + (tier) => { + const { grid, tex } = GRID[tier]; + expect(spacingAt(grid, nearest)).toBeLessThanOrEqual((2 * TERRAIN.span) / (tex - 1)); + } + ); + + it('a dearer tier never has a coarser mesh', () => { + expect(GRID.low.grid).toBeGreaterThanOrEqual(GRID.still.grid); + expect(GRID.full.grid).toBeGreaterThanOrEqual(GRID.low.grid); + }); +}); + +describe('warpUv', () => { + it('maps the patch onto itself, monotonic, centre fixed', () => { + expect(warpUv(0)).toBe(0); + expect(warpUv(1)).toBe(1); + expect(warpUv(0.5)).toBe(0.5); + for (let i = 0; i < 100; i += 1) expect(warpUv((i + 1) / 100)).toBeGreaterThan(warpUv(i / 100)); + }); + + it('packs the vertices toward the camera at the centre: finer there, coarser at the rim, than a plain grid', () => { + const plain = 1 / 192; + const step = (u: number) => warpUv(u + 1 / 192) - warpUv(u); + expect(step(0.5)).toBeLessThan(0.6 * plain); + expect(step(0.99)).toBeGreaterThan(1.3 * plain); + }); +}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts new file mode 100644 index 00000000..7296025b --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -0,0 +1,140 @@ +// The terrain scene's fixed numbers, grid and camera, as pure functions. No DOM, no GL. +// The height field itself is a function of world position written in GLSL (terrainShaders.ts), baked once +// into a texture; the CPU owns only where it is looked at from, and the seed offset it is sampled at. + +/** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ +export const TERRAIN = { span: 3000, hmax: 256 }; + +/** + * Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. The + * mesh is dense enough that a quad is at most two texels wide at the nearest ground (terrainWorld.test.ts); a coarser mesh + * under shading read from the height map shows as hard-edged facets and stepped crests. + */ +export const GRID = { + still: { grid: 192, tex: 512 }, + low: { grid: 320, tex: 1024 }, + full: { grid: 384, tex: 1024 }, +} as const; + +/** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ +export const CAMERA = { + eye: 330, + pitch: (13 * Math.PI) / 180, + fov: (55 * Math.PI) / 180, + near: 10, + far: 6000, + /** Seconds for one full turn. */ + seconds: 420, +}; + +/** The height map's sample offset in metres for a seed: same seed, same terrain. Seed 0 is the origin. */ +export function seedOffset(seed: number): [number, number] { + const s = Math.trunc(seed); + return [(s * 7919) % 65536, (s * 104729) % 65536]; +} + +/** + * Where grid coordinate `u` (0..1 across the patch) lands in the patch, 0..1. The camera stands at the centre, so the + * grid is packed toward it: with s = 2u - 1 the spacing grows as 0.5 + |s| of the plain grid's, about half of it at the + * centre and half again as much at the rim. The vertex shader does the same arithmetic (terrainShaders.ts GRID_VERT). + */ +export const warpUv = (u: number): number => { + const s = 2 * u - 1; + return 0.5 + 0.5 * (0.5 * s + 0.5 * s * Math.abs(s)); +}; + +export const triangleCount = (grid: number): number => 2 * grid * grid; + +/** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ +export function buildGrid(grid: number): { uv: Float32Array; indices: Uint32Array } { + const uv = new Float32Array((grid + 1) * (grid + 1) * 2); + for (let j = 0, k = 0; j <= grid; j += 1) + for (let i = 0; i <= grid; i += 1, k += 2) { + uv[k] = i / grid; + uv[k + 1] = j / grid; + } + const indices = new Uint32Array(grid * grid * 6); + for (let j = 0, k = 0; j < grid; j += 1) + for (let i = 0; i < grid; i += 1, k += 6) { + const a = j * (grid + 1) + i; + indices.set([a, a + grid + 1, a + 1, a + 1, a + grid + 1, a + grid + 2], k); + } + return { uv, indices }; +} + +export interface View { + /** Column-major perspective * view. */ + viewProj: Float32Array; + eye: [number, number, number]; + /** Camera basis in world space, for the sky pass. */ + right: [number, number, number]; + up: [number, number, number]; + forward: [number, number, number]; +} + +const norm = (a: number[]): [number, number, number] => { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; +}; +const cross = (a: number[], b: number[]): [number, number, number] => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], +]; + +/** The camera `turns` full turns round (0..1 wraps), for a viewport aspect (width / height). */ +export function view(aspect: number, turns: number): View { + const yaw = 2 * Math.PI * turns; + const cp = Math.cos(CAMERA.pitch); + const forward = norm([Math.sin(yaw) * cp, -Math.sin(CAMERA.pitch), -Math.cos(yaw) * cp]); + const right = norm(cross(forward, [0, 1, 0])); + const up = cross(right, forward); + const eye: [number, number, number] = [0, CAMERA.eye, 0]; + const t = (a: number[]) => -(a[0] * eye[0] + a[1] * eye[1] + a[2] * eye[2]); + const v = [ + right[0], + up[0], + -forward[0], + 0, + right[1], + up[1], + -forward[1], + 0, + right[2], + up[2], + -forward[2], + 0, + t(right), + t(up), + -t(forward), + 1, + ]; + const f = 1 / Math.tan(CAMERA.fov / 2); + const { near: n, far } = CAMERA; + const p = [ + f / aspect, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + (far + n) / (n - far), + -1, + 0, + 0, + (2 * far * n) / (n - far), + 0, + ]; + const viewProj = new Float32Array(16); + for (let c = 0; c < 4; c += 1) + for (let r = 0; r < 4; r += 1) { + let s = 0; + for (let k = 0; k < 4; k += 1) s += p[k * 4 + r] * v[c * 4 + k]; + viewProj[c * 4 + r] = s; + } + return { viewProj, eye, right, up, forward }; +} diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts new file mode 100644 index 00000000..3e9f3a48 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { showTierLabel } from './tierLabel'; + +let search: string; +let appended: any[]; +const canvas = (parent: boolean) => + ({ + parentElement: parent + ? { + appendChild: (e: any) => appended.push(e), + } + : null, + } as any); + +beforeEach(() => { + search = ''; + appended = []; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); + vi.stubGlobal('document', { + createElement: () => ({ style: {}, remove: vi.fn(), textContent: '' }), + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('showTierLabel', () => { + it('adds nothing in a normal build', () => { + expect(showTierLabel(canvas(true), 'full')).toBeNull(); + expect(appended).toHaveLength(0); + }); + + it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { + search = '?tierlabel=1'; + const remove = showTierLabel(canvas(true), 'sky-only'); + expect(appended).toHaveLength(1); + expect(appended[0].textContent).toBe('terrain · sky-only'); + remove!(); + expect(appended[0].remove).toHaveBeenCalled(); + }); + + it('has nowhere to go without a parent', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(false), 'full')).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts new file mode 100644 index 00000000..0b2c79ec --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -0,0 +1,26 @@ +// A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), +// so a screenshot of a preview says what it shows. Built into preview builds only (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`. +const wanted = (): boolean => + import.meta.env?.VITE_PREVIEW_DEMO === '1' || + new URLSearchParams(window.location.search).get('tierlabel') === '1'; + +/** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ +export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => void) | null { + const parent = canvas.parentElement; + if (!parent || !wanted()) return null; + const el = document.createElement('div'); + el.textContent = `terrain · ${text}`; + Object.assign(el.style, { + position: 'absolute', + left: '2px', + top: '16px', + font: '10px monospace', + color: 'rgba(255,255,255,.75)', + background: 'rgba(0,0,0,.4)', + padding: '1px 5px', + borderRadius: '3px', + pointerEvents: 'none', + }); + parent.appendChild(el); + return () => el.remove(); +} diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts index e2c82325..4a976081 100644 --- a/src/app/features/scene/tier.ts +++ b/src/app/features/scene/tier.ts @@ -15,8 +15,11 @@ export interface Capabilities { hardwareConcurrency?: number; } -/** 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and clouds. */ -export type SceneStyle = 'ridge' | 'raymarch'; +/** + * 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and + * clouds. 'terrain': a baked height map on a displaced grid, lit and hazed by the baked sky (WebGL2). + */ +export type SceneStyle = 'ridge' | 'raymarch' | 'terrain'; export interface BackgroundSceneConfig { tier?: SceneTier; diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index a9733ef0..4fa42d74 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -53,10 +53,17 @@ export const AuthFooter = style({ }); // While the background scene runs (html[data-scene], set by features/scene/BackgroundScene), the -// signed-out ground is clear and the card is a translucent, blurred pane shaped like a message -// bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The -// --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. 85% fill keeps text >= 4.5:1 over the scene's brightest and darkest backdrops. +// signed-out ground is clear and the card is a pane shaped like a message bubble (BubbleContent: +// radius R500, no border), filled with the sky's bubble colour. The scene is one fixed dusk for +// every theme, so the card is opaque: its text and link contrast cannot depend on what the scene +// shows behind it, nor on a blur. The --sky-bubble variables come from the sky themes (Midnight and +// Noonday); the other themes (Light, Silver, Dark, Butter) take the fallback colours. +// WCAG ratios of text / Register link on the card, per theme: +// Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 +// Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 +// Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 +// The fallback link is set here because those themes' own links (Light 2.49:1, Silver 2.49:1) are +// made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -64,14 +71,14 @@ globalStyle(`${scene} ${AuthLayout}`, { backgroundImage: 'none', }); globalStyle(`${scene} ${AuthCard}`, { - backgroundColor: [ - 'rgba(13, 26, 38, 0.85)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 85%, transparent)', - ], + backgroundColor: 'var(--sky-bubble, #0d1a26)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, - borderColor: 'transparent', - backdropFilter: 'blur(12px)', + // A faint inner edge, so a pane this clear still reads as a surface. + borderColor: 'rgba(183, 199, 217, 0.14)', +}); +globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { + vars: { '--tc-link': '#99c7ff' }, }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', diff --git a/src/sky/README.md b/src/sky/README.md new file mode 100644 index 00000000..1f816a15 --- /dev/null +++ b/src/sky/README.md @@ -0,0 +1,105 @@ +# Baked sky + +`npm run bake:sky` (`scripts/bake-sky.mjs`) lights two fixed moments with `sun.js`, the single source of +the sun and the air, and writes `baked.ts`. `baked.midnight`, `baked.noonday` and `baked.scatter` are its +three keys. Everything here is linear RGB unless a name says `hex`. + +## Per sky: `baked.midnight`, `baked.noonday` + +| field | meaning | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `moment` | `{ time, lat, lon }` fed to `sun.js` (equinox, 45N, prime meridian; 00:00 and 12:00 UTC) | +| `elevation` | sun elevation in degrees at that moment: -44.6 midnight, +45.2 noonday | +| `sun.elevation_deg` | the same, for scene code; fade stars by this (and see below for twilight) | +| `sun.azimuth_deg` | degrees clockwise from north, 0..360 | +| `sun.color_linear` | direct sun colour at normal incidence, clear zenith ~ 1; multiply by `max(0, dot(n, toSun))` | +| `ambient_linear` | sky ambient on an upward surface, with a night floor of `AMBIENT_FLOOR` (a moonlit skyglow, not black); daylight is `sun.js`'s own `diffuse` | +| `horizon_hex` | colour of the last gradient stop, the horizon | +| `gradient` | 7 sRGB hex stops, zenith to horizon | +| `tokens`, `bubbles`, ... | theme colours (see `skyTokens.js`) | + +The two skies are single moments. To fade stars through twilight, take the elevation from `sun.js` at the +viewer's real time (`sunPosition(time, lat, lon).elevation`) rather than from `baked..sun`, which only +ever says -44.6 or +45.2. The scattering table below goes dark at -12 degrees and below (the planet's shadow +covers every sunlit air path), so a star fade between about -6 and -12 degrees follows it. + +## `baked.scatter`: atmospheric scattering for a WebGL2 shader + +A Hillaire-style (2020) single-scattering atmosphere, reduced to two small tables. The air is `sun.js`'s: +`TAU` (its zenith optical depth per channel) is the vertical optical depth here, split into a grey aerosol +part (0.02) and a Rayleigh part, so the transmittance table equals `exp(-TAU)` at the zenith. Planet radius +6360 km, atmosphere 100 km, scale heights 8 km (Rayleigh) and 1.2 km (Mie, g = 0.8, albedo 0.9), observer +100 m above the ground. Single scattering only: no ground bounce, no multiple scattering, no sun disc +(the consumer draws it from `sun.color_linear`). That makes dusk a little darker than the real thing. + +Constants: `planet_radius_km`, `atmosphere_height_km`, `observer_altitude_km`, `extinction_per_km` (RGB at the +observer, for aerial perspective: `exp(-k * distance_km)`). + +### Encoding (both tables) + +IEEE 754 half floats (16 bit), little-endian, base64 of the raw bytes, no header. The stored value is +`value * store_scale` (`store_scale` = 64; it lifts the dark end of twilight clear of the half-float +subnormals). Exact decode: for a 16-bit word `h`, `e = (h >> 10) & 31`, `m = h & 1023`, +`half = e == 0 ? m * 2^-24 : (1 + m / 1024) * 2^(e - 15)` (no infinities or NaNs are stored), and +`value = half / store_scale`. In WebGL2 upload as `RGB16F` / `HALF_FLOAT` from a `Uint16Array` over the +decoded bytes, and the GPU does the decode: sample, then divide by `store_scale`. Precision is 11 bits +(about 0.05%): the horizon glow does not step. + +### `transmittance`: `width` 32, `height` 1, 3 channels + +Transmittance from the observer to space along a view elevation, per channel. Texel `i` is the elevation +`-4 + 94 * (i / 31)^2` degrees (`viewElevation(i)` in `scatter.js`; the inverse is `viewIndex(deg)`): +finest at the horizon. Elevations that look at the ground (-4 to about -0.3) are 0. Sample it for the sun's +direct colour: at the sun's elevation, `sun.color_linear`-like attenuation, redder as it sets. + +### `sky_view`: `width` 7, `height` 32, `depth` 12, 3 channels + +Sky radiance per steradian for a sun of unit irradiance (multiply by your own sun colour and exposure). +One 3D texture, `x` fastest: + +- `x`: azimuth of the view away from the sun, 0, 30, ... 180 degrees (`azimuth_step_deg`). The sky is + symmetric about the sun's vertical plane, so use `abs` of the relative azimuth. +- `y`: view elevation, same axis as the transmittance table (`viewElevation(i)`). +- `z`: sun elevation, `sun_elevations_deg` = -18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90. Between them + interpolate linearly; `sunIndex(deg)` in `scatter.js` gives the fractional index. + +Element `((z * 32 + y) * 7 + x) * 3 + c` of the decoded array. Texel centres, for linear filtering: + +```glsl +uniform highp sampler3D uSky; // RGB16F, 7 x 32 x 12, LINEAR, CLAMP_TO_EDGE +uniform float uSunIndex; // sunIndex(sunElevationDeg), 0..11, from the CPU +vec3 skyRadiance(float viewElDeg, float azFromSunDeg) { + float y = sqrt(clamp((viewElDeg + 4.0) / 94.0, 0.0, 1.0)) * 31.0; + float x = clamp(abs(azFromSunDeg) / 180.0, 0.0, 1.0) * 6.0; + return texture(uSky, vec3((x + 0.5) / 7.0, (y + 0.5) / 32.0, (uSunIndex + 0.5) / 12.0)).rgb / 64.0; +} +``` + +Row width is 21 bytes, so set `gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)` before `texImage3D`. + +Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmittance`; about +twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the +layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun, +black below the planet's shadow limit). + +### `aerial_perspective`: the camera volume for ground seen through the air + +After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020, section 5.4, and its +reference code (sebh/UnrealEngineSkyAtmosphere, `RenderCameraVolumePS` in `RenderSkyRayMarching.hlsl`). RGBA half floats +(same encoding, `store_scale` 64), `width` 7, `height` 96, `depth` 12, 4 channels; one 3D texture, `x` fastest: + +- `x`: azimuth of the view ray from the light, 0, 30, ... 180 degrees (as `sky_view`). +- `y`: `slice * 8 + elevation`: 12 slices, each a block of 8 view elevations, `-50 * (1 - i / 7)^2` degrees (finest at 0, + rays at or above the horizon are clamped to 0). Keep the filter inside a block: sample at `(slice * 8 + e + 0.5) / 96`, `e` in 0..7. +- `z`: light elevation, `sun_elevations_deg`, as `sky_view`. The same volume serves the moon: look up at the moon's elevation. +- Slice `k` is the far side of `max_km * ((k + 1) / 12)^2` km (the paper's squared distribution; `max_km` 20). Between slices interpolate + linearly; below slice 0 fade to zero at the observer, as the reference does for `Slice < 0.5`. +- RGB: in-scatter luminance per steradian for a light of unit irradiance. A: opacity `1 - mean(rgb transmittance)`, the paper's grey + transmittance. Apply as `surface * (1 - A) + L * exposure`. + +Follows the paper: the squared slice distribution, the in-scatter integral with each step's extinction integrated analytically +(`S (1 - exp(-e dt)) / e`), single scattering with Rayleigh and Mie (g 0.8) phase, and the grey opacity in alpha. +Shortcuts: sun-relative azimuth and elevation instead of screen position; 12 x 8 x 7 texels, not 32 x 32 x 32; path lengths are the +scene's metres times `distance_scale` (8), because a real atmosphere is nearly clear across a 3 km patch; the ray ignores the ground +(density held at its ground value, sun visibility tested from the ground sphere); no multi-scattering LUT (the paper's dusk fix); +observer at 100 m. Units: distances in km, radiance per unit sun irradiance, as `sky_view`. 86 KB as base64. diff --git a/src/sky/baked.ts b/src/sky/baked.ts index 15e72c8f..0393fdbf 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -8,6 +8,13 @@ export const baked = { lon: 0, }, elevation: -44.579021600011224, + sun: { + elevation_deg: -44.579021600011224, + azimuth_deg: 2.4988773054640205, + color_linear: [0, 0, 0], + }, + ambient_linear: [0.018, 0.024, 0.04], + horizon_hex: '#484e64', gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], tokens: { Background: { @@ -117,6 +124,13 @@ export const baked = { lon: 0, }, elevation: 45.16994813502928, + sun: { + elevation_deg: 45.16994813502928, + azimuth_deg: 182.58737070473103, + color_linear: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], + }, + ambient_linear: [0.07, 0.132, 0.27], + horizon_hex: '#e2f4ff', gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], tokens: { Background: { @@ -219,4 +233,44 @@ export const baked = { ], link: '#2a4f27', }, + scatter: { + planet_radius_km: 6360, + atmosphere_height_km: 100, + observer_altitude_km: 0.1, + extinction_per_km: [0.020271980981493083, 0.02767884208799963, 0.044961518003181586], + format: 'half-float', + store_scale: 64, + view_elevation_deg: { + min: -4, + max: 90, + samples: 32, + }, + transmittance: { + width: 32, + height: 1, + channels: 3, + data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArkjBP0wqR02AR145qk9vS4BBtlCITWBGW1EcT3dJ1FE1UN5LL1K9UBdNdVIrUSdOq1KFURlP1lLOUe5P+VILUlRQFlM+UqVQLVNoUupQQFOLUidRUFOpUltRXlPCUohRaVPYUq5Rc1PqUs9Re1P4UupRgVMFUwFShlMPUxRSilMWUyJSjVMcUyxSj1MfUzNSj1MgUzVS', + }, + sky_view: { + sun_elevations_deg: [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90], + azimuth_step_deg: 30, + width: 7, + height: 32, + depth: 12, + channels: 3, + data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyhRWDusADhBFCV8AUAFcAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRKEGYE5g40C8wBGQGDAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQ++DQsGtQrbCHkCiABRAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVgq5CZkEmAXWBNoBMgAbAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwX3BIkCOAIMAvUACwAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AH2ARkB1wC/AFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwACvAFcASgA1ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQAvAA4AFAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAHAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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LithKgMmxya1JQYhpSDLHh0ZEBhtFJAMugRDARIADAABAAAA2CqjKp8nYykdKcQlUyXYJCAhQyDUHksa9xmgF0MRkhDfC+ACCAkeA0QAKik1KcAmGigRKC8lPSQOJN8glx+KHvgaRBsxGWsUuhVaEnILVBL6DUoF8CcbKMwlWyZ/JoMk2yLJIncgxR4dHj8b5RstGh4WkhjzFZkQoRbcEzsNNCaKJukkAiU8Jb4joiG2IQUgDx6lHUAbFBzDGoQX2xlCGO0T5RisFqUR6yRDJSIkAiRDJJsitCDZIC0fcB0wHRcbFBwMG0YYsRpEGZYVExqLGIQU5iNHJPQigiIEI6Eh9R8nIGQe5hzEHNYa5xsdG54YHRv1GfkWyBp3GRkWayIGI9shWyHUIc0g0R4sH7EdbxxiHIkaixsHG9EYVBtfGgYYJhsVGmgXRiHQIfEgdSDiIBwg5B1AHhQdChwLHDkaJxvaGukYUBuRGmcYURtxGjIYYCDYIC4ggR8gIAwfKB19HYwcZxt9G+oZxhqeGuwYHBuZGqQYQxuYGokYUx8QIBgfXx4DHxEejhzcHBcc0xr4Gp8ZahpcGuAY0BqBGsMYBhuVGrsYLh7bHggedR0AHj0dDxxWHGMbVhqFGloZExoYGssYehpUGsoYtBp0Gs8YQR3QHSYdtxwsHYscTBvNG7Ya7BkhGhwZwxnVGa8YIxoZGr8YWBo/Gs0YgBz2HGkcGBx8HOwbnRoSGyEakxnNGeQYeRmWGZEYzRnXGagY/Bn8GbkYwxtEHJkbMxvZG/QaDRp4GqQZSBmFGbQYNxlcGXMYfBmTGYkYohm0GZoYxxpmG5gaZBrvGicalxn5GToZChlJGYoY/RgpGVcYMBlSGWcYThlrGXUY/xmAGsoZvhkzGoAZOBmSGeQY2RgZGWgYzBj9GD8Y7hgXGUYYAhkoGU8YYxnOGSkZPhmhGf0Y7hhBGZ4YshjzGE0YphjcGCwYtxjmGCsYwxjvGC8Y8RhLGbAY3xg1GZoYuBgEGWgYlxjYGDoYixjFGCAYkBjFGBoYlBjHGBoYphjzGFwYoRjrGFQYkxjaGEEYhhjIGC0Yfhi8GB8YfBi3GBcYfBi2GBUYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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zjJ/Nyg7YELVQnVDVjrRPI4/IDYLOog9WDSaOFo8ETOsN1U7fTIgN9g6YzIJN8Q6YUD1QNtB3jl+PCA/7TXQOVY9PzR8OEM86DJ9NzM7RDLfNqM6HzK8NoQ6YD22PndALDkSPJ4+uTWYOSc9LjRmODI82DJnNyU7KzLANow6/jGVNmQ6yzrKPC0/Zjg+OxU+gjVgOfg8JTRXOCY83jJmNyk7MTLANpE6ADKQNmU6rjhsOxo+YTdoOpM9RDUnOco8ITRNOB08+zJ6Nz47VTLeNq86IzKuNoM6BzclOmM9OjazOSA9/zTtOJs8ITRHOBg8LjOiN2M7mDIYN+c6aTLsNr86pzVQOds8VzUeObo8tjSzOGw8JDREOBU8dzPcN5Y7/TJxNzg71jJNNxg7xDS4OG48qzSmOGE8bTR5OD88JjRDOBM81zMUONg7izPpN6I7cjPTN487KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8SSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4waynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIow3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUwzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQz0y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY1ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w99TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/AjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJA9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJBGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtC', + }, + aerial_perspective: { + slices: 12, + elevations: 8, + elevation_min_deg: -50, + max_km: 20, + distance_scale: 8, + width: 7, + height: 96, + depth: 12, + channels: 4, + data: 'AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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', + }, + }, } as const; diff --git a/src/sky/scatter.js b/src/sky/scatter.js new file mode 100644 index 00000000..bd12b7e0 --- /dev/null +++ b/src/sky/scatter.js @@ -0,0 +1,362 @@ +// Atmospheric scattering tables, baked for a WebGL2 shader (layout: src/sky/README.md). +// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. +// +// bakeScatter() -> { transmittance, sky_view, ... } with base64 RGB16F tables (half floats). +// +// A Hillaire-style single-scattering model (Rayleigh + Mie, spherical planet, observer 100 m up), +// reduced to two small tables: the transmittance of the whole atmosphere along a view direction, and +// the sky's radiance by sun elevation, view elevation and azimuth from the sun. sun.js stays the +// single source of the air: its zenith optical depths (TAU, per channel) are the vertical optical +// depth of this atmosphere, split into a grey aerosol part and a Rayleigh part. So the direct sun +// sun.js returns and the transmittance here agree at the zenith; only the air-mass law differs +// (Kasten-Young there, the spherical integral here). + +import { TAU } from './sun.js'; + +const RAD = Math.PI / 180; +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); + +// Geometry (km) and scale heights. +export const PLANET_RADIUS_KM = 6360; +export const ATMOSPHERE_HEIGHT_KM = 100; +export const OBSERVER_ALTITUDE_KM = 0.1; +const H_RAYLEIGH = 8; +const H_MIE = 1.2; +const MIE_ALBEDO = 0.9; // single-scattering albedo of the aerosol (EYEBALLED) +const MIE_G = 0.8; // Henyey-Greenstein asymmetry (Hillaire 2020) +const MIE_TAU = 0.02; // grey aerosol vertical optical depth (EYEBALLED); the rest of TAU is Rayleigh + +// Vertical optical depth of an exponential layer of scale height H over the atmosphere. +const column = (H) => H * (1 - Math.exp(-ATMOSPHERE_HEIGHT_KM / H)); +const BETA_RAYLEIGH = TAU.map((tau) => (tau - MIE_TAU) / column(H_RAYLEIGH)); // per km at sea level +const BETA_MIE_EXT = MIE_TAU / column(H_MIE); +const BETA_MIE_SCA = BETA_MIE_EXT * MIE_ALBEDO; + +// Table axes. +export const SUN_ELEVATIONS_DEG = [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90]; +export const VIEW_SAMPLES = 32; +export const AZIMUTH_SAMPLES = 7; // 0, 30, ... 180 degrees from the sun +export const AZIMUTH_STEP_DEG = 30; +export const VIEW_MIN_DEG = -4; +export const VIEW_MAX_DEG = 90; +// Tables hold IEEE 754 half floats, little-endian, holding value * STORE_SCALE. The scale lifts the +// dark end (twilight sky radiance is ~1e-4) clear of the half-float subnormals; the brightest +// stored value, a Mie peak near 0.3 x 64, is far below the format's 65504. +export const STORE_SCALE = 64; + +/** View elevation in degrees of sample index i of VIEW_SAMPLES: finest at the horizon. */ +export const viewElevation = (i) => + VIEW_MIN_DEG + (VIEW_MAX_DEG - VIEW_MIN_DEG) * (i / (VIEW_SAMPLES - 1)) ** 2; + +/** Fractional index into SUN_ELEVATIONS_DEG of a sun elevation (clamped to the table): the z texel coordinate. */ +export const sunIndex = (elevationDeg) => { + const e = clamp( + elevationDeg, + SUN_ELEVATIONS_DEG[0], + SUN_ELEVATIONS_DEG[SUN_ELEVATIONS_DEG.length - 1] + ); + const k = Math.min(SUN_ELEVATIONS_DEG.findIndex((x) => x > e) - 1, SUN_ELEVATIONS_DEG.length - 2); + const i = k < 0 ? SUN_ELEVATIONS_DEG.length - 2 : k; + return i + (e - SUN_ELEVATIONS_DEG[i]) / (SUN_ELEVATIONS_DEG[i + 1] - SUN_ELEVATIONS_DEG[i]); +}; + +/** Inverse of viewElevation: the fractional sample index of an elevation. */ +export const viewIndex = (elevationDeg) => + Math.sqrt(clamp((elevationDeg - VIEW_MIN_DEG) / (VIEW_MAX_DEG - VIEW_MIN_DEG), 0, 1)) * + (VIEW_SAMPLES - 1); + +/** Round a non-negative number to the nearest IEEE 754 half float (ties to even); returns its 16 bits. */ +export function toHalf(x) { + if (!(x > 0)) return 0; + const u = new Uint32Array(new Float32Array([x]).buffer)[0]; + const exp = ((u >> 23) & 0xff) - 127 + 15; + const mant = u & 0x7fffff; + if (exp >= 31) return 0x7bff; + let half; + let rem; + let halfway; + if (exp <= 0) { + if (exp < -10) return 0; + const m = mant | 0x800000; + const shift = 14 - exp; + half = m >> shift; + rem = m & ((1 << shift) - 1); + halfway = 1 << (shift - 1); + } else { + half = (exp << 10) | (mant >> 13); + rem = mant & 0x1fff; + halfway = 0x1000; + } + return rem > halfway || (rem === halfway && half & 1) ? half + 1 : half; +} + +/** The exact value of 16 half-float bits (finite values only). */ +export const fromHalf = (h) => { + const e = (h >> 10) & 31; + const m = h & 1023; + return e === 0 ? m * 2 ** -24 : (1 + m / 1024) * 2 ** (e - 15); +}; + +/** Stored 16 bits of a value, and the value back out of them. */ +export const encode = (v) => toHalf(v * STORE_SCALE); +export const decode = (h) => fromHalf(h) / STORE_SCALE; + +const extinction = (h) => { + const r = Math.exp(-h / H_RAYLEIGH); + const m = Math.exp(-h / H_MIE); + return BETA_RAYLEIGH.map((b) => b * r + BETA_MIE_EXT * m); +}; + +// Distance along ray (origin o, unit direction d) to the sphere of radius r centred on the origin +// of the planet: the far root, or the near root if `near`. Infinity when it misses or is behind. +function sphereHit(o, d, r, near) { + const b = o[0] * d[0] + o[1] * d[1] + o[2] * d[2]; + const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - r * r; + const disc = b * b - c; + if (disc < 0) return Infinity; + const sq = Math.sqrt(disc); + const t = near ? -b - sq : -b + sq; + return t > 0 ? t : Infinity; +} + +// Optical depth per channel from p along d to the top of the atmosphere; Infinity if the planet is in the way. +function opticalDepthToSpace(p, d, steps) { + if (sphereHit(p, d, PLANET_RADIUS_KM, true) !== Infinity) return null; + const top = sphereHit(p, d, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); + const dt = top / steps; + const depth = [0, 0, 0]; + for (let k = 0; k < steps; k += 1) { + const t = (k + 0.5) * dt; + const q = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; + const e = extinction(Math.hypot(...q) - PLANET_RADIUS_KM); + for (let c = 0; c < 3; c += 1) depth[c] += e[c] * dt; + } + return depth; +} + +const observer = [0, PLANET_RADIUS_KM + OBSERVER_ALTITUDE_KM, 0]; +const viewDirection = (elevationDeg, azimuthDeg) => { + const e = elevationDeg * RAD; + const a = azimuthDeg * RAD; + return [Math.cos(e) * Math.cos(a), Math.sin(e), Math.cos(e) * Math.sin(a)]; +}; +const sunDirection = (elevationDeg) => [ + Math.cos(elevationDeg * RAD), + Math.sin(elevationDeg * RAD), + 0, +]; + +/** Transmittance per channel from the observer along an elevation to space; zeros where the ground is in the way. */ +export function transmittance(elevationDeg) { + const depth = opticalDepthToSpace(observer, viewDirection(elevationDeg, 0), 64); + return depth ? depth.map((x) => Math.exp(-x)) : [0, 0, 0]; +} + +const rayleighPhase = (c) => (3 / (16 * Math.PI)) * (1 + c * c); +const miePhase = (c) => + (1 - MIE_G * MIE_G) / (4 * Math.PI * (1 + MIE_G * MIE_G - 2 * MIE_G * c) ** 1.5); + +/** + * Sky radiance per channel, per steradian, for a sun of unit irradiance (so the consumer multiplies by + * its own sun colour), in a view direction at an elevation and an azimuth measured from the sun. + * Single scattering, no ground bounce, no sun disc. + */ +export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) { + const v = viewDirection(viewElevationDeg, azimuthDeg); + const s = sunDirection(sunElevationDeg); + const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; + const pr = rayleighPhase(cosTheta); + const pm = miePhase(cosTheta); + const ground = sphereHit(observer, v, PLANET_RADIUS_KM, true); + const top = sphereHit(observer, v, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); + const end = Math.min(ground, top); + const steps = 24; + const dt = end / steps; + const along = [0, 0, 0]; + const radiance = [0, 0, 0]; + for (let k = 0; k < steps; k += 1) { + const t = (k + 0.5) * dt; + const q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; + const h = Math.hypot(...q) - PLANET_RADIUS_KM; + const e = extinction(h); + for (let c = 0; c < 3; c += 1) along[c] += e[c] * dt; + const toSun = opticalDepthToSpace(q, s, 12); + if (toSun) { + const r = Math.exp(-h / H_RAYLEIGH); + const m = Math.exp(-h / H_MIE); + for (let c = 0; c < 3; c += 1) { + const scatter = BETA_RAYLEIGH[c] * r * pr + BETA_MIE_SCA * m * pm; + radiance[c] += Math.exp(-along[c] - toSun[c]) * scatter * dt; + } + } + } + return radiance; +} + +// ---- Aerial perspective ------------------------------------------------------------------------------------------ +// After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020 (section 5.4, the +// aerial perspective "camera volume"), and its reference implementation, sebh/UnrealEngineSkyAtmosphere +// Resources/RenderSkyRayMarching.hlsl `RenderCameraVolumePS`: a volume of AP_SLICES slices whose depth grows with the +// square of the slice index (`Slice *= Slice`), each texel holding the single-scattered luminance L accumulated along +// the view ray to that depth, and in alpha the opacity 1 - mean(rgb transmittance) (a grey transmittance, as the paper). +// Applied to a surface as out = surface * (1 - alpha) + L, with L the in-scatter along that ray. +// +// What differs from the paper, and why: +// - the volume is indexed by the view ray relative to the sun (azimuth from the sun, elevation) and sun elevation, not by +// screen position, because the terrain camera turns and the sky tables here are sun-relative too; +// - the path lengths are the scene's length times AP_DISTANCE_SCALE: a real atmosphere is nearly clear over the 3 km patch, +// so the baked path is that many times longer (EYEBALLED); it sets how fast far ridges go to the sky; +// - the ray ignores the ground: below the observer's altitude the density is held at its ground value, and the sun's +// visibility is tested from the ground sphere; the terrain is the ground; +// - single scattering with the sky tables' own air (no multi-scattering LUT, which the paper adds for dusk); +// - AP_SLICES = 12 slices, 8 view elevations and 7 azimuths, not 32 x 32 x 32 screen froxels, to stay phone-sized. +export const AP_SLICES = 12; +export const AP_ELEVATIONS = 8; +/** The view elevations the volume holds run from this down to 0 degrees, finest at 0: -AP_ELEVATION_MIN_DEG * (1 - i / 7)^2. */ +export const AP_ELEVATION_MIN_DEG = 50; +/** Scene metres a slice volume covers: the patch's far corner (about 2.1 km) with margin. */ +export const AP_SCENE_MAX_M = 2500; +export const AP_DISTANCE_SCALE = 8; +/** Physical kilometres the last slice stands for. */ +export const AP_MAX_KM = (AP_SCENE_MAX_M * AP_DISTANCE_SCALE) / 1000; +const AP_SUBSTEPS = 4; + +export const apElevation = (i) => -AP_ELEVATION_MIN_DEG * (1 - i / (AP_ELEVATIONS - 1)) ** 2; +/** Fractional elevation index of a view elevation in degrees (clamped to 0 degrees and below). */ +export const apElevationIndex = (deg) => + (AP_ELEVATIONS - 1) * (1 - Math.sqrt(clamp(-deg / AP_ELEVATION_MIN_DEG, 0, 1))); +/** Distance in km at the far side of slice k (k = 0 .. AP_SLICES - 1): the squared distribution. */ +export const apSliceKm = (k) => AP_MAX_KM * ((k + 1) / AP_SLICES) ** 2; +/** Fractional slice index of a distance in km: below 0 the volume fades to nothing at the observer. */ +export const apSliceIndex = (km) => AP_SLICES * Math.sqrt(Math.max(km, 0) / AP_MAX_KM) - 1; + +/** + * In-scatter luminance per channel (for a sun of unit irradiance) and opacity along a view ray from the observer, + * at the far side of each of AP_SLICES slices: [[r, g, b, alpha] x AP_SLICES]. + */ +export function aerialRay(sunElevationDeg, viewElevationDeg, azimuthDeg) { + const v = viewDirection(viewElevationDeg, azimuthDeg); + const s = sunDirection(sunElevationDeg); + const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; + const pr = rayleighPhase(cosTheta); + const pm = miePhase(cosTheta); + const out = []; + const along = [0, 0, 0]; + const radiance = [0, 0, 0]; + let from = 0; + for (let k = 0; k < AP_SLICES; k += 1) { + const to = apSliceKm(k); + const dt = (to - from) / AP_SUBSTEPS; + for (let j = 0; j < AP_SUBSTEPS; j += 1) { + const t = from + (j + 0.5) * dt; + let q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; + const r = Math.hypot(...q); + if (r < PLANET_RADIUS_KM) q = q.map((x) => (x * PLANET_RADIUS_KM) / r); + const h = Math.max(r - PLANET_RADIUS_KM, 0); + const e = extinction(h); + const toSun = opticalDepthToSpace(q, s, 12); + const rm = Math.exp(-h / H_RAYLEIGH); + const mm = Math.exp(-h / H_MIE); + for (let c = 0; c < 3; c += 1) { + // The step's own extinction is integrated analytically (Hillaire eq. 6): S (1 - exp(-e dt)) / e. + const step = Math.exp(-e[c] * dt); + if (toSun) { + const scatter = BETA_RAYLEIGH[c] * rm * pr + BETA_MIE_SCA * mm * pm; + radiance[c] += Math.exp(-along[c] - toSun[c]) * ((scatter * (1 - step)) / e[c]); + } + along[c] += e[c] * dt; + } + } + from = to; + const mean = along.reduce((a, x) => a + Math.exp(-x), 0) / 3; + out.push([...radiance, 1 - mean]); + } + return out; +} + +const base64 = (halves) => { + const bytes = new Uint8Array(halves.length * 2); + halves.forEach((h, i) => { + bytes[i * 2] = h & 0xff; // little-endian + bytes[i * 2 + 1] = h >> 8; + }); + let s = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(s); +}; + +export function bakeScatter() { + const view = Array.from({ length: VIEW_SAMPLES }, (_, i) => viewElevation(i)); + + const t = new Uint16Array(VIEW_SAMPLES * 3); + view.forEach((e, i) => transmittance(e).forEach((v, c) => (t[i * 3 + c] = encode(v)))); + + // x = azimuth, y = view elevation, z = sun elevation, three half floats per texel: a 3D texture. + const sky = new Uint16Array(SUN_ELEVATIONS_DEG.length * VIEW_SAMPLES * AZIMUTH_SAMPLES * 3); + let at = 0; + SUN_ELEVATIONS_DEG.forEach((sun) => + view.forEach((e) => { + for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) { + skyRadiance(sun, e, a * AZIMUTH_STEP_DEG).forEach((v) => { + sky[at] = encode(v); + at += 1; + }); + } + }) + ); + + // x = azimuth from the sun, y = elevation block k * AP_ELEVATIONS + i (slice-major), z = sun elevation: four half floats per texel (L rgb, alpha). + const aerial = new Uint16Array( + SUN_ELEVATIONS_DEG.length * AP_SLICES * AP_ELEVATIONS * AZIMUTH_SAMPLES * 4 + ); + let ap = 0; + SUN_ELEVATIONS_DEG.forEach((sun) => { + const rays = Array.from({ length: AP_ELEVATIONS }, (_, i) => + Array.from({ length: AZIMUTH_SAMPLES }, (_, a) => + aerialRay(sun, apElevation(i), a * AZIMUTH_STEP_DEG) + ) + ); + for (let k = 0; k < AP_SLICES; k += 1) + for (let i = 0; i < AP_ELEVATIONS; i += 1) + for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) + rays[i][a][k].forEach((x) => { + aerial[ap] = encode(x); + ap += 1; + }); + }); + + return { + planet_radius_km: PLANET_RADIUS_KM, + atmosphere_height_km: ATMOSPHERE_HEIGHT_KM, + observer_altitude_km: OBSERVER_ALTITUDE_KM, + // Extinction per km at the observer, per channel, for aerial perspective: exp(-k * distance_km). + extinction_per_km: extinction(OBSERVER_ALTITUDE_KM), + format: 'half-float', + store_scale: STORE_SCALE, + view_elevation_deg: { min: VIEW_MIN_DEG, max: VIEW_MAX_DEG, samples: VIEW_SAMPLES }, + transmittance: { width: VIEW_SAMPLES, height: 1, channels: 3, data: base64(t) }, + sky_view: { + sun_elevations_deg: SUN_ELEVATIONS_DEG, + azimuth_step_deg: AZIMUTH_STEP_DEG, + width: AZIMUTH_SAMPLES, + height: VIEW_SAMPLES, + depth: SUN_ELEVATIONS_DEG.length, + channels: 3, + data: base64(sky), + }, + aerial_perspective: { + slices: AP_SLICES, + elevations: AP_ELEVATIONS, + elevation_min_deg: -AP_ELEVATION_MIN_DEG, + max_km: AP_MAX_KM, + distance_scale: AP_DISTANCE_SCALE, + width: AZIMUTH_SAMPLES, + height: AP_SLICES * AP_ELEVATIONS, + depth: SUN_ELEVATIONS_DEG.length, + channels: 4, + data: base64(aerial), + }, + }; +} diff --git a/src/sky/scatter.test.ts b/src/sky/scatter.test.ts new file mode 100644 index 00000000..e0ea9023 --- /dev/null +++ b/src/sky/scatter.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import { + AP_ELEVATIONS, + AP_MAX_KM, + AP_SLICES, + apElevation, + apElevationIndex, + apSliceIndex, + apSliceKm, + aerialRay, + AZIMUTH_SAMPLES, + AZIMUTH_STEP_DEG, + STORE_SCALE, + SUN_ELEVATIONS_DEG, + VIEW_SAMPLES, + bakeScatter, + decode, + fromHalf, + skyRadiance, + sunIndex, + toHalf, + transmittance, + viewElevation, + viewIndex, +} from './scatter.js'; +import { TAU } from './sun.js'; +import { baked } from './baked'; + +const table = (b64: string): Uint16Array => { + const bin = atob(b64); + const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); + return new Uint16Array(bytes.buffer); +}; +// Checksums of the baked halves, so a change to the model or the encoding is a deliberate re-pin. +const SKY_CHECKSUM = 622927107; +const TRANS_CHECKSUM = 1498461058; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const scatter = baked.scatter as any; +const sky = table(scatter.sky_view.data); +const trans = table(scatter.transmittance.data); +const sunIndex_ = (deg: number) => SUN_ELEVATIONS_DEG.indexOf(deg); +// Texel of the sky table: x = azimuth, y = view elevation, z = sun elevation; value = three halves. +const texel = (s: number, v: number, a: number) => + [0, 1, 2].map((c) => decode(sky[((s * VIEW_SAMPLES + v) * AZIMUTH_SAMPLES + a) * 3 + c])); + +describe('half floats', () => { + it('round-trip within half precision across the table range, ties to even', () => { + [6.1e-5, 1e-4, 0.0123, 0.3, 1, 20, 1000].forEach((x) => + expect(Math.abs(fromHalf(toHalf(x)) - x) / x).toBeLessThan(5.1e-4) + ); + expect(toHalf(0)).toBe(0); + expect(toHalf(1)).toBe(0x3c00); + expect(toHalf(2 ** -24)).toBe(1); // smallest subnormal + expect(toHalf(1 + 2 ** -11)).toBe(0x3c00); // exact tie goes to the even neighbour + expect(toHalf(1 + 3 * 2 ** -11)).toBe(0x3c02); + expect(fromHalf(0x7bff)).toBe(65504); + }); + + it('keeps twilight radiance, ~1e-4, within 0.1% thanks to the store scale', () => { + const x = 1.234e-4; + expect(Math.abs(decode(toHalf(x * STORE_SCALE)) - x) / x).toBeLessThan(1e-3); + }); +}); + +describe('sunIndex', () => { + it('is the fractional position of an elevation on the sun axis, clamped to the table', () => { + expect(sunIndex(-18)).toBe(0); + expect(sunIndex(90)).toBe(SUN_ELEVATIONS_DEG.length - 1); + expect(sunIndex(-100)).toBe(0); + expect(sunIndex(100)).toBe(SUN_ELEVATIONS_DEG.length - 1); + expect(sunIndex(0)).toBe(sunIndex_(0)); + expect(sunIndex(1.5)).toBeCloseTo(sunIndex_(0) + 0.5, 12); // halfway between 0 and 3 + expect(sunIndex(35)).toBeCloseTo(sunIndex_(25) + 0.5, 12); // halfway between 25 and 45 + }); +}); + +describe('scattering tables', () => { + it('are what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => { + expect(JSON.parse(JSON.stringify(bakeScatter()))).toEqual(JSON.parse(JSON.stringify(scatter))); + }); + + it('have the documented layout and size', () => { + expect(scatter.format).toBe('half-float'); + expect(scatter.sky_view).toMatchObject({ + width: AZIMUTH_SAMPLES, + height: VIEW_SAMPLES, + depth: SUN_ELEVATIONS_DEG.length, + channels: 3, + azimuth_step_deg: AZIMUTH_STEP_DEG, + }); + expect(sky.length).toBe(AZIMUTH_SAMPLES * VIEW_SAMPLES * SUN_ELEVATIONS_DEG.length * 3); + expect(trans.length).toBe(VIEW_SAMPLES * 3); + expect(sky.length * 2).toBe(16128); // bytes: the budget the README states + }); + + it('pin the view-elevation axis and its inverse', () => { + expect(viewElevation(0)).toBe(-4); + expect(viewElevation(VIEW_SAMPLES - 1)).toBe(90); + expect(viewElevation(6)).toBeCloseTo(-4 + 94 * (6 / 31) ** 2, 10); + [0, 5, 6, 17, 31].forEach((i) => expect(viewIndex(viewElevation(i))).toBeCloseTo(i, 9)); + }); + + it('transmittance to space agrees with sun.js at the zenith and falls toward the horizon', () => { + const top = VIEW_SAMPLES - 1; + [0, 1, 2].forEach((c) => + expect(Math.abs(decode(trans[top * 3 + c]) / Math.exp(-TAU[c]) - 1)).toBeLessThan(0.01) + ); + expect(transmittance(90).map((v, c) => v / Math.exp(-TAU[c]))).toSatisfy((r: number[]) => + r.every((x) => Math.abs(x - 1) < 0.01) + ); + // blue goes first: lower view, lower blue, and redder light at the horizon + let prev = Infinity; + for (let i = top; i >= 8; i -= 1) { + const blue = decode(trans[i * 3 + 2]); + expect(blue).toBeLessThanOrEqual(prev); + prev = blue; + } + expect(decode(trans[8 * 3]) / decode(trans[8 * 3 + 2])).toBeGreaterThan(10); // ~2 degrees up: sunset red + expect(decode(trans[0])).toBe(0); // -4 degrees looks at the ground + }); + + it('colour the daytime sky blue, and a low sun redder toward it than away from it', () => { + const noon = texel(sunIndex_(45), VIEW_SAMPLES - 1, 0); + expect(noon[2]).toBeGreaterThan(noon[1]); + expect(noon[1]).toBeGreaterThan(noon[0]); + const horizon = Math.round(viewIndex(5)); + const toward = texel(sunIndex_(3), horizon, 0); + const away = texel(sunIndex_(3), horizon, AZIMUTH_SAMPLES - 1); + expect(toward[0] / toward[2]).toBeGreaterThan(5); + expect(toward[0] / toward[2]).toBeGreaterThan(2 * (away[0] / away[2])); + expect(toward[0]).toBeGreaterThan(5 * away[0]); // the glow is around the sun + }); + + it('fade through twilight to black below the planet-shadow limit', () => { + const mid = Math.round(viewIndex(30)); + const lum = (s: number) => texel(sunIndex_(s), mid, 3).reduce((x, y) => x + y, 0); + expect(lum(8)).toBeGreaterThan(lum(3)); + expect(lum(3)).toBeGreaterThan(lum(-2)); + expect(lum(-2)).toBeGreaterThan(lum(-5)); + expect(lum(-5)).toBeGreaterThan(0); + expect(lum(-12)).toBe(0); + expect(lum(-18)).toBe(0); + }); + + it('hold the model to half-float precision, with no quantised steps along the horizon glow', () => { + // Every table texel off a handful of sampled ones is the model's own value to < 0.2%. + [ + [sunIndex_(3), 6, 0], + [sunIndex_(3), 9, 2], + [sunIndex_(0), 8, 0], + [sunIndex_(45), 20, 3], + [sunIndex_(-2), 12, 1], + ].forEach(([s, v, a]) => { + const direct = skyRadiance(SUN_ELEVATIONS_DEG[s], viewElevation(v), a * AZIMUTH_STEP_DEG); + texel(s, v, a).forEach((got, c) => expect(Math.abs(got / direct[c] - 1)).toBeLessThan(2e-3)); + }); + // Moving up from the horizon toward the sun, red drops smoothly: distinct, strictly decreasing. + const s = sunIndex_(3); + const reds = Array.from({ length: 8 }, (_, k) => texel(s, 7 + k, 0)[0]); + reds.slice(1).forEach((r, k) => expect(r).toBeLessThan(reds[k])); + expect(new Set(reds).size).toBe(reds.length); + }); + + it('are the same bytes as when pinned', () => { + const sum = (a: Uint16Array) => a.reduce((x, y) => (x * 31 + y) % 2147483647, 7); + expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]); + }); +}); + +describe('aerial perspective volume', () => { + const ap = scatter.aerial_perspective; + + it('has the documented layout and size: RGBA half floats, x azimuth, y slice-major elevations, z sun elevation', () => { + expect([ap.width, ap.height, ap.depth, ap.channels]).toEqual([ + AZIMUTH_SAMPLES, + AP_SLICES * AP_ELEVATIONS, + SUN_ELEVATIONS_DEG.length, + 4, + ]); + expect(table(ap.data)).toHaveLength(ap.width * ap.height * ap.depth * 4); + }); + + it("slices follow the paper's squared distribution, ending at the covered distance", () => { + expect(apSliceKm(AP_SLICES - 1)).toBeCloseTo(AP_MAX_KM, 9); + expect(apSliceKm(0)).toBeCloseTo(AP_MAX_KM / AP_SLICES ** 2, 9); + for (let k = 0; k < AP_SLICES; k += 1) expect(apSliceIndex(apSliceKm(k))).toBeCloseTo(k, 9); + expect(apSliceIndex(0)).toBeCloseTo(-1, 9); + }); + + it('elevations are finest at the horizon and the index inverts them', () => { + expect(apElevation(AP_ELEVATIONS - 1)).toBeCloseTo(0, 12); + for (let i = 0; i < AP_ELEVATIONS; i += 1) + expect(apElevationIndex(apElevation(i))).toBeCloseTo(i, 9); + expect(apElevation(AP_ELEVATIONS - 1) - apElevation(AP_ELEVATIONS - 2)).toBeLessThan( + apElevation(1) - apElevation(0) + ); + }); + + it("opacity grows with path, in-scatter grows with path, and a sun under the planet's shadow scatters nothing", () => { + const r = aerialRay(45, -2, 90); + for (let k = 1; k < AP_SLICES; k += 1) { + expect(r[k][3]).toBeGreaterThan(r[k - 1][3]); + expect(r[k][0]).toBeGreaterThan(r[k - 1][0]); + } + for (const slice of aerialRay(-18, -2, 0)) expect(slice.slice(0, 3)).toEqual([0, 0, 0]); + }); + + it('is brighter toward a low sun than away, and redder toward it (Rayleigh and Mie phase)', () => { + const toward = aerialRay(2, -2, 0)[AP_SLICES - 1]; + const away = aerialRay(2, -2, 180)[AP_SLICES - 1]; + expect(toward[0]).toBeGreaterThan(20 * away[0]); + expect(toward[0] / toward[2]).toBeGreaterThan(away[0] / away[2]); + }); + + it('opacity does not depend on the sun, only on the path', () => { + expect(aerialRay(45, -2, 0)[5][3]).toBeCloseTo(aerialRay(-18, -2, 90)[5][3], 12); + }); + + it("the baked table is the model's output: spot texels equal aerialRay, to half-float precision", () => { + const t = table(ap.data); + const at = (sun: number, slice: number, el: number, az: number, c: number) => + decode(t[((sun * ap.height + slice * AP_ELEVATIONS + el) * ap.width + az) * 4 + c]); + const r = aerialRay(SUN_ELEVATIONS_DEG[8], apElevation(6), 60)[7]; + for (let c = 0; c < 4; c += 1) expect(at(8, 7, 6, 2, c)).toBeCloseTo(r[c], 3); + }); +}); diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js index 0df39c33..c234de12 100644 --- a/src/sky/skyTokens.js +++ b/src/sky/skyTokens.js @@ -1,7 +1,8 @@ // Midnight Sky and Noonday Sky: theme tokens as albedo x irradiance. // Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. // -// bakeSky('midnight' | 'noonday') -> { moment, elevation, gradient: [hex x GRADIENT_STOPS], tokens } +// bakeSky('midnight' | 'noonday') -> { moment, elevation, sun, ambient_linear, horizon_hex, +// gradient: [hex x GRADIENT_STOPS], tokens, ... } // // The sky is lit by sun.js at a fixed moment (clear weather: a build-time bake has no feed). // A surface is `albedo * irradiance`, where irradiance is the sun and sky on an upward-facing @@ -24,6 +25,11 @@ export const MOMENTS = { midnight: { time: '2026-09-22T00:00:00Z', lat: 45, lon: 0 }, }; +// Lowest ambient a baked sky reports, linear RGB. sun.js's night sky is ~0.004, which is +// starlight-true but too dark to see by on a screen; this lifts it to a bluish moonlit-skyglow +// level. Daylight ambient is far above it, so the floor only ever touches the night. +export const AMBIENT_FLOOR = [0.018, 0.024, 0.04]; + // Linear-RGB albedos of the things Yggdrasil is made of. EYEBALLED around measured ranges // (dark bark 0.05-0.1, birch bark ~0.6, leaves ~0.05 red/0.15 green/0.03 blue, wet soil ~0.1). export const ALBEDO = { @@ -193,6 +199,11 @@ export function bakeSky(name) { return { moment, elevation: l.elevation, + // Sun for consumers that light a scene themselves: direction in degrees (azimuth clockwise + // from north) and the direct light's colour at normal incidence (black while it is below the horizon). + sun: { elevation_deg: l.elevation, azimuth_deg: l.azimuth, color_linear: l.direct }, + ambient_linear: l.diffuse.map((v, i) => Math.max(v, AMBIENT_FLOOR[i])), + horizon_hex: gradient[GRADIENT_STOPS - 1], // last stop: the horizon end of the gradient gradient, tokens, bubbles, diff --git a/src/sky/skyTokens.test.ts b/src/sky/skyTokens.test.ts index 0cd6694c..78a29b03 100644 --- a/src/sky/skyTokens.test.ts +++ b/src/sky/skyTokens.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { AA, bakeSky, contrast } from './skyTokens.js'; +import { AA, AMBIENT_FLOOR, bakeSky, contrast } from './skyTokens.js'; +import { light } from './sun.js'; import { baked } from './baked'; const names = ['midnight', 'noonday'] as const; @@ -18,6 +19,37 @@ describe.each(names)('%s sky', (name) => { expect(name === 'noonday' ? sky.elevation : -sky.elevation).toBeGreaterThan(40); }); + it('carries the sun, an ambient light and a horizon colour for consumers that light a scene', () => { + const { sun } = sky; + expect(Object.keys(sun).sort()).toEqual(['azimuth_deg', 'color_linear', 'elevation_deg']); + expect(sun.elevation_deg).toBe(sky.elevation); + expect(sun.color_linear).toHaveLength(3); + sun.color_linear.forEach((v: number) => { + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + }); + expect(sky.ambient_linear).toHaveLength(3); + expect(sky.horizon_hex).toMatch(/^#[0-9a-f]{6}$/); + expect(sky.horizon_hex).toBe(sky.gradient[sky.gradient.length - 1]); + }); + + it('puts the sun where Nibu expects it, azimuth clockwise from north', () => { + const azimuth: number = sky.sun.azimuth_deg; + expect(azimuth).toBeGreaterThanOrEqual(0); + expect(azimuth).toBeLessThan(360); + expect(Math.abs(azimuth - (name === 'noonday' ? 182.6 : 2.5))).toBeLessThan(1); + }); + + it('keeps ambient above the night floor and leaves daylight alone', () => { + sky.ambient_linear.forEach((v: number, i: number) => + expect(v).toBeGreaterThanOrEqual(AMBIENT_FLOOR[i]) + ); + if (name === 'noonday') { + sky.ambient_linear.forEach((v: number) => expect(v).toBeGreaterThan(0.05)); + expect(sky.ambient_linear).toEqual(light(sky.moment).diffuse); + } + }); + it('clears WCAG AA for text on every panel state', () => { ['Background', 'Surface', 'SurfaceVariant'].forEach((p) => states.forEach((s) => diff --git a/src/sky/sun.js b/src/sky/sun.js index d66f0f9a..3173eb4d 100644 --- a/src/sky/sun.js +++ b/src/sky/sun.js @@ -2,13 +2,14 @@ // no imports, so a build-time token script and a runtime shader can share it. // // light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) -// -> { elevation, direct: [r,g,b], diffuse: [r,g,b], wetness } +// -> { elevation, azimuth, direct: [r,g,b], diffuse: [r,g,b], wetness } // // time Date, epoch milliseconds, or an ISO string (read as UTC) // lat, lon degrees, north and east positive // cloud_cover 0..1 (Open-Meteo reports percent: divide by 100) // precipitation mm/h // elevation degrees above the horizon, no refraction +// azimuth degrees clockwise from north (0 N, 90 E, 180 S), 0..360, no refraction // direct sun colour at normal incidence, linear RGB. Clear zenith sun ~ 1. // The consumer multiplies by max(0, dot(normal, toSun)). // diffuse sky ambient on an upward surface, linear RGB @@ -28,8 +29,8 @@ const mix = (a, b, t) => a + (b - a) * t; const toMs = (time) => (time instanceof Date ? time.getTime() : typeof time === 'string' ? Date.parse(time) : Number(time)); -/** Sun elevation in degrees at a UTC moment and place. */ -export function sunElevation(time, lat, lon) { +/** Sun elevation and azimuth in degrees at a UTC moment and place. Azimuth runs clockwise from north. */ +export function sunPosition(time, lat, lon) { const d = toMs(time) / 86400000 - 10957.5; // days since J2000.0 (2000-01-01 12:00 UTC) const L = (280.46 + 0.9856474 * d) * RAD; // mean longitude const g = (357.528 + 0.9856003 * d) * RAD; // mean anomaly @@ -41,13 +42,22 @@ export function sunElevation(time, lat, lon) { const hourAngle = gmst + lon * RAD - ra; const phi = lat * RAD; const sinEl = Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(hourAngle); - return Math.asin(clamp(sinEl, -1, 1)) / RAD; + // Hour angle is westward-positive, so atan2 gives azimuth from south, westward; +180 turns it into + // clockwise from north. + const south = Math.atan2(Math.sin(hourAngle), Math.cos(hourAngle) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)); + return { + elevation: Math.asin(clamp(sinEl, -1, 1)) / RAD, + azimuth: (((south / RAD + 180) % 360) + 360) % 360, + }; } +/** Sun elevation in degrees at a UTC moment and place. */ +export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elevation; + // Optical depth of the clear atmosphere at the zenith, per channel (R, G, B). // Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep // low sun orange rather than brown). -const TAU = [0.06, 0.12, 0.26]; +export const TAU = [0.06, 0.12, 0.26]; // Relative air mass, Kasten & Young 1989. Elevation in degrees, >= 0. const airMass = (el) => 1 / (Math.sin(el * RAD) + 0.50572 * Math.pow(el + 6.07995, -1.6364)); @@ -62,7 +72,7 @@ const GREY_TINT = [0.95, 1, 1.08]; // cool overcast. EYEBALLED const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { - const elevation = sunElevation(time, lat, lon); + const { elevation, azimuth } = sunPosition(time, lat, lon); const cloud = clamp(cloud_cover, 0, 1); const rain = Math.max(0, precipitation); @@ -95,5 +105,5 @@ export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { // 0 dry, ~0.63 at 1 mm/h, ~0.98 at 4 mm/h. EYEBALLED. const wetness = 1 - Math.exp(-rain); - return { elevation, direct, diffuse, wetness }; + return { elevation, azimuth, direct, diffuse, wetness }; }