Files
feuerwerk/src/viewport/particles.ts
T
rootandClaude Opus 5 d5e67cd80c
game-maker / suite (push) Successful in 10m9s
A shader costs a source nothing, because a field was already one draw call (#88)
A particle source can wear a shader asset the way a sprite does, and it is the
cheapest place in the editor to wear one: a shaded sprite gives up its batch and
costs a draw call of its own, while `ParticleContainerPipe` breaks the batch for
every field regardless. Five hundred shaded particles are the one call five
hundred plain ones were.

The fragment the compiler emits needed no change, only a second vertex stage:
Pixi's particle vertex is GLSL ES 1.00 and ours is ES 3.00, so it is translated
rather than invented. What it sees is a particle's picture, not its life — the
buffer carries five fixed attributes and takes no sixth, so age, index and seed
stay on the graph, which is what the graph is for.

Two things the issue did not ask about:

`Shader.resources` is an accessor over the names its constructor declared, so a
slot that is not declared becomes a loose JS property and is never bound. The
adaptor writes `uTranslationMatrix` into whichever shader the container names —
without an empty block for it the vertex gets no matrix and the particles land
elsewhere.

`schemaOf` in serialize.ts hung on `schema !== schemaFor('sprite')`, an identity
check a source never satisfies, so its shader values would have been dropped on
save.

A source is the one asset drawing rows from two graphs, so a name can arrive
twice. Its own row wins and the uniform keeps its default; nothing is rejected
on the shader's side, because the collision exists only at this one wearer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:06:45 +02:00

216 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { disposeParticleShader, type ShaderSource, syncParticleShader } from './shaders';
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;
/** Set while a panel holds the field at one age, instead of reading it off the clock. */
private heldAge: number | null = null;
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;
}
/**
* Stands the field at exactly this age; null lets it read the clock again. Handing it a clock that
* *would* read that age instead costs the last bits of the second – 0.5 comes out as
* 0.49999999999999994 about one time in ten, which is one particle short of the field due there.
*/
hold(age: number | null): void {
this.heldAge = age;
}
/** 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;
}
// An origin in the future means the global clock went back to zero under the field (a play run
// began or ended); ageing every particle backwards from there is no state worth keeping.
let startedAt = this.startedAt;
if (startedAt === null || now < startedAt) {
this.restart(now);
startedAt = now;
}
const settings = emitterSettings(props);
const time = fieldTime(settings, this.heldAge ?? now - 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();
}
/** Puts the shader the source names on every particle of this field, or takes one off (#88). */
wear(props: PropertyBag, shaders: ShaderSource): void {
syncParticleShader(this.container, props, shaders);
}
/** 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 {
disposeParticleShader(this.container);
this.container.destroy();
}
}