3d-spinner 0.9.2 → 0.9.4
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/README.md +66 -4
- package/dist/animation-label.d.ts +8 -0
- package/dist/animation-label.js +47 -0
- package/dist/animation.d.ts +2 -0
- package/dist/animations/charged-orb.d.ts +55 -0
- package/dist/animations/charged-orb.js +229 -0
- package/dist/animations/ghost-train.d.ts +72 -0
- package/dist/animations/ghost-train.js +247 -0
- package/dist/animations/grid-assembly.d.ts +70 -0
- package/dist/animations/grid-assembly.js +282 -0
- package/dist/animations/object-motion.d.ts +19 -7
- package/dist/animations/object-motion.js +38 -32
- package/dist/animations/particles.d.ts +110 -0
- package/dist/animations/particles.js +209 -0
- package/dist/animations/rocket-launch.d.ts +58 -0
- package/dist/animations/rocket-launch.js +375 -0
- package/dist/cjs/animations/charged-orb.cjs +1334 -0
- package/dist/cjs/animations/grid-assembly.cjs +1380 -0
- package/dist/cjs/animations/object-motion.cjs +124 -43
- package/dist/cjs/animations/particles.cjs +1940 -0
- package/dist/cjs/animations/spin.cjs +4 -3
- package/dist/cjs/composite-animation.cjs +58 -0
- package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +143 -22
- package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +30 -1
- package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +345 -0
- package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +528 -0
- package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +712 -0
- package/dist/cjs/prefabs/prefabs.cjs +3916 -0
- package/dist/composite-animation.d.ts +18 -0
- package/dist/composite-animation.js +39 -0
- package/dist/engines/little-3d-engine/little-3d-engine.d.ts +17 -12
- package/dist/engines/little-3d-engine/little-3d-engine.js +13 -8
- package/dist/engines/little-3d-engine/loaders/obj.d.ts +12 -4
- package/dist/engines/little-3d-engine/loaders/obj.js +44 -5
- package/dist/engines/little-3d-engine/renderer.d.ts +7 -1
- package/dist/engines/little-3d-engine/renderer.js +2 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d-textured.d.ts +20 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d-textured.js +135 -0
- package/dist/engines/little-3d-engine/renderers/textured-helpers.d.ts +9 -0
- package/dist/engines/little-3d-engine/renderers/textured-helpers.js +35 -0
- package/dist/engines/little-3d-engine/renderers/webgl-textured.d.ts +43 -0
- package/dist/engines/little-3d-engine/renderers/webgl-textured.js +211 -0
- package/dist/engines/little-3d-engine/renderers/webgpu-textured.d.ts +45 -0
- package/dist/engines/little-3d-engine/renderers/webgpu-textured.js +300 -0
- package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +11 -6
- package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +3 -0
- package/dist/engines/little-3d-engine/shapes/complex/plane.js +33 -0
- package/dist/engines/little-3d-engine/shapes/{cube.d.ts → primitives/cube.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octahedron.d.ts → primitives/octahedron.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{pyramid.d.ts → primitives/pyramid.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +9 -0
- package/dist/engines/little-3d-engine/shapes/primitives/quad.js +18 -0
- package/dist/engines/little-3d-engine/shapes/{cube-sphere.d.ts → primitives/spheres/cube-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{icosphere.d.ts → primitives/spheres/icosphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{icosphere.js → primitives/spheres/icosphere.js} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octa-sphere.d.ts → primitives/spheres/octa-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octa-sphere.js → primitives/spheres/octa-sphere.js} +1 -1
- package/dist/engines/little-3d-engine/shapes/{uv-sphere.d.ts → primitives/spheres/uv-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{tetrahedron.d.ts → primitives/tetrahedron.d.ts} +1 -1
- package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.d.ts +1 -0
- package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.js +8 -0
- package/dist/engines/little-3d-engine/textures/dynamic/shine.d.ts +2 -0
- package/dist/engines/little-3d-engine/textures/dynamic/shine.js +14 -0
- package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +14 -0
- package/dist/engines/little-3d-engine/textures/dynamic/star.js +31 -0
- package/dist/engines/little-3d-engine/textures/dynamic/streak.d.ts +5 -0
- package/dist/engines/little-3d-engine/textures/dynamic/streak.js +20 -0
- package/dist/index.d.ts +2 -1
- package/dist/prefabs/charged-orb.d.ts +17 -0
- package/dist/prefabs/charged-orb.js +37 -0
- package/dist/prefabs/crystal-comet.d.ts +4 -0
- package/dist/prefabs/crystal-comet.js +41 -0
- package/dist/prefabs/ghost-train.d.ts +10 -0
- package/dist/prefabs/ghost-train.js +37 -0
- package/dist/prefabs/grid-assembly.d.ts +13 -0
- package/dist/prefabs/grid-assembly.js +15 -0
- package/dist/prefabs/monochrome-streak.d.ts +4 -0
- package/dist/prefabs/monochrome-streak.js +25 -0
- package/dist/prefabs/plane-star-trail.d.ts +4 -0
- package/dist/prefabs/plane-star-trail.js +40 -0
- package/dist/prefabs/prefabs.d.ts +10 -0
- package/dist/prefabs/prefabs.js +9 -0
- package/dist/prefabs/pulsing-starfield.d.ts +4 -0
- package/dist/prefabs/pulsing-starfield.js +27 -0
- package/dist/prefabs/rocket-launch.d.ts +10 -0
- package/dist/prefabs/rocket-launch.js +16 -0
- package/dist/prefabs/spinner.d.ts +7 -0
- package/dist/prefabs/spinner.js +17 -0
- package/dist/prefabs/star-swarm.d.ts +4 -0
- package/dist/prefabs/star-swarm.js +27 -0
- package/dist/prefabs/types.d.ts +46 -0
- package/dist/prefabs/types.js +1 -0
- package/dist/umd/spinner.global.js +2407 -85
- package/dist/umd/spinner.global.min.js +60 -5
- package/package.json +43 -2
- /package/dist/engines/little-3d-engine/shapes/{cube.js → primitives/cube.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{octahedron.js → primitives/octahedron.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{pyramid.js → primitives/pyramid.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{cube-sphere.js → primitives/spheres/cube-sphere.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{uv-sphere.js → primitives/spheres/uv-sphere.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{tetrahedron.js → primitives/tetrahedron.js} +0 -0
|
@@ -1,19 +1,7 @@
|
|
|
1
|
+
import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
|
|
1
2
|
import { Little3dEngine, cross, normalize, scale, subtract, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
2
3
|
import { multiply, rotationX, rotationY, rotationZ, } from "../engines/little-3d-engine/core/math.js";
|
|
3
|
-
import {
|
|
4
|
-
const LABEL_STYLE = [
|
|
5
|
-
"position:absolute",
|
|
6
|
-
"inset:0",
|
|
7
|
-
"display:flex",
|
|
8
|
-
"align-items:center",
|
|
9
|
-
"justify-content:center",
|
|
10
|
-
"pointer-events:none",
|
|
11
|
-
"font:700 1.6rem/1 system-ui,sans-serif",
|
|
12
|
-
"letter-spacing:0.02em",
|
|
13
|
-
"color:rgba(255,255,255,0.9)",
|
|
14
|
-
"text-shadow:0 1px 10px rgba(0,0,0,0.6)",
|
|
15
|
-
"z-index:1",
|
|
16
|
-
].join(";");
|
|
4
|
+
import { enterFromObjectDirection, leaveInObjectDirection, } from "../motion/transitions.js";
|
|
17
5
|
const WORLD_UP = { x: 0, y: 1, z: 0 };
|
|
18
6
|
const DEFAULT_INTRO_MS = 2100;
|
|
19
7
|
const DEFAULT_OUTRO_MS = 2100;
|
|
@@ -37,6 +25,8 @@ function resolveMesh(mesh) {
|
|
|
37
25
|
return typeof mesh === "function" ? mesh() : mesh;
|
|
38
26
|
}
|
|
39
27
|
function applyColor(mesh, color) {
|
|
28
|
+
if (color === undefined)
|
|
29
|
+
return mesh;
|
|
40
30
|
return { vertices: mesh.vertices, faces: mesh.faces.map((face) => ({ ...face, color })) };
|
|
41
31
|
}
|
|
42
32
|
function faceForward(mesh, facing) {
|
|
@@ -138,7 +128,7 @@ function resolveTransition(config, fallback, durationMs) {
|
|
|
138
128
|
* An object that moves along a {@link MotionController}'s path (a circle, a
|
|
139
129
|
* square, a figure-8, a smooth wander, or any custom controller) with its nose
|
|
140
130
|
* following the path tangent. The runner triggers the lifecycle: {@link enter}
|
|
141
|
-
*
|
|
131
|
+
* flies it in along the path, {@link exit} flies it out. Any mesh works (OBJ imports or engine
|
|
142
132
|
* primitives); `facing` corrects a model that points the wrong way, and
|
|
143
133
|
* `rotation` adds local spin or tilt on top of path following.
|
|
144
134
|
*/
|
|
@@ -156,15 +146,16 @@ export class ObjectMotionAnimation {
|
|
|
156
146
|
this.outroDirection = { x: 1, y: 0, z: 0 };
|
|
157
147
|
const centered = centerAndScaleMesh(resolveMesh(options.mesh), options.size ?? 1);
|
|
158
148
|
const facing = faceForward(centered, options.facing ?? "+x");
|
|
159
|
-
this.mesh = applyColor(facing, options.color
|
|
149
|
+
this.mesh = applyColor(facing, options.color);
|
|
160
150
|
this.motion = options.motion;
|
|
161
151
|
this.backend = options.backend;
|
|
162
152
|
this.transparency = options.transparency;
|
|
163
|
-
this.
|
|
153
|
+
this.labelContent = options.label;
|
|
154
|
+
this.fadeLabel = options.fadeLabel ?? true;
|
|
164
155
|
this.tailCount = Math.max(0, Math.floor(options.tail?.count ?? 0));
|
|
165
156
|
this.tailGap = Math.max(0, options.tail?.gapMs ?? 0);
|
|
166
|
-
this.intro = resolveTransition(options.intro,
|
|
167
|
-
this.outro = resolveTransition(options.outro,
|
|
157
|
+
this.intro = resolveTransition(options.intro, enterFromObjectDirection(), DEFAULT_INTRO_MS);
|
|
158
|
+
this.outro = resolveTransition(options.outro, leaveInObjectDirection(), DEFAULT_OUTRO_MS);
|
|
168
159
|
const rotation = options.rotation;
|
|
169
160
|
this.rotationOffset = { x: rotation?.x ?? 0, y: rotation?.y ?? 0, z: rotation?.z ?? 0 };
|
|
170
161
|
this.rotationSpin = {
|
|
@@ -181,7 +172,8 @@ export class ObjectMotionAnimation {
|
|
|
181
172
|
this.rotationSpin.z !== 0;
|
|
182
173
|
}
|
|
183
174
|
mount(target) {
|
|
184
|
-
target.style.position
|
|
175
|
+
if (!target.style.position)
|
|
176
|
+
target.style.position = "relative";
|
|
185
177
|
const engine = new Little3dEngine({
|
|
186
178
|
backend: this.backend,
|
|
187
179
|
camera: { position: { x: 0, y: 0, z: 3 } },
|
|
@@ -195,11 +187,9 @@ export class ObjectMotionAnimation {
|
|
|
195
187
|
engine.mount(target).catch((error) => {
|
|
196
188
|
target.textContent = error instanceof Error ? error.message : String(error);
|
|
197
189
|
});
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
target.appendChild(label);
|
|
202
|
-
this.label = label;
|
|
190
|
+
this.label = mountAnimationLabel(target, this.labelContent);
|
|
191
|
+
if (this.fadeLabel)
|
|
192
|
+
this.label.setOpacity(0);
|
|
203
193
|
}
|
|
204
194
|
enter(now) {
|
|
205
195
|
if (this.started)
|
|
@@ -218,6 +208,19 @@ export class ObjectMotionAnimation {
|
|
|
218
208
|
isFinished() {
|
|
219
209
|
return this.finished;
|
|
220
210
|
}
|
|
211
|
+
/** Milliseconds the fly-out takes; used to align a following particle trail's outro. */
|
|
212
|
+
get outroDurationMs() {
|
|
213
|
+
return this.outro.durationMs;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A {@link MotionController} that follows the object's *actual* position, including
|
|
217
|
+
* the intro fly-in and outro fly-out (it falls back to the raw motion path before
|
|
218
|
+
* {@link enter} and once idle). Feed it to a particle layer's `emitter` so the
|
|
219
|
+
* particles trail the object through its transitions instead of the bare path.
|
|
220
|
+
*/
|
|
221
|
+
trailEmitter() {
|
|
222
|
+
return { positionAt: (t) => this.sampleAt(t)?.position ?? this.motion.positionAt(t) };
|
|
223
|
+
}
|
|
221
224
|
render(now, frame) {
|
|
222
225
|
if (!this.engine || !this.label)
|
|
223
226
|
return;
|
|
@@ -258,13 +261,16 @@ export class ObjectMotionAnimation {
|
|
|
258
261
|
transform.rotation.y = euler.y;
|
|
259
262
|
transform.rotation.z = euler.z;
|
|
260
263
|
}
|
|
261
|
-
this.label.
|
|
262
|
-
? (this.
|
|
263
|
-
: `${Math.round(frame.progress * 100)}
|
|
264
|
+
this.label.setText(frame.indeterminate
|
|
265
|
+
? (typeof this.labelContent === "string" ? this.labelContent : "")
|
|
266
|
+
: `${Math.round(frame.progress * 100)}%`);
|
|
267
|
+
if (this.fadeLabel) {
|
|
268
|
+
this.label.setOpacity(animationLabelOpacity(now, this.started ? this.introStart : Infinity, this.intro.durationMs, this.outroStart, this.outro.durationMs));
|
|
269
|
+
}
|
|
264
270
|
this.engine.render();
|
|
265
271
|
}
|
|
266
272
|
destroy() {
|
|
267
|
-
this.label?.remove();
|
|
273
|
+
this.label?.container.remove();
|
|
268
274
|
this.label = undefined;
|
|
269
275
|
this.engine?.destroy();
|
|
270
276
|
this.engine = undefined;
|
|
@@ -302,7 +308,7 @@ export class ObjectMotionAnimation {
|
|
|
302
308
|
const delta = transition.durationMs === 0 ? 1 : clamp01(elapsedMs / transition.durationMs);
|
|
303
309
|
const input = this.transitionInput(phase, delta, elapsedMs, transition.durationMs, start);
|
|
304
310
|
const output = transition.transition(input);
|
|
305
|
-
return this.applyTransitionOutput(input, output);
|
|
311
|
+
return this.applyTransitionOutput(input, output, phase, t);
|
|
306
312
|
}
|
|
307
313
|
transitionInput(phase, delta, elapsedMs, durationMs, start) {
|
|
308
314
|
if (phase === "intro") {
|
|
@@ -330,9 +336,9 @@ export class ObjectMotionAnimation {
|
|
|
330
336
|
phase,
|
|
331
337
|
};
|
|
332
338
|
}
|
|
333
|
-
applyTransitionOutput(input, output) {
|
|
339
|
+
applyTransitionOutput(input, output, phase, t) {
|
|
334
340
|
return {
|
|
335
|
-
position: output.position ?? input.position,
|
|
341
|
+
position: output.position ?? (phase === "intro" ? this.motion.positionAt(t) : input.position),
|
|
336
342
|
size: output.size ?? input.size ?? 1,
|
|
337
343
|
orientation: output.orientation,
|
|
338
344
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
|
|
2
|
+
import type { MotionController } from "../motion/controller.js";
|
|
3
|
+
import { type Backend, type Vec3 } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
4
|
+
export interface ParticlesOptions {
|
|
5
|
+
/** Particles emitted per second. Default `20`. */
|
|
6
|
+
rate?: number;
|
|
7
|
+
/** Lifetime of one particle in milliseconds. Default `1800`. */
|
|
8
|
+
lifeMs?: number;
|
|
9
|
+
/** Particle colors, cycled across particles. Defaults to a built-in palette. */
|
|
10
|
+
colors?: string[];
|
|
11
|
+
/** Base particle size in world units, varied per particle. Default `0.16`. */
|
|
12
|
+
size?: number;
|
|
13
|
+
/** Base emission speed in world units per second, varied per particle. Default `0.6`. */
|
|
14
|
+
speed?: number;
|
|
15
|
+
/** Constant acceleration in world units per second squared. Default none. */
|
|
16
|
+
gravity?: Vec3;
|
|
17
|
+
/** Mean emission direction. Omit to emit uniformly in all directions. */
|
|
18
|
+
direction?: Vec3;
|
|
19
|
+
/** Cone half-angle around `direction` in radians. Default `0.5`. */
|
|
20
|
+
spread?: number;
|
|
21
|
+
/** Peak particle opacity `0..1`. Default `0.9`. */
|
|
22
|
+
opacity?: number;
|
|
23
|
+
/** Maximum spin around the view axis in radians per millisecond. Default `0.002`. */
|
|
24
|
+
spin?: number;
|
|
25
|
+
/** Rotate each billboard around the view axis to follow its current velocity. Default `false`. */
|
|
26
|
+
alignToMotion?: boolean;
|
|
27
|
+
/** Seed for the deterministic particle stream. Default `1`. */
|
|
28
|
+
seed?: number;
|
|
29
|
+
/** Rendering backend. Default `"canvas2d"`. */
|
|
30
|
+
backend?: Backend;
|
|
31
|
+
/** Optional moving emission origin. Each particle keeps the origin where it was emitted. */
|
|
32
|
+
emitter?: MotionController;
|
|
33
|
+
/**
|
|
34
|
+
* Milliseconds to keep emitting after {@link ParticlesAnimation.exit}. Default `0`
|
|
35
|
+
* (emission stops at exit). Give it a moving `emitter`'s outro duration so fresh
|
|
36
|
+
* particles keep trailing the emitter as it flies out, instead of freezing where
|
|
37
|
+
* the loop left off.
|
|
38
|
+
*/
|
|
39
|
+
outroMs?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Image applied to every particle (a URL or a drawable element), tinted by
|
|
42
|
+
* the particle color; the image's alpha shapes the particle. Renders
|
|
43
|
+
* through a textured renderer, fetched on demand: the WebGPU one when
|
|
44
|
+
* `backend` is `"webgpu"`, otherwise the WebGL one.
|
|
45
|
+
*/
|
|
46
|
+
texture?: string | TexImageSource;
|
|
47
|
+
/** Overlay label shown in indeterminate mode (no value to show). Hidden if omitted. */
|
|
48
|
+
label?: AnimationLabel;
|
|
49
|
+
/** Fade the label as particles appear and drain away. Default `true`. */
|
|
50
|
+
fadeLabel?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** State of one live particle: where it is and how it looks. */
|
|
53
|
+
export interface ParticleSample {
|
|
54
|
+
position: Vec3;
|
|
55
|
+
/** Rotation around the view axis, radians. */
|
|
56
|
+
roll: number;
|
|
57
|
+
size: number;
|
|
58
|
+
opacity: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A deterministic particle stream. Particle `index` is emitted at
|
|
62
|
+
* `index * spawnGapMs`; its whole life is a pure function of time, so the
|
|
63
|
+
* field holds no per-frame state and the same seed replays the same stream.
|
|
64
|
+
*/
|
|
65
|
+
export interface ParticleField {
|
|
66
|
+
/** Upper bound on simultaneously live particles; slots recycle beyond it. */
|
|
67
|
+
maxLive: number;
|
|
68
|
+
/** Milliseconds between consecutive emissions. */
|
|
69
|
+
spawnGapMs: number;
|
|
70
|
+
/** Lifetime of one particle in milliseconds. */
|
|
71
|
+
lifeMs: number;
|
|
72
|
+
/**
|
|
73
|
+
* State of particle `index` at `t` milliseconds after emission started, or
|
|
74
|
+
* `undefined` while unborn or after death.
|
|
75
|
+
*/
|
|
76
|
+
sample(index: number, t: number): ParticleSample | undefined;
|
|
77
|
+
}
|
|
78
|
+
/** Create a {@link ParticleField} from the emission options. */
|
|
79
|
+
export declare function particleField(options?: ParticlesOptions): ParticleField;
|
|
80
|
+
/**
|
|
81
|
+
* A stream of camera-facing billboard particles: a burst, a fountain, drifting
|
|
82
|
+
* embers - shaped by the emission options. Particles fade in, drift under
|
|
83
|
+
* `gravity`, and fade out; the runner triggers the lifecycle: {@link enter}
|
|
84
|
+
* starts emission, {@link exit} stops it and lets the live particles die out
|
|
85
|
+
* as the outro.
|
|
86
|
+
*/
|
|
87
|
+
export declare class ParticlesAnimation implements SpinnerAnimation {
|
|
88
|
+
private engine?;
|
|
89
|
+
private label?;
|
|
90
|
+
private readonly handles;
|
|
91
|
+
private readonly fades;
|
|
92
|
+
private readonly field;
|
|
93
|
+
private readonly colors;
|
|
94
|
+
private readonly backend?;
|
|
95
|
+
private readonly texture?;
|
|
96
|
+
private readonly labelContent?;
|
|
97
|
+
private readonly fadeLabel;
|
|
98
|
+
private readonly emitter?;
|
|
99
|
+
private readonly outroMs;
|
|
100
|
+
private enterAt;
|
|
101
|
+
private exitAt;
|
|
102
|
+
private finished;
|
|
103
|
+
constructor(options?: ParticlesOptions);
|
|
104
|
+
mount(target: HTMLElement): void;
|
|
105
|
+
enter(now: number): void;
|
|
106
|
+
exit(now: number): void;
|
|
107
|
+
isFinished(): boolean;
|
|
108
|
+
render(now: number, frame: AnimationFrame): void;
|
|
109
|
+
destroy(): void;
|
|
110
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
|
|
2
|
+
import { Little3dEngine, quad, cross, normalize, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
const DEFAULT_COLORS = ["#fde047", "#fb923c", "#f472b6", "#60a5fa"];
|
|
4
|
+
const FADE_IN_END = 0.15;
|
|
5
|
+
const FADE_OUT_START = 0.6;
|
|
6
|
+
function rand01(seed, index, salt) {
|
|
7
|
+
let h = (seed ^ Math.imul(index + 1, 0x9e3779b9) ^ Math.imul(salt + 1, 0x85ebca6b)) >>> 0;
|
|
8
|
+
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
|
9
|
+
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
|
10
|
+
h ^= h >>> 16;
|
|
11
|
+
return (h >>> 0) / 4294967296;
|
|
12
|
+
}
|
|
13
|
+
function smoothstep(edge0, edge1, value) {
|
|
14
|
+
const x = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
|
|
15
|
+
return x * x * (3 - 2 * x);
|
|
16
|
+
}
|
|
17
|
+
function positiveFinite(value, name) {
|
|
18
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
19
|
+
throw new RangeError(`3d-spinner: ${name} must be a finite number greater than zero.`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function emitBasis(direction) {
|
|
24
|
+
const d = normalize(direction);
|
|
25
|
+
const helper = Math.abs(d.y) < 0.99 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 };
|
|
26
|
+
const right = normalize(cross(helper, d));
|
|
27
|
+
return { d, right, up: cross(d, right) };
|
|
28
|
+
}
|
|
29
|
+
/** Create a {@link ParticleField} from the emission options. */
|
|
30
|
+
export function particleField(options = {}) {
|
|
31
|
+
const rate = positiveFinite(options.rate ?? 20, "rate");
|
|
32
|
+
const lifeMs = positiveFinite(options.lifeMs ?? 1800, "lifeMs");
|
|
33
|
+
const size = options.size ?? 0.16;
|
|
34
|
+
const speed = options.speed ?? 0.6;
|
|
35
|
+
const gravity = options.gravity;
|
|
36
|
+
const spread = options.spread ?? 0.5;
|
|
37
|
+
const peak = Math.max(0, Math.min(1, options.opacity ?? 0.9));
|
|
38
|
+
const spin = options.spin ?? 0.002;
|
|
39
|
+
const alignToMotion = options.alignToMotion ?? false;
|
|
40
|
+
const seed = options.seed ?? 1;
|
|
41
|
+
const basis = options.direction && emitBasis(options.direction);
|
|
42
|
+
const spawnGapMs = 1000 / rate;
|
|
43
|
+
const directionOf = (index) => {
|
|
44
|
+
const u = rand01(seed, index, 0);
|
|
45
|
+
const phi = 2 * Math.PI * rand01(seed, index, 1);
|
|
46
|
+
if (!basis) {
|
|
47
|
+
const z = 2 * u - 1;
|
|
48
|
+
const r = Math.sqrt(Math.max(0, 1 - z * z));
|
|
49
|
+
return { x: r * Math.cos(phi), y: r * Math.sin(phi), z };
|
|
50
|
+
}
|
|
51
|
+
const cos = 1 - u * (1 - Math.cos(spread));
|
|
52
|
+
const sin = Math.sqrt(Math.max(0, 1 - cos * cos));
|
|
53
|
+
const { d, right, up } = basis;
|
|
54
|
+
return {
|
|
55
|
+
x: d.x * cos + (right.x * Math.cos(phi) + up.x * Math.sin(phi)) * sin,
|
|
56
|
+
y: d.y * cos + (right.y * Math.cos(phi) + up.y * Math.sin(phi)) * sin,
|
|
57
|
+
z: d.z * cos + (right.z * Math.cos(phi) + up.z * Math.sin(phi)) * sin,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
maxLive: Math.ceil((lifeMs * rate) / 1000) + 1,
|
|
62
|
+
spawnGapMs,
|
|
63
|
+
lifeMs,
|
|
64
|
+
sample(index, t) {
|
|
65
|
+
if (index < 0)
|
|
66
|
+
return undefined;
|
|
67
|
+
const age = t - index * spawnGapMs;
|
|
68
|
+
if (age < 0 || age >= lifeMs)
|
|
69
|
+
return undefined;
|
|
70
|
+
const seconds = age / 1000;
|
|
71
|
+
const dir = directionOf(index);
|
|
72
|
+
const particleSpeed = speed * (0.6 + 0.8 * rand01(seed, index, 2));
|
|
73
|
+
const travel = particleSpeed * seconds;
|
|
74
|
+
const pull = gravity ? 0.5 * seconds * seconds : 0;
|
|
75
|
+
const life = age / lifeMs;
|
|
76
|
+
const roll = alignToMotion
|
|
77
|
+
? Math.atan2(dir.y * particleSpeed + (gravity?.y ?? 0) * seconds, dir.x * particleSpeed + (gravity?.x ?? 0) * seconds)
|
|
78
|
+
: 2 * Math.PI * rand01(seed, index, 3)
|
|
79
|
+
+ (2 * rand01(seed, index, 4) - 1) * spin * age;
|
|
80
|
+
return {
|
|
81
|
+
position: {
|
|
82
|
+
x: dir.x * travel + (gravity ? gravity.x * pull : 0),
|
|
83
|
+
y: dir.y * travel + (gravity ? gravity.y * pull : 0),
|
|
84
|
+
z: dir.z * travel + (gravity ? gravity.z * pull : 0),
|
|
85
|
+
},
|
|
86
|
+
roll,
|
|
87
|
+
size: size * (0.7 + 0.6 * rand01(seed, index, 5)),
|
|
88
|
+
opacity: peak * smoothstep(0, FADE_IN_END, life) * (1 - smoothstep(FADE_OUT_START, 1, life)),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A stream of camera-facing billboard particles: a burst, a fountain, drifting
|
|
95
|
+
* embers - shaped by the emission options. Particles fade in, drift under
|
|
96
|
+
* `gravity`, and fade out; the runner triggers the lifecycle: {@link enter}
|
|
97
|
+
* starts emission, {@link exit} stops it and lets the live particles die out
|
|
98
|
+
* as the outro.
|
|
99
|
+
*/
|
|
100
|
+
export class ParticlesAnimation {
|
|
101
|
+
constructor(options = {}) {
|
|
102
|
+
this.handles = [];
|
|
103
|
+
this.fades = [];
|
|
104
|
+
this.enterAt = Infinity;
|
|
105
|
+
this.exitAt = Infinity;
|
|
106
|
+
this.finished = false;
|
|
107
|
+
this.field = particleField(options);
|
|
108
|
+
this.colors = options.colors ?? DEFAULT_COLORS;
|
|
109
|
+
this.backend = options.backend;
|
|
110
|
+
this.texture = options.texture;
|
|
111
|
+
this.labelContent = options.label;
|
|
112
|
+
this.fadeLabel = options.fadeLabel ?? true;
|
|
113
|
+
this.emitter = options.emitter;
|
|
114
|
+
this.outroMs = Math.max(0, options.outroMs ?? 0);
|
|
115
|
+
}
|
|
116
|
+
mount(target) {
|
|
117
|
+
if (!target.style.position)
|
|
118
|
+
target.style.position = "relative";
|
|
119
|
+
const meshes = this.colors.map((color) => quad(1, [color]));
|
|
120
|
+
const texture = this.texture;
|
|
121
|
+
const backend = texture
|
|
122
|
+
? async (rendererOptions) => {
|
|
123
|
+
const renderer = this.backend === "webgpu"
|
|
124
|
+
? new (await import("../engines/little-3d-engine/renderers/webgpu-textured.js")).WebGPUTexturedRenderer(rendererOptions)
|
|
125
|
+
: this.backend === "webgl"
|
|
126
|
+
? new (await import("../engines/little-3d-engine/renderers/webgl-textured.js")).WebGLTexturedRenderer(rendererOptions)
|
|
127
|
+
: new (await import("../engines/little-3d-engine/renderers/canvas2d-textured.js")).Canvas2DTexturedRenderer(rendererOptions);
|
|
128
|
+
for (const mesh of meshes)
|
|
129
|
+
renderer.setTexture(mesh, texture);
|
|
130
|
+
return renderer;
|
|
131
|
+
}
|
|
132
|
+
: this.backend;
|
|
133
|
+
const engine = new Little3dEngine({
|
|
134
|
+
backend,
|
|
135
|
+
camera: { position: { x: 0, y: 0, z: 3 } },
|
|
136
|
+
light: { intensity: 0, ambient: 1 },
|
|
137
|
+
});
|
|
138
|
+
for (let slot = 0; slot < this.field.maxLive; slot++) {
|
|
139
|
+
const fade = { mode: "one-sided", opacity: 0 };
|
|
140
|
+
this.fades.push(fade);
|
|
141
|
+
this.handles.push(engine.add(meshes[slot % meshes.length], { scale: 0, transparency: fade }));
|
|
142
|
+
}
|
|
143
|
+
this.engine = engine;
|
|
144
|
+
engine.mount(target).catch((error) => {
|
|
145
|
+
target.textContent = error instanceof Error ? error.message : String(error);
|
|
146
|
+
});
|
|
147
|
+
this.label = mountAnimationLabel(target, this.labelContent);
|
|
148
|
+
if (this.fadeLabel)
|
|
149
|
+
this.label.setOpacity(0);
|
|
150
|
+
}
|
|
151
|
+
enter(now) {
|
|
152
|
+
if (this.enterAt === Infinity)
|
|
153
|
+
this.enterAt = now;
|
|
154
|
+
}
|
|
155
|
+
exit(now) {
|
|
156
|
+
if (this.exitAt === Infinity)
|
|
157
|
+
this.exitAt = now;
|
|
158
|
+
}
|
|
159
|
+
isFinished() {
|
|
160
|
+
return this.finished;
|
|
161
|
+
}
|
|
162
|
+
render(now, frame) {
|
|
163
|
+
if (!this.engine || !this.label)
|
|
164
|
+
return;
|
|
165
|
+
if (this.exitAt !== Infinity && now >= this.exitAt + this.outroMs + this.field.lifeMs)
|
|
166
|
+
this.finished = true;
|
|
167
|
+
for (const handle of this.handles)
|
|
168
|
+
handle.transform.scale = 0;
|
|
169
|
+
if (this.enterAt !== Infinity) {
|
|
170
|
+
const t = now - this.enterAt;
|
|
171
|
+
const gap = this.field.spawnGapMs;
|
|
172
|
+
let first = Math.max(0, Math.ceil((t - this.field.lifeMs) / gap));
|
|
173
|
+
let last = Math.floor(t / gap);
|
|
174
|
+
if (this.exitAt !== Infinity) {
|
|
175
|
+
last = Math.min(last, Math.floor((this.exitAt - this.enterAt + this.outroMs) / gap));
|
|
176
|
+
}
|
|
177
|
+
first = Math.max(first, last - this.field.maxLive + 1);
|
|
178
|
+
for (let index = first; index <= last; index++) {
|
|
179
|
+
const sample = this.field.sample(index, t);
|
|
180
|
+
if (!sample)
|
|
181
|
+
continue;
|
|
182
|
+
const slot = index % this.handles.length;
|
|
183
|
+
const transform = this.handles[slot].transform;
|
|
184
|
+
const origin = this.emitter?.positionAt(this.enterAt + index * gap);
|
|
185
|
+
transform.position.x = sample.position.x + (origin?.x ?? 0);
|
|
186
|
+
transform.position.y = sample.position.y + (origin?.y ?? 0);
|
|
187
|
+
transform.position.z = sample.position.z + (origin?.z ?? 0);
|
|
188
|
+
transform.rotation.z = sample.roll;
|
|
189
|
+
transform.scale = sample.size;
|
|
190
|
+
this.fades[slot].opacity = sample.opacity;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.label.setText(frame.indeterminate
|
|
194
|
+
? (typeof this.labelContent === "string" ? this.labelContent : "")
|
|
195
|
+
: `${Math.round(frame.progress * 100)}%`);
|
|
196
|
+
if (this.fadeLabel) {
|
|
197
|
+
this.label.setOpacity(animationLabelOpacity(now, this.enterAt, this.field.lifeMs * FADE_IN_END, this.exitAt, this.field.lifeMs));
|
|
198
|
+
}
|
|
199
|
+
this.engine.render();
|
|
200
|
+
}
|
|
201
|
+
destroy() {
|
|
202
|
+
this.label?.container.remove();
|
|
203
|
+
this.label = undefined;
|
|
204
|
+
this.engine?.destroy();
|
|
205
|
+
this.engine = undefined;
|
|
206
|
+
this.handles.length = 0;
|
|
207
|
+
this.fades.length = 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
|
|
2
|
+
import { type Backend } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
export interface RocketLaunchOptions {
|
|
4
|
+
/** Rendering backend. Default `"canvas2d"`. */
|
|
5
|
+
backend?: Backend;
|
|
6
|
+
/** Overlay label; progress mode shows a percentage. */
|
|
7
|
+
label?: AnimationLabel;
|
|
8
|
+
/** Fade the label with the story's beginning and launch. Default `true`. */
|
|
9
|
+
fadeLabel?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A progress story told by a launch pad. Every 5% of progress a small rocket
|
|
13
|
+
* slides in cartoon-style from the right and lines up left-to-right under the
|
|
14
|
+
* progress text, idling with a thin wisp of smoke. At 100% the whole row blasts
|
|
15
|
+
* off in a loose stagger on columns of fire; partway up three of them suddenly
|
|
16
|
+
* veer 30-50 degrees and streak away. Rocket count follows the reported
|
|
17
|
+
* progress, so scrubbing in either direction stays smooth.
|
|
18
|
+
*/
|
|
19
|
+
export declare class RocketLaunchAnimation implements SpinnerAnimation {
|
|
20
|
+
private engine?;
|
|
21
|
+
private label?;
|
|
22
|
+
private observer?;
|
|
23
|
+
private readonly backend?;
|
|
24
|
+
private readonly labelContent?;
|
|
25
|
+
private readonly fadeLabel;
|
|
26
|
+
private readonly rockets;
|
|
27
|
+
private readonly smoke;
|
|
28
|
+
private readonly fire;
|
|
29
|
+
private readonly smokeFades;
|
|
30
|
+
private readonly fireFades;
|
|
31
|
+
private readonly blends;
|
|
32
|
+
private readonly groundedAt;
|
|
33
|
+
private readonly turnS;
|
|
34
|
+
private readonly turnDir;
|
|
35
|
+
private readonly turnRoll;
|
|
36
|
+
private readonly stagger;
|
|
37
|
+
private aspect;
|
|
38
|
+
private enterAt;
|
|
39
|
+
private exitAt;
|
|
40
|
+
private launchedAt;
|
|
41
|
+
private lastNow;
|
|
42
|
+
private finished;
|
|
43
|
+
constructor(options?: RocketLaunchOptions);
|
|
44
|
+
mount(target: HTMLElement): void;
|
|
45
|
+
enter(now: number): void;
|
|
46
|
+
exit(now: number): void;
|
|
47
|
+
isFinished(): boolean;
|
|
48
|
+
render(now: number, frame: AnimationFrame): void;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
private updateBlends;
|
|
51
|
+
/** Along-track distance climbed `la` ms after this rocket's own blast-off. */
|
|
52
|
+
private ascentDistance;
|
|
53
|
+
/** Rocket center, nose direction, and roll `la` ms into its climb. */
|
|
54
|
+
private ascentPose;
|
|
55
|
+
private renderAscent;
|
|
56
|
+
private emitFire;
|
|
57
|
+
private emitSmoke;
|
|
58
|
+
}
|