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