scene: terrain mesh as fine as the height map (stack: terrain scatter, albedo, stars, haze) #42
37 changed files with 4963 additions and 52 deletions
|
|
@ -6,6 +6,7 @@ Chromium. Not shipped, not run by CI.
|
|||
node bundle.cjs <outdir> # esbuild bundle of entry.ts plus an index.html
|
||||
node drive.cjs <outdir> compile # compile and link each stage, JSON report
|
||||
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
|
||||
node gradAxis.cjs <shaders dir> # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis
|
||||
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
|
||||
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
|
||||
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
|
||||
|
|
|
|||
30
harness/scene/gradAxis.cjs
Normal file
30
harness/scene/gradAxis.cjs
Normal file
|
|
@ -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 <repo>/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()})();
|
||||
|
|
@ -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` +
|
||||
|
|
|
|||
|
|
@ -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' : ''}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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=<elevation deg>&az=<deg from north>` 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
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompil
|
|||
/**
|
||||
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
|
||||
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
|
||||
* main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch'. Null means
|
||||
* main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch' or 'terrain' (which falls back to its
|
||||
* sky alone, `terrain/TerrainSky2D.ts`, where WebGL2 is missing). Null means
|
||||
* "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the
|
||||
* failure goes to onCompileFail and nothing throws.
|
||||
*/
|
||||
|
|
@ -25,10 +26,15 @@ export async function startBackgroundScene(
|
|||
if (tier === 'off') return null;
|
||||
let createScene: (o: SceneOptions) => 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
185
src/app/features/scene/ridge/stars.test.ts
Normal file
185
src/app/features/scene/ridge/stars.test.ts
Normal file
|
|
@ -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<number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
249
src/app/features/scene/ridge/stars.ts
Normal file
249
src/app/features/scene/ridge/stars.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
357
src/app/features/scene/terrain/Terrain.test.ts
Normal file
357
src/app/features/scene/terrain/Terrain.test.ts
Normal file
|
|
@ -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<string, unknown> = {}) {
|
||||
const calls: Record<string, unknown[][]> = {};
|
||||
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<number>).length === 3)
|
||||
.map((a) => Array.from(a[1] as ArrayLike<number>));
|
||||
};
|
||||
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<string, unknown>) => {
|
||||
const { gl, calls } = fakeGl();
|
||||
createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose();
|
||||
return calls.uniform3fv.map((a) => Array.from(a[1] as ArrayLike<number>));
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
431
src/app/features/scene/terrain/Terrain.ts
Normal file
431
src/app/features/scene/terrain/Terrain.ts
Normal file
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
220
src/app/features/scene/terrain/TerrainSky2D.test.ts
Normal file
220
src/app/features/scene/terrain/TerrainSky2D.test.ts
Normal file
|
|
@ -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<typeof vi.fn> };
|
||||
let windowListeners: Record<string, () => 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();
|
||||
});
|
||||
});
|
||||
87
src/app/features/scene/terrain/TerrainSky2D.ts
Normal file
87
src/app/features/scene/terrain/TerrainSky2D.ts
Normal file
|
|
@ -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<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun'>;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
224
src/app/features/scene/terrain/terrainAlbedo.test.ts
Normal file
224
src/app/features/scene/terrain/terrainAlbedo.test.ts
Normal file
|
|
@ -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\(/);
|
||||
});
|
||||
});
|
||||
197
src/app/features/scene/terrain/terrainAlbedo.ts
Normal file
197
src/app/features/scene/terrain/terrainAlbedo.ts
Normal file
|
|
@ -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<readonly [number, number]>, 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<readonly [number, number]>) =>
|
||||
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);
|
||||
141
src/app/features/scene/terrain/terrainScatter.test.ts
Normal file
141
src/app/features/scene/terrain/terrainScatter.test.ts
Normal file
|
|
@ -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 +`);
|
||||
});
|
||||
});
|
||||
179
src/app/features/scene/terrain/terrainScatter.ts
Normal file
179
src/app/features/scene/terrain/terrainScatter.ts
Normal file
|
|
@ -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);
|
||||
}`;
|
||||
237
src/app/features/scene/terrain/terrainShaders.ts
Normal file
237
src/app/features/scene/terrain/terrainShaders.ts
Normal file
|
|
@ -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);
|
||||
}`;
|
||||
300
src/app/features/scene/terrain/terrainSky.test.ts
Normal file
300
src/app/features/scene/terrain/terrainSky.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
246
src/app/features/scene/terrain/terrainSky.ts
Normal file
246
src/app/features/scene/terrain/terrainSky.ts
Normal file
|
|
@ -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<SkyName, SkyData> = {
|
||||
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()));
|
||||
}
|
||||
241
src/app/features/scene/terrain/terrainStars.test.ts
Normal file
241
src/app/features/scene/terrain/terrainStars.test.ts
Normal file
|
|
@ -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<typeof fakeCtx>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
173
src/app/features/scene/terrain/terrainStars.ts
Normal file
173
src/app/features/scene/terrain/terrainStars.ts
Normal file
|
|
@ -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<number, number>();
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
135
src/app/features/scene/terrain/terrainWorld.test.ts
Normal file
135
src/app/features/scene/terrain/terrainWorld.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
140
src/app/features/scene/terrain/terrainWorld.ts
Normal file
140
src/app/features/scene/terrain/terrainWorld.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
49
src/app/features/scene/terrain/tierLabel.test.ts
Normal file
49
src/app/features/scene/terrain/tierLabel.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
26
src/app/features/scene/terrain/tierLabel.ts
Normal file
26
src/app/features/scene/terrain/tierLabel.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
105
src/sky/README.md
Normal file
105
src/sky/README.md
Normal file
|
|
@ -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.<sky>.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.
|
||||
File diff suppressed because one or more lines are too long
362
src/sky/scatter.js
Normal file
362
src/sky/scatter.js
Normal file
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
226
src/sky/scatter.test.ts
Normal file
226
src/sky/scatter.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue