Time is an argument, so speed scales it and a prewarm offsets it (#87)
game-maker / suite (push) Successful in 9m48s
game-maker / suite (push) Successful in 9m48s
Speed and Prewarm are not state and not a second loop: both are arithmetic on the clock argument the field already passes to every particle formula. fieldTime = elapsed * timeScale + prewarmOffset, a pure rule next to liveRange, called at the one place ParticleField.sync forms its time, so preview and world inherit it together. Prewarm offsets by one lifetime because that is the smallest offset which fills a continuous emitter; a burst is dead at time >= lifetime, so the same offset would skip it entirely and it is suppressed there. The tab's slider counts wall-clock seconds, so its span divides by the scale. A missing row falls back to 1 / off, so old project files need no migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fbb2d157f0
commit
8ae20d044f
8 files changed
+191
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<AssetType, PropertySchema> = {
|
||||
{ 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',
|
||||
|
||||
@@ -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<string> = new Set([
|
||||
RATE_KEY,
|
||||
BURST_COUNT_KEY,
|
||||
LIFETIME_KEY,
|
||||
TIME_SCALE_KEY,
|
||||
PREWARM_KEY,
|
||||
MAX_PARTICLES_KEY,
|
||||
PARTICLE_SIZE_KEY,
|
||||
PARTICLE_TEXTURE_KEY,
|
||||
|
||||
@@ -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<ParticlesAsset, GraphCanvas>
|
||||
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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)});
|
||||
|
||||
Reference in new issue
Block a user