diff --git a/README.md b/README.md index 9d9ecb05..f1c811b9 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 816 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 821 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/particles/emission.ts b/src/core/particles/emission.ts index 3098eae6..ed9e826a 100644 --- a/src/core/particles/emission.ts +++ b/src/core/particles/emission.ts @@ -9,6 +9,10 @@ export interface EmitterSettings { count: number; /** Seconds one particle lives. */ lifetime: number; + /** How fast the source's own clock runs against the world's: 2 is twice the speed. */ + timeScale: number; + /** Start a lifetime in, so the field is already full at second 0. */ + prewarm: boolean; /** The most that may be alive at once; the oldest are the ones dropped. */ max: number; } @@ -31,6 +35,24 @@ export function birthTime(settings: EmitterSettings, index: number): number { return settings.rate > 0 ? index / settings.rate : 0; } +/** + * The clock the formulas are handed, from the seconds the source has been alive: a factor and a + * summand on that one argument rather than any state, which is what lets a prewarmed field be shown + * at its first frame without simulating the way there. + */ +export function fieldTime(settings: EmitterSettings, elapsed: number): number { + return elapsed * settings.timeScale + prewarmOffset(settings); +} + +/** + * A lifetime, which is the smallest offset that makes a continuous field full: at that second the + * oldest particle alive is exactly the one about to die. A burst has nothing left by then, so the + * offset would hide it altogether – prewarm means nothing there. + */ +export function prewarmOffset(settings: EmitterSettings): number { + return settings.prewarm && settings.mode === 'continuous' ? Math.max(0, settings.lifetime) : 0; +} + /** * The particles alive at `time`. `emitUntil` is how far the source was still emitting: switched off, * it stops taking in new ones while the ones in flight live out their lives. diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 273d199f..0ea87eed 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -35,7 +35,9 @@ import { PARTICLE_SIZE_KEY, PARTICLE_TEXTURE_KEY, particleParamDefs, + PREWARM_KEY, RATE_KEY, + TIME_SCALE_KEY, } from './particles'; import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders'; @@ -220,6 +222,8 @@ const ASSET_SCHEMAS: Record = { { key: RATE_KEY, label: 'Rate', kind: 'number', default: 20, min: 0, step: 1 }, { key: BURST_COUNT_KEY, label: 'Burst count', kind: 'number', default: 20, min: 0, step: 1, decimals: 0 }, { key: LIFETIME_KEY, label: 'Lifetime', kind: 'number', default: 1, min: 0.01, step: 0.1 }, + { key: TIME_SCALE_KEY, label: 'Time scale', kind: 'number', default: 1, min: 0, step: 0.1 }, + { key: PREWARM_KEY, label: 'Prewarm', kind: 'boolean', default: false }, { key: MAX_PARTICLES_KEY, label: 'Max particles', diff --git a/src/core/properties/particles.ts b/src/core/properties/particles.ts index f262a0bf..1041a4df 100644 --- a/src/core/properties/particles.ts +++ b/src/core/properties/particles.ts @@ -15,6 +15,8 @@ export const MODE_KEY = 'mode'; export const RATE_KEY = 'rate'; export const BURST_COUNT_KEY = 'count'; export const LIFETIME_KEY = 'lifetime'; +export const TIME_SCALE_KEY = 'timeScale'; +export const PREWARM_KEY = 'prewarm'; export const MAX_PARTICLES_KEY = 'maxParticles'; export const PARTICLE_SIZE_KEY = 'size'; export const PARTICLE_TEXTURE_KEY = 'texture'; @@ -54,6 +56,8 @@ const RESERVED_KEYS: ReadonlySet = new Set([ RATE_KEY, BURST_COUNT_KEY, LIFETIME_KEY, + TIME_SCALE_KEY, + PREWARM_KEY, MAX_PARTICLES_KEY, PARTICLE_SIZE_KEY, PARTICLE_TEXTURE_KEY, diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index 12602fda..cf351114 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -1,5 +1,5 @@ import type { Asset, ParticlesAsset } from '../../core/model/Asset'; -import { LIFETIME_KEY } from '../../core/properties/particles'; +import { LIFETIME_KEY, TIME_SCALE_KEY } from '../../core/properties/particles'; import { defaultsFor } from '../../core/properties/PropertySchema'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { Button, clamp, HStack, Label, Slider, Spacer, Splitter, Theme, VStack, type Widget } from '../../ui'; @@ -151,10 +151,15 @@ export class ParticlesEditor extends AssetTabEditor this.syncSpan(asset); } - /** The slider covers three of the asset's own lifetimes, so a particle's whole arc fits. */ + /** + * The slider covers three of the asset's own lifetimes, so a particle's whole arc fits – counted + * in the seconds the slider is in, which a time scale of 2 makes half as many. + */ private syncSpan(asset: ParticlesAsset | null): void { - const lifetime = Number(asset?.props[LIFETIME_KEY] ?? defaultsFor('particles')[LIFETIME_KEY] ?? 1); - const span = Math.max(MIN_SPAN, lifetime * SPAN_LIFETIMES); + const props = { ...defaultsFor('particles'), ...asset?.props }; + const lifetime = Number(props[LIFETIME_KEY] ?? 1); + const scale = Number(props[TIME_SCALE_KEY]); + const span = Math.max(MIN_SPAN, (lifetime * SPAN_LIFETIMES) / (scale > 0 ? scale : 1)); if (span === this.span) return; this.span = span; this.slider.setRange(0, span); diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 6ac371ab..45777708 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -1,6 +1,6 @@ import { Particle, ParticleContainer, type Texture } from 'pixi.js'; import { type CompiledParticles, emptyParticleOut, type ParticleOut } from '../core/particles/compile'; -import { birthTime, type EmitterSettings, liveRange } from '../core/particles/emission'; +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'; @@ -13,7 +13,9 @@ import { 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; @@ -24,6 +26,8 @@ export function emitterSettings(props: PropertyBag): EmitterSettings { 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)), }; } @@ -107,10 +111,10 @@ export class ParticleField { this.emits = emits; } if (this.startedAt === null) this.startedAt = now; - const time = now - this.startedAt; + const settings = emitterSettings(props); + const time = fieldTime(settings, now - this.startedAt); if (emitting) this.emittedUntil = time; - const settings = emitterSettings(props); 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) { diff --git a/tests/core/particles.test.ts b/tests/core/particles.test.ts index 4a6d0126..55320828 100644 --- a/tests/core/particles.test.ts +++ b/tests/core/particles.test.ts @@ -14,7 +14,7 @@ import { type ParticleOut, particlesFingerprint, } from '../../src/core/particles/compile'; -import { birthTime, liveCount, liveRange } from '../../src/core/particles/emission'; +import { birthTime, fieldTime, liveCount, liveRange, prewarmOffset } from '../../src/core/particles/emission'; import { hashId, mixColor, @@ -27,11 +27,14 @@ import { 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 { paletteEntries } from '../../src/editor/graph/paletteEntries'; @@ -272,6 +275,8 @@ describe('when particles are alive', () => { rate: 10, count: 20, lifetime: 1, + timeScale: 1, + prewarm: false, max: 500, }; @@ -305,6 +310,36 @@ describe('when particles are alive', () => { 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); @@ -325,8 +360,19 @@ describe('a particle colour', () => { 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]) + 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', () => { diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index b54c6abd..46181268 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -259,10 +259,27 @@ export async function particles(t) { edited.p.count === 8 && edited.c.span === 6, JSON.stringify({ ...edited.p, span: edited.c.span, defaultsWouldGive: { count: 21, span: 3 } }), ); + + // 31d-vi. (#87) The slider is in the seconds a person waits, not in the source's own: at twice the + // speed the same three lifetimes are over in half the time, so the slider reaches half as + // far – and the second it is held at is twice as far into the field. + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)}); + a.props.timeScale = 2; + `); + await sleep(300); + const scaled = await json('{ c: __gm.particles.controls(), p: __gm.particles.preview() }'); + check( + 'a time scale of 2 halves what the slider spans and doubles what a held second shows', + scaled.c.span === 3 && scaled.p.time === 1.5 && scaled.p.count === 10, + JSON.stringify({ ...scaled.p, span: scaled.c.span, atScaleOne: { count: 8, span: 6 } }), + ); + await mutate(` const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(assetId)}); delete a.props.lifetime; delete a.props.rate; + delete a.props.timeScale; `); await sleep(200); await evaluate('__gm.particles.hold(null)'); @@ -403,6 +420,85 @@ export async function particles(t) { JSON.stringify({ count: burst?.count, alphas: [...alphas], after: afterBurst?.count }), ); + // 31k-ii. (#87) Prewarm means nothing to a burst: one lifetime in, the burst it would start past is + // already over, so the offset would hide the effect instead of warming it up. + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + o.overrides.prewarm = true;`); + await sleep(200); + await rewind(); + await at(0.5); + const warmBurst = await drawn(7); + check( + 'a prewarmed burst is still a burst: the offset that fills a stream would empty this one', + warmBurst?.count === 7, + JSON.stringify({ count: warmBurst?.count, ifItApplied: 0 }), + ); + + // 31k-iii. (#87) A continuous source starts one lifetime in instead, which is exactly the second + // its field is full at: its first frame already carries a whole lifetime of particles. + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + Object.assign(o.overrides, { mode: 'continuous', drift: 0 }); + delete o.overrides.count;`); + await sleep(200); + await rewind(); + await at(0); + const warmStart = await drawn(1); + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + delete o.overrides.prewarm;`); + await sleep(200); + await rewind(); + await at(0); + const coldStart = await drawn(1); + check( + 'a prewarmed stream is full at its first frame, where a cold one has just the first particle', + warmStart?.count === RATE && coldStart?.count === 1, + JSON.stringify({ warm: warmStart?.count, cold: coldStart?.count }), + ); + + // 31k-iv. (#87) Time scale is a factor on that same clock argument and nothing else: at twice the + // speed, half a second in is the very picture the same source shows a whole second in. + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + o.overrides.timeScale = 2;`); + await sleep(200); + await rewind(); + await at(0.5); + const fast = await drawn(3); + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + delete o.overrides.timeScale;`); + await sleep(200); + await rewind(); + await at(1); + const plain = await drawn(3); + check( + 'twice the time scale is the same field half the time in, particle for particle', + fast?.count === RATE && JSON.stringify(fast?.drawn) === JSON.stringify(plain?.drawn), + JSON.stringify({ fast: fast?.count, plain: plain?.count, oldest: fast?.drawn[0] }), + ); + + // 31k-v. (#87) Both rows stand with the ones about time, under the lifetime they are measured in. + const timeRows = await json(`{ + lifetime: __gm.inspectorField('Lifetime'), + scale: __gm.inspectorField('Time scale'), + prewarm: __gm.inspectorField('Prewarm'), + }`); + check( + 'the source shows a Time scale and a Prewarm row, both below Lifetime', + Boolean(timeRows.scale && timeRows.prewarm && timeRows.lifetime) && + timeRows.scale.y > timeRows.lifetime.y && + timeRows.prewarm.y > timeRows.scale.y, + JSON.stringify(timeRows), + ); + + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + Object.assign(o.overrides, { mode: 'burst', count: 7 });`); + await sleep(200); + // 31l. It really draws: the pixel where a burst particle sits is not the scene background. await mutate(` const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});