Files
feuerwerk/src/core/particles/compile.ts
T
root 0f9e1611d1
game-maker / suite (push) Failing after 4m22s
A particle is a function of its age, not a thing that is stepped (#73)
The asset type `particles` is placeable and carries a graph at the same time —
`behavior` was the blueprint. Because a continuous emitter gives birth to particle n
at time n/rate, the whole field is a function of the clock rather than of the frames:
no ring-buffer bookkeeping, exact time jumps, reproducibility as a measurable property.

compile.ts follows compileShader's frame but emits JS closures into a Float64Array
instead of GLSL, and without eval; a node with two consumers is evaluated once.
Drawing is one Pixi ParticleContainer per source, so one draw call.

Collision, wind and braking in flight are out for good — that is the price of the
formula model, not an omission.

The tab count goes from nine to ten, which is exactly the case #84 was built for one
day earlier: at 1280 px the row no longer holds every tab, so guard 14b now asks for
hidden.length > 0 instead of === 0.
2026-09-14 05:44:25 +02:00

166 lines
6.2 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 { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
import type { FlatDef } from '../properties/PropertySchema';
import {
isParticlePropertyNode,
isValidParticlePropertyName,
particleParamDefs,
propertyNameOf,
} from '../properties/particles';
import {
isParticleNodeDef,
PARTICLE_OUTPUT,
PARTICLE_OUTPUTS,
PARTICLE_TIME,
type ParticleEnv,
type ParticleExpr,
type ParticleOutputKey,
} from './nodes';
/** What one particle is drawn with, everything relative to its source. */
export type ParticleOut = Record<ParticleOutputKey, number>;
export interface CompiledParticles {
/** The rows the graph adds to every source placed from this asset, in node order. */
params: FlatDef<'number'>[];
/** A Time node reaches the Output: the whole field moves, not just the particles in it. */
time: boolean;
/** Fills `out` with the seven values for this particle. */
evaluate(env: ParticleEnv, out: ParticleOut): void;
}
export interface ParticlesBuild {
/** Null while the graph has a problem – the source then emits nothing. */
compiled: CompiledParticles | null;
/** Per node id, what keeps the graph from compiling. */
problems: Record<string, string>;
/** One line for the tab: empty once compiled. */
reason: string;
}
/**
* What the motion depends on and nothing else: node ids, types, params and declared ports, and
* which port feeds which. Positions and folding are the editor's, so a moved node does not compile
* again – the same rule the shader fingerprint follows (#65).
*/
export function particlesFingerprint(graph: Graph): string {
return JSON.stringify({
nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]),
edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]),
});
}
/**
* A particle graph to one function per output: every node reachable from the Output becomes one
* local per output, in dependency order, so a node feeding two others is evaluated once per
* particle. There is no interpreter and no eval – the compiler composes closures.
*/
export function compileParticles(graph: Graph): ParticlesBuild {
const problems: Record<string, string> = {};
const problem = (node: GraphNode, text: string) => {
if (!(node.id in problems)) problems[node.id] = text;
};
const names = new Set<string>();
for (const node of graph.nodes) {
if (!isParticlePropertyNode(node)) continue;
const name = propertyNameOf(node);
if (!isValidParticlePropertyName(name))
problem(node, 'Name must be one word: letters, digits, _ – and not one of the source’s own rows');
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
names.add(name);
}
const params = particleParamDefs(graph);
const paramIndex = (name: string) => params.findIndex((row) => row.key === name);
const ops: ParticleExpr[] = [];
const emitted = new Map<string, Record<string, ParticleExpr>>();
const visiting = new Set<string>();
const emitNode = (node: GraphNode): Record<string, ParticleExpr> | null => {
const done = emitted.get(node.id);
if (done) return done;
if (visiting.has(node.id)) {
problem(node, 'Feeds itself');
return null;
}
const def = nodeDef(node.type);
if (!def || !isParticleNodeDef(def)) {
problem(node, 'Not a particle node');
return null;
}
visiting.add(node.id);
const inputs: Record<string, ParticleExpr> = {};
let complete = true;
for (const port of def.inputs) {
const expr = input(node, port);
if (expr) inputs[port.key] = expr;
else complete = false;
}
visiting.delete(node.id);
if (!complete) return null;
const outputs: Record<string, ParticleExpr> = {};
for (const [key, expr] of Object.entries(
def.emit(inputs, node.params, { nodeId: node.id, paramIndex }),
)) {
const slot = ops.length;
ops.push(expr);
outputs[key] = (_env, locals) => locals[slot]!;
}
emitted.set(node.id, outputs);
return outputs;
};
/** What feeds an input: the edge's source, the typed-in value, or what the node says (`unfed`). */
const input = (node: GraphNode, port: PortDef): ParticleExpr | null => {
const edge = edgesInto(graph, node.id, port.key)[0];
if (edge) {
const source = findNode(graph, edge.from.node);
const outputs = source && emitNode(source);
return outputs?.[edge.from.port] ?? null;
}
if (port.field) {
const typed = Number(node.params[port.key] ?? port.default ?? 0);
const value = Number.isFinite(typed) ? typed : 0;
return () => value;
}
const def = nodeDef(node.type);
const unfed = def && isParticleNodeDef(def) ? def.unfed?.[port.key] : undefined;
return unfed ?? (() => 0);
};
const output = graph.nodes.find((n) => n.type === PARTICLE_OUTPUT);
if (!output) return { compiled: null, problems, reason: 'No Output node' };
const outputDef = nodeDef(PARTICLE_OUTPUT)!;
const reads: ParticleExpr[] = [];
for (const key of PARTICLE_OUTPUTS) {
const port = outputDef.inputs.find((p) => p.key === key)!;
const expr = input(output, port);
if (!expr) break;
reads.push(expr);
}
const first = graph.nodes.find((n) => n.id in problems);
if (first || reads.length !== PARTICLE_OUTPUTS.length)
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
const locals = new Float64Array(ops.length);
const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && emitted.has(n.id));
const compiled: CompiledParticles = {
params,
time,
evaluate(env, out) {
// In dependency order, so every local a later op reads is already filled.
for (let i = 0; i < ops.length; i++) locals[i] = ops[i]!(env, locals);
for (let i = 0; i < PARTICLE_OUTPUTS.length; i++) out[PARTICLE_OUTPUTS[i]!] = reads[i]!(env, locals);
},
};
return { compiled, problems, reason: '' };
}
/** A particle whose graph says nothing: at the source, full size, the source's own colour. */
export function emptyParticleOut(): ParticleOut {
return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, color: 0xffffff, alpha: 1 };
}