import type { NodeDef, PortDef } from '../graph/NodeRegistry'; import { type PropertyBag, textOf } from '../model/Properties'; import { propertyNodeParams } from '../properties/declaredRows'; import { flatDef } from '../properties/PropertySchema'; import { PARTICLE_PROPERTY, PROPERTY_NAME_PARAM, propertyNameOf } from '../properties/particles'; import { mixColor } from '../util/color'; /** What one particle is, at one moment: everything a graph may read about it. */ export interface ParticleEnv { /** 0 at birth, 1 at the end of its lifetime. */ age: number; /** The same, in seconds – what the formulas of a fall are written in. */ ageSeconds: number; /** Seconds since the source started emitting; the same for every particle alive. */ time: number; /** Which particle this is, counting up from the first the source ever emitted. */ index: number; /** Fixed for the particle's whole life: what makes two of them differ. */ seed: number; /** The source's own Color row, which is what an unconnected colour output reads. */ color: number; /** The values of the graph's Property nodes, in the order the compiler declared them. */ params: readonly number[]; } /** * One output of one node, as a function of the particle. `locals` holds the outputs already * computed this evaluation, so a node feeding two others is evaluated once (see `compile.ts`). */ export type ParticleExpr = (env: ParticleEnv, locals: Float64Array) => number; /** What a node may need beyond its inputs: which node it is, and where its parameter's value lands. */ export interface ParticleEmitContext { nodeId: string; /** The slot in `env.params` this graph's parameter of that name was given. */ paramIndex(name: string): number; } /** * A node of a particle graph. It never runs as a behavior does: `emit` builds the function each of * its outputs is. Every value is a number – a colour is a packed 0xRRGGBB one, as everywhere else * in the project. */ export interface ParticleNodeDef extends NodeDef { category: 'particles'; emit( inputs: Record, params: PropertyBag, ctx: ParticleEmitContext, ): Record; /** What an input with nothing to type on reads while unconnected; without it, zero. */ unfed?: Record; } export function isParticleNodeDef(def: NodeDef): def is ParticleNodeDef { return def.category === 'particles' && 'emit' in def; } export const PARTICLE_OUTPUT = 'ParticleOutput'; export const PARTICLE_RANDOM = 'ParticleRandom'; export const PARTICLE_TIME = 'ParticleTime'; /** The seven things a particle is drawn with – every output of the Output node. */ export const PARTICLE_OUTPUTS = ['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha'] as const; export type ParticleOutputKey = (typeof PARTICLE_OUTPUTS)[number]; const DEG = Math.PI / 180; const NUMBER_STEP = 0.1; /** * A particle's own random number, 0..1: fixed for its whole life, and different per node, so the * direction and the speed of one particle are not the same draw. Mixing `salt` in is what separates * the nodes; the node's id is hashed into it, so an edit elsewhere in the graph does not reshuffle it. */ function random01(seed: number, salt: number): number { let h = (seed ^ salt) >>> 0; h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0; h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0; h = (h ^ (h >>> 16)) >>> 0; return h / 0x100000000; } export function hashId(id: string): number { let h = 0x811c9dc5; for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 0x01000193) >>> 0; return h >>> 0; } const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' }); /** A number input with an inline value, so a constant needs no node of its own. */ const numberIn = (key: string, value: number, step = NUMBER_STEP): PortDef => ({ key, label: key, type: 'number', default: value, field: { key, label: key, kind: 'number', default: value, step }, }); /** A number input carrying a colour: the field is a swatch, the value a packed 0xRRGGBB. */ const colorIn = (key: string, value = 0xffffff): PortDef => ({ key, label: key, type: 'number', default: value, field: flatDef(key, 'color', { label: key, value }), }); const constant = (value: number): ParticleExpr => () => value; function source(type: string, label: string, key: string, description: string, read: ParticleExpr) { const def: ParticleNodeDef = { type, label, category: 'particles', description, inputs: [], outputs: [numberOut(key)], params: [], emit: () => ({ [key]: read }), }; return def; } export const ParticleAge = source( 'ParticleAge', 'Age', 'age', 'How far through its life this particle is: 0 when it is born, 1 when it dies.', (env) => env.age, ); export const ParticleAgeSeconds = source( 'ParticleAgeSeconds', 'Age in seconds', 'seconds', 'How long this particle has been alive, in seconds – the t of a formula of motion.', (env) => env.ageSeconds, ); export const ParticleTimeNode = source( PARTICLE_TIME, 'Time', 'seconds', 'Seconds since the source started emitting. The same for every particle alive, so it drifts them together.', (env) => env.time, ); export const ParticleIndex = source( 'ParticleIndex', 'Index', 'index', 'Which particle this is, counting up from the first one the source emitted: a fan or a ring instead of a scatter.', (env) => env.index, ); /** Two Randoms give one particle two different draws – that is what the node id in the salt is for. */ export const ParticleRandom: ParticleNodeDef = { type: PARTICLE_RANDOM, label: 'Random', category: 'particles', description: 'A number drawn once per particle and kept for its whole life. Two Random nodes draw differently, so direction and speed can scatter apart.', inputs: [numberIn('min', 0), numberIn('max', 1)], outputs: [numberOut('value')], params: [], emit: ({ min, max }, _params, ctx) => { const salt = hashId(ctx.nodeId); return { value: (env, locals) => { const lo = min!(env, locals); return lo + random01(env.seed, salt) * (max!(env, locals) - lo); }, }; }, }; /** * The offset gravity has added by now: ½·g·t². Written as a node because a formula, unlike a * simulation, has to say the whole fall rather than the force behind it. */ export const ParticleGravity: ParticleNodeDef = { type: 'ParticleGravity', label: 'Gravity', category: 'particles', description: 'How far a steady pull has moved the particle by now, down being positive: ½ × strength × age². Add it to the offset.', inputs: [numberIn('strength', 800, 10)], outputs: [numberOut('x'), numberOut('y')], params: [], emit: ({ strength }) => ({ x: constant(0), y: (env, locals) => 0.5 * strength!(env, locals) * env.ageSeconds * env.ageSeconds, }), }; /** The offset a constant speed has added by now: v₀·t, aimed by an angle in degrees. */ export const ParticleStartVelocity: ParticleNodeDef = { type: 'ParticleStartVelocity', label: 'Start velocity', category: 'particles', description: 'How far the particle has flown at a steady speed: angle in degrees (0 is right, 90 is down), speed in pixels per second.', inputs: [numberIn('angle', 0, 1), numberIn('speed', 200, 10)], outputs: [numberOut('x'), numberOut('y')], params: [], emit: ({ angle, speed }) => ({ x: (env, locals) => Math.cos(angle!(env, locals) * DEG) * speed!(env, locals) * env.ageSeconds, y: (env, locals) => Math.sin(angle!(env, locals) * DEG) * speed!(env, locals) * env.ageSeconds, }), }; export const ParticleNumber: ParticleNodeDef = { type: 'ParticleNumber', label: 'Number', category: 'particles', description: 'A constant number.', inputs: [], outputs: [numberOut('value')], params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: NUMBER_STEP }], emit: (_inputs, params) => ({ value: constant(Number(params.value ?? 0) || 0) }), }; export const ParticleColor: ParticleNodeDef = { type: 'ParticleColor', label: 'Color', category: 'particles', description: 'A constant color.', inputs: [], outputs: [numberOut('color')], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], emit: (_inputs, params) => ({ color: constant(Number(params.value ?? 0xffffff) || 0) }), }; /** `name` is a row on every source placed from this asset, and a value here. */ export const ParticleProperty: ParticleNodeDef = { type: PARTICLE_PROPERTY, label: 'Property', category: 'particles', description: 'A number the source sets: a property row on the placed source, and a value here.', inputs: [], outputs: [numberOut('value')], params: propertyNodeParams(NUMBER_STEP), derivePorts: ({ node }) => ({ label: propertyNameOf(node) || ParticleProperty.label }), emit: (_inputs, params, ctx) => { // The slot is bound once, here: reading it per particle would cost a lookup by name each time. const slot = ctx.paramIndex(paramNameOf(params)); return { value: slot < 0 ? constant(0) : (env) => env.params[slot] ?? 0 }; }, }; /** * Where the particle is, how big, how turned and what colour – all of it relative to the source. * An unconnected input leaves that part of the particle as it would be without a graph. */ export const ParticleOutput: ParticleNodeDef = { type: PARTICLE_OUTPUT, label: 'Output', category: 'particles', description: 'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, color tints it and alpha fades it.', inputs: [ { key: 'x', label: 'x', type: 'number', inline: false }, { key: 'y', label: 'y', type: 'number', inline: false }, { key: 'scaleX', label: 'scaleX', type: 'number', inline: false }, { key: 'scaleY', label: 'scaleY', type: 'number', inline: false }, { key: 'rotation', label: 'rotation', type: 'number', inline: false }, { key: 'color', label: 'color', type: 'number', inline: false }, { key: 'alpha', label: 'alpha', type: 'number', inline: false }, ], outputs: [], params: [], unfed: { x: constant(0), y: constant(0), scaleX: constant(1), scaleY: constant(1), rotation: constant(0), color: (env) => env.color, alpha: constant(1), }, emit: () => ({}), }; function arithmetic( type: string, label: string, description: string, bDefault: number, op: (a: number, b: number) => number, ): ParticleNodeDef { return { type, label, category: 'particles', description, inputs: [numberIn('a', bDefault === 1 ? 1 : 0), numberIn('b', bDefault)], outputs: [numberOut('result')], params: [], emit: ({ a, b }) => ({ result: (env, locals) => op(a!(env, locals), b!(env, locals)) }), }; } export const ParticleAdd = arithmetic('ParticleAdd', 'Add', 'a + b.', 0, (a, b) => a + b); export const ParticleSubtract = arithmetic('ParticleSubtract', 'Subtract', 'a − b.', 0, (a, b) => a - b); export const ParticleMultiply = arithmetic('ParticleMultiply', 'Multiply', 'a × b.', 1, (a, b) => a * b); /** Division by zero would give Infinity, which draws as nothing at all; it reads as 0 instead. */ export const ParticleDivide = arithmetic('ParticleDivide', 'Divide', 'a ÷ b; ÷ 0 is 0.', 1, (a, b) => b === 0 ? 0 : a / b, ); function unary(type: string, label: string, description: string, fn: (v: number) => number) { const def: ParticleNodeDef = { type, label, category: 'particles', description, inputs: [numberIn('value', 0)], outputs: [numberOut('result')], params: [], emit: ({ value }) => ({ result: (env, locals) => fn(value!(env, locals)) }), }; return def; } export const ParticleSin = unary( 'ParticleSin', 'Sin', 'The sine of an angle in degrees: a wave between −1 and 1, one full turn every 360.', (v) => Math.sin(v * DEG), ); export const ParticleCos = unary( 'ParticleCos', 'Cos', 'The cosine of an angle in degrees: the same wave as Sin, a quarter turn ahead.', (v) => Math.cos(v * DEG), ); export const ParticleSqrt = unary( 'ParticleSqrt', 'Sqrt', 'The square root: rises fast at first and then slows down, which is how smoke drifts up. Below 0 it is 0.', (v) => (v > 0 ? Math.sqrt(v) : 0), ); export const ParticleAbs = unary( 'ParticleAbs', 'Abs', 'The distance from 0, sign dropped: −0.3 becomes 0.3.', Math.abs, ); export const ParticleFloor = unary( 'ParticleFloor', 'Floor', 'Rounds down to a whole number: 2.75 becomes 2.', Math.floor, ); export const ParticleFract = unary( 'ParticleFract', 'Fract', 'The part after the decimal point: a rising value becomes a sawtooth from 0 to 1.', (v) => v - Math.floor(v), ); export const ParticleMin: ParticleNodeDef = { type: 'ParticleMin', label: 'Min', category: 'particles', description: 'The smaller of two numbers.', inputs: [numberIn('a', 0), numberIn('b', 0)], outputs: [numberOut('result')], params: [], emit: ({ a, b }) => ({ result: (env, locals) => Math.min(a!(env, locals), b!(env, locals)) }), }; export const ParticleMax: ParticleNodeDef = { type: 'ParticleMax', label: 'Max', category: 'particles', description: 'The larger of two numbers.', inputs: [numberIn('a', 0), numberIn('b', 0)], outputs: [numberOut('result')], params: [], emit: ({ a, b }) => ({ result: (env, locals) => Math.max(a!(env, locals), b!(env, locals)) }), }; export const ParticleClamp: ParticleNodeDef = { type: 'ParticleClamp', label: 'Clamp', category: 'particles', description: 'Holds a value between min and max: below min it is min, above max it is max.', inputs: [numberIn('value', 0), numberIn('min', 0), numberIn('max', 1)], outputs: [numberOut('result')], params: [], emit: ({ value, min, max }) => ({ result: (env, locals) => Math.min(Math.max(value!(env, locals), min!(env, locals)), max!(env, locals)), }), }; export const ParticleMix: ParticleNodeDef = { type: 'ParticleMix', label: 'Mix', category: 'particles', description: 'Blends from a to b: t = 0 is a, t = 1 is b, beyond that it carries on.', inputs: [numberIn('a', 0), numberIn('b', 1), numberIn('t', 0.5)], outputs: [numberOut('result')], params: [], emit: ({ a, b, t }) => ({ result: (env, locals) => { const from = a!(env, locals); return from + (b!(env, locals) - from) * t!(env, locals); }, }), }; /** * Colours are packed numbers, so blending them is a channel-by-channel job a plain Mix cannot do – * mixing 0xff0000 and 0x0000ff as numbers would run through green. */ export const ParticleMixColor: ParticleNodeDef = { type: 'ParticleMixColor', label: 'Mix color', category: 'particles', description: 'Blends two colors channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.', inputs: [colorIn('a', 0xffffff), colorIn('b', 0x000000), numberIn('t', 0.5)], outputs: [numberOut('color')], params: [], emit: ({ a, b, t }) => ({ color: (env, locals) => mixColor(a!(env, locals), b!(env, locals), t!(env, locals)), }), }; export const ParticleStep: ParticleNodeDef = { type: 'ParticleStep', label: 'Step', category: 'particles', description: '0 while value is below edge, 1 from edge on: a hard cut.', inputs: [numberIn('edge', 0.5), numberIn('value', 0)], outputs: [numberOut('result')], params: [], emit: ({ edge, value }) => ({ result: (env, locals) => (value!(env, locals) < edge!(env, locals) ? 0 : 1), }), }; /** Negative bases have no real power, so the value is held at 0 there, as the shader's Pow does. */ export const ParticlePow: ParticleNodeDef = { type: 'ParticlePow', label: 'Pow', category: 'particles', description: 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0.', inputs: [numberIn('value', 0), numberIn('exponent', 2)], outputs: [numberOut('result')], params: [], emit: ({ value, exponent }) => ({ result: (env, locals) => Math.pow(Math.max(value!(env, locals), 0), exponent!(env, locals)), }), }; /** Every node a particle graph offers. Registered by `graph/nodes`, offered by no behavior palette. */ export const PARTICLE_NODES: readonly ParticleNodeDef[] = [ ParticleAge, ParticleAgeSeconds, ParticleTimeNode, ParticleIndex, ParticleRandom, ParticleGravity, ParticleStartVelocity, ParticleOutput, ParticleProperty, ParticleNumber, ParticleColor, ParticleAdd, ParticleSubtract, ParticleMultiply, ParticleDivide, ParticleMin, ParticleMax, ParticleClamp, ParticleMix, ParticleMixColor, ParticleSin, ParticleCos, ParticleSqrt, ParticleAbs, ParticleFloor, ParticleFract, ParticleStep, ParticlePow, ]; /** The name a Property node placed from the palette gets: `value`, then `value2`, `value3` … */ export function nextParticlePropertyName(taken: ReadonlySet): string { if (!taken.has('value')) return 'value'; for (let n = 2; ; n++) if (!taken.has(`value${n}`)) return `value${n}`; } function paramNameOf(params: PropertyBag): string { return textOf(params[PROPERTY_NAME_PARAM] ?? ''); }