WIP ridge: physical sky over Vienna (Hillaire LUTs, Earth coefficients, real-clock sun), ACES display fitted per state, RIDGE_NIGHT_BAND removed
Not landed: 3 tests red (skyState 'night below day and dusk', two skyTheme ink checks that read the old bands), no clouds/rain/mist yet, no stills, no Aqua-method measurement of the new sky, no Soul pass. Local branch only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d9659f13c
commit
3b1405907a
17 changed files with 1284 additions and 140 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { atStandardTime } from './standardTime.testkit';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import type { Scene } from './Scene';
|
||||
import { startBackgroundScene } from './index';
|
||||
import { setSkyClock, setSkyState } from './skyState';
|
||||
|
|
@ -79,7 +78,7 @@ describe('startBackgroundScene follows the sky state', () => {
|
|||
setSkyState('day');
|
||||
await flush();
|
||||
expect(first.dispose).toHaveBeenCalledOnce();
|
||||
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]);
|
||||
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top));
|
||||
scene?.dispose();
|
||||
expect(second.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
|
@ -92,7 +91,7 @@ describe('startBackgroundScene follows the sky state', () => {
|
|||
const scene = await started;
|
||||
await flush();
|
||||
expect(createRidgeScene).toHaveBeenCalledTimes(2);
|
||||
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]);
|
||||
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('day').top));
|
||||
expect(a.dispose).toHaveBeenCalledOnce();
|
||||
scene?.dispose();
|
||||
expect(b.dispose).toHaveBeenCalledOnce();
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ async function skyKey(
|
|||
state: SkyState
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
|
||||
return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state));
|
||||
const { ridgeSkyKey, terrainSkyFor } = await import('./skyForState');
|
||||
return config?.style === 'terrain' ? JSON.stringify(terrainSkyFor(state)) : ridgeSkyKey(state);
|
||||
} catch {
|
||||
// The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure).
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Scene, SceneOptions } from '../Scene';
|
|||
import { overlayRequested } from '../debugOverlays';
|
||||
import { FrameSampler, FrameStats } from './probe';
|
||||
import { skyColours, SkyColours, Srgb } from './skyColour';
|
||||
import { paintSky as paintPhysicalSky } from './physicalSky';
|
||||
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
|
||||
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
|
||||
import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
|
||||
|
|
@ -34,9 +35,35 @@ function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null {
|
|||
return c.getContext('2d');
|
||||
}
|
||||
|
||||
function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean {
|
||||
/**
|
||||
* The host canvas's sky: the physical sky (`physicalSky.ts`) as a small grid of display pixels, scaled up smoothly (it is a soft
|
||||
* field); without one, the three-stop gradient.
|
||||
*/
|
||||
function paintSky(
|
||||
canvas: HTMLCanvasElement,
|
||||
sky: SkyColours,
|
||||
cssW: number,
|
||||
cssH: number,
|
||||
makeCanvas: () => HTMLCanvasElement
|
||||
): boolean {
|
||||
const ctx = context2d(canvas);
|
||||
if (!ctx) return false;
|
||||
if (sky.physical) {
|
||||
const px = paintPhysicalSky(sky.physical, cssW, cssH);
|
||||
const small = makeCanvas();
|
||||
small.width = px.w;
|
||||
small.height = px.h;
|
||||
const sctx = context2d(small);
|
||||
if (sctx) {
|
||||
const img = sctx.createImageData(px.w, px.h);
|
||||
img.data.set(px.data);
|
||||
sctx.putImageData(img, 0, 0);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const g = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||
g.addColorStop(0, css(sky.top));
|
||||
g.addColorStop(0.5, css(sky.mid));
|
||||
|
|
@ -415,7 +442,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
const size = layerSize(w, h, scale);
|
||||
canvas.width = size.period;
|
||||
canvas.height = Math.round(h * scale);
|
||||
if (!paintSky(canvas, sky)) return false;
|
||||
if (!paintSky(canvas, sky, w, h, () => document.createElement('canvas'))) return false;
|
||||
// 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);
|
||||
|
|
|
|||
96
src/app/features/scene/ridge/earthAtmosphere.test.ts
Normal file
96
src/app/features/scene/ridge/earthAtmosphere.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { bakeAtmosphere, bakeCostSteps, PLANET_KM } from './earthAtmosphere';
|
||||
import { TOP_KM } from './earthAtmosphere';
|
||||
|
||||
const rad = (deg: number) => Math.cos(((90 - deg) * Math.PI) / 180);
|
||||
const luma = (c: number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
||||
|
||||
const atNoon = bakeAtmosphere({ sunElevationDeg: 45 });
|
||||
const atSunset = bakeAtmosphere({ sunElevationDeg: 0 });
|
||||
const atDark = bakeAtmosphere({ sunElevationDeg: -20 });
|
||||
|
||||
/** Sky radiance looking `elev` degrees above the horizon, `az` degrees from the sun's azimuth. */
|
||||
const sky = (a: ReturnType<typeof bakeAtmosphere>, elev: number, az: number) => {
|
||||
const out = [0, 0, 0];
|
||||
a.radiance(Math.sin((elev * Math.PI) / 180), Math.cos((az * Math.PI) / 180), out);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('earthAtmosphere: the published Earth atmosphere, baked to look-up tables', () => {
|
||||
it('has the geometry of the references (a 100 km shell on a 6360 km planet)', () => {
|
||||
expect(PLANET_KM).toBe(6360);
|
||||
expect(TOP_KM - PLANET_KM).toBe(100);
|
||||
});
|
||||
|
||||
it('transmittance to space: the zenith is the published vertical optical depth, red passes most, blue least', () => {
|
||||
const t = [0, 0, 0];
|
||||
atNoon.transmittanceToSpace(1, t);
|
||||
// Rayleigh column at 8 km: 5.802e-3 * 8 = 0.0464 (red), 0.108 (green), 0.265 (blue); Mie 4.44e-3 * 1.2 = 0.0053; ozone
|
||||
// 0.65e-3 * 15 = 0.00975 (red), 0.0282 (green), 0.0013 (blue). So exp(-tau) there.
|
||||
expect(t[0]).toBeCloseTo(Math.exp(-(0.0464 + 0.0053 + 0.0098)), 1);
|
||||
expect(t[1]).toBeCloseTo(Math.exp(-(0.1085 + 0.0053 + 0.0282)), 1);
|
||||
expect(t[2]).toBeCloseTo(Math.exp(-(0.2648 + 0.0053 + 0.0013)), 1);
|
||||
expect(t[0]).toBeGreaterThan(t[1]);
|
||||
expect(t[1]).toBeGreaterThan(t[2]);
|
||||
});
|
||||
|
||||
it('a noon sky is blue: the zenith is bluer than the horizon, and bluer than it is red', () => {
|
||||
const zenith = sky(atNoon, 70, 90);
|
||||
expect(zenith[2]).toBeGreaterThan(zenith[0] * 1.5);
|
||||
const horizon = sky(atNoon, 1, 90);
|
||||
expect(luma(horizon)).toBeGreaterThan(luma(zenith)); // the long path whitens and brightens the horizon
|
||||
expect(horizon[2] / horizon[0]).toBeLessThan(zenith[2] / zenith[0]);
|
||||
});
|
||||
|
||||
it('the sky toward the sun at sunset is red-orange, and the zenith opposite is blue', () => {
|
||||
const toward = sky(atSunset, 3, 5);
|
||||
expect(toward[0]).toBeGreaterThan(toward[2] * 1.5);
|
||||
const away = sky(atSunset, 40, 180);
|
||||
expect(away[2]).toBeGreaterThan(away[0]);
|
||||
});
|
||||
|
||||
it('a clear sky brightens toward the sun (the Mie lobe)', () => {
|
||||
expect(luma(sky(atNoon, 45, 4))).toBeGreaterThan(2 * luma(sky(atNoon, 30, 90)));
|
||||
});
|
||||
|
||||
it('the sky is dark when the sun is 20 degrees under the horizon, a thousandth of noon or less', () => {
|
||||
expect(luma(sky(atDark, 20, 90))).toBeLessThan(luma(sky(atNoon, 20, 90)) * 1e-3);
|
||||
});
|
||||
|
||||
it('twilight keeps a blue zenith (the ozone absorption of red)', () => {
|
||||
const twilight = bakeAtmosphere({ sunElevationDeg: -6 });
|
||||
const z = sky(twilight, 60, 180);
|
||||
expect(z[2]).toBeGreaterThan(z[0] * 1.5);
|
||||
});
|
||||
|
||||
it('haze (Mie x4) whitens and brightens the horizon haze band', () => {
|
||||
const hazy = bakeAtmosphere({ sunElevationDeg: 30, mieScale: 4 });
|
||||
const clear = bakeAtmosphere({ sunElevationDeg: 30 });
|
||||
const h = sky(hazy, 12, 20);
|
||||
const c = sky(clear, 12, 20);
|
||||
expect(luma(h)).toBeGreaterThan(luma(c)); // more aerosol, more forward scattering toward the sun
|
||||
expect(h[2] / h[0]).toBeLessThan(c[2] / c[0]); // and less blue
|
||||
});
|
||||
|
||||
it('is energy-sane: the multiple-scattering term never makes the sky brighter than the sun it takes in, and stays finite', () => {
|
||||
for (const a of [atNoon, atSunset, atDark]) {
|
||||
for (let i = 0; i < a.skyView.data.length; i += 1) {
|
||||
expect(Number.isFinite(a.skyView.data[i])).toBe(true);
|
||||
expect(a.skyView.data[i]).toBeGreaterThanOrEqual(0);
|
||||
expect(a.skyView.data[i]).toBeLessThan(5);
|
||||
}
|
||||
}
|
||||
expect(rad(0)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('bake cost: the three LUTs take about the stated number of march steps, in tens of milliseconds', () => {
|
||||
const steps = bakeCostSteps();
|
||||
const t0 = performance.now();
|
||||
bakeAtmosphere({ sunElevationDeg: 24 });
|
||||
const ms = performance.now() - t0;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`atmosphere bake: ${steps} march steps, ${ms.toFixed(1)} ms`);
|
||||
expect(steps).toBeLessThan(1_000_000);
|
||||
expect(ms).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
552
src/app/features/scene/ridge/earthAtmosphere.ts
Normal file
552
src/app/features/scene/ridge/earthAtmosphere.ts
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
/* eslint-disable no-param-reassign, no-plusplus, no-bitwise -- numeric kernels over typed arrays */
|
||||
// Earth's atmosphere as three small look-up tables, baked once on the CPU: transmittance, multiple scattering and
|
||||
// the sky-view LUT. Per sky texel the scene then does one bilinear lookup. No ray march after the bake.
|
||||
//
|
||||
// The method is Hillaire 2020, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique" (EGSR):
|
||||
// the transmittance LUT in Bruneton and Neyret's (2008, 2017) parameterisation, the multiple-scattering LUT of his
|
||||
// section 5.5 (a geometric series over an isotropic second order), and the sky-view LUT of 5.3 with the
|
||||
// squared elevation distribution. The kernels follow his reference code (sebh/UnrealEngineSkyAtmosphere,
|
||||
// Resources/RenderSkyRayMarching.hlsl `IntegrateScatteredLuminance`, `NewMultiScattCS`, `SkyViewLutPS`; MIT).
|
||||
//
|
||||
// Earth's coefficients are the ones that code takes from Bruneton's 2017 reference implementation
|
||||
// (ebruneton/precomputed_atmospheric_scattering, `atmosphere/demo/demo.cc`), in 1/km, at sea level:
|
||||
// Rayleigh scattering (5.802, 13.558, 33.1) e-3 at (R, G, B) wavelengths, scale height 8 km. No Rayleigh absorption.
|
||||
// Mie scattering 3.996e-3 and extinction 4.44e-3 (single scattering albedo 0.9), grey, scale height 1.2 km,
|
||||
// Cornette-Shanks phase, asymmetry g 0.8.
|
||||
// Ozone absorption (0.650, 1.881, 0.085) e-3, a tent layer 15 km either side of 25 km (it makes the zenith blue at twilight).
|
||||
// Planet 6360 km, top of the atmosphere 6460 km (100 km: the Karman line, 99.99% of the air).
|
||||
// Rayleigh phase 3/(16 pi) (1 + cos^2). The sun is a point of unit irradiance; the consumer scales it (`toneMap.ts`).
|
||||
//
|
||||
// Single value the references do not give: ground albedo. 0.25 here, a grass, roofs and bare ground figure for a
|
||||
// city and its wood; it only feeds the multiple-scattering term (and so the dusk and the shadow side of the sky).
|
||||
//
|
||||
// Simplifications against the paper, all on the cheap side: no ground bounce inside the sky-view LUT itself (its rays
|
||||
// leave to space), fixed observer height, a spherical Earth with no terrain, the sun a point (the disc is the
|
||||
// consumer's), no refraction, no clouds (`weather.ts`: haze is a multiplier on the Mie coefficients).
|
||||
|
||||
export const PLANET_KM = 6360;
|
||||
export const TOP_KM = 6460;
|
||||
const RAYLEIGH_SCATTER: Vec3 = [5.802e-3, 13.558e-3, 33.1e-3];
|
||||
const RAYLEIGH_SCALE_KM = 8;
|
||||
const MIE_SCATTER = 3.996e-3;
|
||||
const MIE_EXTINCT = 4.44e-3;
|
||||
const MIE_SCALE_KM = 1.2;
|
||||
export const MIE_G = 0.8;
|
||||
const OZONE_ABSORB: Vec3 = [0.65e-3, 1.881e-3, 0.085e-3];
|
||||
const OZONE_PEAK_KM = 25;
|
||||
const OZONE_HALF_WIDTH_KM = 15;
|
||||
export const GROUND_ALBEDO = 0.25;
|
||||
const RADIUS_OFFSET_KM = 0.01;
|
||||
|
||||
export type Vec3 = readonly [number, number, number];
|
||||
|
||||
const PI = Math.PI;
|
||||
const FOUR_PI = 4 * PI;
|
||||
const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x));
|
||||
|
||||
export interface AtmosphereOptions {
|
||||
/** Sun elevation above the horizon in degrees (Vienna's, `vienna.ts`). */
|
||||
sunElevationDeg: number;
|
||||
/** Multiplier on both Mie coefficients: haze. 1 is the reference air. */
|
||||
mieScale?: number;
|
||||
/** Observer height above the ground in km. Vienna's lowest point is 0.151. */
|
||||
observerKm?: number;
|
||||
}
|
||||
|
||||
/** Sizes of the three tables and the march step counts. The sum is the bake's cost, see `bakeCostSteps`. */
|
||||
export const LUT = {
|
||||
transmittance: { w: 64, h: 24, steps: 32 },
|
||||
multiScatter: { w: 16, h: 16, dirs: 6, steps: 14 },
|
||||
skyView: { w: 72, h: 40, steps: 24 },
|
||||
} as const;
|
||||
|
||||
const NO_WEATHER = 1;
|
||||
|
||||
interface Medium {
|
||||
scatter: Vec3;
|
||||
extinction: Vec3;
|
||||
/** Mie scattering and Rayleigh scattering separately, for the phase functions. */
|
||||
mie: number;
|
||||
ray: Vec3;
|
||||
}
|
||||
|
||||
function sampleMedium(altKm: number, mieScale: number): Medium {
|
||||
const dRay = Math.exp(-altKm / RAYLEIGH_SCALE_KM);
|
||||
const dMie = Math.exp(-altKm / MIE_SCALE_KM) * mieScale;
|
||||
const tent =
|
||||
altKm < OZONE_PEAK_KM
|
||||
? altKm / OZONE_HALF_WIDTH_KM - OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1
|
||||
: -altKm / OZONE_HALF_WIDTH_KM + OZONE_PEAK_KM / OZONE_HALF_WIDTH_KM + 1;
|
||||
const dOzone = clamp(tent, 0, 1);
|
||||
const ray: Vec3 = [
|
||||
dRay * RAYLEIGH_SCATTER[0],
|
||||
dRay * RAYLEIGH_SCATTER[1],
|
||||
dRay * RAYLEIGH_SCATTER[2],
|
||||
];
|
||||
const mie = dMie * MIE_SCATTER;
|
||||
const ext = dMie * MIE_EXTINCT;
|
||||
return {
|
||||
ray,
|
||||
mie,
|
||||
scatter: [ray[0] + mie, ray[1] + mie, ray[2] + mie],
|
||||
extinction: [
|
||||
ray[0] + ext + dOzone * OZONE_ABSORB[0],
|
||||
ray[1] + ext + dOzone * OZONE_ABSORB[1],
|
||||
ray[2] + ext + dOzone * OZONE_ABSORB[2],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Nearest positive hit of a unit-direction ray with a sphere at the origin: its distance, or -1. */
|
||||
function raySphere(
|
||||
ox: number,
|
||||
oy: number,
|
||||
oz: number,
|
||||
dx: number,
|
||||
dy: number,
|
||||
dz: number,
|
||||
r: number
|
||||
): number {
|
||||
const b = ox * dx + oy * dy + oz * dz;
|
||||
const c = ox * ox + oy * oy + oz * oz - r * r;
|
||||
if (c > 0 && b > 0) return -1;
|
||||
const disc = b * b - c;
|
||||
if (disc < 0) return -1;
|
||||
const s = Math.sqrt(disc);
|
||||
const t0 = -b - s;
|
||||
return t0 > 0 ? t0 : -b + s > 0 ? -b + s : -1;
|
||||
}
|
||||
|
||||
const rayleighPhase = (cosT: number): number => (3 / (16 * PI)) * (1 + cosT * cosT);
|
||||
/** Henyey-Greenstein, Hillaire's `hgPhase` with the view direction flipped (his WorldDir is an "in" direction). */
|
||||
const hgPhase = (g: number, cosT: number): number => {
|
||||
const denom = 1 + g * g + 2 * g * cosT;
|
||||
return (1 - g * g) / (FOUR_PI * denom * Math.sqrt(denom));
|
||||
};
|
||||
|
||||
/** A 2D table with `c` channels, sampled bilinearly with clamped edges. */
|
||||
export interface Lut2 {
|
||||
w: number;
|
||||
h: number;
|
||||
c: number;
|
||||
data: Float32Array;
|
||||
}
|
||||
const makeLut = (w: number, h: number, c: number): Lut2 => ({
|
||||
w,
|
||||
h,
|
||||
c,
|
||||
data: new Float32Array(w * h * c),
|
||||
});
|
||||
|
||||
/** Bilinear at texel-space coordinates (0..w-1, 0..h-1), written into `out`. */
|
||||
function sampleTexel(l: Lut2, x: number, y: number, out: number[] | Float32Array): void {
|
||||
const fx = clamp(x, 0, l.w - 1);
|
||||
const fy = clamp(y, 0, l.h - 1);
|
||||
const x0 = Math.floor(fx);
|
||||
const y0 = Math.floor(fy);
|
||||
const x1 = Math.min(l.w - 1, x0 + 1);
|
||||
const y1 = Math.min(l.h - 1, y0 + 1);
|
||||
const tx = fx - x0;
|
||||
const ty = fy - y0;
|
||||
for (let k = 0; k < l.c; k++) {
|
||||
const a = l.data[(y0 * l.w + x0) * l.c + k];
|
||||
const b = l.data[(y0 * l.w + x1) * l.c + k];
|
||||
const c = l.data[(y1 * l.w + x0) * l.c + k];
|
||||
const d = l.data[(y1 * l.w + x1) * l.c + k];
|
||||
out[k] = (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
|
||||
}
|
||||
}
|
||||
|
||||
// Transmittance LUT, Bruneton's parameterisation (r, mu) -> (x_mu, x_r) over rho = horizon distance of height r.
|
||||
const H_TOP = Math.sqrt(TOP_KM * TOP_KM - PLANET_KM * PLANET_KM);
|
||||
|
||||
function transmittanceUv(r: number, mu: number): [number, number] {
|
||||
const rho = Math.sqrt(Math.max(0, r * r - PLANET_KM * PLANET_KM));
|
||||
const disc = r * r * (mu * mu - 1) + TOP_KM * TOP_KM;
|
||||
const d = Math.max(0, -r * mu + Math.sqrt(Math.max(0, disc)));
|
||||
const dMin = TOP_KM - r;
|
||||
const dMax = rho + H_TOP;
|
||||
return [(d - dMin) / (dMax - dMin), rho / H_TOP];
|
||||
}
|
||||
function transmittanceParams(xMu: number, xR: number): [number, number] {
|
||||
const rho = H_TOP * xR;
|
||||
const r = Math.sqrt(rho * rho + PLANET_KM * PLANET_KM);
|
||||
const dMin = TOP_KM - r;
|
||||
const dMax = rho + H_TOP;
|
||||
const d = dMin + xMu * (dMax - dMin);
|
||||
const mu = d === 0 ? 1 : (H_TOP * H_TOP - rho * rho - d * d) / (2 * r * d);
|
||||
return [r, clamp(mu, -1, 1)];
|
||||
}
|
||||
|
||||
const toSub = (u: number, res: number): number => (u + 0.5 / res) * (res / (res + 1));
|
||||
const fromSub = (u: number, res: number): number => (u - 0.5 / res) * (res / (res - 1));
|
||||
|
||||
export interface Atmosphere {
|
||||
readonly transmittance: Lut2;
|
||||
readonly multiScatter: Lut2;
|
||||
readonly skyView: Lut2;
|
||||
readonly sunElevationDeg: number;
|
||||
readonly mieScale: number;
|
||||
readonly observerKm: number;
|
||||
/** Cosine of the sun's zenith angle. */
|
||||
readonly sunCosZenith: number;
|
||||
/** Zenith angle (radians) of the astronomical horizon at the observer: a little past 90 degrees. */
|
||||
readonly horizonZenith: number;
|
||||
/** Transmittance from the observer along a direction `mu` (cos of its zenith angle), to space, per channel. */
|
||||
transmittanceToSpace(mu: number, out: number[]): void;
|
||||
/** Sky radiance per unit of sun irradiance for view zenith cos and cos of the azimuth from the sun; linear RGB. */
|
||||
radiance(viewCosZenith: number, cosFromSun: number, out: number[]): void;
|
||||
/** Light the sky and sun give a horizontal surface at the observer: direct (per unit sun irradiance) and ambient. */
|
||||
irradiance: { direct: Vec3; ambient: Vec3 };
|
||||
}
|
||||
|
||||
interface March {
|
||||
L: [number, number, number];
|
||||
transmittance: [number, number, number];
|
||||
multiScatAs1: [number, number, number];
|
||||
opticalDepth: [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hillaire's `IntegrateScatteredLuminance`. Ray from (ox,oy,oz) along unit d, sun unit s; `miePhase` picks the real phase
|
||||
* functions (the sky) or the isotropic one (the multiple-scattering bake); `ms` the multiple-scattering LUT (absent while it is being built).
|
||||
*/
|
||||
function march(
|
||||
ox: number,
|
||||
oy: number,
|
||||
oz: number,
|
||||
dx: number,
|
||||
dy: number,
|
||||
dz: number,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sz: number,
|
||||
steps: number,
|
||||
ground: boolean,
|
||||
miePhase: boolean,
|
||||
variable: boolean,
|
||||
mieScale: number,
|
||||
trLut: Lut2,
|
||||
ms: Lut2 | undefined,
|
||||
out: March
|
||||
): void {
|
||||
out.L = [0, 0, 0];
|
||||
out.transmittance = [1, 1, 1];
|
||||
out.multiScatAs1 = [0, 0, 0];
|
||||
out.opticalDepth = [0, 0, 0];
|
||||
const tBottom = raySphere(ox, oy, oz, dx, dy, dz, PLANET_KM);
|
||||
const tTop = raySphere(ox, oy, oz, dx, dy, dz, TOP_KM);
|
||||
let tMax: number;
|
||||
if (tBottom < 0) {
|
||||
if (tTop < 0) return;
|
||||
tMax = tTop;
|
||||
} else {
|
||||
tMax = tTop > 0 ? Math.min(tTop, tBottom) : tBottom;
|
||||
}
|
||||
const cosT = dx * sx + dy * sy + dz * sz;
|
||||
const phaseMie = miePhase ? hgPhase(MIE_G, -cosT) : 1 / FOUR_PI;
|
||||
const phaseRay = miePhase ? rayleighPhase(cosT) : 1 / FOUR_PI;
|
||||
const tmp = [0, 0];
|
||||
const msv = [0, 0, 0];
|
||||
const trv = [0, 0, 0];
|
||||
let tPrev = 0;
|
||||
for (let s = 0; s < steps; s++) {
|
||||
let t0;
|
||||
let t1;
|
||||
if (variable) {
|
||||
t0 = tMax * ((s / steps) * (s / steps));
|
||||
t1 = tMax * (((s + 1) / steps) * ((s + 1) / steps));
|
||||
} else {
|
||||
t0 = (tMax * s) / steps;
|
||||
t1 = (tMax * (s + 1)) / steps;
|
||||
}
|
||||
const t = t0 + (t1 - t0) * 0.3;
|
||||
const dt = t1 - t0;
|
||||
tPrev = t;
|
||||
const px = ox + t * dx;
|
||||
const py = oy + t * dy;
|
||||
const pz = oz + t * dz;
|
||||
const r = Math.sqrt(px * px + py * py + pz * pz);
|
||||
const med = sampleMedium(r - PLANET_KM, mieScale);
|
||||
const sunCos = (px * sx + py * sy + pz * sz) / r;
|
||||
const [u, v] = transmittanceUv(r, sunCos);
|
||||
sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv);
|
||||
// Planet shadow: the sun's ray from here must not meet the ground.
|
||||
const shadow = raySphere(px, py, pz, sx, sy, sz, PLANET_KM) >= 0 ? 0 : 1;
|
||||
if (ms) {
|
||||
const uu = toSub(sunCos * 0.5 + 0.5, ms.w);
|
||||
const vv = toSub(clamp((r - PLANET_KM) / (TOP_KM - PLANET_KM), 0, 1), ms.h);
|
||||
sampleTexel(ms, uu * ms.w - 0.5, vv * ms.h - 0.5, msv);
|
||||
} else {
|
||||
msv[0] = 0;
|
||||
msv[1] = 0;
|
||||
msv[2] = 0;
|
||||
}
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const ext = med.extinction[k];
|
||||
const od = ext * dt;
|
||||
const sampleT = Math.exp(-od);
|
||||
out.opticalDepth[k] += od;
|
||||
const phaseScatter = med.mie * phaseMie + med.ray[k] * phaseRay;
|
||||
const S = shadow * trv[k] * phaseScatter + msv[k] * med.scatter[k];
|
||||
const inv = ext > 0 ? (1 - sampleT) / ext : dt;
|
||||
const th = out.transmittance[k];
|
||||
out.L[k] += th * S * inv;
|
||||
out.multiScatAs1[k] += th * med.scatter[k] * inv;
|
||||
out.transmittance[k] = th * sampleT;
|
||||
}
|
||||
// Isotropic second-order inputs for the multiple-scattering bake use the same quantities; they are
|
||||
// accumulated by the caller through `out.L` with `miePhase` false (uniform phase) so nothing more is kept here.
|
||||
}
|
||||
void tPrev;
|
||||
void tmp;
|
||||
if (ground && tBottom > 0 && tMax === tBottom) {
|
||||
const px = ox + tBottom * dx;
|
||||
const py = oy + tBottom * dy;
|
||||
const pz = oz + tBottom * dz;
|
||||
const r = Math.sqrt(px * px + py * py + pz * pz);
|
||||
const sunCos = (px * sx + py * sy + pz * sz) / r;
|
||||
const [u, v] = transmittanceUv(r, sunCos);
|
||||
sampleTexel(trLut, u * (trLut.w - 1), v * (trLut.h - 1), trv);
|
||||
const ndotl = Math.max(0, sunCos);
|
||||
for (let k = 0; k < 3; k++) {
|
||||
out.L[k] += (trv[k] * out.transmittance[k] * ndotl * GROUND_ALBEDO) / PI;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bakeTransmittance(mieScale: number): Lut2 {
|
||||
const { w, h, steps } = LUT.transmittance;
|
||||
const lut = makeLut(w, h, 3);
|
||||
const noLut = makeLut(1, 1, 3); // not read: `ms` is absent and the sun terms are discarded below
|
||||
noLut.data.fill(1);
|
||||
for (let j = 0; j < h; j++) {
|
||||
for (let i = 0; i < w; i++) {
|
||||
const [r, mu] = transmittanceParams(i / (w - 1), j / (h - 1));
|
||||
const sinT = Math.sqrt(1 - mu * mu);
|
||||
// Optical depth only: a straight integration of the extinction to space.
|
||||
const tTop = raySphere(0, 0, r, 0, sinT, mu, TOP_KM);
|
||||
const tBottom = raySphere(0, 0, r, 0, sinT, mu, PLANET_KM);
|
||||
const tMax = tBottom > 0 ? Math.min(tTop, tBottom) : tTop;
|
||||
const od = [0, 0, 0];
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const t = (tMax * (s + 0.3)) / steps;
|
||||
const px = t * sinT;
|
||||
const pz = r + t * mu;
|
||||
const med = sampleMedium(Math.sqrt(px * px + pz * pz) - PLANET_KM, mieScale);
|
||||
const dt = tMax / steps;
|
||||
for (let k = 0; k < 3; k++) od[k] += med.extinction[k] * dt;
|
||||
}
|
||||
const o = (j * w + i) * 3;
|
||||
for (let k = 0; k < 3; k++) lut.data[o + k] = Math.exp(-od[k]);
|
||||
}
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 {
|
||||
const { w, h, dirs, steps } = LUT.multiScatter;
|
||||
const lut = makeLut(w, h, 3);
|
||||
const res: March = {
|
||||
L: [0, 0, 0],
|
||||
transmittance: [1, 1, 1],
|
||||
multiScatAs1: [0, 0, 0],
|
||||
opticalDepth: [0, 0, 0],
|
||||
};
|
||||
const count = dirs * dirs;
|
||||
for (let j = 0; j < h; j++) {
|
||||
for (let i = 0; i < w; i++) {
|
||||
const u = fromSub((i + 0.5) / w, w);
|
||||
const v = fromSub((j + 0.5) / h, h);
|
||||
const cosSun = clamp(u * 2 - 1, -1, 1);
|
||||
const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun));
|
||||
const sz = cosSun;
|
||||
// Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate.
|
||||
const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM);
|
||||
const sumMs = [0, 0, 0];
|
||||
const sumL = [0, 0, 0];
|
||||
for (let a = 0; a < dirs; a++) {
|
||||
for (let b = 0; b < dirs; b++) {
|
||||
const theta = (2 * PI * (a + 0.5)) / dirs;
|
||||
const cosPhi = 1 - (2 * (b + 0.5)) / dirs;
|
||||
const sinPhi = Math.sqrt(1 - cosPhi * cosPhi);
|
||||
march(
|
||||
0,
|
||||
0,
|
||||
height,
|
||||
Math.cos(theta) * sinPhi,
|
||||
Math.sin(theta) * sinPhi,
|
||||
cosPhi,
|
||||
sx,
|
||||
0,
|
||||
sz,
|
||||
steps,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
mieScale,
|
||||
trLut,
|
||||
undefined,
|
||||
res
|
||||
);
|
||||
for (let k = 0; k < 3; k++) {
|
||||
sumMs[k] += (res.multiScatAs1[k] * FOUR_PI) / count;
|
||||
sumL[k] += (res.L[k] * FOUR_PI) / count;
|
||||
}
|
||||
}
|
||||
}
|
||||
const o = (j * w + i) * 3;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const fms = sumMs[k] / FOUR_PI; // equation 7
|
||||
const l2 = sumL[k] / FOUR_PI; // equation 5
|
||||
lut.data[o + k] = l2 / (1 - fms); // equation 10: the geometric series of all orders
|
||||
}
|
||||
}
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle
|
||||
* from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`.
|
||||
*/
|
||||
function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] {
|
||||
const zen = Math.acos(clamp(viewCos, -1, 1));
|
||||
let coord = 1 - clamp(zen / horizonZenith, 0, 1);
|
||||
coord = Math.sqrt(coord);
|
||||
const y = 1 - coord; // 0 at the zenith, 1 at the horizon
|
||||
const x = Math.sqrt(clamp(-cosFromSun * 0.5 + 0.5, 0, 1));
|
||||
return [x, y];
|
||||
}
|
||||
function skyViewUvToParams(x: number, y: number, horizonZenith: number): [number, number] {
|
||||
let coord = 1 - y;
|
||||
coord *= coord;
|
||||
coord = 1 - coord;
|
||||
const viewCos = Math.cos(horizonZenith * coord);
|
||||
const c = x * x;
|
||||
return [viewCos, -(c * 2 - 1)];
|
||||
}
|
||||
|
||||
function bakeSkyView(
|
||||
sunCos: number,
|
||||
observerKm: number,
|
||||
mieScale: number,
|
||||
trLut: Lut2,
|
||||
ms: Lut2,
|
||||
horizonZenith: number
|
||||
): Lut2 {
|
||||
const { w, h, steps } = LUT.skyView;
|
||||
const lut = makeLut(w, h, 3);
|
||||
const res: March = {
|
||||
L: [0, 0, 0],
|
||||
transmittance: [1, 1, 1],
|
||||
multiScatAs1: [0, 0, 0],
|
||||
opticalDepth: [0, 0, 0],
|
||||
};
|
||||
const sx = Math.sqrt(Math.max(0, 1 - sunCos * sunCos));
|
||||
const height = PLANET_KM + observerKm;
|
||||
for (let j = 0; j < h; j++) {
|
||||
for (let i = 0; i < w; i++) {
|
||||
const [viewCos, lightViewCos] = skyViewUvToParams(i / (w - 1), j / (h - 1), horizonZenith);
|
||||
const sinZ = Math.sqrt(1 - viewCos * viewCos);
|
||||
march(
|
||||
0,
|
||||
0,
|
||||
height,
|
||||
sinZ * lightViewCos,
|
||||
sinZ * Math.sqrt(Math.max(0, 1 - lightViewCos * lightViewCos)),
|
||||
viewCos,
|
||||
sx,
|
||||
0,
|
||||
sunCos,
|
||||
steps,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
mieScale,
|
||||
trLut,
|
||||
ms,
|
||||
res
|
||||
);
|
||||
const o = (j * w + i) * 3;
|
||||
lut.data[o] = res.L[0];
|
||||
lut.data[o + 1] = res.L[1];
|
||||
lut.data[o + 2] = res.L[2];
|
||||
}
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
/** Number of ray-march steps the three bakes take: the CPU cost in the unit the tests budget. */
|
||||
export const bakeCostSteps = (): number => {
|
||||
const t = LUT.transmittance;
|
||||
const m = LUT.multiScatter;
|
||||
const s = LUT.skyView;
|
||||
return t.w * t.h * t.steps + m.w * m.h * m.dirs * m.dirs * m.steps + s.w * s.h * s.steps;
|
||||
};
|
||||
|
||||
export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere {
|
||||
const mieScale = opts.mieScale ?? NO_WEATHER;
|
||||
const observerKm = opts.observerKm ?? 0.151;
|
||||
const sunCos = Math.cos(((90 - opts.sunElevationDeg) * PI) / 180);
|
||||
const height = PLANET_KM + observerKm;
|
||||
const beta = Math.acos(Math.sqrt(height * height - PLANET_KM * PLANET_KM) / height);
|
||||
const horizonZenith = PI - beta;
|
||||
const transmittance = bakeTransmittance(mieScale);
|
||||
const multiScatter = bakeMultiScatter(mieScale, transmittance);
|
||||
const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith);
|
||||
const tmp = [0, 0, 0];
|
||||
const trv = [0, 0, 0];
|
||||
const atm: Atmosphere = {
|
||||
transmittance,
|
||||
multiScatter,
|
||||
skyView,
|
||||
sunElevationDeg: opts.sunElevationDeg,
|
||||
mieScale,
|
||||
observerKm,
|
||||
sunCosZenith: sunCos,
|
||||
horizonZenith,
|
||||
transmittanceToSpace(mu, out) {
|
||||
const [u, v] = transmittanceUv(height, mu);
|
||||
sampleTexel(transmittance, u * (transmittance.w - 1), v * (transmittance.h - 1), trv);
|
||||
out[0] = trv[0];
|
||||
out[1] = trv[1];
|
||||
out[2] = trv[2];
|
||||
},
|
||||
radiance(viewCos, cosFromSun, out) {
|
||||
const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith);
|
||||
sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp);
|
||||
out[0] = tmp[0];
|
||||
out[1] = tmp[1];
|
||||
out[2] = tmp[2];
|
||||
},
|
||||
irradiance: { direct: [0, 0, 0], ambient: [0, 0, 0] },
|
||||
};
|
||||
// Direct: the sun's transmittance times the cosine; ambient: the sky's radiance averaged over the upper hemisphere (cosine weighted).
|
||||
const direct: number[] = [0, 0, 0];
|
||||
if (opts.sunElevationDeg > -1) {
|
||||
atm.transmittanceToSpace(Math.max(sunCos, 0), direct);
|
||||
const c = Math.max(0, sunCos);
|
||||
direct[0] *= c;
|
||||
direct[1] *= c;
|
||||
direct[2] *= c;
|
||||
}
|
||||
const amb = [0, 0, 0];
|
||||
const out = [0, 0, 0];
|
||||
const N = 12;
|
||||
const M = 6;
|
||||
let wsum = 0;
|
||||
for (let a = 0; a < N; a++) {
|
||||
for (let b = 0; b < M; b++) {
|
||||
const az = (PI * (a + 0.5)) / N; // 0..pi from the sun: the sky is symmetric
|
||||
const zen = (0.5 * PI * (b + 0.5)) / M;
|
||||
const wgt = Math.cos(zen) * Math.sin(zen);
|
||||
atm.radiance(Math.cos(zen), Math.cos(az), out);
|
||||
for (let k = 0; k < 3; k++) amb[k] += out[k] * wgt;
|
||||
wsum += wgt;
|
||||
}
|
||||
}
|
||||
atm.irradiance.direct = [direct[0], direct[1], direct[2]];
|
||||
atm.irradiance.ambient = [(amb[0] / wsum) * PI, (amb[1] / wsum) * PI, (amb[2] / wsum) * PI];
|
||||
return atm;
|
||||
}
|
||||
95
src/app/features/scene/ridge/physicalSky.ts
Normal file
95
src/app/features/scene/ridge/physicalSky.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// The ridge scene's sky: Vienna's sun on the real clock, the atmosphere baked from it, a camera, and the display that makes it
|
||||
// pixels. `makePhysicalSky` does the one expensive thing (the LUT bake, tens of ms) and fits the display from a fixed 16:9
|
||||
// grid, so resizing the window repaints the same sky. `paintSky` is per-texel lookups only.
|
||||
import type { FixedSkyState } from '../skyBand';
|
||||
import { bakeAtmosphere } from './earthAtmosphere';
|
||||
import type { Atmosphere } from './earthAtmosphere';
|
||||
import { domeSize, HORIZON_AT, renderDome, viewFor } from './skyDome';
|
||||
import type { Dome, DomeView } from './skyDome';
|
||||
import type { Srgb } from './skyColour';
|
||||
import { display8, fitDisplay } from './toneMap';
|
||||
import type { Display, Fit } from './toneMap';
|
||||
import { sunForState, VIENNA } from './vienna';
|
||||
import type { SunSample } from './vienna';
|
||||
import { weatherOf } from './weather';
|
||||
import type { Weather } from './weather';
|
||||
|
||||
export interface PhysicalSky {
|
||||
state: FixedSkyState;
|
||||
sun: SunSample;
|
||||
weather: Weather;
|
||||
atm: Atmosphere;
|
||||
/** The display fitted to this sky's budget. */
|
||||
fit: Fit;
|
||||
/** The sky at the top, the middle and the horizon of the image (mean of each row), for the layers' haze and the stars' cover. */
|
||||
stops: { top: Srgb; mid: Srgb; horizon: Srgb };
|
||||
/** How long the bake took, ms. */
|
||||
bakeMs: number;
|
||||
}
|
||||
|
||||
const CANON = { w: 64, h: 36, aspect: 16 / 9 };
|
||||
|
||||
const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
export function rowMean(dome: Dome, display: Display, row: number): Srgb {
|
||||
const out = [0, 0, 0];
|
||||
const acc = [0, 0, 0];
|
||||
const j = Math.min(dome.h - 1, Math.max(0, row));
|
||||
for (let i = 0; i < dome.w; i += 1) {
|
||||
const o = (j * dome.w + i) * 3;
|
||||
display8(dome.hdr[o], dome.hdr[o + 1], dome.hdr[o + 2], display, out);
|
||||
acc[0] += out[0];
|
||||
acc[1] += out[1];
|
||||
acc[2] += out[2];
|
||||
}
|
||||
return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)];
|
||||
}
|
||||
|
||||
export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky {
|
||||
const t0 = clock();
|
||||
const sun = sunForState(state, now);
|
||||
const weather = weatherOf(weatherValue);
|
||||
const atm = bakeAtmosphere({
|
||||
sunElevationDeg: sun.elevation,
|
||||
mieScale: weather.mieScale,
|
||||
observerKm: VIENNA.altitudeKm,
|
||||
});
|
||||
const dome = renderDome(atm, sun, viewFor(sun, CANON.aspect), CANON.w, CANON.h);
|
||||
const fit = fitDisplay(state, dome.hdr, dome.meanLuma);
|
||||
const at = (f: number) => rowMean(dome, fit.display, Math.round(f * CANON.h));
|
||||
return {
|
||||
state,
|
||||
sun,
|
||||
weather,
|
||||
atm,
|
||||
fit,
|
||||
stops: { top: at(0.02), mid: at(0.5), horizon: at(HORIZON_AT) },
|
||||
bakeMs: clock() - t0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkyPixels {
|
||||
w: number;
|
||||
h: number;
|
||||
/** RGBA, 8 bit. */
|
||||
data: Uint8ClampedArray;
|
||||
dome: Dome;
|
||||
view: DomeView;
|
||||
}
|
||||
|
||||
/** The sky for a viewport, as a small RGBA grid the host canvas upscales smoothly. */
|
||||
export function paintSky(p: PhysicalSky, cssW: number, cssH: number): SkyPixels {
|
||||
const { w, h } = domeSize(cssW, cssH);
|
||||
const view = viewFor(p.sun, cssW / cssH);
|
||||
const dome = renderDome(p.atm, p.sun, view, w, h);
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
const out = [0, 0, 0];
|
||||
for (let i = 0; i < w * h; i += 1) {
|
||||
display8(dome.hdr[i * 3], dome.hdr[i * 3 + 1], dome.hdr[i * 3 + 2], p.fit.display, out);
|
||||
data[i * 4] = out[0];
|
||||
data[i * 4 + 1] = out[1];
|
||||
data[i * 4 + 2] = out[2];
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
return { w, h, data, dome, view };
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake';
|
||||
import { RIDGE_NIGHT_BAND, SKY_BAND } from '../skyBand';
|
||||
import { SKY_BAND } from '../skyBand';
|
||||
import { ridgeSkyFor } from '../skyForState';
|
||||
import { LACUNARITY, LAYERS, profile } from './ridgeWorld';
|
||||
import { skyColours, SkyColours } from './skyColour';
|
||||
|
|
@ -353,9 +353,8 @@ describe('the state band: the theme ink clears 4.5:1 on a ridge as on bare sky',
|
|||
}
|
||||
});
|
||||
|
||||
it("night: no ridge pixel is lighter than the ceiling, the state's or the ridge's own", () => {
|
||||
it('night: no ridge pixel is lighter than the ceiling', () => {
|
||||
expect(range(SKY_BAND.night).hi).toBeLessThanOrEqual(SKY_BAND.night.ceiling);
|
||||
expect(range(RIDGE_NIGHT_BAND).hi).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling);
|
||||
});
|
||||
|
||||
it('is the identity with no band, so nothing else changes', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
// and bakes the result.
|
||||
|
||||
import type { SkyBand } from '../skyBand';
|
||||
import type { PhysicalSky } from './physicalSky';
|
||||
|
||||
export type Srgb = readonly [number, number, number];
|
||||
|
||||
|
|
@ -21,6 +22,8 @@ export interface SkyColours {
|
|||
sunAzimuth?: number;
|
||||
/** The state's luminance band (skyBand.ts): the layers are shaped into it as the sky is, so the theme ink clears 4.5:1 on a ridge as on bare sky. Omit for none. */
|
||||
band?: SkyBand;
|
||||
/** The physical sky this palette was read from; when present the host canvas is painted from it, not from the three stops. */
|
||||
physical?: PhysicalSky;
|
||||
}
|
||||
|
||||
export function skyColours(_now: Date = new Date()): SkyColours {
|
||||
|
|
|
|||
99
src/app/features/scene/ridge/skyDome.ts
Normal file
99
src/app/features/scene/ridge/skyDome.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// The sky as a camera sees it: for each texel of a small grid, the view ray, its radiance out of the atmosphere's sky-view
|
||||
// LUT (`earthAtmosphere.ts`), the sun's disc, and nothing else; one lookup per texel, no march. The result is linear HDR
|
||||
// radiance per unit of sun irradiance (a `Float32Array`); `toneMap.ts` makes it pixels.
|
||||
import type { Atmosphere } from './earthAtmosphere';
|
||||
import type { SunSample } from './vienna';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
/** The sun's angular radius in radians (Hillaire's/Bruneton's `sun_angular_radius`). */
|
||||
export const SUN_RADIUS = 0.004675;
|
||||
/** Vertical field of view of the camera, degrees, and where the horizon sits down the image: the ridge scene's sky stops. */
|
||||
export const VFOV_DEG = 50;
|
||||
export const HORIZON_AT = 0.78;
|
||||
|
||||
export interface DomeView {
|
||||
/** Compass azimuth the camera faces, degrees clockwise from north. */
|
||||
azimuthDeg: number;
|
||||
/** Width over height of the viewport. */
|
||||
aspect: number;
|
||||
}
|
||||
|
||||
/** Where the camera looks: toward the sun's side while it is up, so the sun sits right of centre; south in the dark. */
|
||||
export const viewFor = (sun: SunSample, aspect: number): DomeView => ({
|
||||
azimuthDeg: sun.elevation > -6 ? sun.azimuth - 24 : 180,
|
||||
aspect,
|
||||
});
|
||||
|
||||
export interface Dome {
|
||||
w: number;
|
||||
h: number;
|
||||
/** Linear radiance, 3 floats per texel, row 0 at the top. */
|
||||
hdr: Float32Array;
|
||||
/** Mean over the grid of the log of the radiance luminance, exponentiated: what auto-exposure reads. */
|
||||
meanLuma: number;
|
||||
}
|
||||
|
||||
/** Grid for a viewport: about 8 CSS px per texel, capped. */
|
||||
export const domeSize = (cssW: number, cssH: number): { w: number; h: number } => ({
|
||||
w: Math.min(256, Math.max(8, Math.ceil(cssW / 8))),
|
||||
h: Math.min(160, Math.max(8, Math.ceil(cssH / 8))),
|
||||
});
|
||||
|
||||
/**
|
||||
* Render the HDR sky. Rays below the horizon take the horizon's radiance (the ridges cover them). `sunVisible` false leaves the
|
||||
* disc out (the glare it would add is still in the LUT); the disc is the transmittance-attenuated sun, its coverage of each
|
||||
* texel computed from the angular distance, not a hard edge.
|
||||
*/
|
||||
export function renderDome(atm: Atmosphere, sun: SunSample, view: DomeView, w: number, h: number): Dome {
|
||||
const hdr = new Float32Array(w * h * 3);
|
||||
const tanHalf = Math.tan((VFOV_DEG * RAD) / 2);
|
||||
const fa = view.azimuthDeg * RAD;
|
||||
const fx = Math.sin(fa);
|
||||
const fy = Math.cos(fa);
|
||||
const rx = Math.cos(fa);
|
||||
const ry = -Math.sin(fa);
|
||||
const sunAz = sun.azimuth * RAD;
|
||||
const sunEl = sun.elevation * RAD;
|
||||
const sx = Math.cos(sunEl) * Math.sin(sunAz);
|
||||
const sy = Math.cos(sunEl) * Math.cos(sunAz);
|
||||
const sz = Math.sin(sunEl);
|
||||
const trans = [0, 0, 0];
|
||||
atm.transmittanceToSpace(Math.max(sz, 0), trans);
|
||||
// The disc's radiance for unit irradiance: 1 / (pi r^2), through the air to the sun.
|
||||
const discRadiance = 1 / (Math.PI * SUN_RADIUS * SUN_RADIUS);
|
||||
const texelAngle = (2 * tanHalf) / h; // radians per texel, small-angle
|
||||
const rgb = [0, 0, 0];
|
||||
let logSum = 0;
|
||||
for (let j = 0; j < h; j += 1) {
|
||||
const v = (HORIZON_AT - (j + 0.5) / h) * 2 * tanHalf;
|
||||
for (let i = 0; i < w; i += 1) {
|
||||
const u = ((i + 0.5) / w - 0.5) * view.aspect * 2 * tanHalf;
|
||||
const dx = fx + rx * u;
|
||||
const dy = fy + ry * u;
|
||||
const dz = v;
|
||||
const n = Math.hypot(dx, dy, dz);
|
||||
const vz = dz / n;
|
||||
const cosFromSun = Math.cos(Math.atan2(dx, dy) - sunAz);
|
||||
atm.radiance(vz, cosFromSun, rgb);
|
||||
let r = rgb[0];
|
||||
let g = rgb[1];
|
||||
let b = rgb[2];
|
||||
if (sz > -0.01 && vz > 0) {
|
||||
const cosSun = (dx * sx + dy * sy + dz * sz) / n;
|
||||
const ang = Math.acos(Math.max(-1, Math.min(1, cosSun)));
|
||||
const cover = Math.max(0, Math.min(1, (SUN_RADIUS - ang) / texelAngle + 0.5));
|
||||
if (cover > 0) {
|
||||
r += discRadiance * cover * trans[0];
|
||||
g += discRadiance * cover * trans[1];
|
||||
b += discRadiance * cover * trans[2];
|
||||
}
|
||||
}
|
||||
const o = (j * w + i) * 3;
|
||||
hdr[o] = r;
|
||||
hdr[o + 1] = g;
|
||||
hdr[o + 2] = b;
|
||||
logSum += Math.log(Math.max(1e-9, 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]));
|
||||
}
|
||||
}
|
||||
return { w, h, hdr, meanLuma: Math.exp(logSum / (w * h)) };
|
||||
}
|
||||
155
src/app/features/scene/ridge/toneMap.ts
Normal file
155
src/app/features/scene/ridge/toneMap.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// The one display transform of the ridge scene: linear scene radiance in, 8-bit sRGB out. Exposure, then the ACES fit of
|
||||
// Stephen Hill (BakingLab, "ACES.hlsl": the sRGB-to-AP1 input matrix, the RRT and ODT rational fit, the AP1-to-sRGB output
|
||||
// matrix), then the sRGB encode. A per-pixel function with no state, applied once to the baked sky (CPU: the scene is a 2D
|
||||
// canvas, there is no float render target to fall back from).
|
||||
//
|
||||
// Exposure is chosen per sky state (`exposureFor`): the sky is physical radiance, so brightness is the camera's, not a
|
||||
// shaping of the result. Day and dusk expose to the sky's own luminance (adaptation, as an eye or auto-exposure does) so the
|
||||
// darkest of the sky stays where the state's dark ink needs a backdrop; night is fixed, the physical dark.
|
||||
import type { FixedSkyState } from '../skyBand';
|
||||
|
||||
type Rgb = readonly [number, number, number];
|
||||
|
||||
const IN: readonly Rgb[] = [
|
||||
[0.59719, 0.35458, 0.04823],
|
||||
[0.076, 0.90834, 0.01566],
|
||||
[0.0284, 0.13383, 0.83777],
|
||||
];
|
||||
const OUT: readonly Rgb[] = [
|
||||
[1.60475, -0.53108, -0.07367],
|
||||
[-0.10208, 1.10813, -0.00605],
|
||||
[-0.00327, -0.07276, 1.07602],
|
||||
];
|
||||
const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081);
|
||||
const encode = (l: number): number => {
|
||||
const c = Math.min(1, Math.max(0, l));
|
||||
return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
||||
};
|
||||
|
||||
/** ACES (Hill's fit) of linear radiance `r, g, b` times `exposure`, to linear display RGB 0..1, written into `out`. */
|
||||
export function acesLinear(r: number, g: number, b: number, exposure: number, out: number[]): void {
|
||||
const x = r * exposure;
|
||||
const y = g * exposure;
|
||||
const z = b * exposure;
|
||||
const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z)));
|
||||
for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2]));
|
||||
}
|
||||
|
||||
/** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */
|
||||
export function toneMap8(r: number, g: number, b: number, exposure: number, out: number[]): void {
|
||||
acesLinear(r, g, b, exposure, out);
|
||||
out[0] = Math.round(encode(out[0]));
|
||||
out[1] = Math.round(encode(out[1]));
|
||||
out[2] = Math.round(encode(out[2]));
|
||||
}
|
||||
|
||||
/** WCAG relative luminance of linear display RGB. */
|
||||
export const displayLuma = (c: ArrayLike<number>): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
||||
|
||||
/** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */
|
||||
export interface Budget {
|
||||
/** The darkest texel may not be darker than this (dark ink needs a light backdrop). 0: no floor. */
|
||||
floor: number;
|
||||
/** The lightest texel may not be lighter than this (light ink needs a dark one). 1: no ceiling. */
|
||||
ceiling: number;
|
||||
/** Display luminance the log-mean of the sky is exposed to, before the budget is applied; undefined: a fixed exposure. */
|
||||
key?: number;
|
||||
/** Exposure range, in multiples of scene radiance (per unit sun irradiance). */
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** Display luminance slack for the 8-bit rounding and the smooth upscale of the baked grid. */
|
||||
export const MARGIN = 0.004;
|
||||
|
||||
/**
|
||||
* Day and Dusk wear dark ink (floor: its bound plus margin, `SKY_BAND` day 0.317, dusk 0.4744); Night wears light ink, and its
|
||||
* dimmer foldout headers (L 0.386 on screen, rat-crest-a7c95ce-20261111) need a backdrop of at most 0.047.
|
||||
*/
|
||||
export const BUDGET: Record<FixedSkyState, Budget> = {
|
||||
day: { floor: 0.317 + MARGIN, ceiling: 1, key: 0.45, min: 4, max: 60 },
|
||||
dusk: { floor: 0.4744 + MARGIN, ceiling: 1, key: 0.5, min: 4, max: 400 },
|
||||
night: { floor: 0, ceiling: 0.047 - MARGIN, key: 0.03, min: 8, max: 3000 },
|
||||
};
|
||||
|
||||
/** The display transform of a sky: exposure, then ACES, then a black lift (veiling glare) in display-linear. */
|
||||
export interface Display {
|
||||
exposure: number;
|
||||
/** Added to every channel after the curve, then rescaled so white stays white: out = lift + (1 - lift) * aces. */
|
||||
lift: number;
|
||||
}
|
||||
|
||||
/** The 8-bit sRGB of linear radiance through a display. */
|
||||
export function display8(r: number, g: number, b: number, d: Display, out: number[]): void {
|
||||
acesLinear(r, g, b, d.exposure, out);
|
||||
for (let i = 0; i < 3; i += 1) out[i] = Math.round(encode(d.lift + (1 - d.lift) * out[i]));
|
||||
}
|
||||
|
||||
const decode = (v: number): number => {
|
||||
const c = v / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
/** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */
|
||||
export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number {
|
||||
display8(r, g, b, d, tmp);
|
||||
return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]);
|
||||
}
|
||||
|
||||
export interface Fit {
|
||||
display: Display;
|
||||
/** Darkest and lightest display luminance of the grid under `display`. */
|
||||
lo: number;
|
||||
hi: number;
|
||||
/** The exposure before the budget moved it, and what the budget took (for the numbers a report cites). */
|
||||
keyExposure: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the display of a sky, the one place brightness is decided. `hdr` is the grid's radiance (3 floats a texel) and
|
||||
* `meanLuma` its log-mean luminance. Exposure starts at the state's key (adaptation); a ceiling lowers it until the
|
||||
* lightest texel is inside the budget (bisection: luminance is monotone in exposure); a floor raises the black lift until the
|
||||
* darkest is (luminance is monotone in lift, and linear: the lift needed is solved in one step).
|
||||
*/
|
||||
export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit {
|
||||
const b = BUDGET[state];
|
||||
const keyExposure =
|
||||
b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9)));
|
||||
const n = hdr.length / 3;
|
||||
const stride = Math.max(1, Math.floor(n / 4000));
|
||||
const tmp = [0, 0, 0];
|
||||
const range = (d: Display) => {
|
||||
let lo = 2;
|
||||
let hi = -1;
|
||||
for (let i = 0; i < n; i += stride) {
|
||||
const l = displayLumaOf(hdr[i * 3], hdr[i * 3 + 1], hdr[i * 3 + 2], d, tmp);
|
||||
if (l < lo) lo = l;
|
||||
if (l > hi) hi = l;
|
||||
}
|
||||
return { lo, hi };
|
||||
};
|
||||
let exposure = keyExposure;
|
||||
let r = range({ exposure, lift: 0 });
|
||||
if (r.hi > b.ceiling) {
|
||||
let lo = 0;
|
||||
let hiE = exposure;
|
||||
for (let k = 0; k < 24; k += 1) {
|
||||
const mid = (lo + hiE) / 2;
|
||||
if (range({ exposure: mid, lift: 0 }).hi > b.ceiling) hiE = mid;
|
||||
else lo = mid;
|
||||
}
|
||||
exposure = lo;
|
||||
r = range({ exposure, lift: 0 });
|
||||
}
|
||||
let lift = 0;
|
||||
if (r.lo < b.floor) {
|
||||
// Linear in the displayed linear luminance: L' = lift + (1 - lift) L, but through the 8-bit encode; solve in linear then check once.
|
||||
lift = (b.floor - r.lo) / (1 - r.lo);
|
||||
let probe = range({ exposure, lift });
|
||||
for (let k = 0; k < 6 && probe.lo < b.floor; k += 1) {
|
||||
lift += (b.floor - probe.lo) * 1.05;
|
||||
probe = range({ exposure, lift });
|
||||
}
|
||||
r = probe;
|
||||
}
|
||||
return { display: { exposure, lift }, lo: r.lo, hi: r.hi, keyExposure };
|
||||
}
|
||||
56
src/app/features/scene/ridge/vienna.test.ts
Normal file
56
src/app/features/scene/ridge/vienna.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { solarNoon, STATE_ELEVATION, sunForState, VIENNA, viennaSun } from './vienna';
|
||||
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
describe('the sun over Vienna', () => {
|
||||
it('stands at 90 - lat + declination at solar noon: the solstices and equinox', () => {
|
||||
const june = viennaSun(solarNoon(at('2026-06-21T12:00:00Z')));
|
||||
expect(june.elevation).toBeCloseTo(90 - VIENNA.lat + 23.44, 0);
|
||||
const december = viennaSun(solarNoon(at('2026-12-21T12:00:00Z')));
|
||||
expect(december.elevation).toBeCloseTo(90 - VIENNA.lat - 23.44, 0);
|
||||
const march = viennaSun(solarNoon(at('2026-03-20T12:00:00Z')));
|
||||
expect(march.elevation).toBeCloseTo(90 - VIENNA.lat, 0);
|
||||
});
|
||||
|
||||
it('is due south at solar noon, east in the morning and west in the afternoon', () => {
|
||||
const noon = solarNoon(at('2026-09-23T12:00:00Z'));
|
||||
expect(Math.abs(viennaSun(noon).azimuth - 180)).toBeLessThan(2); // solarNoon is found to 5 minutes, which is 1.25 degrees of azimuth
|
||||
expect(viennaSun(new Date(noon.getTime() - 3 * 3600_000)).azimuth).toBeLessThan(150);
|
||||
expect(viennaSun(new Date(noon.getTime() + 3 * 3600_000)).azimuth).toBeGreaterThan(210);
|
||||
});
|
||||
|
||||
it('solar noon at 16.37 E falls near 10:40 UTC in mid November', () => {
|
||||
// 16.3738 E is 65.5 minutes ahead of Greenwich; the equation of time in mid November is +15 minutes (sun early): 12:00 - 65.5 - 15 = 10:40 UTC.
|
||||
const n = solarNoon(at('2026-11-11T12:00:00Z'));
|
||||
const minutes = n.getUTCHours() * 60 + n.getUTCMinutes();
|
||||
expect(minutes).toBeGreaterThan(10 * 60 + 30);
|
||||
expect(minutes).toBeLessThan(10 * 60 + 50);
|
||||
});
|
||||
|
||||
it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => {
|
||||
const daylight = (day: string) => {
|
||||
let n = 0;
|
||||
for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1;
|
||||
return n;
|
||||
};
|
||||
expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15);
|
||||
expect(Math.abs(daylight('2026-06-21T00:00:00Z') - (16 * 60 + 0))).toBeLessThan(15);
|
||||
});
|
||||
|
||||
it('sunForState: the real sun when it is in the state, else a moment of today that is', () => {
|
||||
const noon = solarNoon(at('2026-11-11T12:00:00Z'));
|
||||
expect(sunForState('day', noon).time).toEqual(noon);
|
||||
const midnight = new Date(noon.getTime() + 12 * 3600_000);
|
||||
for (const state of ['day', 'dusk', 'night'] as const) {
|
||||
for (const now of [noon, midnight, at('2026-11-11T15:30:00Z')]) {
|
||||
const s = sunForState(state, now);
|
||||
const [lo, hi] = STATE_ELEVATION[state];
|
||||
expect(s.elevation).toBeGreaterThanOrEqual(lo - 0.01);
|
||||
expect(s.elevation).toBeLessThanOrEqual(hi + 0.01);
|
||||
}
|
||||
}
|
||||
expect(sunForState('dusk', noon).elevation).toBeCloseTo(0, 1);
|
||||
expect(sunForState('dusk', noon).azimuth).toBeGreaterThan(180); // sets in the west half
|
||||
});
|
||||
});
|
||||
74
src/app/features/scene/ridge/vienna.ts
Normal file
74
src/app/features/scene/ridge/vienna.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// The sun over Vienna (48.2082 N, 16.3738 E, 151 m) at a real moment: one ephemeris, `sunPosition` of src/sky/sun.js (the
|
||||
// low-precision NOAA/Meeus series, a hundredth of a degree for 1900..2100; no refraction), and the choice of a moment for each
|
||||
// fixed sky state. The ridge sky is the physical atmosphere (`earthAtmosphere.ts`) lit from this sun.
|
||||
import { sunPosition } from '../../../../sky/sun.js';
|
||||
import { DAY_FROM } from '../skyState';
|
||||
import type { FixedSkyState } from '../skyBand';
|
||||
|
||||
export const VIENNA = { lat: 48.2082, lon: 16.3738, altitudeKm: 0.151 } as const;
|
||||
|
||||
export interface SunSample {
|
||||
/** Degrees above the horizon, no refraction. */
|
||||
elevation: number;
|
||||
/** Degrees clockwise from north. */
|
||||
azimuth: number;
|
||||
/** The moment this is the sun of. */
|
||||
time: Date;
|
||||
}
|
||||
|
||||
export function viennaSun(time: Date): SunSample {
|
||||
const { elevation, azimuth } = sunPosition(time, VIENNA.lat, VIENNA.lon) as {
|
||||
elevation: number;
|
||||
azimuth: number;
|
||||
};
|
||||
return { elevation, azimuth, time };
|
||||
}
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
/** Sun elevation range of each fixed state: the thresholds `resolveSkyState` uses (day from DAY_FROM, night below -DAY_FROM). */
|
||||
export const STATE_ELEVATION: Record<FixedSkyState, readonly [number, number]> = {
|
||||
day: [DAY_FROM, 90],
|
||||
dusk: [-DAY_FROM, DAY_FROM],
|
||||
night: [-90, -DAY_FROM],
|
||||
};
|
||||
|
||||
const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN;
|
||||
|
||||
/** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */
|
||||
export function solarNoon(t: Date): Date {
|
||||
const d0 = startOfUtcDay(t);
|
||||
let best = d0;
|
||||
let bestEl = -90;
|
||||
for (let m = 0; m < 24 * 60; m += 5) {
|
||||
const el = viennaSun(new Date(d0 + m * MIN)).elevation;
|
||||
if (el > bestEl) {
|
||||
bestEl = el;
|
||||
best = d0 + m * MIN;
|
||||
}
|
||||
}
|
||||
return new Date(best);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sun a state draws. The real sun over Vienna now when it is in the state's range (so 'auto' is the real sky); otherwise the
|
||||
* sun of today at a moment that is: noon for Day, the evening sunset for Dusk, the opposite of noon for Night. A picker that
|
||||
* forces Day at night shows a noon sky, as a person looking at a noon photograph would.
|
||||
*/
|
||||
export function sunForState(state: FixedSkyState, now: Date): SunSample {
|
||||
const real = viennaSun(now);
|
||||
const [lo, hi] = STATE_ELEVATION[state];
|
||||
if (real.elevation >= lo && real.elevation <= hi) return real;
|
||||
const noon = solarNoon(now);
|
||||
if (state === 'day') return viennaSun(noon);
|
||||
if (state === 'night') return viennaSun(new Date(noon.getTime() + 12 * 60 * MIN));
|
||||
// Dusk: bisect the evening crossing of the horizon between noon and the opposite moment.
|
||||
let a = noon.getTime();
|
||||
let b = noon.getTime() + 12 * 60 * MIN;
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const m = (a + b) / 2;
|
||||
if (viennaSun(new Date(m)).elevation > 0) a = m;
|
||||
else b = m;
|
||||
}
|
||||
return viennaSun(new Date((a + b) / 2));
|
||||
}
|
||||
44
src/app/features/scene/ridge/weather.ts
Normal file
44
src/app/features/scene/ridge/weather.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// One weather number, 0..1, and what it does to the sky. No network source yet: the number is a constant, or the page URL's
|
||||
// `?weather=` for a still. 0 clear, about 0.3 fair (a few clouds), 0.55 overcast, 0.75 drizzle and mist, 1 steady rain.
|
||||
// What each part touches: haze multiplies the atmosphere's Mie coefficients before the LUTs are baked (`earthAtmosphere.ts`:
|
||||
// the same air, more aerosol); cloud cover, mist and rain are layers over the baked sky (`clouds.ts`, `Ridge.ts`).
|
||||
|
||||
export const DEFAULT_WEATHER = 0.3;
|
||||
|
||||
export interface Weather {
|
||||
/** The number it was made from. */
|
||||
value: number;
|
||||
/** 0..1 share of the sky the cloud layer covers. */
|
||||
cover: number;
|
||||
/** Multiplier on the Mie coefficients: 1 is the reference air. */
|
||||
mieScale: number;
|
||||
/** 0..1 strength of the mist over the lower ridges. */
|
||||
mist: number;
|
||||
/** 0..1 rain streaks; 0 draws none and builds no layer. */
|
||||
rain: number;
|
||||
}
|
||||
|
||||
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
||||
const smooth = (a: number, b: number, v: number): number => {
|
||||
const t = clamp01((v - a) / (b - a));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
export function weatherOf(value: number): Weather {
|
||||
const w = clamp01(Number.isFinite(value) ? value : DEFAULT_WEATHER);
|
||||
return {
|
||||
value: w,
|
||||
cover: smooth(0.1, 0.7, w),
|
||||
mieScale: 1 + 5 * smooth(0.2, 1, w),
|
||||
mist: smooth(0.45, 0.9, w),
|
||||
rain: smooth(0.68, 0.95, w),
|
||||
};
|
||||
}
|
||||
|
||||
/** The page URL's `?weather=<0..1>`, when it is a number. */
|
||||
export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined {
|
||||
const v = new URLSearchParams(search).get('weather');
|
||||
if (v === null || v.trim() === '') return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? clamp01(n) : undefined;
|
||||
}
|
||||
|
|
@ -73,14 +73,6 @@ export const SKY_BAND: Record<FixedSkyState, SkyBand> = {
|
|||
night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') },
|
||||
};
|
||||
|
||||
/**
|
||||
* The ridge scene's night band, lower than the state's: the navigator's foldout headers wear a dimmer ink than its room rows
|
||||
* (L 0.386 against 0.594 measured on screen, rat-crest-a7c95ce-20261111), so they need a backdrop of at most 0.047 where rows need 0.093,
|
||||
* and the baked sky (0.058 to 0.077) sat above it (headers 3.9 to 4.2:1). 0.04 leaves the 8-bit rounding and the sky's blur
|
||||
* its share. The ridge draws its own sky, so it takes the darker ceiling alone; the terrain keeps SKY_BAND.night.
|
||||
*/
|
||||
export const RIDGE_NIGHT_BAND: SkyBand = { ...SKY_BAND.night, ceiling: 0.04 };
|
||||
|
||||
/**
|
||||
* Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount
|
||||
* that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the
|
||||
|
|
|
|||
|
|
@ -9,19 +9,13 @@ import {
|
|||
TERRAIN_REACH_DEG,
|
||||
ridgeDrawn,
|
||||
ridgeSkyFor,
|
||||
ridgeSkyKey,
|
||||
terrainDrawn,
|
||||
terrainSkyFor,
|
||||
} from './skyForState';
|
||||
import {
|
||||
displayedLuminance,
|
||||
hexToLinear,
|
||||
luma,
|
||||
RIDGE_NIGHT_BAND,
|
||||
shapeToBand,
|
||||
SKY_BAND,
|
||||
} from './skyBand';
|
||||
import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand';
|
||||
import type { FixedSkyState } from './skyBand';
|
||||
import { setSkyClock, skyBackdrop } from './skyState';
|
||||
import { DAY_FROM, setSkyClock, skyBackdrop } from './skyState';
|
||||
import { view } from './terrain/terrainWorld';
|
||||
import { skyThemeId } from './skyTheme';
|
||||
|
||||
|
|
@ -84,60 +78,50 @@ describe('the bands the text ink needs', () => {
|
|||
});
|
||||
|
||||
describe('ridgeSkyFor', () => {
|
||||
it('carries the state band, which the layers are shaped into', () => {
|
||||
for (const state of ['day', 'dusk', 'night'] as const) {
|
||||
expect(ridgeSkyFor(state).band).toEqual(
|
||||
state === 'night' ? RIDGE_NIGHT_BAND : SKY_BAND[state]
|
||||
);
|
||||
it('carries the state band, which the layers are shaped into, and the physical sky of that state', () => {
|
||||
for (const state of STATES) {
|
||||
const sky = ridgeSkyFor(state, 0.3);
|
||||
expect(sky.band).toEqual(SKY_BAND[state]);
|
||||
expect(sky.physical?.state).toBe(state);
|
||||
expect(sky.physical?.stops).toEqual({ top: sky.top, mid: sky.mid, horizon: sky.horizon });
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the gradient of the sky auto resolves to, never nothing', () => {
|
||||
it('draws the sky auto resolves to, never nothing', () => {
|
||||
const key = (s: ReturnType<typeof ridgeSkyFor>) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun];
|
||||
setSkyClock(() => atStandardTime(12, 0));
|
||||
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day'));
|
||||
expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3)));
|
||||
setSkyClock(() => atStandardTime(0, 0));
|
||||
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night'));
|
||||
expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('night', 0.3)));
|
||||
setSkyClock(() => atStandardTime(18, 10));
|
||||
expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk'));
|
||||
expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('dusk', 0.3)));
|
||||
});
|
||||
|
||||
it('draws a lavender, rose and orange twilight for dusk', () => {
|
||||
const d = ridgeSkyFor('dusk');
|
||||
expect(d.ground).toEqual(skyColours().ground);
|
||||
const [t, m, h] = [d.top, d.mid, d.horizon];
|
||||
expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green
|
||||
expect(t[0]).toBeGreaterThan(t[1]);
|
||||
expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue
|
||||
expect(m[2]).toBeGreaterThan(m[1]);
|
||||
expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue
|
||||
expect(h[1]).toBeGreaterThan(h[2]);
|
||||
it("is lit from the real sun over Vienna: a forced state's sun is inside the state, the clear noon sky is blue", () => {
|
||||
const day = ridgeSkyFor('day', 0);
|
||||
expect(day.physical?.sun.elevation).toBeGreaterThanOrEqual(DAY_FROM);
|
||||
expect(day.top[2]).toBeGreaterThan(day.top[0]);
|
||||
const dusk = ridgeSkyFor('dusk', 0);
|
||||
expect(Math.abs(dusk.physical?.sun.elevation ?? 99)).toBeLessThanOrEqual(DAY_FROM);
|
||||
expect(ridgeSkyFor('night', 0).physical?.sun.elevation).toBeLessThanOrEqual(-DAY_FROM);
|
||||
expect(day.ground).toEqual(skyColours().ground);
|
||||
});
|
||||
|
||||
it('draws the baked gradient for day', () => {
|
||||
const day = ridgeSkyFor('day');
|
||||
expect(hex(day.top)).toBe(baked.noonday.gradient[0]);
|
||||
expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]);
|
||||
it('keys a rebuild by state and weather, not by the moving sun', () => {
|
||||
setSkyClock(() => atStandardTime(12, 0));
|
||||
expect(ridgeSkyKey('auto', 0.3)).toBe(ridgeSkyKey('day', 0.3));
|
||||
expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('day', 0.8));
|
||||
expect(ridgeSkyKey('day', 0.3)).not.toBe(ridgeSkyKey('night', 0.3));
|
||||
});
|
||||
|
||||
it("night: draws the baked gradient's hues, lowered to the ridge's night ceiling, which the layers carry", () => {
|
||||
const night = ridgeSkyFor('night');
|
||||
const lum = (c: readonly [number, number, number]) => luma(hexToLinear(hex(c)));
|
||||
expect(night.band).toBe(RIDGE_NIGHT_BAND);
|
||||
expect(RIDGE_NIGHT_BAND.ceiling).toBeLessThan(SKY_BAND.night.ceiling);
|
||||
const BYTE = 0.0005; // the stop is rounded to 8 bits after it is shaped
|
||||
expect(lum(night.top)).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling + BYTE);
|
||||
expect(lum(night.horizon)).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling + BYTE);
|
||||
// the hue survives: the brightest stop is the baked one's brightest channel
|
||||
const b = hexToLinear(baked.midnight.gradient[6]);
|
||||
const d = hexToLinear(hex(night.horizon));
|
||||
expect(d.indexOf(Math.max(...d))).toBe(b.indexOf(Math.max(...b)));
|
||||
});
|
||||
|
||||
it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => {
|
||||
it.each(STATES)('%s: the display is fitted to the budget its text ink needs', (state) => {
|
||||
const { floor, ceiling } = needs(state);
|
||||
const r = ridgeDrawn(state);
|
||||
expect(r.darkest).toBeGreaterThanOrEqual(floor);
|
||||
expect(r.lightest).toBeLessThanOrEqual(ceiling);
|
||||
const fit = ridgeSkyFor(state, 0.3).physical?.fit;
|
||||
expect(fit?.lo).toBeGreaterThanOrEqual(r.darkest - 1e-9);
|
||||
expect(fit?.hi).toBeLessThanOrEqual(r.lightest + 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,69 +8,48 @@ import { chooseSky, skyColour, urlSky } from './terrain/terrainSky';
|
|||
import type { SkyName } from './terrain/terrainSky';
|
||||
import type { SkyColours, Srgb } from './ridge/skyColour';
|
||||
import { skyColours } from './ridge/skyColour';
|
||||
import { resolveSkyState } from './skyState';
|
||||
import { makePhysicalSky } from './ridge/physicalSky';
|
||||
import { BUDGET } from './ridge/toneMap';
|
||||
import { DEFAULT_WEATHER, urlWeather } from './ridge/weather';
|
||||
import { resolveSkyState, skyNow } from './skyState';
|
||||
import type { SkyState } from './skyState';
|
||||
import {
|
||||
displayedLuminance,
|
||||
hexToLinear,
|
||||
RIDGE_NIGHT_BAND,
|
||||
shapeToBand,
|
||||
SKY_BAND,
|
||||
} from './skyBand';
|
||||
import { displayedLuminance, SKY_BAND } from './skyBand';
|
||||
import type { SkyBand } from './skyBand';
|
||||
import { luminance } from '../../../sky/skyTokens.js';
|
||||
import { CAMERA } from './terrain/terrainWorld';
|
||||
|
||||
const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
||||
|
||||
const rgb = (hex: string): Srgb =>
|
||||
[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb;
|
||||
|
||||
const fromGradient = (g: readonly string[], ground: Srgb, band: SkyBand): SkyColours => ({
|
||||
band,
|
||||
top: rgb(g[0]),
|
||||
mid: rgb(g[Math.floor(g.length / 2)]),
|
||||
horizon: rgb(g[g.length - 1]),
|
||||
ground,
|
||||
});
|
||||
|
||||
/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */
|
||||
export const DUSK_SUN_ELEVATION = 2;
|
||||
|
||||
const toByte = (v: number): number =>
|
||||
Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055));
|
||||
/**
|
||||
* What a rebuild of the ridge scene is for: the fixed state it draws and the weather. The sun moves with the clock inside a state and
|
||||
* is not part of the key (the scene is baked at its start), as the sky was one gradient per state before.
|
||||
*/
|
||||
export const ridgeSkyKey = (
|
||||
state: SkyState,
|
||||
weather: number = urlWeather() ?? DEFAULT_WEATHER,
|
||||
now: Date = skyNow()
|
||||
): string => `ridge:${resolveSkyState(state, now)}:${weather}`;
|
||||
|
||||
/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */
|
||||
const inBand = (c: Srgb, band: SkyBand): Srgb =>
|
||||
shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb;
|
||||
|
||||
/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */
|
||||
export function ridgeSkyFor(state: SkyState): SkyColours {
|
||||
/**
|
||||
* The ridge scene's `sky` option: the physical sky over Vienna for the state ('auto': the real sun now; a forced state: the
|
||||
* sun of today at a moment inside it, `sunForState`), as a palette for the layers and stars plus the `physical` sky the host canvas
|
||||
* is painted from. The atmosphere LUTs are baked here, once per scene start (tens of ms).
|
||||
*/
|
||||
export function ridgeSkyFor(
|
||||
state: SkyState,
|
||||
weather: number = urlWeather() ?? DEFAULT_WEATHER,
|
||||
now: Date = skyNow()
|
||||
): SkyColours {
|
||||
const { ground } = skyColours();
|
||||
switch (resolveSkyState(state)) {
|
||||
case 'dusk': {
|
||||
const twilight = skyColours();
|
||||
const band = SKY_BAND.dusk;
|
||||
return {
|
||||
...twilight,
|
||||
band,
|
||||
top: inBand(twilight.top, band),
|
||||
mid: inBand(twilight.mid, band),
|
||||
horizon: inBand(twilight.horizon, band),
|
||||
};
|
||||
}
|
||||
case 'day':
|
||||
return fromGradient(baked.noonday.gradient, ground, SKY_BAND.day);
|
||||
default: {
|
||||
const night = fromGradient(baked.midnight.gradient, ground, RIDGE_NIGHT_BAND);
|
||||
return {
|
||||
...night,
|
||||
top: inBand(night.top, RIDGE_NIGHT_BAND),
|
||||
mid: inBand(night.mid, RIDGE_NIGHT_BAND),
|
||||
horizon: inBand(night.horizon, RIDGE_NIGHT_BAND),
|
||||
};
|
||||
}
|
||||
}
|
||||
const fixed = resolveSkyState(state, now);
|
||||
const physical = makePhysicalSky(fixed, now, weather);
|
||||
return {
|
||||
...physical.stops,
|
||||
ground,
|
||||
band: SKY_BAND[fixed],
|
||||
physical,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -106,26 +85,12 @@ export interface Range {
|
|||
}
|
||||
|
||||
/**
|
||||
* The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates
|
||||
* encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled).
|
||||
* The ridge sky's range: what its display is fitted to hold (`BUDGET`, toneMap.ts), which `physicalSky.test.ts` proves over the
|
||||
* sun's whole year over Vienna. Dark ink needs the floor; light ink the ceiling.
|
||||
*/
|
||||
export function ridgeDrawn(state: SkyState): Range {
|
||||
const { top, mid, horizon } = ridgeSkyFor(state);
|
||||
const l: number[] = [];
|
||||
[
|
||||
[top, mid],
|
||||
[mid, horizon],
|
||||
[top, horizon],
|
||||
].forEach(([a, b]) => {
|
||||
for (let k = 0; k <= 100; k += 1) {
|
||||
l.push(
|
||||
luminance(
|
||||
rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
return { darkest: Math.min(...l), lightest: Math.max(...l) };
|
||||
const b = BUDGET[resolveSkyState(state)];
|
||||
return { darkest: b.floor, lightest: b.ceiling };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -80,6 +80,9 @@ function sync(): void {
|
|||
timer = setTimeout(tick, TICK_MS);
|
||||
}
|
||||
|
||||
/** The moment the sky is drawn for: the real time, or the test clock. */
|
||||
export const skyNow = (): Date => clock();
|
||||
|
||||
/** Replace the clock (tests); no argument restores the real one. */
|
||||
export function setSkyClock(now?: () => Date): void {
|
||||
clock = now ?? (() => new Date());
|
||||
|
|
@ -123,16 +126,17 @@ export interface SkyBackdrop {
|
|||
}
|
||||
|
||||
// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from
|
||||
// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so
|
||||
// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). The ridge sky is the physical sky over Vienna, its
|
||||
// display fitted to the state's budget (ridge/toneMap.ts `BUDGET`): the budget is its extreme, whatever the sun's elevation. Held as constants so
|
||||
// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering
|
||||
// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts
|
||||
// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts).
|
||||
// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts).
|
||||
// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places.
|
||||
const BACKDROPS: Record<FixedSkyState, SkyBackdrop> = {
|
||||
day: { darkest: 0.461063, lightest: 0.901108 },
|
||||
dusk: { darkest: 0.460352, lightest: 0.968744 },
|
||||
night: { darkest: 0.039159, lightest: 0.081642 },
|
||||
day: { darkest: 0.321, lightest: 1 },
|
||||
dusk: { darkest: 0.460352, lightest: 1 },
|
||||
night: { darkest: 0, lightest: 0.081642 },
|
||||
};
|
||||
|
||||
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];
|
||||
|
|
|
|||
Loading…
Reference in a new issue