@3driseai/3drise-viewer 0.1.30 → 0.1.31
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/dist/animationsV2/ClipPlayer.d.ts.map +1 -1
- package/dist/animationsV2/ClipPlayer.js +14 -12
- package/dist/animationsV2/ClipsRuntime.d.ts +9 -0
- package/dist/animationsV2/ClipsRuntime.d.ts.map +1 -1
- package/dist/animationsV2/ClipsRuntime.js +54 -7
- package/dist/animationsV2/appliers.d.ts.map +1 -1
- package/dist/animationsV2/appliers.js +21 -2
- package/dist/animationsV2/clipUtils.d.ts.map +1 -1
- package/dist/animationsV2/clipUtils.js +4 -0
- package/dist/animationsV2/sequenceUtils.js +4 -5
- package/dist/animationsV2/sunSystemLive.d.ts +12 -0
- package/dist/animationsV2/sunSystemLive.d.ts.map +1 -0
- package/dist/animationsV2/sunSystemLive.js +8 -0
- package/dist/api/sceneHandle.d.ts +3 -1
- package/dist/api/sceneHandle.d.ts.map +1 -1
- package/dist/api/sceneHandle.js +2 -2
- package/dist/components/AdaptiveResolution.d.ts +9 -0
- package/dist/components/AdaptiveResolution.d.ts.map +1 -0
- package/dist/components/AdaptiveResolution.js +61 -0
- package/dist/components/CameraController.d.ts.map +1 -1
- package/dist/components/CameraController.js +24 -7
- package/dist/components/MoonDisc.d.ts.map +1 -1
- package/dist/components/MoonDisc.js +2 -1
- package/dist/components/OceanBuilder.d.ts +4 -1
- package/dist/components/OceanBuilder.d.ts.map +1 -1
- package/dist/components/OceanBuilder.js +2 -1
- package/dist/components/ParticlesGenerator.d.ts.map +1 -1
- package/dist/components/ParticlesGenerator.js +2 -0
- package/dist/components/SceneBuilder.d.ts.map +1 -1
- package/dist/components/SceneBuilder.js +5 -2
- package/dist/components/Skybox.d.ts.map +1 -1
- package/dist/components/Skybox.js +2 -1
- package/dist/components/SunLight.d.ts.map +1 -1
- package/dist/components/SunLight.js +3 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/lightBulb/LightBulb.d.ts.map +1 -1
- package/dist/components/lightBulb/LightBulb.js +7 -2
- package/dist/components/particles/FireworksParticles.d.ts +22 -0
- package/dist/components/particles/FireworksParticles.d.ts.map +1 -0
- package/dist/components/particles/FireworksParticles.js +1649 -0
- package/dist/components/particles/index.d.ts +2 -0
- package/dist/components/particles/index.d.ts.map +1 -1
- package/dist/components/particles/index.js +1 -0
- package/dist/components/terrain/TerrainSurface.d.ts.map +1 -1
- package/dist/components/terrain/TerrainSurface.js +26 -4
- package/dist/components/water/OceanSurface.d.ts.map +1 -1
- package/dist/components/water/OceanSurface.js +5 -1
- package/dist/components/water/types.d.ts +3 -1
- package/dist/components/water/types.d.ts.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -1
- package/dist/hooks/useOceanMaterial.d.ts +6 -2
- package/dist/hooks/useOceanMaterial.d.ts.map +1 -1
- package/dist/hooks/useOceanMaterial.js +22 -2
- package/dist/hooks/useOrbitTargetFollow.d.ts +10 -0
- package/dist/hooks/useOrbitTargetFollow.d.ts.map +1 -0
- package/dist/hooks/useOrbitTargetFollow.js +194 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/shaders/glsl/terrainCore.glsl.d.ts +18 -3
- package/dist/shaders/glsl/terrainCore.glsl.d.ts.map +1 -1
- package/dist/shaders/glsl/terrainCore.glsl.js +42 -21
- package/dist/shaders/ocean/OceanSurfaceMaterial.d.ts +36 -11
- package/dist/shaders/ocean/OceanSurfaceMaterial.d.ts.map +1 -1
- package/dist/shaders/ocean/OceanSurfaceMaterial.js +96 -5
- package/dist/triggers/hosts/actions.d.ts.map +1 -1
- package/dist/triggers/hosts/actions.js +2 -0
- package/dist/triggers/hosts/timelineCues.js +1 -1
- package/dist/triggers/kinds/change.d.ts +4 -0
- package/dist/triggers/kinds/change.d.ts.map +1 -0
- package/dist/triggers/kinds/change.js +50 -0
- package/dist/triggers/kinds/index.d.ts +1 -0
- package/dist/triggers/kinds/index.d.ts.map +1 -1
- package/dist/triggers/kinds/index.js +1 -0
- package/dist/utils/CameraSingleton.d.ts +4 -0
- package/dist/utils/CameraSingleton.d.ts.map +1 -1
- package/dist/utils/CameraSingleton.js +4 -0
- package/dist/utils/deviceTier.d.ts +2 -0
- package/dist/utils/deviceTier.d.ts.map +1 -0
- package/dist/utils/deviceTier.js +16 -0
- package/dist/utils/environmentDrawOrder.d.ts +23 -0
- package/dist/utils/environmentDrawOrder.d.ts.map +1 -0
- package/dist/utils/environmentDrawOrder.js +22 -0
- package/dist/utils/terrainUniforms.d.ts +7 -0
- package/dist/utils/terrainUniforms.d.ts.map +1 -1
- package/dist/utils/terrainUniforms.js +15 -2
- package/package.json +2 -2
|
@@ -0,0 +1,1649 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import * as THREE from 'three';
|
|
4
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
5
|
+
import { DEFAULT_GENERATIVE_PARTICLES, GENERATIVE_PARTICLES_ANIMATABLE } from '@3driseai/3drise-core';
|
|
6
|
+
import { applyConfigKeyframeAnimations, applyConfigMouseMove, withConfigDefaults } from '../../utils/configAnimations.js';
|
|
7
|
+
import { createPointerUniforms, stepPointerField, usePointerField } from '../../utils/pointerField.js';
|
|
8
|
+
import { createTrailUniforms, applyTrailConfig, createCpuTrail } from '../../utils/particleTrails.js';
|
|
9
|
+
import { useObjectReady } from '../../hooks/useObjectReady.js';
|
|
10
|
+
import { useConfigLiveHandle } from '../../hooks/useConfigLive.js';
|
|
11
|
+
/**
|
|
12
|
+
* Fireworks — An aerial fireworks show: 20 real shell types, shaped shells, formations and text written in the sky, with drag-limited stars, cooling sparks and lit smoke; fires on its own or where you click. Use for celebrations, New Year and night-sky scenes.
|
|
13
|
+
*/
|
|
14
|
+
// Fireworks — an aerial-shell display: shells rise from a mortar rack, burst at their apex and throw
|
|
15
|
+
// stars whose composition decides what the burst does.
|
|
16
|
+
// Movement: every shell, star, spark and microstar is a ballistic body under gravity and quadratic air
|
|
17
|
+
// drag a = -k |v - w| (v - w) relative to the air w (wind + the cursor's flow). The drag
|
|
18
|
+
// coefficient k = 3 rho_air C_d / (4 rho_star d) is per composition: heavy comets (palm,
|
|
19
|
+
// crossette) carry far, light charcoal stars (willow) stop quickly and hang at their low
|
|
20
|
+
// terminal speed sqrt(g / k), drooping into the willow shape. A shell is drag-free and its
|
|
21
|
+
// time fuse fires at the apex (launch speed sqrt(2 g h) sets the burst height). Stars are
|
|
22
|
+
// packed on a Fibonacci sphere (even, like stars pasted in a shell's hemispheres) or on a
|
|
23
|
+
// great circle (ring), and inherit the shell's velocity.
|
|
24
|
+
// Shell types (what real shells do):
|
|
25
|
+
// peony — coloured stars, no tails; half of them change colour as the star burns through
|
|
26
|
+
// its layers (colour from burn fraction).
|
|
27
|
+
// chrysanthemum — coloured stars that shed charcoal sparks: a burst of drawn lines.
|
|
28
|
+
// willow — long-burning charcoal stars with high drag: golden fronds that hang and droop.
|
|
29
|
+
// palm — a thick rising trunk and a few heavy comets that arc out like palm fronds.
|
|
30
|
+
// ring — stars on one tilted great circle.
|
|
31
|
+
// crossette — a few comets that each split into a cross of four at mid-flight.
|
|
32
|
+
// crackle — gold glitter stars that fragment into popping microstars (dragon eggs).
|
|
33
|
+
// strobe — magnesium stars that blink on and off at their own rates.
|
|
34
|
+
// mix — a show: the types in a seeded sequence.
|
|
35
|
+
// Light: stars emit their metal-salt colour (three config colours = strontium red, barium green,
|
|
36
|
+
// copper blue); charcoal sparks and gold stars glow on the Planck locus — temperature cools
|
|
37
|
+
// exponentially and visible radiance ~ exp(-c2 / (lambda T)) fades them out around 1100 K.
|
|
38
|
+
// Brightness follows the burning fragment (~ surface, so it fades as the star shrinks);
|
|
39
|
+
// film response saturates the brightest cores toward white. Each burst's flash and its
|
|
40
|
+
// stars light the smoke it leaves, which drifts with the wind and is lit again by later bursts.
|
|
41
|
+
// Mouse (pointerMode) — nothing is drawn at the cursor:
|
|
42
|
+
// aim — the rack moves under the cursor and shells burst where it points; a faster stroke
|
|
43
|
+
// fires more often and leans the shells; strength is the lift charge (burst size).
|
|
44
|
+
// wind — the cursor's motion is a gust: a moving-body potential flow plus a wake pushes stars,
|
|
45
|
+
// sparks and smoke, and sparks in the wake get fresh air and flare.
|
|
46
|
+
// reignite — sweeping through the falling stars feeds them oxygen: they flare, burn longer and
|
|
47
|
+
// take the pointer colour.
|
|
48
|
+
// Trails (trailMode): streak = long-exposure photograph, comet = burning tails, echo = afterglow.
|
|
49
|
+
/** mulberry32 — seeded PRNG, returns a function giving floats in [0,1). */
|
|
50
|
+
function mulberry32(seed) {
|
|
51
|
+
let a = seed >>> 0;
|
|
52
|
+
return function () {
|
|
53
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
54
|
+
let t = a;
|
|
55
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
56
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
57
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Standard normal from a uniform PRNG (Box–Muller, one value per call). */
|
|
61
|
+
function gauss(rng) {
|
|
62
|
+
const u = Math.max(rng(), 1e-9);
|
|
63
|
+
const v = rng();
|
|
64
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(6.283185307 * v);
|
|
65
|
+
}
|
|
66
|
+
/** Viewport-height hook so point sizes are pixel-correct at any canvas size. */
|
|
67
|
+
function attachViewH(obj, u) {
|
|
68
|
+
const scratch = new THREE.Vector2();
|
|
69
|
+
obj.onBeforeRender = (renderer) => {
|
|
70
|
+
renderer.getDrawingBufferSize(scratch);
|
|
71
|
+
u.uViewH.value = scratch.y;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function colorUniform(hex) {
|
|
75
|
+
return { value: new THREE.Color(hex) };
|
|
76
|
+
}
|
|
77
|
+
/** Quality levels scale counts and trail caps. Structural. */
|
|
78
|
+
const QUALITY_SCALE = { low: 0.5, medium: 1, high: 1.6 };
|
|
79
|
+
function qualityScale(q) { return QUALITY_SCALE[q] || 1; }
|
|
80
|
+
/** Mix the object's own base seed with the `seed` knob into a mulberry32 seed. */
|
|
81
|
+
function seedMix(base, seed) {
|
|
82
|
+
let h = (base ^ Math.imul((Math.round(seed) | 0) + 0x9e37, 0x85ebca6b)) >>> 0;
|
|
83
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
|
|
84
|
+
return (h ^ (h >>> 16)) >>> 0;
|
|
85
|
+
}
|
|
86
|
+
/** Fixed-step clock: the same motion at 30, 60 or 144 Hz. */
|
|
87
|
+
function createFixedStep(H, maxSteps) {
|
|
88
|
+
let acc = 0;
|
|
89
|
+
return {
|
|
90
|
+
H,
|
|
91
|
+
run(d, step) {
|
|
92
|
+
acc += Math.max(d, 0);
|
|
93
|
+
let k = 0;
|
|
94
|
+
while (acc >= H && k < maxSteps) {
|
|
95
|
+
step(H);
|
|
96
|
+
acc -= H;
|
|
97
|
+
k++;
|
|
98
|
+
}
|
|
99
|
+
if (k === maxSteps)
|
|
100
|
+
acc = Math.min(acc, H);
|
|
101
|
+
return acc / H;
|
|
102
|
+
},
|
|
103
|
+
reset() { acc = 0; },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Selection bounds: an invisible sphere the editor can pick and frame (never rendered). */
|
|
107
|
+
function createPickProxy(center, radius) {
|
|
108
|
+
const geo = new THREE.SphereGeometry(1, 16, 10);
|
|
109
|
+
const mat = new THREE.MeshBasicMaterial({ visible: false });
|
|
110
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
111
|
+
mesh.name = 'pickProxy';
|
|
112
|
+
mesh.position.set(center[0], center[1], center[2]);
|
|
113
|
+
mesh.scale.setScalar(radius);
|
|
114
|
+
return { mesh, dispose() { geo.dispose(); mat.dispose(); } };
|
|
115
|
+
}
|
|
116
|
+
const _frustum = new THREE.Frustum();
|
|
117
|
+
const _pm = new THREE.Matrix4();
|
|
118
|
+
const _sphere = new THREE.Sphere();
|
|
119
|
+
/** Off-screen test: false when the object's bounding sphere is outside the camera frustum. */
|
|
120
|
+
function inView(object, camera, center, radius, size) {
|
|
121
|
+
if (!camera || !camera.projectionMatrix)
|
|
122
|
+
return true;
|
|
123
|
+
object.updateWorldMatrix(true, false);
|
|
124
|
+
_sphere.center.set(center[0], center[1], center[2]).multiplyScalar(size).applyMatrix4(object.matrixWorld);
|
|
125
|
+
_sphere.radius = radius * size * object.matrixWorld.getMaxScaleOnAxis();
|
|
126
|
+
_pm.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
127
|
+
_frustum.setFromProjectionMatrix(_pm);
|
|
128
|
+
return _frustum.intersectsSphere(_sphere);
|
|
129
|
+
}
|
|
130
|
+
/** Tanner Helland blackbody fit, kelvin -> linear RGB normalised to its largest channel. */
|
|
131
|
+
function blackbodyRGB(tK, out) {
|
|
132
|
+
const t = Math.min(Math.max(tK, 1000), 40000) / 100;
|
|
133
|
+
const r = t <= 66 ? 1 : Math.min(Math.max(1.29293618606 * Math.pow(t - 60, -0.1332047592), 0), 1);
|
|
134
|
+
const g = t <= 66 ? Math.min(Math.max(0.39008157876 * Math.log(t) - 0.63184144378, 0), 1)
|
|
135
|
+
: Math.min(Math.max(1.12989086089 * Math.pow(t - 60, -0.0755148492), 0), 1);
|
|
136
|
+
const b = t >= 66 ? 1 : t <= 19 ? 0 : Math.min(Math.max(0.54320678911 * Math.log(t - 10) - 1.19625408914, 0), 1);
|
|
137
|
+
out[0] = Math.pow(r, 2.2);
|
|
138
|
+
out[1] = Math.pow(g, 2.2);
|
|
139
|
+
out[2] = Math.pow(b, 2.2);
|
|
140
|
+
const m = Math.max(out[0], out[1], out[2], 1e-3);
|
|
141
|
+
out[0] /= m;
|
|
142
|
+
out[1] /= m;
|
|
143
|
+
out[2] /= m;
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Blackbody lookup, 900..2700 K in 10 K steps (the sparks' range): the per-frame colour pass reads it instead of
|
|
147
|
+
* evaluating pow / log for every spark. */
|
|
148
|
+
const BB_LUT = (() => { const t = new Float32Array(181 * 3), o = [0, 0, 0]; for (let k = 0; k < 181; k++) {
|
|
149
|
+
blackbodyRGB(900 + k * 10, o);
|
|
150
|
+
t[k * 3] = o[0];
|
|
151
|
+
t[k * 3 + 1] = o[1];
|
|
152
|
+
t[k * 3 + 2] = o[2];
|
|
153
|
+
} return t; })();
|
|
154
|
+
function bbLut(T, out) {
|
|
155
|
+
const f = Math.min(Math.max((T - 900) / 10, 0), 179.999), k = Math.floor(f), w = f - k, a = k * 3;
|
|
156
|
+
out[0] = BB_LUT[a] + (BB_LUT[a + 3] - BB_LUT[a]) * w;
|
|
157
|
+
out[1] = BB_LUT[a + 1] + (BB_LUT[a + 4] - BB_LUT[a + 1]) * w;
|
|
158
|
+
out[2] = BB_LUT[a + 2] + (BB_LUT[a + 5] - BB_LUT[a + 2]) * w;
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
const POINTER_MODES = ['aim', 'wind', 'reignite'];
|
|
162
|
+
const CLICK_FUSE = 0.9; // seconds from a click to the break
|
|
163
|
+
/** Clicks for fireOnClick: a press and release within 6 px and 350 ms (an orbit drag is not a click).
|
|
164
|
+
* Read-only listeners on the same element as the pointer field; the queue lives on the pointer state. */
|
|
165
|
+
function bindClickFire(el, state) {
|
|
166
|
+
state.clicks = state.clicks || [];
|
|
167
|
+
let x0 = 0, y0 = 0, t0 = -1;
|
|
168
|
+
const down = (e) => { if (e.button !== 0)
|
|
169
|
+
return; x0 = e.clientX; y0 = e.clientY; t0 = performance.now(); };
|
|
170
|
+
const up = (e) => {
|
|
171
|
+
if (e.button !== 0 || t0 < 0)
|
|
172
|
+
return;
|
|
173
|
+
const dt = performance.now() - t0;
|
|
174
|
+
t0 = -1;
|
|
175
|
+
if (dt > 350 || Math.hypot(e.clientX - x0, e.clientY - y0) > 6)
|
|
176
|
+
return;
|
|
177
|
+
const r = el.getBoundingClientRect();
|
|
178
|
+
state.clicks.push([((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1]);
|
|
179
|
+
if (state.clicks.length > 8)
|
|
180
|
+
state.clicks.shift();
|
|
181
|
+
};
|
|
182
|
+
el.addEventListener('pointerdown', down, { passive: true });
|
|
183
|
+
el.addEventListener('pointerup', up, { passive: true });
|
|
184
|
+
return () => { el.removeEventListener('pointerdown', down); el.removeEventListener('pointerup', up); };
|
|
185
|
+
}
|
|
186
|
+
const _cray = new THREE.Raycaster();
|
|
187
|
+
const _cplane = new THREE.Plane();
|
|
188
|
+
const _cn = new THREE.Vector3();
|
|
189
|
+
const _cc = new THREE.Vector3();
|
|
190
|
+
const _chit = new THREE.Vector3();
|
|
191
|
+
const _cv = new THREE.Vector2();
|
|
192
|
+
/** Where a click lands, in the object's sim units: the nearest scene surface it hits (an object, the ground)
|
|
193
|
+
* — skipping this object, sprites and lines, inside-out domes (sky) and anything implausibly far — or else the
|
|
194
|
+
* camera-facing plane through the show. */
|
|
195
|
+
function clickTarget(ndc, camera, object, size, anchor, own) {
|
|
196
|
+
_cv.set(ndc[0], ndc[1]);
|
|
197
|
+
_cray.setFromCamera(_cv, camera);
|
|
198
|
+
let root = object;
|
|
199
|
+
while (root.parent)
|
|
200
|
+
root = root.parent;
|
|
201
|
+
object.updateWorldMatrix(true, false);
|
|
202
|
+
const scale = size * object.matrixWorld.getMaxScaleOnAxis();
|
|
203
|
+
const far = 40 * scale + camera.position.distanceTo(object.getWorldPosition(_cc));
|
|
204
|
+
const hits = _cray.intersectObject(root, true);
|
|
205
|
+
for (const h of hits) {
|
|
206
|
+
const o = h.object, mat = o['material'];
|
|
207
|
+
if (!('isMesh' in o) || h.distance > far || own.has(o))
|
|
208
|
+
continue;
|
|
209
|
+
const m = Array.isArray(mat) ? mat[0] : mat;
|
|
210
|
+
if (!m || m.visible === false || m.side === THREE.BackSide)
|
|
211
|
+
continue;
|
|
212
|
+
let shown = true;
|
|
213
|
+
for (let a = h.object; a; a = a.parent)
|
|
214
|
+
if (!a.visible) {
|
|
215
|
+
shown = false;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
if (!shown)
|
|
219
|
+
continue;
|
|
220
|
+
_chit.copy(h.point);
|
|
221
|
+
object.worldToLocal(_chit).divideScalar(Math.max(size, 1e-4));
|
|
222
|
+
return [_chit.x, _chit.y, _chit.z];
|
|
223
|
+
}
|
|
224
|
+
object.localToWorld(_cc.copy(anchor).multiplyScalar(size));
|
|
225
|
+
camera.getWorldDirection(_cn);
|
|
226
|
+
_cplane.setFromNormalAndCoplanarPoint(_cn, _cc);
|
|
227
|
+
if (!_cray.ray.intersectPlane(_cplane, _chit))
|
|
228
|
+
return null;
|
|
229
|
+
object.worldToLocal(_chit).divideScalar(Math.max(size, 1e-4));
|
|
230
|
+
return [_chit.x, _chit.y, _chit.z];
|
|
231
|
+
}
|
|
232
|
+
const SHELL_TYPES = ['peony', 'chrysanthemum', 'willow', 'palm', 'ring', 'crossette', 'crackle', 'strobe',
|
|
233
|
+
'brocade', 'timeRain', 'fish', 'fallingLeaves', 'heart', 'shapedStar', 'kamuro', 'horsetail', 'multiBreak', 'saturn', 'pistil'];
|
|
234
|
+
const TEXT_MODES = ['word', 'letters'];
|
|
235
|
+
const FORMATIONS = ['none', 'line', 'arc', 'v', 'circle', 'triangle', 'square', 'star', 'heart'];
|
|
236
|
+
const FORM_FUSE = 1.8;
|
|
237
|
+
/** Points sampled from text drawn on a hidden canvas: the stars of a text shell. `perGlyph` false — the whole
|
|
238
|
+
* string, scaled so it spans x in [-1, 1]; true — one set per character, scaled by the cap height (y in ~[-1, 1]).
|
|
239
|
+
* Returns null where there is no canvas (server render, a worker without OffscreenCanvas). */
|
|
240
|
+
function textPoints(str, n, rng, perGlyph) {
|
|
241
|
+
const make = (w, h) => {
|
|
242
|
+
let c = null;
|
|
243
|
+
if (typeof document !== 'undefined') {
|
|
244
|
+
c = document.createElement('canvas');
|
|
245
|
+
c.width = w;
|
|
246
|
+
c.height = h;
|
|
247
|
+
}
|
|
248
|
+
else if (typeof OffscreenCanvas !== 'undefined')
|
|
249
|
+
c = new OffscreenCanvas(w, h);
|
|
250
|
+
return c;
|
|
251
|
+
};
|
|
252
|
+
const FS = 72, font = '900 ' + FS + 'px "Arial Black", "Helvetica Neue", Arial, sans-serif';
|
|
253
|
+
const probe = make(4, 4);
|
|
254
|
+
if (!probe)
|
|
255
|
+
return null;
|
|
256
|
+
const pctx = probe.getContext('2d');
|
|
257
|
+
pctx.font = font;
|
|
258
|
+
const sample = (txt) => {
|
|
259
|
+
const w = Math.max(8, Math.ceil(pctx.measureText(txt).width) + 12), h = Math.round(FS * 1.3);
|
|
260
|
+
const cv = make(w, h), ctx = cv.getContext('2d');
|
|
261
|
+
ctx.font = font;
|
|
262
|
+
ctx.textBaseline = 'alphabetic';
|
|
263
|
+
ctx.fillStyle = '#fff';
|
|
264
|
+
ctx.fillText(txt, 6, Math.round(FS * 1.02));
|
|
265
|
+
const d = ctx.getImageData(0, 0, w, h).data, px = [];
|
|
266
|
+
for (let yy = 0; yy < h; yy += 2)
|
|
267
|
+
for (let xx = 0; xx < w; xx += 2)
|
|
268
|
+
if (d[(yy * w + xx) * 4 + 3] > 140)
|
|
269
|
+
px.push(xx, yy);
|
|
270
|
+
return { px, w, h };
|
|
271
|
+
};
|
|
272
|
+
const pick = (px, m) => {
|
|
273
|
+
const cnt = px.length / 2, out = [];
|
|
274
|
+
const idx = Array.from({ length: cnt }, (_, k) => k);
|
|
275
|
+
for (let k = 0; k < Math.min(m, cnt); k++) {
|
|
276
|
+
const r = k + Math.floor(rng() * (cnt - k));
|
|
277
|
+
const t = idx[k];
|
|
278
|
+
idx[k] = idx[r];
|
|
279
|
+
idx[r] = t;
|
|
280
|
+
out.push([px[idx[k] * 2], px[idx[k] * 2 + 1]]);
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
};
|
|
284
|
+
const capH = FS * 0.72, base = Math.round(FS * 1.02);
|
|
285
|
+
if (!perGlyph) {
|
|
286
|
+
const { px } = sample(str);
|
|
287
|
+
if (!px.length)
|
|
288
|
+
return null;
|
|
289
|
+
let x0 = 1e9, x1 = -1e9;
|
|
290
|
+
for (let k = 0; k < px.length; k += 2) {
|
|
291
|
+
x0 = Math.min(x0, px[k]);
|
|
292
|
+
x1 = Math.max(x1, px[k]);
|
|
293
|
+
}
|
|
294
|
+
const cx = (x0 + x1) / 2, sc = 2 / Math.max(x1 - x0, 1);
|
|
295
|
+
return pick(px, n).map(([a, b]) => [(a - cx) * sc, (base - capH / 2 - b) * sc]);
|
|
296
|
+
}
|
|
297
|
+
return Array.from(str).map((ch) => {
|
|
298
|
+
if (ch === ' ')
|
|
299
|
+
return null;
|
|
300
|
+
const { px } = sample(ch);
|
|
301
|
+
if (!px.length)
|
|
302
|
+
return null;
|
|
303
|
+
let x0 = 1e9, x1 = -1e9;
|
|
304
|
+
for (let k = 0; k < px.length; k += 2) {
|
|
305
|
+
x0 = Math.min(x0, px[k]);
|
|
306
|
+
x1 = Math.max(x1, px[k]);
|
|
307
|
+
}
|
|
308
|
+
const cx = (x0 + x1) / 2, sc = 2 / capH;
|
|
309
|
+
return pick(px, n).map(([a, b]) => [(a - cx) * sc, (base - capH / 2 - b) * sc]);
|
|
310
|
+
});
|
|
311
|
+
} // a formation's shells share one fuse time, so they break together
|
|
312
|
+
/** `n` points spread evenly (by arc length) along a unit outline — a formation of bursts, or the stars of a
|
|
313
|
+
* shaped shell. Open shapes (line, arc, v) include both ends; closed ones wrap. Max radius ~1. */
|
|
314
|
+
function outline(shape, n) {
|
|
315
|
+
let poly, closed = true;
|
|
316
|
+
if (shape === 'line') {
|
|
317
|
+
poly = [[-1, 0], [1, 0]];
|
|
318
|
+
closed = false;
|
|
319
|
+
}
|
|
320
|
+
else if (shape === 'arc') {
|
|
321
|
+
poly = [];
|
|
322
|
+
for (let k = 0; k <= 24; k++) {
|
|
323
|
+
const a = Math.PI * (1 - k / 24);
|
|
324
|
+
poly.push([Math.cos(a), 0.6 * Math.sin(a) - 0.25]);
|
|
325
|
+
}
|
|
326
|
+
closed = false;
|
|
327
|
+
}
|
|
328
|
+
else if (shape === 'v') {
|
|
329
|
+
poly = [[-1, 0.55], [0, -0.55], [1, 0.55]];
|
|
330
|
+
closed = false;
|
|
331
|
+
}
|
|
332
|
+
else if (shape === 'triangle') {
|
|
333
|
+
poly = [[0, 0.9], [-0.95, -0.65], [0.95, -0.65]];
|
|
334
|
+
}
|
|
335
|
+
else if (shape === 'square') {
|
|
336
|
+
poly = [[-0.75, 0.75], [0.75, 0.75], [0.75, -0.75], [-0.75, -0.75]];
|
|
337
|
+
}
|
|
338
|
+
else if (shape === 'star' || shape === 'shapedStar') {
|
|
339
|
+
poly = [];
|
|
340
|
+
for (let k = 0; k < 10; k++) {
|
|
341
|
+
const a = Math.PI / 2 + k * Math.PI / 5, r = k % 2 ? 0.42 : 1;
|
|
342
|
+
poly.push([r * Math.cos(a), r * Math.sin(a)]);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else if (shape === 'heart') {
|
|
346
|
+
poly = [];
|
|
347
|
+
for (let k = 0; k < 48; k++) {
|
|
348
|
+
const t = (k / 48) * Math.PI * 2, st = Math.sin(t);
|
|
349
|
+
poly.push([st * st * st, (13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)) / 17 + 0.1]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
poly = [];
|
|
354
|
+
for (let k = 0; k < 48; k++) {
|
|
355
|
+
const a = (k / 48) * Math.PI * 2;
|
|
356
|
+
poly.push([Math.cos(a), Math.sin(a)]);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const segs = closed ? poly.length : poly.length - 1;
|
|
360
|
+
const len = [], tot = [0];
|
|
361
|
+
for (let k = 0; k < segs; k++) {
|
|
362
|
+
const a = poly[k], b = poly[(k + 1) % poly.length];
|
|
363
|
+
len.push(Math.hypot(b[0] - a[0], b[1] - a[1]));
|
|
364
|
+
tot.push(tot[k] + len[k]);
|
|
365
|
+
}
|
|
366
|
+
const L = tot[segs], out = [];
|
|
367
|
+
for (let q = 0; q < n; q++) {
|
|
368
|
+
let d = closed ? (q / n) * L : (n > 1 ? (q / (n - 1)) * L : 0.5 * L);
|
|
369
|
+
let k = 0;
|
|
370
|
+
while (k < segs - 1 && tot[k + 1] < d)
|
|
371
|
+
k++;
|
|
372
|
+
const f = len[k] > 0 ? (d - tot[k]) / len[k] : 0, a = poly[k], b = poly[(k + 1) % poly.length];
|
|
373
|
+
out.push([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]);
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
const SHELL_SELECT = ['mix', ...SHELL_TYPES, 'text'];
|
|
378
|
+
const TEXT_SEL = SHELL_SELECT.length - 1; // shellType 'text': every launch writes the text
|
|
379
|
+
const G = 0.2; // gravity, sim units / s^2 (one sim unit ~ 50 m)
|
|
380
|
+
const Y0 = -1.2; // the mortar rack (ground)
|
|
381
|
+
const H_STEP = 1 / 90; // fixed physics step (s)
|
|
382
|
+
const TR_MODE = { streak: 0.22, comet: 0.2, echo: 0.45 }; // seconds of history per trail unit
|
|
383
|
+
const TRAIL_GAIN = { streak: 1.4, comet: 1.6, echo: 2.6 }; // per-type look-back gain so every mode reads at defaults
|
|
384
|
+
const SPARK_K = 9; // drag of a charcoal spark (tiny, light)
|
|
385
|
+
const GOLD_T = 1850; // charcoal / gold star temperature (K)
|
|
386
|
+
const NB = 24; // burst lights (flash + smoke lighting)
|
|
387
|
+
const NS = 40; // smoke puffs
|
|
388
|
+
/** Selection / pick volume in sim units (× size): the culling sphere and the click proxy. */
|
|
389
|
+
export const BOUNDS = { center: [0, 0, 0], radius: 1.3 };
|
|
390
|
+
// Per composition: stars per shell, ejection speed, drag, burn time (s), sparks / s shed by each star,
|
|
391
|
+
// rising-tail sparks / s, star sprite size, colour slots (0..2 config colours, 3 gold, 4 magnesium white).
|
|
392
|
+
const PROFILE = {
|
|
393
|
+
peony: { stars: 150, v: 1.49, k: 2.6, burn: 1.7, tail: 0, rise: 30, sz: 1.0 },
|
|
394
|
+
chrysanthemum: { stars: 120, v: 1.49, k: 2.6, burn: 1.9, tail: 30, rise: 55, sz: 0.9 },
|
|
395
|
+
willow: { stars: 95, v: 1.28, k: 5.5, burn: 4.2, tail: 34, rise: 70, sz: 0.85 },
|
|
396
|
+
palm: { stars: 8, v: 1.25, k: 1.7, burn: 2.6, tail: 150, rise: 240, sz: 1.5 },
|
|
397
|
+
ring: { stars: 72, v: 1.49, k: 2.4, burn: 1.6, tail: 0, rise: 30, sz: 1.05 },
|
|
398
|
+
crossette: { stars: 7, v: 1.55, k: 1.6, burn: 0.8, tail: 90, rise: 60, sz: 1.3 },
|
|
399
|
+
crackle: { stars: 90, v: 1.35, k: 3.2, burn: 1.05, tail: 12, rise: 40, sz: 0.8 },
|
|
400
|
+
strobe: { stars: 85, v: 1.28, k: 3.4, burn: 2.8, tail: 0, rise: 30, sz: 0.95 },
|
|
401
|
+
brocade: { stars: 170, v: 1.45, k: 3.6, burn: 2.4, tail: 48, rise: 70, sz: 0.85 },
|
|
402
|
+
timeRain: { stars: 110, v: 1.35, k: 3.0, burn: 0.9, tail: 0, rise: 40, sz: 0.9 },
|
|
403
|
+
fish: { stars: 60, v: 1.35, k: 2.8, burn: 0.8, tail: 0, rise: 40, sz: 0.85 },
|
|
404
|
+
fallingLeaves: { stars: 70, v: 1.7, k: 6.5, burn: 4.5, tail: 0, rise: 40, sz: 1.35 },
|
|
405
|
+
heart: { stars: 84, v: 0.95, k: 2.4, burn: 1.7, tail: 0, rise: 30, sz: 1.05 },
|
|
406
|
+
shapedStar: { stars: 80, v: 0.95, k: 2.4, burn: 1.7, tail: 0, rise: 30, sz: 1.05 },
|
|
407
|
+
kamuro: { stars: 110, v: 1.3, k: 7.5, burn: 5.5, tail: 40, rise: 80, sz: 0.85 },
|
|
408
|
+
horsetail: { stars: 60, v: 0.9, k: 1.6, burn: 3.6, tail: 45, rise: 60, sz: 0.9 },
|
|
409
|
+
multiBreak: { stars: 5, v: 0.9, k: 1.5, burn: 0.6, tail: 60, rise: 50, sz: 1.1 },
|
|
410
|
+
saturn: { stars: 110, v: 1.35, k: 2.6, burn: 1.8, tail: 0, rise: 30, sz: 1.0 },
|
|
411
|
+
pistil: { stars: 130, v: 1.45, k: 2.6, burn: 1.8, tail: 0, rise: 30, sz: 1.0 },
|
|
412
|
+
text: { stars: 320, v: 0.95, k: 2.4, burn: 1.9, tail: 0, rise: 30, sz: 1.0 },
|
|
413
|
+
};
|
|
414
|
+
// The show order for 'mix' (a seeded walk through it, so neighbours differ).
|
|
415
|
+
const MIX = ['peony', 'chrysanthemum', 'willow', 'ring', 'palm', 'crossette', 'brocade', 'peony', 'crackle', 'timeRain', 'strobe',
|
|
416
|
+
'chrysanthemum', 'fish', 'willow', 'heart', 'fallingLeaves', 'peony', 'shapedStar', 'kamuro', 'saturn', 'horsetail',
|
|
417
|
+
'pistil', 'multiBreak', 'chrysanthemum'];
|
|
418
|
+
function createUniforms() {
|
|
419
|
+
return {
|
|
420
|
+
uTime: { value: 0 },
|
|
421
|
+
uSize: { value: 1 },
|
|
422
|
+
uViewH: { value: 800 },
|
|
423
|
+
uIntensity: { value: 1 },
|
|
424
|
+
uSpeed: { value: 1 },
|
|
425
|
+
uOpacity: { value: 1 },
|
|
426
|
+
uColor: colorUniform('#ffffff'),
|
|
427
|
+
uSecondColor: colorUniform('#ffffff'),
|
|
428
|
+
uAccentColor: colorUniform('#ffffff'),
|
|
429
|
+
uSparkColor: colorUniform('#ffffff'),
|
|
430
|
+
uShellType: { value: 0 },
|
|
431
|
+
uLaunchRate: { value: 0.6 },
|
|
432
|
+
uBurstSize: { value: 1 },
|
|
433
|
+
uBurstHeight: { value: 1 },
|
|
434
|
+
uBurnTime: { value: 1 },
|
|
435
|
+
uSpread: { value: 1 },
|
|
436
|
+
uWind: { value: 0 },
|
|
437
|
+
uSmoke: { value: 0.5 },
|
|
438
|
+
uFireOnClick: { value: 0 },
|
|
439
|
+
uRandomFire: { value: 1 },
|
|
440
|
+
uFormation: { value: 0 },
|
|
441
|
+
uFormationCount: { value: 9 },
|
|
442
|
+
uFormationSize: { value: 1 },
|
|
443
|
+
uTextMode: { value: 0 },
|
|
444
|
+
uTextSize: { value: 1 },
|
|
445
|
+
uText: { value: '' },
|
|
446
|
+
uPointerMode: { value: 0 },
|
|
447
|
+
uPointerStrength: { value: 1 },
|
|
448
|
+
uPointerRadius: { value: 1 },
|
|
449
|
+
uPointerColor: colorUniform('#ffffff'),
|
|
450
|
+
...createPointerUniforms(),
|
|
451
|
+
...createTrailUniforms(),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function applyConfig(u, c, size) {
|
|
455
|
+
u.uSize.value = size;
|
|
456
|
+
u.uIntensity.value = c.intensity;
|
|
457
|
+
u.uSpeed.value = c.speed;
|
|
458
|
+
u.uOpacity.value = c.opacity;
|
|
459
|
+
u.uColor.value.set(c.color);
|
|
460
|
+
u.uSecondColor.value.set(c.secondColor);
|
|
461
|
+
u.uAccentColor.value.set(c.accentColor);
|
|
462
|
+
u.uSparkColor.value.set(c.sparkColor);
|
|
463
|
+
u.uShellType.value = Math.max(0, SHELL_SELECT.indexOf(c.shellType));
|
|
464
|
+
u.uLaunchRate.value = c.launchRate;
|
|
465
|
+
u.uBurstSize.value = c.burstSize;
|
|
466
|
+
u.uBurstHeight.value = c.burstHeight;
|
|
467
|
+
u.uBurnTime.value = c.burnTime;
|
|
468
|
+
u.uSpread.value = c.spread;
|
|
469
|
+
u.uWind.value = c.wind;
|
|
470
|
+
u.uSmoke.value = c.smoke;
|
|
471
|
+
u.uFireOnClick.value = c.fireOnClick ? 1 : 0;
|
|
472
|
+
u.uRandomFire.value = c.randomFire === false ? 0 : 1;
|
|
473
|
+
u.uFormation.value = Math.max(0, FORMATIONS.indexOf(c.formation));
|
|
474
|
+
u.uFormationCount.value = c.formationCount;
|
|
475
|
+
u.uFormationSize.value = c.formationSize;
|
|
476
|
+
u.uTextMode.value = Math.max(0, TEXT_MODES.indexOf(c.textMode));
|
|
477
|
+
u.uTextSize.value = c.textSize;
|
|
478
|
+
u.uText.value = typeof c.text === 'string' ? c.text : '';
|
|
479
|
+
u.uPointerMode.value = POINTER_MODES.indexOf(c.pointerMode); // -1 = 'none' (off)
|
|
480
|
+
u.uPointerStrength.value = c.pointerStrength;
|
|
481
|
+
u.uPointerRadius.value = c.pointerRadius;
|
|
482
|
+
u.uPointerColor.value.set(c.pointerColor);
|
|
483
|
+
applyTrailConfig(u, c);
|
|
484
|
+
}
|
|
485
|
+
// Stars, sparks and microstars: one sprite each, light computed on the CPU from the body's state.
|
|
486
|
+
const starVertex = /* glsl */ `
|
|
487
|
+
uniform float uSize, uViewH, uIntensity, uOpacity;
|
|
488
|
+
attribute vec3 aCol;
|
|
489
|
+
attribute float aS;
|
|
490
|
+
varying vec3 vCol;
|
|
491
|
+
varying float vA;
|
|
492
|
+
void main() {
|
|
493
|
+
vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
|
|
494
|
+
vCol = aCol * uIntensity;
|
|
495
|
+
vA = uOpacity;
|
|
496
|
+
float px = uSize * uViewH * aS / -mv.z;
|
|
497
|
+
gl_PointSize = clamp(px, 0.0, 48.0) * step(0.0001, aS);
|
|
498
|
+
gl_Position = projectionMatrix * mv;
|
|
499
|
+
}
|
|
500
|
+
`;
|
|
501
|
+
const starFragment = /* glsl */ `
|
|
502
|
+
varying vec3 vCol;
|
|
503
|
+
varying float vA;
|
|
504
|
+
void main() {
|
|
505
|
+
vec2 d = gl_PointCoord * 2.0 - 1.0;
|
|
506
|
+
float r2 = dot(d, d);
|
|
507
|
+
if (r2 > 1.0) discard;
|
|
508
|
+
// a hot core with a soft halo (the star's glare)
|
|
509
|
+
float a = (exp(-r2 * 9.0) + 0.22 * exp(-r2 * 2.4)) * (1.0 - r2) * vA;
|
|
510
|
+
if (a < 0.002) discard;
|
|
511
|
+
gl_FragColor = vec4(vCol * a, 1.0);
|
|
512
|
+
#include <tonemapping_fragment>
|
|
513
|
+
#include <colorspace_fragment>
|
|
514
|
+
}
|
|
515
|
+
`;
|
|
516
|
+
// Burst flashes: the flash powder's white blink and the short-lived glow of a fresh burst.
|
|
517
|
+
const flashVertex = /* glsl */ `
|
|
518
|
+
uniform float uSize, uViewH, uIntensity, uOpacity;
|
|
519
|
+
attribute vec4 aF; // r, g, b, strength
|
|
520
|
+
varying vec3 vCol;
|
|
521
|
+
void main() {
|
|
522
|
+
vec4 mv = modelViewMatrix * vec4(position * uSize, 1.0);
|
|
523
|
+
vCol = aF.rgb * aF.a * uIntensity * uOpacity;
|
|
524
|
+
float px = uSize * uViewH * (0.014 + 0.025 * min(aF.a, 1.5)) / -mv.z;
|
|
525
|
+
gl_PointSize = clamp(px, 0.0, 220.0) * step(0.002, aF.a);
|
|
526
|
+
gl_Position = projectionMatrix * mv;
|
|
527
|
+
}
|
|
528
|
+
`;
|
|
529
|
+
const flashFragment = /* glsl */ `
|
|
530
|
+
varying vec3 vCol;
|
|
531
|
+
void main() {
|
|
532
|
+
vec2 d = gl_PointCoord * 2.0 - 1.0;
|
|
533
|
+
float r2 = dot(d, d);
|
|
534
|
+
if (r2 > 1.0) discard;
|
|
535
|
+
float a = (0.35 * exp(-r2 * 40.0) + 0.05 * exp(-r2 * 6.0)) * (1.0 - r2);
|
|
536
|
+
if (a < 0.002) discard;
|
|
537
|
+
gl_FragColor = vec4(vCol * a, 1.0);
|
|
538
|
+
#include <tonemapping_fragment>
|
|
539
|
+
#include <colorspace_fragment>
|
|
540
|
+
}
|
|
541
|
+
`;
|
|
542
|
+
// Smoke: camera-facing billowy puffs, premultiplied, lit on the CPU by the bursts around them.
|
|
543
|
+
const smokeVertex = /* glsl */ `
|
|
544
|
+
uniform float uSize;
|
|
545
|
+
attribute vec3 aP;
|
|
546
|
+
attribute vec4 aC; // lit colour rgb, alpha
|
|
547
|
+
attribute vec2 aR; // radius, seed
|
|
548
|
+
varying vec3 vCol;
|
|
549
|
+
varying float vA;
|
|
550
|
+
varying vec2 vUv;
|
|
551
|
+
varying float vSeed;
|
|
552
|
+
void main() {
|
|
553
|
+
vec4 mv = modelViewMatrix * vec4(aP * uSize, 1.0);
|
|
554
|
+
mv.xy += position.xy * aR.x * uSize;
|
|
555
|
+
vCol = aC.rgb; vA = aC.a; vUv = position.xy * 2.0; vSeed = aR.y;
|
|
556
|
+
gl_Position = projectionMatrix * mv;
|
|
557
|
+
}
|
|
558
|
+
`;
|
|
559
|
+
const smokeFragment = /* glsl */ `
|
|
560
|
+
uniform float uOpacity, uTime;
|
|
561
|
+
varying vec3 vCol;
|
|
562
|
+
varying float vA;
|
|
563
|
+
varying vec2 vUv;
|
|
564
|
+
varying float vSeed;
|
|
565
|
+
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
566
|
+
float vnoise(vec2 p) {
|
|
567
|
+
vec2 i = floor(p), f = fract(p);
|
|
568
|
+
f = f * f * (3.0 - 2.0 * f);
|
|
569
|
+
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
|
|
570
|
+
}
|
|
571
|
+
void main() {
|
|
572
|
+
float r = length(vUv);
|
|
573
|
+
if (r > 1.0) discard;
|
|
574
|
+
vec2 q = vUv * 1.6 + vSeed * 17.0 + vec2(0.0, uTime * 0.03);
|
|
575
|
+
float n = 0.55 * vnoise(q) + 0.3 * vnoise(q * 2.1 + 3.1) + 0.15 * vnoise(q * 4.3 + 7.7);
|
|
576
|
+
float body = smoothstep(1.0, 0.25, r + 0.35 * (n - 0.5));
|
|
577
|
+
float a = body * (0.55 + 0.6 * n) * vA * uOpacity;
|
|
578
|
+
if (a < 0.003) discard;
|
|
579
|
+
gl_FragColor = vec4(vCol * a, a);
|
|
580
|
+
#include <tonemapping_fragment>
|
|
581
|
+
#include <colorspace_fragment>
|
|
582
|
+
}
|
|
583
|
+
`;
|
|
584
|
+
function build(u, c, size) {
|
|
585
|
+
applyConfig(u, c, size); // the warm-up runs on this config
|
|
586
|
+
const q = qualityScale(c.quality);
|
|
587
|
+
const nStar = Math.max(300, Math.round(2400 * q)); // shells, stars, microstars
|
|
588
|
+
const nSpark = Math.max(500, Math.round(c.particleCount * q)); // charcoal sparks
|
|
589
|
+
const n = nStar + nSpark;
|
|
590
|
+
const rng = mulberry32(seedMix(0xf1e7, c.seed));
|
|
591
|
+
const pos = new Float32Array(n * 3), vel = new Float32Array(n * 3);
|
|
592
|
+
const kind = new Uint8Array(n); // 0 free, 1 shell, 2 star, 3 spark, 4 microstar
|
|
593
|
+
const age = new Float32Array(n);
|
|
594
|
+
const life = new Float32Array(n); // burn time (stars), fuse delay (microstars), tau (sparks)
|
|
595
|
+
const drag = new Float32Array(n);
|
|
596
|
+
const slotA = new Uint8Array(n), slotB = new Uint8Array(n);
|
|
597
|
+
const change = new Float32Array(n); // burn fraction where the colour changes (2 = never)
|
|
598
|
+
const tailRate = new Float32Array(n), tailAcc = new Float32Array(n);
|
|
599
|
+
const split = new Uint8Array(n); // crossette: split into a cross at burn-out
|
|
600
|
+
const crackle = new Uint8Array(n); // microstars spawned at burn-out
|
|
601
|
+
const strobe = new Float32Array(n); // blink rate (Hz), 0 = steady
|
|
602
|
+
const rain = new Uint8Array(n); // time rain: slow embers dropped at burn-out
|
|
603
|
+
const fishN = new Uint8Array(n); // fish: wriggling stars released at burn-out
|
|
604
|
+
const wiggle = new Float32Array(n); // fish: lateral swimming acceleration
|
|
605
|
+
const flutter = new Float32Array(n); // falling leaves: rocking rate (rad / s)
|
|
606
|
+
const sTau = new Float32Array(n), sT0 = new Float32Array(n); // sparks this star sheds: cooling time, temperature
|
|
607
|
+
const forceA = new Uint8Array(n).fill(255), forceB = new Uint8Array(n); // shells of a formation share colours
|
|
608
|
+
const chargeMul = new Float32Array(n); // shells: share of the lift charge (smaller in a formation)
|
|
609
|
+
const sub = new Uint8Array(n); // multi-break: the star breaks again into a small peony
|
|
610
|
+
const pattern = new Array(nStar).fill(null); // shaped / text shells: the 2D points their stars are laid on
|
|
611
|
+
const phase = new Float32Array(n);
|
|
612
|
+
const tint = new Float32Array(n); // share of the pointer colour (aimed shells)
|
|
613
|
+
const oxy = new Float32Array(n); // extra oxygen (cursor wake / reignite)
|
|
614
|
+
const T0 = new Float32Array(n); // spark launch temperature
|
|
615
|
+
const sz = new Float32Array(n);
|
|
616
|
+
const shellType = new Uint8Array(n); // shells: which composition they carry
|
|
617
|
+
const targetY = new Float32Array(n);
|
|
618
|
+
const kTrail = new Float32Array(n); // trails: slot * 10 + brightness
|
|
619
|
+
const aCol = new Float32Array(n * 3), aS = new Float32Array(n);
|
|
620
|
+
const freeStar = new Int32Array(nStar), freeSpark = new Int32Array(nSpark);
|
|
621
|
+
let nfStar = nStar, nfSpark = nSpark;
|
|
622
|
+
for (let i = 0; i < nStar; i++)
|
|
623
|
+
freeStar[i] = nStar - 1 - i;
|
|
624
|
+
for (let i = 0; i < nSpark; i++)
|
|
625
|
+
freeSpark[i] = n - 1 - i;
|
|
626
|
+
let trail = null, trailKey = '', trailTR = TR_MODE.comet;
|
|
627
|
+
// burst lights and smoke
|
|
628
|
+
const bPos = new Float32Array(NB * 3), bCol = new Float32Array(NB * 3), bAge = new Float32Array(NB).fill(1e9);
|
|
629
|
+
let bHead = 0;
|
|
630
|
+
const sPos = new Float32Array(NS * 3), sVel = new Float32Array(NS * 3), sAge = new Float32Array(NS).fill(1e9);
|
|
631
|
+
const sSeed = new Float32Array(NS), sScale = new Float32Array(NS), sCol = new Float32Array(NS * 3);
|
|
632
|
+
let sHead = 0;
|
|
633
|
+
for (let s = 0; s < NS; s++)
|
|
634
|
+
sSeed[s] = rng();
|
|
635
|
+
let launchAcc = 0.85, mixIdx = Math.floor(rng() * MIX.length), clock = 0, strokeRate = 0;
|
|
636
|
+
const hp = new THREE.Vector3(), hv = new THREE.Vector3();
|
|
637
|
+
const tmp = [0, 0, 0];
|
|
638
|
+
function takeStar() { return nfStar > 0 ? freeStar[--nfStar] : -1; }
|
|
639
|
+
function takeSpark() { return nfSpark > 0 ? freeSpark[--nfSpark] : -1; }
|
|
640
|
+
function release(i) {
|
|
641
|
+
kind[i] = 0;
|
|
642
|
+
aS[i] = 0;
|
|
643
|
+
kTrail[i] = 0;
|
|
644
|
+
if (i < nStar)
|
|
645
|
+
freeStar[nfStar++] = i;
|
|
646
|
+
else
|
|
647
|
+
freeSpark[nfSpark++] = i;
|
|
648
|
+
}
|
|
649
|
+
function place(i, x, y, z, vx, vy, vz) {
|
|
650
|
+
const j = i * 3;
|
|
651
|
+
pos[j] = x;
|
|
652
|
+
pos[j + 1] = y;
|
|
653
|
+
pos[j + 2] = z;
|
|
654
|
+
vel[j] = vx;
|
|
655
|
+
vel[j + 1] = vy;
|
|
656
|
+
vel[j + 2] = vz;
|
|
657
|
+
age[i] = 0;
|
|
658
|
+
oxy[i] = 0;
|
|
659
|
+
tint[i] = 0;
|
|
660
|
+
tailAcc[i] = 0;
|
|
661
|
+
if (trail && i < nStar)
|
|
662
|
+
trail.reset(i);
|
|
663
|
+
}
|
|
664
|
+
function spark(x, y, z, vx, vy, vz, t0, tau, k = 0) {
|
|
665
|
+
const i = takeSpark();
|
|
666
|
+
if (i < 0)
|
|
667
|
+
return;
|
|
668
|
+
kind[i] = 3;
|
|
669
|
+
place(i, x, y, z, vx, vy, vz);
|
|
670
|
+
T0[i] = t0;
|
|
671
|
+
life[i] = tau;
|
|
672
|
+
drag[i] = (k || SPARK_K) * (0.8 + 0.4 * rng());
|
|
673
|
+
}
|
|
674
|
+
/** A fresh star: every composition flag cleared (slots are pooled). */
|
|
675
|
+
function star(i) {
|
|
676
|
+
kind[i] = 2;
|
|
677
|
+
split[i] = 0;
|
|
678
|
+
crackle[i] = 0;
|
|
679
|
+
strobe[i] = 0;
|
|
680
|
+
rain[i] = 0;
|
|
681
|
+
fishN[i] = 0;
|
|
682
|
+
wiggle[i] = 0;
|
|
683
|
+
flutter[i] = 0;
|
|
684
|
+
sub[i] = 0;
|
|
685
|
+
change[i] = 2;
|
|
686
|
+
tailRate[i] = 0;
|
|
687
|
+
sTau[i] = 0.45;
|
|
688
|
+
sT0[i] = 1900;
|
|
689
|
+
phase[i] = rng() * 6.283;
|
|
690
|
+
}
|
|
691
|
+
// ---- launch: a shell leaves the mortar with sqrt(2 g h) and bursts at its apex ----
|
|
692
|
+
// `at` (a clicked point, sim units): the mortar below it fires a quick-fuse shell that breaks there
|
|
693
|
+
// after CLICK_FUSE seconds, still climbing (v0 = h / t + g t / 2).
|
|
694
|
+
function nextType() {
|
|
695
|
+
const sel = u.uShellType.value;
|
|
696
|
+
const type = sel === TEXT_SEL ? 'peony' : sel > 0 ? SHELL_TYPES[sel - 1] : MIX[mixIdx];
|
|
697
|
+
if (sel === 0)
|
|
698
|
+
mixIdx = (mixIdx + 1 + (rng() < 0.3 ? 1 : 0)) % MIX.length;
|
|
699
|
+
return type;
|
|
700
|
+
}
|
|
701
|
+
// a formation: one salvo of shells whose bursts sit on a shape (line, arc, circle, heart...), all the
|
|
702
|
+
// same composition and colours, fired on one fuse time so they break together
|
|
703
|
+
function salvo(at = null) {
|
|
704
|
+
const shape = FORMATIONS[u.uFormation.value];
|
|
705
|
+
const cnt = Math.max(2, Math.min(24, Math.round(u.uFormationCount.value)));
|
|
706
|
+
const span = 0.7 * u.uFormationSize.value;
|
|
707
|
+
const type = nextType();
|
|
708
|
+
const A = type === 'willow' || type === 'palm' || type === 'crackle' || type === 'brocade' ? 3 : type === 'strobe' ? 4 : pickSlot();
|
|
709
|
+
const B = rng() < 0.4 ? (A < 3 ? (A + 1) % 3 : A) : A;
|
|
710
|
+
const cx = at ? at[0] : u.uSpread.value * (rng() * 2 - 1) * 0.25;
|
|
711
|
+
const cy = at ? at[1] : -0.1 + 0.5 * u.uBurstHeight.value;
|
|
712
|
+
const cz = at ? at[2] : 0;
|
|
713
|
+
const pts = outline(shape, cnt);
|
|
714
|
+
const cm = Math.min(1, 1.9 / Math.sqrt(cnt)) * Math.min(1.2, u.uFormationSize.value + 0.2);
|
|
715
|
+
for (const p of pts)
|
|
716
|
+
launch(false, [cx + p[0] * span, cy + p[1] * span, cz], { type, A, B, fuse: FORM_FUSE, charge: cm });
|
|
717
|
+
}
|
|
718
|
+
// text: the word as one shell, or one shell per letter in rows (words wrap to lines of <= 8 letters), all on
|
|
719
|
+
// one fuse so the text appears at once
|
|
720
|
+
let textKey = '', textSets = null;
|
|
721
|
+
function textFire(at = null) {
|
|
722
|
+
const str = u.uText.value.trim().slice(0, 32), mode = u.uTextMode.value;
|
|
723
|
+
if (!str)
|
|
724
|
+
return false;
|
|
725
|
+
const key = mode + '|' + str;
|
|
726
|
+
if (key !== textKey) {
|
|
727
|
+
textKey = key;
|
|
728
|
+
textSets = mode === 0 ? textPoints(str.toUpperCase(), PROFILE.text.stars, rng, false) : textPoints(str.toUpperCase(), 64, rng, true);
|
|
729
|
+
}
|
|
730
|
+
if (!textSets)
|
|
731
|
+
return false;
|
|
732
|
+
const A = pickSlot(), B = rng() < 0.4 ? (A + 1) % 3 : A;
|
|
733
|
+
const cx = at ? at[0] : 0, cy = at ? at[1] : -0.05 + 0.5 * u.uBurstHeight.value, cz = at ? at[2] : 0;
|
|
734
|
+
const size = u.uTextSize.value;
|
|
735
|
+
if (mode === 0) {
|
|
736
|
+
launch(false, [cx, cy, cz], { type: 'text', A, B, fuse: FORM_FUSE, charge: 2.0 * size, pattern: textSets });
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
// lay the letters out in lines
|
|
740
|
+
const chars = Array.from(str.toUpperCase()), lines = [[]];
|
|
741
|
+
let word = [];
|
|
742
|
+
const flush = () => { const L = lines[lines.length - 1]; if (L.length && L.length + 1 + word.length > 8)
|
|
743
|
+
lines.push([]); const T = lines[lines.length - 1]; if (T.length)
|
|
744
|
+
T.push(-1); T.push(...word); word = []; };
|
|
745
|
+
chars.forEach((ch, k) => { if (ch === ' ')
|
|
746
|
+
flush();
|
|
747
|
+
else
|
|
748
|
+
word.push(k); });
|
|
749
|
+
flush();
|
|
750
|
+
const longest = Math.max(...lines.map((l) => l.length));
|
|
751
|
+
const pitch = Math.min(0.34 * size, 2.3 / Math.max(longest, 1));
|
|
752
|
+
lines.forEach((L, r) => {
|
|
753
|
+
const ly = cy + ((lines.length - 1) / 2 - r) * pitch * 1.5;
|
|
754
|
+
L.forEach((k, q) => {
|
|
755
|
+
if (k < 0 || !textSets[k])
|
|
756
|
+
return;
|
|
757
|
+
launch(false, [cx + (q - (L.length - 1) / 2) * pitch, ly, cz], { type: 'text', A, B, fuse: FORM_FUSE, charge: pitch * 0.8, pattern: textSets[k] });
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
function fire(aimed, at = null) {
|
|
763
|
+
if (u.uShellType.value === TEXT_SEL) {
|
|
764
|
+
if (!textFire(at))
|
|
765
|
+
launch(aimed, at);
|
|
766
|
+
return;
|
|
767
|
+
} // no text (or no canvas): a plain peony
|
|
768
|
+
if (u.uFormation.value > 0)
|
|
769
|
+
salvo(at);
|
|
770
|
+
else
|
|
771
|
+
launch(aimed, at);
|
|
772
|
+
}
|
|
773
|
+
function launch(aimed, at = null, opt = null) {
|
|
774
|
+
const type = opt ? opt.type : nextType();
|
|
775
|
+
const spread = u.uSpread.value;
|
|
776
|
+
let x = spread * (rng() * 2 - 1) * 0.7, z = spread * (rng() - 0.5) * 0.5;
|
|
777
|
+
let yb = -0.1 + 0.5 * u.uBurstHeight.value * (0.8 + 0.4 * rng());
|
|
778
|
+
let vx = 0.02 * gauss(rng), vz = 0.02 * gauss(rng);
|
|
779
|
+
if (aimed) {
|
|
780
|
+
const R = u.uPointerRadius.value;
|
|
781
|
+
x = hp.x + 0.18 * R * gauss(rng);
|
|
782
|
+
z = hp.z + 0.1 * R * gauss(rng);
|
|
783
|
+
yb = Math.min(Math.max(hp.y, -0.6), 1.1) + 0.05 * gauss(rng);
|
|
784
|
+
vx += hv.x * 0.25;
|
|
785
|
+
vz += hv.z * 0.25; // a stroke leans the shells
|
|
786
|
+
}
|
|
787
|
+
if (at) {
|
|
788
|
+
x = at[0];
|
|
789
|
+
z = at[2];
|
|
790
|
+
yb = at[1];
|
|
791
|
+
vx = 0;
|
|
792
|
+
vz = 0;
|
|
793
|
+
}
|
|
794
|
+
yb = Math.max(yb, Y0 + (at ? 0.15 : 0.4));
|
|
795
|
+
const i = takeStar();
|
|
796
|
+
if (i < 0)
|
|
797
|
+
return;
|
|
798
|
+
const fz = opt ? opt.fuse : CLICK_FUSE;
|
|
799
|
+
const v0 = at ? (yb - Y0) / fz + 0.5 * G * fz : Math.sqrt(2 * G * (yb - Y0));
|
|
800
|
+
kind[i] = 1;
|
|
801
|
+
place(i, x, Y0, z, vx, v0, vz);
|
|
802
|
+
shellType[i] = type === 'text' ? 255 : SHELL_TYPES.indexOf(type);
|
|
803
|
+
targetY[i] = yb;
|
|
804
|
+
pattern[i] = opt && opt.pattern ? opt.pattern : null;
|
|
805
|
+
tint[i] = aimed ? 0.45 : 0;
|
|
806
|
+
life[i] = 0;
|
|
807
|
+
drag[i] = 0;
|
|
808
|
+
slotA[i] = 3;
|
|
809
|
+
sz[i] = type === 'palm' ? 1.4 : 0.9;
|
|
810
|
+
tailRate[i] = PROFILE[type].rise;
|
|
811
|
+
forceA[i] = opt ? opt.A : 255;
|
|
812
|
+
forceB[i] = opt ? opt.B : 0;
|
|
813
|
+
chargeMul[i] = opt ? opt.charge : 1;
|
|
814
|
+
}
|
|
815
|
+
function pickSlot() { return Math.floor(rng() * 3); }
|
|
816
|
+
// ---- burst: stars packed on a Fibonacci sphere (or a great circle), plus flash and smoke ----
|
|
817
|
+
function burst(s) {
|
|
818
|
+
const type = shellType[s] === 255 ? 'text' : SHELL_TYPES[shellType[s]];
|
|
819
|
+
const P = PROFILE[type];
|
|
820
|
+
const j = s * 3;
|
|
821
|
+
const x = pos[j], y = pos[j + 1], z = pos[j + 2];
|
|
822
|
+
const svx = vel[j], svy = vel[j + 1], svz = vel[j + 2];
|
|
823
|
+
const aimed = tint[s];
|
|
824
|
+
const charge = u.uBurstSize.value * (aimed > 0 ? 0.7 + 0.3 * u.uPointerStrength.value : 1) * chargeMul[s];
|
|
825
|
+
const burn = u.uBurnTime.value;
|
|
826
|
+
const gold = type === 'willow' || type === 'palm' || type === 'crackle' || type === 'brocade' || type === 'fish' || type === 'kamuro' || type === 'horsetail';
|
|
827
|
+
let A = gold ? 3 : type === 'strobe' ? 4 : pickSlot();
|
|
828
|
+
let B = A;
|
|
829
|
+
if (type === 'peony' && rng() < 0.5)
|
|
830
|
+
B = (A + 1 + Math.floor(rng() * 2)) % 3;
|
|
831
|
+
if (type === 'chrysanthemum' && rng() < 0.3)
|
|
832
|
+
B = 3;
|
|
833
|
+
if (type === 'fallingLeaves') {
|
|
834
|
+
A = rng() < 0.6 ? 3 : pickSlot();
|
|
835
|
+
B = A;
|
|
836
|
+
}
|
|
837
|
+
if (type === 'saturn' || type === 'multiBreak')
|
|
838
|
+
B = (A + 1 + Math.floor(rng() * 2)) % 3;
|
|
839
|
+
if (type === 'pistil')
|
|
840
|
+
B = rng() < 0.5 ? 4 : (A + 1) % 3;
|
|
841
|
+
if (forceA[s] !== 255) {
|
|
842
|
+
A = forceA[s];
|
|
843
|
+
B = forceB[s];
|
|
844
|
+
}
|
|
845
|
+
const pat = pattern[s];
|
|
846
|
+
pattern[s] = null;
|
|
847
|
+
const shaped = type === 'heart' || type === 'shapedStar' || type === 'text';
|
|
848
|
+
const m = type === 'text' && pat ? pat.length : Math.max(type === 'multiBreak' ? 4 : 12, Math.round(P.stars * Math.min(1, 0.35 + 0.65 * chargeMul[s])));
|
|
849
|
+
// the burst's plane: a shaped shell stands upright facing the front; a ring gets a random tilt
|
|
850
|
+
const th = rng() * Math.PI * 2, tl = shaped ? 0.25 * rng() : (0.3 + 0.7 * rng()) * 1.05;
|
|
851
|
+
const nx = Math.sin(tl) * Math.cos(th), ny = shaped ? Math.sin(tl) * Math.sin(th) : Math.cos(tl) * 0.6, nz = shaped ? 1 : Math.sin(tl) * Math.sin(th) * 0.5 + 0.8;
|
|
852
|
+
const nl = Math.hypot(nx, ny, nz);
|
|
853
|
+
const n0 = [nx / nl, ny / nl, nz / nl];
|
|
854
|
+
const e1 = shaped ? [0, 1, 0] : Math.abs(n0[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
|
|
855
|
+
// u1 = normalize(e1 x n0), u2 = n0 x u1 (shaped: u1 points right, u2 up, so the shape stands upright)
|
|
856
|
+
let u1 = [e1[1] * n0[2] - e1[2] * n0[1], e1[2] * n0[0] - e1[0] * n0[2], e1[0] * n0[1] - e1[1] * n0[0]];
|
|
857
|
+
const ul = Math.hypot(u1[0], u1[1], u1[2]);
|
|
858
|
+
u1 = [u1[0] / ul, u1[1] / ul, u1[2] / ul];
|
|
859
|
+
const u2 = [n0[1] * u1[2] - n0[2] * u1[1], n0[2] * u1[0] - n0[0] * u1[2], n0[0] * u1[1] - n0[1] * u1[0]];
|
|
860
|
+
const rot = shaped ? 0 : rng() * Math.PI * 2;
|
|
861
|
+
// one layer of stars: 'sphere' (Fibonacci), 'ring' (great circle), 'pattern' (2D points, speed ~ radius,
|
|
862
|
+
// so the shape grows self-similar under one drag law) or 'cone' (thrown upward: a horsetail)
|
|
863
|
+
function layer(cnt, mode, spMul, a, b, pts = null, szMul = 1) {
|
|
864
|
+
for (let k = 0; k < cnt; k++) {
|
|
865
|
+
const i = takeStar();
|
|
866
|
+
if (i < 0)
|
|
867
|
+
break;
|
|
868
|
+
let dx, dy, dz, rad = 1;
|
|
869
|
+
if (mode === 'ring') {
|
|
870
|
+
const an = (k / cnt) * Math.PI * 2 + rot + 0.02 * gauss(rng);
|
|
871
|
+
const ca = Math.cos(an), sa = Math.sin(an);
|
|
872
|
+
dx = u1[0] * ca + u2[0] * sa;
|
|
873
|
+
dy = u1[1] * ca + u2[1] * sa;
|
|
874
|
+
dz = u1[2] * ca + u2[2] * sa;
|
|
875
|
+
}
|
|
876
|
+
else if (mode === 'pattern') {
|
|
877
|
+
const p = pts[k % pts.length];
|
|
878
|
+
rad = Math.hypot(p[0], p[1]) || 1e-3;
|
|
879
|
+
const ex = p[0] / rad, ey = p[1] / rad;
|
|
880
|
+
dx = u1[0] * ex + u2[0] * ey;
|
|
881
|
+
dy = u1[1] * ex + u2[1] * ey;
|
|
882
|
+
dz = u1[2] * ex + u2[2] * ey;
|
|
883
|
+
}
|
|
884
|
+
else if (mode === 'cone') {
|
|
885
|
+
const an = rng() * Math.PI * 2, c = 1 - rng() * 0.18;
|
|
886
|
+
const sn = Math.sqrt(1 - c * c);
|
|
887
|
+
dx = sn * Math.cos(an);
|
|
888
|
+
dy = c;
|
|
889
|
+
dz = sn * Math.sin(an);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
const yy = 1 - (2 * (k + 0.5)) / cnt;
|
|
893
|
+
const r = Math.sqrt(1 - yy * yy);
|
|
894
|
+
const an = k * 2.399963 + rot;
|
|
895
|
+
dx = r * Math.cos(an) + 0.06 * gauss(rng);
|
|
896
|
+
dy = yy + 0.06 * gauss(rng);
|
|
897
|
+
dz = r * Math.sin(an) + 0.06 * gauss(rng);
|
|
898
|
+
if (type === 'palm')
|
|
899
|
+
dy = dy * 0.6 + 0.35; // fronds open upward, then droop
|
|
900
|
+
const dl = Math.hypot(dx, dy, dz) || 1;
|
|
901
|
+
dx /= dl;
|
|
902
|
+
dy /= dl;
|
|
903
|
+
dz /= dl;
|
|
904
|
+
}
|
|
905
|
+
const sp = P.v * charge * spMul * rad * (mode === 'pattern' ? 0.99 + 0.02 * rng() : 0.93 + 0.14 * rng());
|
|
906
|
+
const keep = mode === 'pattern' ? 0.3 : 1; // a pattern sheds the shell's motion so it reads upright
|
|
907
|
+
star(i);
|
|
908
|
+
place(i, x, y, z, svx * keep + dx * sp, svy * keep + dy * sp, svz * keep + dz * sp);
|
|
909
|
+
life[i] = P.burn * burn * (0.85 + 0.3 * rng());
|
|
910
|
+
// pattern: quadratic drag gives x(t) = ln(1 + k v t) / k, so k ~ 1 / v keeps k v the same for every star and
|
|
911
|
+
// the reach proportional to v — the pattern grows as an exact scaled copy instead of bunching at its edges
|
|
912
|
+
drag[i] = mode === 'pattern' ? P.k / Math.max(rad, 0.15) : P.k * (0.88 + 0.24 * rng());
|
|
913
|
+
slotA[i] = a;
|
|
914
|
+
slotB[i] = b;
|
|
915
|
+
change[i] = b !== a ? 0.45 + 0.1 * rng() : 2;
|
|
916
|
+
tailRate[i] = P.tail * (0.8 + 0.4 * rng());
|
|
917
|
+
split[i] = type === 'crossette' ? 1 : 0;
|
|
918
|
+
crackle[i] = type === 'crackle' ? 4 + Math.floor(rng() * 4) : 0;
|
|
919
|
+
strobe[i] = type === 'strobe' ? 6 + 7 * rng() : 0;
|
|
920
|
+
rain[i] = type === 'timeRain' ? 4 + Math.floor(rng() * 3) : 0;
|
|
921
|
+
fishN[i] = type === 'fish' ? 2 : 0;
|
|
922
|
+
flutter[i] = type === 'fallingLeaves' ? 3 + 3 * rng() : 0;
|
|
923
|
+
sub[i] = type === 'multiBreak' ? 1 : 0;
|
|
924
|
+
if (type === 'brocade') {
|
|
925
|
+
sTau[i] = 1.0 * Math.sqrt(burn);
|
|
926
|
+
sT0[i] = 1950;
|
|
927
|
+
}
|
|
928
|
+
if (type === 'willow') {
|
|
929
|
+
sTau[i] = 1.5 * Math.sqrt(burn);
|
|
930
|
+
sT0[i] = 1800;
|
|
931
|
+
}
|
|
932
|
+
if (type === 'kamuro') {
|
|
933
|
+
sTau[i] = 2.0 * Math.sqrt(burn);
|
|
934
|
+
sT0[i] = 1760;
|
|
935
|
+
}
|
|
936
|
+
if (type === 'horsetail') {
|
|
937
|
+
sTau[i] = 1.1 * Math.sqrt(burn);
|
|
938
|
+
sT0[i] = 1850;
|
|
939
|
+
}
|
|
940
|
+
tint[i] = aimed;
|
|
941
|
+
sz[i] = P.sz * (szMul || 1);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (type === 'ring')
|
|
945
|
+
layer(m, 'ring', 1, A, B);
|
|
946
|
+
else if (type === 'text' && pat)
|
|
947
|
+
layer(m, 'pattern', 1, A, B, pat);
|
|
948
|
+
else if (shaped)
|
|
949
|
+
layer(m, 'pattern', 1, A, B, outline(type, m));
|
|
950
|
+
else if (type === 'saturn') {
|
|
951
|
+
layer(Math.round(m * 0.6), 'sphere', 0.55, A, A);
|
|
952
|
+
layer(Math.round(m * 0.55), 'ring', 1.25, B, B);
|
|
953
|
+
}
|
|
954
|
+
else if (type === 'pistil') {
|
|
955
|
+
layer(m, 'sphere', 1, A, A);
|
|
956
|
+
layer(Math.round(m * 0.3), 'sphere', 0.38, B, B, null, 1.25);
|
|
957
|
+
}
|
|
958
|
+
else if (type === 'horsetail')
|
|
959
|
+
layer(m, 'cone', 1, A, B);
|
|
960
|
+
else if (type === 'multiBreak') {
|
|
961
|
+
layer(m, 'sphere', 1, A, B);
|
|
962
|
+
layer(30, 'sphere', 0.45, A, A);
|
|
963
|
+
}
|
|
964
|
+
else
|
|
965
|
+
layer(m, 'sphere', 1, A, B);
|
|
966
|
+
// flash light
|
|
967
|
+
const b = bHead;
|
|
968
|
+
bHead = (bHead + 1) % NB;
|
|
969
|
+
bPos[b * 3] = x;
|
|
970
|
+
bPos[b * 3 + 1] = y;
|
|
971
|
+
bPos[b * 3 + 2] = z;
|
|
972
|
+
bAge[b] = 0;
|
|
973
|
+
slotColor(A, 1900, tmp);
|
|
974
|
+
bCol[b * 3] = 0.45 + 0.55 * tmp[0];
|
|
975
|
+
bCol[b * 3 + 1] = 0.45 + 0.55 * tmp[1];
|
|
976
|
+
bCol[b * 3 + 2] = 0.45 + 0.55 * tmp[2];
|
|
977
|
+
// the shell leaves a puff of smoke where it broke
|
|
978
|
+
const sm = sHead;
|
|
979
|
+
sHead = (sHead + 1) % NS;
|
|
980
|
+
sPos[sm * 3] = x;
|
|
981
|
+
sPos[sm * 3 + 1] = y;
|
|
982
|
+
sPos[sm * 3 + 2] = z;
|
|
983
|
+
sVel[sm * 3] = svx * 0.3;
|
|
984
|
+
sVel[sm * 3 + 1] = svy * 0.3;
|
|
985
|
+
sVel[sm * 3 + 2] = svz * 0.3;
|
|
986
|
+
sAge[sm] = 0;
|
|
987
|
+
sScale[sm] = (0.7 + 0.5 * rng()) * Math.min(Math.max(charge, 0.4), 1.6) * (type === 'palm' || type === 'crossette' ? 0.75 : 1);
|
|
988
|
+
release(s);
|
|
989
|
+
}
|
|
990
|
+
// linear colour of a slot: 0..2 the config colours, 3 gold (charcoal at temperature T), 4 magnesium white
|
|
991
|
+
const spk = [0, 0, 0];
|
|
992
|
+
function slotColor(slot, T, out) {
|
|
993
|
+
if (slot === 0) {
|
|
994
|
+
out[0] = u.uColor.value.r;
|
|
995
|
+
out[1] = u.uColor.value.g;
|
|
996
|
+
out[2] = u.uColor.value.b;
|
|
997
|
+
}
|
|
998
|
+
else if (slot === 1) {
|
|
999
|
+
out[0] = u.uSecondColor.value.r;
|
|
1000
|
+
out[1] = u.uSecondColor.value.g;
|
|
1001
|
+
out[2] = u.uSecondColor.value.b;
|
|
1002
|
+
}
|
|
1003
|
+
else if (slot === 2) {
|
|
1004
|
+
out[0] = u.uAccentColor.value.r;
|
|
1005
|
+
out[1] = u.uAccentColor.value.g;
|
|
1006
|
+
out[2] = u.uAccentColor.value.b;
|
|
1007
|
+
}
|
|
1008
|
+
else if (slot === 3) {
|
|
1009
|
+
bbLut(T, out);
|
|
1010
|
+
const sc = u.uSparkColor.value, mm = Math.max(sc.r, sc.g, sc.b, 1e-3);
|
|
1011
|
+
// the tint sets the hue (a sodium, titanium or iron mix); temperature still sets how it cools
|
|
1012
|
+
const w = 0.65, lum = 0.4 + 0.6 * out[1]; // green channel falls first as it cools
|
|
1013
|
+
out[0] += (sc.r / mm * lum - out[0]) * w;
|
|
1014
|
+
out[1] += (sc.g / mm * lum - out[1]) * w;
|
|
1015
|
+
out[2] += (sc.b / mm * lum - out[2]) * w;
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
out[0] = 1;
|
|
1019
|
+
out[1] = 0.96;
|
|
1020
|
+
out[2] = 0.9;
|
|
1021
|
+
}
|
|
1022
|
+
return out;
|
|
1023
|
+
}
|
|
1024
|
+
function step(h) {
|
|
1025
|
+
clock += h;
|
|
1026
|
+
const mode = u.uPointerMode.value;
|
|
1027
|
+
const S = u.uPointerStrength.value, act = u.uPointerActive.value, Rr = u.uPointerRadius.value;
|
|
1028
|
+
hp.copy(u.uPointer.value);
|
|
1029
|
+
hv.copy(u.uPointerVel.value);
|
|
1030
|
+
const aimOn = mode === 0 && S > 0.01 && act > 0.05;
|
|
1031
|
+
const windOn = mode === 1 && S > 0.01 && act > 0.01;
|
|
1032
|
+
const reOn = mode === 2 && S > 0.01 && act > 0.01;
|
|
1033
|
+
const stroke = hv.length() * act;
|
|
1034
|
+
strokeRate += (stroke - strokeRate) * Math.min(1, h * 4);
|
|
1035
|
+
// ---- the show clock: shells per second (a faster aiming stroke fires more) ----
|
|
1036
|
+
const rate = u.uLaunchRate.value * (aimOn ? 1 + 1.5 * S * strokeRate / (strokeRate + 0.5) : 1);
|
|
1037
|
+
// randomFire off: the show clock stops (aimed shells too) and only clicks fire
|
|
1038
|
+
// a formation fires whole salvos, one every ~3 shells' worth of time
|
|
1039
|
+
if (u.uRandomFire.value > 0.5)
|
|
1040
|
+
launchAcc += rate * h * (u.uFormation.value > 0 || u.uShellType.value === TEXT_SEL ? 1 / 3 : 1);
|
|
1041
|
+
while (launchAcc >= 1) {
|
|
1042
|
+
launchAcc -= 1;
|
|
1043
|
+
fire(aimOn);
|
|
1044
|
+
}
|
|
1045
|
+
// ---- air: the wind plus the cursor's flow (moving-body dipole + trailing wake) ----
|
|
1046
|
+
const wx = u.uWind.value * 0.12;
|
|
1047
|
+
const RS = 0.18 * Rr;
|
|
1048
|
+
const AIR = 1.4 * S * act;
|
|
1049
|
+
const bvx = hv.x * AIR, bvy = hv.y * AIR, bvz = hv.z * AIR;
|
|
1050
|
+
const bsp = Math.hypot(bvx, bvy, bvz), bn = bsp > 1e-4 ? 1 / bsp : 0;
|
|
1051
|
+
const gust = windOn && bsp > 0.02;
|
|
1052
|
+
const reach = 0.3 * Rr, reGain = reOn ? S * act * Math.min(1, stroke / (stroke + 0.35)) * 2.2 : 0;
|
|
1053
|
+
for (let i = 0; i < n; i++) {
|
|
1054
|
+
const kd = kind[i];
|
|
1055
|
+
if (kd === 0)
|
|
1056
|
+
continue;
|
|
1057
|
+
const j = i * 3;
|
|
1058
|
+
let x = pos[j], y = pos[j + 1], z = pos[j + 2];
|
|
1059
|
+
let vx = vel[j], vy = vel[j + 1], vz = vel[j + 2];
|
|
1060
|
+
age[i] += h;
|
|
1061
|
+
let ax = wx, ay = 0, az = 0, wake = 0;
|
|
1062
|
+
if (gust) {
|
|
1063
|
+
const qx = x - hp.x, qy = y - hp.y, qz = z - hp.z;
|
|
1064
|
+
const r2 = qx * qx + qy * qy + qz * qz;
|
|
1065
|
+
if (r2 < 64 * RS * RS) {
|
|
1066
|
+
const r = Math.sqrt(r2) + 1e-6;
|
|
1067
|
+
if (r > RS) {
|
|
1068
|
+
const k3 = (RS * RS * RS) / (2 * r2 * r);
|
|
1069
|
+
const vr = (bvx * qx + bvy * qy + bvz * qz) / r;
|
|
1070
|
+
ax += k3 * (3 * vr * qx / r - bvx);
|
|
1071
|
+
ay += k3 * (3 * vr * qy / r - bvy);
|
|
1072
|
+
az += k3 * (3 * vr * qz / r - bvz);
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
ax += bvx;
|
|
1076
|
+
ay += bvy;
|
|
1077
|
+
az += bvz;
|
|
1078
|
+
}
|
|
1079
|
+
const behind = -(qx * bvx + qy * bvy + qz * bvz) * bn;
|
|
1080
|
+
if (behind > 0) {
|
|
1081
|
+
const wk = Math.exp(-(r2 - behind * behind) / (RS * RS * 1.5) - behind / (8 * RS));
|
|
1082
|
+
ax += bvx * wk * 0.7;
|
|
1083
|
+
ay += bvy * wk * 0.7;
|
|
1084
|
+
az += bvz * wk * 0.7;
|
|
1085
|
+
wake = wk;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
let feed = 0;
|
|
1090
|
+
if (reGain > 0 && kd !== 1) {
|
|
1091
|
+
const qx = x - hp.x, qy = y - hp.y, qz = z - hp.z;
|
|
1092
|
+
const d2 = (qx * qx + qy * qy + qz * qz) / (reach * reach);
|
|
1093
|
+
if (d2 < 4)
|
|
1094
|
+
feed = reGain * Math.max(0, 1 - d2 * 0.25) * Math.max(0, 1 - d2 * 0.25);
|
|
1095
|
+
}
|
|
1096
|
+
const o = Math.min(2, wake * S * 1.5 + feed);
|
|
1097
|
+
oxy[i] += (o - oxy[i]) * Math.min(1, h * (o > oxy[i] ? 10 : 2.5));
|
|
1098
|
+
if (kd === 1) {
|
|
1099
|
+
// shell: drag-free ballistic flight; the time fuse fires at the apex
|
|
1100
|
+
vy -= G * h;
|
|
1101
|
+
vx += (ax - wx) * h * 0.3;
|
|
1102
|
+
vz += az * h * 0.3;
|
|
1103
|
+
x += vx * h;
|
|
1104
|
+
y += vy * h;
|
|
1105
|
+
z += vz * h;
|
|
1106
|
+
tailAcc[i] += tailRate[i] * h;
|
|
1107
|
+
while (tailAcc[i] >= 1) {
|
|
1108
|
+
tailAcc[i] -= 1;
|
|
1109
|
+
spark(x, y, z, vx * 0.1 + 0.03 * gauss(rng), vy * 0.1 + 0.02 * gauss(rng), vz * 0.1 + 0.03 * gauss(rng), 1800, 0.32);
|
|
1110
|
+
}
|
|
1111
|
+
pos[j] = x;
|
|
1112
|
+
pos[j + 1] = y;
|
|
1113
|
+
pos[j + 2] = z;
|
|
1114
|
+
vel[j] = vx;
|
|
1115
|
+
vel[j + 1] = vy;
|
|
1116
|
+
vel[j + 2] = vz;
|
|
1117
|
+
if (vy <= 0 || y >= targetY[i])
|
|
1118
|
+
burst(i);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (kd === 2 && wiggle[i] > 0) {
|
|
1122
|
+
// fish: a little rocket that swims — thrust along its heading plus a lateral wriggle
|
|
1123
|
+
const sp = Math.hypot(vx, vy, vz) || 1;
|
|
1124
|
+
const w = wiggle[i] * Math.sin(age[i] * 13 + phase[i]);
|
|
1125
|
+
vx += (vx / sp * 0.35 - vy / sp * w) * h;
|
|
1126
|
+
vy += (vy / sp * 0.35 + vx / sp * w + G * 0.6) * h;
|
|
1127
|
+
vz += vz / sp * 0.35 * h;
|
|
1128
|
+
}
|
|
1129
|
+
else if (kd === 2 && flutter[i] > 0) {
|
|
1130
|
+
// falling leaves: a flat piece rocks side to side as it sinks (lift ~ sin of its swing)
|
|
1131
|
+
ax += 0.22 * Math.sin(age[i] * flutter[i] + phase[i]);
|
|
1132
|
+
}
|
|
1133
|
+
// quadratic drag relative to the air, semi-implicit; then gravity
|
|
1134
|
+
const k = drag[i];
|
|
1135
|
+
const rx = vx - ax, ry = vy - ay, rz = vz - az;
|
|
1136
|
+
const damp = 1 / (1 + k * Math.sqrt(rx * rx + ry * ry + rz * rz) * h);
|
|
1137
|
+
vx = ax + rx * damp;
|
|
1138
|
+
vy = ay + ry * damp - G * h;
|
|
1139
|
+
vz = az + rz * damp;
|
|
1140
|
+
x += vx * h;
|
|
1141
|
+
y += vy * h;
|
|
1142
|
+
z += vz * h;
|
|
1143
|
+
pos[j] = x;
|
|
1144
|
+
pos[j + 1] = y;
|
|
1145
|
+
pos[j + 2] = z;
|
|
1146
|
+
vel[j] = vx;
|
|
1147
|
+
vel[j + 1] = vy;
|
|
1148
|
+
vel[j + 2] = vz;
|
|
1149
|
+
if (kd === 3) {
|
|
1150
|
+
if (oxy[i] > 0.05)
|
|
1151
|
+
age[i] -= h * Math.min(oxy[i], 1) * 0.8; // fresh air keeps it burning
|
|
1152
|
+
if (age[i] > life[i] * 2 || y < Y0)
|
|
1153
|
+
release(i);
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
if (kd === 4) {
|
|
1157
|
+
if (age[i] > life[i] + 0.07)
|
|
1158
|
+
release(i);
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
// star
|
|
1162
|
+
if (oxy[i] > 0.05)
|
|
1163
|
+
age[i] -= h * Math.min(oxy[i], 1.2) * 0.6;
|
|
1164
|
+
if (tailRate[i] > 0) {
|
|
1165
|
+
tailAcc[i] += tailRate[i] * h * (1 + oxy[i]);
|
|
1166
|
+
while (tailAcc[i] >= 1) {
|
|
1167
|
+
tailAcc[i] -= 1;
|
|
1168
|
+
spark(x, y, z, vx * 0.25 + 0.025 * gauss(rng), vy * 0.25 + 0.025 * gauss(rng), vz * 0.25 + 0.025 * gauss(rng), sT0[i], sTau[i]);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (age[i] >= life[i] || y < Y0) {
|
|
1172
|
+
if (split[i] && y > Y0) {
|
|
1173
|
+
// crossette: the comet splits into a cross perpendicular to its flight
|
|
1174
|
+
const sp = Math.hypot(vx, vy, vz) || 1;
|
|
1175
|
+
const fx = vx / sp, fy = vy / sp, fz = vz / sp;
|
|
1176
|
+
let px = -fy, py = fx, pz = 0;
|
|
1177
|
+
const pl = Math.hypot(px, py) || 1;
|
|
1178
|
+
px /= pl;
|
|
1179
|
+
py /= pl;
|
|
1180
|
+
const qx2 = fy * pz - fz * py, qy2 = fz * px - fx * pz, qz2 = fx * py - fy * px;
|
|
1181
|
+
const dirs = [[px, py, pz], [-px, -py, -pz], [qx2, qy2, qz2], [-qx2, -qy2, -qz2]];
|
|
1182
|
+
for (let d = 0; d < 4; d++) {
|
|
1183
|
+
const c2 = takeStar();
|
|
1184
|
+
if (c2 < 0)
|
|
1185
|
+
break;
|
|
1186
|
+
const s2 = 0.42 * u.uBurstSize.value;
|
|
1187
|
+
const pa = slotA[i], pb = slotB[i], pt = tint[i];
|
|
1188
|
+
star(c2);
|
|
1189
|
+
place(c2, x, y, z, vx * 0.5 + dirs[d][0] * s2, vy * 0.5 + dirs[d][1] * s2, vz * 0.5 + dirs[d][2] * s2);
|
|
1190
|
+
life[c2] = 1.1 * u.uBurnTime.value * (0.9 + 0.2 * rng());
|
|
1191
|
+
drag[c2] = 2.4;
|
|
1192
|
+
slotA[c2] = pa;
|
|
1193
|
+
slotB[c2] = pb;
|
|
1194
|
+
tailRate[c2] = 55;
|
|
1195
|
+
tint[c2] = pt;
|
|
1196
|
+
sz[c2] = 1.0;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (sub[i] && y > Y0) {
|
|
1200
|
+
// multi-break: the comet breaks again into a small peony in the second colour
|
|
1201
|
+
const sb = slotB[i], pt = tint[i];
|
|
1202
|
+
for (let d = 0; d < 34; d++) {
|
|
1203
|
+
const c2 = takeStar();
|
|
1204
|
+
if (c2 < 0)
|
|
1205
|
+
break;
|
|
1206
|
+
const yy = 1 - (2 * (d + 0.5)) / 34, r = Math.sqrt(1 - yy * yy), an = d * 2.399963;
|
|
1207
|
+
const s2 = 0.62 * u.uBurstSize.value * (0.93 + 0.14 * rng());
|
|
1208
|
+
star(c2);
|
|
1209
|
+
place(c2, x, y, z, vx * 0.4 + r * Math.cos(an) * s2, vy * 0.4 + yy * s2, vz * 0.4 + r * Math.sin(an) * s2);
|
|
1210
|
+
life[c2] = 1.3 * u.uBurnTime.value * (0.85 + 0.3 * rng());
|
|
1211
|
+
drag[c2] = 2.8;
|
|
1212
|
+
slotA[c2] = sb;
|
|
1213
|
+
slotB[c2] = sb;
|
|
1214
|
+
tint[c2] = pt;
|
|
1215
|
+
sz[c2] = 0.95;
|
|
1216
|
+
}
|
|
1217
|
+
const b2 = bHead;
|
|
1218
|
+
bHead = (bHead + 1) % NB;
|
|
1219
|
+
bPos[b2 * 3] = x;
|
|
1220
|
+
bPos[b2 * 3 + 1] = y;
|
|
1221
|
+
bPos[b2 * 3 + 2] = z;
|
|
1222
|
+
bAge[b2] = 0;
|
|
1223
|
+
slotColor(sb, 1900, tmp);
|
|
1224
|
+
bCol[b2 * 3] = 0.45 + 0.55 * tmp[0];
|
|
1225
|
+
bCol[b2 * 3 + 1] = 0.45 + 0.55 * tmp[1];
|
|
1226
|
+
bCol[b2 * 3 + 2] = 0.45 + 0.55 * tmp[2];
|
|
1227
|
+
}
|
|
1228
|
+
if (rain[i] && y > Y0) {
|
|
1229
|
+
// time rain: the star burns out into slow embers that sink and glow for seconds
|
|
1230
|
+
for (let d = 0; d < rain[i]; d++)
|
|
1231
|
+
spark(x, y, z, vx * 0.3 + 0.04 * gauss(rng), vy * 0.3 + 0.04 * gauss(rng), vz * 0.3 + 0.04 * gauss(rng), 1850, 2.2 * u.uBurnTime.value, 18);
|
|
1232
|
+
}
|
|
1233
|
+
if (fishN[i] && y > Y0) {
|
|
1234
|
+
// fish: each star releases little swimmers that wriggle off in their own directions
|
|
1235
|
+
for (let d = 0; d < fishN[i]; d++) {
|
|
1236
|
+
const c2 = takeStar();
|
|
1237
|
+
if (c2 < 0)
|
|
1238
|
+
break;
|
|
1239
|
+
const pa = slotA[i], pt = tint[i];
|
|
1240
|
+
let dx = gauss(rng), dy = gauss(rng), dz = gauss(rng);
|
|
1241
|
+
const dl = Math.hypot(dx, dy, dz) || 1;
|
|
1242
|
+
star(c2);
|
|
1243
|
+
place(c2, x, y, z, vx * 0.3 + dx / dl * 0.32, vy * 0.3 + dy / dl * 0.32, vz * 0.3 + dz / dl * 0.32);
|
|
1244
|
+
life[c2] = 1.2 * u.uBurnTime.value * (0.8 + 0.4 * rng());
|
|
1245
|
+
drag[c2] = 1.6;
|
|
1246
|
+
slotA[c2] = pa;
|
|
1247
|
+
slotB[c2] = pa;
|
|
1248
|
+
wiggle[c2] = 2.2 + rng();
|
|
1249
|
+
tailRate[c2] = 28;
|
|
1250
|
+
sTau[c2] = 0.3;
|
|
1251
|
+
tint[c2] = pt;
|
|
1252
|
+
sz[c2] = 0.8;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (crackle[i] && y > Y0) {
|
|
1256
|
+
// dragon eggs: the glitter star breaks into microstars that pop a moment later
|
|
1257
|
+
for (let d = 0; d < crackle[i]; d++) {
|
|
1258
|
+
const c2 = takeStar();
|
|
1259
|
+
if (c2 < 0)
|
|
1260
|
+
break;
|
|
1261
|
+
const pt = tint[i];
|
|
1262
|
+
kind[c2] = 4;
|
|
1263
|
+
place(c2, x, y, z, vx * 0.6 + 0.09 * gauss(rng), vy * 0.6 + 0.09 * gauss(rng), vz * 0.6 + 0.09 * gauss(rng));
|
|
1264
|
+
life[c2] = 0.05 + 0.6 * rng();
|
|
1265
|
+
drag[c2] = 6;
|
|
1266
|
+
slotA[c2] = 4;
|
|
1267
|
+
tint[c2] = pt;
|
|
1268
|
+
sz[c2] = 1.1;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
release(i);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// ---- smoke: drifts with the air, buoyant, spreads ~ sqrt(t) ----
|
|
1275
|
+
for (let s = 0; s < NS; s++) {
|
|
1276
|
+
if (sAge[s] > 30)
|
|
1277
|
+
continue;
|
|
1278
|
+
sAge[s] += h;
|
|
1279
|
+
const j = s * 3;
|
|
1280
|
+
let ax = wx, ay = 0.008, az = 0;
|
|
1281
|
+
if (gust) {
|
|
1282
|
+
const qx = sPos[j] - hp.x, qy = sPos[j + 1] - hp.y, qz = sPos[j + 2] - hp.z;
|
|
1283
|
+
const r2 = qx * qx + qy * qy + qz * qz, R2 = 4 * RS * RS + 0.04;
|
|
1284
|
+
const f = Math.exp(-r2 / R2);
|
|
1285
|
+
ax += bvx * f * 0.6;
|
|
1286
|
+
ay += bvy * f * 0.6;
|
|
1287
|
+
az += bvz * f * 0.6;
|
|
1288
|
+
}
|
|
1289
|
+
const kk = Math.min(1, h * 1.5);
|
|
1290
|
+
sVel[j] += (ax - sVel[j]) * kk;
|
|
1291
|
+
sVel[j + 1] += (ay - sVel[j + 1]) * kk;
|
|
1292
|
+
sVel[j + 2] += (az - sVel[j + 2]) * kk;
|
|
1293
|
+
sPos[j] += sVel[j] * h;
|
|
1294
|
+
sPos[j + 1] += sVel[j + 1] * h;
|
|
1295
|
+
sPos[j + 2] += sVel[j + 2] * h;
|
|
1296
|
+
}
|
|
1297
|
+
for (let b = 0; b < NB; b++)
|
|
1298
|
+
if (bAge[b] < 1e8)
|
|
1299
|
+
bAge[b] += h;
|
|
1300
|
+
}
|
|
1301
|
+
// ---- warm-up: the show is under way when it appears ----
|
|
1302
|
+
const stepper = createFixedStep(H_STEP, 8);
|
|
1303
|
+
for (let s = 0; s < Math.round(4.2 / H_STEP); s++)
|
|
1304
|
+
step(H_STEP);
|
|
1305
|
+
// ---- geometry ----
|
|
1306
|
+
const geo = new THREE.BufferGeometry();
|
|
1307
|
+
const posAttr = new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage);
|
|
1308
|
+
const colAttr = new THREE.BufferAttribute(aCol, 3).setUsage(THREE.DynamicDrawUsage);
|
|
1309
|
+
const sAttr = new THREE.BufferAttribute(aS, 1).setUsage(THREE.DynamicDrawUsage);
|
|
1310
|
+
geo.setAttribute('position', posAttr);
|
|
1311
|
+
geo.setAttribute('aCol', colAttr);
|
|
1312
|
+
geo.setAttribute('aS', sAttr);
|
|
1313
|
+
const mat = new THREE.ShaderMaterial({
|
|
1314
|
+
uniforms: u, vertexShader: starVertex, fragmentShader: starFragment,
|
|
1315
|
+
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
|
|
1316
|
+
});
|
|
1317
|
+
const stars = new THREE.Points(geo, mat);
|
|
1318
|
+
stars.frustumCulled = false;
|
|
1319
|
+
stars.renderOrder = 2;
|
|
1320
|
+
attachViewH(stars, u);
|
|
1321
|
+
const fpos = new Float32Array(NB * 3), fF = new Float32Array(NB * 4);
|
|
1322
|
+
const fgeo = new THREE.BufferGeometry();
|
|
1323
|
+
const fpAttr = new THREE.BufferAttribute(fpos, 3).setUsage(THREE.DynamicDrawUsage);
|
|
1324
|
+
const fFAttr = new THREE.BufferAttribute(fF, 4).setUsage(THREE.DynamicDrawUsage);
|
|
1325
|
+
fgeo.setAttribute('position', fpAttr);
|
|
1326
|
+
fgeo.setAttribute('aF', fFAttr);
|
|
1327
|
+
const fmat = new THREE.ShaderMaterial({
|
|
1328
|
+
uniforms: u, vertexShader: flashVertex, fragmentShader: flashFragment,
|
|
1329
|
+
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
|
|
1330
|
+
});
|
|
1331
|
+
const flashes = new THREE.Points(fgeo, fmat);
|
|
1332
|
+
flashes.frustumCulled = false;
|
|
1333
|
+
flashes.renderOrder = 2;
|
|
1334
|
+
// smoke quads: the template corners stay within ±0.5 (selection bounds read them)
|
|
1335
|
+
const sgeo = new THREE.InstancedBufferGeometry();
|
|
1336
|
+
sgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
|
|
1337
|
+
sgeo.setIndex([0, 1, 2, 0, 2, 3]);
|
|
1338
|
+
const iP = new Float32Array(NS * 3), iC = new Float32Array(NS * 4), iR = new Float32Array(NS * 2);
|
|
1339
|
+
const iPA = new THREE.InstancedBufferAttribute(iP, 3).setUsage(THREE.DynamicDrawUsage);
|
|
1340
|
+
const iCA = new THREE.InstancedBufferAttribute(iC, 4).setUsage(THREE.DynamicDrawUsage);
|
|
1341
|
+
const iRA = new THREE.InstancedBufferAttribute(iR, 2).setUsage(THREE.DynamicDrawUsage);
|
|
1342
|
+
sgeo.setAttribute('aP', iPA);
|
|
1343
|
+
sgeo.setAttribute('aC', iCA);
|
|
1344
|
+
sgeo.setAttribute('aR', iRA);
|
|
1345
|
+
sgeo.instanceCount = NS;
|
|
1346
|
+
const smat = new THREE.ShaderMaterial({
|
|
1347
|
+
uniforms: u, vertexShader: smokeVertex, fragmentShader: smokeFragment,
|
|
1348
|
+
transparent: true, depthWrite: false, premultipliedAlpha: true, blending: THREE.NormalBlending, side: THREE.DoubleSide,
|
|
1349
|
+
});
|
|
1350
|
+
const smoke = new THREE.Mesh(sgeo, smat);
|
|
1351
|
+
smoke.frustumCulled = false;
|
|
1352
|
+
smoke.renderOrder = 1;
|
|
1353
|
+
const object = new THREE.Group();
|
|
1354
|
+
const proxy = createPickProxy(BOUNDS.center, BOUNDS.radius);
|
|
1355
|
+
object.add(smoke, flashes, stars, proxy.mesh);
|
|
1356
|
+
const anchor = new THREE.Vector3(0, 0.25, 0);
|
|
1357
|
+
// a click on this object's own pick volume is a click in the show's space, not on another object
|
|
1358
|
+
const own = new Set();
|
|
1359
|
+
function ownProxies(obj) {
|
|
1360
|
+
own.clear();
|
|
1361
|
+
obj.traverse((o) => own.add(o));
|
|
1362
|
+
let a = obj.parent;
|
|
1363
|
+
for (let d = 0; a && d < 5; d++, a = a.parent)
|
|
1364
|
+
for (const ch of a.children)
|
|
1365
|
+
if (ch.name === 'pickProxy')
|
|
1366
|
+
own.add(ch);
|
|
1367
|
+
}
|
|
1368
|
+
// ---- light: computed once per rendered frame from each body's state ----
|
|
1369
|
+
const cA = [0, 0, 0], cB = [0, 0, 0];
|
|
1370
|
+
function upload() {
|
|
1371
|
+
const pc = u.uPointerColor.value;
|
|
1372
|
+
for (let i = 0; i < n; i++) {
|
|
1373
|
+
const kd = kind[i];
|
|
1374
|
+
if (kd === 0) {
|
|
1375
|
+
aS[i] = 0;
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
let b = 0, r = 0, g = 0, bl = 0, size = 0, slot = 3;
|
|
1379
|
+
if (kd === 3) {
|
|
1380
|
+
// charcoal spark on the Planck locus, cooling exponentially
|
|
1381
|
+
const T = 1000 + (T0[i] - 1000) * Math.exp(-age[i] / life[i]) + 220 * Math.min(oxy[i], 1);
|
|
1382
|
+
b = Math.exp(-5500 * (1 / T - 1 / 1900)) * 1.3;
|
|
1383
|
+
slotColor(3, T, cA);
|
|
1384
|
+
r = cA[0];
|
|
1385
|
+
g = cA[1];
|
|
1386
|
+
bl = cA[2];
|
|
1387
|
+
size = 0.022;
|
|
1388
|
+
}
|
|
1389
|
+
else if (kd === 1) {
|
|
1390
|
+
b = 0.9;
|
|
1391
|
+
slotColor(3, 1950, cA);
|
|
1392
|
+
r = cA[0];
|
|
1393
|
+
g = cA[1];
|
|
1394
|
+
bl = cA[2];
|
|
1395
|
+
size = 0.03 * sz[i];
|
|
1396
|
+
}
|
|
1397
|
+
else if (kd === 4) {
|
|
1398
|
+
// microstar: dark until its fuse pops it
|
|
1399
|
+
const t = age[i] - life[i];
|
|
1400
|
+
b = t < 0 ? 0 : 3.2 * Math.exp(-t / 0.025);
|
|
1401
|
+
slotColor(4, 0, cA);
|
|
1402
|
+
r = cA[0];
|
|
1403
|
+
g = cA[1];
|
|
1404
|
+
bl = cA[2];
|
|
1405
|
+
size = 0.04 * sz[i];
|
|
1406
|
+
slot = 4;
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
const fr = Math.min(age[i] / life[i], 1);
|
|
1410
|
+
// ignition flare, then the fragment burns down (light ~ surface ~ (1 - f)^(2/3))
|
|
1411
|
+
b = Math.min(age[i] / 0.05, 1) * Math.pow(1 - fr, 0.66) * (1 + 0.7 * Math.exp(-age[i] / 0.12));
|
|
1412
|
+
b *= Math.min(1, (1 - fr) / 0.12);
|
|
1413
|
+
if (flutter[i] > 0)
|
|
1414
|
+
b *= 0.35 + 0.65 * Math.abs(Math.sin(age[i] * flutter[i] * 0.5 + phase[i]));
|
|
1415
|
+
if (strobe[i] > 0) {
|
|
1416
|
+
const s = 0.5 + 0.5 * Math.sin(age[i] * strobe[i] * 6.2832 + phase[i]);
|
|
1417
|
+
b *= 0.04 + 1.4 * s * s * s * s * s * s;
|
|
1418
|
+
}
|
|
1419
|
+
const w = change[i] < 1.5 ? Math.min(Math.max((fr - change[i]) / 0.08 + 0.5, 0), 1) : 0;
|
|
1420
|
+
slot = w > 0.5 ? slotB[i] : slotA[i];
|
|
1421
|
+
slotColor(slotA[i], GOLD_T, cA);
|
|
1422
|
+
if (w > 0) {
|
|
1423
|
+
slotColor(slotB[i], GOLD_T, cB);
|
|
1424
|
+
cA[0] += (cB[0] - cA[0]) * w;
|
|
1425
|
+
cA[1] += (cB[1] - cA[1]) * w;
|
|
1426
|
+
cA[2] += (cB[2] - cA[2]) * w;
|
|
1427
|
+
}
|
|
1428
|
+
r = cA[0];
|
|
1429
|
+
g = cA[1];
|
|
1430
|
+
bl = cA[2];
|
|
1431
|
+
b *= slotA[i] === 3 ? 1.1 : 1.15;
|
|
1432
|
+
size = 0.036 * sz[i];
|
|
1433
|
+
}
|
|
1434
|
+
// the cursor's oxygen: brighter, and toward the pointer colour; aimed shells carry its tint
|
|
1435
|
+
const tt = Math.min(1, tint[i] + 0.35 * oxy[i]);
|
|
1436
|
+
if (tt > 0) {
|
|
1437
|
+
r += (pc.r - r) * tt;
|
|
1438
|
+
g += (pc.g - g) * tt;
|
|
1439
|
+
bl += (pc.b - bl) * tt;
|
|
1440
|
+
}
|
|
1441
|
+
b *= 1 + 1.3 * oxy[i];
|
|
1442
|
+
// film response: bright cores saturate toward white
|
|
1443
|
+
const E = 1.25 * b;
|
|
1444
|
+
const j = i * 3;
|
|
1445
|
+
aCol[j] = 1 - Math.exp(-r * E) + 0.05 * Math.max(E - 2, 0);
|
|
1446
|
+
aCol[j + 1] = 1 - Math.exp(-g * E) + 0.05 * Math.max(E - 2, 0);
|
|
1447
|
+
aCol[j + 2] = 1 - Math.exp(-bl * E) + 0.05 * Math.max(E - 2, 0);
|
|
1448
|
+
aS[i] = b > 0.004 ? size * (0.75 + 0.35 * Math.min(b, 2)) : 0;
|
|
1449
|
+
if (i < nStar)
|
|
1450
|
+
kTrail[i] = slot * 10 + Math.min(b, 9.5) * (kd === 1 ? 0.6 : 1);
|
|
1451
|
+
}
|
|
1452
|
+
posAttr.needsUpdate = true;
|
|
1453
|
+
colAttr.needsUpdate = true;
|
|
1454
|
+
sAttr.needsUpdate = true;
|
|
1455
|
+
// flashes
|
|
1456
|
+
for (let b = 0; b < NB; b++) {
|
|
1457
|
+
const a = bAge[b];
|
|
1458
|
+
const s = a > 3 ? 0 : 0.6 * Math.exp(-a / 0.05) + 0.1 * Math.exp(-a / 0.6);
|
|
1459
|
+
fpos[b * 3] = bPos[b * 3];
|
|
1460
|
+
fpos[b * 3 + 1] = bPos[b * 3 + 1];
|
|
1461
|
+
fpos[b * 3 + 2] = bPos[b * 3 + 2];
|
|
1462
|
+
fF[b * 4] = bCol[b * 3];
|
|
1463
|
+
fF[b * 4 + 1] = bCol[b * 3 + 1];
|
|
1464
|
+
fF[b * 4 + 2] = bCol[b * 3 + 2];
|
|
1465
|
+
fF[b * 4 + 3] = s;
|
|
1466
|
+
}
|
|
1467
|
+
fpAttr.needsUpdate = true;
|
|
1468
|
+
fFAttr.needsUpdate = true;
|
|
1469
|
+
// smoke: ambient night sky + every burst's light (falls off with distance, fades as its stars burn out)
|
|
1470
|
+
const sm = u.uSmoke.value;
|
|
1471
|
+
smoke.visible = sm > 0.001;
|
|
1472
|
+
for (let s = 0; s < NS; s++) {
|
|
1473
|
+
const a = sAge[s];
|
|
1474
|
+
const j = s * 3;
|
|
1475
|
+
iP[j] = sPos[j];
|
|
1476
|
+
iP[j + 1] = sPos[j + 1];
|
|
1477
|
+
iP[j + 2] = sPos[j + 2];
|
|
1478
|
+
if (a > 30) {
|
|
1479
|
+
iC[s * 4 + 3] = 0;
|
|
1480
|
+
iR[s * 2] = 0;
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
let r = 0.035, g = 0.04, bl = 0.06;
|
|
1484
|
+
for (let b = 0; b < NB; b++) {
|
|
1485
|
+
const ba = bAge[b];
|
|
1486
|
+
if (ba > 4)
|
|
1487
|
+
continue;
|
|
1488
|
+
const dx = bPos[b * 3] - sPos[j], dy = bPos[b * 3 + 1] - sPos[j + 1], dz = bPos[b * 3 + 2] - sPos[j + 2];
|
|
1489
|
+
const L = (0.6 * Math.exp(-ba / 0.12) + 0.3 * Math.exp(-ba / 1.4)) * 0.035 / (dx * dx + dy * dy + dz * dz + 0.05);
|
|
1490
|
+
r += bCol[b * 3] * L;
|
|
1491
|
+
g += bCol[b * 3 + 1] * L;
|
|
1492
|
+
bl += bCol[b * 3 + 2] * L;
|
|
1493
|
+
}
|
|
1494
|
+
const lm = Math.max(r, g, bl);
|
|
1495
|
+
if (lm > 0.9) {
|
|
1496
|
+
r *= 0.9 / lm;
|
|
1497
|
+
g *= 0.9 / lm;
|
|
1498
|
+
bl *= 0.9 / lm;
|
|
1499
|
+
}
|
|
1500
|
+
iC[s * 4] = r;
|
|
1501
|
+
iC[s * 4 + 1] = g;
|
|
1502
|
+
iC[s * 4 + 2] = bl;
|
|
1503
|
+
iC[s * 4 + 3] = sm * 0.11 * Math.min(a / 0.6, 1) * Math.exp(-a / 9);
|
|
1504
|
+
iR[s * 2] = sScale[s] * (0.25 + 0.9 * Math.sqrt(a / (a + 5)));
|
|
1505
|
+
iR[s * 2 + 1] = sSeed[s];
|
|
1506
|
+
}
|
|
1507
|
+
iPA.needsUpdate = true;
|
|
1508
|
+
iCA.needsUpdate = true;
|
|
1509
|
+
iRA.needsUpdate = true;
|
|
1510
|
+
}
|
|
1511
|
+
// ---- trails: the star's own colour (slot) and brightness, packed in TRAIL_K ----
|
|
1512
|
+
const trailGLSL = /* glsl */ `
|
|
1513
|
+
uniform vec3 uColor, uSecondColor, uAccentColor, uSparkColor;
|
|
1514
|
+
vec3 trailShade(vec3 p, float f) {
|
|
1515
|
+
float slot = floor(TRAIL_K / 10.0 + 0.001);
|
|
1516
|
+
float b = TRAIL_K - slot * 10.0;
|
|
1517
|
+
vec3 c = slot < 0.5 ? uColor : slot < 1.5 ? uSecondColor : slot < 2.5 ? uAccentColor
|
|
1518
|
+
: slot < 3.5 ? mix(vec3(1.0, 0.55, 0.2), uSparkColor / max(max(uSparkColor.r, uSparkColor.g), max(uSparkColor.b, 1e-3)), 0.65)
|
|
1519
|
+
: vec3(1.0, 0.96, 0.9);
|
|
1520
|
+
// the star's own colour at the head, cooling toward the trail colour along the tail
|
|
1521
|
+
return mix(c, uTrailColor, 0.2 + 0.6 * f) * min(b, 2.5) * 1.1;
|
|
1522
|
+
}
|
|
1523
|
+
vec3 trailView(vec3 mv, out float mag) { mag = 1.0; return mv; }
|
|
1524
|
+
`;
|
|
1525
|
+
const materials = [mat, fmat, smat];
|
|
1526
|
+
function buildTrails(mode, share) {
|
|
1527
|
+
trailTR = TR_MODE[mode] || TR_MODE.comet;
|
|
1528
|
+
const key = mode + ':' + share;
|
|
1529
|
+
if (key === trailKey)
|
|
1530
|
+
return;
|
|
1531
|
+
trailKey = key;
|
|
1532
|
+
if (trail) {
|
|
1533
|
+
object.remove(trail.object);
|
|
1534
|
+
trail.dispose();
|
|
1535
|
+
trail = null;
|
|
1536
|
+
}
|
|
1537
|
+
materials.length = 3;
|
|
1538
|
+
// trails only on the star slots (shells, stars, microstars) — sparks are their own tails
|
|
1539
|
+
trail = createCpuTrail(mode, { u, n: nStar, pos, vel, k: kTrail, glsl: trailGLSL, sizePx: 0.02, spacing: 0.004, sub: 8, alpha: TRAIL_GAIN[mode] || 1 });
|
|
1540
|
+
if (!trail)
|
|
1541
|
+
return;
|
|
1542
|
+
trail.object.renderOrder = 2;
|
|
1543
|
+
trail.object.traverse((o) => { o.renderOrder = 2; });
|
|
1544
|
+
object.add(trail.object);
|
|
1545
|
+
materials.push(...trail.materials);
|
|
1546
|
+
}
|
|
1547
|
+
buildTrails(c.trailMode, c.trailShare);
|
|
1548
|
+
upload();
|
|
1549
|
+
return {
|
|
1550
|
+
object,
|
|
1551
|
+
materials,
|
|
1552
|
+
apply(cfg) { if (cfg)
|
|
1553
|
+
buildTrails(cfg.trailMode, cfg.trailShare); },
|
|
1554
|
+
update(dt, camera, pointer) {
|
|
1555
|
+
const rdt = Math.min(Math.max(dt, 0), 1 / 20);
|
|
1556
|
+
if (camera && !inView(object, camera, BOUNDS.center, BOUNDS.radius, u.uSize.value))
|
|
1557
|
+
return;
|
|
1558
|
+
const d = rdt * u.uSpeed.value;
|
|
1559
|
+
u.uTime.value += d;
|
|
1560
|
+
if (camera && pointer)
|
|
1561
|
+
stepPointerField(u, pointer, camera, object, u.uSize.value, rdt, anchor);
|
|
1562
|
+
if (pointer && pointer.clicks && pointer.clicks.length) {
|
|
1563
|
+
if (camera && u.uFireOnClick.value > 0.5) {
|
|
1564
|
+
ownProxies(object);
|
|
1565
|
+
for (const c of pointer.clicks) {
|
|
1566
|
+
const t = clickTarget(c, camera, object, u.uSize.value, anchor, own);
|
|
1567
|
+
if (t)
|
|
1568
|
+
fire(false, t);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
pointer.clicks.length = 0;
|
|
1572
|
+
}
|
|
1573
|
+
u.uVelScale.value = trailTR;
|
|
1574
|
+
let simD = 0;
|
|
1575
|
+
stepper.run(d, (hh) => { step(hh); simD += hh; });
|
|
1576
|
+
if (simD > 0) {
|
|
1577
|
+
upload();
|
|
1578
|
+
if (trail)
|
|
1579
|
+
trail.update(simD / trailTR);
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
seek(t) { for (let s = 0; s < Math.round(t / H_STEP); s++)
|
|
1583
|
+
step(H_STEP); upload(); },
|
|
1584
|
+
dispose() {
|
|
1585
|
+
geo.dispose();
|
|
1586
|
+
mat.dispose();
|
|
1587
|
+
fgeo.dispose();
|
|
1588
|
+
fmat.dispose();
|
|
1589
|
+
sgeo.dispose();
|
|
1590
|
+
smat.dispose();
|
|
1591
|
+
proxy.dispose();
|
|
1592
|
+
if (trail)
|
|
1593
|
+
trail.dispose();
|
|
1594
|
+
},
|
|
1595
|
+
_state: { pos, vel, kind, slotA, slotB, n, nStar },
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
/** Defaults for every knob live in core (DEFAULT_GENERATIVE_PARTICLES.fireworks). */
|
|
1599
|
+
const DEFAULTS = DEFAULT_GENERATIVE_PARTICLES.fireworks;
|
|
1600
|
+
/** Knobs that rebuild the simulation: they change on commit, never through the live path. */
|
|
1601
|
+
const STRUCTURAL = ['particleCount', 'seed', 'quality'];
|
|
1602
|
+
/** Knobs an animation (or mouse move) may drive per frame — generated from applyConfig. */
|
|
1603
|
+
const ANIMATABLE = new Set(GENERATIVE_PARTICLES_ANIMATABLE.fireworks.map((p) => p.value));
|
|
1604
|
+
const FireworksParticles = ({ id, size, config, animations, mouseMove, onObjectReady, onObjectDispose }) => {
|
|
1605
|
+
const rootRef = useRef(null);
|
|
1606
|
+
useObjectReady(rootRef, config, onObjectReady, onObjectDispose);
|
|
1607
|
+
const s = size > 0 ? size : 1;
|
|
1608
|
+
// the committed config over the core defaults (a plain record: the lab code reads it by key)
|
|
1609
|
+
const cfg = useMemo(() => withConfigDefaults(DEFAULTS, config), [config]);
|
|
1610
|
+
// uniforms outlive the objects, so keyframe animations survive a rebuild
|
|
1611
|
+
const uniforms = useMemo(() => createUniforms(), []);
|
|
1612
|
+
// rebuilt only when a structural knob changes (trails rebuild inside apply())
|
|
1613
|
+
const instance = useMemo(() => build(uniforms, cfg, s), [uniforms, cfg.particleCount, cfg.seed, cfg.quality]);
|
|
1614
|
+
useEffect(() => () => instance.dispose(), [instance]);
|
|
1615
|
+
useLayoutEffect(() => {
|
|
1616
|
+
applyConfig(uniforms, cfg, s);
|
|
1617
|
+
instance.apply(cfg);
|
|
1618
|
+
}, [instance, uniforms, cfg, s]);
|
|
1619
|
+
// Slider drags and keyframe animations are both overrides on the committed config.
|
|
1620
|
+
// This is the one place they are merged and pushed; keyframes first, pointer on top.
|
|
1621
|
+
const keyframes = animations?.particles?.animations;
|
|
1622
|
+
const animOverride = useRef({});
|
|
1623
|
+
const animFrom = useRef({});
|
|
1624
|
+
const animStart = useRef(0);
|
|
1625
|
+
useEffect(() => { animStart.current = performance.now(); animFrom.current = {}; }, [keyframes]);
|
|
1626
|
+
const push = useCallback((overrides) => {
|
|
1627
|
+
const merged = { ...cfg, ...overrides, ...animOverride.current };
|
|
1628
|
+
const z = merged.size;
|
|
1629
|
+
applyConfig(uniforms, merged, z && z > 0 ? z : s);
|
|
1630
|
+
}, [cfg, uniforms, s]);
|
|
1631
|
+
const live = useConfigLiveHandle(rootRef, id, 'particles', cfg, STRUCTURAL, push);
|
|
1632
|
+
// The pointer field, plus clicks (fireOnClick) read from the same element, never intercepted.
|
|
1633
|
+
const pointer = usePointerField();
|
|
1634
|
+
const gl = useThree((st) => st.gl);
|
|
1635
|
+
const connected = useThree((st) => st.events.connected);
|
|
1636
|
+
useEffect(() => bindClickFire(connected ?? gl.domElement, pointer), [gl, connected, pointer]);
|
|
1637
|
+
useFrame((state, delta) => {
|
|
1638
|
+
instance.update(Math.min(delta, 0.1), state.camera, pointer);
|
|
1639
|
+
const pointerBound = !!mouseMove?.enabled && !!mouseMove.properties?.length;
|
|
1640
|
+
if (keyframes?.length || pointerBound) {
|
|
1641
|
+
applyConfigKeyframeAnimations(keyframes, animFrom.current, cfg, animOverride.current, performance.now() - animStart.current, ANIMATABLE);
|
|
1642
|
+
if (pointerBound)
|
|
1643
|
+
applyConfigMouseMove(mouseMove.properties, cfg, animOverride.current, state.pointer, ANIMATABLE);
|
|
1644
|
+
push(live.current);
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
return (_jsx("group", { ref: rootRef, children: _jsx("primitive", { object: instance.object }) }));
|
|
1648
|
+
};
|
|
1649
|
+
export default FireworksParticles;
|