/** * A particle asset is a graph that says what one particle *is*, as a function of its age and of a * random number drawn once for it – not what forces act on it. That choice is what these tests * pin: the same second always looks the same, and the whole field is a function of the clock. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { findAssetOf } from '../../src/core/model/Project'; import { graphFingerprint } from '../../src/core/graph/Graph'; import { compileParticles, emptyParticleOut, type ParticleOut } from '../../src/core/particles/compile'; import { birthTime, fieldTime, type LiveRange, liveRange, prewarmOffset, } from '../../src/core/particles/emission'; import { mixColor } from '../../src/core/util/color'; import { hashId, PARTICLE_NODES, PARTICLE_OUTPUT, PARTICLE_OUTPUTS, PARTICLE_RANDOM, type ParticleEnv, } from '../../src/core/particles/nodes'; import { BURST_COUNT_KEY, EMITTING_KEY, isValidParticlePropertyName, LIFETIME_KEY, MAX_PARTICLES_KEY, PARTICLE_PROPERTY, particleParamDefs, PREWARM_KEY, RATE_KEY, TIME_SCALE_KEY, } from '../../src/core/properties/particles'; import { schemaFor, schemaForAsset } from '../../src/core/properties/PropertySchema'; import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; import { paletteEntries } from '../../src/editor/graph/paletteEntries'; import { makeProject, type ProjectFixture, roundTrip } from '../helpers'; /** Command-driven editing of a particle asset's graph, the way the tab does it. */ function particlesEditor(f: ProjectFixture, name = 'Sparks') { const create = new CreateAssetCommand('particles', name); f.stack.execute(create); const id = create.asset.id; const asset = () => findAssetOf(f.project, id, 'particles')!; const add = (type: string, params = {}) => { const command = new AddNodeCommand(id, type, 0, 0, params); f.stack.execute(command); return command.node.id; }; const link = (from: string, fromPort: string, to: string, toPort: string) => f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort })); return { id, asset, add, link }; } const env = (over: Partial = {}): ParticleEnv => ({ age: 0, ageSeconds: 0, time: 0, index: 0, seed: 0, color: 0xffffff, params: [], ...over, }); function run(p: ReturnType, at: Partial): ParticleOut { const build = compileParticles(p.asset().graph); expect(build.reason).toBe(''); const out = emptyParticleOut(); build.compiled!.evaluate(env(at), out); return out; } describe('a particle asset', () => { it('is placeable, carries a graph, and starts out with nothing to compile', () => { const f = makeProject(); const p = particlesEditor(f); expect(isPlaceableType('particles')).toBe(true); expect(p.asset().graph).toEqual({ nodes: [], edges: [] }); expect(compileParticles(p.asset().graph).reason).toBe('No Output node'); // The source's own rows are there from the start; the graph adds to them. expect(schemaFor('particles').map((d) => d.key)).toContain(RATE_KEY); expect(schemaFor('particles').map((d) => d.key)).toContain(EMITTING_KEY); }); it('leaves every unconnected output at what a particle without a graph would be', () => { const f = makeProject(); const p = particlesEditor(f); p.add(PARTICLE_OUTPUT); const out = run(p, { age: 0.5, color: 0x336699 }); expect(out).toEqual({ x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, // Unconnected, the colour is the source's own row – not a hard-coded white. color: 0x336699, alpha: 1, }); }); it('fades a particle out over its life when Age feeds alpha through Subtract', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); const age = p.add('ParticleAge'); const fade = p.add('ParticleSubtract', { a: 1 }); p.link(age, 'age', fade, 'b'); p.link(fade, 'result', output, 'alpha'); expect(run(p, { age: 0 }).alpha).toBe(1); expect(run(p, { age: 0.25 }).alpha).toBe(0.75); expect(run(p, { age: 1 }).alpha).toBe(0); }); it('falls by ½gt², which is the whole point of a formula instead of a force', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); const gravity = p.add('ParticleGravity', { strength: 800 }); p.link(gravity, 'y', output, 'y'); expect(run(p, { ageSeconds: 0 }).y).toBe(0); expect(run(p, { ageSeconds: 0.5 }).y).toBe(100); expect(run(p, { ageSeconds: 1 }).y).toBe(400); }); it('aims Start velocity by degrees, 0 to the right and 90 down', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); const v = p.add('ParticleStartVelocity', { angle: 90, speed: 200 }); p.link(v, 'x', output, 'x'); p.link(v, 'y', output, 'y'); const out = run(p, { ageSeconds: 0.5 }); expect(out.x).toBeCloseTo(0, 10); expect(out.y).toBeCloseTo(100, 10); }); it('draws one random number per particle and a different one per Random node', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); const a = p.add(PARTICLE_RANDOM, { min: 0, max: 100 }); const b = p.add(PARTICLE_RANDOM, { min: 0, max: 100 }); p.link(a, 'value', output, 'x'); p.link(b, 'value', output, 'y'); const first = run(p, { seed: 7, ageSeconds: 0 }); // The same particle at a later moment: its draw has not moved. const later = run(p, { seed: 7, ageSeconds: 9, age: 0.9 }); expect(later.x).toBe(first.x); // Two nodes, two draws – otherwise direction and speed of one particle would be the same number. expect(first.y).not.toBe(first.x); // A different particle draws differently. expect(run(p, { seed: 8 }).x).not.toBe(first.x); // And so does the same index at another source: the drawing party's salt goes into the seed, // which is what keeps two fountains of one asset from being the same fountain twice. expect(run(p, { seed: 7 ^ hashId('source-b') }).x).not.toBe(first.x); }); it('feeds one node into two without evaluating it twice differently', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); const r = p.add(PARTICLE_RANDOM, { min: 0, max: 100 }); p.link(r, 'value', output, 'x'); p.link(r, 'value', output, 'y'); const out = run(p, { seed: 3 }); expect(out.x).toBe(out.y); }); it('declares a row per Property node and reads its value from the source', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 }); const second = p.add(PARTICLE_PROPERTY, { name: 'spread', default: 1, min: 0, max: 2 }); p.link(second, 'value', output, 'x'); const rows = particleParamDefs(p.asset().graph); expect(rows.map((r) => r.key)).toEqual(['speed', 'spread']); expect(schemaForAsset(f.project, p.asset()).map((d) => d.key)).toEqual([ ...schemaFor('particles').map((d) => d.key), 'speed', 'spread', ]); // `spread` is the second row, so it reads slot 1 – not slot 0, and not by name per particle. expect(run(p, { params: [99, 42] }).x).toBe(42); }); it('refuses a parameter named after one of the source’s own rows', () => { const f = makeProject(); const p = particlesEditor(f); p.add(PARTICLE_OUTPUT); const bad = p.add(PARTICLE_PROPERTY, { name: RATE_KEY }); const build = compileParticles(p.asset().graph); expect(build.compiled).toBeNull(); expect(build.problems[bad]).toContain('Name must be one word'); expect(particleParamDefs(p.asset().graph)).toEqual([]); }); it('names the node that feeds itself instead of hanging', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); const a = p.add('ParticleAdd'); const b = p.add('ParticleAdd'); p.link(a, 'result', b, 'a'); p.link(b, 'result', a, 'a'); p.link(a, 'result', output, 'x'); const build = compileParticles(p.asset().graph); expect(build.compiled).toBeNull(); expect(Object.values(build.problems)).toContain('Feeds itself'); }); it('keeps the fingerprint when a node only moves, and changes it on a parameter', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); const n = p.add('ParticleNumber', { value: 2 }); p.link(n, 'value', output, 'x'); const before = graphFingerprint(p.asset().graph); f.stack.execute(new MoveNodeCommand(p.id, n, 120, 80)); expect(graphFingerprint(p.asset().graph)).toBe(before); f.stack.execute(new AddNodeCommand(p.id, 'ParticleNumber', 0, 0, { value: 5 })); expect(graphFingerprint(p.asset().graph)).not.toBe(before); }); it('survives a round trip through the project file, graph and rows intact', () => { const f = makeProject(); const p = particlesEditor(f); const output = p.add(PARTICLE_OUTPUT); p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 }); const g = p.add('ParticleGravity', { strength: 400 }); p.link(g, 'y', output, 'y'); const back = roundTrip(f.project); const asset = findAssetOf(back, p.id, 'particles')!; expect(asset.graph.nodes).toHaveLength(3); expect(asset.graph.edges).toHaveLength(1); expect(particleParamDefs(asset.graph).map((r) => r.key)).toEqual(['speed']); const out = emptyParticleOut(); compileParticles(asset.graph).compiled!.evaluate(env({ ageSeconds: 1 }), out); expect(out.y).toBe(200); }); it('offers only particle nodes in its palette, and the Output only while there is none', () => { const f = makeProject(); const p = particlesEditor(f); const before = paletteEntries(f.project, p.asset()); expect(before.every((e) => e.def.category === 'particles')).toBe(true); expect(before.map((e) => e.id)).toContain(PARTICLE_OUTPUT); expect(before).toHaveLength(PARTICLE_NODES.length); // A behavior node has no business here, and neither has a shader one. expect(before.map((e) => e.id)).not.toContain('Branch'); p.add(PARTICLE_OUTPUT); expect(paletteEntries(f.project, p.asset()).map((e) => e.id)).not.toContain(PARTICLE_OUTPUT); }); it('numbers the Property nodes it places from the palette', () => { const f = makeProject(); const p = particlesEditor(f); const first = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!; expect(first.params.name).toBe('value'); p.add(PARTICLE_PROPERTY, first.params); const second = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!; expect(second.params.name).toBe('value2'); }); }); const liveCount = (range: LiveRange): number => Math.max(0, range.to - range.from + 1); describe('when particles are alive', () => { const settings = { mode: 'continuous' as const, rate: 10, count: 20, lifetime: 1, timeScale: 1, prewarm: false, max: 500, }; it('puts a continuous source on a fixed grid, so the field is a function of the clock', () => { // Particle n is born at n/rate: at 2 s exactly, particles 11..20 are the ones within a lifetime. expect(birthTime(settings, 15)).toBe(1.5); expect(liveRange(settings, 2)).toEqual({ from: 11, to: 20 }); expect(liveCount(liveRange(settings, 2))).toBe(10); // Before the first one is due there is nothing, and one frame in there is exactly one. expect(liveCount(liveRange(settings, 0))).toBe(1); expect(liveCount(liveRange(settings, 0.05))).toBe(1); }); it('drops the oldest when the cap is reached, never the newest', () => { expect(liveRange({ ...settings, max: 4 }, 2)).toEqual({ from: 17, to: 20 }); }); it('lets the ones in flight live out their lives after emission stops', () => { // Emission stopped at 1 s; at 1.5 s only what was born after 0.5 s is still alive. expect(liveRange(settings, 1.5, 1)).toEqual({ from: 6, to: 10 }); // A whole lifetime after the stop there is nothing left. expect(liveCount(liveRange(settings, 2.01, 1))).toBe(0); }); it('sends a burst out at once and ends it one lifetime later', () => { const burst = { ...settings, mode: 'burst' as const, count: 30 }; expect(birthTime(burst, 29)).toBe(0); expect(liveRange(burst, 0.5)).toEqual({ from: 0, to: 29 }); expect(liveCount(liveRange(burst, 1))).toBe(0); // The cap holds for a burst too. expect(liveRange({ ...burst, max: 8 }, 0.5)).toEqual({ from: 0, to: 7 }); }); it('runs the source clock at its own speed, which is all a time scale is', () => { const fast = { ...settings, timeScale: 2 }; expect(fieldTime(fast, 1)).toBe(2); // Twice the speed is the same picture half the time earlier, particle for particle. expect(liveRange(fast, fieldTime(fast, 1))).toEqual(liveRange(settings, fieldTime(settings, 2))); // A scale of zero holds the second it starts on rather than emptying the field. expect(fieldTime({ ...settings, timeScale: 0 }, 99)).toBe(0); }); it('starts a prewarmed source one lifetime in, where the field is already full', () => { const warm = { ...settings, prewarm: true }; expect(fieldTime(warm, 0)).toBe(settings.lifetime); // Its very first frame carries a whole lifetime of particles; a cold one carries the first. expect(liveCount(liveRange(warm, fieldTime(warm, 0)))).toBe(10); expect(liveCount(liveRange(settings, fieldTime(settings, 0)))).toBe(1); // And it is the steady state, not a spike: a second later there are as many. expect(liveCount(liveRange(warm, fieldTime(warm, 1)))).toBe(10); }); it('leaves a burst alone, which one lifetime in would be over', () => { const burst = { ...settings, mode: 'burst' as const, prewarm: true }; expect(prewarmOffset(burst)).toBe(0); expect(liveCount(liveRange(burst, fieldTime(burst, 0)))).toBe(20); }); it('counts the prewarm in the seconds a lifetime is in, not in the world clock', () => { // Scaled first, then offset: at twice the speed one second in is the third second of the field. expect(fieldTime({ ...settings, prewarm: true, timeScale: 2 }, 1)).toBe(3); }); it('emits nothing without a rate, a lifetime or a future', () => { expect(liveCount(liveRange({ ...settings, rate: 0 }, 2))).toBe(0); expect(liveCount(liveRange({ ...settings, lifetime: 0 }, 2))).toBe(0); expect(liveCount(liveRange(settings, -1))).toBe(0); }); }); describe('a particle colour', () => { it('blends channel by channel, which a plain number mix cannot', () => { expect(mixColor(0xff0000, 0x0000ff, 0)).toBe(0xff0000); expect(mixColor(0xff0000, 0x0000ff, 1)).toBe(0x0000ff); // Half way is dark magenta – mixing the packed numbers would have run through green. expect(mixColor(0xff0000, 0x0000ff, 0.5)).toBe(0x800080); expect(mixColor(0x000000, 0xffffff, 2)).toBe(0xffffff); }); }); describe('the source rows', () => { it('name every setting the emission rule reads', () => { const keys = schemaFor('particles').map((d) => d.key); for (const key of [ RATE_KEY, BURST_COUNT_KEY, LIFETIME_KEY, MAX_PARTICLES_KEY, EMITTING_KEY, TIME_SCALE_KEY, PREWARM_KEY, ]) expect(keys).toContain(key); // A graph parameter may not take one of those names, or the inspector would show it twice. expect(isValidParticlePropertyName(TIME_SCALE_KEY)).toBe(false); expect(isValidParticlePropertyName(PREWARM_KEY)).toBe(false); }); it('has one Output input per drawn value', () => { expect([...PARTICLE_OUTPUTS]).toEqual(['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha']); }); }); /** * A source wears a shader the way a sprite does (#88) – but it is the only asset drawing its rows * from two graphs at once, so what a name means when both declare it is pinned here. */ describe('a source wearing a shader', () => { function wearing(uniform = 'glow') { const f = makeProject(); const p = particlesEditor(f); const create = new CreateAssetCommand('shader', 'Glow'); f.stack.execute(create); const shaderId = create.asset.id; const declared = { name: uniform, default: 3, min: 0, max: 10 }; f.stack.execute(new AddNodeCommand(shaderId, SHADER_PROPERTY, 0, 0, declared)); const keys = () => schemaForAsset(f.project, p.asset()).map((d) => d.key); const wear = () => f.stack.execute(new SetAssetPropertyCommand(p.id, SHADER_KEY, shaderId)); return { ...f, p, shaderId, keys, wear }; } it('has the picker, and the shader’s rows once one is picked', () => { const f = wearing(); expect(schemaFor('particles').find((d) => d.key === SHADER_KEY)).toMatchObject({ kind: 'asset', assetTypes: ['shader'], default: '', }); expect(f.keys()).not.toContain('glow'); f.wear(); expect(f.keys().at(-1)).toBe('glow'); expect(schemaForAsset(f.project, f.p.asset()).at(-1)).toMatchObject({ kind: 'number', default: 3 }); }); it('keeps a name it already has: the uniform gets no second row', () => { const f = wearing(RATE_KEY); f.wear(); // One row named `rate`, and it is the source's own – the uniform stays on its declared default. expect(f.keys().filter((k) => k === RATE_KEY)).toEqual([RATE_KEY]); expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === RATE_KEY)).toMatchObject({ label: 'Rate', default: 20, }); }); it('takes a name the particle graph declares, too', () => { const f = wearing('spin'); f.p.add(PARTICLE_PROPERTY, { name: 'spin', default: 7 }); f.wear(); expect(f.keys().filter((k) => k === 'spin')).toEqual(['spin']); expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === 'spin')).toMatchObject({ default: 7, }); }); it('carries the shader and its values through a save', () => { const f = wearing(); f.wear(); f.stack.execute(new SetAssetPropertyCommand(f.p.id, 'glow', 5)); const loaded = findAssetOf(roundTrip(f.project), f.p.id, 'particles')!; expect(loaded.props[SHADER_KEY]).toBe(f.shaderId); expect(loaded.props.glow).toBe(5); }); });