Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/pool/__tests__/pool-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { presets } from '@wavegrid/layout/client';

import {
cannonPoints,
CURRENT_N,
DEFAULT_POOL_SETTINGS,
type Hsb,
OutputSmoother,
Expand Down Expand Up @@ -322,3 +323,82 @@ describe('OutputSmoother', () => {
expect(quantize({ h: 359.6, s: 49.5, b: 0.4 })).toEqual({ h: 0, s: 50, b: 0 });
});
});

describe('the current', () => {
/** Drag a finger round the centre for `seconds`. */
function circle(field: PoolField, seconds: number, radius = 0.3) {
field.pointerDown(1, 0.5 + radius, 0.5, COLOR);
for (let t = 0; t < seconds; t += DT) {
const a = t * 2;
field.pointerMove(1, 0.5 + radius * Math.cos(a), 0.5 + radius * Math.sin(a));
field.step(DT);
}
field.pointerUp(1);
}
const angleOfFirst = (field: PoolField) => {
let first: { x: number; y: number } | null = null;
field.forEachSource((s) => {
if (!first) first = { x: s.x, y: s.y };
});
return first ? Math.atan2(first!.y - 0.5, first!.x - 0.5) : NaN;
};

it('a circling drag leaves a slow swirl that keeps carrying the light after release', () => {
const field = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true });
circle(field, 3);
expect(field.currentActive).toBe(true);
const c = field.currentAt(0.8, 0.5);
// Anticlockwise drag (in screen coords) → the water at the right moves down-screen.
expect(c.vy).toBeGreaterThan(0.005);
expect(Math.hypot(c.vx, c.vy)).toBeLessThan(0.1);
const a0 = angleOfFirst(field);
run(field, 20);
const turned = ((angleOfFirst(field) - a0 + 3 * Math.PI) % (2 * Math.PI)) - Math.PI;
// Carried round, slowly: a clear fraction of a lap in 20s, not a whole one.
expect(turned).toBeGreaterThan(0.4);
expect(turned).toBeLessThan(Math.PI);
expect(maxB(field.sampleAll(gracePoints))).toBeGreaterThan(20);
});

it('the water settles over minutes on its own and within seconds on release()', () => {
const slow = new PoolField();
circle(slow, 3);
const s0 = Math.hypot(slow.currentAt(0.8, 0.5).vx, slow.currentAt(0.8, 0.5).vy);
run(slow, 30);
const s30 = Math.hypot(slow.currentAt(0.8, 0.5).vx, slow.currentAt(0.8, 0.5).vy);
expect(s30).toBeLessThan(s0);
expect(s30).toBeGreaterThan(s0 * 0.25);

const released = new PoolField();
circle(released, 3);
released.release();
run(released, 12);
const c = released.currentAt(0.8, 0.5);
expect(Math.hypot(c.vx, c.vy)).toBeLessThan(s0 * 0.02);
});

it('reset() stills the water at once; a still pool sends no current', () => {
const field = new PoolField();
expect(field.snapshot().current).toEqual([]);
circle(field, 2);
expect(field.snapshot().current.length).toBe(CURRENT_N * CURRENT_N * 2);
field.reset();
expect(field.currentActive).toBe(false);
expect(field.currentAt(0.8, 0.5)).toEqual({ vx: 0, vy: 0 });
});

it('sources carried by the current stay inside the pool', () => {
const field = new PoolField({ ...DEFAULT_POOL_SETTINGS, hold: true, motion: 1 });
field.pointerDown(1, 0.1, 0.5, COLOR);
for (let t = 0; t < 2; t += DT) {
field.pointerMove(1, 0.1 + t * 0.4, 0.5);
field.step(DT);
}
field.pointerUp(1);
run(field, 30);
field.forEachSource((s) => {
expect(s.x).toBeGreaterThanOrEqual(-0.05);
expect(s.x).toBeLessThanOrEqual(1.05);
});
});
});
194 changes: 194 additions & 0 deletions packages/pool/src/current.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* The current: the water's memory of how it was stirred.
*
* A coarse velocity grid over the 0..1 square. Fingers push on it as they
* drag; the light sources are carried by it afterwards. A tiny pressure solve
* keeps the flow incompressible, which is what turns a push into a swirl —
* pull one side of the pool and the other side comes round to fill the gap —
* instead of shoving everything into a corner. It diffuses so neighbouring
* pushes agree, and it decays over minutes, so the pool keeps moving long
* after the hands are gone and eventually comes to rest.
*/

export const CURRENT_N = 16;
/** Current speeds are capped here (normalized units per second): a lap of the pool takes tens of seconds. */
export const CURRENT_MAX_SPEED = 0.08;
/** How long a swirl takes to fall to 1/e with nobody touching it. */
const CURRENT_TAU = 90;
/** How fast neighbouring cells pull each other's flow into agreement. */
const CURRENT_DIFFUSION = 0.6;
const PROJECT_ITERATIONS = 10;

const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const round3 = (v: number) => Math.round(v * 1000) / 1000;

export class Current {
readonly n = CURRENT_N;
private u = new Float32Array(CURRENT_N * CURRENT_N);
private v = new Float32Array(CURRENT_N * CURRENT_N);
private scratch = new Float32Array(CURRENT_N * CURRENT_N);
private pressure = new Float32Array(CURRENT_N * CURRENT_N);
private divergence = new Float32Array(CURRENT_N * CURRENT_N);
/** Time constant the flow currently dies on; release() shortens it. */
private tau = CURRENT_TAU;
private dirty = false;

/** Push the water near (x, y) toward moving at (vx, vy). `radius` in normalized units. */
push(x: number, y: number, vx: number, vy: number, radius: number, dt: number, maxSpeed = CURRENT_MAX_SPEED): void {
const speed = Math.hypot(vx, vy);
if (speed < 1e-6) return;
// A finger moves far faster than the water ever should: aim the current
// along the finger, at a slow fraction of its speed, capped.
const want = Math.min(maxSpeed, speed * 0.35);
const tx = (vx / speed) * want;
const ty = (vy / speed) * want;
const n = this.n;
const r2 = radius * radius;
const gain = 1 - Math.exp(-dt / 0.25);
const i0 = clamp(Math.floor((x - radius) * n), 0, n - 1);
const i1 = clamp(Math.ceil((x + radius) * n), 0, n - 1);
const j0 = clamp(Math.floor((y - radius) * n), 0, n - 1);
const j1 = clamp(Math.ceil((y + radius) * n), 0, n - 1);
for (let j = j0; j <= j1; j++) {
const cy = (j + 0.5) / n;
for (let i = i0; i <= i1; i++) {
const cx = (i + 0.5) / n;
const d2 = (cx - x) * (cx - x) + (cy - y) * (cy - y);
if (d2 > r2 * 4) continue;
const w = Math.exp((-0.5 * d2) / r2) * gain;
const k = j * n + i;
this.u[k] += (tx - this.u[k]) * w;
this.v[k] += (ty - this.v[k]) * w;
}
}
this.tau = CURRENT_TAU;
this.dirty = true;
}

/** Advance: diffuse, keep incompressible, decay. */
step(dt: number): void {
if (!this.dirty) return;
const n = this.n;
const { u, v, scratch } = this;

// Diffuse: each cell eases toward the mean of its neighbours.
const a = Math.min(0.24, CURRENT_DIFFUSION * dt);
for (const f of [u, v]) {
for (let j = 0; j < n; j++) {
for (let i = 0; i < n; i++) {
const k = j * n + i;
const l = f[j * n + Math.max(0, i - 1)];
const r = f[j * n + Math.min(n - 1, i + 1)];
const up = f[Math.max(0, j - 1) * n + i];
const dn = f[Math.min(n - 1, j + 1) * n + i];
scratch[k] = f[k] + a * ((l + r + up + dn) * 0.25 - f[k]);
}
}
f.set(scratch);
}

this.walls();
this.project();

// Decay, and notice when the water has come to rest.
const decay = Math.exp(-dt / this.tau);
let energy = 0;
for (let k = 0; k < u.length; k++) {
u[k] *= decay;
v[k] *= decay;
energy += u[k] * u[k] + v[k] * v[k];
}
if (energy < 1e-7) {
u.fill(0);
v.fill(0);
this.dirty = false;
}
}

/** Flow at a point, bilinear. */
at(x: number, y: number): { vx: number; vy: number } {
if (!this.dirty) return { vx: 0, vy: 0 };
const n = this.n;
const fx = clamp(x * n - 0.5, 0, n - 1);
const fy = clamp(y * n - 0.5, 0, n - 1);
const i = Math.min(n - 2, Math.floor(fx));
const j = Math.min(n - 2, Math.floor(fy));
const sx = fx - i;
const sy = fy - j;
const k00 = j * n + i;
const k10 = k00 + 1;
const k01 = k00 + n;
const k11 = k01 + 1;
const lerp2 = (f: Float32Array) =>
(f[k00] * (1 - sx) + f[k10] * sx) * (1 - sy) + (f[k01] * (1 - sx) + f[k11] * sx) * sy;
return { vx: lerp2(this.u), vy: lerp2(this.v) };
}

/** Still water. */
get active(): boolean {
return this.dirty;
}

/** Let the swirl die over a few seconds rather than minutes. */
release(): void {
this.tau = 2.5;
}

reset(): void {
this.u.fill(0);
this.v.fill(0);
this.tau = CURRENT_TAU;
this.dirty = false;
}

/** Interleaved [u, v] per cell, row-major, rounded for the wire; empty when still. */
snapshot(): number[] {
if (!this.dirty) return [];
const out: number[] = new Array(this.u.length * 2);
for (let k = 0; k < this.u.length; k++) {
out[2 * k] = round3(this.u[k]);
out[2 * k + 1] = round3(this.v[k]);
}
return out;
}

/** Nothing flows through the edge of the pool. */
private walls(): void {
const n = this.n;
for (let j = 0; j < n; j++) {
this.u[j * n] = 0;
this.u[j * n + n - 1] = 0;
}
for (let i = 0; i < n; i++) {
this.v[i] = 0;
this.v[(n - 1) * n + i] = 0;
}
}

/** Remove divergence: a few Gauss–Seidel sweeps for pressure, then subtract its gradient. */
private project(): void {
const n = this.n;
const { u, v, pressure: p, divergence: div } = this;
const idx = (i: number, j: number) => clamp(j, 0, n - 1) * n + clamp(i, 0, n - 1);
for (let j = 0; j < n; j++) {
for (let i = 0; i < n; i++) {
div[j * n + i] = 0.5 * (u[idx(i + 1, j)] - u[idx(i - 1, j)] + v[idx(i, j + 1)] - v[idx(i, j - 1)]);
}
}
p.fill(0);
for (let it = 0; it < PROJECT_ITERATIONS; it++) {
for (let j = 0; j < n; j++) {
for (let i = 0; i < n; i++) {
p[j * n + i] = (p[idx(i - 1, j)] + p[idx(i + 1, j)] + p[idx(i, j - 1)] + p[idx(i, j + 1)] - div[j * n + i]) * 0.25;
}
}
}
for (let j = 0; j < n; j++) {
for (let i = 0; i < n; i++) {
u[j * n + i] -= 0.5 * (p[idx(i + 1, j)] - p[idx(i - 1, j)]);
v[j * n + i] -= 0.5 * (p[idx(i, j + 1)] - p[idx(i, j - 1)]);
}
}
this.walls();
}
}
1 change: 1 addition & 0 deletions packages/pool/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './pool-field';
export * from './current';
31 changes: 28 additions & 3 deletions packages/pool/src/pool-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* hue and saturation are the energy-weighted mix of whatever is glowing there.
*/

import { Current, CURRENT_MAX_SPEED, CURRENT_N } from './current';

export type PoolMode = 'flow' | 'spiral' | 'droplets';

export interface PoolSettings {
Expand Down Expand Up @@ -98,8 +100,12 @@ export interface PoolSnapshot {
settings: PoolSettings;
sources: SourceSnapshot[];
spiral: { cx: number; cy: number; omega: number };
/** The current, CURRENT_N² cells of [u, v], row-major; empty when the water is still. */
current: number[];
}

export { CURRENT_N };

interface Touch {
/** Where the finger actually is. */
tx: number;
Expand Down Expand Up @@ -164,6 +170,8 @@ export class PoolField {
private spiral = { cx: 0.5, cy: 0.5, omega: 0, targetCx: 0.5, targetCy: 0.5, targetOmega: 0 };
/** Total smoothed finger speed — the field brightens a touch when it is stirred. */
private stir = 0;
/** How the water remembers being stirred; sources ride it. */
private current = new Current();
/** Bumped by reset(), so whoever smooths the output knows to drop its state too. */
generation = 0;

Expand Down Expand Up @@ -230,6 +238,17 @@ export class PoolField {
s.releasing = true;
s.target = 0;
}
this.current.release();
}

/** Whether the water is still moving on its own. */
get currentActive(): boolean {
return this.current.active;
}

/** Flow at a point in normalized units per second. */
currentAt(x: number, y: number): { vx: number; vy: number } {
return this.current.at(x, y);
}

/** Drop everything at once. Only for leaving the tab; the show uses release(). */
Expand All @@ -240,6 +259,7 @@ export class PoolField {
this.spiral.omega = 0;
this.spiral.targetOmega = 0;
this.stir = 0;
this.current.reset();
}

/** Advance the field by `dt` seconds. */
Expand All @@ -253,6 +273,7 @@ export class PoolField {

// 1. Fingers: chase the raw position, measure velocity, deposit along the way.
const k = lagStep(dt, INPUT_TAU);
const currentSpeed = lerp(0.3, 1, this.settings.motion) * CURRENT_MAX_SPEED;
let stirTarget = 0;
let sumX = 0;
let sumY = 0;
Expand All @@ -269,6 +290,8 @@ export class PoolField {
t.age += dt;
t.sinceDeposit += dt;
stirTarget += Math.hypot(t.vx, t.vy);
// Every drag stirs the water, whatever it deposits.
this.current.push(t.x, t.y, t.vx, t.vy, sigma * 1.5, dt, currentSpeed);
sumX += t.x;
sumY += t.y;

Expand Down Expand Up @@ -304,6 +327,7 @@ export class PoolField {
}
}
this.stir = lerp(this.stir, stirTarget, lagStep(dt, 0.5));
this.current.step(dt);

// 2. Spiral: centre is the fingers' centroid, spin follows their circling.
if (mode === 'spiral') {
Expand Down Expand Up @@ -360,8 +384,9 @@ export class PoolField {
s.energy *= Math.exp(-ringGrow * 2.5);
continue;
}
s.x += s.vx * dt;
s.y += s.vy * dt;
const c = this.current.at(s.x, s.y);
s.x = clamp(s.x + (s.vx + c.vx) * dt, -0.05, 1.05);
s.y = clamp(s.y + (s.vy + c.vy) * dt, -0.05, 1.05);
s.vx -= s.vx * dragK;
s.vy -= s.vy * dragK;
s.sigma += diffusion * dt;
Expand Down Expand Up @@ -438,7 +463,7 @@ export class PoolField {
kind: s.kind
});
}
return { settings: { ...this.settings }, sources, spiral: this.spiralState };
return { settings: { ...this.settings }, sources, spiral: this.spiralState, current: this.current.snapshot() };
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

private deposit(x: number, y: number, vx: number, vy: number, color: PoolColor, kind: Source['kind']): void {
Expand Down
Loading
Loading