Skip to content

packages/core/src/features/textures/lib/water-waves.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 { bytes } from "./wasm/water-waves-bytes.js";
7 import { validateSeed } from "./palette.js";
8 
9 /**
10  * Seed, animation speed, feature scale, ripple strength, and initial pause state of water waves.
11  *
12  * @see {@link waterWaves}
13  */
14 export interface WaterWavesOptions {
15   /**
16    * Seed used for deterministic sampling or simulation initialization. See
17    * {@link WaterWavesOptions}.
18    */
19   readonly seed?: number;
20   /** Multiplier controlling the simulation's animation rate. See {@link WaterWavesOptions}. */
21   readonly speed?: number;
22   /** Spatial scale of the generated wave pattern. See {@link WaterWavesOptions}. */
23   readonly scale?: number;
24   /** Strength of injected wave disturbances. See {@link WaterWavesOptions}. */
25   readonly rippleStrength?: number;
26   /** Whether simulation or playback advancement is suspended. See {@link WaterWavesOptions}. */
27   readonly paused?: boolean;
28 }
29 
30 /**
31  * Ripple, pause, and reset commands for a water-wave texture.
32  *
33  * @see {@link waterWaves}
34  */
35 export type WaterWavesMessage =
36   | {
37       /** The literal "pause" identifying this variant. See {@link WaterWavesMessage}. */
38       readonly type: "pause";
39       /** Whether simulation or playback advancement is suspended. See {@link WaterWavesMessage}. */
40       readonly paused: boolean;
41     }
42   | {
43       /** The literal "reset" identifying this variant. See {@link WaterWavesMessage}. */
44       readonly type: "reset";
45     }
46   | {
47       /** The literal "ripple" identifying this variant. See {@link WaterWavesMessage}. */
48       readonly type: "ripple";
49       /**
50        * Horizontal coordinate or displacement in the containing coordinate system. See
51        * {@link WaterWavesMessage}.
52        */
53       readonly x: number;
54       /**
55        * Vertical coordinate or displacement in the containing coordinate system. See
56        * {@link WaterWavesMessage}.
57        */
58       readonly y: number;
59     };
60 
61 interface Config {
62   seed: number;
63   speed: number;
64   scale: number;
65   rippleStrength: number;
66   paused: boolean;
67 }
68 interface State {
69   config: Config;
70   field: WaveField;
71   phase: number;
72   paused: boolean;
73   fresh: boolean;
74   rippleCount: number;
75 }
76 interface WaveKernel {
77   initializeModes(ptr: number, seed: number, scale: number): void;
78   reset(): void;
79   ripple(x: number, y: number): void;
80   advance(elapsed: number): number;
81   raster(
82     pixels: number,
83     width: number,
84     height: number,
85     time: number,
86     strength: number,
87   ): void;
88 }
89 let compiled: WebAssembly.Module | undefined;
90 class WaveField {
91   readonly kernel: WaveKernel;
92   readonly image: ImageData;
93   constructor(width: number, height: number, config: Config) {
94     const pages = Math.ceil((66024 + width * height * 4) / 65536);
95     const memory = new WebAssembly.Memory({ initial: pages, maximum: pages });
96     compiled ??= new WebAssembly.Module(
97       Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0)),
98     );
99     this.kernel = new WebAssembly.Instance(compiled, {
100       env: {
101         memory,
102         abort() {
103           throw new Error("Water waves WASM aborted.");
104         },
105       },
106     }).exports as unknown as WaveKernel;
107     this.image = new ImageData(
108       new Uint8ClampedArray(memory.buffer, 66024, width * height * 4),
109       width,
110       height,
111     );
112     this.configure(config);
113   }
114   configure(config: Config) {
115     this.kernel.initializeModes(65536, config.seed, config.scale);
116   }
117   raster(time: number, strength: number) {
118     this.kernel.raster(
119       66024,
120       this.image.width,
121       this.image.height,
122       time,
123       strength,
124     );
125   }
126 }
127 
128 function config(input: WaterWavesOptions): Config {
129   const seed = validateSeed(input.seed ?? 0);
130   const speed = input.speed ?? 1;
131   const scale = input.scale ?? 4;
132   const rippleStrength = input.rippleStrength ?? 1;
133   if (!Number.isFinite(speed) || speed < 0)
134     throw new RangeError("Water wave speed must be finite and nonnegative.");
135   if (!Number.isInteger(scale) || scale < 1 || scale > 32)
136     throw new RangeError("Water wave scale must be an integer from 1 to 32.");
137   if (!Number.isFinite(rippleStrength) || rippleStrength < 0)
138     throw new RangeError(
139       "Water ripple strength must be finite and nonnegative.",
140     );
141   return { seed, speed, scale, rippleStrength, paused: input.paused ?? false };
142 }
143 
144 function assertDataTexture(context: TextureContext): void {
145   if (context.palette !== undefined)
146     throw new TypeError(
147       "waterWaves is a numeric displacement map and does not accept a palette.",
148     );
149 }
150 function isAnimating(state: State): boolean {
151   return state.config.speed > 0 || state.rippleCount > 0;
152 }
153 function assertRippleCoordinate(value: number, name: "x" | "y"): void {
154   if (!Number.isFinite(value) || value < 0 || value > 1)
155     throw new RangeError(
156       `Water ripple ${name} must be finite and within [0, 1].`,
157     );
158 }
159 
160 const recipe = /* @__PURE__ */ defineTexture<
161   WaterWavesOptions,
162   State,
163   WaterWavesMessage
164 >({
165   create(context, input) {
166     assertDataTexture(context);
167     const next = config(input);
168     if (!next.paused && next.speed > 0) context.requestFrame();
169     return {
170       config: next,
171       field: new WaveField(context.canvas.width, context.canvas.height, next),
172       phase: 0,
173       paused: next.paused,
174       fresh: true,
175       rippleCount: 0,
176     };
177   },
178   update(state, input, context) {
179     assertDataTexture(context);
180     const next = config(input);
181     const previous = state.config;
182     if (next.seed !== previous.seed || next.scale !== previous.scale) {
183       state.phase = 0;
184       state.field.configure(next);
185       context.invalidate();
186     }
187     if (next.rippleStrength !== previous.rippleStrength) context.invalidate();
188     if (next.paused !== previous.paused) state.paused = next.paused;
189     if (next.paused !== previous.paused || next.speed !== previous.speed)
190       state.fresh = true;
191     state.config = next;
192     if (!state.paused && isAnimating(state)) context.requestFrame();
193   },
194   receive(state, message, context) {
195     if (message.type === "pause") {
196       state.paused = message.paused;
197       state.fresh = true;
198       if (!state.paused && isAnimating(state)) context.requestFrame();
199       return;
200     }
201     if (message.type === "reset") {
202       state.phase = 0;
203       state.field.kernel.reset();
204       state.rippleCount = 0;
205       state.fresh = true;
206       context.invalidate();
207       return;
208     }
209     if (message.type === "ripple") {
210       assertRippleCoordinate(message.x, "x");
211       assertRippleCoordinate(message.y, "y");
212       if (!isAnimating(state)) state.fresh = true;
213       state.field.kernel.ripple(message.x, message.y);
214       state.rippleCount = Math.min(16, state.rippleCount + 1);
215       context.invalidate();
216       if (!state.paused) context.requestFrame();
217       return;
218     }
219     throw new TypeError("Unknown water wave message.");
220   },
221   advance(state, frame, context) {
222     if (state.paused || !isAnimating(state)) return;
223     if (!state.fresh) {
224       const elapsed = frame.delta / 1000;
225       state.phase += elapsed * state.config.speed;
226       state.rippleCount = state.field.kernel.advance(elapsed);
227       context.invalidate();
228     }
229     state.fresh = false;
230     if (isAnimating(state)) context.requestFrame();
231   },
232   rasterize(state, context) {
233     const ctx = context.canvas.getContext("2d")!;
234     state.field.raster(state.phase, state.config.rippleStrength);
235     ctx.putImageData(state.field.image, 0, 0);
236   },
237 });
238 
239 /**
240  * Creates an animated water-wave texture recipe that accepts ripple, pause, and reset commands.
241  *
242  * @param options - Wave simulation and rendering settings. See {@link WaterWavesOptions}.
243  * @returns A water-wave texture recipe accepting wave messages. See {@link TextureRecipe} ,
244  * {@link WaterWavesMessage} .
245  *
246  * @see {@link WaterWavesOptions}
247  * @see {@link TextureRecipe}
248  * @see {@link WaterWavesMessage}
249  */
250 export function waterWaves(
251   options: WaterWavesOptions = {},
252 ): TextureRecipe<WaterWavesMessage> {
253   return recipe(options);
254 }
255 

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