Skip to content

packages/core/src/features/particles/lib/flow/hook.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 { 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 built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.