Skip to content

packages/core/src/features/particles/lib/canvas/component.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 {
2   createElement,
3   useLayoutBox,
4   layoutDiagnostic,
5   type PibblNode,
6 } from "@pibbl/core";
7 import {
8   pibblInternalCurrentRenderOwner,
9   pibblInternalOnRenderFinish,
10   pibblInternalRequestRender,
11   useInternalHookSlot,
12   type PibblInternalRenderOwner,
13 } from "@pibbl/core/internal";
14 import { parseParticleColor } from "../color.js";
15 import type { CompiledEffect2D, CompiledParameterEntry } from "../compiler.js";
16 import type {
17   PibblParticleEffect2D,
18   PibblParticleCommonProps,
19   PibblParticleParameterValue,
20   PibblParticleParameterValues,
21   Particles2DProps,
22 } from "../types.js";
23 import { useParticleSystemBinding } from "../system/binding.js";
24 import {
25   createParticleSystemController,
26   getParticleSystemController,
27   type ParticleEffectCandidate,
28   type ParticleSystemController,
29 } from "../system/controller.js";
30 import { resolveDeclaredParticleProps } from "../signal-inputs.js";
31 import {
32   createCanvasParticleExecutor,
33   type CanvasParticleExecutor,
34 } from "./executor.js";
35 import { ParticleCanvasPrimitive } from "./primitive.js";
36 
37 interface ImplicitSystemSlot {
38   readonly owner: PibblInternalRenderOwner;
39   controller: ParticleSystemController | undefined;
40   mountSettled: boolean;
41   revision: number;
42   disposed: boolean;
43 }
44 
45 interface CanvasExecutorSlot {
46   readonly owner: PibblInternalRenderOwner;
47   controller: ParticleSystemController | undefined;
48   pendingController: ParticleSystemController | undefined;
49   committed: CanvasParticleExecutor | undefined;
50   pending: CanvasParticleExecutor | undefined;
51   revision: number;
52   disposed: boolean;
53 }
54 
55 function validateAllocation(
56   allocation: Readonly<{
57     width: number;
58     height: number;
59   }>,
60 ): void {
61   if (!Number.isFinite(allocation.width)) {
62     throw layoutDiagnostic({
63       component: "Particles2D",
64       property: "width",
65       value: allocation.width,
66       algorithm: "component-allocation",
67       reason: "requires a finite declared allocation",
68     });
69   }
70   if (!Number.isFinite(allocation.height)) {
71     throw layoutDiagnostic({
72       component: "Particles2D",
73       property: "height",
74       value: allocation.height,
75       algorithm: "component-allocation",
76       reason: "requires a finite declared allocation",
77     });
78   }
79 }
80 
81 function writeParameter(
82   output: Float64Array,
83   entry: CompiledParameterEntry,
84   value: PibblParticleParameterValue,
85 ): void {
86   const numeric: readonly number[] | undefined =
87     entry.type === "color"
88       ? typeof value === "string"
89         ? parseParticleColor(value)
90         : undefined
91       : entry.length === 1
92         ? typeof value === "number"
93           ? [value]
94           : undefined
95         : Array.isArray(value)
96           ? value
97           : undefined;
98   if (
99     numeric === undefined ||
100     numeric.length !== entry.length ||
101     numeric.some((component) => !Number.isFinite(component))
102   ) {
103     throw new TypeError(
104       `Pibbl Particles2D parameter "${entry.name}" does not match ${entry.type}.`,
105     );
106   }
107   output.set(numeric, entry.offset);
108 }
109 
110 function resolveParameters(
111   compiled: CompiledEffect2D,
112   supplied: Readonly<Partial<PibblParticleParameterValues>> | undefined,
113 ): Float64Array {
114   const result = new Float64Array(compiled.parameterLayout.defaults);
115   if (supplied === undefined) return result;
116   for (const [name, value] of Object.entries(supplied)) {
117     const entry = compiled.parameterLayout.byName.get(name);
118     if (entry === undefined) {
119       throw new TypeError(
120         `Pibbl Particles2D received unknown parameter "${name}".`,
121       );
122     }
123     if (value === undefined) continue;
124     writeParameter(result, entry, value);
125   }
126   return result;
127 }
128 
129 function prepareImplicitSystem(
130   slot: ImplicitSystemSlot,
131   owner: PibblInternalRenderOwner,
132   effect: PibblParticleEffect2D,
133   autoplay: boolean | undefined,
134   seed: number | undefined,
135 ): Readonly<{
136   controller: ParticleSystemController;
137   effectCandidate: ParticleEffectCandidate;
138 }> {
139   if (slot.controller === undefined) {
140     slot.controller = createParticleSystemController(owner, effect, {
141       autoplay,
142       seed,
143     });
144     slot.mountSettled = false;
145   }
146   const controller = slot.controller;
147   const effectCandidate = controller.prepareEffect(effect, { autoplay, seed });
148   const revision = ++slot.revision;
149   pibblInternalOnRenderFinish(owner, (success) => {
150     if (!success || slot.disposed || revision !== slot.revision) {
151       controller.cancelEffect(effectCandidate);
152       return;
153     }
154     controller.adoptEffect(effectCandidate);
155     if (!slot.mountSettled) {
156       slot.mountSettled = true;
157       if (autoplay === true) controller.handle.play();
158     }
159   });
160   return { controller, effectCandidate };
161 }
162 
163 /**
164  * Renders one 2D effect through exactly one private Canvas drawing primitive.
165  *
166  * @param props - Particle system, drawing configuration, and event handlers. See
167  * {@link Particles2DProps} .
168  * @returns Pibbl nodes that paint the system's Canvas particles. See {@link PibblNode}.
169  *
170  * @see {@link Particles2DProps}
171  * @see {@link PibblNode}
172  * @see {@link PibblParticleParameterValues}
173  */
174 export function Particles2D<
175   EmitterName extends string,
176   Parameters extends PibblParticleParameterValues,
177 >(props: Particles2DProps<EmitterName, Parameters>): PibblNode {
178   const looseProps = resolveDeclaredParticleProps(
179     props,
180     PRESERVED_PARTICLE_PROPS,
181     "style",
182   ) as unknown as PibblParticleCommonProps<Parameters> & {
183     readonly effect?: PibblParticleEffect2D<EmitterName, Parameters>;
184     readonly system?: import("../types.js").PibblParticleSystem<
185       EmitterName,
186       Parameters
187     >;
188     readonly autoplay?: boolean;
189     readonly seed?: number;
190     readonly style?: import("../types.js").Particles2DStyle;
191   };
192   const owner = pibblInternalCurrentRenderOwner();
193   const allocation = useLayoutBox();
194   validateAllocation(allocation);
195 
196   const hasEffect = looseProps.effect !== undefined;
197   const hasSystem = looseProps.system !== undefined;
198   if (hasEffect === hasSystem) {
199     throw new TypeError(
200       "Pibbl Particles2D requires exactly one of effect or system.",
201     );
202   }
203   if (
204     hasSystem &&
205     (looseProps.autoplay !== undefined || looseProps.seed !== undefined)
206   ) {
207     throw new TypeError(
208       "Pibbl Particles2D autoplay and seed belong to implicit effect mode.",
209     );
210   }
211 
212   const implicitSlot = useInternalHookSlot<ImplicitSystemSlot>(
213     "particles2d-implicit-system",
214     (teardowns) => {
215       const value: ImplicitSystemSlot = {
216         owner,
217         controller: undefined,
218         mountSettled: false,
219         revision: 0,
220         disposed: false,
221       };
222       teardowns.add(() => {
223         value.disposed = true;
224         value.controller?.dispose();
225         value.controller = undefined;
226       });
227       return value;
228     },
229   ).value;
230   if (implicitSlot.owner !== owner) {
231     throw new Error("Pibbl Particles2D changed render ownership.");
232   }
233 
234   let controller: ParticleSystemController;
235   let compiled: CompiledEffect2D;
236   if (hasEffect) {
237     const prepared = prepareImplicitSystem(
238       implicitSlot,
239       owner,
240       looseProps.effect!,
241       looseProps.autoplay,
242       looseProps.seed,
243     );
244     controller = prepared.controller;
245     compiled = prepared.effectCandidate.compiled as CompiledEffect2D;
246   } else {
247     controller = getParticleSystemController(looseProps.system!);
248     if (controller.compiled.dimension !== 2) {
249       throw new TypeError("Pibbl Particles2D requires a two-dimensional system.");
250     }
251     compiled = controller.compiled;
252     implicitSlot.revision += 1;
253   }
254 
255   const parameters = resolveParameters(
256     compiled,
257     looseProps.parameters as
258       Readonly<Partial<PibblParticleParameterValues>> | undefined,
259   );
260   const executorSlot = useInternalHookSlot<CanvasExecutorSlot>(
261     "particles2d-canvas-executor",
262     (teardowns) => {
263       const value: CanvasExecutorSlot = {
264         owner,
265         controller: undefined,
266         pendingController: undefined,
267         committed: undefined,
268         pending: undefined,
269         revision: 0,
270         disposed: false,
271       };
272       teardowns.add(() => {
273         value.disposed = true;
274         value.pending?.dispose();
275         if (value.committed !== value.pending) value.committed?.dispose();
276         value.pending = undefined;
277         value.committed = undefined;
278         value.controller = undefined;
279         value.pendingController = undefined;
280       });
281       return value;
282     },
283   ).value;
284   if (executorSlot.owner !== owner) {
285     throw new Error("Pibbl Particles2D executor changed render ownership.");
286   }
287 
288   let candidate: CanvasParticleExecutor;
289   if (
290     executorSlot.pending !== undefined &&
291     executorSlot.pendingController === controller &&
292     executorSlot.pending.compiled === compiled
293   ) {
294     // A managed Layer can compose the same child again before the shared outer
295     // render transaction finishes. Consume the binding hook in place and keep
296     // drawing its existing candidate; the original finish callback remains the
297     // sole owner of reservation adoption and transaction finalization.
298     useInternalHookSlot("particle-system-binding", () => {
299       throw new Error("Pibbl Particles2D binding state was not initialized.");
300     });
301     candidate = executorSlot.pending;
302     candidate.prepareParameters(parameters);
303   } else {
304     candidate = useParticleSystemBinding({
305       system: controller.handle,
306       backend: "canvas",
307       onEvent: looseProps.onEvent,
308       onError: looseProps.onError,
309       create(bindingOwner) {
310         const current = executorSlot.committed;
311         if (
312           current !== undefined &&
313           executorSlot.controller === controller &&
314           current.compiled === compiled
315         ) {
316           current.prepareParameters(parameters);
317           executorSlot.pending = current;
318           executorSlot.pendingController = controller;
319           return current;
320         }
321         const next = createCanvasParticleExecutor({
322           owner: bindingOwner,
323           flow: controller.flow,
324           compiled,
325           parameters,
326           requestRender: () => pibblInternalRequestRender(bindingOwner),
327           currentSpawnEpoch: () => controller.currentSpawnEpoch,
328           spawnParameters: (epoch) => controller.spawnParameters(epoch),
329           retainSpawnEpoch: (epoch) => controller.retainSpawnEpoch(epoch),
330           releaseSpawnEpoch: (epoch) => controller.releaseSpawnEpoch(epoch),
331         });
332         executorSlot.pending = next;
333         executorSlot.pendingController = controller;
334         return next;
335       },
336     }) as CanvasParticleExecutor;
337   }
338 
339   const executorRevision = ++executorSlot.revision;
340   pibblInternalOnRenderFinish(owner, (success) => {
341     if (executorSlot.disposed || executorRevision !== executorSlot.revision) {
342       return;
343     }
344     executorSlot.pending = undefined;
345     executorSlot.pendingController = undefined;
346     if (!success) return;
347     executorSlot.committed = candidate;
348     executorSlot.controller = controller;
349 
350     if (!hasEffect && implicitSlot.controller !== undefined) {
351       const previousImplicit = implicitSlot.controller;
352       implicitSlot.controller = undefined;
353       implicitSlot.mountSettled = false;
354       previousImplicit.dispose();
355     }
356   });
357 
358   return createElement(ParticleCanvasPrimitive, {
359     executor: candidate,
360     interaction: looseProps,
361     style: looseProps.style,
362   });
363 }
364 
365 const PRESERVED_PARTICLE_PROPS = new Set<PropertyKey>(["system"]);
366 

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