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