packages/core/src/features/particles/lib/flow/hook.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import type { PibblParticleSystemOptions } from '../types.js';
2 import {
3 resolveSignalValue,
4 signal,
5 type Signal,
6 type SignalValue,
7 } from "@pibbl/core";
8 import {
9 pibblInternalCurrentRenderOwner,
10 pibblInternalCurrentSimulationOwner,
11 pibblInternalOnRenderFinish,
12 pibblInternalQueueSimulationCommitAcknowledgement,
13 pibblInternalRegisterSimulationAdvance,
14 useInternalHookSlot,
15 type PibblInternalSimulationOwner,
16 } from "@pibbl/core/internal";
17 import { FlowFieldKernel2D, type FlowSource2D } from "./field.js";
18
19 /**
20 * Committed obstacle geometry and motion packed into borrowed numeric buffers.
21 *
22 * @see {@link PibblFlowObstacleSource2D}
23 */
24 export interface PibblFlowObstacleBatch2D {
25 /**
26 * Revision used to detect changes to the underlying state or geometry. See
27 * {@link PibblFlowObstacleBatch2D}.
28 */
29 readonly revision: number;
30 /** cx, cy, halfWidth, halfHeight, radians, vx, vy, omega, COM x/y. */
31 readonly boxes: Float64Array;
32 /**
33 * Packed ellipse obstacle data consumed by the simulation. See {@link PibblFlowObstacleBatch2D}.
34 */
35 readonly ellipses?: Float64Array;
36 /**
37 * Packed polygon obstacle data consumed by the simulation. See {@link PibblFlowObstacleBatch2D}.
38 */
39 readonly polygons?: Float64Array;
40 }
41 /**
42 * Supplies committed obstacle batches to a flow field without transferring buffer ownership.
43 *
44 * @see {@link PibblFlowObstacleBatch2D}
45 * @see {@link PibblFlowFieldOptions2D}
46 */
47 export interface PibblFlowObstacleSource2D {
48 /**
49 * Returns committed simulation state. Buffers are borrowed for this call only.
50 * @returns The current obstacle revision and packed obstacle geometry. See
51 * {@link PibblFlowObstacleBatch2D} .
52 */
53 read(): PibblFlowObstacleBatch2D;
54 }
55 /**
56 * Logical bounds, grid resolution, obstacle source, and buoyancy for a flow field.
57 *
58 * @see {@link PibblFlowObstacleSource2D}
59 * @see {@link useFlowField2D}
60 */
61 export interface PibblFlowFieldOptions2D {
62 /** Logical region constraining the content or query. See {@link PibblFlowFieldOptions2D}. */
63 readonly bounds: Readonly<{
64 /**
65 * Horizontal extent in the units of the containing geometry or surface. See
66 * {@link PibblFlowFieldOptions2D}.
67 */
68 width: number;
69 /**
70 * Vertical extent in the units of the containing geometry or surface. See
71 * {@link PibblFlowFieldOptions2D}.
72 */
73 height: number;
74 }>;
75 /** Dimensions of the simulation or raster grid. See {@link PibblFlowFieldOptions2D}. */
76 readonly resolution: readonly [columns: number, rows: number];
77 /**
78 * Obstacle geometry used by the flow or water simulation. See {@link PibblFlowObstacleSource2D}.
79 */
80 readonly obstacles?: PibblFlowObstacleSource2D;
81 /** Strength of heat-driven upward motion in the flow field. See {@link PibblFlowFieldOptions2D}. */
82 readonly buoyancy?: number;
83 }
84 declare const flowBrand: unique symbol;
85 /**
86 * A mount-owned flow field with revision tracking and batched velocity sampling.
87 *
88 * @see {@link Signal}
89 * @see {@link useFlowField2D}
90 * @see {@link useFlowSource2D}
91 * @see {@link PibblParticleSystemOptions}
92 */
93 export interface PibblFlowField2D {
94 readonly [flowBrand]: true;
95 /** Revision used to detect changes to the underlying state or geometry. See {@link Signal}. */
96 readonly revision: Signal<number>;
97 /**
98 * Samples velocities for packed position pairs into caller-provided output storage. See
99 * {@link PibblFlowField2D}.
100 * @param positions - Packed input positions, with two floats per particle.
101 * @param velocities - Output buffer receiving sampled velocities, with two floats per particle.
102 * @param count - Number of position pairs to sample.
103 */
104 sampleBatch(positions: Float32Array, velocities: Float32Array, count: number): void;
105 }
106 /**
107 * Reactive position, radius, acceleration, heat, and enablement of a flow source.
108 *
109 * @see {@link SignalValue}
110 * @see {@link useFlowSource2D}
111 */
112 export interface PibblFlowSourceOptions2D {
113 /** Position of the geometry, source, or selected resize handle. See {@link SignalValue}. */
114 readonly position: SignalValue<readonly [number, number]>;
115 /** Radius in the coordinate system of this geometry or effect. See {@link SignalValue}. */
116 readonly radius: SignalValue<number>;
117 /** Acceleration injected by this flow source. See {@link SignalValue}. */
118 readonly acceleration?: SignalValue<readonly [number, number]>;
119 /** Heat injected by this flow source. See {@link SignalValue}. */
120 readonly heat?: SignalValue<number>;
121 /** Whether this operation or resource participates in updates. See {@link SignalValue}. */
122 readonly enabled?: SignalValue<boolean>;
123 }
124 const controllers = new WeakMap<PibblFlowField2D, FlowController2D>();
125 const STEP = 1000 / 60;
126 const EMPTY_OBSTACLES = Object.freeze({ boxes: new Float64Array(0) });
127
128 /** Private coupling point; ordinary callers see only the read-only field handle. */
129 export function getFlowController2D(handle: PibblFlowField2D): FlowController2D {
130 const controller = controllers.get(handle);
131 if (!controller) throw new TypeError("Expected a Pibbl flow field.");
132 controller.requireActive();
133 return controller;
134 }
135
136 export class FlowController2D {
137 readonly handle: PibblFlowField2D;
138 private committed: FlowFieldKernel2D | undefined;
139 private candidate: FlowFieldKernel2D | undefined;
140 private readonly revision = signal(0);
141 private readonly sources = new Map<object, FlowSource2D>();
142 private sourceList: readonly FlowSource2D[] = [];
143 private unregister: (() => void) | undefined;
144 private lastTime: number | undefined;
145 private remainder = 0;
146 private obstacleRevision: number | undefined;
147 private obstacleSourceChanged = true;
148 private options: PibblFlowFieldOptions2D;
149 private consumers = 0;
150 constructor(
151 private readonly owner: PibblInternalSimulationOwner,
152 options: PibblFlowFieldOptions2D,
153 ) {
154 this.options = options;
155 const [columns, rows] = options.resolution;
156 this.committed = new FlowFieldKernel2D(
157 columns,
158 rows,
159 options.bounds.width,
160 options.bounds.height,
161 );
162 this.candidate = new FlowFieldKernel2D(
163 columns,
164 rows,
165 options.bounds.width,
166 options.bounds.height,
167 );
168 this.handle = Object.freeze({
169 revision: this.revision.asReadonly(),
170 sampleBatch: (
171 positions: Float32Array,
172 velocities: Float32Array,
173 count: number,
174 ) => {
175 this.requireActive().sampleBatch(positions, velocities, count);
176 },
177 }) as PibblFlowField2D;
178 controllers.set(this.handle, this);
179 }
180 requireActive(): FlowFieldKernel2D {
181 if (!this.committed) throw new Error("Pibbl flow field is disposed.");
182 return this.committed;
183 }
184 validateOptions(options: PibblFlowFieldOptions2D): void {
185 const field = this.requireActive();
186 if (
187 field.width !== options.bounds.width ||
188 field.height !== options.bounds.height ||
189 field.columns !== options.resolution[0] ||
190 field.rows !== options.resolution[1]
191 ) {
192 throw new Error(
193 "Flow bounds and resolution are mount-only; use a keyed remount to change them.",
194 );
195 }
196 if (!Number.isFinite(options.buoyancy ?? -300))
197 throw new RangeError("Flow buoyancy must be finite.");
198 }
199 adoptOptions(options: PibblFlowFieldOptions2D): void {
200 if (!this.committed) return;
201 if (this.options.obstacles !== options.obstacles)
202 this.obstacleSourceChanged = true;
203 this.options = options;
204 }
205 setSource(key: object, source: FlowSource2D | undefined): void {
206 if (!this.committed) return;
207 if (source) this.sources.set(key, source);
208 else this.sources.delete(key);
209 this.sourceList = Array.from(this.sources.values());
210 this.synchronize();
211 }
212 retain(): () => void {
213 this.requireActive();
214 this.consumers++;
215 this.synchronize();
216 let released = false;
217 return () => {
218 if (released) return;
219 released = true;
220 this.consumers--;
221 this.synchronize();
222 };
223 }
224 private synchronize(): void {
225 const active =
226 this.committed && (this.sources.size > 0 || this.consumers > 0);
227 if (active && !this.unregister) {
228 this.unregister = pibblInternalRegisterSimulationAdvance(
229 this.owner,
230 "physics",
231 (frame, batch) => {
232 const committed = this.requireActive();
233 const candidate = this.candidate!;
234 const elapsed =
235 this.lastTime === undefined
236 ? 0
237 : Math.max(0, frame.logicalTime - this.lastTime);
238 const accumulated = this.remainder + Math.min(elapsed, STEP * 4);
239 const steps = Math.min(4, Math.floor((accumulated + 1e-7) / STEP));
240 const remainder = Math.max(0, accumulated - steps * STEP);
241 candidate.copyFrom(committed);
242 const obstacles = this.options.obstacles?.read();
243 const nextObstacleRevision = obstacles?.revision;
244 const obstaclesChanged =
245 this.obstacleSourceChanged ||
246 nextObstacleRevision !== this.obstacleRevision;
247 if (obstaclesChanged) {
248 candidate.setObstacles(obstacles ?? EMPTY_OBSTACLES);
249 }
250 for (let i = 0; i < steps; i++)
251 candidate.advance(
252 STEP / 1000,
253 this.sourceList,
254 this.options.buoyancy ?? -300,
255 );
256 if (steps > 0 || obstaclesChanged) {
257 batch.stage(this.revision, this.revision.get() + 1, this);
258 }
259 pibblInternalQueueSimulationCommitAcknowledgement(
260 this.owner,
261 (_frame, outcome) => {
262 if (!outcome.applied || !this.committed) return;
263 this.committed = candidate;
264 this.candidate = committed;
265 this.lastTime = frame.logicalTime;
266 this.remainder = remainder;
267 this.obstacleRevision = nextObstacleRevision;
268 this.obstacleSourceChanged = false;
269 },
270 );
271 },
272 );
273 } else if (!active && this.unregister) {
274 this.unregister();
275 this.unregister = undefined;
276 this.lastTime = undefined;
277 }
278 }
279 dispose(): void {
280 this.unregister?.();
281 this.unregister = undefined;
282 this.sources.clear();
283 this.sourceList = [];
284 this.committed = undefined;
285 this.candidate = undefined;
286 }
287 }
288
289 /**
290 * Creates a mount-owned 2D flow field with configured bounds, resolution, and optional obstacles.
291 *
292 * @param options - Field dimensions, sampling, decay, and obstacle settings. See
293 * {@link PibblFlowFieldOptions2D} .
294 * @returns A mount-owned 2D flow field sampled by particle effects. See {@link PibblFlowField2D}.
295 *
296 * @see {@link PibblFlowFieldOptions2D}
297 * @see {@link PibblFlowField2D}
298 */
299 export function useFlowField2D(options: PibblFlowFieldOptions2D): PibblFlowField2D {
300 const simulationOwner = pibblInternalCurrentSimulationOwner();
301 const renderOwner = pibblInternalCurrentRenderOwner();
302 const controller = useInternalHookSlot("flow-field", (teardowns) => {
303 const value = new FlowController2D(simulationOwner, options);
304 teardowns.add(() => value.dispose());
305 return value;
306 }).value;
307 controller.validateOptions(options);
308 pibblInternalOnRenderFinish(renderOwner, (success) => {
309 if (success) controller.adoptOptions(options);
310 });
311 return controller.handle;
312 }
313
314 /**
315 * Registers a mount-owned source of acceleration or heat in a flow field.
316 *
317 * @param field - Flow field receiving this source. See {@link PibblFlowField2D}.
318 * @param options - Source position, influence, and strength settings. See
319 * {@link PibblFlowSourceOptions2D} .
320 *
321 * @see {@link PibblFlowField2D}
322 * @see {@link PibblFlowSourceOptions2D}
323 */
324 export function useFlowSource2D(
325 field: PibblFlowField2D,
326 options: PibblFlowSourceOptions2D,
327 ): void {
328 const controller = getFlowController2D(field);
329 const renderOwner = pibblInternalCurrentRenderOwner();
330 const position = resolveSignalValue(options.position);
331 const radius = resolveSignalValue(options.radius);
332 const acceleration = resolveSignalValue(
333 options.acceleration ?? ([0, 0] as const),
334 );
335 const heat = resolveSignalValue(options.heat ?? 0);
336 const enabled = resolveSignalValue(options.enabled ?? true);
337 if (
338 !Number.isFinite(position[0]) ||
339 !Number.isFinite(position[1]) ||
340 !Number.isFinite(radius) ||
341 radius <= 0 ||
342 !Number.isFinite(acceleration[0]) ||
343 !Number.isFinite(acceleration[1]) ||
344 !Number.isFinite(heat) ||
345 heat < 0
346 ) {
347 throw new RangeError(
348 "Flow source requires finite values, positive radius and nonnegative heat.",
349 );
350 }
351 const slot = useInternalHookSlot("flow-source", (teardowns) => {
352 const value = { key: {}, controller, revision: 0 };
353 teardowns.add(() => value.controller.setSource(value.key, undefined));
354 return value;
355 }).value;
356 const revision = ++slot.revision;
357 const source: FlowSource2D | undefined = enabled
358 ? {
359 x: position[0],
360 y: position[1],
361 radius,
362 acceleration: [acceleration[0], acceleration[1]],
363 heat,
364 }
365 : undefined;
366 pibblInternalOnRenderFinish(renderOwner, (success) => {
367 if (!success || revision !== slot.revision) return;
368 if (slot.controller !== controller)
369 slot.controller.setSource(slot.key, undefined);
370 slot.controller = controller;
371 controller.setSource(slot.key, source);
372 });
373 }
374
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.