Skip to content

Commit fad7050

Browse files
RuitingMaclaude
andcommitted
Cell: 2-wide flares with sideways diffusion
Path-light tuning pass: - PATH_MIN_LENGTH (7 Chebyshev): reject endpoint pairs whose span is too small so flares stretch substantially rather than barely tracing a corner. - 2-wide line core: each Bresenham step lights its own cell (d=0) and the cell one perpendicular step over (d=1) at the same instant, so the flare reads as a band rather than a thread. - Sideways spread: outside the core, layers d ∈ [-3, -2, -1, 2, 3, 4] (relative to the line direction) ignite with a per-layer delay (PATH_SPREAD_STEP_MS = 200ms) and peak alpha falling off geometrically (PATH_SPREAD_DECAY = 0.6 per layer). The flare reads as a moving glow that bleeds outward over time. - PATH_PEAK_ALPHA 1.0 → 0.7: less white-out at the line core. - PATH_FADE_MS 1800 → 3500: longer afterglow. - PATH_SPAWN_INTERVAL_MS 8000 → 5000: slightly more frequent. Per-cell entry now stores both litAt and peak (since spread cells have varying peaks), and the bbox-loop lookup uses peak * fade instead of just fade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e568f3c commit fad7050

1 file changed

Lines changed: 90 additions & 34 deletions

File tree

src/components/CellSketch.astro

Lines changed: 90 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,24 @@
108108
// cells barely respond and fast cells light up sharply.
109109
const MOMENTUM_REF = 3.0; // |thetaDot| (rad/s) at saturation
110110
const MOMENTUM_BOOST = 0.5; // peak additive alpha at saturation
111-
// Path-light flares: once in a while pick two random cells on the
112-
// grid boundary (padding-inclusive) and trace a Bresenham-grid path
113-
// between them; each cell on the path ignites at full alpha after a
114-
// small delay, then fades. Multiple paths can overlap; the per-cell
115-
// lit alpha takes max-blend with the veil base so the path stays
116-
// crisp on top of the existing tint.
117-
const PATH_SPAWN_INTERVAL_MS = 8000; // mean ms between spawns (Poisson)
118-
const PATH_STEP_MS = 110; // delay between successive cell ignitions
119-
const PATH_FADE_MS = 1800; // each cell's fade duration
111+
// Path-light flares: pick two random cells on the grid boundary
112+
// (padding-inclusive) with a minimum end-to-end span, trace a
113+
// Bresenham-grid line between them, and ignite cells along the way.
114+
// The line itself is 2 cells wide (the Bresenham trace + a single
115+
// cell offset on the perpendicular). Light then bleeds outward
116+
// perpendicular to the line, peak alpha decaying with distance and
117+
// each spread layer ignited a bit later than the one before — so
118+
// the flare reads as a moving glow that diffuses sideways. Multiple
119+
// paths can overlap; per-cell lit alpha takes max-blend, then
120+
// max-blends again with the veil + momentum boost.
121+
const PATH_SPAWN_INTERVAL_MS = 5000; // mean ms between spawns (Poisson)
122+
const PATH_STEP_MS = 110; // ms between successive cell ignitions along the line
123+
const PATH_FADE_MS = 3500; // each cell's fade duration
124+
const PATH_PEAK_ALPHA = 0.70; // peak alpha at the line core (full opacity ≡ 1)
125+
const PATH_MIN_LENGTH = 7; // Chebyshev cells; below this we skip & retry
126+
const PATH_MAX_SPREAD = 3; // perpendicular cells the flare bleeds into
127+
const PATH_SPREAD_DECAY = 0.60; // peak alpha ratio per cell of spread distance
128+
const PATH_SPREAD_STEP_MS = 200; // ms delay per cell of spread distance
120129

121130
// ----- Pre-rendered orbital point sprite (true exp decay) -----
122131
const DOT_HALF = 11;
@@ -317,9 +326,10 @@
317326
}
318327

319328
// ----- Path-light flares -----
329+
type PathCell = { litAt: number; peak: number };
320330
type PathAnim = {
321-
cellLitAt: Map<string, number>; // cellKey → ms when this cell ignites
322-
endTime: number; // ms when the last cell finishes fading
331+
cells: Map<string, PathCell>;
332+
endTime: number;
323333
};
324334
const activePaths: PathAnim[] = [];
325335

@@ -331,38 +341,82 @@
331341
if (side === 2) return [iMin + Math.floor(Math.random() * (iMax - iMin + 1)), jMin];
332342
return [iMin + Math.floor(Math.random() * (iMax - iMin + 1)), jMax];
333343
};
334-
let [i0, j0] = pickBoundary();
335-
let [i1, j1] = pickBoundary();
336-
let safety = 8;
337-
while (i0 === i1 && j0 === j1 && safety-- > 0) {
344+
// Reject endpoint pairs whose Chebyshev distance falls below
345+
// PATH_MIN_LENGTH so flares always span a substantial portion of
346+
// the grid. Bound the retry loop — for very narrow viewports the
347+
// constraint may be unsatisfiable; we just skip the spawn.
348+
let i0 = 0, j0 = 0, i1 = 0, j1 = 0;
349+
let found = false;
350+
for (let attempt = 0; attempt < 12; attempt++) {
351+
[i0, j0] = pickBoundary();
338352
[i1, j1] = pickBoundary();
353+
const cheb = Math.max(Math.abs(i1 - i0), Math.abs(j1 - j0));
354+
if (cheb >= PATH_MIN_LENGTH) { found = true; break; }
339355
}
340-
if (i0 === i1 && j0 === j1) return;
341-
342-
// Bresenham line on the integer grid: each step lands on an
343-
// adjacent cell (4- or 8-neighbor), which on this scale lines up
344-
// with Voronoi adjacency for the seeds. Path ignites cell-by-cell.
345-
const cellLitAt = new Map<string, number>();
356+
if (!found) return;
357+
358+
// Per-path perpendicular: 90° rotation of the overall direction
359+
// sign vector. Constant across the path so the flare reads as a
360+
// single straight band rather than a zigzag of per-step perps.
361+
const sgnDI = Math.sign(i1 - i0);
362+
const sgnDJ = Math.sign(j1 - j0);
363+
const perpI = -sgnDJ;
364+
const perpJ = sgnDI;
365+
366+
// Bresenham trace from start to end.
367+
const linePath: { i: number; j: number }[] = [];
346368
const di = Math.abs(i1 - i0);
347369
const dj = Math.abs(j1 - j0);
348370
const si = i0 < i1 ? 1 : -1;
349371
const sj = j0 < j1 ? 1 : -1;
350372
let err = di - dj;
351373
let i = i0, j = j0;
352-
let step = 0;
353-
// Safety bound just in case.
354374
const maxSteps = di + dj + 2;
355-
while (step < maxSteps) {
356-
cellLitAt.set(cellKey(i, j), now + step * PATH_STEP_MS);
357-
step++;
375+
while (linePath.length < maxSteps) {
376+
linePath.push({ i, j });
358377
if (i === i1 && j === j1) break;
359378
const e2 = 2 * err;
360379
if (e2 > -dj) { err -= dj; i += si; }
361380
if (e2 < di) { err += di; j += sj; }
362381
}
382+
383+
// Core of the band is 2 cells wide: d=0 is the Bresenham trace,
384+
// d=1 is one perpendicular step. Both ignite at the same moment
385+
// (no spread delay). For |d| beyond the core, distance from the
386+
// core grows by 1 per step on each side; that distance feeds both
387+
// the peak-alpha decay and the per-layer ignition delay so the
388+
// flare diffuses outward over time.
389+
const spreadDist = (d: number): number => {
390+
if (d === 0 || d === 1) return 0;
391+
return d > 1 ? d - 1 : -d;
392+
};
393+
const cells = new Map<string, PathCell>();
394+
let maxSpreadDist = 0;
395+
for (let s = 0; s < linePath.length; s++) {
396+
const p = linePath[s];
397+
for (let d = -PATH_MAX_SPREAD; d <= PATH_MAX_SPREAD + 1; d++) {
398+
const ci = p.i + d * perpI;
399+
const cj = p.j + d * perpJ;
400+
const dist = spreadDist(d);
401+
if (dist > maxSpreadDist) maxSpreadDist = dist;
402+
const peak = PATH_PEAK_ALPHA * Math.pow(PATH_SPREAD_DECAY, dist);
403+
const litAt = now + s * PATH_STEP_MS + dist * PATH_SPREAD_STEP_MS;
404+
const key = cellKey(ci, cj);
405+
const existing = cells.get(key);
406+
// Earliest non-zero peak wins — a cell touched by a later
407+
// step would otherwise overwrite the brighter earlier hit.
408+
if (!existing || peak > existing.peak) {
409+
cells.set(key, { litAt, peak });
410+
}
411+
}
412+
}
363413
activePaths.push({
364-
cellLitAt,
365-
endTime: now + (step - 1) * PATH_STEP_MS + PATH_FADE_MS,
414+
cells,
415+
endTime:
416+
now +
417+
(linePath.length - 1) * PATH_STEP_MS +
418+
maxSpreadDist * PATH_SPREAD_STEP_MS +
419+
PATH_FADE_MS,
366420
});
367421
}
368422

@@ -560,15 +614,17 @@
560614
const m = Math.min(1, Math.abs(c.thetaDot) / MOMENTUM_REF);
561615
const boost = MOMENTUM_BOOST * m * m;
562616
// Path-light: max-blend the largest active path contribution
563-
// for this cell against the veil. Linear fade from 1 → 0 over
564-
// PATH_FADE_MS starting at this cell's individual ignition time.
617+
// for this cell. Each cell on a path carries its own peak
618+
// (higher near the line core, lower in the spread halo); fade
619+
// is linear from peak → 0 across PATH_FADE_MS, starting at the
620+
// cell's own ignition time.
565621
let lit = 0;
566622
for (let pi = 0; pi < activePaths.length; pi++) {
567-
const t = activePaths[pi].cellLitAt.get(v.key);
568-
if (t === undefined) continue;
569-
const age = now - t;
623+
const entry = activePaths[pi].cells.get(v.key);
624+
if (!entry) continue;
625+
const age = now - entry.litAt;
570626
if (age < 0 || age > PATH_FADE_MS) continue;
571-
const a = 1 - age / PATH_FADE_MS;
627+
const a = entry.peak * (1 - age / PATH_FADE_MS);
572628
if (a > lit) lit = a;
573629
}
574630
v.fillAlpha = Math.max(Math.min(1, baseAlpha + boost), lit);

0 commit comments

Comments
 (0)