@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 +4 -4
- package/src/controls.ts +38 -0
- package/src/emission.ts +83 -12
- package/src/emitter.tsx +93 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dailies-studio/particles",
|
|
3
|
-
"version": "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.
|
|
33
|
-
"@dailies-studio/engine": "0.
|
|
34
|
-
"@dailies-studio/runtime": "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
);
|