79 lines
3.5 KiB
TypeScript
79 lines
3.5 KiB
TypeScript
import { Color } from 'three'
|
|
|
|
import { MEDIEVAL_PALETTE, type MedievalPalette } from './materials.ts'
|
|
import { jitter } from './random.ts'
|
|
|
|
/**
|
|
* Factory that produces colours deviated from the palette.
|
|
*
|
|
* Every model used to rewrite this inline — the same five lines thirteen
|
|
* times. The cost of the repetition was not just line count: the deviation
|
|
* amounts had drifted from model to model, so when two models were put side
|
|
* by side one of them visibly had more variation than the other.
|
|
*
|
|
* Every call returns A NEW Color.
|
|
*
|
|
* It used to return one shared object and mutate it, on the reasoning that the
|
|
* geometry helpers read the colour immediately and an allocation per call is
|
|
* waste. The reasoning holds right up until two tints appear as arguments of
|
|
* the SAME call:
|
|
*
|
|
* latheGeometry(profile, 7, origin, tint('char', 0.06),
|
|
* { colourTop: tint('charHot', -0.12) })
|
|
*
|
|
* Both arguments are the same object, so both carry whatever the second call
|
|
* computed. That is not a hypothetical: it is why the torch's pitch head
|
|
* rendered hot orange instead of black, and the same mistake was made twice
|
|
* more in one session -- once in the anvil's stump, which came out painted in
|
|
* end grain on every face, and once nearly in the shovel. Three times means
|
|
* the hazard is in the helper, not in the callers.
|
|
*
|
|
* A few hundred Color allocations at build time is not a cost worth one class
|
|
* of silent, invisible bug.
|
|
*/
|
|
/**
|
|
* Lightness a tinted colour is never taken below.
|
|
*
|
|
* These lightnesses are LINEAR, not sRGB, so the whole palette lives between
|
|
* about 0.03 and 0.36 and a lift of -0.28 is an enormous move rather than a
|
|
* gentle darkening. That is survivable while the palette sits high and fatal
|
|
* once it drops. When `cloth` was measured against its references and taken
|
|
* from linear lightness 0.364 to 0.252, every part carrying a -0.28 lift went
|
|
* straight through zero: nine parts across six models -- the market stall's
|
|
* whole trestle, two stretchers, three cords and a set of bindings -- came out
|
|
* pure black. In the render they then read as neutral GREY, because a black
|
|
* albedo contributes nothing and the only thing left is the white specular.
|
|
*
|
|
* The lifts were not wrong. They are relative offsets and the thing they were
|
|
* relative to moved. So the floor lives here rather than in nine call sites,
|
|
* because otherwise the next palette measurement re-breaks all of them, and
|
|
* because a part that keeps its hue at the bottom of its range is always a
|
|
* better answer than one that loses it.
|
|
*/
|
|
const FLOOR = 0.045
|
|
|
|
export function createTinter(random: () => number) {
|
|
return (
|
|
key: keyof MedievalPalette,
|
|
/** Lightness shift. Negative darkens. */
|
|
lift = 0,
|
|
/** Deviation multiplier. 0 gives a completely flat colour. */
|
|
spread = 1,
|
|
): Color => {
|
|
const scratch = new Color(MEDIEVAL_PALETTE[key])
|
|
// `offsetHSL` is get-add-set, and this is the same thing with the lightness
|
|
// clamped before the set rather than after. Clamping afterwards is not the
|
|
// same: a colour that has already reached zero has no hue or saturation
|
|
// left to read back, so lifting it returns grey instead of dark timber.
|
|
const hsl = { h: 0, s: 0, l: 0 }
|
|
scratch.getHSL(hsl)
|
|
scratch.setHSL(
|
|
hsl.h + jitter(random, 0.012 * spread),
|
|
hsl.s + jitter(random, 0.05 * spread),
|
|
Math.max(FLOOR, hsl.l + lift + jitter(random, 0.05 * spread)),
|
|
)
|
|
return scratch
|
|
}
|
|
}
|
|
|
|
export type Tinter = ReturnType<typeof createTinter>
|