Skip to content

packages/core/src/features/textures/lib/rain-drops.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { defineTexture, type TextureContext, type TextureRecipe } from "@pibbl/core";
2 import { validateSeed } from "./palette.js";
3 
4 /**
5  * Rain rate, gravity, initial drops, condensation, trail, and outline controls for a raindrop texture.
6  *
7  * @see {@link rainDrops}
8  */
9 export interface RainDropsOptions {
10   /**
11    * Seed used for deterministic sampling or simulation initialization. See
12    * {@link RainDropsOptions}.
13    */
14   readonly seed?: number;
15   /** New beads per second, from 0 through 120. */
16   readonly rainRate?: number;
17   /** Downward acceleration multiplier, from 0 through 10. */
18   readonly gravity?: number;
19   /** Number of drops seeded before continuous rain starts. See {@link RainDropsOptions}. */
20   readonly initialDrops?: number;
21   /** Whether simulation or playback advancement is suspended. See {@link RainDropsOptions}. */
22   readonly paused?: boolean;
23   /** Fine droplet film coverage, from 0 (clear) through 1 (mist). */
24   readonly condensation?: number;
25   /** Seconds controlling trail drying and condensation recovery, from 0 through 60. */
26   readonly trailPersistence?: number;
27   /** Residual water left behind a moving drop, from 0 through 1. */
28   readonly trailStrength?: number;
29   /** Outline and path variation, from 0 (smooth) through 1. */
30   readonly irregularity?: number;
31 }
32 
33 /**
34  * Pause, reset, finger-wipe, release, and localized nudge commands for a raindrop texture.
35  *
36  * @see {@link rainDrops}
37  */
38 export type RainDropsMessage =
39   | {
40       /** The literal "pause" identifying this variant. See {@link RainDropsMessage}. */
41       readonly type: "pause";
42       /** Whether simulation or playback advancement is suspended. See {@link RainDropsMessage}. */
43       readonly paused: boolean;
44     }
45   | {
46       /** The literal "reset" identifying this variant. See {@link RainDropsMessage}. */
47       readonly type: "reset";
48     }
49   /** Begin or move one finger contact; successive positions wipe a continuous path. */
50   | {
51       /** The literal "finger" identifying this variant. See {@link RainDropsMessage}. */
52       readonly type: "finger";
53       /**
54        * Horizontal coordinate or displacement in the containing coordinate system. See
55        * {@link RainDropsMessage}.
56        */
57       readonly x: number;
58       /**
59        * Vertical coordinate or displacement in the containing coordinate system. See
60        * {@link RainDropsMessage}.
61        */
62       readonly y: number;
63       /** Radius in the coordinate system of this geometry or effect. See {@link RainDropsMessage}. */
64       readonly radius?: number;
65     }
66   | {
67       /** The literal "release" identifying this variant. See {@link RainDropsMessage}. */
68       readonly type: "release";
69     }
70   /** Center and movement use normalized width/height; radius uses the shorter side. */
71   | {
72       /** The literal "nudge" identifying this variant. See {@link RainDropsMessage}. */
73       readonly type: "nudge";
74       /**
75        * Horizontal coordinate or displacement in the containing coordinate system. See
76        * {@link RainDropsMessage}.
77        */
78       readonly x: number;
79       /**
80        * Vertical coordinate or displacement in the containing coordinate system. See
81        * {@link RainDropsMessage}.
82        */
83       readonly y: number;
84       /** Horizontal displacement applied by the interaction. See {@link RainDropsMessage}. */
85       readonly dx: number;
86       /** Vertical displacement applied by the interaction. See {@link RainDropsMessage}. */
87       readonly dy: number;
88       /** Radius in the coordinate system of this geometry or effect. See {@link RainDropsMessage}. */
89       readonly radius?: number;
90     };
91 
92 interface Drop { x: number; y: number; radius: number; vx: number; vy: number; phase: number }
93 interface Config { seed: number; rainRate: number; gravity: number; initialDrops: number; paused: boolean; condensation: number; trailPersistence: number; trailStrength: number; irregularity: number }
94 interface State {
95   finger?: { x: number; y: number; radius: number; drop?: Drop };
96   config: Config;
97   drops: Drop[];
98   random: number;
99   births: number;
100   paused: boolean;
101   fresh: boolean;
102   image: ImageData;
103   film: Float32Array;
104   trails: Float32Array;
105   surface: Float32Array;
106   grain: Float32Array;
107   fieldActive: boolean;
108 }
109 const MAX_DROPS = 256;
110 
111 function bounded(value: number, name: string, maximum: number): number {
112   if (!Number.isFinite(value) || value < 0 || value > maximum) throw new RangeError(`Rain ${name} must be finite and within [0, ${maximum}].`);
113   return value;
114 }
115 function options(input: RainDropsOptions): Config {
116   const initialDrops = bounded(input.initialDrops ?? 100, "initialDrops", MAX_DROPS);
117   if (!Number.isInteger(initialDrops)) throw new RangeError("Rain initialDrops must be an integer.");
118   return { seed: validateSeed(input.seed ?? 7), rainRate: bounded(input.rainRate ?? 24, "rainRate", 120), gravity: bounded(input.gravity ?? 1, "gravity", 10), initialDrops, paused: input.paused ?? false,
119     condensation: bounded(input.condensation ?? 0, "condensation", 1),
120     trailPersistence: bounded(input.trailPersistence ?? 8, "trailPersistence", 60),
121     trailStrength: bounded(input.trailStrength ?? 0.65, "trailStrength", 1),
122     irregularity: bounded(input.irregularity ?? 0.8, "irregularity", 1) };
123 }
124 function assertMap(context: TextureContext): void {
125   if (context.palette !== undefined) throw new TypeError("rainDrops is a numeric displacement map and does not accept a palette.");
126 }
127 function random(state: State): number {
128   state.random = (Math.imul(state.random, 1664525) + 1013904223) >>> 0;
129   return state.random / 4294967296;
130 }
131 function spawn(state: State, context: TextureContext): void {
132   if (state.drops.length >= MAX_DROPS) return;
133   const side = Math.min(context.canvas.width, context.canvas.height);
134   state.drops.push({ x: random(state) * context.canvas.width / side, y: random(state) * context.canvas.height / side, radius: 0.005 + random(state) ** 2 * 0.015, vx: 0, vy: 0, phase: random(state) * Math.PI * 2 });
135 }
136 function reset(state: State, context: TextureContext): void {
137   state.finger = undefined;
138   state.random = state.config.seed; state.drops = []; state.births = 0; state.fresh = true;
139   state.film.fill(1); state.trails.fill(0); state.fieldActive = false;
140   for (let i = 0; i < state.grain.length; i++) {
141     let value = Math.imul(i + 1, 0x45d9f3b) ^ state.config.seed;
142     value = Math.imul(value ^ (value >>> 16), 0x45d9f3b);
143     state.grain[i] = ((value ^ (value >>> 16)) >>> 0) / 4294967296;
144   }
145   for (let i = 0; i < state.config.initialDrops; i++) spawn(state, context);
146 }
147 /** Coalescence conserves volume and volume-weighted position/momentum. */
148 function merge(drops: Drop[]): void {
149   for (let i = 0; i < drops.length; i++) {
150     const a = drops[i];
151     for (let j = i + 1; j < drops.length; j++) {
152       const b = drops[j];
153       if (Math.hypot(a.x - b.x, a.y - b.y) >= a.radius + b.radius) continue;
154       const av = a.radius ** 3, bv = b.radius ** 3, volume = av + bv;
155       a.x = (a.x * av + b.x * bv) / volume; a.y = (a.y * av + b.y * bv) / volume;
156       a.vx = (a.vx * av + b.vx * bv) / volume; a.vy = (a.vy * av + b.vy * bv) / volume;
157       a.radius = Math.cbrt(volume);
158       drops.splice(j--, 1);
159     }
160   }
161 }
162 function active(state: State): boolean {
163   return state.fieldActive || state.config.rainRate > 0 || state.drops.some(d => (state.config.gravity > 0 && d.radius > 0.012) || Math.abs(d.vx) + Math.abs(d.vy) > 0.0001);
164 }
165 function schedule(state: State, context: TextureContext): void {
166   if (!state.paused && active(state)) context.requestFrame();
167 }
168 
169 /** Sweep the actual drop footprint, collecting film and leaving a beaded residual trail. */
170 function sweep(state: State, drop: Drop, oldX: number, oldY: number, context: TextureContext): void {
171   const { width, height } = context.canvas, side = Math.min(width, height);
172   const ax = oldX * side, ay = oldY * side, bx = drop.x * side, by = drop.y * side;
173   const dx = bx - ax, dy = by - ay, length2 = dx * dx + dy * dy;
174   if (length2 < 0.000001) return;
175   const radius = Math.max(1, drop.radius * side * 1.1);
176   let collected = 0;
177   for (let y = Math.max(0, Math.floor(Math.min(ay, by) - radius)); y < Math.min(height, Math.ceil(Math.max(ay, by) + radius)); y++) {
178     for (let x = Math.max(0, Math.floor(Math.min(ax, bx) - radius)); x < Math.min(width, Math.ceil(Math.max(ax, bx) + radius)); x++) {
179       const t = Math.max(0, Math.min(1, ((x + 0.5 - ax) * dx + (y + 0.5 - ay) * dy) / length2));
180       const distance = Math.hypot(x + 0.5 - ax - t * dx, y + 0.5 - ay - t * dy) / radius;
181       if (distance >= 1) continue;
182       const i = y * width + x;
183       const remaining = Math.max(0, Math.min(1, (distance - 0.7) / 0.3));
184       const removed = Math.max(0, state.film[i] - remaining);
185       collected += removed;
186       state.film[i] = Math.min(state.film[i], remaining);
187       // Spatially stable necks and beads avoid frame-rate-dependent trail stamping.
188       const neck = 0.18 + 0.12 * Math.sin(y * 0.7 + drop.phase);
189       const bead = Math.pow(state.grain[i], 7);
190       const residual = Math.max(0, 1 - distance / (neck + bead * 0.6));
191       state.trails[i] = Math.max(state.trails[i], residual * (0.00045 + bead * 0.0018) * state.config.trailStrength);
192     }
193   }
194   // Film volume is swept area times a small effective thickness.
195   drop.radius = Math.cbrt(drop.radius ** 3 + collected / (side * side) * state.config.condensation * 0.0002);
196   state.fieldActive = true;
197 }
198 
199 /** At capacity, coalesce the closest free beads before releasing newly gathered water. */
200 function makeReleaseRoom(drops: Drop[]): void {
201   if (drops.length < MAX_DROPS) return;
202   let first = 0, second = 1, nearest = Infinity;
203   for (let i = 0; i < drops.length; i++) for (let j = i + 1; j < drops.length; j++) {
204     const distance = Math.hypot(drops[i].x - drops[j].x, drops[i].y - drops[j].y);
205     if (distance < nearest) { nearest = distance; first = i; second = j; }
206   }
207   const a = drops[first], b = drops[second], av = a.radius ** 3, bv = b.radius ** 3, volume = av + bv;
208   a.x = (a.x * av + b.x * bv) / volume; a.y = (a.y * av + b.y * bv) / volume;
209   a.vx = (a.vx * av + b.vx * bv) / volume; a.vy = (a.vy * av + b.vy * bv) / volume;
210   a.radius = Math.cbrt(volume); drops.splice(second, 1);
211 }
212 
213 /** A finger removes film and residual water, and carries intersected beads without inertia. */
214 function wipeFinger(state: State, oldX: number, oldY: number, context: TextureContext): void {
215   const finger = state.finger;
216   if (!finger) return;
217   const { width, height } = context.canvas, side = Math.min(width, height);
218   const dx = finger.x - oldX, dy = finger.y - oldY, length2 = dx * dx + dy * dy;
219   const distance = (x: number, y: number): number => {
220     const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((x - oldX) * dx + (y - oldY) * dy) / length2));
221     return Math.hypot(x - oldX - t * dx, y - oldY - t * dy);
222   };
223   let volume = finger.drop ? finger.drop.radius ** 3 : 0;
224   for (let i = state.drops.length - 1; i >= 0; i--) {
225     const drop = state.drops[i];
226     if (distance(drop.x, drop.y) > finger.radius + drop.radius * 1.3) continue;
227     volume += drop.radius ** 3;
228     state.drops.splice(i, 1);
229   }
230   for (let y = Math.max(0, Math.floor((Math.min(oldY, finger.y) - finger.radius) * side)); y < Math.min(height, Math.ceil((Math.max(oldY, finger.y) + finger.radius) * side)); y++) {
231     for (let x = Math.max(0, Math.floor((Math.min(oldX, finger.x) - finger.radius) * side)); x < Math.min(width, Math.ceil((Math.max(oldX, finger.x) + finger.radius) * side)); x++) {
232       const coverage = Math.max(0, Math.min(1, (1 - distance((x + 0.5) / side, (y + 0.5) / side) / finger.radius) / 0.15));
233       if (coverage === 0) continue;
234       const i = y * width + x, remaining = 1 - coverage;
235       const removedFilm = Math.max(0, state.film[i] - remaining);
236       const removedTrail = state.trails[i] * coverage;
237       volume += (removedFilm * state.config.condensation * 0.0002 + removedTrail) / (side * side);
238       state.film[i] = Math.min(state.film[i], remaining);
239       state.trails[i] -= removedTrail;
240     }
241   }
242   if (volume > 0) {
243     finger.drop ??= { x: finger.x, y: finger.y, radius: 0, vx: 0, vy: 0, phase: 0 };
244     finger.drop.x = finger.x; finger.drop.y = finger.y; finger.drop.radius = Math.cbrt(volume);
245   }
246   state.fieldActive = true;
247 }
248 
249 function recoverFilm(state: State, elapsed: number): void {
250   if (!state.fieldActive) return;
251   const retention = state.config.trailPersistence === 0 ? 0 : Math.exp(-elapsed / state.config.trailPersistence);
252   let active = false;
253   for (let i = 0; i < state.film.length; i++) {
254     const missing = (1 - state.film[i]) * retention;
255     state.film[i] = missing < 0.002 ? 1 : 1 - missing;
256     const trail = state.trails[i] * retention;
257     state.trails[i] = trail < 0.000005 ? 0 : trail;
258     active ||= state.film[i] < 1 || state.trails[i] > 0;
259   }
260   state.fieldActive = active;
261 }
262 
263 const recipe = defineTexture<RainDropsOptions, State, RainDropsMessage>({
264   create(context, input) {
265     assertMap(context);
266     const config = options(input);
267     const cells = context.canvas.width * context.canvas.height;
268     const state: State = { film: new Float32Array(cells), trails: new Float32Array(cells), surface: new Float32Array(cells), grain: new Float32Array(cells), fieldActive: false, config, drops: [], random: config.seed, births: 0, paused: config.paused, fresh: true, image: context.canvas.getContext("2d")!.createImageData(context.canvas.width, context.canvas.height) };
269     reset(state, context); schedule(state, context); return state;
270   },
271   update(state, input, context) {
272     assertMap(context);
273     const next = options(input), previous = state.config;
274     state.config = next;
275     if (next.condensation !== previous.condensation || next.irregularity !== previous.irregularity || next.trailStrength !== previous.trailStrength) context.invalidate();
276     if (next.paused !== previous.paused) { state.paused = next.paused; state.fresh = true; }
277     if (next.rainRate !== previous.rainRate || next.gravity !== previous.gravity) state.fresh = true;
278     if (next.seed !== previous.seed || next.initialDrops !== previous.initialDrops) { reset(state, context); context.invalidate(); }
279     schedule(state, context);
280   },
281   receive(state, message, context) {
282     if (message.type === "pause") { state.paused = message.paused; state.fresh = true; }
283     else if (message.type === "reset") { reset(state, context); context.invalidate(); }
284     else if (message.type === "finger") {
285       const x = bounded(message.x, "finger x", 1), y = bounded(message.y, "finger y", 1);
286       const radius = bounded(message.radius ?? 0.045, "finger radius", 2);
287       if (radius === 0) throw new RangeError("Rain finger radius must be positive.");
288       const side = Math.min(context.canvas.width, context.canvas.height);
289       const nextX = x * context.canvas.width / side, nextY = y * context.canvas.height / side;
290       const oldX = state.finger?.x ?? nextX, oldY = state.finger?.y ?? nextY;
291       state.finger ??= { x: nextX, y: nextY, radius };
292       state.finger.x = nextX; state.finger.y = nextY; state.finger.radius = radius;
293       wipeFinger(state, oldX, oldY, context); context.invalidate();
294     }
295     else if (message.type === "release") {
296       if (state.finger?.drop) { makeReleaseRoom(state.drops); state.drops.push(state.finger.drop); }
297       state.finger = undefined; context.invalidate();
298     }
299     else if (message.type === "nudge") {
300       const x = bounded(message.x, "nudge x", 1), y = bounded(message.y, "nudge y", 1);
301       const radius = bounded(message.radius ?? 0.12, "nudge radius", 2);
302       if (radius === 0) throw new RangeError("Rain nudge radius must be positive.");
303       for (const delta of [message.dx, message.dy]) if (!Number.isFinite(delta) || Math.abs(delta) > 1) throw new RangeError("Rain nudge movement must be finite and within [-1, 1].");
304       if (message.dx === 0 && message.dy === 0) return;
305       const side = Math.min(context.canvas.width, context.canvas.height);
306       const width = context.canvas.width / side, height = context.canvas.height / side;
307       for (const drop of state.drops) {
308         const distance = Math.hypot(drop.x - x * width, drop.y - y * height);
309         const weight = Math.max(0, 1 - distance / radius) ** 2;
310         const oldX = drop.x, oldY = drop.y;
311         drop.x = Math.max(0, Math.min(width, drop.x + message.dx * width * weight));
312         drop.y = Math.max(0, Math.min(height, drop.y + message.dy * height * weight));
313         sweep(state, drop, oldX, oldY, context);
314         drop.vx += message.dx * width * weight * 3; drop.vy += message.dy * height * weight * 3;
315       }
316       merge(state.drops); context.invalidate();
317     } else throw new TypeError("Unknown rain drop message.");
318     schedule(state, context);
319   },
320   advance(state, frame, context) {
321     if (state.paused) return;
322     if (state.fresh) state.fresh = false;
323     else {
324       // Bounded substeps prevent tab suspension from creating a rainfall burst.
325       const elapsed = Math.min(frame.delta / 1000, 0.05);
326       recoverFilm(state, elapsed);
327       const steps = Math.max(1, Math.ceil(elapsed * 120)), dt = elapsed / steps;
328       const side = Math.min(context.canvas.width, context.canvas.height);
329       for (let step = 0; step < steps; step++) {
330         state.births += state.config.rainRate * dt;
331         while (state.births >= 1) { spawn(state, context); state.births--; }
332         for (const drop of state.drops) {
333           const slide = Math.max(0, drop.radius / 0.012 - 1);
334           const oldX = drop.x, oldY = drop.y;
335           drop.vx += Math.sin(drop.y * 85 + drop.phase) * slide * state.config.gravity * state.config.irregularity * 0.09 * dt;
336           drop.vy += state.config.gravity * slide * 0.3 * dt;
337           drop.vx *= Math.exp(-7 * dt); drop.vy *= Math.exp(-3 * dt);
338           if (Math.abs(drop.vx) < 0.00005) drop.vx = 0;
339           if (Math.abs(drop.vy) < 0.00005) drop.vy = 0;
340           drop.x += drop.vx * dt; drop.y += drop.vy * dt;
341           sweep(state, drop, oldX, oldY, context);
342         }
343         merge(state.drops);
344         state.drops = state.drops.filter(d => d.y - d.radius < context.canvas.height / side && d.y + d.radius > 0 && d.x + d.radius > 0 && d.x - d.radius < context.canvas.width / side);
345       }
346       if (state.finger) wipeFinger(state, state.finger.x, state.finger.y, context);
347       context.invalidate();
348     }
349     schedule(state, context);
350   },
351   rasterize(state, context) {
352     const { width, height } = context.canvas, side = Math.min(width, height);
353     const data = state.image.data, surface = state.surface;
354     for (let i = 0; i < surface.length; i++) {
355       surface[i] = state.trails[i] + state.grain[i] * 0.00018 * state.config.condensation * state.film[i];
356     }
357     const visibleDrops = state.finger?.drop ? [...state.drops, state.finger.drop] : state.drops;
358     for (const drop of visibleDrops) {
359       const stretch = 1 + Math.min(1.3, Math.abs(drop.vy) * 3);
360       const rx = drop.radius * side / Math.sqrt(stretch), ry = drop.radius * side * stretch;
361       const cx = drop.x * side, cy = drop.y * side;
362       const extent = 1.3;
363       for (let y = Math.max(0, Math.floor(cy - ry * extent)); y < Math.min(height, Math.ceil(cy + ry * extent)); y++) {
364         for (let x = Math.max(0, Math.floor(cx - rx * extent)); x < Math.min(width, Math.ceil(cx + rx * extent)); x++) {
365           const ny = (y + 0.5 - cy) / ry;
366           const nx = (x + 0.5 - cx) / rx - state.config.irregularity * 0.09 * Math.sin(ny * 3 + drop.phase);
367           const angle = Math.atan2(ny, nx);
368           const edge = 1 + state.config.irregularity * (0.12 * Math.sin(angle * 3 + drop.phase) + 0.07 * Math.sin(angle * 5 - drop.phase));
369           const r2 = (nx * nx + ny * ny) / (edge * edge);
370           if (r2 >= 1) continue;
371           const i = y * width + x;
372           surface[i] = Math.max(surface[i], drop.radius * Math.sqrt(1 - r2));
373         }
374       }
375     }
376     for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
377       const i = y * width + x, index = i * 4;
378       const dx = (surface[y * width + Math.min(width - 1, x + 1)] - surface[y * width + Math.max(0, x - 1)]) * side * 0.5;
379       const dy = (surface[Math.min(height - 1, y + 1) * width + x] - surface[Math.max(0, y - 1) * width + x]) * side * 0.5;
380       data[index] = Math.round(128 + Math.max(-1, Math.min(1, dx)) * 110);
381       data[index + 1] = Math.round(128 + Math.max(-1, Math.min(1, dy)) * 110);
382       const droplet = Math.min(1, Math.max(0, (surface[i] - 0.001) * 1500));
383       data[index + 2] = Math.round(255 * state.config.condensation * state.film[i] * (0.8 + state.grain[i] * 0.2) * (1 - droplet));
384       data[index + 3] = state.config.condensation > 0 ? 255 : Math.round(Math.min(1, surface[i] * side * 2) * 255);
385     }
386     context.canvas.getContext("2d")!.putImageData(state.image, 0, 0);
387   },
388 });
389 
390 /**
391  * A seeded rain-on-glass displacement map, advanced by Pibbl's scheduler.
392  *
393  * @param options - Raindrop simulation and appearance settings. See {@link RainDropsOptions}.
394  * @returns A raindrop texture recipe accepting raindrop messages. See {@link TextureRecipe} ,
395  * {@link RainDropsMessage} .
396  *
397  * @see {@link RainDropsOptions}
398  * @see {@link TextureRecipe}
399  * @see {@link RainDropsMessage}
400  */
401 export function rainDrops(options: RainDropsOptions = {}): TextureRecipe<RainDropsMessage> { return recipe(options); }
402 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.