fleeting-garden/src/game-loop/agent-population.ts
2026-05-16 16:15:54 +01:00

224 lines
7.6 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 { 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_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
export class AgentPopulation {
private activeCount = 0;
private replacementCursor = 0;
private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false;
private isCompacting = false;
private readonly strokeAgentData = new Float32Array(
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
);
public constructor(private readonly pipeline: AgentGenerationPipeline) {}
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.writeAgentBatch(
createIntroTitleAgents({
count: introAgentCount,
width: canvasSize[0],
height: canvasSize[1],
})
);
}
public onVibeChanged(): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.trimActiveCountToBudget();
}
public growBudget(
deltaTime: number,
smoothedFps: number,
refreshTargetFps: number
): void {
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
}
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(1, vec2.dist(from, to));
const count = Math.max(
MIN_STROKE_AGENT_COUNT,
Math.min(
MAX_STROKE_AGENT_COUNT,
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
)
);
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 =
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
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;
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax);
this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, settings.agentBudgetMax - 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,
refreshTargetFps: number
): void {
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
this.canExpandAdaptiveCap =
refreshTargetFps <= 0 ||
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom;
if (this.canExpandAdaptiveCap) {
settings.agentBudgetMax = previousCap;
this.trimActiveCountToBudget();
return;
}
const decrease = Math.max(
1,
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
);
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
settings.agentBudgetMax = nextCap;
this.trimActiveCountToBudget(decrease);
}
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, settings.agentBudgetMax - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return;
}
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax);
const pendingAgentCount = requestedAgentCount - available;
settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount);
}
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
if (this.activeCount <= settings.agentBudgetMax) {
return;
}
this.activeCount = Math.max(
settings.agentBudgetMax,
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(ADAPTIVE_CAP_MAX, pipelineCap);
const minCap = Math.min(ADAPTIVE_CAP_MIN, maxCap);
const finiteValue = Number.isFinite(value) ? value : minCap;
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
}
}