This commit is contained in:
Andras Schmelczer 2026-05-18 08:11:52 +01:00
commit 15e99380b5
84 changed files with 1659 additions and 4726 deletions

View file

@ -6,11 +6,13 @@ import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { AgentPopulation } from './agent-population';
import { DevStatsOverlay } from './dev-stats-overlay';
import { EraserPreview } from './eraser-preview';
import { Export4KRenderer } from './export-4k-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 { GardenPointerInput } from './pointer-input';
import { RenderInputCache } from './render-input-cache';
@ -26,11 +28,11 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly devStatsOverlay: DevStatsOverlay | null;
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
private hasFinished = false;
@ -38,11 +40,14 @@ export default class GameLoop {
public constructor(
private readonly canvas: HTMLCanvasElement,
device: GPUDevice,
private readonly device: GPUDevice,
private readonly deltaTimeCalculator: DeltaTimeCalculator,
ui: GardenUi
) {
this.resize();
this.devStatsOverlay = import.meta.env.DEV
? new DevStatsOverlay(canvas.parentElement ?? document.body)
: null;
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@ -50,7 +55,7 @@ export default class GameLoop {
this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline,
this.seedValue,
() => this.devicePixelRatio
() => this.canvasPixelRatio
);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
@ -60,7 +65,7 @@ export default class GameLoop {
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
getDevicePixelRatio: () => this.devicePixelRatio,
getCanvasPixelRatio: () => this.canvasPixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
@ -79,13 +84,8 @@ export default class GameLoop {
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getVibeId: () => activeVibe.id,
});
this.keydownListener = () => {
this.audio.start(activeVibe, { userGesture: true });
this.introPrompt.complete();
};
window.addEventListener('resize', this.resizeListener);
window.addEventListener('keydown', this.keydownListener, { once: true });
this.pointerInput.attach();
}
@ -132,15 +132,16 @@ export default class GameLoop {
await this.finished.promise;
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.devStatsOverlay?.destroy();
this.toolbarContrastMonitor.destroy();
this.introPrompt.destroy();
await this.agentPopulation.waitForCompaction();
this.resources.destroy();
await this.audio.destroy();
}
private readonly render = async (time: DOMHighResTimeStamp) => {
private readonly render = (time: DOMHighResTimeStamp) => {
if (this.hasFinished) {
this.finished.resolve();
return;
@ -148,7 +149,10 @@ export default class GameLoop {
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
this.agentPopulation.growBudget(
this.framePerformance.frameDeltaSeconds,
this.framePerformance.smoothedFps
);
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
this.resize();
this.resizeSimulationToCanvas(time);
@ -156,8 +160,8 @@ export default class GameLoop {
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const devicePixelRatio = this.devicePixelRatio;
const eraserPixelSize = settings.eraserSize * devicePixelRatio;
const canvasPixelRatio = this.canvasPixelRatio;
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
@ -171,7 +175,7 @@ export default class GameLoop {
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
devicePixelRatio,
canvasPixelRatio,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
@ -186,20 +190,28 @@ export default class GameLoop {
);
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.devStatsOverlay?.update({
time,
fps: this.framePerformance.measuredFps,
agentCount: this.agentPopulation.activeAgentCount,
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
renderWidth: this.canvas.width,
renderHeight: this.canvas.height,
});
requestAnimationFrame(this.render);
};
private resize(): void {
const width = Math.max(
1,
Math.floor(this.canvas.clientWidth * this.devicePixelRatio)
);
const height = Math.max(
1,
Math.floor(this.canvas.clientHeight * this.devicePixelRatio)
);
const rect = this.canvas.getBoundingClientRect();
const { width, height } = getInternalRenderSize({
clientHeight: rect.height || this.canvas.clientHeight,
clientWidth: rect.width || this.canvas.clientWidth,
maxPixelScale: appConfig.simulation.maxDevicePixelRatio,
maxTextureDimension: this.device.limits.maxTextureDimension2D,
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
});
if (this.canvas.width === width && this.canvas.height === height) {
return;
@ -249,8 +261,11 @@ export default class GameLoop {
return vec2.fromValues(this.canvas.width, this.canvas.height);
}
private get devicePixelRatio(): number {
const ratio = window.devicePixelRatio;
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;
}