Skip to content

packages/core/src/features/textures/lib/water-surface.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 {
2   defineTexture,
3   type TextureContext,
4   type TextureRecipe,
5 } from "@pibbl/core";
6 import { validateSeed } from "./palette.js";
7 import { bytes } from "./wasm/water-surface-bytes.js";
8 
9 /**
10  * An axis-aligned obstacle rectangle in a water-surface simulation.
11  *
12  * @see {@link WaterSurfaceOptions}
13  */
14 export interface WaterSurfaceObstacle {
15   /**
16    * Horizontal coordinate or displacement in the containing coordinate system. See
17    * {@link WaterSurfaceObstacle}.
18    */
19   readonly x: number;
20   /**
21    * Vertical coordinate or displacement in the containing coordinate system. See
22    * {@link WaterSurfaceObstacle}.
23    */
24   readonly y: number;
25   /**
26    * Horizontal extent in the units of the containing geometry or surface. See
27    * {@link WaterSurfaceObstacle}.
28    */
29   readonly width: number;
30   /**
31    * Vertical extent in the units of the containing geometry or surface. See
32    * {@link WaterSurfaceObstacle}.
33    */
34   readonly height: number;
35 }
36 
37 /**
38  * Water simulation controls including wave motion, viscosity, pause state, and obstacles.
39  *
40  * @see {@link WaterSurfaceObstacle}
41  * @see {@link waterSurface}
42  */
43 export interface WaterSurfaceOptions {
44   /**
45    * Seed used for deterministic sampling or simulation initialization. See
46    * {@link WaterSurfaceOptions}.
47    */
48   readonly seed?: number;
49   /** Multiplier controlling the simulation's animation rate. See {@link WaterSurfaceOptions}. */
50   readonly speed?: number;
51   /** Spatial scale of the generated water pattern. See {@link WaterSurfaceOptions}. */
52   readonly scale?: number;
53   /** Strength of injected wave disturbances. See {@link WaterSurfaceOptions}. */
54   readonly rippleStrength?: number;
55   /** Artistic thickness from 0 (water) to 1 (slow, strongly damped). */
56   readonly viscosity?: number;
57   /** Whether simulation or playback advancement is suspended. See {@link WaterSurfaceOptions}. */
58   readonly paused?: boolean;
59   /** Obstacle geometry used by the flow or water simulation. See {@link WaterSurfaceObstacle}. */
60   readonly obstacles?: readonly WaterSurfaceObstacle[];
61 }
62 
63 /**
64  * Ripple, pause, and reset commands for a water-surface simulation.
65  *
66  * @see {@link waterSurface}
67  */
68 export type WaterSurfaceMessage =
69   | {
70       /** The literal "pause" identifying this variant. See {@link WaterSurfaceMessage}. */
71       readonly type: "pause";
72       /** Whether simulation or playback advancement is suspended. See {@link WaterSurfaceMessage}. */
73       readonly paused: boolean;
74     }
75   | {
76       /** The literal "reset" identifying this variant. See {@link WaterSurfaceMessage}. */
77       readonly type: "reset";
78     }
79   | {
80       /** The literal "ripple" identifying this variant. See {@link WaterSurfaceMessage}. */
81       readonly type: "ripple";
82       /**
83        * Horizontal coordinate or displacement in the containing coordinate system. See
84        * {@link WaterSurfaceMessage}.
85        */
86       readonly x: number;
87       /**
88        * Vertical coordinate or displacement in the containing coordinate system. See
89        * {@link WaterSurfaceMessage}.
90        */
91       readonly y: number;
92     };
93 
94 interface Config {
95   readonly seed: number;
96   readonly speed: number;
97   readonly scale: number;
98   readonly rippleStrength: number;
99   readonly viscosity: number;
100   readonly paused: boolean;
101   readonly obstacles: readonly WaterSurfaceObstacle[];
102 }
103 interface State {
104   config: Config;
105   simulation: WaterSimulation;
106   phase: number;
107   paused: boolean;
108   fresh: boolean;
109   elapsed: number;
110   energy: number;
111 }
112 interface WaterKernel {
113   clear(
114     height: number,
115     velocity: number,
116     nextHeight: number,
117     nextVelocity: number,
118     count: number,
119   ): void;
120   impulse(
121     velocity: number,
122     solid: number,
123     width: number,
124     rows: number,
125     u: number,
126     v: number,
127     radius: number,
128     strength: number,
129   ): void;
130   steps(
131     height: number,
132     velocity: number,
133     nextHeight: number,
134     nextVelocity: number,
135     solid: number,
136     width: number,
137     rows: number,
138     count: number,
139     viscosity: number,
140   ): number;
141   clearMask(solid: number, count: number): void;
142   maskRectangle(
143     solid: number,
144     width: number,
145     rows: number,
146     x: number,
147     y: number,
148     w: number,
149     h: number,
150   ): void;
151   maskFields(
152     height: number,
153     velocity: number,
154     nextHeight: number,
155     nextVelocity: number,
156     solid: number,
157     count: number,
158   ): void;
159   wake(
160     velocity: number,
161     solid: number,
162     width: number,
163     rows: number,
164     bx: number,
165     by: number,
166     bw: number,
167     bh: number,
168     ax: number,
169     ay: number,
170     aw: number,
171     ah: number,
172   ): number;
173   initializeModes(ptr: number, seed: number, scale: number): void;
174   raster(
175     height: number,
176     solid: number,
177     width: number,
178     rows: number,
179     pixels: number,
180     outWidth: number,
181     outHeight: number,
182     modes: number,
183     time: number,
184     strength: number,
185   ): void;
186 }
187 
188 const FIXED_STEP = 1 / 120;
189 const MAX_STEPS = 6;
190 const IDLE_ENERGY = 0.0006;
191 let compiled: WebAssembly.Module | undefined;
192 
193 function kernel(): WebAssembly.Module {
194   return (compiled ??= new WebAssembly.Module(
195     Uint8Array.from(atob(bytes), (character) => character.charCodeAt(0)),
196   ));
197 }
198 
199 function gridSize(width: number, height: number): readonly [number, number] {
200   const scale = Math.min(
201     1,
202     128 / Math.min(width, height),
203     256 / Math.max(width, height),
204   );
205   return [
206     Math.max(2, Math.round(width * scale)),
207     Math.max(2, Math.round(height * scale)),
208   ];
209 }
210 
211 class WaterSimulation {
212   readonly memory: WebAssembly.Memory;
213   readonly image: ImageData;
214   private readonly modeOffset: number;
215   readonly height: Float32Array;
216   readonly velocity: Float32Array;
217   private readonly nextHeight: Float32Array;
218   private readonly nextVelocity: Float32Array;
219   readonly solid: Uint8Array;
220   private readonly kernel: WaterKernel;
221   private previousObstacles: readonly WaterSurfaceObstacle[] = [];
222   constructor(
223     readonly width: number,
224     readonly rows: number,
225     obstacles: readonly WaterSurfaceObstacle[],
226     readonly outputWidth: number,
227     readonly outputHeight: number,
228     config: Config,
229   ) {
230     const count = width * rows;
231     const scalarBytes = count * Float32Array.BYTES_PER_ELEMENT;
232     const solidOffset = 65536 + scalarBytes * 4;
233     this.modeOffset = Math.ceil((solidOffset + count) / 8) * 8;
234     const pixelOffset = this.modeOffset + 96;
235     const pages = Math.ceil(
236       (pixelOffset + outputWidth * outputHeight * 4) / 65536,
237     );
238     this.memory = new WebAssembly.Memory({ initial: pages, maximum: pages });
239     this.kernel = new WebAssembly.Instance(kernel(), {
240       env: {
241         memory: this.memory,
242         abort() {
243           throw new Error("Water surface WASM aborted.");
244         },
245       },
246     }).exports as unknown as WaterKernel;
247     this.height = new Float32Array(this.memory.buffer, 65536, count);
248     this.velocity = new Float32Array(
249       this.memory.buffer,
250       65536 + scalarBytes,
251       count,
252     );
253     this.nextHeight = new Float32Array(
254       this.memory.buffer,
255       65536 + scalarBytes * 2,
256       count,
257     );
258     this.nextVelocity = new Float32Array(
259       this.memory.buffer,
260       65536 + scalarBytes * 3,
261       count,
262     );
263     this.solid = new Uint8Array(this.memory.buffer, solidOffset, count);
264     this.image = new ImageData(
265       new Uint8ClampedArray(
266         this.memory.buffer,
267         pixelOffset,
268         outputWidth * outputHeight * 4,
269       ),
270       outputWidth,
271       outputHeight,
272     );
273     this.configure(config);
274     this.setObstacles(obstacles);
275   }
276   reset() {
277     this.kernel.clear(
278       this.height.byteOffset,
279       this.velocity.byteOffset,
280       this.nextHeight.byteOffset,
281       this.nextVelocity.byteOffset,
282       this.height.length,
283     );
284   }
285   setObstacles(obstacles: readonly WaterSurfaceObstacle[]): number {
286     if (sameObstacles(this.previousObstacles, obstacles)) return 0;
287     const previous = this.previousObstacles;
288     this.kernel.clearMask(this.solid.byteOffset, this.solid.length);
289     for (const o of obstacles)
290       this.kernel.maskRectangle(
291         this.solid.byteOffset,
292         this.width,
293         this.rows,
294         o.x,
295         o.y,
296         o.width,
297         o.height,
298       );
299     this.kernel.maskFields(
300       this.height.byteOffset,
301       this.velocity.byteOffset,
302       this.nextHeight.byteOffset,
303       this.nextVelocity.byteOffset,
304       this.solid.byteOffset,
305       this.solid.length,
306     );
307     this.previousObstacles = obstacles;
308     // A moving solid leaves one bounded impulse at its trailing edge. The
309     // height-field remains an appearance-only texture; this is not a force or
310     // collider update in Pibbl physics.
311     let wakeEnergy = 0;
312     for (
313       let index = 0;
314       index < Math.min(previous.length, obstacles.length);
315       index++
316     ) {
317       const before = previous[index],
318         after = obstacles[index];
319       wakeEnergy += this.kernel.wake(
320         this.velocity.byteOffset,
321         this.solid.byteOffset,
322         this.width,
323         this.rows,
324         before.x,
325         before.y,
326         before.width,
327         before.height,
328         after.x,
329         after.y,
330         after.width,
331         after.height,
332       );
333     }
334     return wakeEnergy;
335   }
336   ripple(u: number, v: number, strength: number) {
337     this.kernel.impulse(
338       this.velocity.byteOffset,
339       this.solid.byteOffset,
340       this.width,
341       this.rows,
342       u,
343       v,
344       Math.max(2, Math.min(this.width, this.rows) * 0.075),
345       strength * 1.8,
346     );
347   }
348   step(count: number, viscosity: number): number {
349     return this.kernel.steps(
350       this.height.byteOffset,
351       this.velocity.byteOffset,
352       this.nextHeight.byteOffset,
353       this.nextVelocity.byteOffset,
354       this.solid.byteOffset,
355       this.width,
356       this.rows,
357       count,
358       viscosity,
359     );
360   }
361   configure(config: Config) {
362     this.kernel.initializeModes(this.modeOffset, config.seed, config.scale);
363   }
364   raster(time: number, strength: number) {
365     this.kernel.raster(
366       this.height.byteOffset,
367       this.solid.byteOffset,
368       this.width,
369       this.rows,
370       this.image.data.byteOffset,
371       this.outputWidth,
372       this.outputHeight,
373       this.modeOffset,
374       time,
375       strength,
376     );
377   }
378   dispose() {
379     this.height.fill(0);
380     this.velocity.fill(0);
381     this.solid.fill(0);
382   }
383 }
384 
385 function sameObstacles(
386   left: readonly WaterSurfaceObstacle[],
387   right: readonly WaterSurfaceObstacle[],
388 ) {
389   return (
390     left.length === right.length &&
391     left.every((value, index) => {
392       const other = right[index];
393       return (
394         value.x === other.x &&
395         value.y === other.y &&
396         value.width === other.width &&
397         value.height === other.height
398       );
399     })
400   );
401 }
402 function options(input: WaterSurfaceOptions): Config {
403   const seed = validateSeed(input.seed ?? 0),
404     speed = input.speed ?? 1,
405     scale = input.scale ?? 4,
406     rippleStrength = input.rippleStrength ?? 1,
407     viscosity = input.viscosity ?? 0;
408   if (!Number.isFinite(viscosity) || viscosity < 0 || viscosity > 1)
409     throw new RangeError(
410       "Water surface viscosity must be finite and within [0, 1].",
411     );
412   if (!Number.isFinite(speed) || speed < 0)
413     throw new RangeError("Water surface speed must be finite and nonnegative.");
414   if (!Number.isInteger(scale) || scale < 1 || scale > 32)
415     throw new RangeError(
416       "Water surface scale must be an integer from 1 to 32.",
417     );
418   if (!Number.isFinite(rippleStrength) || rippleStrength < 0)
419     throw new RangeError(
420       "Water surface ripple strength must be finite and nonnegative.",
421     );
422   const obstacles = Object.freeze(
423     (input.obstacles ?? []).map((obstacle) => {
424       if (
425         ![obstacle.x, obstacle.y, obstacle.width, obstacle.height].every(
426           Number.isFinite,
427         ) ||
428         obstacle.x < 0 ||
429         obstacle.y < 0 ||
430         obstacle.width < 0 ||
431         obstacle.height < 0 ||
432         obstacle.x + obstacle.width > 1 ||
433         obstacle.y + obstacle.height > 1
434       )
435         throw new RangeError(
436           "Water surface obstacles must be finite normalized rectangles within [0, 1].",
437         );
438       return Object.freeze({ ...obstacle });
439     }),
440   );
441   return {
442     seed,
443     speed,
444     scale,
445     rippleStrength,
446     viscosity,
447     paused: input.paused ?? false,
448     obstacles,
449   };
450 }
451 function assertDataTexture(context: TextureContext) {
452   if (context.palette !== undefined)
453     throw new TypeError(
454       "waterSurface is a numeric displacement map and does not accept a palette.",
455     );
456 }
457 function assertCoordinate(value: number, name: "x" | "y") {
458   if (!Number.isFinite(value) || value < 0 || value > 1)
459     throw new RangeError(
460       `Water ripple ${name} must be finite and within [0, 1].`,
461     );
462 }
463 function active(state: State) {
464   return state.config.speed > 0 || state.energy > IDLE_ENERGY;
465 }
466 function replaceSimulation(
467   state: State,
468   context: TextureContext,
469   config: Config,
470 ): number {
471   const [width, rows] = gridSize(context.canvas.width, context.canvas.height);
472   if (
473     state.simulation.width !== width ||
474     state.simulation.rows !== rows ||
475     state.simulation.outputWidth !== context.canvas.width ||
476     state.simulation.outputHeight !== context.canvas.height
477   ) {
478     state.simulation.dispose();
479     state.simulation = new WaterSimulation(
480       width,
481       rows,
482       config.obstacles,
483       context.canvas.width,
484       context.canvas.height,
485       config,
486     );
487     return 0;
488   }
489   return state.simulation.setObstacles(config.obstacles);
490 }
491 const recipe = /* @__PURE__ */ defineTexture<
492   WaterSurfaceOptions,
493   State,
494   WaterSurfaceMessage
495 >({
496   defaultPlacement: { mode: "stretch" },
497   create(context, input) {
498     assertDataTexture(context);
499     const config = options(input),
500       [width, rows] = gridSize(context.canvas.width, context.canvas.height);
501     if (!config.paused && config.speed > 0) context.requestFrame();
502     return {
503       config,
504       simulation: new WaterSimulation(
505         width,
506         rows,
507         config.obstacles,
508         context.canvas.width,
509         context.canvas.height,
510         config,
511       ),
512       phase: 0,
513       paused: config.paused,
514       fresh: true,
515       elapsed: 0,
516       energy: 0,
517     };
518   },
519   update(state, input, context) {
520     assertDataTexture(context);
521     const next = options(input),
522       previous = state.config;
523     const wasActive = active(state);
524     const wakeEnergy = replaceSimulation(state, context, next);
525     if (next.seed !== previous.seed || next.scale !== previous.scale) {
526       state.phase = 0;
527       state.simulation.configure(next);
528       state.simulation.reset();
529       state.energy = 0;
530     }
531     if (next.paused !== previous.paused) state.paused = next.paused;
532     if (next.paused !== previous.paused || next.speed !== previous.speed) {
533       state.fresh = true;
534       state.elapsed = 0;
535     }
536     state.config = next;
537     if (wakeEnergy > 0) {
538       state.energy = Math.max(state.energy, wakeEnergy);
539       if (!wasActive) {
540         state.fresh = true;
541         state.elapsed = 0;
542       }
543     }
544     context.invalidate();
545     if (!state.paused && active(state)) context.requestFrame();
546   },
547   receive(state, message, context) {
548     if (message.type === "pause") {
549       state.paused = message.paused;
550       state.fresh = true;
551       state.elapsed = 0;
552       if (!state.paused && active(state)) context.requestFrame();
553       return;
554     }
555     if (message.type === "reset") {
556       state.simulation.reset();
557       state.phase = 0;
558       state.energy = 0;
559       state.elapsed = 0;
560       state.fresh = true;
561       context.invalidate();
562       return;
563     }
564     if (message.type === "ripple") {
565       assertCoordinate(message.x, "x");
566       assertCoordinate(message.y, "y");
567       const wasActive = active(state);
568       state.simulation.ripple(message.x, message.y, 1);
569       state.energy = Math.max(state.energy, 1);
570       if (!wasActive) {
571         state.fresh = true;
572         state.elapsed = 0;
573       }
574       context.invalidate();
575       if (!state.paused) context.requestFrame();
576       return;
577     }
578     throw new TypeError("Unknown water surface message.");
579   },
580   advance(state, frame, context) {
581     if (state.paused || !active(state)) return;
582     if (!state.fresh) {
583       state.elapsed += Math.min(0.05, frame.delta / 1000);
584       const steps = Math.min(MAX_STEPS, Math.floor(state.elapsed / FIXED_STEP));
585       if (steps) {
586         state.elapsed -= steps * FIXED_STEP;
587         state.energy = state.simulation.step(steps, state.config.viscosity);
588         state.phase +=
589           ((frame.delta / 1000) * state.config.speed) /
590           (1 + 4 * state.config.viscosity);
591         context.invalidate();
592       }
593     }
594     state.fresh = false;
595     if (active(state)) context.requestFrame();
596   },
597   rasterize(state, context) {
598     state.simulation.raster(state.phase, state.config.rippleStrength);
599     context.canvas.getContext("2d")!.putImageData(state.simulation.image, 0, 0);
600   },
601   dispose(state) {
602     state.simulation.dispose();
603   },
604 });
605 
606 /**
607  * A WASM-only, bounded reflective height-field displacement map.
608  *
609  * @param options - Water appearance and simulation settings. See {@link WaterSurfaceOptions}.
610  * @returns A water-surface texture recipe accepting surface messages. See {@link TextureRecipe} ,
611  * {@link WaterSurfaceMessage} .
612  *
613  * @see {@link WaterSurfaceOptions}
614  * @see {@link TextureRecipe}
615  * @see {@link WaterSurfaceMessage}
616  */
617 export function waterSurface(
618   options: WaterSurfaceOptions = {},
619 ): TextureRecipe<WaterSurfaceMessage> {
620   return recipe(options);
621 }
622 

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