fleeting-garden/src/game-loop/agent-population.ts
2026-05-17 17:21:49 +01:00

239 lines
7.8 KiB
TypeScript

import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { getSafeDevicePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import { createIntroTitleAgents } from './intro-title-agents';
export class AgentPopulation {
private activeCount = 0;
private adaptiveCap: number;
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private readonly strokeAgentData = new Float32Array(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
);
public constructor(
private readonly pipeline: AgentGenerationPipeline,
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
private readonly getDevicePixelRatio = () => 1
) {
this.adaptiveCap = this.clampAdaptiveCap(
appConfig.simulation.budget.adaptiveCapInitial
);
}
public get activeAgentCount(): number {
return this.activeCount;
}
public initializeIntroAgents(canvasSize: vec2): void {
this.replaceIntroAgents(canvasSize, 0);
}
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
const introAgentCount = Math.min(
this.adaptiveCap,
appConfig.simulation.initialAgentCount
);
const data = createIntroTitleAgents({
count: introAgentCount,
width: canvasSize[0],
height: canvasSize[1],
progress,
seed: this.introSeed,
});
if (data.length === 0) {
return;
}
this.pipeline.writeAgents(0, data);
this.activeCount = data.length / AGENT_FLOAT_COUNT;
this.replacementCursor = 0;
}
public onVibeChanged(): void {
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget();
}
public growBudget(deltaTime: number, smoothedFps: number): void {
this.updateAdaptiveCap(deltaTime, smoothedFps);
}
public resizeAgents(scale: vec2): void {
this.pipeline.resizeAgents(this.activeCount, scale);
}
public requestCompactionAfterErase(): void {
this.shouldCompactAfterErase = true;
}
public async compactAfterErase(isSwipeActive: boolean): Promise<void> {
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
return;
}
this.shouldCompactAfterErase = false;
if (this.activeCount === 0) {
return;
}
this.isCompacting = true;
try {
const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount);
this.activeCount = compactedAgentCount;
this.replacementCursor =
compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount;
} finally {
this.isCompacting = false;
}
}
public spawnStrokeAgents(from: vec2, to: vec2): void {
const length = Math.max(
appConfig.simulation.stroke.minSegmentLengthPx,
vec2.dist(from, to)
);
const count = Math.max(
appConfig.simulation.stroke.minAgentCount,
Math.min(
appConfig.simulation.stroke.maxAgentCount,
this.strokeAgentData.length / AGENT_FLOAT_COUNT,
Math.ceil(
length * settings.spawnPerPixel * appConfig.simulation.stroke.densityMultiplier
)
)
);
const direction = vec2.sub(vec2.create(), to, from);
const baseAngle = Math.atan2(direction[1], direction[0]);
for (let i = 0; i < count; i++) {
const t = count === 1 ? 1 : i / (count - 1);
const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t;
const angle =
baseAngle +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
const spread =
settings.brushSize *
getSafeDevicePixelRatio(this.getDevicePixelRatio()) *
settings.strokeSpawnSpreadBrushSizeMultiplier;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 2] = angle;
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
this.strokeAgentData[base + 4] = -1;
this.strokeAgentData[base + 5] = -1;
this.strokeAgentData[base + 6] = angle;
this.strokeAgentData[base + 7] = 0;
}
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT));
}
private writeAgentBatch(data: Float32Array): void {
if (data.length === 0) {
return;
}
const count = data.length / AGENT_FLOAT_COUNT;
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, this.adaptiveCap - this.activeCount);
const appendCount = Math.min(count, available);
if (appendCount > 0) {
this.pipeline.writeAgents(
this.activeCount,
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
);
this.activeCount += appendCount;
}
let sourceAgentOffset = appendCount;
while (sourceAgentOffset < count && this.activeCount > 0) {
const targetAgentOffset = this.replacementCursor % this.activeCount;
const chunkAgentCount = Math.min(
count - sourceAgentOffset,
this.activeCount - targetAgentOffset
);
this.pipeline.writeAgents(
targetAgentOffset,
data.subarray(
sourceAgentOffset * AGENT_FLOAT_COUNT,
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
)
);
sourceAgentOffset += chunkAgentCount;
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
}
}
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
this.canExpandAdaptiveCap =
smoothedFps >=
appConfig.simulation.budget.adaptiveRefreshTargetFps *
appConfig.simulation.budget.fpsHeadroom;
if (this.canExpandAdaptiveCap) {
this.adaptiveCap = previousCap;
this.trimActiveCountToBudget();
return;
}
const decrease = Math.max(
1,
Math.ceil(
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
)
);
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
this.adaptiveCap = nextCap;
this.trimActiveCountToBudget(decrease);
}
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, this.adaptiveCap - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
const currentCap = this.clampAdaptiveCap(this.adaptiveCap);
const pendingAgentCount = requestedAgentCount - available;
this.adaptiveCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
}
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
if (this.activeCount <= this.adaptiveCap) {
return;
}
this.activeCount = Math.max(
this.adaptiveCap,
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
);
this.replacementCursor =
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
}
private clampAdaptiveCap(value: number): number {
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
const maxCap = Math.min(appConfig.simulation.budget.adaptiveCapMax, pipelineCap);
const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
}
}