game-maker / suite (push) Successful in 9m48s
Speed and Prewarm are not state and not a second loop: both are arithmetic on the clock argument the field already passes to every particle formula. fieldTime = elapsed * timeScale + prewarmOffset, a pure rule next to liveRange, called at the one place ParticleField.sync forms its time, so preview and world inherit it together. Prewarm offsets by one lifetime because that is the smallest offset which fills a continuous emitter; a burst is dead at time >= lifetime, so the same offset would skip it entirely and it is suppressed there. The tab's slider counts wall-clock seconds, so its span divides by the scale. A missing row falls back to 1 / off, so old project files need no migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
import { Particle, ParticleContainer, type Texture } from 'pixi.js';
|
||
import { type CompiledParticles, emptyParticleOut, type ParticleOut } from '../core/particles/compile';
|
||
import { birthTime, type EmitterSettings, fieldTime, liveRange } from '../core/particles/emission';
|
||
import type { ParticleEnv } from '../core/particles/nodes';
|
||
import { type Mat, scaleOf } from '../core/model/matrix';
|
||
import { booleanOf, type PropertyBag } from '../core/model/Properties';
|
||
import {
|
||
BURST_COUNT_KEY,
|
||
emitModeOf,
|
||
EMITTING_KEY,
|
||
LIFETIME_KEY,
|
||
MAX_PARTICLES_KEY,
|
||
MOVE_WITH_SOURCE_KEY,
|
||
PARTICLE_COLOR_KEY,
|
||
PARTICLE_SIZE_KEY,
|
||
PREWARM_KEY,
|
||
RATE_KEY,
|
||
TIME_SCALE_KEY,
|
||
} from '../core/properties/particles';
|
||
|
||
const DEG = Math.PI / 180;
|
||
|
||
export function emitterSettings(props: PropertyBag): EmitterSettings {
|
||
return {
|
||
mode: emitModeOf(props),
|
||
rate: numberOf(props[RATE_KEY], 20),
|
||
count: numberOf(props[BURST_COUNT_KEY], 20),
|
||
lifetime: numberOf(props[LIFETIME_KEY], 1),
|
||
timeScale: Math.max(0, numberOf(props[TIME_SCALE_KEY], 1)),
|
||
prewarm: booleanOf(props[PREWARM_KEY] ?? false),
|
||
max: Math.max(1, numberOf(props[MAX_PARTICLES_KEY], 500)),
|
||
};
|
||
}
|
||
|
||
function numberOf(value: unknown, fallback: number): number {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
}
|
||
|
||
/**
|
||
* One particle source as it is drawn: a Pixi `ParticleContainer`, which puts all of them in a single
|
||
* draw call, plus the little state a formula cannot hold – when emission started and, for particles
|
||
* left behind in the world, where their source stood when they were born.
|
||
*/
|
||
export class ParticleField {
|
||
readonly container = new ParticleContainer<Particle>({
|
||
dynamicProperties: { position: true, vertex: true, rotation: true, color: true, uvs: false },
|
||
});
|
||
private readonly out: ParticleOut = emptyParticleOut();
|
||
private readonly pool: Particle[] = [];
|
||
/** Seconds, on the library's clock, at which this source last started emitting. */
|
||
private startedAt: number | null = null;
|
||
/** How far it was still taking in new particles: frozen while `emitting` is off. */
|
||
private emittedUntil = 0;
|
||
/** The source's transform when each particle was born, kept per index modulo the capacity. */
|
||
private born: Mat[] = [];
|
||
private capacity = 0;
|
||
private highest = -1;
|
||
/** The Emit count last seen; a change is a request to start over. */
|
||
private emits = -1;
|
||
|
||
constructor() {
|
||
this.container.eventMode = 'none';
|
||
}
|
||
|
||
/** Seconds the source has been emitting; -1 before it ever started. */
|
||
elapsed(now: number): number {
|
||
return this.startedAt === null ? -1 : now - this.startedAt;
|
||
}
|
||
|
||
/** Starts the clock over: a burst goes out again, a continuous stream begins at particle 0. */
|
||
restart(now: number): void {
|
||
this.startedAt = now;
|
||
this.emittedUntil = 0;
|
||
this.reset();
|
||
}
|
||
|
||
/**
|
||
* Moves the origin so the field is `to` seconds old at `now`, keeping every particle where it is.
|
||
* What a held preview needs when it is let go: without it the clock ran on underneath the freeze.
|
||
*/
|
||
rebase(now: number, to: number): void {
|
||
this.startedAt = now - to;
|
||
}
|
||
|
||
private reset(): void {
|
||
this.highest = -1;
|
||
this.born = [];
|
||
this.capacity = 0;
|
||
}
|
||
|
||
/**
|
||
* Brings the drawn particles up to `now`. Everything about one particle comes out of `compiled`;
|
||
* the only thing read from a frame is how far the clock has moved.
|
||
*/
|
||
sync(
|
||
compiled: CompiledParticles | null,
|
||
props: PropertyBag,
|
||
params: readonly number[],
|
||
m: Mat,
|
||
texture: Texture,
|
||
now: number,
|
||
emits = 0,
|
||
/** Mixed into every particle's seed, so two sources of one asset do not scatter identically. */
|
||
salt = 0,
|
||
): void {
|
||
const emitting = booleanOf(props[EMITTING_KEY] ?? true);
|
||
if (this.emits !== emits) {
|
||
// Not on the first sight of a source: that would throw its first burst away again.
|
||
if (this.emits >= 0) this.restart(now);
|
||
this.emits = emits;
|
||
}
|
||
if (this.startedAt === null) this.startedAt = now;
|
||
const settings = emitterSettings(props);
|
||
const time = fieldTime(settings, now - this.startedAt);
|
||
if (emitting) this.emittedUntil = time;
|
||
|
||
const range = compiled ? liveRange(settings, time, this.emittedUntil) : { from: 0, to: -1 };
|
||
const capacity = Math.max(1, Math.floor(settings.max));
|
||
if (capacity !== this.capacity) {
|
||
this.capacity = capacity;
|
||
this.born = [];
|
||
this.highest = -1;
|
||
}
|
||
// Stepping back in time (the tab's slider) leaves birth places that no longer belong to anyone.
|
||
if (range.to < this.highest) this.highest = range.from - 1;
|
||
for (let i = Math.max(this.highest + 1, range.from); i <= range.to; i++) this.born[i % capacity] = m;
|
||
this.highest = Math.max(this.highest, range.to);
|
||
|
||
const moves = booleanOf(props[MOVE_WITH_SOURCE_KEY] ?? false);
|
||
const size = Math.max(0.01, numberOf(props[PARTICLE_SIZE_KEY], 16));
|
||
const sourceColor = numberOf(props[PARTICLE_COLOR_KEY], 0xffffff);
|
||
const baseW = texture.width || 1;
|
||
const baseH = texture.height || 1;
|
||
const env: ParticleEnv = {
|
||
age: 0,
|
||
ageSeconds: 0,
|
||
time,
|
||
index: 0,
|
||
seed: 0,
|
||
color: sourceColor,
|
||
params,
|
||
};
|
||
|
||
// The shader samples the container's own texture, not each particle's, so it has to be set here.
|
||
this.container.texture = texture;
|
||
const particles = this.container.particleChildren;
|
||
particles.length = 0;
|
||
if (compiled)
|
||
for (let index = range.from; index <= range.to; index++) {
|
||
const ageSeconds = time - birthTime(settings, index);
|
||
env.index = index;
|
||
env.seed = index ^ salt;
|
||
env.ageSeconds = ageSeconds;
|
||
env.age = settings.lifetime > 0 ? ageSeconds / settings.lifetime : 0;
|
||
compiled.evaluate(env, this.out);
|
||
const place = moves ? m : (this.born[index % capacity] ?? m);
|
||
const p = this.take(particles.length, texture);
|
||
p.texture = texture;
|
||
p.x = place.a * this.out.x + place.c * this.out.y + place.tx;
|
||
p.y = place.b * this.out.x + place.d * this.out.y + place.ty;
|
||
const scale = scaleOf(place);
|
||
p.scaleX = (size * this.out.scaleX * scale) / baseW;
|
||
p.scaleY = (size * this.out.scaleY * scale) / baseH;
|
||
p.rotation = this.out.rotation * DEG + Math.atan2(place.b, place.a);
|
||
p.tint = this.out.color;
|
||
p.alpha = this.out.alpha;
|
||
particles.push(p);
|
||
}
|
||
this.container.update();
|
||
}
|
||
|
||
/** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */
|
||
private take(slot: number, texture: Texture): Particle {
|
||
let p = this.pool[slot];
|
||
if (!p) {
|
||
p = new Particle({ texture, anchorX: 0.5, anchorY: 0.5 });
|
||
this.pool[slot] = p;
|
||
}
|
||
return p;
|
||
}
|
||
|
||
/** How many are drawn right now – what the checks read. */
|
||
get count(): number {
|
||
return this.container.particleChildren.length;
|
||
}
|
||
|
||
destroy(): void {
|
||
this.container.destroy();
|
||
}
|
||
}
|