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