Skip to content

packages/core/src/lib/transitions/embers-physics.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 type { PibblEmbersOptions } from './embers.js';
2 import { finite } from "./capture.js";
3 import type { lazyTransitionKernel } from "./wasm/kernel.js";
4 /**
5  * Collision information produced by an ember's physical interaction.
6  *
7  * @see {@link PibblEmbersPhysicsOptions}
8  */
9 export interface PibblEmberCollision {
10   /** Fraction of the swept segment at impact, from zero to one. */
11   readonly fraction: number;
12   /** Outward surface normal in the receiving primitive's local coordinates. */
13   readonly normal: readonly [number, number];
14 }
15 /**
16  * Collision and physical motion options for transition embers.
17  *
18  * @see {@link PibblEmberCollision}
19  * @see {@link PibblEmbersOptions}
20  */
21 export interface PibblEmbersPhysicsOptions {
22   /** Local units/second². Default [0, -60]. */
23   readonly gravity?: readonly [number, number];
24   /** Target air velocity in local units/second. Default [0, -45]. */
25   readonly wind?: readonly [number, number];
26   /** Velocity damping per second. Default 4. */
27   readonly drag?: number;
28   /** Bounce coefficient, zero to one. Default 0.5. */
29   readonly restitution?: number;
30   /** Packed committed solids in receiver-local coordinates (e.g. physicsObstacles2D).
31    * Revision must change whenever data changes. Up to 8192 packed numbers.
32    * Sweeps run as one WASM batch; mutually exclusive with collide.
33    */
34   readonly obstacles?: Readonly<{
35     /**
36      * Reads the current committed values required by this integration. See
37      * {@link PibblEmbersPhysicsOptions}.
38      * @returns The current obstacle revision and packed geometry arrays.
39      */
40     read(): Readonly<{
41       /**
42        * Revision used to detect changes to the underlying state or geometry. See
43        * {@link PibblEmbersPhysicsOptions}.
44        */
45       revision: number;
46       /** Packed box obstacle data consumed by the simulation. See {@link PibblEmbersPhysicsOptions}. */
47       boxes: Float64Array;
48       /**
49        * Packed ellipse obstacle data consumed by the simulation. See
50        * {@link PibblEmbersPhysicsOptions}.
51        */
52       ellipses: Float64Array;
53       /**
54        * Packed polygon obstacle data consumed by the simulation. See
55        * {@link PibblEmbersPhysicsOptions}.
56        */
57       polygons: Float64Array;
58     }>;
59   }>;
60   /**
61    * Synchronous point sweep, called in Advance. Return the closest hit, if any.
62    * @param from - Previous particle position.
63    * @param to - Proposed particle position.
64    * @returns Collision information, or undefined when motion is unobstructed. See
65    * {@link PibblEmberCollision} .
66    */
67   readonly collide?: (
68     from: readonly [number, number],
69     to: readonly [number, number],
70   ) => PibblEmberCollision | undefined;
71 }
72 export function resolveEmbersPhysics(options: PibblEmbersPhysicsOptions) {
73   const vector = (value: readonly [number, number], name: string) =>
74     Object.freeze([
75       finite(value[0], `${name}.x`),
76       finite(value[1], `${name}.y`),
77     ] as const);
78   const restitution = finite(
79     options.restitution ?? 0.5,
80     "physics.restitution",
81     0,
82   );
83   if (restitution > 1)
84     throw new RangeError("physics.restitution must be between zero and one.");
85   if (options.collide !== undefined && typeof options.collide !== "function")
86     throw new TypeError("physics.collide must be a synchronous function.");
87   if (
88     options.obstacles &&
89     (typeof options.obstacles.read !== "function" || options.collide)
90   )
91     throw new TypeError(
92       "physics.obstacles requires read() and cannot be combined with collide.",
93     );
94   return Object.freeze({
95     obstacles: options.obstacles,
96     gravity: vector(options.gravity ?? [0, -60], "physics.gravity"),
97     wind: vector(options.wind ?? [0, -45], "physics.wind"),
98     drag: finite(options.drag ?? 4, "physics.drag", 0),
99     restitution,
100     collide: options.collide,
101   });
102 }
103 type Kernel = ReturnType<ReturnType<typeof lazyTransitionKernel>>;
104 export function emberSimulation(
105   kernel: Kernel,
106   options: ReturnType<typeof resolveEmbersPhysics>,
107   region: Readonly<{ x: number; y: number; width: number; height: number }>,
108   linger: number,
109   burnWidth: number,
110 ) {
111   let previous = 0,
112     remainder = 0,
113     needsReset = true,
114     disposed = false;
115   const step = 1000 / 60;
116   let obstacleRevision: number | undefined;
117   let boxLength = 0,
118     ellipseLength = 0,
119     polygonLength = 0;
120   return {
121     reset() {
122       previous = 0;
123       remainder = 0;
124       needsReset = true;
125     },
126     get disposed() {
127       return disposed;
128     },
129     dispose() {
130       disposed = true;
131       kernel.dispose();
132     },
133     advance(elapsed: number, progressAt: (time: number) => number) {
134       if (disposed || elapsed <= previous) return;
135       const { exports, values } = kernel();
136       if (needsReset) {
137         exports.resetPhysics!();
138         needsReset = false;
139       }
140       remainder += Math.min(elapsed - previous, step * 4);
141       previous = elapsed;
142       if (remainder + 1e-8 >= step && options.obstacles) {
143         const batch = options.obstacles.read();
144         if (batch.revision !== obstacleRevision) {
145           const { boxes, ellipses, polygons } = batch;
146           if (
147             !Number.isFinite(batch.revision) ||
148             boxes.length % 10 ||
149             ellipses.length % 10 ||
150             boxes.length + ellipses.length + polygons.length > 8192
151           )
152             throw new RangeError(
153               "Invalid ember obstacle batch or capacity exceeded (8192 numbers).",
154             );
155           for (const packed of [boxes, ellipses, polygons])
156             for (const value of packed)
157               if (!Number.isFinite(value))
158                 throw new RangeError("Ember obstacles must be finite.");
159           for (const packed of [boxes, ellipses])
160             for (let i = 0; i < packed.length; i += 10)
161               if (packed[i + 2]! <= 0 || packed[i + 3]! <= 0)
162                 throw new RangeError(
163                   "Ember obstacle extents must be positive.",
164                 );
165           for (let at = 0; at < polygons.length;) {
166             const count = polygons[at]!;
167             if (
168               !Number.isInteger(count) ||
169               count < 3 ||
170               at + 6 + count * 2 > polygons.length
171             )
172               throw new RangeError("Invalid ember obstacle polygon.");
173             at += 6 + count * 2;
174           }
175           values.set(boxes, 22400);
176           values.set(ellipses, 22400 + boxes.length);
177           values.set(polygons, 22400 + boxes.length + ellipses.length);
178           boxLength = boxes.length;
179           ellipseLength = ellipses.length;
180           polygonLength = polygons.length;
181           obstacleRevision = batch.revision;
182         }
183       }
184       while (remainder + 1e-8 >= step) {
185         const time = elapsed - remainder + step;
186         remainder = Math.max(0, remainder - step);
187         const q = progressAt(time);
188         exports.births!(time, linger);
189         for (let i = 0; i < 1200; i++)
190           values[11166 + i] = progressAt(values[12366 + i]!);
191         exports.sample!(
192           q,
193           1,
194           time,
195           linger,
196           0,
197           burnWidth,
198           region.x,
199           region.y,
200           region.width,
201           region.height,
202           Math.min(512, Math.max(1, Math.ceil(region.width / 2))),
203         );
204         exports.stepPhysics!(
205           time,
206           step / 1000,
207           ...options.gravity,
208           ...options.wind,
209           options.drag,
210           linger,
211         );
212         if (options.obstacles)
213           exports.collideObstacles!(
214             boxLength,
215             ellipseLength,
216             polygonLength,
217             options.restitution,
218           );
219         if (options.collide)
220           for (let i = 0; i < 1200; i++) {
221             const s = 14000 + i * 7;
222             if (values[s + 4]! < 0) continue;
223             const from = [values[s + 5]!, values[s + 6]!] as const,
224               to = [values[s]!, values[s + 1]!] as const;
225             if (from[0] === to[0] && from[1] === to[1]) continue;
226             const hit = options.collide(from, to);
227             if (!hit) continue;
228             const fraction = finite(hit.fraction, "collision.fraction", 0);
229             const nx = finite(hit.normal?.[0], "collision.normal.x"),
230               ny = finite(hit.normal?.[1], "collision.normal.y"),
231               length = Math.hypot(nx, ny);
232             if (fraction > 1 || length < 1e-8)
233               throw new RangeError(
234                 "Ember collision requires a fraction in [0, 1] and a nonzero normal.",
235               );
236             exports.collidePhysics!(
237               i,
238               fraction,
239               nx / length,
240               ny / length,
241               options.restitution,
242             );
243           }
244       }
245     },
246     present() {
247       if (!needsReset && !disposed) kernel().exports.presentPhysics!();
248       else {
249         const { values } = kernel();
250         for (let i = 0; i < 1200; i++) values[1566 + i * 8 + 5] = -1;
251       }
252     },
253   };
254 }
255 

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