This commit is contained in:
Andras Schmelczer 2026-05-13 21:07:10 +01:00
commit 39b0160064
136 changed files with 7129 additions and 1950 deletions

View file

@ -1,258 +1,244 @@
import { vec2 } from 'gl-matrix';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CommonState } from '../pipelines/common-state/common-state';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { GardenAudio } from '../audio/garden-audio';
import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { initializeContext } from '../utils/graphics/initialize-context';
import { ResizableTexture } from '../utils/graphics/resizable-texture';
import { sleep } from '../utils/sleep';
import { GamePresentation } from './game-presentation';
import { GameRules } from './game-rules';
import { AgentPopulation } from './agent-population';
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 { IntroPrompt } from './intro-prompt';
import { GardenPointerInput } from './pointer-input';
import { RenderInputCache } from './render-input-cache';
export default class GameLoop {
private readonly trailMapA: ResizableTexture;
private readonly trailMapB: ResizableTexture;
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private readonly commonState: CommonState;
private readonly copyPipeline: CopyPipeline;
private readonly agentGenerationPipeline: AgentGenerationPipeline;
private readonly agentPipeline: AgentPipeline;
private readonly renderPipeline: RenderPipeline;
private readonly brushPipeline: BrushPipeline;
private readonly diffusionPipeline: DiffusionPipeline;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
private readonly renderInputs = new RenderInputCache();
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private hasFinished = false;
private readonly finished = Promise.withResolvers<void>();
private activePointerId: number | null = null;
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
device: GPUDevice,
private readonly deltaTimeCalculator: DeltaTimeCalculator,
private readonly gameRules: GameRules
ui: GardenUi
) {
const context = initializeContext({ device, canvas });
this.trailMapA = new ResizableTexture(this.device, this.canvasSize);
this.trailMapB = new ResizableTexture(this.device, this.canvasSize);
this.resize();
this.copyPipeline = new CopyPipeline(this.device);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize: this.canvasSize,
time: 0,
deltaTime: 0,
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
canvas,
audio: this.audio,
brushPipeline: this.resources.brushPipeline,
eraserAgentPipeline: this.resources.eraserAgentPipeline,
eraserTexturePipeline: this.resources.eraserTexturePipeline,
eraserPreview: this.eraserPreview,
getCanvasSize: () => this.canvasSize,
getDevicePixelRatio: () => this.devicePixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => {
this.introPrompt.markStartedDrawing();
this.introPrompt.complete();
},
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
});
this.export4KRenderer = new Export4KRenderer({
device,
renderPipeline: this.resources.renderPipeline,
statusElement: ui.exportStatus,
seed: this.seed,
getSourceSize: () => ({
width: this.canvas.width,
height: this.canvas.height,
}),
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getVibeId: () => activeVibe.id,
});
this.keydownListener = (event: KeyboardEvent) => {
this.audio.start(activeVibe, { userGesture: event.isTrusted });
this.introPrompt.complete();
};
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
this.commonState,
settings.maxAgentCountUpperLimit
);
this.agentGenerationPipeline.spawnFirstGeneration();
this.agentPipeline = new AgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
);
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
window.addEventListener('resize', this.resize.bind(this));
canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
window.addEventListener('resize', this.resizeListener);
window.addEventListener('keydown', this.keydownListener, { once: true });
this.pointerInput.attach();
}
private onPointerDown(event: PointerEvent) {
if (this.activePointerId !== null) {
return;
}
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.brushPipeline.clearSwipes();
this.addSwipeAt(event);
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
}
private onPointerMove(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
public updateEraserPreview(event?: PointerEvent): void {
this.pointerInput.updateEraserPreview(event);
}
private onPointerUp(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
this.canvas.releasePointerCapture(event.pointerId);
this.activePointerId = null;
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.renderInputs.invalidate();
}
private addSwipeAt(event: PointerEvent) {
const position = vec2.fromValues(
event.clientX * this.devicePixelRatio,
this.canvas.height - event.clientY * this.devicePixelRatio
);
this.brushPipeline.addSwipe(position);
public setAudioMuted(isMuted: boolean): void {
this.audio.setMuted(isMuted);
}
private get isSwipeActive(): boolean {
return this.activePointerId !== null;
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> {
requestAnimationFrame(this.render.bind(this));
requestAnimationFrame(this.updateCounts.bind(this));
requestAnimationFrame(this.render);
return this.finished.promise;
}
private async updateCounts(): Promise<void> {
if (this.hasFinished) {
return;
}
const generationCounts = await this.agentGenerationPipeline.countAgents(
settings.agentCount
);
this.gameRules.updateGenerationCounts(generationCounts);
requestAnimationFrame(this.updateCounts.bind(this));
}
public get aliveAgentCounts(): {
currentGenerationCount: number;
nextGenerationCount: number;
} {
return this.gameRules.generationCounts;
}
public get maxAgentCount(): number {
return this.agentGenerationPipeline.maxAgentCount;
return this.agentPopulation.maxAgentCount;
}
private resize() {
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
public async export4K(): Promise<void> {
return this.export4KRenderer.export();
}
private async render(time: DOMHighResTimeStamp) {
public async destroy(): Promise<void> {
this.hasFinished = true;
await this.finished.promise;
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.introPrompt.destroy();
this.resources.destroy();
await this.audio.destroy();
}
private readonly render = async (time: DOMHighResTimeStamp) => {
if (this.hasFinished) {
this.finished.resolve();
return;
}
const accentColor = GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId - 1
);
document.documentElement.style.setProperty(
'--accent-color',
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
);
const frameCpuStartedAt = this.framePerformance.markCpuStart();
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
time *= settings.renderSpeed;
const timeInSeconds = time / 1000;
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
[
this.commonState,
this.agentPipeline,
this.brushPipeline,
this.diffusionPipeline,
this.renderPipeline,
].forEach((pipeline) =>
pipeline.setParameters({
time,
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
infectionProbability: this.gameRules.getInfectionProbability(),
deltaTime,
canvasSize: this.canvasSize,
brushColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId - 1
),
evenGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 0
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
oddGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 1
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
...settings,
center: spawnAction.position,
radius: spawnAction.radius,
})
this.framePerformance.update(deltaTime);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.introPrompt.update();
this.resize();
this.resizeSimulationToCanvas();
for (let i = 0; i < settings.renderSpeed; i++) {
const commandEncoder = this.device.createCommandEncoder();
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1 + (1 - introProgress) * appConfig.simulation.introCameraZoom;
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,
});
this.copyPipeline.execute(
commandEncoder,
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
this.agentPipeline.execute(
commandEncoder,
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.diffusionPipeline.execute(
commandEncoder,
this.trailMapB.getTextureView(),
this.trailMapA.getTextureView()
);
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
this.resources.setFrameParameters({
time: scaledTime,
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
introProgress,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
});
this.device.queue.submit([commandEncoder.finish()]);
}
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(settings.renderSpeed, isErasing);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
if (!this.isSwipeActive) {
this.brushPipeline.clearSwipes();
}
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.framePerformance.renderTelemetry({
frameCpuStartedAt,
encodeCpuMs,
activeAgentCount: this.agentPopulation.activeAgentCount,
targetAgentBudget: this.agentPopulation.targetAgentBudget,
canvas: this.canvas,
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
});
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
}
// avoid resizing during rendering
this.trailMapA.resize(this.canvasSize);
this.trailMapB.resize(this.canvasSize);
requestAnimationFrame(this.render);
};
requestAnimationFrame(this.render.bind(this));
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)
);
if (this.canvas.width === width && this.canvas.height === height) {
return;
}
this.canvas.width = width;
this.canvas.height = height;
}
public async destroy() {
this.hasFinished = true;
await this.finished.promise;
private resizeSimulationToCanvas(): void {
const scale = this.resources.resizeSimulationTo(this.canvasSize);
if (!scale) {
return;
}
this.copyPipeline?.destroy();
this.agentGenerationPipeline?.destroy();
this.agentPipeline?.destroy();
this.brushPipeline?.destroy();
this.diffusionPipeline?.destroy();
this.renderPipeline?.destroy();
this.commonState?.destroy();
this.trailMapA?.destroy();
this.trailMapB?.destroy();
this.agentPopulation.resizeAgents(scale);
this.pointerInput.scaleLastPointerPosition(scale);
}
private get canvasSize(): vec2 {
@ -260,6 +246,14 @@ export default class GameLoop {
}
private get devicePixelRatio(): number {
return window.devicePixelRatio || 1;
const ratio = window.devicePixelRatio;
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
}
private get mirrorSegmentCount(): number {
const count = Number.isFinite(settings.mirrorSegmentCount)
? settings.mirrorSegmentCount
: 1;
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
}
}