478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
/**
|
||
* Pure math for the per-system visual effects — NO Phaser import, so the
|
||
* dev harness (dev/system-effects.test.mjs) can run it in bare Node.
|
||
*
|
||
* The system effect is a full-screen composite over the WORLD camera
|
||
* (see SystemEffects.js): a fragment shader displaces the camera's
|
||
* screen-space UVs around the system's star. The star is never rendered
|
||
* (it is invisible flavor — data/systems.json), but it sits at the
|
||
* system origin (world 0,0), so the ripple center is "where world (0,0)
|
||
* projects to, in the camera's UV space".
|
||
*
|
||
* A camera's `matrixCombined` is the world->screen affine transform
|
||
* (a·x + c·y + tx, b·x + d·y + ty). Dividing the screen point by the
|
||
* camera's viewport size gives the UV: (0,0) = the camera's top-left,
|
||
* (1,1) = its bottom-right — exactly the space the shader's
|
||
* `outTexCoord` lives in.
|
||
*/
|
||
|
||
/**
|
||
* @typedef {{ a: number, b: number, c: number, d: number, tx: number, ty: number }} Affine2D
|
||
*
|
||
* Affine2D — the shape of a Phaser camera's matrixCombined (the
|
||
* world->screen transform). Duck-typed on purpose: the tests pass plain
|
||
* objects, the game passes the real matrix.
|
||
*/
|
||
|
||
/**
|
||
* Screen-space point of a world point under an affine transform.
|
||
*
|
||
* @param {Affine2D} m world->screen transform
|
||
* @param {number} [wx=0] world x
|
||
* @param {number} [wy=0] world y
|
||
* @returns {{ x: number, y: number }} screen point (camera viewport px)
|
||
*/
|
||
export function worldToScreen(m, wx = 0, wy = 0) {
|
||
return {
|
||
x: m.a * wx + m.c * wy + m.tx,
|
||
y: m.b * wx + m.d * wy + m.ty,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* UV coordinate of a world point for a camera of the given viewport size
|
||
* (0,0 top-left -> 1,1 bottom-right).
|
||
*
|
||
* @param {Affine2D} m world->screen transform
|
||
* @param {number} width camera viewport width (px)
|
||
* @param {number} height camera viewport height (px)
|
||
* @param {number} [wx=0] world x
|
||
* @param {number} [wy=0] world y
|
||
* @returns {{ x: number, y: number }} UV
|
||
*/
|
||
export function worldToUV(m, width, height, wx = 0, wy = 0) {
|
||
const p = worldToScreen(m, wx, wy);
|
||
return { x: p.x / width, y: p.y / height };
|
||
}
|
||
|
||
/**
|
||
* The wave's phase (radians) at a moment — the shader's `time` uniform.
|
||
* Monotonic in the game clock, scaled by the configured speed, so the
|
||
* ripple keeps its pace across system entries without any per-frame
|
||
* accumulation to drift.
|
||
*
|
||
* @param {number} nowMs game-loop time (ms, monotonic)
|
||
* @param {number} [speed=1] angular speed multiplier (radians/second)
|
||
* @returns {number} phase, radians
|
||
*/
|
||
export function ripplePhase(nowMs, speed = 1) {
|
||
return (nowMs / 1000) * (Number(speed) || 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Multi-center ripple, flares, and color grade — pure, Node-testable.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* A star's FLARE intensity at a moment: 0 (quiet) .. 1 (peak), mostly 0
|
||
* with a smooth periodic surge. A raised-cosine bell (0→1→0) over
|
||
* `durationSec`, repeating every `intervalSec`, shifted by a per-system
|
||
* `phaseSec` so each system has its own flare rhythm. Deterministic (no
|
||
* Math.random) so the same system flares the same way every run.
|
||
*
|
||
* @param {number} nowMs game clock (ms)
|
||
* @param {number} intervalSec seconds between flares
|
||
* @param {number} durationSec seconds a flare lasts
|
||
* @param {number} [phaseSec=0] per-system phase offset (seconds)
|
||
* @returns {number} 0..1
|
||
*/
|
||
export function flareIntensity(nowMs, intervalSec, durationSec, phaseSec = 0) {
|
||
const period = Math.max(0.5, Number(intervalSec) || 18);
|
||
const dur = Math.max(0.2, Number(durationSec) || 1.5);
|
||
const t = nowMs / 1000 - (Number(phaseSec) || 0);
|
||
const u = ((t % period) + period) % period; // local phase, 0..period
|
||
if (u >= dur) return 0; // quiet
|
||
const x = u / dur; // 0..1 through the flare
|
||
return 0.5 * (1 - Math.cos(2 * Math.PI * x)); // 0 → 1 → 0, smooth
|
||
}
|
||
|
||
/**
|
||
* The two ORBITING shimmer centers (binary) at a moment: two points,
|
||
* diametrically opposed, circling the view's middle (0.5,0.5) at radius
|
||
* `radius` (in UV), one full lap every `periodSec`, seeded by `phase`
|
||
* (0..1 → starting angle). Returns both centers' UVs.
|
||
*
|
||
* @param {number} nowMs game clock (ms)
|
||
* @param {number} periodSec seconds per orbit lap
|
||
* @param {number} radius orbit radius in UV (fraction of screen)
|
||
* @param {number} [phase=0] per-system phase (0..1)
|
||
* @returns {{cx0:number,cy0:number,cx1:number,cy1:number}}
|
||
*/
|
||
export function orbitCenters(nowMs, periodSec, radius, phase = 0) {
|
||
const period = Math.max(2, Number(periodSec) || 36);
|
||
const r = Math.max(0, Number(radius) || 0);
|
||
const a = 2 * Math.PI * ((nowMs / 1000) / period + (Number(phase) || 0));
|
||
const dx = r * Math.cos(a);
|
||
const dy = r * Math.sin(a);
|
||
return { cx0: 0.5 + dx, cy0: 0.5 + dy, cx1: 0.5 - dx, cy1: 0.5 - dy };
|
||
}
|
||
|
||
/**
|
||
* A smoothstep on a 0..1 domain (mirrors the shader's).
|
||
*/
|
||
function sstep01(x) {
|
||
const t = Math.min(1, Math.max(0, x));
|
||
return t * t * (3 - 2 * t);
|
||
}
|
||
|
||
/**
|
||
* The HABITABLE storm's envelope at a moment: `{ charge, flash }`, both 0..1.
|
||
*
|
||
* The cycle (per cluster, seeded by its phase):
|
||
* CHARGE — a smooth rise over the last `chargeWindow` fraction of the
|
||
* cycle (the clouds gathering light before the strike);
|
||
* STRIKE — at the cycle boundary: a rapid flicker (real lightning
|
||
* strobes — a bright burst plus two decaying rebounds) and a
|
||
* soft afterglow that decays into the next cycle's quiet.
|
||
* Deterministic (no Math.random), so the same system storms the same way
|
||
* every run. The strike sits at the cycle boundary (t ≡ phase mod period).
|
||
*
|
||
* @param {number} nowMs game clock (ms)
|
||
* @param {number} intervalSec seconds per storm cycle
|
||
* @param {number} strikeDurSec the flicker's span (seconds)
|
||
* @param {number} [phaseSec=0] per-cluster phase offset (seconds)
|
||
* @param {number} [chargeWindow=0.45] fraction of the cycle over which charge rises
|
||
* @returns {{charge:number, flash:number}} both 0..1
|
||
*/
|
||
export function stormEnvelope(nowMs, intervalSec, strikeDurSec, phaseSec = 0, chargeWindow = 0.45) {
|
||
const period = Math.max(2, Number(intervalSec) || 12);
|
||
const W = Math.max(0.15, Number(strikeDurSec) || 0.5);
|
||
const cw = Math.min(1, Math.max(0.1, Number(chargeWindow) || 0.45));
|
||
const t = nowMs / 1000 - (Number(phaseSec) || 0);
|
||
const tLocal = ((t % period) + period) % period; // 0..period; strike at tLocal === 0 (≡ period)
|
||
|
||
// CHARGE: 0 early in the cycle, rising smoothly to 1 at the strike.
|
||
const u = tLocal / period;
|
||
const charge = sstep01((u - (1 - cw)) / cw);
|
||
|
||
// FLASH: time relative to the strike (negative = before it). The
|
||
// afterglow is clipped at 3× the flicker span (it is negligible there —
|
||
// a hard 0 keeps the envelope exact instead of an asymptote).
|
||
const dt = tLocal <= period / 2 ? tLocal : tLocal - period; // (-period/2, period/2]
|
||
let flash = 0;
|
||
if (dt >= 0 && dt <= 3 * W) {
|
||
const gauss = (x, sigma) => Math.exp(-(x * x) / (2 * sigma * sigma));
|
||
// A bright primary burst + two decaying rebounds (the strobe) +
|
||
// a soft afterglow (light that lingers in the cloud).
|
||
const b1 = 1.0 * gauss(dt, 0.12 * W);
|
||
const b2 = 0.55 * gauss(dt - 0.45 * W, 0.1 * W);
|
||
const b3 = 0.35 * gauss(dt - 0.85 * W, 0.09 * W);
|
||
const afterglow = 0.22 * Math.exp(-dt / (0.9 * W));
|
||
flash = Math.min(1, b1 + b2 + b3 + afterglow);
|
||
}
|
||
return { charge, flash };
|
||
}
|
||
|
||
/**
|
||
* A seeded 32-bit PRNG (mulberry32) — a deterministic float sequence
|
||
* (0..1) from a uint32 seed. The bolt shapes use it, so the same
|
||
* (seed, size) always yields the same lightning (Node-testable, and
|
||
* stable across lazy vs. eager scene builds).
|
||
*
|
||
* @param {number} seedNum any number (mixed to uint32)
|
||
* @returns {() => number} next-draw function
|
||
*/
|
||
export function seededRandom(seedNum) {
|
||
let a = (seedNum >>> 0) || 0x9e3779b9;
|
||
return () => {
|
||
a |= 0;
|
||
a = (a + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// NEBULA LIGHTNING — the far-sky bolts (pure; drawn by NebulaLightning.js).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Which STRIKE is in progress at a moment: an integer that increments at
|
||
* each cycle boundary. Mixing it into the shape seed gives every strike a
|
||
* DIFFERENT but fully deterministic bolt — strike N of a system is always
|
||
* the same bolt, every run.
|
||
*
|
||
* @param {number} nowMs game clock (ms)
|
||
* @param {number} intervalSec seconds per strike cycle
|
||
* @param {number} [phaseSec=0] per-system phase offset (seconds)
|
||
* @returns {number} integer strike ordinal (can be negative before t=0)
|
||
*/
|
||
export function strikeCycle(nowMs, intervalSec, phaseSec = 0) {
|
||
const period = Math.max(2, Number(intervalSec) || 12);
|
||
const t = nowMs / 1000 - (Number(phaseSec) || 0);
|
||
return Math.floor(t / period);
|
||
}
|
||
|
||
/**
|
||
* The nebula lightning's STROBE envelope at a moment: 0 (dark sky) .. 1
|
||
* (peak), mostly 0 with a rapid flicker — the same strobe the habitable
|
||
* storm uses (a bright primary burst + two decaying rebounds + a soft
|
||
* afterglow), minus the charge (there are no clouds to gather it):
|
||
* real lightning STROBES, it does not fade in and out.
|
||
*
|
||
* The strike sits at the cycle boundary (t ≡ phase, mod period).
|
||
* Deterministic (no Math.random), so the same system strikes the same way
|
||
* every run.
|
||
*
|
||
* @param {number} nowMs game clock (ms)
|
||
* @param {number} intervalSec seconds per strike cycle
|
||
* @param {number} strikeDurSec the strobe's span (seconds)
|
||
* @param {number} [phaseSec=0] per-system phase offset (seconds)
|
||
* @returns {number} 0..1
|
||
*/
|
||
export function boltStrobe(nowMs, intervalSec, strikeDurSec, phaseSec = 0) {
|
||
const period = Math.max(2, Number(intervalSec) || 12);
|
||
const W = Math.max(0.15, Number(strikeDurSec) || 0.5);
|
||
const t = nowMs / 1000 - (Number(phaseSec) || 0);
|
||
const tLocal = ((t % period) + period) % period; // 0..period; strike at tLocal === 0 (≡ period)
|
||
// Time relative to the strike (negative = before it); the afterglow is
|
||
// clipped at 3× the strobe span (negligible there — a hard 0 keeps the
|
||
// envelope exact instead of an asymptote).
|
||
const dt = tLocal <= period / 2 ? tLocal : tLocal - period; // (-period/2, period/2]
|
||
if (dt < 0 || dt > 3 * W) return 0;
|
||
const gauss = (x, sigma) => Math.exp(-(x * x) / (2 * sigma * sigma));
|
||
const b1 = 1.0 * gauss(dt, 0.12 * W);
|
||
const b2 = 0.55 * gauss(dt - 0.45 * W, 0.1 * W);
|
||
const b3 = 0.35 * gauss(dt - 0.85 * W, 0.09 * W);
|
||
const afterglow = 0.22 * Math.exp(-dt / (0.9 * W));
|
||
return Math.min(1, b1 + b2 + b3 + afterglow);
|
||
}
|
||
|
||
/**
|
||
* MIDPOINT DISPLACEMENT — split each segment, push its midpoint off-axis
|
||
* by a random amount, halve the displacement, recurse. The classic
|
||
* fractal bolt: a few segments in, a jagged filament out.
|
||
*
|
||
* @param {{x:number,y:number}} a start point
|
||
* @param {{x:number,y:number}} b end point
|
||
* @param {number} amp level-0 displacement (px, perpendicular jitter budget)
|
||
* @param {number} levels recursion depth (2..8)
|
||
* @param {() => number} rand seeded next-draw
|
||
* @returns {{x:number,y:number}[]}
|
||
*/
|
||
function displace(a, b, amp, levels, rand) {
|
||
let pts = [a, b];
|
||
let cur = Math.max(0, amp);
|
||
for (let l = 0; l < levels; l++) {
|
||
const next = [];
|
||
for (let i = 0; i < pts.length - 1; i++) {
|
||
const p = pts[i];
|
||
const q = pts[i + 1];
|
||
next.push(p, {
|
||
x: (p.x + q.x) / 2 + (rand() - 0.5) * cur,
|
||
y: (p.y + q.y) / 2 + (rand() - 0.5) * cur,
|
||
});
|
||
}
|
||
next.push(pts[pts.length - 1]);
|
||
pts = next;
|
||
cur *= 0.5;
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
/**
|
||
* One lightning bolt, in VIEW space (px, 0,0 = top-left of the view):
|
||
* a jagged main trunk from one view EDGE to the opposite ("starts on one
|
||
* end and ends on the other"), plus a few shorter BRANCHES forking off
|
||
* the trunk at random points and heading off-axis.
|
||
*
|
||
* Deterministic from (seed, w, h, opts) — same inputs, same bolt. Pure
|
||
* (no Phaser), so the dev harness can exercise it in bare Node.
|
||
*
|
||
* @param {number} seed uint32 (mix in the strike ordinal for variety)
|
||
* @param {number} w view width (px)
|
||
* @param {number} h view height (px)
|
||
* @param {{levels?:number, branches?:number, jitter?:number}} [opts]
|
||
* levels — recursion depth (2..8, default 6); branches — branch count
|
||
* (default 3); jitter — trunk displacement as a fraction of view height
|
||
* (default 0.16)
|
||
* @returns {{ main: {x:number,y:number}[], branches: {x:number,y:number}[][] }}
|
||
*/
|
||
export function boltShape(seed, w, h, opts = {}) {
|
||
const rand = seededRandom(seed);
|
||
const levels = Math.max(2, Math.min(8, Math.round(opts.levels ?? 6)));
|
||
const jitter = Math.max(0, Number(opts.jitter) || 0.16);
|
||
const amp = jitter * h; // the trunk's perpendicular wander budget
|
||
|
||
// Edge to edge: one endpoint on each horizontal edge (just outside the
|
||
// view, so the bolt runs the FULL width), random height at each end —
|
||
// a diagonal streak, direction random.
|
||
const dir = rand() < 0.5 ? 1 : -1;
|
||
const over = 0.05 * w;
|
||
const yA = h * (0.08 + 0.84 * rand());
|
||
const yB = h * (0.08 + 0.84 * rand());
|
||
const a = dir > 0 ? { x: -over, y: yA } : { x: w + over, y: yA };
|
||
const b = dir > 0 ? { x: w + over, y: yB } : { x: -over, y: yB };
|
||
const main = displace(a, b, amp, levels, rand);
|
||
|
||
const nBranch = Math.max(0, Math.round(opts.branches ?? 3));
|
||
const branches = [];
|
||
for (let i = 0; i < nBranch; i++) {
|
||
// Fork from a random INTERIOR point of the trunk (never the tips),
|
||
// heading off the trunk's axis by a seeded angle, at a fraction of
|
||
// the trunk's length.
|
||
const pi = 1 + Math.floor(rand() * (main.length - 2));
|
||
const p0 = main[pi];
|
||
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
||
const off = (rand() < 0.5 ? 1 : -1) * (0.6 + 0.9 * rand());
|
||
const ang = Math.atan2(b.y - a.y, b.x - a.x) + off;
|
||
const blen = len * (0.12 + 0.3 * rand());
|
||
const p1 = { x: p0.x + Math.cos(ang) * blen, y: p0.y + Math.sin(ang) * blen };
|
||
branches.push(displace(p0, p1, amp * 0.55, Math.max(2, levels - 2), rand));
|
||
}
|
||
return { main, branches };
|
||
}
|
||
|
||
// --- Color grade (pure; the shader does the same thing per-pixel) ---------
|
||
|
||
/**
|
||
* Parse a CSS hex color ('#rrggbb' or 'rrggbb') to [r,g,b] in 0..1.
|
||
* NO Phaser (Node-testable). Bad input → white.
|
||
*
|
||
* @param {string} hex
|
||
* @returns {[number, number, number]}
|
||
*/
|
||
export function hexToRgb01(hex) {
|
||
const m = String(hex).trim().match(/^#?([0-9a-f]{6})$/i);
|
||
if (!m) return [1, 1, 1];
|
||
const n = parseInt(m[1], 16);
|
||
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
||
}
|
||
|
||
/**
|
||
* SHADOW LIFT — push the DARK part of a color toward a lift hue (the red
|
||
* void behind a redDwarf's stars), leaving bright pixels essentially
|
||
* untouched. Luminance-gated: the darker the pixel, the more it lifts, so
|
||
* near-black space becomes a thin red haze while the white stars still pop.
|
||
* Mirrors the grade shader's lift step exactly (the shader's `grain` is a
|
||
* per-pixel spatial effect and lives there only).
|
||
*
|
||
* @param {[number,number,number]} rgb 0..1
|
||
* @param {[number,number,number]} liftColor 0..1 (the hue to lift toward)
|
||
* @param {number} amount 0..1 (strength)
|
||
* @param {number} [cutoff=3] luma multiplier (higher = tighter to the blacks)
|
||
* @returns {[number,number,number]} lifted color (0..~1+)
|
||
*/
|
||
export function shadowLift(rgb, liftColor, amount, cutoff = 3) {
|
||
const a = Number(amount) || 0;
|
||
if (a <= 0 || !Array.isArray(liftColor)) return [rgb[0] ?? 0, rgb[1] ?? 0, rgb[2] ?? 0];
|
||
const r = rgb[0] ?? 0;
|
||
const g = rgb[1] ?? 0;
|
||
const b = rgb[2] ?? 0;
|
||
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||
const dark = (1 - Math.min(1, Math.max(0, lum * (Number(cutoff) || 3)))) * a;
|
||
return [r + liftColor[0] * dark, g + liftColor[1] * dark, b + liftColor[2] * dark];
|
||
}
|
||
|
||
/**
|
||
* A tint color as a MULTIPLIER vec3, normalized so its average channel is
|
||
* 1.0 — i.e. a pure hue shift that neither brightens nor darkens on its
|
||
* own (the grade's `brightness` handles that separately). White → [1,1,1].
|
||
*
|
||
* @param {string} hex
|
||
* @returns {[number, number, number]}
|
||
*/
|
||
export function tintMultiplier(hex) {
|
||
const [r, g, b] = hexToRgb01(hex);
|
||
const avg = (r + g + b) / 3 || 1;
|
||
return [r / avg, g / avg, b / avg];
|
||
}
|
||
|
||
/** Safe numeric read: `Number(v)` if finite, else the default. */
|
||
function num(v, d) {
|
||
const x = Number(v);
|
||
return Number.isFinite(x) ? x : d;
|
||
}
|
||
|
||
/**
|
||
* Apply the full grade to an [r,g,b] color (0..1) — the exact per-pixel
|
||
* math the grade shader runs, factored out so it is testable and the
|
||
* shader stays a thin wrapper. Order: brightness → saturation → tint →
|
||
* directional split → flare flash.
|
||
*
|
||
* `grade` shape (all optional):
|
||
* bright (0..1+, default 1) — brightness multiplier
|
||
* sat (0..1+, default 1) — saturation (1 = none, <1 desaturates)
|
||
* tint ([r,g,b] 0..1) — hue multiplier (see tintMultiplier)
|
||
* tintAmt(0..1, default 0) — how strongly the tint is applied
|
||
* liftColor ([r,g,b] 0..1) — shadow-lift hue (the red void; redDwarf)
|
||
* liftAmt (0..1, default 0) — shadow-lift strength
|
||
* split ({ a:[r,g,b], b:[r,g,b], axis:[x,y], mix:0..1 }) — two-color
|
||
* directional wash (binary)
|
||
* flash (0..1) — flare flash intensity
|
||
* flashColor ([r,g,b]) — the flash's color
|
||
*
|
||
* @param {[number,number,number]} rgb input color
|
||
* @param {object} grade grade parameters
|
||
* @returns {[number,number,number]}
|
||
*/
|
||
export function gradeColor(rgb, grade = {}) {
|
||
let r = rgb[0] ?? 1;
|
||
let g = rgb[1] ?? 1;
|
||
let b = rgb[2] ?? 1;
|
||
|
||
// 1. brightness
|
||
const bright = num(grade.bright, 1);
|
||
r *= bright; g *= bright; b *= bright;
|
||
|
||
// 2. saturation (mix toward luma)
|
||
const sat = num(grade.sat, 1);
|
||
if (sat !== 1) {
|
||
const l = 0.299 * r + 0.587 * g + 0.114 * b;
|
||
r = l + (r - l) * sat; g = l + (g - l) * sat; b = l + (b - l) * sat;
|
||
}
|
||
|
||
// 3. tint (hue multiplier, blended by amount)
|
||
const amt = num(grade.tintAmt, 0);
|
||
if (amt > 0 && Array.isArray(grade.tint)) {
|
||
r = r + (r * grade.tint[0] - r) * amt;
|
||
g = g + (g * grade.tint[1] - g) * amt;
|
||
b = b + (b * grade.tint[2] - b) * amt;
|
||
}
|
||
|
||
// 3b. shadow lift (the red void — only the darkness moves)
|
||
const liftAmt = num(grade.liftAmt, 0);
|
||
if (liftAmt > 0 && Array.isArray(grade.liftColor)) {
|
||
[r, g, b] = shadowLift([r, g, b], grade.liftColor, liftAmt);
|
||
}
|
||
|
||
// 4. directional split (two-color wash across the screen — binary)
|
||
const sp = grade.split;
|
||
if (sp && num(sp.mix, 0) > 0) {
|
||
const px = grade.pos?.[0] ?? 0.5;
|
||
const py = grade.pos?.[1] ?? 0.5;
|
||
const ax = sp.axis?.[0] ?? 1;
|
||
const ay = sp.axis?.[1] ?? 0;
|
||
const alen = Math.hypot(ax, ay) || 1;
|
||
let s = ((px - 0.5) * (ax / alen) + (py - 0.5) * (ay / alen)) * 2;
|
||
s = Math.min(1, Math.max(0, s * 0.5 + 0.5));
|
||
const m = num(sp.mix, 0);
|
||
const wa = sp.a ?? [1, 1, 1];
|
||
const wb = sp.b ?? [1, 1, 1];
|
||
r = r + (r * (wb[0] + (wa[0] - wb[0]) * s) - r) * m;
|
||
g = g + (g * (wb[1] + (wa[1] - wb[1]) * s) - g) * m;
|
||
b = b + (b * (wb[2] + (wa[2] - wb[2]) * s) - b) * m;
|
||
}
|
||
|
||
// 5. flare flash (additive warm spike)
|
||
const fl = num(grade.flash, 0);
|
||
if (fl > 0 && Array.isArray(grade.flashColor)) {
|
||
r += grade.flashColor[0] * fl;
|
||
g += grade.flashColor[1] * fl;
|
||
b += grade.flashColor[2] * fl;
|
||
}
|
||
|
||
return [r, g, b];
|
||
}
|