Ridge: shade faces by depth below the crest; hold the ridge inside the label band (Nibu) #99
5 changed files with 192 additions and 13 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue