packages/core/src/features/textures/lib/rain-drops.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.