@dailies-studio/particles 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dailies-studio/particles",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "First-party GLB particle emitter: a contracted react-three-fiber component whose emission is a pure function of (frame, seed) — seeded PCG32 + closed-form kinematics, never per-tick integration. Dogfoods the contract system's curve/gradient/asset kinds.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,9 +29,9 @@
29
29
  "@react-three/drei": "^10.7.7",
30
30
  "@react-three/fiber": "^9.6.1",
31
31
  "three": "^0.185.1",
32
- "@dailies-studio/contract": "0.1.0",
33
- "@dailies-studio/engine": "0.1.0",
34
- "@dailies-studio/runtime": "0.1.0"
32
+ "@dailies-studio/contract": "0.2.0",
33
+ "@dailies-studio/engine": "0.2.0",
34
+ "@dailies-studio/runtime": "0.2.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": ">=18.0"
package/src/controls.ts CHANGED
@@ -39,6 +39,14 @@ export const DEFAULTS = {
39
39
  scaleOverLife: DEFAULT_SCALE_OVER_LIFE,
40
40
  colorOverLife: DEFAULT_COLOR_OVER_LIFE,
41
41
  glbSrc: null as string | null,
42
+ // The basics-overhaul growth: an emission volume, a burst mode, a sprite.
43
+ // Every default is the pre-growth behaviour, so "no props" stays the same
44
+ // animation it always was.
45
+ emitterShape: "point" as "point" | "box" | "sphere",
46
+ emitterExtent: 0.5,
47
+ emitMode: "loop" as "loop" | "burst",
48
+ burstSeconds: 0.25,
49
+ spriteSrc: null as string | null,
42
50
  } as const;
43
51
 
44
52
  export const FOIL_BALLOONS_DEFAULTS = {
@@ -143,5 +151,35 @@ export const controls: Contract = defineControls({
143
151
  seed: c.number({ default: DEFAULTS.seed, min: 0, max: 99999, step: 1, animatable: false }),
144
152
  scaleOverLife: c.curve({ default: DEFAULT_SCALE_OVER_LIFE, label: "Scale over life" }),
145
153
  colorOverLife: c.gradient({ default: DEFAULT_COLOR_OVER_LIFE, label: "Color over life" }),
154
+ // Where particles are born. No "cone" volume on purpose: conical emission is
155
+ // `spread`'s whole job, and a second cone would be two knobs over one
156
+ // meaning.
157
+ emitterShape: c.enum({
158
+ options: ["point", "box", "sphere"],
159
+ default: DEFAULTS.emitterShape,
160
+ label: "Emitter shape",
161
+ }),
162
+ emitterExtent: c.number({
163
+ default: DEFAULTS.emitterExtent,
164
+ min: 0.01,
165
+ max: 10,
166
+ step: 0.01,
167
+ label: "Emitter extent",
168
+ }),
169
+ // "loop" streams forever; "burst" emits only the first window's worth —
170
+ // closed-form (an index cap, never emitted-so-far state), so scrubbing
171
+ // backwards through a burst is exact.
172
+ emitMode: c.enum({ options: ["loop", "burst"], default: DEFAULTS.emitMode, label: "Emit" }),
173
+ burstSeconds: c.number({
174
+ default: DEFAULTS.burstSeconds,
175
+ min: 0.05,
176
+ max: 5,
177
+ step: 0.05,
178
+ unit: "s",
179
+ label: "Burst window",
180
+ }),
146
181
  glbSrc: c.asset({ assetKind: "glb", default: null, label: "GLB model" }),
182
+ // A textured quad as the particle — Spline's model. The GLB wins when both
183
+ // are set (the pre-growth precedence, so no shipped document changes).
184
+ spriteSrc: c.asset({ assetKind: "image", default: null, label: "Sprite image" }),
147
185
  });
package/src/emission.ts CHANGED
@@ -27,6 +27,22 @@ export interface EmissionConfig {
27
27
  spinMaxDegPerSec: number;
28
28
  /** The contract seed: same seed = same animation, everywhere, forever. */
29
29
  seed: number;
30
+ /**
31
+ * The emission VOLUME a particle is born inside (basics overhaul). Absent =
32
+ * "point", which is every animation shipped before the field existed. There
33
+ * is deliberately no "cone" volume: conical emission is `spreadDeg`'s whole
34
+ * job, and a second cone would be two knobs fighting over one meaning.
35
+ */
36
+ emitterShape?: "point" | "box" | "sphere";
37
+ /** The volume's extent, world units: box half-extent per axis, sphere
38
+ * radius. Absent = 0.5. Ignored for "point". */
39
+ emitterExtent?: number;
40
+ /** "loop" (absent, the default) streams forever; "burst" emits ONLY the
41
+ * first `burstSeconds` worth of particles and then nothing, closed-form —
42
+ * the range is capped by index, never by state. */
43
+ emitMode?: "loop" | "burst";
44
+ /** The burst window, seconds. Absent = 0.25. */
45
+ burstSeconds?: number;
30
46
  }
31
47
 
32
48
  export type Vec3Tuple = readonly [number, number, number];
@@ -43,6 +59,9 @@ export interface ParticleAttributes {
43
59
  spinAxis: Vec3Tuple;
44
60
  /** Signed spin rate, radians/sec. */
45
61
  spinRate: number;
62
+ /** Birth position inside the emitter volume, emitter-local. [0,0,0] for a
63
+ * "point" emitter — every pre-volume animation, bit-identical. */
64
+ origin: Vec3Tuple;
46
65
  }
47
66
 
48
67
  const DEG = Math.PI / 180;
@@ -51,6 +70,14 @@ const DEG = Math.PI / 180;
51
70
  * Particle i's immutable attributes. Draw order (the wire format):
52
71
  * 1. cone cap height 2. cone azimuth 3. speed jitter
53
72
  * 4. spin-axis z 5. spin-axis azimuth 6. spin rate
73
+ * 7. volume u1 8. volume u2 9. volume u3
74
+ *
75
+ * Draws 7-9 were APPENDED for the emitter volume (basics overhaul) and are
76
+ * drawn UNCONDITIONALLY — a "point" emitter discards them — so switching the
77
+ * shape can never reshuffle another channel, and a future draw 10 sits after
78
+ * them for every shape. Draws 1-6 are byte-identical to the original wire
79
+ * format, which is what keeps every animation exported before the volume
80
+ * existed exact.
54
81
  */
55
82
  export function particleAttributes(cfg: EmissionConfig, index: number): ParticleAttributes {
56
83
  // PCG32 seeding is 64-bit integer math; a fractional or non-finite seed
@@ -81,15 +108,44 @@ export function particleAttributes(cfg: EmissionConfig, index: number): Particle
81
108
 
82
109
  const spinRate = (rng.next01() * 2 - 1) * cfg.spinMaxDegPerSec * DEG;
83
110
 
111
+ // Draws 7-9: the volume uniforms, drawn for EVERY shape (see the wire-format
112
+ // note above). Interpretation is per shape and closed-form — no rejection
113
+ // sampling, whose variable draw count would be its own wire-format break.
114
+ const u1 = rng.next01();
115
+ const u2 = rng.next01();
116
+ const u3 = rng.next01();
117
+ const extent = Number.isFinite(cfg.emitterExtent) && cfg.emitterExtent! > 0 ? cfg.emitterExtent! : 0.5;
118
+ let origin: Vec3Tuple = [0, 0, 0];
119
+ if (cfg.emitterShape === "box") {
120
+ origin = [(u1 * 2 - 1) * extent, (u2 * 2 - 1) * extent, (u3 * 2 - 1) * extent];
121
+ } else if (cfg.emitterShape === "sphere") {
122
+ // Uniform in the ball: direction from (z, phi), radius by cbrt — the
123
+ // closed-form density-correct draw.
124
+ const z = u1 * 2 - 1;
125
+ const phi = u2 * 2 * Math.PI;
126
+ const r = Math.cbrt(u3) * extent;
127
+ const ring = Math.sqrt(Math.max(0, 1 - z * z));
128
+ origin = [ring * Math.cos(phi) * r, z * r, ring * Math.sin(phi) * r];
129
+ }
130
+
84
131
  return {
85
132
  birth: index / cfg.emissionRate,
86
133
  direction,
87
134
  speed,
88
135
  spinAxis,
89
136
  spinRate,
137
+ origin,
90
138
  };
91
139
  }
92
140
 
141
+ /** How many particles a BURST emits: every index whose birth lands inside the
142
+ * window. Infinity for the loop mode, so range math can `min` against it. */
143
+ export function burstIndexCap(cfg: EmissionConfig): number {
144
+ if (cfg.emitMode !== "burst") return Number.POSITIVE_INFINITY;
145
+ const seconds = Number.isFinite(cfg.burstSeconds) && cfg.burstSeconds! > 0 ? cfg.burstSeconds! : 0.25;
146
+ return Math.ceil(cfg.emissionRate * seconds);
147
+ }
148
+
93
149
  /**
94
150
  * CANDIDATE indices for particles alive at time t — a guaranteed superset of
95
151
  * every index whose {@link particleState} gate (birth ≤ t < birth+lifetime)
@@ -104,15 +160,20 @@ export function particleAttributes(cfg: EmissionConfig, index: number): Particle
104
160
  export function activeIndexRange(cfg: EmissionConfig, t: number): [number, number] {
105
161
  if (!(cfg.emissionRate > 0) || !(cfg.particleLifetime > 0) || !(t >= 0)) return [0, 0];
106
162
  // birth = i/rate ≤ t → i ≤ t·rate ; t < birth + L → i > (t - L)·rate
107
- const end = Math.floor(t * cfg.emissionRate) + 2;
163
+ // A burst caps the candidate INDEX, never the gate: indices past the window
164
+ // simply never existed, which is what keeps the mode a pure function of the
165
+ // frame rather than a piece of emitted-so-far state.
166
+ const end = Math.min(Math.floor(t * cfg.emissionRate) + 2, burstIndexCap(cfg));
108
167
  const start = Math.max(0, Math.floor((t - cfg.particleLifetime) * cfg.emissionRate) - 1);
109
168
  return start < end ? [start, end] : [0, 0];
110
169
  }
111
170
 
112
- /** Capacity an instance buffer needs to hold every concurrently-live particle. */
171
+ /** Capacity an instance buffer needs to hold every concurrently-live particle.
172
+ * A burst can never hold more than its own index cap. */
113
173
  export function particleCapacity(cfg: EmissionConfig, cap = 4096): number {
114
174
  if (!(cfg.emissionRate > 0) || !(cfg.particleLifetime > 0)) return 0;
115
- return Math.min(cap, Math.ceil(cfg.emissionRate * cfg.particleLifetime) + 1);
175
+ const streaming = Math.ceil(cfg.emissionRate * cfg.particleLifetime) + 1;
176
+ return Math.min(cap, streaming, burstIndexCap(cfg));
116
177
  }
117
178
 
118
179
  export interface ParticleState {
@@ -132,18 +193,28 @@ export function particleState(
132
193
  attrs: ParticleAttributes,
133
194
  cfg: EmissionConfig,
134
195
  t: number,
196
+ /** Written in place and returned when given: the emitter's per-frame loop
197
+ * runs this once per live particle (up to 4096 per frame) and allocated a
198
+ * state object and a position tuple for each. Same numbers either way. */
199
+ out?: ParticleState,
135
200
  ): ParticleState | null {
136
201
  const age = t - attrs.birth;
137
202
  if (!(age >= 0) || age >= cfg.particleLifetime) return null;
138
203
  const s = attrs.speed;
139
204
  const halfG = -0.5 * cfg.gravity * age * age;
140
- return {
141
- position: [
142
- attrs.direction[0] * s * age,
143
- attrs.direction[1] * s * age + halfG,
144
- attrs.direction[2] * s * age,
145
- ],
146
- spinAngle: attrs.spinRate * age,
147
- life01: age / cfg.particleLifetime,
148
- };
205
+ const x = attrs.origin[0] + attrs.direction[0] * s * age;
206
+ const y = attrs.origin[1] + attrs.direction[1] * s * age + halfG;
207
+ const z = attrs.origin[2] + attrs.direction[2] * s * age;
208
+ const spinAngle = attrs.spinRate * age;
209
+ const life01 = age / cfg.particleLifetime;
210
+ if (out !== undefined) {
211
+ const position = out.position as [number, number, number];
212
+ position[0] = x;
213
+ position[1] = y;
214
+ position[2] = z;
215
+ out.spinAngle = spinAngle;
216
+ out.life01 = life01;
217
+ return out;
218
+ }
219
+ return { position: [x, y, z], spinAngle, life01 };
149
220
  }
package/src/emitter.tsx CHANGED
@@ -7,7 +7,7 @@
7
7
  import React, { Suspense, useLayoutEffect, useMemo } from "react";
8
8
  import * as THREE from "three";
9
9
  import { useThree } from "@react-three/fiber";
10
- import { useGLTF } from "@react-three/drei";
10
+ import { useGLTF, useTexture } from "@react-three/drei";
11
11
  import { compileCurve, compileGradient } from "@dailies-studio/contract";
12
12
  import type { CurveValue, GradientValue } from "@dailies-studio/contract";
13
13
  import { useStudioClock } from "@dailies-studio/runtime";
@@ -17,7 +17,7 @@ import {
17
17
  particleCapacity,
18
18
  particleState,
19
19
  } from "./emission.js";
20
- import type { EmissionConfig, ParticleAttributes } from "./emission.js";
20
+ import type { EmissionConfig, ParticleAttributes, ParticleState } from "./emission.js";
21
21
  import { DEFAULTS, DEFAULT_COLOR_OVER_LIFE, DEFAULT_SCALE_OVER_LIFE } from "./controls.js";
22
22
 
23
23
  export interface ParticleEmitterProps {
@@ -33,6 +33,11 @@ export interface ParticleEmitterProps {
33
33
  scaleOverLife?: CurveValue;
34
34
  colorOverLife?: GradientValue;
35
35
  glbSrc?: string | null;
36
+ emitterShape?: "point" | "box" | "sphere";
37
+ emitterExtent?: number;
38
+ emitMode?: "loop" | "burst";
39
+ burstSeconds?: number;
40
+ spriteSrc?: string | null;
36
41
  }
37
42
 
38
43
  // Loop temporaries — module scope so the per-frame update allocates nothing.
@@ -41,7 +46,23 @@ const POS = new THREE.Vector3();
41
46
  const SCL = new THREE.Vector3();
42
47
  const AXIS = new THREE.Vector3();
43
48
  const QUAT = new THREE.Quaternion();
44
- const COLOR = new THREE.Color();
49
+ /** Written in place by `particleState` for every live particle per frame. */
50
+ const STATE: ParticleState = { position: [0, 0, 0], spinAngle: 0, life01: 0 };
51
+
52
+ // `THREE.Color.set(string)` is a CSS colour parse, and it ran once per live
53
+ // particle per frame (up to 4096). The gradient hands back 8-bit hex strings,
54
+ // so the distinct values are few: one parsed Color per hex, in a table cleared
55
+ // whole when it fills. The returned Color is READ ONLY (`setColorAt` copies).
56
+ const COLOR_BY_HEX = new Map<string, THREE.Color>();
57
+ const COLOR_BY_HEX_LIMIT = 4096;
58
+ function colorForHex(hex: string): THREE.Color {
59
+ const cached = COLOR_BY_HEX.get(hex);
60
+ if (cached !== undefined) return cached;
61
+ const color = new THREE.Color().set(hex);
62
+ if (COLOR_BY_HEX.size >= COLOR_BY_HEX_LIMIT) COLOR_BY_HEX.clear();
63
+ COLOR_BY_HEX.set(hex, color);
64
+ return color;
65
+ }
45
66
 
46
67
  /** First mesh in a GLTF scene — the emitter's instanced geometry source. */
47
68
  export function firstMeshOf(scene: THREE.Object3D): THREE.Mesh | null {
@@ -96,6 +117,8 @@ export function ParticleInstances(props: ParticleInstancesProps): React.ReactEle
96
117
  config.speed,
97
118
  config.speedJitter,
98
119
  config.spinMaxDegPerSec,
120
+ config.emitterShape,
121
+ config.emitterExtent,
99
122
  ];
100
123
  return new Map<number, ParticleAttributes>();
101
124
  }, [
@@ -105,6 +128,11 @@ export function ParticleInstances(props: ParticleInstancesProps): React.ReactEle
105
128
  config.speed,
106
129
  config.speedJitter,
107
130
  config.spinMaxDegPerSec,
131
+ // The volume feeds `origin`, so its two fields re-roll the cache exactly
132
+ // as the six draw inputs above do. The burst fields deliberately do NOT:
133
+ // they cap the index range and never touch an attribute.
134
+ config.emitterShape,
135
+ config.emitterExtent,
108
136
  ]);
109
137
 
110
138
  const scaleFn = useMemo(() => compileCurve(props.scaleOverLife), [props.scaleOverLife]);
@@ -124,7 +152,7 @@ export function ParticleInstances(props: ParticleInstancesProps): React.ReactEle
124
152
  attrs = particleAttributes(config, i);
125
153
  attrCache.set(i, attrs);
126
154
  }
127
- const st = particleState(attrs, config, timeSeconds);
155
+ const st = particleState(attrs, config, timeSeconds, STATE);
128
156
  if (!st) continue;
129
157
  const s = Math.max(0, size * scaleFn(st.life01));
130
158
  AXIS.set(attrs.spinAxis[0], attrs.spinAxis[1], attrs.spinAxis[2]);
@@ -134,8 +162,7 @@ export function ParticleInstances(props: ParticleInstancesProps): React.ReactEle
134
162
  M4.compose(POS, QUAT, SCL);
135
163
  mesh.setMatrixAt(n, M4);
136
164
  // THREE.Color has no alpha channel — an 8-digit stop keeps its rgb.
137
- COLOR.set(colorFn(st.life01).slice(0, 7));
138
- mesh.setColorAt(n, COLOR);
165
+ mesh.setColorAt(n, colorForHex(colorFn(st.life01).slice(0, 7)));
139
166
  n++;
140
167
  }
141
168
  mesh.count = n;
@@ -172,6 +199,34 @@ const DEFAULT_GEOMETRY = new THREE.TetrahedronGeometry(1);
172
199
  // Unlit: the gradient IS the look, with or without user lights.
173
200
  const DEFAULT_MATERIAL = new THREE.MeshBasicMaterial({ color: 0xffffff });
174
201
 
202
+ // The sprite quad, shared: a unit plane every sprite particle instances.
203
+ const SPRITE_GEOMETRY = new THREE.PlaneGeometry(1, 1);
204
+
205
+ /**
206
+ * Sprite particles — a textured quad as the particle (Spline's model).
207
+ *
208
+ * NOT billboarded, deliberately: a camera-facing quad needs the camera pose at
209
+ * commit time, which is a second read-order dependency between subtrees this
210
+ * lane refuses to own. The quad faces +Z, which faces the camera in the rest
211
+ * framing, and the ordinary spin channel reads as designed on round sprites.
212
+ * Alpha-blended with depthWrite off, the layer-plane rule, so soft edges never
213
+ * punch holes in what draws behind them.
214
+ */
215
+ function SpriteParticles(
216
+ props: Omit<ParticleInstancesProps, "geometry" | "material"> & { url: string },
217
+ ): React.ReactElement | null {
218
+ const texture = useTexture(props.url);
219
+ const material = useMemo(() => {
220
+ // drei caches textures by url; the colour space stamp is idempotent, and
221
+ // the MATERIAL is ours to make (and dispose) per mount.
222
+ texture.colorSpace = THREE.SRGBColorSpace;
223
+ return new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false });
224
+ }, [texture]);
225
+ useLayoutEffect(() => () => material.dispose(), [material]);
226
+ const { url: _url, ...rest } = props;
227
+ return <ParticleInstances {...rest} geometry={SPRITE_GEOMETRY} material={material} />;
228
+ }
229
+
175
230
  /**
176
231
  * The contracted emitter. Props mirror ./controls.ts one-to-one; DEFAULTS
177
232
  * keeps "no props at all" identical to the contract's defaults.
@@ -190,6 +245,11 @@ export function ParticleEmitter(props: ParticleEmitterProps): React.ReactElement
190
245
  scaleOverLife = DEFAULT_SCALE_OVER_LIFE,
191
246
  colorOverLife = DEFAULT_COLOR_OVER_LIFE,
192
247
  glbSrc = null,
248
+ emitterShape = DEFAULTS.emitterShape,
249
+ emitterExtent = DEFAULTS.emitterExtent,
250
+ emitMode = DEFAULTS.emitMode,
251
+ burstSeconds = DEFAULTS.burstSeconds,
252
+ spriteSrc = null,
193
253
  } = props;
194
254
 
195
255
  const clock = useStudioClock();
@@ -205,11 +265,30 @@ export function ParticleEmitter(props: ParticleEmitterProps): React.ReactElement
205
265
  gravity,
206
266
  spinMaxDegPerSec: spin,
207
267
  seed,
268
+ emitterShape,
269
+ emitterExtent,
270
+ emitMode,
271
+ burstSeconds,
208
272
  }),
209
- [emissionRate, particleLifetime, spread, speed, speedJitter, gravity, spin, seed],
273
+ [
274
+ emissionRate,
275
+ particleLifetime,
276
+ spread,
277
+ speed,
278
+ speedJitter,
279
+ gravity,
280
+ spin,
281
+ seed,
282
+ emitterShape,
283
+ emitterExtent,
284
+ emitMode,
285
+ burstSeconds,
286
+ ],
210
287
  );
211
288
 
212
289
  const shared = { config, size, scaleOverLife, colorOverLife, timeSeconds };
290
+ // Precedence: the GLB wins over the sprite — the pre-growth rule, so no
291
+ // shipped document's look moves — and the tetrahedron is the fallback.
213
292
  if (glbSrc) {
214
293
  return (
215
294
  <Suspense fallback={null}>
@@ -217,6 +296,13 @@ export function ParticleEmitter(props: ParticleEmitterProps): React.ReactElement
217
296
  </Suspense>
218
297
  );
219
298
  }
299
+ if (spriteSrc) {
300
+ return (
301
+ <Suspense fallback={null}>
302
+ <SpriteParticles url={spriteSrc} {...shared} />
303
+ </Suspense>
304
+ );
305
+ }
220
306
  return (
221
307
  <ParticleInstances {...shared} geometry={DEFAULT_GEOMETRY} material={DEFAULT_MATERIAL} />
222
308
  );