239 lines
7.8 KiB
TypeScript
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)));
|
|
}
|
|
}
|