babylon.quarks 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1194 -8
- package/README.md +13 -9
- package/dist/babylon.quarks.cjs +1 -1
- package/dist/babylon.quarks.cjs.map +1 -1
- package/dist/babylon.quarks.esm.js +1 -1
- package/dist/babylon.quarks.esm.js.map +1 -1
- package/dist/babylon.quarks.umd.min.js +1 -1
- package/dist/babylon.quarks.umd.min.js.map +1 -1
- package/dist/types/index.d.cts +10108 -0
- package/dist/types/index.d.ts +5794 -998
- package/package.json +18 -7
- package/src/BatchedRenderer.ts +274 -156
- package/src/EffectPool.ts +94 -12
- package/src/MeshSurfaceEmitter.ts +50 -14
- package/src/ParticleEmitter.ts +75 -17
- package/src/ParticleSystem.ts +981 -2066
- package/src/QuarksLoader.ts +666 -194
- package/src/QuarksPrefab.ts +367 -73
- package/src/QuarksSerializer.ts +512 -0
- package/src/QuarksUtil.ts +77 -7
- package/src/SpriteBatch.ts +127 -252
- package/src/TrailBatch.ts +545 -348
- package/src/VFXBatch.ts +238 -596
- package/src/batchKey.ts +239 -0
- package/src/batchSettings.ts +546 -0
- package/src/effectDocumentJSON.ts +261 -0
- package/src/effectResources.ts +76 -0
- package/src/envAtlas.ts +126 -18
- package/src/geometryUtil.ts +1 -1
- package/src/index.ts +69 -35
- package/src/nodeId.ts +36 -0
- package/src/shaderProgram.ts +419 -0
- package/src/shaderVariants.ts +245 -0
- package/src/shaders/chunks/blend.ts +31 -0
- package/src/shaders/chunks/fog.ts +12 -5
- package/src/shaders/chunks/sheet.ts +111 -0
- package/src/shaders/chunks/soft.ts +85 -0
- package/src/shaders/chunks/surface.ts +12 -2
- package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
- package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
- package/src/shaders/particle_frag.glsl.ts +5 -22
- package/src/shaders/particle_frag.wgsl.ts +5 -23
- package/src/shaders/particle_physics_frag.glsl.ts +16 -26
- package/src/shaders/particle_physics_frag.wgsl.ts +14 -26
- package/src/shaders/particle_vert.glsl.ts +5 -27
- package/src/shaders/particle_vert.wgsl.ts +5 -26
- package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
- package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
- package/src/shaders/trail_frag.glsl.ts +13 -0
- package/src/shaders/trail_frag.wgsl.ts +13 -0
- package/src/shaders/trail_vert.glsl.ts +45 -36
- package/src/shaders/trail_vert.wgsl.ts +46 -40
- package/src/softDepth.ts +136 -0
- package/src/system/BehaviorPlan.ts +85 -0
- package/src/system/BoundsTracker.ts +181 -0
- package/src/system/EmissionController.ts +358 -0
- package/src/system/EventDispatcher.ts +60 -0
- package/src/system/ParticleSpawner.ts +274 -0
- package/src/system/SimulationClock.ts +130 -0
- package/src/system/geometry.ts +104 -0
- package/src/system/integrate.ts +428 -0
- package/src/system/materialResolve.ts +165 -0
- package/src/system/normalize.ts +100 -0
- package/src/system/serialization.ts +422 -0
- package/src/system/settingsSchema.ts +339 -0
- package/src/system/types.ts +449 -0
- package/src/textureSource.ts +30 -0
- package/src/trailSettings.ts +54 -0
- package/src/BatchedParticleRenderer.ts +0 -3
- package/src/materials/ParticleMaterials.ts +0 -23
- package/src/materials/index.ts +0 -1
- package/src/shaders/chunks/index.ts +0 -1
- package/src/shaders/chunks/register-shader-chunks.ts +0 -32
- package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
- package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
- package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
- package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
- package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
- package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
- package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
- package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
- package/src/shaders/index.ts +0 -9
- package/src/shaders/local_particle_vert.glsl.ts +0 -50
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Length of one simulation step, in seconds: the simulation runs at a fixed 60 Hz. */
|
|
2
|
+
export const SIMULATION_STEP = 1 / 60;
|
|
3
|
+
|
|
4
|
+
/** Most steps one host frame runs at playback speed 1; a faster speed raises the cap in proportion. */
|
|
5
|
+
export const MAX_STEPS_PER_FRAME = 8;
|
|
6
|
+
|
|
7
|
+
/** Most host time one frame simulates, in seconds, so a stall or a tab in the background is not replayed. */
|
|
8
|
+
export const MAX_FRAME_SECONDS = 0.1;
|
|
9
|
+
|
|
10
|
+
/** Most off-screen time a `'pauseAndCatchUp'` system replays on coming back into view, in seconds. */
|
|
11
|
+
export const MAX_CATCH_UP_SECONDS = 2;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Math.round() breaks an exact tie (the accumulator sitting at precisely half a step) by rounding
|
|
15
|
+
* up. At exactly 120 Hz that tie is not a corner case, it is every frame, and rounding it up would
|
|
16
|
+
* run a step on every one of them instead of every other one. Nudging the tie down costs nothing on
|
|
17
|
+
* a real display (real frame times essentially never land on the tie exactly) and keeps the
|
|
18
|
+
* well-tested "half a step is no step yet" behaviour.
|
|
19
|
+
*/
|
|
20
|
+
const ROUND_TIE_BIAS = 1e-9;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The fixed-step clock of a particle system: turns the host's uneven frames into whole 60 Hz
|
|
24
|
+
* steps, and keeps the start delay and the time a culled system still owes. Pure numbers, so the
|
|
25
|
+
* frame-rate rules can be tested without a scene.
|
|
26
|
+
*
|
|
27
|
+
* The steps a frame runs are chosen by *rounding* the accumulated time to the nearest step, not
|
|
28
|
+
* flooring it. Flooring always leaves the accumulator positive, and when the real frame rate sits
|
|
29
|
+
* very close to 60 Hz (as most displays do) that leftover creeps toward a full step for many frames
|
|
30
|
+
* before tipping over into a frame that runs two steps at once and resets it to near zero: a
|
|
31
|
+
* visible lurch, every particle at once, with a perfectly steady frame rate. Rounding lets the
|
|
32
|
+
* leftover go negative instead, which keeps it centred near zero. The renderer closes the gap by
|
|
33
|
+
* drawing each particle advanced along its velocity by {@link residual}.
|
|
34
|
+
*/
|
|
35
|
+
export class SimulationClock {
|
|
36
|
+
/** Simulated time not yet covered by a whole step, in seconds; negative when a step ran early. */
|
|
37
|
+
private accumulator = 0;
|
|
38
|
+
/** Off-screen time a `'pauseAndCatchUp'` system has not simulated yet, in seconds. */
|
|
39
|
+
private pendingCatchUp = 0;
|
|
40
|
+
/** Start delay still to wait out, in seconds. */
|
|
41
|
+
private delayLeft = 0;
|
|
42
|
+
|
|
43
|
+
/** Back to the start of a play: nothing accumulated, nothing owed. The start delay is set apart. */
|
|
44
|
+
reset(): void {
|
|
45
|
+
this.accumulator = 0;
|
|
46
|
+
this.pendingCatchUp = 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sets the start delay to wait out before the first step; a negative or NaN delay is none. */
|
|
50
|
+
setStartDelay(seconds: number): void {
|
|
51
|
+
this.delayLeft = seconds > 0 ? seconds : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether a start delay is still being waited out. */
|
|
55
|
+
get delaying(): boolean {
|
|
56
|
+
return this.delayLeft > 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The simulated seconds a host frame of `hostDelta` seconds stands for at `playbackSpeed`. The
|
|
61
|
+
* host time is clamped to {@link MAX_FRAME_SECONDS} first, so a fast playback speed advances
|
|
62
|
+
* its full share of a normal frame (speed 10 at 60 fps simulates 10 seconds per second) while a
|
|
63
|
+
* stalled frame is still not replayed. A negative or NaN frame is no time at all.
|
|
64
|
+
*/
|
|
65
|
+
static frameSeconds(hostDelta: number, playbackSpeed: number): number {
|
|
66
|
+
if (!(hostDelta > 0)) return 0;
|
|
67
|
+
const clamped = hostDelta > MAX_FRAME_SECONDS ? MAX_FRAME_SECONDS : hostDelta;
|
|
68
|
+
return clamped * playbackSpeed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Takes `delta` seconds off the start delay and returns what is left over for the simulation:
|
|
73
|
+
* 0 while the delay runs, the remainder of the frame that ends it (at most one frame's worth, so
|
|
74
|
+
* a long frame does not burst the first steps), and `delta` itself once there is no delay.
|
|
75
|
+
*/
|
|
76
|
+
consumeStartDelay(delta: number): number {
|
|
77
|
+
if (this.delayLeft <= 0) {
|
|
78
|
+
return delta;
|
|
79
|
+
}
|
|
80
|
+
this.delayLeft -= delta;
|
|
81
|
+
if (this.delayLeft >= 0) {
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
const left = Math.min(-this.delayLeft, MAX_FRAME_SECONDS);
|
|
85
|
+
this.delayLeft = 0;
|
|
86
|
+
return left;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Adds `delta` simulated seconds and returns how many whole steps to run for them, at most
|
|
91
|
+
* {@link MAX_STEPS_PER_FRAME} scaled by `playbackSpeed` above 1. The steps' time is taken off
|
|
92
|
+
* the accumulator whether or not the caller runs them all, so a frame that cannot afford its
|
|
93
|
+
* steps drops their time rather than carrying it into the next frame.
|
|
94
|
+
*/
|
|
95
|
+
accrue(delta: number, playbackSpeed = 1): number {
|
|
96
|
+
this.accumulator += delta;
|
|
97
|
+
const cap = playbackSpeed > 1 ? Math.ceil(MAX_STEPS_PER_FRAME * playbackSpeed) : MAX_STEPS_PER_FRAME;
|
|
98
|
+
const steps = Math.min(cap, Math.max(0, Math.round(this.accumulator / SIMULATION_STEP - ROUND_TIE_BIAS)));
|
|
99
|
+
this.accumulator -= steps * SIMULATION_STEP;
|
|
100
|
+
return steps;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Time the display is ahead of the simulation (positive) or behind it (negative, the simulation
|
|
105
|
+
* ran a hair early), in seconds. Its magnitude never exceeds one step, even when the per-frame
|
|
106
|
+
* step cap left more than that accumulated.
|
|
107
|
+
*/
|
|
108
|
+
get residual(): number {
|
|
109
|
+
if (this.accumulator > SIMULATION_STEP) return SIMULATION_STEP;
|
|
110
|
+
if (this.accumulator < -SIMULATION_STEP) return -SIMULATION_STEP;
|
|
111
|
+
return this.accumulator;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Remembers `delta` simulated seconds a culled system missed, up to {@link MAX_CATCH_UP_SECONDS}. */
|
|
115
|
+
deferCatchUp(delta: number): void {
|
|
116
|
+
this.pendingCatchUp = Math.min(MAX_CATCH_UP_SECONDS, this.pendingCatchUp + delta);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether a culled system still owes steps. */
|
|
120
|
+
get owesCatchUp(): boolean {
|
|
121
|
+
return this.pendingCatchUp > 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The whole steps a system owes from its time off screen, and forgets them. */
|
|
125
|
+
takeCatchUpSteps(): number {
|
|
126
|
+
const steps = Math.floor(this.pendingCatchUp / SIMULATION_STEP);
|
|
127
|
+
this.pendingCatchUp = 0;
|
|
128
|
+
return steps;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type {BufferGeometryDataJSON, GeometryArrays, GeometryEntry} from '../effectDocumentJSON';
|
|
2
|
+
import {ensureTriangleIndices} from '../geometryUtil';
|
|
3
|
+
|
|
4
|
+
/** Positions of the unit quad billboards are drawn with, and the default mesh. */
|
|
5
|
+
export const DEFAULT_POSITIONS = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]);
|
|
6
|
+
/** UVs of the default quad. */
|
|
7
|
+
export const DEFAULT_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
|
|
8
|
+
/** Indices of the default quad's two triangles. */
|
|
9
|
+
export const DEFAULT_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
|
|
10
|
+
|
|
11
|
+
/** Zero UVs sized to a position buffer — missing/mismatched uv attrs → GL_INVALID_OPERATION on iOS. */
|
|
12
|
+
const fallbackUVs = new WeakMap<Float32Array, Float32Array>();
|
|
13
|
+
const fallbackNormals = new WeakMap<Float32Array, Float32Array>();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Zero UVs for a geometry given without any, one array per geometry: batches
|
|
17
|
+
* are keyed by the UV array too, so systems sharing a geometry share it.
|
|
18
|
+
*/
|
|
19
|
+
export function fallbackUVsForPositions(positions: Float32Array): Float32Array {
|
|
20
|
+
let uvs = fallbackUVs.get(positions);
|
|
21
|
+
if (uvs === undefined || uvs.length !== (positions.length / 3) * 2) {
|
|
22
|
+
uvs = new Float32Array((positions.length / 3) * 2);
|
|
23
|
+
fallbackUVs.set(positions, uvs);
|
|
24
|
+
}
|
|
25
|
+
return uvs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether `uvs` are the ones {@link fallbackUVsForPositions} made for `positions`. */
|
|
29
|
+
export function isFallbackUVs(uvs: Float32Array | undefined, positions: Float32Array): boolean {
|
|
30
|
+
return uvs !== undefined && uvs === fallbackUVs.get(positions);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normals facing +Z, one array per geometry so systems sharing it still batch together. */
|
|
34
|
+
export function fallbackNormalsForPositions(positions: Float32Array): Float32Array {
|
|
35
|
+
let normals = fallbackNormals.get(positions);
|
|
36
|
+
if (!normals) {
|
|
37
|
+
normals = new Float32Array(positions.length);
|
|
38
|
+
for (let i = 2; i < normals.length; i += 3) {
|
|
39
|
+
normals[i] = 1;
|
|
40
|
+
}
|
|
41
|
+
fallbackNormals.set(positions, normals);
|
|
42
|
+
}
|
|
43
|
+
return normals;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `normals` are the ones {@link fallbackNormalsForPositions} made for `positions`. */
|
|
47
|
+
export function isFallbackNormals(normals: Float32Array | undefined, positions: Float32Array): boolean {
|
|
48
|
+
return normals !== undefined && normals === fallbackNormals.get(positions);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `values` as a Float32Array: itself when it is one, else a copy. */
|
|
52
|
+
function floats(values: number[] | Float32Array | undefined): Float32Array | undefined {
|
|
53
|
+
return values ? (values instanceof Float32Array ? values : new Float32Array(values)) : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Typed arrays from a geometry entry of an effect document: quarks' own `{positions, indices, uvs,
|
|
58
|
+
* uvs2, normals}`, or a three.js `BufferGeometry` JSON or its `data`. Anything else gives the
|
|
59
|
+
* default quad.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveGeometryData(entry: GeometryEntry | BufferGeometryDataJSON | undefined): GeometryArrays {
|
|
62
|
+
if (entry && 'positions' in entry && entry.positions) {
|
|
63
|
+
const positions = floats(entry.positions)!;
|
|
64
|
+
const rawIndices =
|
|
65
|
+
entry.indices instanceof Uint16Array || entry.indices instanceof Uint32Array
|
|
66
|
+
? entry.indices
|
|
67
|
+
: entry.indices
|
|
68
|
+
? new Uint32Array(entry.indices)
|
|
69
|
+
: undefined;
|
|
70
|
+
return {
|
|
71
|
+
positions,
|
|
72
|
+
indices: ensureTriangleIndices(positions, rawIndices),
|
|
73
|
+
uvs: floats(entry.uvs),
|
|
74
|
+
uvs2: floats(entry.uvs2),
|
|
75
|
+
normals: floats(entry.normals),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = entry && 'data' in entry ? entry.data : entry;
|
|
80
|
+
const attributes = data && 'attributes' in data ? data.attributes : undefined;
|
|
81
|
+
const position = attributes?.position?.array;
|
|
82
|
+
if (data && 'attributes' in data && attributes && position) {
|
|
83
|
+
const positions = new Float32Array(position);
|
|
84
|
+
let indices: Uint32Array | Uint16Array | undefined;
|
|
85
|
+
if (data.index?.array) {
|
|
86
|
+
const indexType = data.index.type === 'Uint16Array' ? Uint16Array : Uint32Array;
|
|
87
|
+
indices = new indexType(data.index.array);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
positions,
|
|
91
|
+
indices: ensureTriangleIndices(positions, indices),
|
|
92
|
+
uvs: floats(attributes.uv?.array),
|
|
93
|
+
// three.js names the second set `uv1` (r151 on), `uv2` before.
|
|
94
|
+
uvs2: floats((attributes.uv1 ?? attributes.uv2)?.array),
|
|
95
|
+
normals: floats(attributes.normal?.array),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
positions: DEFAULT_POSITIONS,
|
|
101
|
+
indices: DEFAULT_INDICES,
|
|
102
|
+
uvs: DEFAULT_UVS,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Behavior,
|
|
3
|
+
FusionStep,
|
|
4
|
+
Matrix4,
|
|
5
|
+
Particle,
|
|
6
|
+
ParticleStore,
|
|
7
|
+
RendererEmitterSettings,
|
|
8
|
+
TrailParticle,
|
|
9
|
+
TrailSettings,
|
|
10
|
+
Vector3,
|
|
11
|
+
} from 'quarks.core';
|
|
12
|
+
import type {VFXBatchSettings} from '../BatchedRenderer';
|
|
13
|
+
import {trailLifetime, trailMinVertexDistance} from '../trailSettings';
|
|
14
|
+
import type {BoundsTracker} from './BoundsTracker';
|
|
15
|
+
import {SIMULATION_STEP} from './SimulationClock';
|
|
16
|
+
|
|
17
|
+
// The passes of one simulation step after emission: the snapshot the renderer extrapolates
|
|
18
|
+
// from, the behaviors, then the motion, ageing and removal of the sprite path (one pass over
|
|
19
|
+
// the store's columns) or of the trail path (over the particle objects their history lives on).
|
|
20
|
+
// Plain functions over the store and the particle array, so the hot loops stay monomorphic.
|
|
21
|
+
|
|
22
|
+
/** Particle backed by a {@link ParticleStore} row, as a system always creates. */
|
|
23
|
+
type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
|
|
24
|
+
|
|
25
|
+
/** Called with each particle that died this step, when the system has `particleDied` listeners. */
|
|
26
|
+
export type DeathListener = (particle: Particle) => void;
|
|
27
|
+
|
|
28
|
+
/** Settings the bounds measurement reads, see {@link BoundsTracker.store}. */
|
|
29
|
+
export interface BoundsSettings {
|
|
30
|
+
/** The render settings, for the particle size the bounds are padded by. */
|
|
31
|
+
rendererSettings: VFXBatchSettings;
|
|
32
|
+
/** The render mode's settings, for a stretched billboard's length. */
|
|
33
|
+
rendererEmitterSettings: RendererEmitterSettings;
|
|
34
|
+
/** Whether the positions are in world space already. */
|
|
35
|
+
worldSpace: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tempV = new Vector3();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remembers where everything starts this step, so the renderer can carry the motion the step
|
|
42
|
+
* produces on into the part of the frame that comes after it. Size and colour are only
|
|
43
|
+
* snapshotted when some behavior can change them; otherwise the columns stay what spawn wrote
|
|
44
|
+
* and the renderer reads them as they are. Only stretched billboards draw from velocity, and only
|
|
45
|
+
* they pay for keeping the previous one; `rebind` is called when keeping it first allocates its
|
|
46
|
+
* column, since every particle then has to be re-pointed at the store.
|
|
47
|
+
*/
|
|
48
|
+
export function snapshotPreviousState(
|
|
49
|
+
store: ParticleStore,
|
|
50
|
+
particleCount: number,
|
|
51
|
+
snapshotSize: boolean,
|
|
52
|
+
snapshotColor: boolean,
|
|
53
|
+
keepVelocity: boolean,
|
|
54
|
+
rebind: () => void
|
|
55
|
+
): void {
|
|
56
|
+
const vector3Rows = particleCount * 3;
|
|
57
|
+
store.useSizeSnapshot(snapshotSize);
|
|
58
|
+
store.useColorSnapshot(snapshotColor);
|
|
59
|
+
store.previousPosition.set(store.position.subarray(0, vector3Rows));
|
|
60
|
+
if (snapshotSize) {
|
|
61
|
+
store.previousSize.set(store.size.subarray(0, vector3Rows));
|
|
62
|
+
}
|
|
63
|
+
if (keepVelocity) {
|
|
64
|
+
if (store.keepPreviousVelocity()) {
|
|
65
|
+
rebind();
|
|
66
|
+
}
|
|
67
|
+
store.previousVelocity.set(store.velocity.subarray(0, vector3Rows));
|
|
68
|
+
}
|
|
69
|
+
if (snapshotColor) {
|
|
70
|
+
store.previousColor.set(store.color.subarray(0, particleCount * 4));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Runs the behaviors' frame updates and then each step of the plan over the live particles. */
|
|
75
|
+
export function runBehaviorSteps(
|
|
76
|
+
steps: ReadonlyArray<FusionStep>,
|
|
77
|
+
behaviors: Behavior[],
|
|
78
|
+
particles: Particle[],
|
|
79
|
+
particleCount: number,
|
|
80
|
+
delta: number,
|
|
81
|
+
store: ParticleStore
|
|
82
|
+
): void {
|
|
83
|
+
for (let s = 0; s < steps.length; s++) {
|
|
84
|
+
const step = steps[s];
|
|
85
|
+
const stepBehaviors = step.behaviors;
|
|
86
|
+
for (let j = 0; j < stepBehaviors.length; j++) {
|
|
87
|
+
stepBehaviors[j].frameUpdate(delta);
|
|
88
|
+
}
|
|
89
|
+
// Nothing to update: skip, matching what the per-particle loop did.
|
|
90
|
+
// A behavior may set up state before its loop that is only valid
|
|
91
|
+
// once it has seen a particle.
|
|
92
|
+
if (particleCount === 0) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (step.run !== undefined) {
|
|
96
|
+
step.run(behaviors, particles, particleCount, delta, store);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const behavior = stepBehaviors[0];
|
|
100
|
+
if (behavior.updateAll !== undefined) {
|
|
101
|
+
behavior.updateAll(particles, particleCount, delta);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (let i = 0; i < particleCount; i++) {
|
|
105
|
+
const particle = particles[i];
|
|
106
|
+
// Inlined `died` — this runs once per behavior per particle.
|
|
107
|
+
if (particle.age < particle.life) {
|
|
108
|
+
behavior.update(particle, delta);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Moves a dead particle's slot and row to the end of the live range, so slot k always owns
|
|
116
|
+
* row k and the live particles stay one contiguous range the renderer copies as is. Returns
|
|
117
|
+
* the particle that died.
|
|
118
|
+
*/
|
|
119
|
+
function retire(particles: Particle[], store: ParticleStore, i: number, last: number): Particle {
|
|
120
|
+
const particle = particles[i];
|
|
121
|
+
const survivor = particles[last] as StoreBackedParticle;
|
|
122
|
+
particles[i] = survivor;
|
|
123
|
+
particles[last] = particle;
|
|
124
|
+
if (i !== last) {
|
|
125
|
+
store.swapRows(i, last);
|
|
126
|
+
survivor.setStoreIndex(i);
|
|
127
|
+
(particle as StoreBackedParticle).setStoreIndex(last);
|
|
128
|
+
}
|
|
129
|
+
return particle;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The sprite path's step after the behaviors, as one pass over the columns that does three
|
|
134
|
+
* things, none of which touches a particle object until a death. Returns the live count.
|
|
135
|
+
*
|
|
136
|
+
* Newborns were snapshotted with their spawn size and colour, before SizeOverLife and
|
|
137
|
+
* ColorOverLife ran. That false jump would be extrapolated by the renderer when the residual is
|
|
138
|
+
* negative (common with the rounded fixed step) and flash the start appearance at the emitter,
|
|
139
|
+
* so the snapshot is collapsed onto the curves' t=0 value. Age is still 0 at that point; the
|
|
140
|
+
* same iteration then ages it.
|
|
141
|
+
*
|
|
142
|
+
* Position is integrated along the velocity.
|
|
143
|
+
*
|
|
144
|
+
* A particle whose age reached its life is swapped with the last live one, rows included. The
|
|
145
|
+
* survivor moved into the slot has not been visited yet, so the slot is processed again.
|
|
146
|
+
*
|
|
147
|
+
* The bounds fall out of the same pass. Measuring every particle on every step costs about as
|
|
148
|
+
* much as the integration itself, so the full measurement runs every few steps; in between,
|
|
149
|
+
* newborns extend the last bounds and the fastest particle seen since pads them for the motion,
|
|
150
|
+
* which keeps them conservative.
|
|
151
|
+
*/
|
|
152
|
+
export function integrateSprites(
|
|
153
|
+
delta: number,
|
|
154
|
+
particles: Particle[],
|
|
155
|
+
store: ParticleStore,
|
|
156
|
+
liveParticleCount: number,
|
|
157
|
+
collapseSize: boolean,
|
|
158
|
+
collapseColor: boolean,
|
|
159
|
+
bounds: BoundsTracker,
|
|
160
|
+
settings: BoundsSettings,
|
|
161
|
+
onDied: DeathListener | undefined
|
|
162
|
+
): number {
|
|
163
|
+
const scalars = store.scalars;
|
|
164
|
+
const stride = ParticleStore.SCALAR_STRIDE;
|
|
165
|
+
const positions = store.position;
|
|
166
|
+
const velocities = store.velocity;
|
|
167
|
+
const prevSize = store.previousSize;
|
|
168
|
+
const curSize = store.size;
|
|
169
|
+
const prevColor = store.previousColor;
|
|
170
|
+
const curColor = store.color;
|
|
171
|
+
const measure = bounds.due;
|
|
172
|
+
const carriedMin = bounds.min;
|
|
173
|
+
const carriedMax = bounds.max;
|
|
174
|
+
let minX = measure ? Infinity : carriedMin.x;
|
|
175
|
+
let minY = measure ? Infinity : carriedMin.y;
|
|
176
|
+
let minZ = measure ? Infinity : carriedMin.z;
|
|
177
|
+
let maxX = measure ? -Infinity : carriedMax.x;
|
|
178
|
+
let maxY = measure ? -Infinity : carriedMax.y;
|
|
179
|
+
let maxZ = measure ? -Infinity : carriedMax.z;
|
|
180
|
+
let maxSize = measure ? 0 : bounds.maxSize;
|
|
181
|
+
let maxSpeedSq = measure ? 0 : bounds.maxSpeedSq;
|
|
182
|
+
for (let i = 0; i < liveParticleCount; i++) {
|
|
183
|
+
const scalar = i * stride;
|
|
184
|
+
let age = scalars[scalar + ParticleStore.AGE];
|
|
185
|
+
const offset = i * 3;
|
|
186
|
+
const track = measure || age === 0;
|
|
187
|
+
if (age === 0) {
|
|
188
|
+
if (collapseSize) {
|
|
189
|
+
prevSize[offset] = curSize[offset];
|
|
190
|
+
prevSize[offset + 1] = curSize[offset + 1];
|
|
191
|
+
prevSize[offset + 2] = curSize[offset + 2];
|
|
192
|
+
}
|
|
193
|
+
if (collapseColor) {
|
|
194
|
+
const o4 = i * 4;
|
|
195
|
+
prevColor[o4] = curColor[o4];
|
|
196
|
+
prevColor[o4 + 1] = curColor[o4 + 1];
|
|
197
|
+
prevColor[o4 + 2] = curColor[o4 + 2];
|
|
198
|
+
prevColor[o4 + 3] = curColor[o4 + 3];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
|
|
202
|
+
const vx = velocities[offset];
|
|
203
|
+
const vy = velocities[offset + 1];
|
|
204
|
+
const vz = velocities[offset + 2];
|
|
205
|
+
const x = positions[offset] + vx * step;
|
|
206
|
+
const y = positions[offset + 1] + vy * step;
|
|
207
|
+
const z = positions[offset + 2] + vz * step;
|
|
208
|
+
positions[offset] = x;
|
|
209
|
+
positions[offset + 1] = y;
|
|
210
|
+
positions[offset + 2] = z;
|
|
211
|
+
if (track) {
|
|
212
|
+
minX = Math.min(minX, x);
|
|
213
|
+
maxX = Math.max(maxX, x);
|
|
214
|
+
minY = Math.min(minY, y);
|
|
215
|
+
maxY = Math.max(maxY, y);
|
|
216
|
+
minZ = Math.min(minZ, z);
|
|
217
|
+
maxZ = Math.max(maxZ, z);
|
|
218
|
+
maxSize = Math.max(maxSize, Math.max(curSize[offset], Math.max(curSize[offset + 1], curSize[offset + 2])));
|
|
219
|
+
}
|
|
220
|
+
// Speed is tracked every step: it bounds how far the particles
|
|
221
|
+
// can have moved since the last measurement.
|
|
222
|
+
maxSpeedSq = Math.max(maxSpeedSq, vx * vx + vy * vy + vz * vz);
|
|
223
|
+
age += delta;
|
|
224
|
+
scalars[scalar + ParticleStore.AGE] = age;
|
|
225
|
+
if (age >= scalars[scalar + ParticleStore.LIFE]) {
|
|
226
|
+
const particle = retire(particles, store, i, liveParticleCount - 1);
|
|
227
|
+
liveParticleCount--;
|
|
228
|
+
i--;
|
|
229
|
+
if (onDied !== undefined) {
|
|
230
|
+
onDied(particle);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
bounds.store(
|
|
235
|
+
liveParticleCount,
|
|
236
|
+
measure,
|
|
237
|
+
minX,
|
|
238
|
+
minY,
|
|
239
|
+
minZ,
|
|
240
|
+
maxX,
|
|
241
|
+
maxY,
|
|
242
|
+
maxZ,
|
|
243
|
+
maxSize,
|
|
244
|
+
maxSpeedSq,
|
|
245
|
+
settings.rendererSettings,
|
|
246
|
+
settings.rendererEmitterSettings,
|
|
247
|
+
settings.worldSpace,
|
|
248
|
+
SIMULATION_STEP
|
|
249
|
+
);
|
|
250
|
+
return liveParticleCount;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The newborn collapse of the trail path, see {@link integrateSprites}; trails keep their history on the objects, so it is its own pass. */
|
|
254
|
+
export function collapseTrailNewborns(
|
|
255
|
+
store: ParticleStore,
|
|
256
|
+
particleCount: number,
|
|
257
|
+
snapshotSize: boolean,
|
|
258
|
+
snapshotColor: boolean
|
|
259
|
+
): void {
|
|
260
|
+
const scalars = store.scalars;
|
|
261
|
+
const stride = ParticleStore.SCALAR_STRIDE;
|
|
262
|
+
const prevSize = store.previousSize;
|
|
263
|
+
const curSize = store.size;
|
|
264
|
+
const prevColor = store.previousColor;
|
|
265
|
+
const curColor = store.color;
|
|
266
|
+
for (let i = 0, o3 = 0, o4 = 0, s = 0; i < particleCount; i++, o3 += 3, o4 += 4, s += stride) {
|
|
267
|
+
if (scalars[s + ParticleStore.AGE] !== 0) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (snapshotSize) {
|
|
271
|
+
prevSize[o3] = curSize[o3];
|
|
272
|
+
prevSize[o3 + 1] = curSize[o3 + 1];
|
|
273
|
+
prevSize[o3 + 2] = curSize[o3 + 2];
|
|
274
|
+
}
|
|
275
|
+
if (snapshotColor) {
|
|
276
|
+
prevColor[o4] = curColor[o4];
|
|
277
|
+
prevColor[o4 + 1] = curColor[o4 + 1];
|
|
278
|
+
prevColor[o4 + 2] = curColor[o4 + 2];
|
|
279
|
+
prevColor[o4 + 3] = curColor[o4 + 3];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Moves and ages the trail particles: a trail that follows its local origin is placed by its
|
|
286
|
+
* emitter (or parent) matrix every step, any other along its velocity.
|
|
287
|
+
*/
|
|
288
|
+
export function moveTrails(
|
|
289
|
+
delta: number,
|
|
290
|
+
particles: Particle[],
|
|
291
|
+
store: ParticleStore,
|
|
292
|
+
particleCount: number,
|
|
293
|
+
followLocalOrigin: boolean,
|
|
294
|
+
emitterMatrix: Matrix4
|
|
295
|
+
): void {
|
|
296
|
+
if (followLocalOrigin) {
|
|
297
|
+
for (let i = 0; i < particleCount; i++) {
|
|
298
|
+
const particle = particles[i];
|
|
299
|
+
if ((particle as TrailParticle).localPosition) {
|
|
300
|
+
particle.position.copy((particle as TrailParticle).localPosition!);
|
|
301
|
+
particle.position.applyMatrix4(particle.parentMatrix ?? emitterMatrix);
|
|
302
|
+
} else {
|
|
303
|
+
particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
|
|
304
|
+
}
|
|
305
|
+
particle.age += delta;
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const scalars = store.scalars;
|
|
310
|
+
const stride = ParticleStore.SCALAR_STRIDE;
|
|
311
|
+
const positions = store.position;
|
|
312
|
+
const velocities = store.velocity;
|
|
313
|
+
for (let i = 0, offset = 0, scalar = 0; i < particleCount; i++, offset += 3, scalar += stride) {
|
|
314
|
+
const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
|
|
315
|
+
positions[offset] += velocities[offset] * step;
|
|
316
|
+
positions[offset + 1] += velocities[offset + 1] * step;
|
|
317
|
+
positions[offset + 2] += velocities[offset + 2] * step;
|
|
318
|
+
scalars[scalar + ParticleStore.AGE] += delta;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Records this step's sample into each trail's history: its position (in world space when the
|
|
324
|
+
* trail asks for it and the system is not already there) and, for a swept trail, the side axis
|
|
325
|
+
* as the emitter holds it, in the space the positions are recorded in. With a minimum vertex
|
|
326
|
+
* distance the newest sample follows the particle until it is that far from the sample before;
|
|
327
|
+
* samples older than the trail's lifetime are dropped from the tail. A trail that dies with its
|
|
328
|
+
* particle has its history emptied instead, so the removal pass takes it this same step.
|
|
329
|
+
*/
|
|
330
|
+
export function recordTrailHistory(
|
|
331
|
+
particles: Particle[],
|
|
332
|
+
particleCount: number,
|
|
333
|
+
trailSettings: TrailSettings,
|
|
334
|
+
worldSpace: boolean,
|
|
335
|
+
emitterMatrix: Matrix4
|
|
336
|
+
): void {
|
|
337
|
+
const dieWithParticle = trailSettings.dieWithParticle;
|
|
338
|
+
// A world-space system records world positions already.
|
|
339
|
+
const recordWorldSpace = trailSettings.recordWorldSpace && !worldSpace;
|
|
340
|
+
const sideAxis = trailSettings.sideAxis;
|
|
341
|
+
const sidesInWorld = worldSpace || Boolean(trailSettings.recordWorldSpace);
|
|
342
|
+
const minDistance = trailMinVertexDistance(trailSettings);
|
|
343
|
+
const lifetime = trailLifetime(trailSettings);
|
|
344
|
+
for (let i = 0; i < particleCount; i++) {
|
|
345
|
+
const trail = particles[i] as TrailParticle;
|
|
346
|
+
// A particle at the end of its life is dead: it records nothing more and its trail fades.
|
|
347
|
+
const alive = trail.age < trail.life;
|
|
348
|
+
if (dieWithParticle && !alive) {
|
|
349
|
+
trail.resetHistory();
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// The newest sample follows the particle until it is the minimum distance from the sample
|
|
353
|
+
// before it; then it is fixed where it is and this step's sample starts a new one.
|
|
354
|
+
let replaceHead = false;
|
|
355
|
+
if (minDistance > 0 && alive && trail.historyCount > 1) {
|
|
356
|
+
const distances = trail.historyDistances;
|
|
357
|
+
const count = trail.historyCount;
|
|
358
|
+
replaceHead =
|
|
359
|
+
distances[trail.getHistoryIndex(count - 1)] - distances[trail.getHistoryIndex(count - 2)] < minDistance;
|
|
360
|
+
}
|
|
361
|
+
trail.update(replaceHead, !recordWorldSpace);
|
|
362
|
+
// update() wrote a sample only while the particle is alive.
|
|
363
|
+
if (recordWorldSpace && alive) {
|
|
364
|
+
const offset = ((trail.historyHead + trail.historyCapacity - 1) % trail.historyCapacity) * 3;
|
|
365
|
+
const positions = trail.historyPositions;
|
|
366
|
+
tempV.set(positions[offset], positions[offset + 1], positions[offset + 2]);
|
|
367
|
+
tempV.applyMatrix4(trail.parentMatrix ?? emitterMatrix);
|
|
368
|
+
positions[offset] = tempV.x;
|
|
369
|
+
positions[offset + 1] = tempV.y;
|
|
370
|
+
positions[offset + 2] = tempV.z;
|
|
371
|
+
trail.measureHeadDistance();
|
|
372
|
+
}
|
|
373
|
+
if (lifetime !== Infinity) {
|
|
374
|
+
const ages = trail.historyAges;
|
|
375
|
+
const age = trail.age;
|
|
376
|
+
while (trail.historyCount > 0 && age - ages[trail.getHistoryIndex(0)] > lifetime) {
|
|
377
|
+
trail.dropOldest();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (sideAxis && alive) {
|
|
381
|
+
trail.ensureSideHistory();
|
|
382
|
+
const offset = ((trail.historyHead + trail.historyCapacity - 1) % trail.historyCapacity) * 3;
|
|
383
|
+
const sides = trail.historySides;
|
|
384
|
+
let x = sideAxis.x;
|
|
385
|
+
let y = sideAxis.y;
|
|
386
|
+
let z = sideAxis.z;
|
|
387
|
+
if (sidesInWorld) {
|
|
388
|
+
const m = (trail.parentMatrix ?? emitterMatrix).elements;
|
|
389
|
+
const tx = m[0] * x + m[4] * y + m[8] * z;
|
|
390
|
+
const ty = m[1] * x + m[5] * y + m[9] * z;
|
|
391
|
+
z = m[2] * x + m[6] * y + m[10] * z;
|
|
392
|
+
x = tx;
|
|
393
|
+
y = ty;
|
|
394
|
+
}
|
|
395
|
+
sides[offset] = x;
|
|
396
|
+
sides[offset + 1] = y;
|
|
397
|
+
sides[offset + 2] = z;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Retires each trail particle that has died and whose ribbon has faded out (an empty history),
|
|
404
|
+
* the same swap as the sprite path. Returns the live count.
|
|
405
|
+
*/
|
|
406
|
+
export function removeDeadTrails(
|
|
407
|
+
particles: Particle[],
|
|
408
|
+
store: ParticleStore,
|
|
409
|
+
liveParticleCount: number,
|
|
410
|
+
onDied: DeathListener | undefined
|
|
411
|
+
): number {
|
|
412
|
+
const scalars = store.scalars;
|
|
413
|
+
const stride = ParticleStore.SCALAR_STRIDE;
|
|
414
|
+
for (let i = 0; i < liveParticleCount; i++) {
|
|
415
|
+
const particle = particles[i] as TrailParticle;
|
|
416
|
+
const scalar = i * stride;
|
|
417
|
+
const dead = scalars[scalar + ParticleStore.AGE] >= scalars[scalar + ParticleStore.LIFE];
|
|
418
|
+
if (dead && particle.historyCount === 0) {
|
|
419
|
+
retire(particles, store, i, liveParticleCount - 1);
|
|
420
|
+
liveParticleCount--;
|
|
421
|
+
i--;
|
|
422
|
+
if (onDied !== undefined) {
|
|
423
|
+
onDied(particle);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return liveParticleCount;
|
|
428
|
+
}
|