packages/core/src/features/textures/lib/water-surface.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 { 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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.