@dailies-studio/particles 0.1.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 +45 -0
- package/src/balloons.ts +132 -0
- package/src/catalog.ts +28 -0
- package/src/controls.ts +147 -0
- package/src/emission.ts +149 -0
- package/src/emitter.tsx +223 -0
- package/src/foilBalloons.tsx +228 -0
- package/src/index.ts +30 -0
- package/src/pcg32.ts +43 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dailies-studio/particles",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"./controls": {
|
|
12
|
+
"types": "./src/controls.ts",
|
|
13
|
+
"default": "./src/controls.ts"
|
|
14
|
+
},
|
|
15
|
+
"./catalog": {
|
|
16
|
+
"types": "./src/catalog.ts",
|
|
17
|
+
"default": "./src/catalog.ts"
|
|
18
|
+
},
|
|
19
|
+
"./pcg32": {
|
|
20
|
+
"types": "./src/pcg32.ts",
|
|
21
|
+
"default": "./src/pcg32.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@react-three/drei": "^10.7.7",
|
|
30
|
+
"@react-three/fiber": "^9.6.1",
|
|
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"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=18.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@react-three/test-renderer": "^9.1.0",
|
|
41
|
+
"@types/react": "^19.0.0",
|
|
42
|
+
"@types/three": "^0.185.1",
|
|
43
|
+
"react": "^19.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/balloons.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The foil balloon cluster's layout model — the same discipline as
|
|
2
|
+
// emission.ts: every balloon attribute is a pure function of (seed, index)
|
|
3
|
+
// from its own PCG32 stream, and every pose is a closed-form function of
|
|
4
|
+
// (attributes, config, rise, time). No simulation state anywhere; scrubbing
|
|
5
|
+
// backwards, re-mounting and out-of-order export rendering see identical
|
|
6
|
+
// balloons. React/three-free so the numbers are directly testable.
|
|
7
|
+
import { pcg32 } from "./pcg32.js";
|
|
8
|
+
|
|
9
|
+
export interface BalloonClusterConfig {
|
|
10
|
+
count: number;
|
|
11
|
+
seed: number;
|
|
12
|
+
/** Cluster anchor, fractions of the frame (x of width, y of height, 0/0 top-left). */
|
|
13
|
+
centerX: number;
|
|
14
|
+
centerY: number;
|
|
15
|
+
/** Cluster half-extent, fractions of the frame. */
|
|
16
|
+
spreadX: number;
|
|
17
|
+
spreadY: number;
|
|
18
|
+
/** Mean balloon height as a fraction of the frame height. */
|
|
19
|
+
size: number;
|
|
20
|
+
/** Ambient bob/tilt amount, 0..1. */
|
|
21
|
+
sway: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BalloonAttributes {
|
|
25
|
+
/** Even slot across the cluster, -1..1. The hero (middle) balloon sits on 0. */
|
|
26
|
+
slot: number;
|
|
27
|
+
jitterX: number;
|
|
28
|
+
jitterY: number;
|
|
29
|
+
sizeScale: number;
|
|
30
|
+
/** World-space depth offset; the hero floats nearest the camera. */
|
|
31
|
+
zOffset: number;
|
|
32
|
+
/** Resting tilts, radians. */
|
|
33
|
+
tiltX: number;
|
|
34
|
+
tiltZ: number;
|
|
35
|
+
/** Yaw sway amplitude, radians. */
|
|
36
|
+
yawAmp: number;
|
|
37
|
+
/** Phase offset for every oscillator, 0..2π. */
|
|
38
|
+
phase: number;
|
|
39
|
+
/** Oscillator rate, rad/s over studio time. */
|
|
40
|
+
speed: number;
|
|
41
|
+
/** Extra entrance drop, 0..1. */
|
|
42
|
+
dropExtra: number;
|
|
43
|
+
/** Entrance stagger position, 0..1 (0 enters first). */
|
|
44
|
+
delayNorm: number;
|
|
45
|
+
/** Which foil color the balloon wears. */
|
|
46
|
+
colorIndex: 0 | 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Attributes for balloon `index` — one independent PCG32 stream per balloon,
|
|
50
|
+
* keyed (seed, index). The DRAW ORDER below is part of the cluster's
|
|
51
|
+
* identity: reordering the draws re-rolls every shipped template. */
|
|
52
|
+
export function balloonAttributes(seed: number, index: number, count: number): BalloonAttributes {
|
|
53
|
+
const rng = pcg32(Math.max(0, Math.floor(seed)), index);
|
|
54
|
+
const jx = rng.next01();
|
|
55
|
+
const jy = rng.next01();
|
|
56
|
+
const jSize = rng.next01();
|
|
57
|
+
const jz = rng.next01();
|
|
58
|
+
const jTiltX = rng.next01();
|
|
59
|
+
const jTiltZ = rng.next01();
|
|
60
|
+
const jYaw = rng.next01();
|
|
61
|
+
const jPhase = rng.next01();
|
|
62
|
+
const jSpeed = rng.next01();
|
|
63
|
+
const jDrop = rng.next01();
|
|
64
|
+
const jDelay = rng.next01();
|
|
65
|
+
const jColor = rng.next01();
|
|
66
|
+
|
|
67
|
+
// The middle slot is the hero: biggest, centred, nearest the camera, first
|
|
68
|
+
// through the entrance — the reference framing every foil-balloon promo uses.
|
|
69
|
+
const hero = index === Math.floor((count - 1) / 2);
|
|
70
|
+
return {
|
|
71
|
+
slot: count <= 1 ? 0 : (index / (count - 1)) * 2 - 1,
|
|
72
|
+
jitterX: (jx - 0.5) * 0.5,
|
|
73
|
+
jitterY: (jy - 0.5) * 2,
|
|
74
|
+
sizeScale: hero ? 1.6 : 0.7 + jSize * 0.55,
|
|
75
|
+
zOffset: hero ? 0.25 : (jz - 0.5) * 0.5,
|
|
76
|
+
tiltX: (jTiltX - 0.5) * 0.24,
|
|
77
|
+
tiltZ: (jTiltZ - 0.5) * (hero ? 0.22 : 0.5),
|
|
78
|
+
yawAmp: 0.2 + jYaw * 0.35,
|
|
79
|
+
phase: jPhase * Math.PI * 2,
|
|
80
|
+
speed: 0.5 + jSpeed * 0.7,
|
|
81
|
+
dropExtra: jDrop,
|
|
82
|
+
delayNorm: hero ? 0 : 0.2 + jDelay * 0.8,
|
|
83
|
+
colorIndex: hero ? 0 : jColor < 0.4 ? 1 : 0,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface BalloonPose {
|
|
88
|
+
/** Frame fractions (x of width, y of height, 0/0 top-left). */
|
|
89
|
+
x: number;
|
|
90
|
+
y: number;
|
|
91
|
+
/** Balloon height as a fraction of the frame height. */
|
|
92
|
+
scale: number;
|
|
93
|
+
/** Radians. */
|
|
94
|
+
rotX: number;
|
|
95
|
+
rotY: number;
|
|
96
|
+
rotZ: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
|
100
|
+
|
|
101
|
+
/** The closed-form pose at (rise, time). `rise` drives the staggered entrance
|
|
102
|
+
* (each balloon starts after its `delayNorm` share and eases out); `time` is
|
|
103
|
+
* studio seconds and drives the ambient bob/sway oscillators. */
|
|
104
|
+
export function balloonPose(
|
|
105
|
+
a: BalloonAttributes,
|
|
106
|
+
cfg: BalloonClusterConfig,
|
|
107
|
+
rise: number,
|
|
108
|
+
timeSeconds: number,
|
|
109
|
+
): BalloonPose {
|
|
110
|
+
const delay = 0.45 * a.delayNorm;
|
|
111
|
+
const local = clamp01((clamp01(rise) - delay) / (1 - delay));
|
|
112
|
+
const eased = 1 - (1 - local) ** 3;
|
|
113
|
+
|
|
114
|
+
const settledX = cfg.centerX + (a.slot * 0.85 + a.jitterX) * cfg.spreadX;
|
|
115
|
+
const settledY = cfg.centerY + a.jitterY * cfg.spreadY;
|
|
116
|
+
const drop = 1.05 + a.dropExtra * 0.4;
|
|
117
|
+
|
|
118
|
+
const t = timeSeconds;
|
|
119
|
+
const bobY = Math.sin(t * a.speed + a.phase) * 0.014 * cfg.sway * (0.6 + 0.4 * a.dropExtra);
|
|
120
|
+
const driftX = Math.cos(t * a.speed * 0.7 + a.phase * 1.3) * 0.008 * cfg.sway;
|
|
121
|
+
// A pendulum swing that dies out as the balloon settles.
|
|
122
|
+
const swing = (1 - eased) * 0.3 * Math.sin(a.phase + eased * 6);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
x: settledX + driftX,
|
|
126
|
+
y: settledY + (1 - eased) * drop + bobY,
|
|
127
|
+
scale: cfg.size * a.sizeScale,
|
|
128
|
+
rotX: a.tiltX,
|
|
129
|
+
rotY: Math.sin(t * a.speed * 0.5 + a.phase) * a.yawAmp * cfg.sway,
|
|
130
|
+
rotZ: a.tiltZ * eased + swing + Math.sin(t * a.speed * 0.8 + a.phase * 1.7) * 0.07 * cfg.sway,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The metadata entry ("@dailies-studio/particles/catalog"): everything the CLI
|
|
2
|
+
// server needs to register this package's components as builtins. Deliberately
|
|
3
|
+
// react/three-free (no component imports) — the server loads this in plain
|
|
4
|
+
// node, exactly as it loads the primitives and effects catalogs.
|
|
5
|
+
import type { Contract } from "@dailies-studio/contract";
|
|
6
|
+
import { controls as particleEmitterControls, foilBalloonsControls } from "./controls.js";
|
|
7
|
+
|
|
8
|
+
export const PARTICLES_MODULE = "@dailies-studio/particles" as const;
|
|
9
|
+
|
|
10
|
+
export interface ParticleCatalogEntry {
|
|
11
|
+
/** Stable builtin id: `<module>#<exportName>`, the shared convention. */
|
|
12
|
+
id: string;
|
|
13
|
+
module: typeof PARTICLES_MODULE;
|
|
14
|
+
exportName: string;
|
|
15
|
+
/** Display name in the components palette. */
|
|
16
|
+
name: string;
|
|
17
|
+
contract: Contract;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function entry(exportName: string, name: string, contract: Contract): ParticleCatalogEntry {
|
|
21
|
+
return { id: `${PARTICLES_MODULE}#${exportName}`, module: PARTICLES_MODULE, exportName, name, contract };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every entry is a `kind:"three"` layer on the shared stage. */
|
|
25
|
+
export const PARTICLES_CATALOG: readonly ParticleCatalogEntry[] = [
|
|
26
|
+
entry("ParticleEmitter", "Particle Emitter", particleEmitterControls),
|
|
27
|
+
entry("FoilBalloons", "Foil Balloons", foilBalloonsControls),
|
|
28
|
+
];
|
package/src/controls.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The emitter's contract — authored with the SAME DSL users get, because this
|
|
2
|
+
// package exists to dogfood the contract system: number ranges, a hold-only
|
|
3
|
+
// curve, a gradient, and a GLB asset all on one component. DEFAULTS is shared
|
|
4
|
+
// with the component's prop destructuring so "no props" and "contract
|
|
5
|
+
// defaults" are the same animation.
|
|
6
|
+
import { c, defineControls } from "@dailies-studio/contract";
|
|
7
|
+
import type { Contract, CurveValue, GradientValue } from "@dailies-studio/contract";
|
|
8
|
+
import { LINEAR_HANDLES } from "@dailies-studio/engine";
|
|
9
|
+
|
|
10
|
+
const H = (): [number, number, number, number] => [...LINEAR_HANDLES];
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_SCALE_OVER_LIFE: CurveValue = {
|
|
13
|
+
kind: "curve",
|
|
14
|
+
points: [
|
|
15
|
+
{ x: 0, y: 0, handles: H() },
|
|
16
|
+
{ x: 0.15, y: 1, handles: H() },
|
|
17
|
+
{ x: 1, y: 0, handles: H() },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_COLOR_OVER_LIFE: GradientValue = {
|
|
22
|
+
kind: "gradient",
|
|
23
|
+
stops: [
|
|
24
|
+
{ at: 0, color: "#ffd166" },
|
|
25
|
+
{ at: 1, color: "#ef476f" },
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const DEFAULTS = {
|
|
30
|
+
emissionRate: 24,
|
|
31
|
+
particleLifetime: 1.6,
|
|
32
|
+
spread: 28,
|
|
33
|
+
speed: 2.2,
|
|
34
|
+
speedJitter: 0.3,
|
|
35
|
+
gravity: 1.6,
|
|
36
|
+
spin: 120,
|
|
37
|
+
seed: 1,
|
|
38
|
+
size: 0.12,
|
|
39
|
+
scaleOverLife: DEFAULT_SCALE_OVER_LIFE,
|
|
40
|
+
colorOverLife: DEFAULT_COLOR_OVER_LIFE,
|
|
41
|
+
glbSrc: null as string | null,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export const FOIL_BALLOONS_DEFAULTS = {
|
|
45
|
+
count: 5,
|
|
46
|
+
seed: 7,
|
|
47
|
+
rise: 1,
|
|
48
|
+
centerX: 0.5,
|
|
49
|
+
centerY: 0.3,
|
|
50
|
+
spreadX: 0.36,
|
|
51
|
+
spreadY: 0.12,
|
|
52
|
+
size: 0.12,
|
|
53
|
+
sway: 0.5,
|
|
54
|
+
colorA: "#e2b13c",
|
|
55
|
+
colorB: "#9ccc3f",
|
|
56
|
+
shine: 0.65,
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
/** The foil balloon cluster (foilBalloons.tsx). Positions and sizes are
|
|
60
|
+
* FRACTIONS of the frame (x of width, y of height, 0/0 = top-left) rather
|
|
61
|
+
* than px: a three layer always fills the composition viewport, so fractions
|
|
62
|
+
* are the coordinate space that survives any comp size. */
|
|
63
|
+
export const foilBalloonsControls: Contract = defineControls({
|
|
64
|
+
count: c.number({ default: FOIL_BALLOONS_DEFAULTS.count, min: 1, max: 12, step: 1, label: "Count" }),
|
|
65
|
+
// Randomness identity, exactly like the emitter's: animating the seed would
|
|
66
|
+
// re-roll the cluster every frame.
|
|
67
|
+
seed: c.number({
|
|
68
|
+
default: FOIL_BALLOONS_DEFAULTS.seed,
|
|
69
|
+
min: 0,
|
|
70
|
+
max: 99999,
|
|
71
|
+
step: 1,
|
|
72
|
+
animatable: false,
|
|
73
|
+
label: "Seed",
|
|
74
|
+
}),
|
|
75
|
+
// The entrance: 0 = every balloon below the frame, 1 = cluster settled.
|
|
76
|
+
// Keyframe this (a spring reads beautifully); each balloon staggers and
|
|
77
|
+
// eases inside the component as a closed-form function of this one value.
|
|
78
|
+
rise: c.number({ default: FOIL_BALLOONS_DEFAULTS.rise, min: 0, max: 1, step: 0.01, label: "Rise" }),
|
|
79
|
+
centerX: c.number({
|
|
80
|
+
default: FOIL_BALLOONS_DEFAULTS.centerX,
|
|
81
|
+
min: 0,
|
|
82
|
+
max: 1,
|
|
83
|
+
step: 0.001,
|
|
84
|
+
label: "Center X",
|
|
85
|
+
}),
|
|
86
|
+
centerY: c.number({
|
|
87
|
+
default: FOIL_BALLOONS_DEFAULTS.centerY,
|
|
88
|
+
min: 0,
|
|
89
|
+
max: 1,
|
|
90
|
+
step: 0.001,
|
|
91
|
+
label: "Center Y",
|
|
92
|
+
}),
|
|
93
|
+
spreadX: c.number({
|
|
94
|
+
default: FOIL_BALLOONS_DEFAULTS.spreadX,
|
|
95
|
+
min: 0,
|
|
96
|
+
max: 0.6,
|
|
97
|
+
step: 0.001,
|
|
98
|
+
label: "Spread X",
|
|
99
|
+
}),
|
|
100
|
+
spreadY: c.number({
|
|
101
|
+
default: FOIL_BALLOONS_DEFAULTS.spreadY,
|
|
102
|
+
min: 0,
|
|
103
|
+
max: 0.6,
|
|
104
|
+
step: 0.001,
|
|
105
|
+
label: "Spread Y",
|
|
106
|
+
}),
|
|
107
|
+
size: c.number({
|
|
108
|
+
default: FOIL_BALLOONS_DEFAULTS.size,
|
|
109
|
+
min: 0.02,
|
|
110
|
+
max: 0.4,
|
|
111
|
+
step: 0.001,
|
|
112
|
+
label: "Balloon size",
|
|
113
|
+
}),
|
|
114
|
+
sway: c.number({ default: FOIL_BALLOONS_DEFAULTS.sway, min: 0, max: 1, step: 0.01, label: "Sway" }),
|
|
115
|
+
colorA: c.color({ default: FOIL_BALLOONS_DEFAULTS.colorA, label: "Foil color A" }),
|
|
116
|
+
colorB: c.color({ default: FOIL_BALLOONS_DEFAULTS.colorB, label: "Foil color B" }),
|
|
117
|
+
shine: c.number({ default: FOIL_BALLOONS_DEFAULTS.shine, min: 0, max: 1, step: 0.01, label: "Shine" }),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const controls: Contract = defineControls({
|
|
121
|
+
emissionRate: c.number({
|
|
122
|
+
default: DEFAULTS.emissionRate,
|
|
123
|
+
min: 0,
|
|
124
|
+
max: 200,
|
|
125
|
+
unit: "1/s",
|
|
126
|
+
label: "Emission rate",
|
|
127
|
+
}),
|
|
128
|
+
particleLifetime: c.number({
|
|
129
|
+
default: DEFAULTS.particleLifetime,
|
|
130
|
+
min: 0.05,
|
|
131
|
+
max: 20,
|
|
132
|
+
unit: "s",
|
|
133
|
+
label: "Particle lifetime",
|
|
134
|
+
}),
|
|
135
|
+
spread: c.number({ default: DEFAULTS.spread, min: 0, max: 180, unit: "deg" }),
|
|
136
|
+
speed: c.number({ default: DEFAULTS.speed, min: 0, max: 50 }),
|
|
137
|
+
speedJitter: c.number({ default: DEFAULTS.speedJitter, min: 0, max: 1, label: "Speed jitter" }),
|
|
138
|
+
gravity: c.number({ default: DEFAULTS.gravity, min: -20, max: 20 }),
|
|
139
|
+
spin: c.number({ default: DEFAULTS.spin, min: 0, max: 720, unit: "deg/s" }),
|
|
140
|
+
size: c.number({ default: DEFAULTS.size, min: 0.001, max: 10 }),
|
|
141
|
+
// Randomness identity: animating the seed would re-roll every particle each
|
|
142
|
+
// frame — it is deliberately not animatable.
|
|
143
|
+
seed: c.number({ default: DEFAULTS.seed, min: 0, max: 99999, step: 1, animatable: false }),
|
|
144
|
+
scaleOverLife: c.curve({ default: DEFAULT_SCALE_OVER_LIFE, label: "Scale over life" }),
|
|
145
|
+
colorOverLife: c.gradient({ default: DEFAULT_COLOR_OVER_LIFE, label: "Color over life" }),
|
|
146
|
+
glbSrc: c.asset({ assetKind: "glb", default: null, label: "GLB model" }),
|
|
147
|
+
});
|
package/src/emission.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The emission model: every particle attribute is a CLOSED-FORM function of
|
|
2
|
+
// (config, particleIndex, timeSeconds). No per-tick integration, no mutable
|
|
3
|
+
// simulation state — position at time t is p0 + v0·age + ½g·age², so sampling
|
|
4
|
+
// frame 300 before frame 10 (parallel export, backwards scrub) is exact by
|
|
5
|
+
// construction (plan §4.4).
|
|
6
|
+
//
|
|
7
|
+
// Randomness discipline: particle i draws from its OWN pcg32(seed, i) stream
|
|
8
|
+
// in a FIXED order (documented on ParticleAttributes). Changing the order or
|
|
9
|
+
// count of draws changes every animation ever exported — treat it like a wire
|
|
10
|
+
// format. New randomized attributes must draw AFTER the existing six.
|
|
11
|
+
import { pcg32 } from "./pcg32.js";
|
|
12
|
+
|
|
13
|
+
export interface EmissionConfig {
|
|
14
|
+
/** Particles born per second (> 0 to emit). */
|
|
15
|
+
emissionRate: number;
|
|
16
|
+
/** Seconds each particle lives (> 0). */
|
|
17
|
+
particleLifetime: number;
|
|
18
|
+
/** Cone half-angle around +Y, degrees, 0..180 (180 = full sphere). */
|
|
19
|
+
spreadDeg: number;
|
|
20
|
+
/** Initial speed, world units/sec. */
|
|
21
|
+
speed: number;
|
|
22
|
+
/** Fractional speed jitter in [0,1]: actual = speed · (1 ± jitter). */
|
|
23
|
+
speedJitter: number;
|
|
24
|
+
/** Downward acceleration, units/sec² (negative floats particles upward). */
|
|
25
|
+
gravity: number;
|
|
26
|
+
/** Max spin rate, deg/sec; each particle draws in [-max, +max]. */
|
|
27
|
+
spinMaxDegPerSec: number;
|
|
28
|
+
/** The contract seed: same seed = same animation, everywhere, forever. */
|
|
29
|
+
seed: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Vec3Tuple = readonly [number, number, number];
|
|
33
|
+
|
|
34
|
+
/** The per-particle random draw, in draw order. Pure in (seed, index). */
|
|
35
|
+
export interface ParticleAttributes {
|
|
36
|
+
/** Birth time in seconds: index / emissionRate. */
|
|
37
|
+
birth: number;
|
|
38
|
+
/** Unit launch direction (uniform on the spread cone around +Y). */
|
|
39
|
+
direction: Vec3Tuple;
|
|
40
|
+
/** Launch speed after jitter, units/sec. */
|
|
41
|
+
speed: number;
|
|
42
|
+
/** Unit spin axis (uniform on the sphere). */
|
|
43
|
+
spinAxis: Vec3Tuple;
|
|
44
|
+
/** Signed spin rate, radians/sec. */
|
|
45
|
+
spinRate: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEG = Math.PI / 180;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Particle i's immutable attributes. Draw order (the wire format):
|
|
52
|
+
* 1. cone cap height 2. cone azimuth 3. speed jitter
|
|
53
|
+
* 4. spin-axis z 5. spin-axis azimuth 6. spin rate
|
|
54
|
+
*/
|
|
55
|
+
export function particleAttributes(cfg: EmissionConfig, index: number): ParticleAttributes {
|
|
56
|
+
// PCG32 seeding is 64-bit integer math; a fractional or non-finite seed
|
|
57
|
+
// (hand-edited document, direct props) must degrade deterministically,
|
|
58
|
+
// never throw inside a render effect.
|
|
59
|
+
const seed = Number.isFinite(cfg.seed) ? Math.trunc(cfg.seed) : 0;
|
|
60
|
+
const rng = pcg32(seed, index);
|
|
61
|
+
|
|
62
|
+
// Uniform on the spherical cap: cosθ uniform in [cos(spread), 1].
|
|
63
|
+
const cosSpread = Math.cos(Math.min(180, Math.max(0, cfg.spreadDeg)) * DEG);
|
|
64
|
+
const cosTheta = 1 - rng.next01() * (1 - cosSpread);
|
|
65
|
+
const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta));
|
|
66
|
+
const phi = rng.next01() * 2 * Math.PI;
|
|
67
|
+
const direction: Vec3Tuple = [
|
|
68
|
+
sinTheta * Math.cos(phi),
|
|
69
|
+
cosTheta,
|
|
70
|
+
sinTheta * Math.sin(phi),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const jitter = Math.min(1, Math.max(0, cfg.speedJitter));
|
|
74
|
+
const speed = cfg.speed * (1 + (rng.next01() * 2 - 1) * jitter);
|
|
75
|
+
|
|
76
|
+
// Uniform on the sphere for the spin axis.
|
|
77
|
+
const az = rng.next01() * 2 - 1;
|
|
78
|
+
const aphi = rng.next01() * 2 * Math.PI;
|
|
79
|
+
const ar = Math.sqrt(Math.max(0, 1 - az * az));
|
|
80
|
+
const spinAxis: Vec3Tuple = [ar * Math.cos(aphi), az, ar * Math.sin(aphi)];
|
|
81
|
+
|
|
82
|
+
const spinRate = (rng.next01() * 2 - 1) * cfg.spinMaxDegPerSec * DEG;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
birth: index / cfg.emissionRate,
|
|
86
|
+
direction,
|
|
87
|
+
speed,
|
|
88
|
+
spinAxis,
|
|
89
|
+
spinRate,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* CANDIDATE indices for particles alive at time t — a guaranteed superset of
|
|
95
|
+
* every index whose {@link particleState} gate (birth ≤ t < birth+lifetime)
|
|
96
|
+
* passes; callers filter through the gate. The bounds are widened by one
|
|
97
|
+
* index on each side because they are computed by MULTIPLICATION
|
|
98
|
+
* (floor(t·rate)) while the gate divides (t − i/rate): the two round
|
|
99
|
+
* differently, and at frame-reachable times (e.g. rate = fps) the tight
|
|
100
|
+
* bound excludes a gate-live particle for exactly one frame — a visible pop.
|
|
101
|
+
* A dead candidate costs one gate check; a dropped live particle costs a
|
|
102
|
+
* determinism bug.
|
|
103
|
+
*/
|
|
104
|
+
export function activeIndexRange(cfg: EmissionConfig, t: number): [number, number] {
|
|
105
|
+
if (!(cfg.emissionRate > 0) || !(cfg.particleLifetime > 0) || !(t >= 0)) return [0, 0];
|
|
106
|
+
// birth = i/rate ≤ t → i ≤ t·rate ; t < birth + L → i > (t - L)·rate
|
|
107
|
+
const end = Math.floor(t * cfg.emissionRate) + 2;
|
|
108
|
+
const start = Math.max(0, Math.floor((t - cfg.particleLifetime) * cfg.emissionRate) - 1);
|
|
109
|
+
return start < end ? [start, end] : [0, 0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Capacity an instance buffer needs to hold every concurrently-live particle. */
|
|
113
|
+
export function particleCapacity(cfg: EmissionConfig, cap = 4096): number {
|
|
114
|
+
if (!(cfg.emissionRate > 0) || !(cfg.particleLifetime > 0)) return 0;
|
|
115
|
+
return Math.min(cap, Math.ceil(cfg.emissionRate * cfg.particleLifetime) + 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ParticleState {
|
|
119
|
+
position: Vec3Tuple;
|
|
120
|
+
/** Spin angle in radians around the particle's spinAxis. */
|
|
121
|
+
spinAngle: number;
|
|
122
|
+
/** Age fraction of lifetime in [0, 1). */
|
|
123
|
+
life01: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Closed-form state of one particle at absolute time t, or null when not alive.
|
|
128
|
+
* p = v0·age + ½·(0,-g,0)·age² — the emitter's local origin is the wrapper's
|
|
129
|
+
* transform, so position is relative to it.
|
|
130
|
+
*/
|
|
131
|
+
export function particleState(
|
|
132
|
+
attrs: ParticleAttributes,
|
|
133
|
+
cfg: EmissionConfig,
|
|
134
|
+
t: number,
|
|
135
|
+
): ParticleState | null {
|
|
136
|
+
const age = t - attrs.birth;
|
|
137
|
+
if (!(age >= 0) || age >= cfg.particleLifetime) return null;
|
|
138
|
+
const s = attrs.speed;
|
|
139
|
+
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
|
+
};
|
|
149
|
+
}
|
package/src/emitter.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// The GLB particle emitter — a contracted react-three-fiber component.
|
|
2
|
+
// Rendering is an InstancedMesh whose per-instance matrices/colors are
|
|
3
|
+
// recomputed from the CLOSED-FORM model on every studio-clock change; there is
|
|
4
|
+
// no simulation state anywhere in this file. Time comes exclusively from
|
|
5
|
+
// useStudioClock() (the harness/player provide it) — never from r3f's
|
|
6
|
+
// wall-time clock, which would break scrubbing and out-of-order export.
|
|
7
|
+
import React, { Suspense, useLayoutEffect, useMemo } from "react";
|
|
8
|
+
import * as THREE from "three";
|
|
9
|
+
import { useThree } from "@react-three/fiber";
|
|
10
|
+
import { useGLTF } from "@react-three/drei";
|
|
11
|
+
import { compileCurve, compileGradient } from "@dailies-studio/contract";
|
|
12
|
+
import type { CurveValue, GradientValue } from "@dailies-studio/contract";
|
|
13
|
+
import { useStudioClock } from "@dailies-studio/runtime";
|
|
14
|
+
import {
|
|
15
|
+
activeIndexRange,
|
|
16
|
+
particleAttributes,
|
|
17
|
+
particleCapacity,
|
|
18
|
+
particleState,
|
|
19
|
+
} from "./emission.js";
|
|
20
|
+
import type { EmissionConfig, ParticleAttributes } from "./emission.js";
|
|
21
|
+
import { DEFAULTS, DEFAULT_COLOR_OVER_LIFE, DEFAULT_SCALE_OVER_LIFE } from "./controls.js";
|
|
22
|
+
|
|
23
|
+
export interface ParticleEmitterProps {
|
|
24
|
+
emissionRate?: number;
|
|
25
|
+
particleLifetime?: number;
|
|
26
|
+
spread?: number;
|
|
27
|
+
speed?: number;
|
|
28
|
+
speedJitter?: number;
|
|
29
|
+
gravity?: number;
|
|
30
|
+
spin?: number;
|
|
31
|
+
size?: number;
|
|
32
|
+
seed?: number;
|
|
33
|
+
scaleOverLife?: CurveValue;
|
|
34
|
+
colorOverLife?: GradientValue;
|
|
35
|
+
glbSrc?: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Loop temporaries — module scope so the per-frame update allocates nothing.
|
|
39
|
+
const M4 = new THREE.Matrix4();
|
|
40
|
+
const POS = new THREE.Vector3();
|
|
41
|
+
const SCL = new THREE.Vector3();
|
|
42
|
+
const AXIS = new THREE.Vector3();
|
|
43
|
+
const QUAT = new THREE.Quaternion();
|
|
44
|
+
const COLOR = new THREE.Color();
|
|
45
|
+
|
|
46
|
+
/** First mesh in a GLTF scene — the emitter's instanced geometry source. */
|
|
47
|
+
export function firstMeshOf(scene: THREE.Object3D): THREE.Mesh | null {
|
|
48
|
+
let found: THREE.Mesh | null = null;
|
|
49
|
+
scene.traverse((o) => {
|
|
50
|
+
if (!found && (o as THREE.Mesh).isMesh) found = o as THREE.Mesh;
|
|
51
|
+
});
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ParticleInstancesProps {
|
|
56
|
+
geometry: THREE.BufferGeometry;
|
|
57
|
+
material: THREE.Material;
|
|
58
|
+
config: EmissionConfig;
|
|
59
|
+
size: number;
|
|
60
|
+
scaleOverLife: CurveValue;
|
|
61
|
+
colorOverLife: GradientValue;
|
|
62
|
+
/** Studio time in seconds (already resolved by the caller). */
|
|
63
|
+
timeSeconds: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The deterministic core renderer, exported for tests and advanced use:
|
|
68
|
+
* geometry/material in, closed-form instanced particles out.
|
|
69
|
+
*/
|
|
70
|
+
export function ParticleInstances(props: ParticleInstancesProps): React.ReactElement | null {
|
|
71
|
+
const { geometry, material, config, size, timeSeconds } = props;
|
|
72
|
+
const invalidate = useThree((s) => s.invalidate);
|
|
73
|
+
|
|
74
|
+
const capacity = particleCapacity(config);
|
|
75
|
+
const mesh = useMemo(() => {
|
|
76
|
+
if (capacity === 0) return null;
|
|
77
|
+
const m = new THREE.InstancedMesh(geometry, material, capacity);
|
|
78
|
+
// Closed-form positions leave the source geometry's bounds immediately;
|
|
79
|
+
// per-instance culling would need bounds we never compute.
|
|
80
|
+
m.frustumCulled = false;
|
|
81
|
+
m.count = 0;
|
|
82
|
+
return m;
|
|
83
|
+
}, [geometry, material, capacity]);
|
|
84
|
+
useLayoutEffect(() => {
|
|
85
|
+
if (!mesh) return;
|
|
86
|
+
return () => mesh.dispose(); // instance buffers only; geometry/material are the caller's
|
|
87
|
+
}, [mesh]);
|
|
88
|
+
|
|
89
|
+
// Per-particle randoms are pure in (seed, index) — cache them for the fields
|
|
90
|
+
// that feed the draw. Any of these changing re-rolls the population.
|
|
91
|
+
const attrCache = useMemo(() => {
|
|
92
|
+
void [
|
|
93
|
+
config.seed,
|
|
94
|
+
config.emissionRate,
|
|
95
|
+
config.spreadDeg,
|
|
96
|
+
config.speed,
|
|
97
|
+
config.speedJitter,
|
|
98
|
+
config.spinMaxDegPerSec,
|
|
99
|
+
];
|
|
100
|
+
return new Map<number, ParticleAttributes>();
|
|
101
|
+
}, [
|
|
102
|
+
config.seed,
|
|
103
|
+
config.emissionRate,
|
|
104
|
+
config.spreadDeg,
|
|
105
|
+
config.speed,
|
|
106
|
+
config.speedJitter,
|
|
107
|
+
config.spinMaxDegPerSec,
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const scaleFn = useMemo(() => compileCurve(props.scaleOverLife), [props.scaleOverLife]);
|
|
111
|
+
const colorFn = useMemo(() => compileGradient(props.colorOverLife), [props.colorOverLife]);
|
|
112
|
+
|
|
113
|
+
useLayoutEffect(() => {
|
|
114
|
+
if (!mesh) return;
|
|
115
|
+
// Bound the memo: indices grow ~rate·t over a long timeline, and old
|
|
116
|
+
// entries are just recomputable pure values — dropping them can only
|
|
117
|
+
// cost a recompute on a backwards scrub, never change a pixel.
|
|
118
|
+
if (attrCache.size > capacity * 4 + 64) attrCache.clear();
|
|
119
|
+
const [start, end] = activeIndexRange(config, timeSeconds);
|
|
120
|
+
let n = 0;
|
|
121
|
+
for (let i = start; i < end && n < capacity; i++) {
|
|
122
|
+
let attrs = attrCache.get(i);
|
|
123
|
+
if (!attrs) {
|
|
124
|
+
attrs = particleAttributes(config, i);
|
|
125
|
+
attrCache.set(i, attrs);
|
|
126
|
+
}
|
|
127
|
+
const st = particleState(attrs, config, timeSeconds);
|
|
128
|
+
if (!st) continue;
|
|
129
|
+
const s = Math.max(0, size * scaleFn(st.life01));
|
|
130
|
+
AXIS.set(attrs.spinAxis[0], attrs.spinAxis[1], attrs.spinAxis[2]);
|
|
131
|
+
QUAT.setFromAxisAngle(AXIS, st.spinAngle);
|
|
132
|
+
POS.set(st.position[0], st.position[1], st.position[2]);
|
|
133
|
+
SCL.set(s, s, s);
|
|
134
|
+
M4.compose(POS, QUAT, SCL);
|
|
135
|
+
mesh.setMatrixAt(n, M4);
|
|
136
|
+
// 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);
|
|
139
|
+
n++;
|
|
140
|
+
}
|
|
141
|
+
mesh.count = n;
|
|
142
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
143
|
+
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
|
144
|
+
invalidate(); // no-op under the harness ('never' + explicit advance); real in demand mode
|
|
145
|
+
}, [mesh, config, timeSeconds, size, scaleFn, colorFn, attrCache, capacity, invalidate]);
|
|
146
|
+
|
|
147
|
+
return mesh ? <primitive object={mesh} /> : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function GlbParticles(
|
|
151
|
+
props: Omit<ParticleInstancesProps, "geometry" | "material"> & { url: string },
|
|
152
|
+
): React.ReactElement | null {
|
|
153
|
+
const gltf = useGLTF(props.url);
|
|
154
|
+
const mesh = useMemo(() => firstMeshOf(gltf.scene), [gltf.scene]);
|
|
155
|
+
if (!mesh) throw new Error(`GLB '${props.url}' contains no mesh to instance`);
|
|
156
|
+
// Clone: drei caches GLTFs by url; instanceColor tinting must never mutate
|
|
157
|
+
// another consumer's material.
|
|
158
|
+
const material = useMemo(() => {
|
|
159
|
+
const m = Array.isArray(mesh.material) ? mesh.material[0]! : mesh.material;
|
|
160
|
+
return m.clone();
|
|
161
|
+
}, [mesh]);
|
|
162
|
+
useLayoutEffect(() => {
|
|
163
|
+
// The clone is OURS — without disposal every crash-remount or glbSrc
|
|
164
|
+
// change leaks a compiled GPU program.
|
|
165
|
+
return () => material.dispose();
|
|
166
|
+
}, [material]);
|
|
167
|
+
const { url: _url, ...rest } = props;
|
|
168
|
+
return <ParticleInstances {...rest} geometry={mesh.geometry} material={material} />;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const DEFAULT_GEOMETRY = new THREE.TetrahedronGeometry(1);
|
|
172
|
+
// Unlit: the gradient IS the look, with or without user lights.
|
|
173
|
+
const DEFAULT_MATERIAL = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The contracted emitter. Props mirror ./controls.ts one-to-one; DEFAULTS
|
|
177
|
+
* keeps "no props at all" identical to the contract's defaults.
|
|
178
|
+
*/
|
|
179
|
+
export function ParticleEmitter(props: ParticleEmitterProps): React.ReactElement {
|
|
180
|
+
const {
|
|
181
|
+
emissionRate = DEFAULTS.emissionRate,
|
|
182
|
+
particleLifetime = DEFAULTS.particleLifetime,
|
|
183
|
+
spread = DEFAULTS.spread,
|
|
184
|
+
speed = DEFAULTS.speed,
|
|
185
|
+
speedJitter = DEFAULTS.speedJitter,
|
|
186
|
+
gravity = DEFAULTS.gravity,
|
|
187
|
+
spin = DEFAULTS.spin,
|
|
188
|
+
size = DEFAULTS.size,
|
|
189
|
+
seed = DEFAULTS.seed,
|
|
190
|
+
scaleOverLife = DEFAULT_SCALE_OVER_LIFE,
|
|
191
|
+
colorOverLife = DEFAULT_COLOR_OVER_LIFE,
|
|
192
|
+
glbSrc = null,
|
|
193
|
+
} = props;
|
|
194
|
+
|
|
195
|
+
const clock = useStudioClock();
|
|
196
|
+
const timeSeconds = clock?.timeSeconds ?? 0;
|
|
197
|
+
|
|
198
|
+
const config = useMemo<EmissionConfig>(
|
|
199
|
+
() => ({
|
|
200
|
+
emissionRate,
|
|
201
|
+
particleLifetime,
|
|
202
|
+
spreadDeg: spread,
|
|
203
|
+
speed,
|
|
204
|
+
speedJitter,
|
|
205
|
+
gravity,
|
|
206
|
+
spinMaxDegPerSec: spin,
|
|
207
|
+
seed,
|
|
208
|
+
}),
|
|
209
|
+
[emissionRate, particleLifetime, spread, speed, speedJitter, gravity, spin, seed],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const shared = { config, size, scaleOverLife, colorOverLife, timeSeconds };
|
|
213
|
+
if (glbSrc) {
|
|
214
|
+
return (
|
|
215
|
+
<Suspense fallback={null}>
|
|
216
|
+
<GlbParticles url={glbSrc} {...shared} />
|
|
217
|
+
</Suspense>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return (
|
|
221
|
+
<ParticleInstances {...shared} geometry={DEFAULT_GEOMETRY} material={DEFAULT_MATERIAL} />
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// A cluster of mylar "foil" envelope balloons — the floating brand-object
|
|
2
|
+
// look of fintech promo motion. A contracted kind:"three" citizen of the
|
|
3
|
+
// shared stage, built the same way the emitter and the effect adapter are:
|
|
4
|
+
//
|
|
5
|
+
// - Every pose is CLOSED-FORM (balloons.ts): a pure function of (seed,
|
|
6
|
+
// index, rise, studio time). No simulation, no accumulation — scrubbing
|
|
7
|
+
// backwards and out-of-order export rendering are exact.
|
|
8
|
+
// - Time comes exclusively from useStudioClock(); outside a stage the clock
|
|
9
|
+
// is null and the cluster renders its t=0 pose.
|
|
10
|
+
// - The foil shading is a TSL colorNode on MeshBasicNodeMaterial — UNLIT,
|
|
11
|
+
// like every effect: a fixed key-light lambert + specular streak + fresnel
|
|
12
|
+
// rim computed from the view-space normal. No scene lights, no environment
|
|
13
|
+
// map (the shared stage ships none), no tone mapping surprises; identical
|
|
14
|
+
// on the WebGPU and WebGL2 backends because TSL compiles to both.
|
|
15
|
+
// - The node graph is built once over uniforms; prop changes only write
|
|
16
|
+
// uniform.value — no pipeline recompiles while scrubbing.
|
|
17
|
+
//
|
|
18
|
+
// Geometry is procedural (an extruded rounded-rect pillow plus a triangular
|
|
19
|
+
// flap prism, the envelope read) and module-scope: pure functions of
|
|
20
|
+
// constants, shared by every instance, never disposed.
|
|
21
|
+
import React, { useLayoutEffect, useMemo } from "react";
|
|
22
|
+
import { useThree } from "@react-three/fiber";
|
|
23
|
+
import {
|
|
24
|
+
Color,
|
|
25
|
+
ExtrudeGeometry,
|
|
26
|
+
Group,
|
|
27
|
+
Mesh,
|
|
28
|
+
MeshBasicNodeMaterial,
|
|
29
|
+
Shape,
|
|
30
|
+
} from "three/webgpu";
|
|
31
|
+
import { normalView, uniform, vec3 } from "three/tsl";
|
|
32
|
+
import { useStudioClock } from "@dailies-studio/runtime";
|
|
33
|
+
import { balloonAttributes, balloonPose } from "./balloons.js";
|
|
34
|
+
import type { BalloonAttributes, BalloonClusterConfig } from "./balloons.js";
|
|
35
|
+
import { FOIL_BALLOONS_DEFAULTS } from "./controls.js";
|
|
36
|
+
|
|
37
|
+
export interface FoilBalloonsProps {
|
|
38
|
+
count?: number;
|
|
39
|
+
seed?: number;
|
|
40
|
+
rise?: number;
|
|
41
|
+
centerX?: number;
|
|
42
|
+
centerY?: number;
|
|
43
|
+
spreadX?: number;
|
|
44
|
+
spreadY?: number;
|
|
45
|
+
size?: number;
|
|
46
|
+
sway?: number;
|
|
47
|
+
colorA?: string;
|
|
48
|
+
colorB?: string;
|
|
49
|
+
shine?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function roundedRectShape(width: number, height: number, radius: number): Shape {
|
|
53
|
+
const s = new Shape();
|
|
54
|
+
const x = -width / 2;
|
|
55
|
+
const y = -height / 2;
|
|
56
|
+
s.moveTo(x + radius, y);
|
|
57
|
+
s.lineTo(x + width - radius, y);
|
|
58
|
+
s.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
59
|
+
s.lineTo(x + width, y + height - radius);
|
|
60
|
+
s.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
61
|
+
s.lineTo(x + radius, y + height);
|
|
62
|
+
s.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
63
|
+
s.lineTo(x, y + radius);
|
|
64
|
+
s.quadraticCurveTo(x, y, x + radius, y);
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The pillow body, normalized to height 1 and centred. The extrude bevel's
|
|
69
|
+
* faceted normals are the point — a mylar balloon is creased, not smooth. */
|
|
70
|
+
const BODY_GEOMETRY = (() => {
|
|
71
|
+
const g = new ExtrudeGeometry(roundedRectShape(1.34, 1.0, 0.3), {
|
|
72
|
+
depth: 0.22,
|
|
73
|
+
steps: 1,
|
|
74
|
+
bevelEnabled: true,
|
|
75
|
+
bevelThickness: 0.16,
|
|
76
|
+
bevelSize: 0.14,
|
|
77
|
+
bevelSegments: 5,
|
|
78
|
+
curveSegments: 12,
|
|
79
|
+
});
|
|
80
|
+
g.center();
|
|
81
|
+
g.computeBoundingBox();
|
|
82
|
+
const bb = g.boundingBox!;
|
|
83
|
+
const s = 1 / (bb.max.y - bb.min.y);
|
|
84
|
+
g.scale(s, s, s);
|
|
85
|
+
g.computeBoundingBox();
|
|
86
|
+
return g;
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
const BODY_FRONT_Z = BODY_GEOMETRY.boundingBox!.max.z;
|
|
90
|
+
|
|
91
|
+
/** The envelope flap: a shallow triangular prism sitting proud of the front
|
|
92
|
+
* face. Same material as the body — the ridge reads through shading alone. */
|
|
93
|
+
const FLAP_GEOMETRY = (() => {
|
|
94
|
+
const tri = new Shape();
|
|
95
|
+
tri.moveTo(-0.46, 0.3);
|
|
96
|
+
tri.lineTo(0.46, 0.3);
|
|
97
|
+
tri.lineTo(0, -0.14);
|
|
98
|
+
tri.closePath();
|
|
99
|
+
const g = new ExtrudeGeometry(tri, {
|
|
100
|
+
depth: 0.02,
|
|
101
|
+
steps: 1,
|
|
102
|
+
bevelEnabled: true,
|
|
103
|
+
bevelThickness: 0.025,
|
|
104
|
+
bevelSize: 0.025,
|
|
105
|
+
bevelSegments: 2,
|
|
106
|
+
curveSegments: 4,
|
|
107
|
+
});
|
|
108
|
+
return g;
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
/** One foil material: fake-foil shading over the view-space normal —
|
|
112
|
+
* key-light lambert, a bounce fill, a tight specular streak scaled by
|
|
113
|
+
* `shine`, and a fresnel rim. Built over uniforms so prop changes mutate
|
|
114
|
+
* `value` and never rebuild the node graph. */
|
|
115
|
+
function makeFoilMaterial(colorHex: string, shineDefault: number) {
|
|
116
|
+
const colorU = uniform(new Color(colorHex));
|
|
117
|
+
const shineU = uniform(shineDefault);
|
|
118
|
+
const n = normalView.normalize();
|
|
119
|
+
const key = n.dot(vec3(0.34, 0.53, 0.78).normalize()).clamp(0, 1);
|
|
120
|
+
const fill = n.dot(vec3(-0.55, -0.3, 0.78).normalize()).clamp(0, 1);
|
|
121
|
+
const base = colorU
|
|
122
|
+
.mul(key.mul(0.55).add(0.38))
|
|
123
|
+
.add(colorU.mul(fill.pow(2).mul(0.22)));
|
|
124
|
+
const spec = key.pow(26).mul(shineU);
|
|
125
|
+
const rim = n.z.clamp(0, 1).oneMinus().pow(3).mul(0.3);
|
|
126
|
+
const material = new MeshBasicNodeMaterial();
|
|
127
|
+
material.colorNode = base.add(vec3(1.0, 0.98, 0.9).mul(spec.add(rim.mul(0.6))));
|
|
128
|
+
return { material, colorU, shineU };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The contracted cluster. Props mirror controls.ts one-to-one;
|
|
133
|
+
* FOIL_BALLOONS_DEFAULTS keeps "no props at all" identical to the contract's
|
|
134
|
+
* defaults.
|
|
135
|
+
*/
|
|
136
|
+
export function FoilBalloons(props: FoilBalloonsProps): React.ReactElement {
|
|
137
|
+
const {
|
|
138
|
+
count = FOIL_BALLOONS_DEFAULTS.count,
|
|
139
|
+
seed = FOIL_BALLOONS_DEFAULTS.seed,
|
|
140
|
+
rise = FOIL_BALLOONS_DEFAULTS.rise,
|
|
141
|
+
centerX = FOIL_BALLOONS_DEFAULTS.centerX,
|
|
142
|
+
centerY = FOIL_BALLOONS_DEFAULTS.centerY,
|
|
143
|
+
spreadX = FOIL_BALLOONS_DEFAULTS.spreadX,
|
|
144
|
+
spreadY = FOIL_BALLOONS_DEFAULTS.spreadY,
|
|
145
|
+
size = FOIL_BALLOONS_DEFAULTS.size,
|
|
146
|
+
sway = FOIL_BALLOONS_DEFAULTS.sway,
|
|
147
|
+
colorA = FOIL_BALLOONS_DEFAULTS.colorA,
|
|
148
|
+
colorB = FOIL_BALLOONS_DEFAULTS.colorB,
|
|
149
|
+
shine = FOIL_BALLOONS_DEFAULTS.shine,
|
|
150
|
+
} = props;
|
|
151
|
+
|
|
152
|
+
const viewport = useThree((s) => s.viewport);
|
|
153
|
+
const invalidate = useThree((s) => s.invalidate);
|
|
154
|
+
const clock = useStudioClock();
|
|
155
|
+
const timeSeconds = clock?.timeSeconds ?? 0;
|
|
156
|
+
|
|
157
|
+
const countInt = Math.min(12, Math.max(1, Math.round(count)));
|
|
158
|
+
const seedInt = Math.max(0, Math.floor(seed));
|
|
159
|
+
|
|
160
|
+
// Materials + uniforms: built once per mount, mutated per commit.
|
|
161
|
+
const shading = useMemo(
|
|
162
|
+
() => ({
|
|
163
|
+
a: makeFoilMaterial(FOIL_BALLOONS_DEFAULTS.colorA, FOIL_BALLOONS_DEFAULTS.shine),
|
|
164
|
+
b: makeFoilMaterial(FOIL_BALLOONS_DEFAULTS.colorB, FOIL_BALLOONS_DEFAULTS.shine),
|
|
165
|
+
}),
|
|
166
|
+
[],
|
|
167
|
+
);
|
|
168
|
+
useLayoutEffect(() => {
|
|
169
|
+
const { a, b } = shading;
|
|
170
|
+
return () => {
|
|
171
|
+
a.material.dispose(); // ours — leaking one leaks a compiled pipeline
|
|
172
|
+
b.material.dispose();
|
|
173
|
+
};
|
|
174
|
+
}, [shading]);
|
|
175
|
+
|
|
176
|
+
// The population: a pure function of (seed, count). Geometry and materials
|
|
177
|
+
// are shared, so a re-roll allocates only meshes and attribute records.
|
|
178
|
+
const rig = useMemo(() => {
|
|
179
|
+
const group = new Group();
|
|
180
|
+
const items: { mesh: Mesh; attrs: BalloonAttributes }[] = [];
|
|
181
|
+
for (let i = 0; i < countInt; i++) {
|
|
182
|
+
const attrs = balloonAttributes(seedInt, i, countInt);
|
|
183
|
+
const material = attrs.colorIndex === 0 ? shading.a.material : shading.b.material;
|
|
184
|
+
const body = new Mesh(BODY_GEOMETRY, material);
|
|
185
|
+
const flap = new Mesh(FLAP_GEOMETRY, material);
|
|
186
|
+
flap.position.set(0, 0.1, BODY_FRONT_Z - 0.01);
|
|
187
|
+
body.add(flap);
|
|
188
|
+
group.add(body);
|
|
189
|
+
items.push({ mesh: body, attrs });
|
|
190
|
+
}
|
|
191
|
+
return { group, items };
|
|
192
|
+
}, [countInt, seedInt, shading]);
|
|
193
|
+
|
|
194
|
+
// Every commit: write uniforms and recompute every pose closed-form. A
|
|
195
|
+
// dozen scalar writes and trig calls — cheaper than memo bookkeeping.
|
|
196
|
+
useLayoutEffect(() => {
|
|
197
|
+
// THREE.Color has no alpha channel — an 8-digit pick keeps its rgb.
|
|
198
|
+
shading.a.colorU.value.set(colorA.slice(0, 7));
|
|
199
|
+
shading.b.colorU.value.set(colorB.slice(0, 7));
|
|
200
|
+
shading.a.shineU.value = shine;
|
|
201
|
+
shading.b.shineU.value = shine;
|
|
202
|
+
const cfg: BalloonClusterConfig = {
|
|
203
|
+
count: countInt,
|
|
204
|
+
seed: seedInt,
|
|
205
|
+
centerX,
|
|
206
|
+
centerY,
|
|
207
|
+
spreadX,
|
|
208
|
+
spreadY,
|
|
209
|
+
size,
|
|
210
|
+
sway,
|
|
211
|
+
};
|
|
212
|
+
for (const { mesh, attrs } of rig.items) {
|
|
213
|
+
const pose = balloonPose(attrs, cfg, rise, timeSeconds);
|
|
214
|
+
// Frame fractions → world units: the viewport is the world-space rect
|
|
215
|
+
// of this instance's View at z=0 (the full composition).
|
|
216
|
+
mesh.position.set(
|
|
217
|
+
(pose.x - 0.5) * viewport.width,
|
|
218
|
+
(0.5 - pose.y) * viewport.height,
|
|
219
|
+
attrs.zOffset,
|
|
220
|
+
);
|
|
221
|
+
mesh.scale.setScalar(pose.scale * viewport.height);
|
|
222
|
+
mesh.rotation.set(pose.rotX, pose.rotY, pose.rotZ);
|
|
223
|
+
}
|
|
224
|
+
invalidate(); // no-op under the harness (frameloop 'never'); real in demand mode
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return <primitive object={rig.group} />;
|
|
228
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { ParticleEmitter, ParticleInstances, firstMeshOf } from "./emitter.js";
|
|
2
|
+
export type { ParticleEmitterProps, ParticleInstancesProps } from "./emitter.js";
|
|
3
|
+
export { FoilBalloons } from "./foilBalloons.js";
|
|
4
|
+
export type { FoilBalloonsProps } from "./foilBalloons.js";
|
|
5
|
+
export {
|
|
6
|
+
controls,
|
|
7
|
+
DEFAULTS,
|
|
8
|
+
DEFAULT_COLOR_OVER_LIFE,
|
|
9
|
+
DEFAULT_SCALE_OVER_LIFE,
|
|
10
|
+
FOIL_BALLOONS_DEFAULTS,
|
|
11
|
+
foilBalloonsControls,
|
|
12
|
+
} from "./controls.js";
|
|
13
|
+
export { balloonAttributes, balloonPose } from "./balloons.js";
|
|
14
|
+
export type { BalloonAttributes, BalloonClusterConfig, BalloonPose } from "./balloons.js";
|
|
15
|
+
export { PARTICLES_CATALOG, PARTICLES_MODULE } from "./catalog.js";
|
|
16
|
+
export type { ParticleCatalogEntry } from "./catalog.js";
|
|
17
|
+
export {
|
|
18
|
+
activeIndexRange,
|
|
19
|
+
particleAttributes,
|
|
20
|
+
particleCapacity,
|
|
21
|
+
particleState,
|
|
22
|
+
} from "./emission.js";
|
|
23
|
+
export type {
|
|
24
|
+
EmissionConfig,
|
|
25
|
+
ParticleAttributes,
|
|
26
|
+
ParticleState,
|
|
27
|
+
Vec3Tuple,
|
|
28
|
+
} from "./emission.js";
|
|
29
|
+
export { pcg32 } from "./pcg32.js";
|
|
30
|
+
export type { Pcg32 } from "./pcg32.js";
|
package/src/pcg32.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// PCG32 (PCG-XSH-RR 64/32, O'Neill) — the emitter's ONLY randomness source.
|
|
2
|
+
// Every particle owns an independent PCG stream keyed by (seed, particleIndex),
|
|
3
|
+
// so particle i's attributes are a pure function of the contract's seed and its
|
|
4
|
+
// index: scrubbing backwards, re-mounting, and out-of-order export rendering
|
|
5
|
+
// all see identical particles (plan §4.4). BigInt keeps the 64-bit state
|
|
6
|
+
// arithmetic exact and readable; streams are created once per particle and
|
|
7
|
+
// memoized by the caller, so construction cost never sits on the frame path.
|
|
8
|
+
//
|
|
9
|
+
// Matches the reference pcg32 (pcg_basic.c): verified against the canonical
|
|
10
|
+
// demo vectors in the test suite.
|
|
11
|
+
|
|
12
|
+
const MULT = 6364136223846793005n;
|
|
13
|
+
const MASK64 = (1n << 64n) - 1n;
|
|
14
|
+
|
|
15
|
+
export interface Pcg32 {
|
|
16
|
+
/** Next uniform u32 in [0, 2^32). */
|
|
17
|
+
nextU32(): number;
|
|
18
|
+
/** Next uniform float in [0, 1) — u32 / 2^32. */
|
|
19
|
+
next01(): number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** pcg32_srandom_r(initstate, initseq) + pcg32_random_r, reference semantics. */
|
|
23
|
+
export function pcg32(initstate: number | bigint, initseq: number | bigint): Pcg32 {
|
|
24
|
+
const inc = ((BigInt(initseq) << 1n) | 1n) & MASK64;
|
|
25
|
+
let state = 0n;
|
|
26
|
+
const step = (): number => {
|
|
27
|
+
const old = state;
|
|
28
|
+
state = (old * MULT + inc) & MASK64;
|
|
29
|
+
const xorshifted = Number(((old >> 18n) ^ old) >> 27n & 0xffffffffn);
|
|
30
|
+
const rot = Number(old >> 59n);
|
|
31
|
+
// 32-bit rotate right; >>> 0 keeps the result unsigned.
|
|
32
|
+
return ((xorshifted >>> rot) | (xorshifted << ((-rot) & 31))) >>> 0;
|
|
33
|
+
};
|
|
34
|
+
// Reference seeding: from state 0, one step lands on `inc` (0*MULT + inc),
|
|
35
|
+
// then initstate is added and one more step is taken (output discarded).
|
|
36
|
+
state = inc;
|
|
37
|
+
state = (state + (BigInt(initstate) & MASK64)) & MASK64;
|
|
38
|
+
step();
|
|
39
|
+
return {
|
|
40
|
+
nextU32: step,
|
|
41
|
+
next01: () => step() / 0x1_0000_0000,
|
|
42
|
+
};
|
|
43
|
+
}
|