Ridge: shade faces by depth below the crest; hold the ridge inside the label band (Nibu) #99

Merged
void merged 1 commit from void/nibu-ridge-face-volume into dev 2026-11-11 18:31:12 +00:00
5 changed files with 192 additions and 13 deletions

View file

@ -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);
}
}
}
});
});

View file

@ -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;
}

View file

@ -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 {

View file

@ -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'));

View file

@ -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);
}
}