Fable Review
This commit is contained in:
169
src/milkweed/umbel.js
Normal file
169
src/milkweed/umbel.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/* ============================================================
|
||||
umbel.js — a common-milkweed (Asclepias syriaca) inflorescence
|
||||
as a scene for the watercolour renderer.
|
||||
|
||||
Why it belongs (the taxonomy / scale-rhyme):
|
||||
a milkweed umbel is a *radial event*. A hidden central node;
|
||||
pedicels radiating outward like tracks; each ending not in a
|
||||
bubble but in a five-pointed star-flower — the reflexed corolla
|
||||
and its pale corona crown. It is the bubble chamber's vertex and
|
||||
δ-ray spray restated in botany: life wearing the same shape as
|
||||
the particle event. "Evidence of the invisible" → here the
|
||||
invisible is growth, the slow algorithm of a living thing.
|
||||
|
||||
generateUmbel(params) returns the same scene shape the watercolour
|
||||
renderer consumes, but expressed in its area-wash / stroke / dab
|
||||
primitives instead of particle tracks. Deterministic from seed.
|
||||
Parts carry a `kind` routed by the `milkweed` palette to a pigment:
|
||||
leaf/leafdk/ground → greens behind
|
||||
stem/pedicel/midrib → stalk & veins
|
||||
corolla/corollad → the dusty mauve reflexed petals
|
||||
corona/center → the pale crown & dark gynostegium
|
||||
bud/budtip → unopened maroon buds
|
||||
============================================================ */
|
||||
import { makeRng, gauss, cyrb53 } from '../rng.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
/* a lance/ovate leaf outline from base→tip, widest near the middle, with a gentle
|
||||
bend. Returns a closed polygon (frame coords). */
|
||||
function leafPoly(bx, by, tx, ty, halfW, bend = 0) {
|
||||
const dx = tx - bx, dy = ty - by, L = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / L, uy = dy / L; // spine
|
||||
const nx = -uy, ny = ux; // normal
|
||||
const left = [], right = [];
|
||||
const STEPS = 14;
|
||||
for (let i = 0; i <= STEPS; i++) {
|
||||
const t = i / STEPS;
|
||||
const w = halfW * Math.pow(Math.sin(Math.PI * t), 0.8);
|
||||
const b = bend * Math.sin(Math.PI * t); // sideways bow of the spine
|
||||
const sx = bx + dx * t + nx * b, sy = by + dy * t + ny * b;
|
||||
left.push([sx + nx * w, sy + ny * w]);
|
||||
right.push([sx - nx * w, sy - ny * w]);
|
||||
}
|
||||
const pts = [...left, ...right.reverse()].map(([x, y]) => ({ x, y }));
|
||||
return pts;
|
||||
}
|
||||
|
||||
/* sample a quadratic bezier into a short polyline (for curved pedicels/petals). */
|
||||
function quad(p0, c, p1, n = 6) {
|
||||
const out = [];
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = i / n, mt = 1 - t;
|
||||
out.push({
|
||||
x: mt * mt * p0.x + 2 * mt * t * c.x + t * t * p1.x,
|
||||
y: mt * mt * p0.y + 2 * mt * t * c.y + t * t * p1.y,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function generateUmbel(params) {
|
||||
const rng = makeRng(params.seed, 'umbel');
|
||||
const washes = [], strokes = [], dabs = [];
|
||||
|
||||
const cx = params.umbelX ?? 0.0;
|
||||
const cy = params.umbelY ?? -0.1; // cluster centre (slightly high)
|
||||
const R = params.umbelR ?? 0.4; // umbel radius — the hero
|
||||
const squash = params.umbelSquash ?? 0.98; // gentle vertical droop
|
||||
const nodeX = cx + (params.nodeDX ?? 0.0);
|
||||
const nodeY = cy + (params.nodeDY ?? 0.52); // pedicels attach below the globe
|
||||
const nFlor = Math.round(params.florets ?? 80);
|
||||
const baseR = params.flowerR ?? 0.092;
|
||||
|
||||
/* ---- foliage behind: a few big soft leaves splaying out from below the cluster,
|
||||
kept mostly clear of the umbel so the flowers read in front. Faint. ---- */
|
||||
const leafSpec = [ // [angle, length, halfWidth, foreground?] milkweed opposite-leaf feel
|
||||
[2.55, 1.05, 0.13, false], [0.62, 1.15, 0.14, false], // upper-left & upper-right, behind
|
||||
[2.05, 1.25, 0.15, true], [1.05, 1.05, 0.13, true], // lower flanks, nearer
|
||||
[1.55, 0.7, 0.10, false], // one straight down behind
|
||||
];
|
||||
const nLeaf = Math.min(leafSpec.length, Math.round(params.leaves ?? 4));
|
||||
for (let i = 0; i < nLeaf; i++) {
|
||||
const [a0, len0, hw0, fore] = leafSpec[i];
|
||||
const ang = a0 + gauss(rng) * 0.12;
|
||||
const len = len0 * (0.9 + rng() * 0.2);
|
||||
// start the blade just outside the umbel so it doesn't smother the flowers
|
||||
const bx = cx + Math.cos(ang) * R * 0.72 + gauss(rng) * 0.04;
|
||||
const by = cy + Math.sin(ang) * R * 0.72 * squash + 0.06;
|
||||
const tx = bx + Math.cos(ang) * len, ty = by + Math.sin(ang) * len;
|
||||
const hw = hw0 + rng() * 0.03, bend = gauss(rng) * 0.14;
|
||||
washes.push({ kind: fore ? 'leaf' : 'leafdk', pts: leafPoly(bx, by, tx, ty, hw, bend), alpha: fore ? 0.24 : 0.16 });
|
||||
strokes.push({ kind: 'midrib', pts: quad({ x: bx, y: by }, { x: (bx + tx) / 2 + bend, y: (by + ty) / 2 }, { x: tx, y: ty }, 6), width: 0.004, alpha: fore ? 0.2 : 0.12 });
|
||||
}
|
||||
|
||||
// main stalk, rising to the node from the bottom
|
||||
strokes.push({ kind: 'stem', pts: [{ x: cx + 0.015, y: 1.15 }, { x: cx + 0.005, y: nodeY + 0.28 }, { x: nodeX, y: nodeY }], width: 0.028, alpha: 0.5 });
|
||||
|
||||
/* ---- the florets on a drooping dome ---- */
|
||||
const rot0 = rng() * TAU;
|
||||
for (let i = 0; i < nFlor; i++) {
|
||||
// fill a disk, slightly denser toward the centre; depth (near→far) from radius
|
||||
// so the cluster reads as a rounded dome: centre flowers near/large/open, the
|
||||
// rim far/small/budding. This fills the middle (a hemisphere projection didn't).
|
||||
const rr = R * Math.pow(rng(), 0.62);
|
||||
const az = rng() * TAU;
|
||||
const fx = cx + Math.cos(az) * rr + gauss(rng) * 0.01;
|
||||
const fy = cy + Math.sin(az) * rr * squash + 0.03 + gauss(rng) * 0.01; // +droop
|
||||
const radial = rr / R; // 0 centre … 1 rim
|
||||
const depth = 1 - radial * radial + gauss(rng) * 0.12; // centre nearest
|
||||
const dA = 0.4 + 0.6 * Math.max(0, Math.min(1, depth)); // front brighter
|
||||
const size = 0.6 + 0.55 * Math.max(0, depth); // front flowers larger
|
||||
const fr = baseR * size;
|
||||
|
||||
// pedicel from node, gently bowing out to the floret
|
||||
const mid = { x: (nodeX + fx) / 2 + gauss(rng) * 0.04, y: (nodeY + fy) / 2 + gauss(rng) * 0.03 };
|
||||
strokes.push({ kind: 'pedicel', pts: quad({ x: nodeX, y: nodeY }, mid, { x: fx, y: fy }, 6), width: 0.0045, alpha: 0.26 * dA });
|
||||
|
||||
// back/upper florets tend to be unopened buds; front/lower are open stars
|
||||
const budProb = 0.05 + 0.22 * radial; // a minority, mostly toward the rim
|
||||
if (rng() < budProb) {
|
||||
// bud: a maroon teardrop (body + tip) with a pale highlight — kept modest so
|
||||
// the open flowers, not the buds, carry the cluster.
|
||||
const ba = -Math.PI * 0.5 + gauss(rng) * 0.6; // points roughly up/out
|
||||
const bl = fr * 0.62;
|
||||
const tipx = fx + Math.cos(ba) * bl, tipy = fy + Math.sin(ba) * bl;
|
||||
dabs.push({ kind: 'bud', x: fx, y: fy, r: fr * 0.32, alpha: 0.42 * dA });
|
||||
dabs.push({ kind: 'bud', x: (fx + tipx) / 2, y: (fy + tipy) / 2, r: fr * 0.22, alpha: 0.36 * dA });
|
||||
dabs.push({ kind: 'budtip', x: tipx, y: tipy, r: fr * 0.14, alpha: 0.32 * dA });
|
||||
} else {
|
||||
// open flower: 5 reflexed petals (a star) + pale corona crown + dark centre
|
||||
const rot = rot0 + i * 2.39996 + gauss(rng) * 0.3; // golden-angle spin
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const a = rot + k * (TAU / 5);
|
||||
const il = fr * 0.3, ol = fr * (1.32 + rng() * 0.22); // long rays splaying out
|
||||
const p0 = { x: fx + Math.cos(a) * il, y: fy + Math.sin(a) * il };
|
||||
const p1 = { x: fx + Math.cos(a) * ol, y: fy + Math.sin(a) * ol };
|
||||
// a slight reflex curve (petal bends back)
|
||||
const c = { x: (p0.x + p1.x) / 2 + Math.cos(a + Math.PI / 2) * fr * 0.16,
|
||||
y: (p0.y + p1.y) / 2 + Math.sin(a + Math.PI / 2) * fr * 0.16 };
|
||||
const shaded = (k % 2 === 0) && depth < 0.7;
|
||||
strokes.push({ kind: shaded ? 'corollad' : 'corolla', pts: quad(p0, c, p1, 5), width: 0.02 * size, alpha: 0.95 * dA });
|
||||
}
|
||||
// corona: a soft PALE centre (the crown reads light, the flower pink around it),
|
||||
// 5 little hoods, and only a faint dark eye — not a bullseye.
|
||||
dabs.push({ kind: 'corona', x: fx, y: fy, r: fr * 0.34, alpha: 0.4 * dA });
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const a = rot + k * (TAU / 5) + Math.PI / 5;
|
||||
dabs.push({ kind: 'corona', x: fx + Math.cos(a) * fr * 0.22, y: fy + Math.sin(a) * fr * 0.22, r: fr * 0.12, alpha: 0.46 * dA });
|
||||
}
|
||||
if (rng() < 0.6) dabs.push({ kind: 'center', x: fx, y: fy, r: fr * 0.09, alpha: 0.32 * dA });
|
||||
}
|
||||
}
|
||||
|
||||
// deterministic herbarium metadata (reuses the archive identity system)
|
||||
const hash = cyrb53(params.seed);
|
||||
const ds = parseInt(hash.slice(0, 8), 16);
|
||||
const plate = (parseInt(hash.slice(-3), 16) % 999).toString().padStart(3, '0');
|
||||
const year = 1901 + (ds % 96);
|
||||
const month = 6 + (ds >> 4) % 3; // milkweed blooms Jun–Aug
|
||||
const day = 1 + ((ds >> 8) % 28);
|
||||
const exposure = `${year}.${String(month).padStart(2, '0')}.${String(day).padStart(2, '0')}`;
|
||||
|
||||
return {
|
||||
tracks: [], washes, strokes, dabs,
|
||||
shock: null, artifacts: { rings: [] },
|
||||
lab: 'ASCLEPIAS SYRIACA · COMMON MILKWEED',
|
||||
plate, exposure, vertex: { x: cx, y: cy },
|
||||
};
|
||||
}
|
||||
@@ -147,10 +147,30 @@ function lifeColor(life, inv) {
|
||||
return hslToRgb(h, 0.72, (inv ? 0.44 : 0.62) - 0.07 * t);
|
||||
}
|
||||
|
||||
// botanical pigments for the milkweed umbel (a taxonomy subject sharing the plate
|
||||
// language). Keyed by the part `kind` the milkweed generator stamps. Mid-dark
|
||||
// subtractive pigments — real watercolours: sap green, quinacridone rose/magenta,
|
||||
// a dusty mauve corolla, a pale crown that barely tints the paper.
|
||||
const MILK = {
|
||||
leaf: [110, 140, 86], leafdk: [80, 108, 64],
|
||||
stem: [98, 124, 66], pedicel: [128, 156, 88],
|
||||
midrib: [150, 100, 84],
|
||||
corolla: [168, 104, 134], corollad: [126, 70, 100],
|
||||
corona: [228, 202, 210], center: [112, 76, 86],
|
||||
bud: [134, 70, 92], budtip: [180, 130, 142],
|
||||
ground: [120, 140, 118],
|
||||
};
|
||||
|
||||
/* ---------- the registry: each entry is a "feel" ---------- */
|
||||
export const PALETTES = {
|
||||
mono: { id: 'mono', label: 'Monochrome' }, // all defaults → B&W
|
||||
|
||||
// botanical: one pigment per flower part — the milkweed umbel as a wet plate.
|
||||
milkweed: {
|
||||
id: 'milkweed', label: 'Milkweed (botanical)',
|
||||
ink: (t, e) => MILK[t.kind] || e.baseInk,
|
||||
},
|
||||
|
||||
charge: {
|
||||
id: 'charge', label: 'Charge duotone',
|
||||
ink: (t, e) => chargeColor(t.q ?? 1, e.inv),
|
||||
|
||||
427
src/render/watercolor.js
Normal file
427
src/render/watercolor.js
Normal file
@@ -0,0 +1,427 @@
|
||||
/* ============================================================
|
||||
watercolor.js — the wet renderer. One scene model → pigment.
|
||||
|
||||
The conceit (why this belongs in the project):
|
||||
a bubble chamber is superheated liquid hydrogen, and the track
|
||||
is a wake of boiling bubbles *in a fluid*. Watercolour is pigment
|
||||
migrating through water on paper. To render the trace as ink in
|
||||
water is not a style laid over the physics — it is the same
|
||||
physics restated in a wetter medium. The slow end of a track
|
||||
(the particle losing momentum, ionising harder: density ∝ 1/β²)
|
||||
is exactly where the wash pools darkest. The δ-ray's logarithmic
|
||||
curl becomes a curl of pigment; the shock disk blooms like a
|
||||
drop released in still water. "Evidence of the invisible," in
|
||||
the most literal wet sense.
|
||||
|
||||
The craft — the watercolour "tells" reproduced here:
|
||||
1. Subtractive pigment optics (Beer–Lambert): paper·e^(−A).
|
||||
Density saturates softly, never to flat black — the glow of
|
||||
the paper survives under every wash.
|
||||
2. The wet edge / edge-darkening: as a wash dries, water carries
|
||||
pigment to the receding perimeter, leaving a darker rim. The
|
||||
single most recognisable watercolour signature. (rim = the
|
||||
sharp pigment minus its own diffusion.)
|
||||
3. Granulation: pigment settles into the tooth of cold-press
|
||||
paper, mottling the density and separating in the valleys.
|
||||
4. Wet-into-wet bleed: a soft diffused halo beyond each stroke.
|
||||
5. Pigment load tracks speed: heavy pooled pigment at the
|
||||
slow/dense end of range, thin dry-brush where the particle is
|
||||
fast — this falls straight out of the bubble density model.
|
||||
6. The paper itself: warm rag, cold-press tooth, a back-light
|
||||
bloom, a deckle edge and faint cockle. Unpainted paper is
|
||||
never dead white.
|
||||
|
||||
Geometry is identical to the photographic + vector renderers; only
|
||||
the medium differs. Deterministic from params.seed.
|
||||
============================================================ */
|
||||
import { makeRng } from '../rng.js';
|
||||
import { sampleBubbles, trackInkWeight, depthFactors } from '../scene/bubbles.js';
|
||||
import { mottleCanvas } from './noise.js';
|
||||
import { resolvePalette, paperTone } from './palette.js';
|
||||
|
||||
const MARGIN = 0.02;
|
||||
|
||||
/* deep pigments for the monochrome default — a sympathetic-magic palette of
|
||||
real watercolours: a warm sepia bound with a cool payne's-grey shadow, the
|
||||
pairing landscape painters reach for when they want "almost black" that still
|
||||
breathes. Used as baseInk so the mono feel reads as ink-in-water, not toner. */
|
||||
const PIGMENT_SEPIA = [54, 33, 20]; // warm walnut-sepia (the body of the wash)
|
||||
const PIGMENT_SHADOW = [20, 31, 54]; // cool payne's-grey (granulates into valleys)
|
||||
|
||||
// fast particles ionise less → thinner, paler tracks; let them recede so the slow,
|
||||
// blooming focal event holds the eye (atmosphere via physics, not arbitrary dimming).
|
||||
const KIND_LOAD = { primary: 1, delta: 1, vdecay: 1, cosmic: 0.55, sweep: 0.6 };
|
||||
|
||||
/* a scratch canvas pool keyed by size, so we are not allocating 1200² buffers
|
||||
on every blur read. */
|
||||
const scratch = { w: 0, a: null, b: null };
|
||||
function scratchPair(w, h) {
|
||||
if (scratch.w !== w) {
|
||||
scratch.w = w;
|
||||
scratch.a = document.createElement('canvas'); scratch.a.width = w; scratch.a.height = h;
|
||||
scratch.b = document.createElement('canvas'); scratch.b.width = w; scratch.b.height = h;
|
||||
}
|
||||
return [scratch.a.getContext('2d', { willReadFrequently: true }),
|
||||
scratch.b.getContext('2d', { willReadFrequently: true })];
|
||||
}
|
||||
|
||||
/* read one channel of a canvas into a Float32 field in [0,1]. */
|
||||
function readField(ctx, w, h, chan = 0) {
|
||||
const d = ctx.getImageData(0, 0, w, h).data;
|
||||
const f = new Float32Array(w * h);
|
||||
for (let i = 0, p = chan; i < f.length; i++, p += 4) f[i] = d[p] / 255;
|
||||
return f;
|
||||
}
|
||||
|
||||
/* paper tooth field: cold-press grain (fine) modulated by a broad cockle/mottle,
|
||||
in [0,1] where 1 = a raised fibre (catches less pigment) and 0 = a valley
|
||||
(pools / granulates). Built from the project's own fbm mottle so it stays
|
||||
deterministic and consistent with the other renderers' analog layer. */
|
||||
function toothField(seed, w, h) {
|
||||
const fine = mottleCanvas(seed + '::tooth', 512, 150, 5); // paper grain
|
||||
const broad = mottleCanvas(seed + '::cockle', 512, 6, 4); // cockle / undulation
|
||||
const [ca] = scratchPair(w, h);
|
||||
ca.clearRect(0, 0, w, h);
|
||||
ca.drawImage(fine, 0, 0, w, h);
|
||||
const fineF = readField(ca, w, h);
|
||||
ca.clearRect(0, 0, w, h);
|
||||
ca.drawImage(broad, 0, 0, w, h);
|
||||
const broadF = readField(ca, w, h);
|
||||
const out = new Float32Array(w * h);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
// centre both around 0, combine, recentre — fine dominates, broad tilts it
|
||||
const t = 0.5 + (fineF[i] - 0.5) * 0.85 + (broadF[i] - 0.5) * 0.5;
|
||||
out[i] = Math.max(0, Math.min(1, t));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* blur a Float32 field by rendering it through the GPU canvas blur and reading
|
||||
it back — far faster than a JS convolution at print sizes. */
|
||||
function blurField(field, w, h, radiusPx) {
|
||||
const [ca, cb] = scratchPair(w, h);
|
||||
const img = ca.createImageData(w, h);
|
||||
const d = img.data;
|
||||
for (let i = 0, p = 0; i < field.length; i++, p += 4) {
|
||||
const v = Math.max(0, Math.min(255, field[i] * 255));
|
||||
d[p] = d[p + 1] = d[p + 2] = v; d[p + 3] = 255;
|
||||
}
|
||||
ca.putImageData(img, 0, 0);
|
||||
cb.clearRect(0, 0, w, h);
|
||||
cb.filter = `blur(${radiusPx.toFixed(2)}px)`;
|
||||
cb.drawImage(ca.canvas, 0, 0);
|
||||
cb.filter = 'none';
|
||||
return readField(cb, w, h);
|
||||
}
|
||||
|
||||
export function renderWatercolor(ctx, w, h, scene, params, opts = {}) {
|
||||
const pre = opts.preview !== false;
|
||||
const u = w / 1000; // unit scale relative to 1000px
|
||||
const scale = (w / 2) * (1 - MARGIN);
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const tx = (x) => cx + x * scale;
|
||||
const ty = (y) => cy + y * scale;
|
||||
|
||||
// ---- the medium dials (all optional; sensible wet defaults) ----
|
||||
const D = {
|
||||
pigment: params.wcPigment ?? 1.0, // overall optical density
|
||||
edge: params.wcEdge ?? 1.0, // wet-edge / edge-darkening strength
|
||||
bleed: params.wcBleed ?? 1.0, // wet-into-wet halo
|
||||
granulate: params.wcGranulate ?? 1.0, // how much the paper tooth bites
|
||||
tooth: params.wcTooth ?? 1.0, // visible paper texture in the wash
|
||||
wetness: params.wcWetness ?? 1.0, // diffusion radius scale
|
||||
};
|
||||
|
||||
// ---- colour: a light rag-paper ground, pigments dark-on-light ----
|
||||
// Force the light-ground (positive) reading regardless of the global invert —
|
||||
// watercolour lives on paper. Palettes still map physics → pigment hue.
|
||||
const pt = paperTone({ ...params, paperBright: (params.paperBright ?? 1) * 1.06 }, true);
|
||||
const pal = resolvePalette(params.palette, {
|
||||
inv: false, sat: params.saturation ?? 1, hue: (params.hueShift ?? 0) * 360,
|
||||
cycles: params.hueCycles ?? 3,
|
||||
traceHue: params.traceHue ?? 0, diskHue: params.diskHue ?? 0.06, diskSat: params.diskSat ?? 0.82,
|
||||
baseInk: PIGMENT_SEPIA,
|
||||
basePaper: { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut },
|
||||
baseVign: pt.vign,
|
||||
});
|
||||
const paper = pal.hasPaper ? pal.paper() : { flat: pt.flat, glowIn: pt.glowIn, glowOut: pt.glowOut };
|
||||
|
||||
// ---- paper tooth (shared by every wash) ----
|
||||
const tooth = toothField(params.seed, w, h);
|
||||
|
||||
// global absorbance accumulators (Beer–Lambert, per channel)
|
||||
const N = w * h;
|
||||
const Ar = new Float32Array(N), Ag = new Float32Array(N), Ab = new Float32Array(N);
|
||||
const bubbleRng = makeRng(params.seed, 'bubbles');
|
||||
|
||||
/* lay one pigment down as a wash: `paint(mc)` deposits white "wet mass" onto a
|
||||
scratch context; we then derive the wet structure (edge-darkening, bleed,
|
||||
granulation) and pull the pigment's colour through Beer–Lambert into the
|
||||
global absorbance. `col` is the pigment; `granPartner` (optional) is a second
|
||||
pigment that separates into the tooth valleys — real granulating-watercolour
|
||||
behaviour, and the source of the wash's living, two-tone shimmer. */
|
||||
function layWash(paint, col, granPartner = null, granAmt = 0) {
|
||||
const [mc] = scratchPair(w, h);
|
||||
mc.clearRect(0, 0, w, h);
|
||||
mc.globalCompositeOperation = 'lighter'; // pigment mass accumulates
|
||||
mc.lineCap = 'round'; mc.lineJoin = 'round';
|
||||
paint(mc);
|
||||
mc.globalAlpha = 1;
|
||||
mc.globalCompositeOperation = 'source-over';
|
||||
|
||||
const M = readField(mc, w, h);
|
||||
const sharp = blurField(M, w, h, 1.15 * u); // soften beads into wash
|
||||
const diffuse = blurField(M, w, h, (4.5 * D.wetness) * u); // the drying-front spread
|
||||
const wide = blurField(M, w, h, (14 * D.wetness) * u); // the wet-into-wet halo
|
||||
|
||||
const cr = 1 - col[0] / 255, cg = 1 - col[1] / 255, cb = 1 - col[2] / 255;
|
||||
let gr = cr, gg = cg, gb = cb;
|
||||
if (granPartner) { gr = 1 - granPartner[0] / 255; gg = 1 - granPartner[1] / 255; gb = 1 - granPartner[2] / 255; }
|
||||
const k = 2.7 * D.pigment;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = sharp[i];
|
||||
if (s < 0.003 && wide[i] < 0.003) continue;
|
||||
// wet edge: water receding to the perimeter concentrates pigment in a rim.
|
||||
const rim = Math.max(0, s - diffuse[i]);
|
||||
// wet-into-wet halo: pigment carried out beyond the stroke into damp paper.
|
||||
const halo = Math.max(0, wide[i] - s);
|
||||
let p = s + rim * (2.2 * D.edge) + halo * (0.85 * D.bleed);
|
||||
// granulation: pigment settles in the tooth valleys (low tooth), thins on
|
||||
// the raised fibres — a textured, mottled density rather than a flat wash.
|
||||
const tv = tooth[i] - 0.5; // +raised fibre … −valley
|
||||
const g = 1 - D.granulate * (0.6 * tv + 0.12);
|
||||
p *= Math.max(0.18, g);
|
||||
const a = p * k;
|
||||
// the granulating partner deposits preferentially in the valleys
|
||||
const frac = granPartner ? granAmt * Math.max(0, 0.5 - tv) : 0;
|
||||
const aMain = a * (1 - frac), aGran = a * frac;
|
||||
Ar[i] += aMain * cr + aGran * gr;
|
||||
Ag[i] += aMain * cg + aGran * gg;
|
||||
Ab[i] += aMain * cb + aGran * gb;
|
||||
}
|
||||
}
|
||||
|
||||
// soft round pigment deposit (radial alpha) — no per-stamp filter (slow); the
|
||||
// diffusion blur in layWash carries the wet spread.
|
||||
function blob(mc, X, Y, R, a) {
|
||||
const grd = mc.createRadialGradient(X, Y, 0, X, Y, R);
|
||||
grd.addColorStop(0, `rgba(255,255,255,${a})`);
|
||||
grd.addColorStop(0.5, `rgba(255,255,255,${a * 0.5})`);
|
||||
grd.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
mc.fillStyle = grd;
|
||||
mc.beginPath(); mc.arc(X, Y, R, 0, Math.PI * 2); mc.fill();
|
||||
}
|
||||
|
||||
// ---- collect pigment deposits into colour buckets ----
|
||||
// Each bucket is one pigment with its own drying front (its own wet edge). For a
|
||||
// per-track palette a track contributes its whole self to one bucket; for a
|
||||
// per-bubble palette (lifecycle, kindrise, psychedelic…) each *bubble* lands in
|
||||
// the bucket of its own colour, so the wash shifts hue along the trail the way wet
|
||||
// pigment really migrates and separates. mono additionally carries a granulating
|
||||
// partner (cool payne's-grey settling out of the warm sepia).
|
||||
const isMono = (params.palette ?? 'mono') === 'mono';
|
||||
const perBubble = pal.perBubble;
|
||||
const QUANT = 28; // colour quantisation → bounded buckets
|
||||
const qkey = (c) => `${Math.round(c[0] / QUANT)}_${Math.round(c[1] / QUANT)}_${Math.round(c[2] / QUANT)}`;
|
||||
const deps = new Map(); // key → { col, fills, strokes, blobs }
|
||||
const bucketFor = (col) => {
|
||||
const key = qkey(col);
|
||||
let d = deps.get(key);
|
||||
if (!d) { d = { col, fills: [], strokes: [], blobs: [] }; deps.set(key, d); }
|
||||
return d;
|
||||
};
|
||||
|
||||
for (const track of scene.tracks || []) {
|
||||
if (track.pts.length < 2) continue;
|
||||
const df = depthFactors(track, params);
|
||||
const load = KIND_LOAD[track.kind] ?? 1;
|
||||
const repCol = pal.ink(track);
|
||||
// 1) continuity wash — a soft varying-width body of water, heavier where the
|
||||
// particle ionises hardest (1/β² via trackInkWeight) — to the track's pigment.
|
||||
const iw = trackInkWeight(track);
|
||||
const lw = Math.min(5.5, 0.7 + Math.sqrt(iw) * 0.6) * u * params.size * track.weight;
|
||||
if (lw >= 0.25 * u) bucketFor(repCol).strokes.push({ pts: track.pts, lw, alpha: 0.27 * df.tone * load });
|
||||
// 2) pigment grain — discrete bubbles (shared nucleation model); pooling at the
|
||||
// slow/dense end falls out of the 1/β² density for free. Per-bubble colour
|
||||
// when the palette varies along the trail.
|
||||
const bubs = sampleBubbles({ ...track, sizeScale: df.sizeScale }, params, bubbleRng);
|
||||
const a = Math.min(1, 0.5 + df.tone * 0.5) * load;
|
||||
for (const b of bubs) {
|
||||
const col = perBubble ? pal.bubbleInk(track, b.life, b.beta) : repCol;
|
||||
bucketFor(col).blobs.push({ x: tx(b.x), y: ty(b.y), r: Math.max(b.r * scale, 0.5) * 2.6, a: a * 0.36 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- explicit geometry primitives (used by non-particle subjects, e.g. the
|
||||
// milkweed umbel): area washes (leaves/ground), strokes (pedicels, petals,
|
||||
// midribs — no bubble grain), and dabs (corona crowns, bud bodies, centres).
|
||||
// Each carries its own pigment `kind`, routed through the same palette. All
|
||||
// optional → particle scenes set none of these and behave unchanged. ----
|
||||
const inkKind = (kind) => pal.ink({ kind, pts: [{ beta: 0.5 }], q: 1 });
|
||||
for (const wsh of (scene.washes || [])) {
|
||||
if (!wsh.pts || wsh.pts.length < 3) continue;
|
||||
bucketFor(inkKind(wsh.kind)).fills.push({ pts: wsh.pts, alpha: wsh.alpha ?? 0.45 });
|
||||
}
|
||||
for (const st of (scene.strokes || [])) {
|
||||
if (!st.pts || st.pts.length < 2) continue;
|
||||
bucketFor(inkKind(st.kind)).strokes.push({ pts: st.pts, lw: (st.width ?? 0.008) * scale, alpha: st.alpha ?? 0.3 });
|
||||
}
|
||||
for (const db of (scene.dabs || [])) {
|
||||
bucketFor(inkKind(db.kind)).blobs.push({ x: tx(db.x), y: ty(db.y), r: (db.r ?? 0.01) * scale, a: db.alpha ?? 0.5 });
|
||||
}
|
||||
|
||||
// ---- lay each pigment bucket as its own wash ----
|
||||
for (const { col, fills, strokes, blobs } of deps.values()) {
|
||||
layWash((mc) => {
|
||||
// area washes first (they sit beneath), then strokes, then grain/dabs
|
||||
for (const f of fills) {
|
||||
mc.globalAlpha = f.alpha;
|
||||
mc.beginPath(); mc.moveTo(tx(f.pts[0].x), ty(f.pts[0].y));
|
||||
for (let i = 1; i < f.pts.length; i++) mc.lineTo(tx(f.pts[i].x), ty(f.pts[i].y));
|
||||
mc.closePath(); mc.fillStyle = 'rgba(255,255,255,1)'; mc.fill();
|
||||
}
|
||||
for (const s of strokes) {
|
||||
mc.globalAlpha = s.alpha; mc.lineWidth = s.lw;
|
||||
mc.beginPath(); mc.moveTo(tx(s.pts[0].x), ty(s.pts[0].y));
|
||||
for (let i = 1; i < s.pts.length; i++) mc.lineTo(tx(s.pts[i].x), ty(s.pts[i].y));
|
||||
mc.stroke();
|
||||
}
|
||||
// larger, softer, lower-alpha deposits coalesce into a textured wash, not beads
|
||||
for (const b of blobs) blob(mc, b.x, b.y, b.r, b.a);
|
||||
}, col, isMono ? PIGMENT_SHADOW : null, isMono ? 0.5 : 0);
|
||||
}
|
||||
|
||||
// ---- the shock disk: a drop of pigment released into still water ----
|
||||
if (scene.shock && (params.diskOff !== true)) {
|
||||
const sh = scene.shock;
|
||||
const feat = pal.feature();
|
||||
const SX = tx(sh.x), SY = ty(sh.y), R = sh.r * scale;
|
||||
layWash((mc) => {
|
||||
// the bloom: a dense mid-radius ring (the drop's wet front) fading both ways
|
||||
mc.globalAlpha = 1;
|
||||
// a translucent bloom: faint, luminous centre (the drop's clearing eye)
|
||||
// rising to a soft wet front near the rim, then feathering out. Kept light
|
||||
// on purpose so paper-glow, striations and granulation read *through* it.
|
||||
const ring = mc.createRadialGradient(SX, SY, 0, SX, SY, R);
|
||||
const I = sh.intensity;
|
||||
ring.addColorStop(0.0, `rgba(255,255,255,${0.03 * I})`);
|
||||
ring.addColorStop(0.5, `rgba(255,255,255,${0.07 * I})`);
|
||||
ring.addColorStop(0.86, `rgba(255,255,255,${0.17 * I})`);
|
||||
ring.addColorStop(1.0, 'rgba(255,255,255,0)');
|
||||
mc.fillStyle = ring;
|
||||
mc.beginPath(); mc.arc(SX, SY, R, 0, Math.PI * 2); mc.fill();
|
||||
// striations as fine pigment threads radiating from the centre — now the
|
||||
// dominant disk gesture (the sunburst of a struck bell / a drop's corona).
|
||||
mc.globalCompositeOperation = 'lighter';
|
||||
mc.lineCap = 'round';
|
||||
for (const s of (sh.striations || [])) {
|
||||
const ix = SX + Math.cos(s.a) * s.inner * scale, iy = SY + Math.sin(s.a) * s.inner * scale;
|
||||
const ox = SX + Math.cos(s.a) * s.outer * scale, oy = SY + Math.sin(s.a) * s.outer * scale;
|
||||
mc.globalAlpha = s.opacity * 0.38;
|
||||
mc.lineWidth = (s.width || 1) * u;
|
||||
mc.beginPath(); mc.moveTo(ix, iy); mc.lineTo(ox, oy); mc.stroke();
|
||||
}
|
||||
// disk-bubble strokes (rings/rim/core) if present, as grain
|
||||
if (sh.bubbleStrokes) {
|
||||
const dRng = makeRng(params.seed, 'diskbubbles');
|
||||
for (const stroke of sh.bubbleStrokes) {
|
||||
const bubs = sampleBubbles(stroke, params, dRng);
|
||||
for (const b of bubs) blob(mc, tx(b.x), ty(b.y), Math.max(b.r * scale, 0.5) * 1.9, 0.24);
|
||||
}
|
||||
}
|
||||
}, feat, isMono ? PIGMENT_SHADOW : null, isMono ? 0.4 : 0);
|
||||
}
|
||||
|
||||
// ---- paper ground with back-light bloom + tooth, then pull pigment through it ----
|
||||
const img = ctx.createImageData(w, h);
|
||||
const D8 = img.data;
|
||||
const pf = paper.flat, pin = paper.glowIn, pout = paper.glowOut;
|
||||
const cxg = w * 0.5, cyg = h * 0.46, rg = w * 0.72;
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
// radial back-light: paper glows from within (a lit chamber behind the wash)
|
||||
const dx = (x - cxg) / rg, dy = (y - cyg) / rg;
|
||||
const rr = Math.min(1, Math.sqrt(dx * dx + dy * dy));
|
||||
const gl = 1 - rr * rr;
|
||||
let pr = pout[0] + (pin[0] - pout[0]) * gl;
|
||||
let pg = pout[1] + (pin[1] - pout[1]) * gl;
|
||||
let pb = pout[2] + (pin[2] - pout[2]) * gl;
|
||||
pr = pf[0] + (pr - pf[0]) * 0.55; pg = pf[1] + (pg - pf[1]) * 0.55; pb = pf[2] + (pb - pf[2]) * 0.55;
|
||||
// paper tooth visible even on bare paper (very subtle), stronger in the wash
|
||||
const th = (tooth[i] - 0.5);
|
||||
const toothShade = 1 - D.tooth * 0.05 * (0.5 - th) * 2;
|
||||
pr *= toothShade; pg *= toothShade; pb *= toothShade;
|
||||
// Beer–Lambert: paper · e^(−A)
|
||||
const p4 = i * 4;
|
||||
D8[p4] = Math.max(0, Math.min(255, pr * Math.exp(-Ar[i])));
|
||||
D8[p4 + 1] = Math.max(0, Math.min(255, pg * Math.exp(-Ag[i])));
|
||||
D8[p4 + 2] = Math.max(0, Math.min(255, pb * Math.exp(-Ab[i])));
|
||||
D8[p4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
|
||||
// ---- tidelines: the scene's own water rings read as pale pigment high-water
|
||||
// marks — a quiet rhyme between "plate damage" and the wet medium. ----
|
||||
if (scene.artifacts && scene.artifacts.rings && D.bleed > 0) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
for (const ring of scene.artifacts.rings) {
|
||||
const rr = ring.r * scale;
|
||||
const grd = ctx.createRadialGradient(tx(ring.x), ty(ring.y), rr * 0.86, tx(ring.x), ty(ring.y), rr);
|
||||
grd.addColorStop(0, 'rgba(120,96,70,0)');
|
||||
grd.addColorStop(0.7, `rgba(120,96,70,${0.05 * ring.opacity * 8})`);
|
||||
grd.addColorStop(1, 'rgba(120,96,70,0)');
|
||||
ctx.strokeStyle = grd;
|
||||
ctx.lineWidth = Math.max(1, rr * 0.06);
|
||||
ctx.beginPath(); ctx.arc(tx(ring.x), ty(ring.y), rr * 0.93, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---- deckle edge: the torn, soft border of hand-made paper ----
|
||||
drawDeckle(ctx, w, h, params, u);
|
||||
|
||||
// ---- the hand: a pencilled seed/lab caption, the studied archive note ----
|
||||
if (params.showHeader) drawCaption(ctx, w, h, scene, params, u);
|
||||
}
|
||||
|
||||
/* an irregular soft paper border — bright paper feathering to a faint torn line. */
|
||||
function drawDeckle(ctx, w, h, params, u) {
|
||||
const rng = makeRng(params.seed, 'deckle');
|
||||
const inset = 14 * u;
|
||||
ctx.save();
|
||||
// a soft inner vignette of *light* (paper curling up at the edges into the light)
|
||||
const vg = ctx.createRadialGradient(w / 2, h / 2, w * 0.40, w / 2, h / 2, w * 0.72);
|
||||
vg.addColorStop(0, 'rgba(40,30,18,0)');
|
||||
vg.addColorStop(1, 'rgba(40,30,18,0.10)');
|
||||
ctx.fillStyle = vg; ctx.fillRect(0, 0, w, h);
|
||||
// torn deckle line
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
ctx.strokeStyle = 'rgba(60,48,32,0.16)';
|
||||
ctx.lineWidth = 1.2 * u;
|
||||
ctx.beginPath();
|
||||
const edge = (along, base) => base + (rng() - 0.5) * 6 * u;
|
||||
ctx.moveTo(edge(0, inset), inset);
|
||||
for (let x = 0; x <= w; x += 24 * u) ctx.lineTo(x, edge(x, inset));
|
||||
for (let y = 0; y <= h; y += 24 * u) ctx.lineTo(edge(y, w - inset), y);
|
||||
for (let x = w; x >= 0; x -= 24 * u) ctx.lineTo(x, edge(x, h - inset));
|
||||
for (let y = h; y >= 0; y -= 24 * u) ctx.lineTo(edge(y, inset), y);
|
||||
ctx.closePath(); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* a faint graphite caption — the human who studied the plate, kept in. */
|
||||
function drawCaption(ctx, w, h, scene, params, u) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'multiply';
|
||||
ctx.fillStyle = 'rgba(58,52,44,0.55)';
|
||||
ctx.font = `${11 * u}px 'JetBrains Mono', ui-monospace, monospace`;
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(`${scene.lab}`, 30 * u, h - 30 * u - 15 * u);
|
||||
ctx.fillStyle = 'rgba(58,52,44,0.42)';
|
||||
ctx.font = `${9 * u}px 'JetBrains Mono', ui-monospace, monospace`;
|
||||
ctx.fillText(`seed ${params.seed} · plate ${scene.plate} · ${scene.exposure}`, 30 * u, h - 30 * u);
|
||||
ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user