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