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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user