This commit is contained in:
Andras Schmelczer 2026-05-16 16:15:54 +01:00
parent ce383ce34c
commit d2da0d1617
25 changed files with 531 additions and 1036 deletions

View file

@ -1,6 +1,11 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
@ -11,11 +16,6 @@ vi.hoisted(() => {
});
});
import { appConfig } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
const originalAgentBudgetMax = settings.agentBudgetMax;
const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex;
@ -63,10 +63,35 @@ describe('AgentPopulation adaptive budget', () => {
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
appConfig.simulation.globalAgentCap
appConfig.simulation.budget.adaptiveCapMax
);
});
it('does not grow the cap above the adaptive max agent count', () => {
const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount - 1;
setPopulationActiveCount(population, maxAgentCount - 1);
population.growBudget(1 / 60, 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount);
});
it('clamps a manually raised cap before adding agents', () => {
const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount + 1_000;
setPopulationActiveCount(population, maxAgentCount);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount);
});
it('decreases the cap and active count slowly when FPS falls below the threshold', () => {
const population = createPopulation();
setPopulationActiveCount(population, 1_000_000);
@ -74,8 +99,6 @@ describe('AgentPopulation adaptive budget', () => {
population.growBudget(10, 50, 60);
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
expect(population.activeAgentCount).toBe(
appConfig.simulation.budget.adaptiveCapMin
);
expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
});
});

View file

@ -12,6 +12,7 @@ const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
const ADAPTIVE_CAP_MAX = appConfig.simulation.budget.adaptiveCapMax;
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
@ -129,6 +130,7 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
@ -214,8 +216,9 @@ export class AgentPopulation {
private clampAdaptiveCap(value: number): number {
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap);
const maxCap = Math.min(ADAPTIVE_CAP_MAX, pipelineCap);
const minCap = Math.min(ADAPTIVE_CAP_MIN, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue)));
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
}
}

View file

@ -1,14 +1,5 @@
import { appConfig } from '../config';
interface TelemetrySnapshot {
frameCpuStartedAt: number;
encodeCpuMs: number;
activeAgentCount: number;
agentBudgetMax: number;
canvas: HTMLCanvasElement;
devicePixelRatio: number;
}
const COMMON_DISPLAY_REFRESH_RATES = [
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
] as const;
@ -22,20 +13,11 @@ export class FramePerformance {
public displayRefreshFps = 60;
public readonly refreshTargetFps = 60;
private lastTelemetryAt = 0;
private previousFrameTime: DOMHighResTimeStamp | null = null;
private hasConfirmedDisplayRefreshFps = false;
private pendingDisplayRefreshFps = 0;
private pendingDisplayRefreshFrameCount = 0;
public markCpuStart(): number {
return appConfig.telemetry.enabled ? performance.now() : 0;
}
public measureSince(startedAt: number): number {
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
}
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
this.previousFrameTime = time;
@ -56,39 +38,6 @@ export class FramePerformance {
fps * appConfig.simulation.budget.fpsSmoothingNew;
}
public renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount,
agentBudgetMax,
canvas,
devicePixelRatio,
}: TelemetrySnapshot): void {
if (!appConfig.telemetry.enabled) {
return;
}
const now = performance.now();
if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) {
return;
}
this.lastTelemetryAt = now;
console.debug('Fleeting Garden telemetry', {
fps: Math.round(this.latestFps),
smoothedFps: Math.round(this.smoothedFps),
refreshTargetFps: Math.round(this.refreshTargetFps),
displayRefreshFps: Math.round(this.displayRefreshFps),
activeAgentCount,
agentBudgetMax,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
dpr: devicePixelRatio,
frameCpuMs: now - frameCpuStartedAt,
encodeCpuMs,
});
}
private updateDisplayRefreshEstimate(fps: number): void {
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
if (displayRefreshFps === null) {

View file

@ -4,6 +4,4 @@ export interface GameLoopSettings {
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;
startColorHue: number;
}

View file

@ -23,11 +23,7 @@ export default class GameLoop {
private static readonly DEV_STATS_INTERVAL_MS = 250;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(
gardenAudioConfig,
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
private readonly audio = new GardenAudio(gardenAudioConfig);
private readonly renderInputs = new RenderInputCache();
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
@ -68,7 +64,6 @@ export default class GameLoop {
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
@ -167,7 +162,6 @@ export default class GameLoop {
return;
}
const frameCpuStartedAt = this.framePerformance.markCpuStart();
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
this.agentPopulation.growBudget(
@ -194,7 +188,6 @@ export default class GameLoop {
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
mirrorSegmentCount: this.mirrorSegmentCount,
});
this.resources.setFrameParameters({
@ -212,24 +205,14 @@ export default class GameLoop {
eraserPixelSize,
});
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(
isErasing,
this.toolbarContrastMonitor.takeReadbackRequest(time)
);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.framePerformance.renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount: this.agentPopulation.activeAgentCount,
agentBudgetMax: settings.agentBudgetMax,
canvas: this.canvas,
devicePixelRatio: this.devicePixelRatio,
});
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {

View file

@ -118,7 +118,6 @@ const createPointerInput = async () => {
eraserAgentPipeline,
eraserPreview,
eraserTexturePipeline,
getCanvasSize: () => [canvas.width, canvas.height],
getDevicePixelRatio: () => 1,
getMirrorSegmentCount: () => 1,
onEraseGestureEnded,
@ -185,9 +184,7 @@ describe('GardenPointerInput drawing startup', () => {
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(audio.touchDown).toHaveBeenCalledWith(
expect.objectContaining({
canvasSize: [300, 200],
colorIndex: 0,
position: expect.any(Float32Array),
})
);
expect(audio.stroke).not.toHaveBeenCalled();

View file

@ -16,7 +16,6 @@ interface GardenPointerInputOptions {
eraserAgentPipeline: EraserAgentPipeline;
eraserTexturePipeline: EraserTexturePipeline;
eraserPreview: EraserPreview;
getCanvasSize: () => vec2;
getDevicePixelRatio: () => number;
getMirrorSegmentCount: () => number;
onStartDrawing: () => void;
@ -32,7 +31,6 @@ export class GardenPointerInput {
private activePointerId: number | null = null;
private lastPointerPosition: vec2 | null = null;
private lastPointerEventTimeMs: number | null = null;
private lastPointerPressure = 0.5;
private smoothedStrokePoints: Array<vec2> = [];
private lastSmoothedBrushPosition: vec2 | null = null;
private isErasing = false;
@ -109,18 +107,12 @@ export class GardenPointerInput {
return;
}
const position = this.getCanvasPointerPosition(event);
if (event.pointerType !== 'touch') {
this.options.audio.start(activeVibe, { userGesture: true });
}
this.options.audio.beginGesture();
this.options.audio.touchDown({
vibe: activeVibe,
colorIndex: settings.selectedColorIndex,
position,
canvasSize: this.options.getCanvasSize(),
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pressure: this.getPointerPressure(event),
});
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
@ -131,7 +123,6 @@ export class GardenPointerInput {
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.clearSmoothedStroke();
this.lastPointerPressure = this.getPointerPressure(event);
this.addSwipeAt(event, { emitAudio: false });
};
@ -178,7 +169,6 @@ export class GardenPointerInput {
};
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
const devicePixelRatio = this.options.getDevicePixelRatio();
const position = this.getCanvasPointerPosition(event);
const previousPosition = this.lastPointerPosition ?? position;
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
@ -186,10 +176,6 @@ export class GardenPointerInput {
appConfig.deltaTime.minDeltaTimeSeconds,
(event.timeStamp - previousTimeMs) / 1000
);
const distancePixels = vec2.distance(previousPosition, position);
const velocityPixelsPerSecond = distancePixels / elapsedSeconds;
const pressure = this.getPointerPressure(event);
this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure;
const segments = this.isErasing
? [{ from: previousPosition, to: position }]
@ -214,14 +200,9 @@ export class GardenPointerInput {
vibe: activeVibe,
from: previousPosition,
to: position,
canvasSize: this.options.getCanvasSize(),
colorIndex: settings.selectedColorIndex,
isErasing: this.isErasing,
pressure: pressure > 0 ? pressure : this.lastPointerPressure,
velocityPixelsPerSecond,
elapsedSeconds,
eraserSizePixels: settings.eraserSize * devicePixelRatio,
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
});
}
this.lastPointerPosition = position;
@ -363,14 +344,6 @@ export class GardenPointerInput {
return segments;
}
private getPointerPressure(event: PointerEvent): number {
if (Number.isFinite(event.pressure) && event.pressure > 0) {
return Math.min(1, Math.max(0, event.pressure));
}
return 0;
}
}
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
@ -409,5 +382,4 @@ const getBrushCurveResolution = (): number => {
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
left.clientX === right.clientX &&
left.clientY === right.clientY &&
left.pressure === right.pressure &&
left.buttons === right.buttons;