224 lines
7.6 KiB
TypeScript
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)));
|
|
}
|
|
}
|