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 { 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))); } }