more cleaning up

This commit is contained in:
Andras Schmelczer 2026-05-16 20:45:42 +01:00
parent 2c7d72a699
commit 560398fefb
110 changed files with 933 additions and 2647 deletions

View file

@ -16,7 +16,6 @@ vi.hoisted(() => {
});
});
const originalAgentBudgetMax = settings.agentBudgetMax;
const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex;
const originalSpawnPerPixel = settings.spawnPerPixel;
@ -38,16 +37,23 @@ const setPopulationActiveCount = (population: AgentPopulation, activeCount: numb
});
};
const setPopulationAdaptiveCap = (population: AgentPopulation, adaptiveCap: number) => {
Object.assign(population as unknown as Record<string, number>, {
adaptiveCap,
});
};
const getPopulationAdaptiveCap = (population: AgentPopulation): number =>
(population as unknown as { adaptiveCap: number }).adaptiveCap;
describe('AgentPopulation adaptive budget', () => {
beforeEach(() => {
settings.agentBudgetMax = 1_000_000;
settings.brushSize = 1;
settings.selectedColorIndex = 0;
settings.spawnPerPixel = 1;
});
afterEach(() => {
settings.agentBudgetMax = originalAgentBudgetMax;
settings.brushSize = originalBrushSize;
settings.selectedColorIndex = originalSelectedColorIndex;
settings.spawnPerPixel = originalSpawnPerPixel;
@ -57,12 +63,16 @@ describe('AgentPopulation adaptive budget', () => {
const population = createPopulation();
setPopulationActiveCount(population, 1_000_000);
population.growBudget(1 / 60, 60, 60);
population.growBudget(1 / 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
expect(getPopulationAdaptiveCap(population)).toBeGreaterThan(
appConfig.simulation.budget.adaptiveCapInitial
);
expect(population.activeAgentCount).toBeGreaterThan(
appConfig.simulation.budget.adaptiveCapInitial
);
expect(getPopulationAdaptiveCap(population)).toBeLessThanOrEqual(
appConfig.simulation.budget.adaptiveCapMax
);
});
@ -70,25 +80,25 @@ describe('AgentPopulation adaptive budget', () => {
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;
setPopulationAdaptiveCap(population, maxAgentCount - 1);
setPopulationActiveCount(population, maxAgentCount - 1);
population.growBudget(1 / 60, 60, 60);
population.growBudget(1 / 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount);
expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount);
});
it('clamps a manually raised cap before adding agents', () => {
it('clamps a stale cap before adding agents', () => {
const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount + 1_000;
setPopulationAdaptiveCap(population, maxAgentCount + 1_000);
setPopulationActiveCount(population, maxAgentCount);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount);
expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount);
});
@ -96,9 +106,11 @@ describe('AgentPopulation adaptive budget', () => {
const population = createPopulation();
setPopulationActiveCount(population, 1_000_000);
population.growBudget(10, 50, 60);
population.growBudget(10, 50);
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin);
expect(getPopulationAdaptiveCap(population)).toBe(
appConfig.simulation.budget.adaptiveCapMin
);
expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
});
});

View file

@ -6,19 +6,20 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag
import { settings } from '../settings';
import { createIntroTitleAgents } from './intro-title-agents';
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
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_INITIAL = appConfig.simulation.budget.adaptiveCapInitial;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
export class AgentPopulation {
private activeCount = 0;
private adaptiveCap: number;
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
@ -27,19 +28,17 @@ export class AgentPopulation {
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
);
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
public constructor(private readonly pipeline: AgentGenerationPipeline) {
this.adaptiveCap = this.clampAdaptiveCap(ADAPTIVE_CAP_INITIAL);
}
public get activeAgentCount(): number {
return this.activeCount;
}
public get maxAgentCount(): number {
return this.pipeline.maxAgentCount;
}
public initializeIntroAgents(canvasSize: vec2): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT);
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
const introAgentCount = Math.min(this.adaptiveCap, INITIAL_AGENT_COUNT);
this.writeAgentBatch(
createIntroTitleAgents({
count: introAgentCount,
@ -50,16 +49,12 @@ export class AgentPopulation {
}
public onVibeChanged(): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget();
}
public growBudget(
deltaTime: number,
smoothedFps: number,
refreshTargetFps: number
): void {
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
public growBudget(deltaTime: number, smoothedFps: number): void {
this.updateAdaptiveCap(deltaTime, smoothedFps);
}
public resizeAgents(scale: vec2): void {
@ -108,7 +103,7 @@ export class AgentPopulation {
const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t;
const angle =
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
baseAngle +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
@ -130,10 +125,10 @@ export class AgentPopulation {
}
const count = data.length / AGENT_FLOAT_COUNT;
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
const available = Math.max(0, this.adaptiveCap - this.activeCount);
const appendCount = Math.min(count, available);
if (appendCount > 0) {
@ -165,18 +160,14 @@ export class AgentPopulation {
}
}
private updateAdaptiveCap(
deltaTime: number,
smoothedFps: number,
refreshTargetFps: number
): void {
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
this.canExpandAdaptiveCap =
refreshTargetFps <= 0 ||
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
smoothedFps >=
ADAPTIVE_REFRESH_TARGET_FPS * appConfig.simulation.budget.fpsHeadroom;
if (this.canExpandAdaptiveCap) {
settings.agentBudgetMax = previousCap;
this.adaptiveCap = previousCap;
this.trimActiveCountToBudget();
return;
}
@ -186,28 +177,28 @@ export class AgentPopulation {
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
);
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
settings.agentBudgetMax = nextCap;
this.adaptiveCap = nextCap;
this.trimActiveCountToBudget(decrease);
}
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
const available = Math.max(0, this.adaptiveCap - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
const currentCap = this.clampAdaptiveCap(this.adaptiveCap);
const pendingAgentCount = requestedAgentCount - available;
settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount);
this.adaptiveCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
}
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
if (this.activeCount <= settings.agentBudgetMax) {
if (this.activeCount <= this.adaptiveCap) {
return;
}
this.activeCount = Math.max(
settings.agentBudgetMax,
this.adaptiveCap,
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
);
this.replacementCursor =

View file

@ -71,10 +71,7 @@ export class Export4KRenderer {
texture = this.device.createTexture({
size: { width, height },
format,
usage:
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.TEXTURE_BINDING,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
output = this.device.createBuffer({
size: estimate.readbackBufferBytes,

View file

@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import {
estimateExport4KMemory,
formatByteSize,
getAspectFitExport4KDimensions,
getExport4KPreflightError,
} from './export-4k';
@ -39,7 +38,6 @@ describe('4K export preflight', () => {
expect(estimate.height).toBe(2160);
expect(estimate.bytesPerRow % 256).toBe(0);
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
});
it('rejects GPUs that cannot allocate the export texture', () => {

View file

@ -14,14 +14,10 @@ const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
interface Export4KMemoryEstimate {
width: number;
height: number;
bytesPerPixel: number;
unpaddedBytesPerRow: number;
bytesPerRow: number;
textureBytes: number;
readbackBufferBytes: number;
pixelBytes: number;
canvasBytes: number;
encoderSafetyBytes: number;
estimatedJsHeapBytes: number;
estimatedPeakBytes: number;
}
@ -49,8 +45,7 @@ const alignTo = (value: number, alignment: number): number =>
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
export const formatByteSize = (bytes: number): string =>
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
const formatByteSize = (bytes: number): string => `${Math.ceil(bytes / 1024 / 1024)} MiB`;
export const getAspectFitExport4KDimensions = (
sourceWidth: number,
@ -83,22 +78,15 @@ export const estimateExport4KMemory = (
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
const textureBytes = unpaddedBytesPerRow * height;
const readbackBufferBytes = bytesPerRow * height;
const pixelBytes = textureBytes;
const canvasBytes = textureBytes;
const encoderSafetyBytes = textureBytes * 2;
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
const estimatedJsHeapBytes = textureBytes * 4;
return {
width,
height,
bytesPerPixel: BYTES_PER_PIXEL,
unpaddedBytesPerRow,
bytesPerRow,
textureBytes,
readbackBufferBytes,
pixelBytes,
canvasBytes,
encoderSafetyBytes,
estimatedJsHeapBytes,
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
};

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { FramePerformance } from './frame-performance';
const INITIAL_FPS = 60;
function createScenario() {
const performance = new FramePerformance();
let time = 0;
@ -13,52 +15,31 @@ function createScenario() {
return { performance, advance };
}
describe('FramePerformance refresh target', () => {
it('uses 60 FPS as the fixed adaptive budget target', () => {
const { performance, advance } = createScenario();
describe('FramePerformance', () => {
it('starts at the adaptive budget target', () => {
const { performance } = createScenario();
[123, 126, 130, 121, 60, 30].forEach(advance);
expect(performance.refreshTargetFps).toBe(60);
expect(performance.smoothedFps).toBe(INITIAL_FPS);
});
it('keeps latest and smoothed FPS separate from the fixed target', () => {
it('smooths measured frame rates', () => {
const { performance, advance } = createScenario();
advance(120);
expect(performance.latestFps).toBe(120);
expect(performance.smoothedFps).toBeGreaterThan(60);
expect(performance.refreshTargetFps).toBe(60);
expect(performance.smoothedFps).toBeGreaterThan(INITIAL_FPS);
expect(performance.smoothedFps).toBeLessThan(120);
});
it('reports true FPS even when the simulation delta would clamp', () => {
const { performance, advance } = createScenario();
it('ignores long gaps before smoothing resumes', () => {
const performance = new FramePerformance();
performance.update(0);
performance.update(2_000);
[5, 5, 5, 5, 5].forEach(advance);
expect(performance.smoothedFps).toBe(INITIAL_FPS);
expect(performance.latestFps).toBeCloseTo(5, 5);
});
performance.update(2_000 + 1000 / 30);
it('snaps the display refresh estimate to a stable screen frequency', () => {
const { performance, advance } = createScenario();
[123, 126, 130, 121, 124, 127, 125, 122].forEach(advance);
expect(performance.refreshTargetFps).toBe(60);
expect(performance.displayRefreshFps).toBe(120);
});
it('ignores a single startup spike before settling the display refresh estimate', () => {
const { performance, advance } = createScenario();
advance(240);
expect(performance.displayRefreshFps).toBe(60);
Array.from({ length: 8 }).forEach(() => advance(120));
expect(performance.refreshTargetFps).toBe(60);
expect(performance.displayRefreshFps).toBe(120);
expect(performance.smoothedFps).toBeLessThan(INITIAL_FPS);
});
});

View file

@ -1,22 +1,12 @@
import { appConfig } from '../config';
const COMMON_DISPLAY_REFRESH_RATES = [
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
] as const;
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
const INITIAL_FPS = 60;
const FRAME_GAP_RESET_SECONDS = 1;
export class FramePerformance {
public latestFps = 60;
public smoothedFps = 60;
public displayRefreshFps = 60;
public readonly refreshTargetFps = 60;
public smoothedFps = INITIAL_FPS;
private previousFrameTime: DOMHighResTimeStamp | null = null;
private hasConfirmedDisplayRefreshFps = false;
private pendingDisplayRefreshFps = 0;
private pendingDisplayRefreshFrameCount = 0;
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
@ -31,67 +21,8 @@ export class FramePerformance {
}
const fps = 1 / deltaSeconds;
this.latestFps = fps;
this.updateDisplayRefreshEstimate(fps);
this.smoothedFps =
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
fps * appConfig.simulation.budget.fpsSmoothingNew;
}
private updateDisplayRefreshEstimate(fps: number): void {
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
if (displayRefreshFps === null) {
this.resetPendingDisplayRefreshEstimate();
return;
}
if (
this.hasConfirmedDisplayRefreshFps &&
displayRefreshFps < this.displayRefreshFps
) {
this.resetPendingDisplayRefreshEstimate();
return;
}
if (displayRefreshFps !== this.pendingDisplayRefreshFps) {
this.pendingDisplayRefreshFps = displayRefreshFps;
this.pendingDisplayRefreshFrameCount = 1;
} else {
this.pendingDisplayRefreshFrameCount += 1;
}
if (this.pendingDisplayRefreshFrameCount < DISPLAY_REFRESH_CONFIRMATION_FRAMES) {
return;
}
this.displayRefreshFps = displayRefreshFps;
this.hasConfirmedDisplayRefreshFps = true;
this.resetPendingDisplayRefreshEstimate();
}
private snapDisplayRefreshRate(fps: number): number | null {
if (!Number.isFinite(fps) || fps <= 0) {
return null;
}
let nearestRefreshRate: number = COMMON_DISPLAY_REFRESH_RATES[0];
let nearestDifference = Math.abs(fps - nearestRefreshRate);
COMMON_DISPLAY_REFRESH_RATES.forEach((refreshRate) => {
const difference = Math.abs(fps - refreshRate);
if (difference < nearestDifference) {
nearestRefreshRate = refreshRate;
nearestDifference = difference;
}
});
return nearestDifference / nearestRefreshRate <= DISPLAY_REFRESH_SNAP_TOLERANCE
? nearestRefreshRate
: null;
}
private resetPendingDisplayRefreshEstimate(): void {
this.pendingDisplayRefreshFps = 0;
this.pendingDisplayRefreshFrameCount = 0;
}
}

View file

@ -19,7 +19,8 @@ const getRenderStepSource = () => {
const start = simulationFrameSource.indexOf(
'const commandEncoder = this.device.createCommandEncoder();'
);
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
const swapCall = ' this.textures.swapBrushEffectMaps();';
const end = simulationFrameSource.indexOf(swapCall, start) + swapCall.length;
if (start < 0 || end < 0) {
throw new Error('Could not find the simulation frame execution body');
@ -38,7 +39,7 @@ describe('GameLoop ping-pong texture flow', () => {
/commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/
);
expect(renderStepSource).toMatch(
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapBrushEffectMaps\(\);/
);
});
@ -47,6 +48,7 @@ describe('GameLoop ping-pong texture flow', () => {
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
expect(resizableTextureSource).toContain('this.copyPipeline.execute(');
expect(simulationTexturesSource).toContain('private readonly copyPipeline');
});
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
@ -54,6 +56,7 @@ describe('GameLoop ping-pong texture flow', () => {
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain('public swapBrushEffectMaps(): void');
expect(simulationTexturesSource).toContain(
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
);

View file

@ -11,7 +11,6 @@ import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeli
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context';
import { GLOBAL_AGENT_CAP } from './agent-population';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
@ -24,8 +23,6 @@ interface FrameParameters extends RenderInputs {
introProgress: number;
selectedColorIndex: number;
isErasing: boolean;
cameraCenter: [number, number];
cameraZoom: number;
eraserPixelSize: number;
}
@ -56,13 +53,11 @@ export class GameLoopResources {
this.commonState.setParameters({
canvasSize,
time: 0,
deltaTime: 0,
});
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
this.commonState,
GLOBAL_AGENT_CAP
appConfig.simulation.budget.adaptiveCapMax
);
this.agentPipeline = new AgentPipeline(
@ -73,15 +68,11 @@ export class GameLoopResources {
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.eraserAgentPipeline = new EraserAgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
);
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
this.device,
this.commonState
);
this.diffusionPipeline = new DiffusionPipeline(this.device);
this.brushEffectDiffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
@ -106,17 +97,13 @@ export class GameLoopResources {
activeAgentCount,
introProgress,
selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
}: FrameParameters): void {
this.commonState.setParameters({
canvasSize,
time,
deltaTime,
});
this.agentPipeline.setParameters({
...settings,
@ -133,19 +120,15 @@ export class GameLoopResources {
this.brushPipeline.setParameters({
...settings,
selectedColorIndex,
isErasing,
});
this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({
...settings,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
});
this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount,
eraserSize: eraserPixelSize,
});
this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize,
@ -160,10 +143,6 @@ export class GameLoopResources {
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
}
public clearSwipes(): void {
this.frameRenderer.clearSwipes();
}
public destroy(): void {
this.agentGenerationPipeline.destroy();
this.agentPipeline.destroy();

View file

@ -1,7 +0,0 @@
export interface GameLoopSettings {
agentBudgetMax: number;
agentCount: number;
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;
}

View file

@ -5,7 +5,6 @@ import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { sleep } from '../utils/sleep';
import { AgentPopulation } from './agent-population';
import { EraserPreview } from './eraser-preview';
import { Export4KRenderer } from './export-4k-renderer';
@ -18,9 +17,7 @@ import { RenderInputCache } from './render-input-cache';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
export default class GameLoop {
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private static readonly DEV_STATS_INTERVAL_MS = 250;
private static readonly MAX_MIRROR_SEGMENT_COUNT = appConfig.toolbar.mirror.max;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
@ -32,13 +29,10 @@ export default class GameLoop {
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly devStatsElement: HTMLDivElement | null;
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private lastDevStatsUpdateAt = 0;
private isStatsOverlayPinned = false;
private hasFinished = false;
private readonly finished = Promise.withResolvers<void>();
@ -49,8 +43,6 @@ export default class GameLoop {
ui: GardenUi
) {
this.resize();
this.devStatsElement = this.createDevStatsElement();
this.syncDevStatsVisibility();
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@ -102,25 +94,14 @@ export default class GameLoop {
}
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.renderInputs.invalidate();
this.agentPopulation.onVibeChanged();
}
public setAudioMuted(isMuted: boolean): void {
this.audio.setMuted(isMuted);
}
public setStatsOverlayPinned(isPinned: boolean): void {
const wasVisible = this.shouldShowDevStats;
this.isStatsOverlayPinned = isPinned;
this.syncDevStatsVisibility();
if (!wasVisible && this.shouldShowDevStats) {
this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY;
this.updateDevStats(performance.now());
}
}
public startAudio(userGesture = false): void {
this.audio.start(activeVibe, { userGesture });
}
@ -134,10 +115,6 @@ export default class GameLoop {
return this.finished.promise;
}
public get maxAgentCount(): number {
return this.agentPopulation.maxAgentCount;
}
public async export4K(): Promise<void> {
return this.export4KRenderer.export();
}
@ -150,7 +127,6 @@ export default class GameLoop {
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.toolbarContrastMonitor.destroy();
this.devStatsElement?.remove();
this.introPrompt.destroy();
this.resources.destroy();
await this.audio.destroy();
@ -164,29 +140,19 @@ export default class GameLoop {
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
this.introPrompt.update();
this.resize();
this.resizeSimulationToCanvas();
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
];
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
this.audio.update({
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
});
@ -200,8 +166,6 @@ export default class GameLoop {
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
});
@ -213,60 +177,9 @@ export default class GameLoop {
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
}
requestAnimationFrame(this.render);
};
private createDevStatsElement(): HTMLDivElement | null {
const container = this.canvas.parentElement;
if (!container) {
return null;
}
const element = document.createElement('div');
element.className = 'dev-stats-overlay';
element.setAttribute('aria-hidden', 'true');
container.appendChild(element);
return element;
}
private updateDevStats(time: DOMHighResTimeStamp): void {
if (
!this.devStatsElement ||
!this.shouldShowDevStats ||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
) {
return;
}
this.lastDevStatsUpdateAt = time;
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
this.devStatsElement.textContent = [
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
].join('\n');
}
private syncDevStatsVisibility(): void {
if (!this.devStatsElement) {
return;
}
const isVisible = this.shouldShowDevStats;
this.devStatsElement.hidden = !isVisible;
this.devStatsElement.setAttribute('aria-hidden', String(!isVisible));
}
private formatDevStatNumber(value: number): string {
return Math.max(0, Math.round(value)).toLocaleString('en-US');
}
private resize(): void {
const width = Math.max(
1,
@ -310,8 +223,4 @@ export default class GameLoop {
: 1;
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
}
private get shouldShowDevStats(): boolean {
return import.meta.env.DEV || this.isStatsOverlayPinned;
}
}

View file

@ -1,13 +1,13 @@
import { appConfig } from '../config';
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
const DRAW_HINT_CLASS = 'draw-hint';
export class IntroPrompt {
private introComplete = false;
private introStartedAt = performance.now();
private introCompletedAt: number | null = null;
private hasStartedDrawing = false;
private isDrawHintVisible = false;
public constructor(private readonly prompt: HTMLElement) {}
@ -55,12 +55,11 @@ export class IntroPrompt {
}
private showDrawHint(): void {
if (this.isDrawHintVisible) {
if (this.prompt.classList.contains(DRAW_HINT_CLASS)) {
return;
}
this.isDrawHintVisible = true;
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
this.prompt.classList.add(DRAW_HINT_CLASS);
this.prompt.innerHTML = `
<svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false">
<path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
@ -68,13 +67,12 @@ export class IntroPrompt {
<circle class="draw-hint-start" cx="12" cy="50" r="4" />
<circle class="draw-hint-end" cx="116" cy="42" r="7" />
</svg>
<span class="draw-hint-text">Draw on the screen</span>
<span>Draw on the screen</span>
`;
}
private hideDrawHint(): void {
this.isDrawHintVisible = false;
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
this.prompt.classList.remove(DRAW_HINT_CLASS);
this.prompt.replaceChildren();
}
}

View file

@ -97,7 +97,6 @@ const createPointerInput = async () => {
endGesture: vi.fn(),
start: vi.fn(),
stroke: vi.fn(),
touchDown: vi.fn(),
};
const brushPipeline = makeSwipePipeline();
const eraserAgentPipeline = makeSwipePipeline();
@ -182,11 +181,6 @@ describe('GardenPointerInput drawing startup', () => {
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(audio.touchDown).toHaveBeenCalledWith(
expect.objectContaining({
colorIndex: 0,
})
);
expect(audio.stroke).not.toHaveBeenCalled();
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);

View file

@ -111,9 +111,6 @@ export class GardenPointerInput {
this.options.audio.start(activeVibe, { userGesture: true });
}
this.options.audio.beginGesture();
this.options.audio.touchDown({
colorIndex: settings.selectedColorIndex,
});
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
@ -183,14 +180,11 @@ export class GardenPointerInput {
if (this.isErasing) {
segments.forEach((segment) => {
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
this.options.eraserAgentPipeline.addSwipeSegment();
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
});
} else {
this.addSmoothedBrushSample(position);
}
if (!this.isErasing) {
segments.forEach((segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to);
});
@ -200,7 +194,6 @@ export class GardenPointerInput {
vibe: activeVibe,
from: previousPosition,
to: position,
colorIndex: settings.selectedColorIndex,
isErasing: this.isErasing,
elapsedSeconds,
});
@ -375,7 +368,7 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
: appConfig.runtimeSettings.defaults.brushCurveResolution;
: appConfig.defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};

View file

@ -80,13 +80,6 @@ export class SimulationFrameRenderer {
this.device.queue.submit([commandEncoder.finish()]);
canvasReadbackRequest?.afterSubmit();
this.textures.swapSourceMaps();
this.textures.swapInfluenceMaps();
}
public clearSwipes(): void {
this.pipelines.brushPipeline.clearSwipes();
this.pipelines.eraserAgentPipeline.clearSwipes();
this.pipelines.eraserTexturePipeline.clearSwipes();
this.textures.swapBrushEffectMaps();
}
}

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { ResizableTexture } from '../utils/graphics/resizable-texture';
export class SimulationTextures {
@ -10,18 +11,20 @@ export class SimulationTextures {
public influenceMapA: ResizableTexture;
public influenceMapB: ResizableTexture;
public eraserMask: ResizableTexture;
private readonly copyPipeline: CopyPipeline;
public constructor(
private readonly device: GPUDevice,
canvasSize: vec2
) {
this.trailMapA = new ResizableTexture(this.device, canvasSize);
this.trailMapB = new ResizableTexture(this.device, canvasSize);
this.sourceMapA = new ResizableTexture(this.device, canvasSize);
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
this.influenceMapB = new ResizableTexture(this.device, canvasSize);
this.eraserMask = new ResizableTexture(this.device, canvasSize);
this.copyPipeline = new CopyPipeline(this.device);
this.trailMapA = this.createTexture(canvasSize);
this.trailMapB = this.createTexture(canvasSize);
this.sourceMapA = this.createTexture(canvasSize);
this.sourceMapB = this.createTexture(canvasSize);
this.influenceMapA = this.createTexture(canvasSize);
this.influenceMapB = this.createTexture(canvasSize);
this.eraserMask = this.createTexture(canvasSize);
}
public resizeTo(nextSize: vec2): vec2 | null {
@ -69,11 +72,8 @@ export class SimulationTextures {
);
}
public swapSourceMaps(): void {
public swapBrushEffectMaps(): void {
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
}
public swapInfluenceMaps(): void {
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
}
@ -85,5 +85,10 @@ export class SimulationTextures {
this.influenceMapA.destroy();
this.influenceMapB.destroy();
this.eraserMask.destroy();
this.copyPipeline.destroy();
}
private createTexture(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, this.copyPipeline, size);
}
}

View file

@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
getToolbarContrastMetrics,
shouldDimToolbarBackground,
} from './toolbar-contrast-monitor';
import { getToolbarContrastMetrics } from './toolbar-contrast-monitor';
const makePixels = (
samples: ReadonlyArray<readonly [number, number, number]>
@ -27,12 +24,28 @@ describe('toolbar contrast monitoring', () => {
false
);
expect(metrics.dimmingStrength).toBe(0);
expect(metrics.backgroundOpacity).toBe(0);
expect(metrics.lowContrastRatio).toBe(0);
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
});
it('dims the toolbar when enough samples have poor contrast with white controls', () => {
it('ramps background opacity as canvas samples get lighter', () => {
const dimMetrics = getToolbarContrastMetrics(
makePixels(Array.from({ length: 91 }, () => [130, 130, 130])),
91,
false
);
const brightMetrics = getToolbarContrastMetrics(
makePixels(Array.from({ length: 91 }, () => [210, 210, 210])),
91,
false
);
expect(dimMetrics.backgroundOpacity).toBeGreaterThan(0);
expect(brightMetrics.backgroundOpacity).toBeGreaterThan(dimMetrics.backgroundOpacity);
expect(brightMetrics.backgroundOpacity).toBeLessThanOrEqual(0.82);
});
it('raises background opacity when enough samples have poor contrast with white controls', () => {
const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const);
const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const);
const metrics = getToolbarContrastMetrics(
@ -42,21 +55,7 @@ describe('toolbar contrast monitoring', () => {
);
expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08);
expect(shouldDimToolbarBackground(metrics, false)).toBe(true);
});
it('keeps the dimmed state until contrast has clearly recovered', () => {
const metrics = getToolbarContrastMetrics(
makePixels([
...Array.from({ length: 86 }, () => [8, 12, 18] as const),
...Array.from({ length: 5 }, () => [245, 240, 218] as const),
]),
91,
false
);
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
expect(shouldDimToolbarBackground(metrics, true)).toBe(true);
expect(metrics.backgroundOpacity).toBeGreaterThan(0);
});
it('reads bgra canvas samples in the correct channel order', () => {

View file

@ -7,8 +7,8 @@ interface CanvasSamplePoint {
interface ToolbarContrastMetrics {
averageLuminance: number;
backgroundOpacity: number;
brightRatio: number;
dimmingStrength: number;
lowContrastRatio: number;
}
@ -16,10 +16,9 @@ const BYTES_PER_SAMPLE = 4;
const SAMPLE_COLUMNS = 13;
const SAMPLE_ROWS = 7;
const SAMPLE_INTERVAL_MS = 300;
const LOW_CONTRAST_RATIO_TO_DIM = 0.08;
const LOW_CONTRAST_RATIO_TO_CLEAR = 0.04;
const DIMMING_STRENGTH_TO_DIM = 0.18;
const DIMMING_STRENGTH_TO_CLEAR = 0.1;
const BACKGROUND_OPACITY_MAX = 0.82;
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
@ -44,8 +43,8 @@ export const getToolbarContrastMetrics = (
if (count === 0) {
return {
averageLuminance: 0,
backgroundOpacity: 0,
brightRatio: 0,
dimmingStrength: 0,
lowContrastRatio: 0,
};
}
@ -74,34 +73,24 @@ export const getToolbarContrastMetrics = (
const averageLuminance = luminanceTotal / count;
const brightRatio = brightCount / count;
const lowContrastRatio = lowContrastCount / count;
const dimmingStrength = clamp01(
const backgroundStrength = clamp01(
Math.max(0, averageLuminance - 0.11) / 0.28 +
brightRatio * 0.65 +
lowContrastRatio * 1.8
);
const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
return {
averageLuminance,
backgroundOpacity,
brightRatio,
dimmingStrength,
lowContrastRatio,
};
};
export const shouldDimToolbarBackground = (
metrics: ToolbarContrastMetrics,
wasDimmed: boolean
): boolean =>
wasDimmed
? metrics.dimmingStrength > DIMMING_STRENGTH_TO_CLEAR ||
metrics.lowContrastRatio > LOW_CONTRAST_RATIO_TO_CLEAR
: metrics.dimmingStrength > DIMMING_STRENGTH_TO_DIM ||
metrics.lowContrastRatio >= LOW_CONTRAST_RATIO_TO_DIM;
export class ToolbarContrastMonitor {
private readonly isBgra: boolean;
private isDestroyed = false;
private isDimmed = false;
private isReadbackPending = false;
private lastSampleAt = Number.NEGATIVE_INFINITY;
@ -210,7 +199,28 @@ export class ToolbarContrastMonitor {
public destroy(): void {
this.isDestroyed = true;
this.toolbar.classList.remove('needs-contrast-background');
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
}
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
const safeBackgroundOpacity = Math.min(
BACKGROUND_OPACITY_MAX,
Math.max(0, backgroundOpacity)
);
const backgroundStrength =
BACKGROUND_OPACITY_MAX > 0
? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
: 0;
this.toolbar.style.setProperty(
TOOLBAR_BACKGROUND_OPACITY_PROPERTY,
`${(safeBackgroundOpacity * 100).toFixed(1)}%`
);
this.toolbar.style.setProperty(
TOOLBAR_BACKGROUND_STRENGTH_PROPERTY,
backgroundStrength.toFixed(3)
);
}
private getSamplePoints(): Array<CanvasSamplePoint> {
@ -268,8 +278,7 @@ export class ToolbarContrastMonitor {
if (!this.isDestroyed) {
const pixels = new Uint8Array(buffer.getMappedRange());
const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra);
this.isDimmed = shouldDimToolbarBackground(metrics, this.isDimmed);
this.toolbar.classList.toggle('needs-contrast-background', this.isDimmed);
this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
}
} catch {
// Readback is an enhancement; leave rendering alone if the GPU rejects it.