From 48d9c56dc7026d6be3daa193dd9e730eeaa43c42 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 17:13:46 +0000 Subject: [PATCH] Ridge: shade the faces by depth below the crest, and hold the ridge inside the sky state's label band Volume: the foot of each face darkens (scaled by the layer's contrast, before the mist lifts it toward the haze), the sun catches the top, and the crest rim gets a soft glow under its 2 px lip. Baked per pixel; nothing per frame. Legibility, with no plate: the state's luminance band (skyBand.ts, the one the sky is shaped into) now also shapes the layers, as a shift of linear light in the bake. Day and dusk add the zenith colour so the darkest ridge pixel is on the text floor; night scales to the ceiling. The face keeps its whole luminance gradient. Rows of full-opacity label over a ridge measure 2.4-2.6 before and 6.7-7.1 after at day and dusk (360 px, real label colour against the real pixels under it). Tests: 'foot is hazier' becomes two tests (the foot is clearly darker; the valley mist still pulls it toward the haze), plus the band tests and ridgeSkyFor carrying the state band. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/ridge/ridgeBake.test.ts | 111 +++++++++++++++++- src/app/features/scene/ridge/ridgeBake.ts | 76 +++++++++++- src/app/features/scene/ridge/skyColour.ts | 4 + src/app/features/scene/skyForState.test.ts | 6 + src/app/features/scene/skyForState.ts | 8 +- 5 files changed, 192 insertions(+), 13 deletions(-) diff --git a/src/app/features/scene/ridge/ridgeBake.test.ts b/src/app/features/scene/ridge/ridgeBake.test.ts index 7c452954..8ce91f41 100644 --- a/src/app/features/scene/ridge/ridgeBake.test.ts +++ b/src/app/features/scene/ridge/ridgeBake.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake'; +import { SKY_BAND } from '../skyBand'; +import { ridgeSkyFor } from '../skyForState'; import { LACUNARITY, LAYERS, profile } from './ridgeWorld'; import { skyColours, SkyColours } from './skyColour'; @@ -155,22 +157,41 @@ describe('shading from the slope normal', () => { } }); - it('the foot of the face is hazier than its top: it moves toward the haze colour', () => { + it('the foot of the face is clearly darker than its top, on every layer: volume from the depth below the crest', () => { + // Without the depth shading the bottom row is 0.93 to 0.98 of the luminance 12 rows under the crest; with it, 0.73 to 0.82. + for (const li of [0, 1, 2, 3]) { + const cols = 900; + const d = bake(params(li, 0, cols, { spec: { ...LAYERS[li], mist: 0, gully: 0 } })); + let ratio = 0; + let n = 0; + for (let x = 0; x < cols; x += 1) { + const y = solidRow(d, cols, x); + if (y + 90 >= BAND) continue; + n += 1; + ratio += lum(d, cols, x, BAND - 1) / Math.max(1, lum(d, cols, x, y + 12)); + } + expect(n).toBeGreaterThan(30); + expect(ratio / n).toBeLessThan(0.88); + } + }); + + it('the valley mist still pulls the foot toward the haze colour', () => { const cols = 300; const p = params(0, 0, cols); - const d = bake(p); + const misty = bake(p); + const clear = bake(params(0, 0, cols, { spec: { ...LAYERS[0], mist: 0 } })); const haze = layerPaint(p.spec, p.sky).haze; - const dist = (x: number, y: number) => { + const dist = (d: Uint8ClampedArray, x: number, y: number) => { const o = (y * cols + x) * 4; return Math.hypot(d[o] - haze[0], d[o + 1] - haze[1], d[o + 2] - haze[2]); }; let nearer = 0; let n = 0; for (let x = 0; x < cols; x += 1) { - const y = solidRow(d, cols, x); + const y = solidRow(misty, cols, x); if (y + 200 >= BAND) continue; n += 1; - if (dist(x, y + 200) < dist(x, y + 20)) nearer += 1; + if (dist(misty, x, y + 200) < dist(clear, x, y + 200)) nearer += 1; } expect(n).toBeGreaterThan(50); expect(nearer / n).toBeGreaterThan(0.9); @@ -301,3 +322,83 @@ describe('the ridged multifractal core, each rule on its own', () => { expect(gap(0)).toBeLessThan(gap(3)); }); }); + +describe('the state band: the theme ink clears 4.5:1 on a ridge as on bare sky', () => { + const linear = (v: number) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + const luma = (d: Uint8ClampedArray, o: number) => + 0.2126 * linear(d[o]) + 0.7152 * linear(d[o + 1]) + 0.0722 * linear(d[o + 2]); + /** Lowest and highest luminance over the solid pixels of every layer. */ + const range = (band: typeof SKY_BAND[keyof typeof SKY_BAND] | undefined) => { + let lo = 1; + let hi = 0; + for (const li of [0, 1, 2, 3]) { + const p = params(li, 0, 700, { sky: { ...SKY, band } }); + const d = bake(p); + for (let o = 0; o < d.length; o += 4) { + if (d[o + 3] < 255) continue; + const l = luma(d, o); + lo = Math.min(lo, l); + hi = Math.max(hi, l); + } + } + return { lo, hi }; + }; + + it("day and dusk: no ridge pixel is darker than the text floor (Day's), on every layer", () => { + for (const band of [SKY_BAND.day, SKY_BAND.dusk]) { + expect(range(band).lo).toBeGreaterThanOrEqual(SKY_BAND.day.floor); + } + }); + + it('night: no ridge pixel is lighter than the ceiling', () => { + expect(range(SKY_BAND.night).hi).toBeLessThanOrEqual(SKY_BAND.night.ceiling); + }); + + it('is the identity with no band, so nothing else changes', () => { + expect(layerPaint(LAYERS[2], SKY).shift).toBeNull(); + expect(range(undefined).lo).toBeLessThan(SKY_BAND.day.floor); + }); + + it('keeps the shading: the foot is still darker than the top inside the band', () => { + // Inside the band the face's range is compressed, not removed: dusk, no mist or gullies, bottom row over 12 rows under the crest. + const sky = { ...SKY, band: SKY_BAND.dusk }; + for (const li of [0, 1, 2, 3]) { + const cols = 900; + const d = bake(params(li, 0, cols, { sky, spec: { ...LAYERS[li], mist: 0, gully: 0 } })); + let ratio = 0; + let n = 0; + for (let x = 0; x < cols; x += 1) { + const y = solidRow(d, cols, x); + if (y + 90 >= BAND) continue; + n += 1; + ratio += luma(d, ((BAND - 1) * cols + x) * 4) / luma(d, ((y + 12) * cols + x) * 4); + } + expect(n).toBeGreaterThan(30); + // Measured 0.84, 0.85, 0.91, 0.97 far to near: the nearest ground is near black, so its range is small in absolute terms. + expect(ratio / n).toBeLessThan(li < 3 ? 0.95 : 1); + } + }); + + it('the real states, over a sweep of the sun: no pixel overflows the encoding (an overflow reads as a stray black or yellow channel)', () => { + for (const state of ['day', 'dusk', 'night'] as const) { + for (const sunAzimuth of [0, 0.1, 0.25, 0.3, 0.5, 0.75, 0.9]) { + const sky = { ...ridgeSkyFor(state), sunAzimuth }; + for (const li of [0, 1, 2, 3]) { + const d = bake(params(li, 1000, 400, { sky })); + let stray = 0; + for (let o = 0; o < d.length; o += 4) { + if (d[o + 3] < 255) continue; + // A channel far below both others is the signature of a lookup that read past its table. + const lo = Math.min(d[o], d[o + 1], d[o + 2]); + const mid = d[o] + d[o + 1] + d[o + 2] - lo - Math.max(d[o], d[o + 1], d[o + 2]); + if (mid - lo > 120) stray += 1; + } + expect(stray).toBe(0); + } + } + } + }); +}); diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts index 3d47526c..770c30e0 100644 --- a/src/app/features/scene/ridge/ridgeBake.ts +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -4,6 +4,8 @@ // gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast // by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone. import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld'; +import type { SkyBand } from '../skyBand'; +import { LUMA, SKY_BAND } from '../skyBand'; import type { SkyColours, Srgb } from './skyColour'; export type Vec3 = readonly [number, number, number]; @@ -58,6 +60,8 @@ const SUN_WHITE: Srgb = [255, 238, 205]; /** Everything `bakeSlice` needs per layer, computed once. */ export interface LayerPaint { + /** The state's band as a shift of linear light (raises the floor, lowers the ceiling); null when the sky has none. */ + shift: BandShift | null; base: Srgb; haze: Srgb; warm: Srgb; @@ -65,6 +69,46 @@ export interface LayerPaint { sun: Vec3; } +const decode = (v: number): number => { + const s = v / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +}; +const DECODE = Float32Array.from({ length: 256 }, (_, v) => decode(v)); +const ENCODE_STEPS = 4095; +const ENCODE = Uint8ClampedArray.from({ length: ENCODE_STEPS + 1 }, (_, i) => { + const l = i / ENCODE_STEPS; + return 255 * (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055); +}); +/** What the band is held clear of at its edges: the 8-bit rounding and the blur and in-scatter the chunk takes after the bake. */ +const BAND_MARGIN = 0.012; + +/** Linear light out = in * `scale` + `add[channel]`. */ +export interface BandShift { + add: readonly [number, number, number]; + scale: number; +} + +/** + * The state's band (skyBand.ts) as a shift of linear light, solved once per layer. Adding `add` (the zenith colour, so + * the ridge reads as shadowed by the sky above, a hue apart from the horizon behind it) raises every pixel's + * luminance by exactly the floor, so the face keeps its whole gradient and the darkest pixel is on the floor; scaling by + * `scale` puts the lightest pixel on the ceiling. Smooth in the pixel's own luminance: no cut and no region. + * The floor is the text bound (Day's), not Dusk's higher link bound: the link ink is not set on ridges, and Dusk's + * floor washes the faces to a flat pale slab. + */ +export function bandShift(band: SkyBand | undefined, lift: Srgb): BandShift | null { + if (!band) return null; + const floor = Math.min(band.floor, SKY_BAND.day.floor); + const scale = band.ceiling < 1 ? band.ceiling - BAND_MARGIN / 3 : 1; + if (floor <= 0 && scale === 1) return null; + const lin = lift.map(decode); + const k = + floor > 0 + ? (floor + BAND_MARGIN) / (LUMA[0] * lin[0] + LUMA[1] * lin[1] + LUMA[2] * lin[2]) + : 0; + return { add: [lin[0] * k, lin[1] * k, lin[2] * k], scale }; +} + export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint { const warm: Srgb = [ (sky.horizon[0] + SUN_WHITE[0]) / 2, @@ -72,6 +116,7 @@ export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint { (sky.horizon[2] + SUN_WHITE[2]) / 2, ]; return { + shift: bandShift(sky.band, sky.top), base: layerColour(spec, sky), haze: hazeColour(sky), warm, @@ -109,7 +154,8 @@ export function bakeSlice( const crest = crestRows(p, c0, c1); const faceH = 0.45 * p.viewH; const rim = 2.2 * p.scale; - const { base, haze, warm, contrast, sun } = paint; + const glow = 9 * p.scale; + const { base, haze, warm, contrast, sun, shift } = paint; for (let c = c0; c < c1; c += 1) { const cy = crest[c - c0 + 1]; const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column @@ -123,15 +169,35 @@ export function bakeSlice( const cover = clamp01(y + 1 - cy); const gully = spec.gully * gl * smoothstep(0, 0.2, dn); // Shade the ground colour by the sun; the lit warm light fades down the face and in the gullies. - const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully); - const warmK = 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully); - const rimK = clamp01(1 - d / rim) * contrast * (0.3 + 0.7 * lit) * 0.6; + // Volume from the depth below the crest: the face darkens toward its foot (before the mist lifts it + // toward the haze), and the sun catches its top. A few multiplies per pixel, in the bake only. + const foot = 1 - 0.4 * contrast * smoothstep(0.05, 0.9, dn); + const top = 1 - smoothstep(0, 0.3, dn); + const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully) * foot; + const warmK = + 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully) + + 0.04 * contrast * lit * top; + // A hard 2 px lip on the crest and a soft glow under it. + const rimK = + (clamp01(1 - d / rim) * 0.4 + (d < glow * 5 ? Math.exp(-d / glow) * 0.08 : 0)) * + contrast * + (0.3 + 0.7 * lit); const mist = spec.mist * smoothstep(0, 1, dn); const o = (y * p.cols + c) * 4; for (let i = 0; i < 3; i += 1) { let v = base[i] * shade + warm[i] * (warmK + rimK); v += (haze[i] - v) * mist; - out[o + i] = v; + out[o + i] = shift + ? ENCODE[ + Math.min( + ENCODE_STEPS, + ((DECODE[v >= 255 ? 255 : v <= 0 ? 0 : Math.round(v)] * shift.scale + + shift.add[i]) * + ENCODE_STEPS) | + 0 + ) + ] + : v; } out[o + 3] = cover * 255; } diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 7917d89e..1fa7e6fe 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -4,6 +4,8 @@ // shaped into the dusk band of ../skyBand.ts (`ridgeSkyFor`, skyForState.ts), whose floor the link of Noonday Sky needs. Replace the body, keep the signature: the scene calls it once, at startup, // and bakes the result. +import type { SkyBand } from '../skyBand'; + export type Srgb = readonly [number, number, number]; export interface SkyColours { @@ -17,6 +19,8 @@ export interface SkyColours { ground: Srgb; /** Where the sun sits across one drift period, 0..1; the layers pick up a faint warm in-scatter there. Omit for none. */ 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; } export function skyColours(_now: Date = new Date()): SkyColours { diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index a8199b1b..37e170cb 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -77,6 +77,12 @@ 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(SKY_BAND[state]); + } + }); + it('draws the gradient of the sky auto resolves to, never nothing', () => { setSkyClock(() => atStandardTime(12, 0)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 71e07ca6..a9551f65 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -20,7 +20,8 @@ const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).jo 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): SkyColours => ({ +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]), @@ -46,15 +47,16 @@ export function ridgeSkyFor(state: SkyState): 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); + return fromGradient(baked.noonday.gradient, ground, SKY_BAND.day); default: - return fromGradient(baked.midnight.gradient, ground); + return fromGradient(baked.midnight.gradient, ground, SKY_BAND.night); } } -- 2.47.3