Skip to content

packages/core/src/features/particles/lib/definition.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   assertFiniteVector,
3   assertNonnegativeNumber,
4   assertPositiveNumber,
5   cloneParticleRecord,
6   createParticleEffect2D,
7   hashParticleEmitterName,
8   normalizeBounds2D,
9   normalizeEmission,
10   normalizeParameters,
11   normalizeRenderer2D,
12   particleDefinitionError,
13   particleScalarRange,
14   validateAppearance,
15   validateScalarVector,
16   type ParticleEffect2DRecord,
17   type ParticleEmitter2DRecord,
18   type ParticleScalarRange,
19   type ParticleValidationContext,
20 } from './definition-records.js';
21 import { getParticleDescriptorRecord } from './particle.js';
22 import { compileParticleEffect2D } from './compiler.js';
23 import type {
24   PibblParticleAppearance,
25   PibblParticleBounds2D,
26   PibblParticleEffect2D,
27   PibblParticleEffect2DInput,
28   PibblParticleInitial2D,
29   PibblParticleMotion2D,
30   PibblParticleParameterDefinitions,
31   PibblParticleShape2D,
32   PibblParticleValuesOf,
33 } from './types.js';
34 
35 interface MutableBounds {
36   min: number[];
37   max: number[];
38 }
39 
40 function minimumDisplacement(range: ParticleScalarRange, seconds: number): number {
41   return Math.min(0, range.min * seconds, range.max * seconds);
42 }
43 
44 function maximumDisplacement(range: ParticleScalarRange, seconds: number): number {
45   return Math.max(0, range.min * seconds, range.max * seconds);
46 }
47 
48 function maximumMagnitude(range: ParticleScalarRange): number {
49   return Math.max(Math.abs(range.min), Math.abs(range.max));
50 }
51 
52 function normalizeShape(
53   shape: PibblParticleShape2D,
54   context: ParticleValidationContext,
55 ): { readonly shape: PibblParticleShape2D; readonly bounds: MutableBounds } {
56   if (shape === null || typeof shape !== 'object') {
57     particleDefinitionError({ ...context, property: 'shape' }, 'must be a record', shape);
58   }
59   const module = `shape ${String((shape as { type?: unknown }).type)}`;
60   switch (shape.type) {
61     case 'point': {
62       const position = shape.position ?? [0, 0] as const;
63       assertFiniteVector(position, 2, { ...context, module, property: 'shape.position' });
64       return {
65         shape: Object.freeze({ type: 'point', position: cloneParticleRecord(position) }),
66         bounds: { min: [...position], max: [...position] },
67       };
68     }
69     case 'line':
70       assertFiniteVector(shape.from, 2, { ...context, module, property: 'shape.from' });
71       assertFiniteVector(shape.to, 2, { ...context, module, property: 'shape.to' });
72       return {
73         shape: cloneParticleRecord(shape),
74         bounds: {
75           min: [Math.min(shape.from[0], shape.to[0]), Math.min(shape.from[1], shape.to[1])],
76           max: [Math.max(shape.from[0], shape.to[0]), Math.max(shape.from[1], shape.to[1])],
77         },
78       };
79     case 'rectangle':
80       assertPositiveNumber(shape.width, { ...context, module, property: 'shape.width' });
81       assertPositiveNumber(shape.height, { ...context, module, property: 'shape.height' });
82       if (shape.sample !== 'interior' && shape.sample !== 'edge') {
83         particleDefinitionError(
84           { ...context, module, property: 'shape.sample' },
85           'must be interior or edge',
86           shape.sample,
87         );
88       }
89       return {
90         shape: cloneParticleRecord(shape),
91         bounds: {
92           min: [-shape.width / 2, -shape.height / 2],
93           max: [shape.width / 2, shape.height / 2],
94         },
95       };
96     case 'circle':
97       assertNonnegativeNumber(shape.radius, {
98         ...context, module, property: 'shape.radius',
99       });
100       if (shape.sample !== 'interior' && shape.sample !== 'circumference') {
101         particleDefinitionError(
102           { ...context, module, property: 'shape.sample' },
103           'must be interior or circumference',
104           shape.sample,
105         );
106       }
107       return {
108         shape: cloneParticleRecord(shape),
109         bounds: {
110           min: [-shape.radius, -shape.radius],
111           max: [shape.radius, shape.radius],
112         },
113       };
114     case 'ring':
115       assertNonnegativeNumber(shape.innerRadius, {
116         ...context, module, property: 'shape.innerRadius',
117       });
118       assertNonnegativeNumber(shape.outerRadius, {
119         ...context, module, property: 'shape.outerRadius',
120       });
121       if (shape.innerRadius > shape.outerRadius) {
122         particleDefinitionError(
123           { ...context, module, property: 'shape.outerRadius' },
124           'must be at least shape.innerRadius',
125           shape.outerRadius,
126           RangeError,
127         );
128       }
129       return {
130         shape: cloneParticleRecord(shape),
131         bounds: {
132           min: [-shape.outerRadius, -shape.outerRadius],
133           max: [shape.outerRadius, shape.outerRadius],
134         },
135       };
136     default:
137       particleDefinitionError(
138         { ...context, module, property: 'shape.type' },
139         'uses an unsupported 2D shape',
140         (shape as { type?: unknown }).type,
141       );
142   }
143 }
144 
145 function requireRange(
146   value: Parameters<typeof particleScalarRange>[0],
147   parameters: PibblParticleParameterDefinitions,
148   context: ParticleValidationContext,
149   minimum?: number,
150   maximum?: number,
151 ): ParticleScalarRange {
152   const range = particleScalarRange(value, parameters, context);
153   if (minimum !== undefined && range.min < minimum) {
154     particleDefinitionError(context, `must be at least ${minimum}`, range.min, RangeError);
155   }
156   if (maximum !== undefined && range.max > maximum) {
157     particleDefinitionError(context, `must be at most ${maximum}`, range.max, RangeError);
158   }
159   return range;
160 }
161 
162 function validateColor(
163   value: PibblParticleInitial2D['color'],
164   parameters: PibblParticleParameterDefinitions,
165   context: ParticleValidationContext,
166 ): void {
167   if (value === undefined) return;
168   if (typeof value === 'string') {
169     if (value.trim().length === 0) {
170       particleDefinitionError(context, 'must be a nonempty color string', value);
171     }
172     return;
173   }
174   const record = getParticleDescriptorRecord(value);
175   if (record?.type === 'parameter') {
176     const definition = parameters[record.name];
177     if (definition === undefined) {
178       particleDefinitionError(context, 'references an unknown parameter', record.name);
179     }
180     if (definition.type !== 'color') {
181       particleDefinitionError(
182         context,
183         `parameter "${record.name}" must have type color; received ${definition.type}`,
184       );
185     }
186     return;
187   }
188   if (record?.type !== 'choice') {
189     particleDefinitionError(context, 'must use particleChoice for variable colors', value);
190   }
191   for (let index = 0; index < record.choices.length; index += 1) {
192     const choice = record.choices[index]!.value;
193     if (typeof choice !== 'string' || choice.trim().length === 0) {
194       particleDefinitionError(
195         { ...context, property: `${context.property}.choices[${index}].value` },
196         'must be a nonempty color string',
197         choice,
198       );
199     }
200   }
201 }
202 
203 function normalizeInitial(
204   initial: PibblParticleInitial2D,
205   parameters: PibblParticleParameterDefinitions,
206   context: ParticleValidationContext,
207 ): {
208   readonly initial: PibblParticleInitial2D;
209   readonly lifetime: ParticleScalarRange;
210   readonly velocity: readonly ParticleScalarRange[];
211   readonly maximumSize: number;
212   readonly dynamic: boolean;
213 } {
214   if (initial === null || typeof initial !== 'object') {
215     particleDefinitionError(
216       { ...context, property: 'initial' }, 'must be a record', initial,
217     );
218   }
219   const lifetime = requireRange(
220     initial.lifetimeMilliseconds,
221     parameters,
222     { ...context, module: 'initial', property: 'initial.lifetimeMilliseconds' },
223   );
224   if (lifetime.min <= 0) {
225     particleDefinitionError(
226       { ...context, module: 'initial', property: 'initial.lifetimeMilliseconds' },
227       'must be positive',
228       lifetime.min,
229       RangeError,
230     );
231   }
232   const velocity = initial.velocity === undefined ? Object.freeze([
233     Object.freeze({ min: 0, max: 0, dynamic: false }),
234     Object.freeze({ min: 0, max: 0, dynamic: false }),
235   ]) : validateScalarVector(
236     initial.velocity,
237     2,
238     parameters,
239     { ...context, module: 'initial', property: 'initial.velocity' },
240   );
241   let maximumSize = 1;
242   let sizeDynamic = false;
243   if (initial.size !== undefined) {
244     const sizeDescriptor = getParticleDescriptorRecord(initial.size);
245     const vectorSize = sizeDescriptor?.type === 'parameter' &&
246       parameters[sizeDescriptor.name]?.type === 'vector2';
247     const ranges = Array.isArray(initial.size) || vectorSize ? validateScalarVector(
248       initial.size,
249       2,
250       parameters,
251       { ...context, module: 'initial', property: 'initial.size' },
252     ) : [requireRange(
253       initial.size as Parameters<typeof particleScalarRange>[0],
254       parameters,
255       { ...context, module: 'initial', property: 'initial.size' },
256       0,
257     )];
258     for (const range of ranges) {
259       if (range.min < 0) {
260         particleDefinitionError(
261           { ...context, module: 'initial', property: 'initial.size' },
262           'must be nonnegative',
263           range.min,
264           RangeError,
265         );
266       }
267       maximumSize = Math.max(maximumSize, maximumMagnitude(range));
268       sizeDynamic ||= range.dynamic;
269     }
270   }
271   if (initial.rotationDegrees !== undefined) {
272     requireRange(initial.rotationDegrees, parameters, {
273       ...context, module: 'initial', property: 'initial.rotationDegrees',
274     });
275   }
276   if (initial.angularVelocityDegreesPerSecond !== undefined) {
277     requireRange(initial.angularVelocityDegreesPerSecond, parameters, {
278       ...context, module: 'initial',
279       property: 'initial.angularVelocityDegreesPerSecond',
280     });
281   }
282   validateColor(initial.color, parameters, {
283     ...context, module: 'initial', property: 'initial.color',
284   });
285   if (initial.opacity !== undefined) {
286     requireRange(initial.opacity, parameters, {
287       ...context, module: 'initial', property: 'initial.opacity',
288     }, 0, 1);
289   }
290   return Object.freeze({
291     initial: cloneParticleRecord(initial),
292     lifetime,
293     velocity,
294     maximumSize,
295     dynamic: lifetime.dynamic || velocity.some(range => range.dynamic) || sizeDynamic,
296   });
297 }
298 
299 function maximumAppearanceSize(
300   appearance: PibblParticleAppearance | undefined,
301   current: number,
302 ): number {
303   const curve = appearance?.size;
304   if (curve === undefined) return current;
305   let result = current;
306   for (const [, value] of curve.keys) {
307     if (typeof value === 'number') result = Math.max(result, Math.abs(value));
308     else result = Math.max(result, Math.abs(value[0]), Math.abs(value[1]));
309   }
310   return result;
311 }
312 
313 function normalizeMotion(
314   motion: readonly PibblParticleMotion2D[] | undefined,
315   parameters: PibblParticleParameterDefinitions,
316   context: ParticleValidationContext,
317 ): { readonly motion: readonly PibblParticleMotion2D[]; readonly ranges: readonly unknown[]; readonly dynamic: boolean } {
318   if (motion === undefined) {
319     return Object.freeze({ motion: Object.freeze([]), ranges: Object.freeze([]), dynamic: false });
320   }
321   if (!Array.isArray(motion)) {
322     particleDefinitionError({ ...context, property: 'motion' }, 'must be an array', motion);
323   }
324   let orbitWriter: number | undefined;
325   let additivePositionWriter: number | undefined;
326   const ranges: unknown[] = [];
327   let dynamic = false;
328   for (let index = 0; index < motion.length; index += 1) {
329     const moduleValue = motion[index]!;
330     if (moduleValue === null || typeof moduleValue !== 'object') {
331       particleDefinitionError(
332         { ...context, module: `motion[${index}]`, property: 'motion' },
333         'must be a record',
334         moduleValue,
335       );
336     }
337     const module = `motion[${index}] ${String((moduleValue as { type?: unknown }).type)}`;
338     if (moduleValue.type === 'orbit') {
339       const earlier = orbitWriter ?? additivePositionWriter;
340       if (earlier !== undefined) {
341         particleDefinitionError(
342           { ...context, module, property: 'position' },
343           `ambiguously writes position already written by motion[${earlier}]`,
344           moduleValue.type,
345         );
346       }
347       orbitWriter = index;
348     } else if (moduleValue.type === 'drift' || moduleValue.type === 'sway') {
349       if (orbitWriter !== undefined) {
350         particleDefinitionError(
351           { ...context, module, property: 'position' },
352           `ambiguously writes position already written by motion[${orbitWriter}]`,
353           moduleValue.type,
354         );
355       }
356       additivePositionWriter ??= index;
357     }
358     switch (moduleValue.type) {
359       case 'acceleration':
360       case 'gravity':
361       case 'drift': {
362         const vector = moduleValue.type === 'drift' ?
363           moduleValue.velocity : moduleValue.value;
364         const valueRanges = validateScalarVector(vector, 2, parameters, {
365           ...context, module,
366           property: moduleValue.type === 'drift' ? 'motion.velocity' : 'motion.value',
367         });
368         ranges.push({ type: moduleValue.type, value: valueRanges });
369         dynamic ||= valueRanges.some(range => range.dynamic);
370         break;
371       }
372       case 'drag': {
373         const amount = requireRange(moduleValue.amount, parameters, {
374           ...context, module, property: 'motion.amount',
375         }, 0);
376         ranges.push({ type: 'drag', amount });
377         dynamic ||= amount.dynamic;
378         break;
379       }
380       case 'radialAcceleration': {
381         assertFiniteVector(moduleValue.center, 2, {
382           ...context, module, property: 'motion.center',
383         });
384         const amount = requireRange(moduleValue.amount, parameters, {
385           ...context, module, property: 'motion.amount',
386         });
387         ranges.push({ type: 'radialAcceleration', center: moduleValue.center, amount });
388         dynamic ||= amount.dynamic;
389         break;
390       }
391       case 'orbit': {
392         assertFiniteVector(moduleValue.center, 2, {
393           ...context, module, property: 'motion.center',
394         });
395         const speed = requireRange(moduleValue.degreesPerSecond, parameters, {
396           ...context, module, property: 'motion.degreesPerSecond',
397         });
398         ranges.push({ type: 'orbit', center: moduleValue.center, speed });
399         dynamic ||= speed.dynamic;
400         break;
401       }
402       case 'sway': {
403         const amplitude = requireRange(moduleValue.amplitude, parameters, {
404           ...context, module, property: 'motion.amplitude',
405         }, 0);
406         const frequency: readonly ParticleScalarRange[] =
407           Array.isArray(moduleValue.frequency) ? moduleValue.frequency.map(
408             (value: number, valueIndex: number) => requireRange(value, parameters, {
409               ...context, module, property: `motion.frequency[${valueIndex}]`,
410             }, 0),
411           ) : [requireRange(moduleValue.frequency, parameters, {
412             ...context, module, property: 'motion.frequency',
413           }, 0)];
414         ranges.push({ type: 'sway', amplitude, frequency });
415         dynamic ||= amplitude.dynamic || frequency.some(range => range.dynamic);
416         break;
417       }
418       case 'angularVelocity': {
419         const speed = requireRange(moduleValue.degreesPerSecond, parameters, {
420           ...context, module, property: 'motion.degreesPerSecond',
421         });
422         ranges.push({ type: 'angularVelocity', speed });
423         dynamic ||= speed.dynamic;
424         break;
425       }
426       default:
427         particleDefinitionError(
428           { ...context, module, property: 'motion.type' },
429           'uses an unsupported 2D motion module',
430           (moduleValue as { type?: unknown }).type,
431         );
432     }
433   }
434   return Object.freeze({
435     motion: cloneParticleRecord(motion),
436     ranges: Object.freeze(ranges.map(range => cloneParticleRecord(range))),
437     dynamic,
438   });
439 }
440 
441 function deriveBounds(
442   shape: MutableBounds,
443   lifetimeMilliseconds: number,
444   velocity: readonly ParticleScalarRange[],
445   motionRanges: readonly unknown[],
446   size: number,
447 ): PibblParticleBounds2D {
448   const seconds = lifetimeMilliseconds / 1_000;
449   const bounds: MutableBounds = { min: [...shape.min], max: [...shape.max] };
450   for (let axis = 0; axis < 2; axis += 1) {
451     bounds.min[axis]! += minimumDisplacement(velocity[axis]!, seconds);
452     bounds.max[axis]! += maximumDisplacement(velocity[axis]!, seconds);
453   }
454   for (const untyped of motionRanges) {
455     const motion = untyped as {
456       type: string;
457       value?: readonly ParticleScalarRange[];
458       amount?: ParticleScalarRange;
459       amplitude?: ParticleScalarRange;
460       center?: readonly [number, number];
461     };
462     if (motion.type === 'acceleration' || motion.type === 'gravity') {
463       for (let axis = 0; axis < 2; axis += 1) {
464         const scale = 0.5 * seconds * seconds;
465         bounds.min[axis]! += minimumDisplacement(motion.value![axis]!, scale);
466         bounds.max[axis]! += maximumDisplacement(motion.value![axis]!, scale);
467       }
468     } else if (motion.type === 'drift') {
469       for (let axis = 0; axis < 2; axis += 1) {
470         bounds.min[axis]! += minimumDisplacement(motion.value![axis]!, seconds);
471         bounds.max[axis]! += maximumDisplacement(motion.value![axis]!, seconds);
472       }
473     } else if (motion.type === 'radialAcceleration') {
474       const expansion = 0.5 * maximumMagnitude(motion.amount!) * seconds * seconds;
475       for (let axis = 0; axis < 2; axis += 1) {
476         bounds.min[axis]! -= expansion;
477         bounds.max[axis]! += expansion;
478       }
479     } else if (motion.type === 'sway') {
480       const expansion = maximumMagnitude(motion.amplitude!);
481       for (let axis = 0; axis < 2; axis += 1) {
482         bounds.min[axis]! -= expansion;
483         bounds.max[axis]! += expansion;
484       }
485     } else if (motion.type === 'orbit') {
486       const center = motion.center!;
487       const radius = Math.max(
488         Math.hypot(bounds.min[0]! - center[0], bounds.min[1]! - center[1]),
489         Math.hypot(bounds.min[0]! - center[0], bounds.max[1]! - center[1]),
490         Math.hypot(bounds.max[0]! - center[0], bounds.min[1]! - center[1]),
491         Math.hypot(bounds.max[0]! - center[0], bounds.max[1]! - center[1]),
492       );
493       bounds.min = [center[0] - radius, center[1] - radius];
494       bounds.max = [center[0] + radius, center[1] + radius];
495     }
496   }
497   const rendererExpansion = size / 2;
498   return Object.freeze({
499     min: Object.freeze([
500       bounds.min[0]! - rendererExpansion,
501       bounds.min[1]! - rendererExpansion,
502     ]) as readonly [number, number],
503     max: Object.freeze([
504       bounds.max[0]! + rendererExpansion,
505       bounds.max[1]! + rendererExpansion,
506     ]) as readonly [number, number],
507   });
508 }
509 
510 function normalizeEmitter(
511   input: import('./types.js').PibblParticleEmitter2D,
512   parameters: PibblParticleParameterDefinitions,
513   debugName: string | undefined,
514 ): ParticleEmitter2DRecord {
515   if (input === null || typeof input !== 'object') {
516     particleDefinitionError(
517       { debugName, property: 'emitter' }, 'must be a record', input,
518     );
519   }
520   const context = { debugName, emitter: input.name };
521   if (typeof input.name !== 'string' || input.name.trim().length === 0) {
522     particleDefinitionError(
523       { debugName, emitter: String(input.name), property: 'name' },
524       'must be a nonempty string',
525       input.name,
526     );
527   }
528   if (!Number.isSafeInteger(input.capacity) || input.capacity <= 0) {
529     particleDefinitionError(
530       { ...context, property: 'capacity' },
531       'must be a positive finite safe integer',
532       input.capacity,
533       RangeError,
534     );
535   }
536   if (input.space !== undefined && input.space !== 'local') {
537     particleDefinitionError(
538       { ...context, property: 'space' }, 'must be local in Project 1', input.space,
539     );
540   }
541   if (input.overflow !== undefined &&
542     input.overflow !== 'reject-newest' && input.overflow !== 'recycle-oldest') {
543     particleDefinitionError(
544       { ...context, property: 'overflow' }, 'is unsupported', input.overflow,
545     );
546   }
547   const emission = normalizeEmission(input.emission, context);
548   const normalizedShape = normalizeShape(input.shape, context);
549   const normalizedInitial = normalizeInitial(input.initial, parameters, context);
550   const normalizedMotion = normalizeMotion(input.motion, parameters, context);
551   const appearance = validateAppearance(input.appearance, context);
552   const renderer = normalizeRenderer2D(input.renderer, context);
553   const cannotDerive = normalizedInitial.dynamic || normalizedMotion.dynamic ||
554     renderer.type === 'path' || renderer.type === 'sprite';
555   let bounds: PibblParticleBounds2D;
556   let boundsSource: 'explicit' | 'derived';
557   if (input.bounds !== undefined) {
558     bounds = normalizeBounds2D(input.bounds, context);
559     boundsSource = 'explicit';
560   } else if (cannotDerive) {
561     particleDefinitionError(
562       { ...context, property: 'bounds' },
563       'must be explicit and finite for dynamic, parameterized, or renderer-unbounded motion',
564     );
565   } else {
566     bounds = normalizeBounds2D(
567       deriveBounds(
568         normalizedShape.bounds,
569         normalizedInitial.lifetime.max,
570         normalizedInitial.velocity,
571         normalizedMotion.ranges,
572         maximumAppearanceSize(appearance, normalizedInitial.maximumSize),
573       ),
574       context,
575     );
576     boundsSource = 'derived';
577   }
578   return Object.freeze({
579     name: input.name,
580     nameHash: hashParticleEmitterName(input.name),
581     capacity: input.capacity,
582     overflow: input.overflow ?? 'reject-newest',
583     space: 'local',
584     emission,
585     shape: normalizedShape.shape,
586     initial: normalizedInitial.initial,
587     motion: normalizedMotion.motion,
588     ...(appearance === undefined ? {} : { appearance }),
589     renderer,
590     bounds,
591     boundsSource,
592   });
593 }
594 
595 /**
596  * Validates and brands a reusable Canvas 2D particle effect while preserving emitter names and
597  * parameter types.
598  *
599  * @param input - Emitter definitions, parameter declarations, and effect configuration.
600  * @returns A validated particle effect preserving emitter names and parameter types. See
601  * {@link PibblParticleEffect2D} , {@link PibblParticleValuesOf} ,
602  * {@link PibblParticleParameterDefinitions} .
603  *
604  * @see {@link PibblParticleEffect2D}
605  * @see {@link PibblParticleValuesOf}
606  * @see {@link PibblParticleParameterDefinitions}
607  * @see {@link PibblParticleEffect2DInput}
608  */
609 export function defineParticleEffect2D<
610   const Input extends PibblParticleEffect2DInput,
611 >(
612   input: Input,
613 ): PibblParticleEffect2D<
614   Input['emitters'][number]['name'],
615   PibblParticleValuesOf<
616     Input extends { readonly parameters: infer Definitions }
617       ? Extract<Definitions, PibblParticleParameterDefinitions>
618       : undefined
619   >
620 > {
621   if (input === null || typeof input !== 'object') {
622     particleDefinitionError({}, 'input must be a record', input);
623   }
624   const debugName = input.debugName;
625   if (debugName !== undefined &&
626     (typeof debugName !== 'string' || debugName.trim().length === 0)) {
627     particleDefinitionError({ property: 'debugName' }, 'must be a nonempty string', debugName);
628   }
629   if (!Array.isArray(input.emitters) || input.emitters.length === 0) {
630     particleDefinitionError({ debugName, property: 'emitters' }, 'must be nonempty', input.emitters);
631   }
632   const parameters = normalizeParameters(input.parameters, debugName);
633   const seenNames = new Set<string>();
634   const emitters: ParticleEmitter2DRecord[] = [];
635   for (const emitter of input.emitters) {
636     if (emitter === null || typeof emitter !== 'object') {
637       particleDefinitionError(
638         { debugName, property: 'emitter' }, 'must be a record', emitter,
639       );
640     }
641     if (seenNames.has(emitter.name)) {
642       particleDefinitionError(
643         { debugName, emitter: emitter.name, property: 'name' },
644         'must be unique; duplicate emitter name',
645         emitter.name,
646       );
647     }
648     seenNames.add(emitter.name);
649     emitters.push(normalizeEmitter(emitter, parameters, debugName));
650   }
651   const record: ParticleEffect2DRecord = Object.freeze({
652     dimension: 2,
653     debugName,
654     parameters,
655     emitters: Object.freeze(emitters),
656     emitterNames: Object.freeze(emitters.map(emitter => emitter.name)),
657     capabilities: Object.freeze({
658       statelessCanvas: true,
659       requiresStateful: Object.freeze([]),
660     }),
661   });
662   const effect = createParticleEffect2D(record) as PibblParticleEffect2D<
663     Input['emitters'][number]['name'],
664     PibblParticleValuesOf<
665       Input extends { readonly parameters: infer Definitions }
666         ? Extract<Definitions, PibblParticleParameterDefinitions>
667         : undefined
668     >
669   >;
670   compileParticleEffect2D(effect);
671   return effect;
672 }
673 

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