fleeting-garden/src/game-loop/game-loop.ts
Andras Schmelczer 0fddad6b45
All checks were successful
Check & deploy / build (pull_request) Successful in 7m18s
Check & deploy / build (push) Successful in 7m6s
Clean up configs
2026-05-25 09:27:39 +01:00

367 lines
12 KiB
TypeScript

import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { createGardenAudioConfig } from '../audio/garden-audio-config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population';
import { EraserPreview } from './eraser-preview';
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
import { FramePerformance } from './frame-performance';
import { GameLoopResources } from './game-loop-resources';
import { GardenUi } from './game-loop-types';
import { getInternalRenderSize } from './internal-render-size';
import { IntroPrompt } from './intro-prompt';
import { PerfStatsOverlay, perfStatsOverlayState } from './perf-stats-overlay';
import { GardenPointerInput } from './pointer-input';
import { MAX_MIRROR_SEGMENT_COUNT, MIN_MIRROR_SEGMENT_COUNT } from './stroke-mirroring';
import { PipelineStrokeOutput } from './stroke-output';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
const INTRO_RESIZE_SETTLE_MS = 120;
const INTRO_RESIZE_MINIMUM_REMAINING_SECONDS = 1.4;
const GARDEN_AUDIO_CONFIG = createGardenAudioConfig();
export default class GameLoop {
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(GARDEN_AUDIO_CONFIG);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
private readonly agentPopulation: AgentPopulation;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
private perfStatsOverlay: PerfStatsOverlay | null = null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16);
private readonly _canvasSize: vec2 = vec2.create();
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private previousAccentColor = '';
private previousGrainStrength = Number.NaN;
private hasFinished = false;
private animationFrameId: number | null = null;
private destroyPromise: Promise<void> | null = null;
private readonly finished = Promise.withResolvers<void>();
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
private readonly canvasFormat: GPUTextureFormat,
private readonly deltaTimeCalculator: DeltaTimeCalculator,
private readonly ui: GardenUi
) {
this.resize();
this.resources = new GameLoopResources(
canvas,
device,
this.canvasFormat,
this.canvasSize,
this.framePerformance.adaptiveCapInitial,
settings.maxAgentCount
);
this.introPrompt = new IntroPrompt(ui.prompt);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
canvas,
ui.toolbar,
device,
this.canvasFormat
);
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
this.seedValue,
() => this.canvasPixelRatio,
this.framePerformance
);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
canvas,
audio: this.audio,
strokeOutput: new PipelineStrokeOutput(
this.resources.brushPipeline,
this.resources.eraserAgentPipeline,
this.resources.eraserTexturePipeline
),
getCanvasPixelRatio: () => this.canvasPixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => {
this.introPrompt.markStartedDrawing();
this.agentPopulation.beginStroke();
},
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
this.eraserPreview = new EraserPreview(
canvas,
ui.eraserPreview,
() => this.pointerInput.isSwipeActive
);
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
device,
renderPipeline: this.resources.renderPipeline,
canvasFormat: this.canvasFormat,
statusElement: ui.exportStatus,
seed: this.seed,
getSourceSize: () => {
const size = this.resources.textures.trailMapA.getSize();
return {
width: size[0],
height: size[1],
};
},
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getSourceActive: () => this.resources.isSourceMapActive,
getVibeId: () => activeVibe.id,
});
this.syncPerfStatsOverlay();
}
public attachPointerInput(): void {
this.pointerInput.attach();
this.eraserPreview.attach();
}
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
this.eraserPreview.setEraseMode(isErasing);
}
public updateEraserPreview(event?: PointerEvent): void {
this.eraserPreview.update(event);
}
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.syncPerfStatsOverlay();
}
public setAudioMuted(isMuted: boolean): void {
this.audio.setMuted(isMuted);
}
public setAudioVolume(volume: number): void {
this.audio.setMasterVolume(volume);
}
public startAudio(userGesture = false): void {
this.audio.start(activeVibe, { userGesture });
}
public playVibeChangeAudio(userGesture = false): void {
this.audio.changeVibe(activeVibe, { userGesture });
}
public async start(): Promise<void> {
if (this.animationFrameId === null && !this.hasFinished) {
this.animationFrameId = requestAnimationFrame(this.render);
}
return this.finished.promise;
}
public async exportSnapshot(): Promise<void> {
return this.exportSnapshotRenderer.export();
}
public async destroy(): Promise<void> {
this.destroyPromise ??= this.dispose();
return this.destroyPromise;
}
private async dispose(): Promise<void> {
this.hasFinished = true;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.pointerInput.detach();
this.eraserPreview.detach();
this.perfStatsOverlay?.destroy();
this.perfStatsOverlay = null;
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
await this.agentPopulation.waitForCompaction();
this.resources.destroy();
await this.audio.destroy();
this.finished.resolve();
}
private readonly render = (time: DOMHighResTimeStamp) => {
this.animationFrameId = null;
if (this.hasFinished) {
this.finished.resolve();
return;
}
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
this.agentPopulation.updateAdaptiveCap();
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
this.resize();
this.resizeSimulationToCanvas(time);
this.regenerateIntroAfterSettledResize(time);
const channelColors = activeVibe.colors;
const backgroundColor = activeVibe.backgroundColor;
const runtimeSettings = { ...settings };
const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio;
const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor =
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
this.updateAccentColor(accentColor);
this.updateGrainOverlay(runtimeSettings.backgroundGrainStrength);
this.audio.update({
vibe: activeVibe,
isErasing,
});
this.resources.setFrameParameters({
time,
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
canvasPixelRatio,
introProgress,
selectedColorIndex: runtimeSettings.selectedColorIndex,
channelColors,
backgroundColor,
eraserPixelSize,
runtimeSettings,
});
this.resources.executeFrame(
isErasing,
this.toolbarContrastMonitor.takeReadbackRequest(time)
);
this.pointerInput.clearSwipesIfIdle();
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.perfStatsOverlay?.update({
time,
fps: this.framePerformance.measuredFps,
agentCount: this.agentPopulation.activeAgentCount,
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
gpuPassTimeMs: this.resources.gpuPassTimeMs,
renderWidth: this.canvas.width,
renderHeight: this.canvas.height,
});
this.animationFrameId = requestAnimationFrame(this.render);
};
private syncPerfStatsOverlay(): void {
if (perfStatsOverlayState.isVisible) {
this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
return;
}
this.perfStatsOverlay?.destroy();
this.perfStatsOverlay = null;
}
private updateAccentColor(color: RgbColor): void {
const accentColor = rgbColorToCss(color);
if (this.previousAccentColor === accentColor) {
return;
}
this.previousAccentColor = accentColor;
document.documentElement.style.setProperty('--accent-color', accentColor);
}
private updateGrainOverlay(strength: number): void {
const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0;
if (Object.is(this.previousGrainStrength, safeStrength)) {
return;
}
this.previousGrainStrength = safeStrength;
this.grainOverlay.hidden = safeStrength <= 0;
this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength));
}
private resize(): void {
const rect = this.canvas.getBoundingClientRect();
const { width, height } = getInternalRenderSize({
clientHeight: rect.height || this.canvas.clientHeight,
clientWidth: rect.width || this.canvas.clientWidth,
maxTextureDimension: this.device.limits.maxTextureDimension2D,
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
});
if (this.canvas.width === width && this.canvas.height === height) {
return;
}
this.canvas.width = width;
this.canvas.height = height;
}
private resizeSimulationToCanvas(time: DOMHighResTimeStamp): void {
const scale = this.resources.resizeSimulationTo(this.canvasSize);
if (!scale) {
return;
}
this.agentPopulation.resizeAgents(scale);
this.pointerInput.scaleLastPointerPosition(scale);
if (this.introPrompt.shouldRegenerateTitleOnResize) {
this.pendingIntroResizeAt = time;
}
}
private regenerateIntroAfterSettledResize(time: DOMHighResTimeStamp): void {
if (this.pendingIntroResizeAt === null) {
return;
}
if (!this.introPrompt.shouldRegenerateTitleOnResize) {
this.pendingIntroResizeAt = null;
return;
}
if (time - this.pendingIntroResizeAt < INTRO_RESIZE_SETTLE_MS) {
return;
}
this.introPrompt.rewindToLeaveRemainingTime(INTRO_RESIZE_MINIMUM_REMAINING_SECONDS);
this.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null;
}
private get canvasSize(): vec2 {
vec2.set(this._canvasSize, this.canvas.width, this.canvas.height);
return this._canvasSize;
}
private get canvasPixelRatio(): number {
const rect = this.canvas.getBoundingClientRect();
const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1;
const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale;
const ratio = (xScale + yScale) / 2;
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
}
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
: MIN_MIRROR_SEGMENT_COUNT;
return Math.min(
MAX_MIRROR_SEGMENT_COUNT,
Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(count))
);
}
private get grainOverlay(): HTMLElement {
return this.ui.grainOverlay;
}
}