Rework garden simulation pipelines
This commit is contained in:
parent
c2efb33683
commit
018f8c9d4d
67 changed files with 6544 additions and 1727 deletions
178
src/game-loop/agent-population.test.ts
Normal file
178
src/game-loop/agent-population.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { AgentPopulation } from './agent-population';
|
||||||
|
import { type FramePerformance } from './frame-performance';
|
||||||
|
|
||||||
|
const originalSettings = {
|
||||||
|
brushSize: settings.brushSize,
|
||||||
|
maxAgentCount: settings.maxAgentCount,
|
||||||
|
selectedColorIndex: settings.selectedColorIndex,
|
||||||
|
spawnPerPixel: settings.spawnPerPixel,
|
||||||
|
strokeAngleJitterRadians: settings.strokeAngleJitterRadians,
|
||||||
|
};
|
||||||
|
|
||||||
|
class RecordingAgentGenerationPipeline {
|
||||||
|
public readonly writtenAgentCounts: Array<number> = [];
|
||||||
|
public readonly writtenAgentOffsets: Array<number> = [];
|
||||||
|
public readonly writtenBatches: Array<Float32Array> = [];
|
||||||
|
public readonly maxSupportedAgentCount = 1_000_000;
|
||||||
|
public maxAgentCount = 1_000_000;
|
||||||
|
private compactResolver: ((compactedAgentCount: number) => void) | null = null;
|
||||||
|
|
||||||
|
public ensureMaxAgentCount(requestedMaxAgentCount: number): number {
|
||||||
|
this.maxAgentCount = Math.max(this.maxAgentCount, requestedMaxAgentCount);
|
||||||
|
return this.maxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public writeAgents(agentOffset: number, data: Float32Array): void {
|
||||||
|
this.writtenAgentOffsets.push(agentOffset);
|
||||||
|
this.writtenAgentCounts.push(data.length / AGENT_FLOAT_COUNT);
|
||||||
|
this.writtenBatches.push(data.slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
public compactAgents(): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.compactResolver = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public finishCompaction(compactedAgentCount: number): void {
|
||||||
|
this.compactResolver?.(compactedAgentCount);
|
||||||
|
this.compactResolver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const framePerformance = {
|
||||||
|
adaptiveCapDecreaseAgents: 0,
|
||||||
|
adaptiveCapInitial: 1_000_000,
|
||||||
|
adaptiveCapMin: 0,
|
||||||
|
hasAdaptiveCapHeadroom: true,
|
||||||
|
} as FramePerformance;
|
||||||
|
|
||||||
|
const createPopulation = (): {
|
||||||
|
pipeline: RecordingAgentGenerationPipeline;
|
||||||
|
population: AgentPopulation;
|
||||||
|
} => {
|
||||||
|
const pipeline = new RecordingAgentGenerationPipeline();
|
||||||
|
const population = new AgentPopulation(
|
||||||
|
pipeline as unknown as AgentGenerationPipeline,
|
||||||
|
0,
|
||||||
|
() => 1,
|
||||||
|
framePerformance
|
||||||
|
);
|
||||||
|
population.beginStroke();
|
||||||
|
return { pipeline, population };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSpawnRate = (agentsPerPixel: number): void => {
|
||||||
|
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AgentPopulation stroke spawning', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
settings.brushSize = 0;
|
||||||
|
settings.maxAgentCount = 1_000_000;
|
||||||
|
settings.selectedColorIndex = 0;
|
||||||
|
settings.strokeAngleJitterRadians = 0;
|
||||||
|
setSpawnRate(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(settings, originalSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns the same count for the same stroke length regardless of segmentation', () => {
|
||||||
|
const segmented = createPopulation();
|
||||||
|
for (let x = 0; x < 10; x++) {
|
||||||
|
segmented.population.spawnStrokeAgents(
|
||||||
|
vec2.fromValues(x, 0),
|
||||||
|
vec2.fromValues(x + 1, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleSegment = createPopulation();
|
||||||
|
singleSegment.population.spawnStrokeAgents(
|
||||||
|
vec2.fromValues(0, 0),
|
||||||
|
vec2.fromValues(10, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(segmented.population.activeAgentCount).toBe(10);
|
||||||
|
expect(singleSegment.population.activeAgentCount).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries fractional spawn budget within a stroke', () => {
|
||||||
|
setSpawnRate(0.5);
|
||||||
|
const { population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(0);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(1, 0), vec2.fromValues(2, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(1);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(2, 0), vec2.fromValues(3, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(1);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(3, 0), vec2.fromValues(4, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
|
||||||
|
const { pipeline, population } = createPopulation();
|
||||||
|
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
|
||||||
|
const expectedAgentCount = batchCapacity + 10;
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(
|
||||||
|
vec2.fromValues(0, 0),
|
||||||
|
vec2.fromValues(expectedAgentCount, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(expectedAgentCount);
|
||||||
|
expect(pipeline.writtenAgentCounts).toEqual([batchCapacity, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns agents in the movement direction', () => {
|
||||||
|
const { pipeline, population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(3);
|
||||||
|
expect(pipeline.writtenBatches[0][2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears active agents when an intro replacement has no generated agents', () => {
|
||||||
|
const { population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(3);
|
||||||
|
|
||||||
|
settings.maxAgentCount = 0;
|
||||||
|
population.replaceIntroAgents(vec2.fromValues(100, 100), 0);
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues stroke writes while async compaction is in flight', async () => {
|
||||||
|
const { pipeline, population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(10, 0));
|
||||||
|
population.requestCompactionAfterErase();
|
||||||
|
population.compactAfterErase(false);
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(10, 0), vec2.fromValues(15, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(10);
|
||||||
|
expect(pipeline.writtenAgentCounts).toEqual([10]);
|
||||||
|
|
||||||
|
pipeline.finishCompaction(6);
|
||||||
|
await population.waitForCompaction();
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(11);
|
||||||
|
expect(pipeline.writtenAgentOffsets).toEqual([0, 6]);
|
||||||
|
expect(pipeline.writtenAgentCounts).toEqual([10, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
339
src/game-loop/agent-population.ts
Normal file
339
src/game-loop/agent-population.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||||
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||||
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import type { FramePerformance } from './frame-performance';
|
||||||
|
import { createIntroTitleAgents } from './intro-title-agents';
|
||||||
|
|
||||||
|
export class AgentPopulation {
|
||||||
|
private activeCount = 0;
|
||||||
|
// Current performance-aware limit; new agents above it replace old agents.
|
||||||
|
private adaptiveCap: number;
|
||||||
|
// Next active agent slot to overwrite when new agents exceed the current cap.
|
||||||
|
private replacementCursor = 0;
|
||||||
|
private canExpandAdaptiveCap = true;
|
||||||
|
private shouldCompactAfterErase = false;
|
||||||
|
private isCompacting = false;
|
||||||
|
private pendingCompaction: Promise<void> | null = null;
|
||||||
|
private readonly queuedAgentBatches: Array<Float32Array> = [];
|
||||||
|
private pendingStrokeAgentCount = 0;
|
||||||
|
private readonly strokeAgentData = new Float32Array(
|
||||||
|
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||||
|
);
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly pipeline: AgentGenerationPipeline,
|
||||||
|
private readonly introSeed: number,
|
||||||
|
private readonly getCanvasPixelRatio: () => number,
|
||||||
|
private readonly framePerformance: FramePerformance
|
||||||
|
) {
|
||||||
|
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(
|
||||||
|
this.framePerformance.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.clampAndEnsureAdaptiveCap(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) {
|
||||||
|
this.activeCount = 0;
|
||||||
|
this.replacementCursor = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pipeline.writeAgents(0, data);
|
||||||
|
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
||||||
|
this.replacementCursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onVibeChanged(): void {
|
||||||
|
this.pendingStrokeAgentCount = 0;
|
||||||
|
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||||
|
this.trimActiveCountToBudget();
|
||||||
|
}
|
||||||
|
|
||||||
|
public beginStroke(): void {
|
||||||
|
this.pendingStrokeAgentCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public resizeAgents(scale: vec2): void {
|
||||||
|
this.pipeline.resizeAgents(this.activeCount, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
public requestCompactionAfterErase(): void {
|
||||||
|
this.shouldCompactAfterErase = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public compactAfterErase(isSwipeActive: boolean): void {
|
||||||
|
if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shouldCompactAfterErase = false;
|
||||||
|
if (this.activeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCompacting = true;
|
||||||
|
this.pendingCompaction = this.pipeline
|
||||||
|
.compactAgents(this.activeCount)
|
||||||
|
.then((compactedAgentCount) => {
|
||||||
|
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
|
||||||
|
? Math.max(0, Math.floor(compactedAgentCount))
|
||||||
|
: 0;
|
||||||
|
this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount);
|
||||||
|
this.clampReplacementCursor();
|
||||||
|
this.trimActiveCountToBudget();
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn('Could not compact agents after erase.', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isCompacting = false;
|
||||||
|
this.pendingCompaction = null;
|
||||||
|
this.flushQueuedAgentBatches();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForCompaction(): Promise<void> {
|
||||||
|
await this.pendingCompaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateAdaptiveCap(): void {
|
||||||
|
const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||||
|
this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom;
|
||||||
|
|
||||||
|
if (this.canExpandAdaptiveCap) {
|
||||||
|
this.adaptiveCap = previousCap;
|
||||||
|
this.trimActiveCountToBudget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrease = this.framePerformance.adaptiveCapDecreaseAgents;
|
||||||
|
const responsiveCap = Math.min(
|
||||||
|
previousCap,
|
||||||
|
this.clampAndEnsureAdaptiveCap(this.activeCount)
|
||||||
|
);
|
||||||
|
const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease);
|
||||||
|
this.adaptiveCap = nextCap;
|
||||||
|
this.trimActiveCountToBudget(decrease);
|
||||||
|
}
|
||||||
|
|
||||||
|
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||||
|
const deltaX = to[0] - from[0];
|
||||||
|
const deltaY = to[1] - from[1];
|
||||||
|
const length = Math.hypot(deltaX, deltaY);
|
||||||
|
const spawnRate = getStrokeSpawnRate();
|
||||||
|
if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount;
|
||||||
|
if (!Number.isFinite(expectedAgentCount)) {
|
||||||
|
this.pendingStrokeAgentCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.floor(expectedAgentCount);
|
||||||
|
this.pendingStrokeAgentCount = expectedAgentCount - count;
|
||||||
|
|
||||||
|
if (count <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAngle = Math.atan2(deltaY, deltaX);
|
||||||
|
const spread =
|
||||||
|
getRenderQualityBrushSize(
|
||||||
|
settings.brushSize,
|
||||||
|
settings.internalRenderAreaMegapixels
|
||||||
|
) * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||||
|
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
|
||||||
|
if (batchCapacity <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let written = 0; written < count; written += batchCapacity) {
|
||||||
|
const batchCount = Math.min(batchCapacity, count - written);
|
||||||
|
this.populateStrokeAgentBatch({
|
||||||
|
baseAngle,
|
||||||
|
batchCount,
|
||||||
|
from,
|
||||||
|
spread,
|
||||||
|
to,
|
||||||
|
totalCount: count,
|
||||||
|
written,
|
||||||
|
});
|
||||||
|
this.writeAgentBatch(
|
||||||
|
this.strokeAgentData.subarray(0, batchCount * AGENT_FLOAT_COUNT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private populateStrokeAgentBatch({
|
||||||
|
baseAngle,
|
||||||
|
batchCount,
|
||||||
|
from,
|
||||||
|
spread,
|
||||||
|
to,
|
||||||
|
totalCount,
|
||||||
|
written,
|
||||||
|
}: {
|
||||||
|
baseAngle: number;
|
||||||
|
batchCount: number;
|
||||||
|
from: vec2;
|
||||||
|
spread: number;
|
||||||
|
to: vec2;
|
||||||
|
totalCount: number;
|
||||||
|
written: number;
|
||||||
|
}): void {
|
||||||
|
for (let i = 0; i < batchCount; i++) {
|
||||||
|
const agentIndex = written + i;
|
||||||
|
const t = totalCount === 1 ? 0.5 : agentIndex / (totalCount - 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) * settings.strokeAngleJitterRadians;
|
||||||
|
const positionX = x + (Math.random() - 0.5) * spread;
|
||||||
|
const positionY = y + (Math.random() - 0.5) * spread;
|
||||||
|
|
||||||
|
writeAgentValues(this.strokeAgentData, i, {
|
||||||
|
positionX,
|
||||||
|
positionY,
|
||||||
|
angle,
|
||||||
|
colorIndex: settings.selectedColorIndex,
|
||||||
|
targetPositionX: -1,
|
||||||
|
targetPositionY: -1,
|
||||||
|
targetAngle: angle,
|
||||||
|
introDelay: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeAgentBatch(data: Float32Array): void {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isCompacting) {
|
||||||
|
this.queuedAgentBatches.push(data.slice());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = data.length / AGENT_FLOAT_COUNT;
|
||||||
|
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(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 flushQueuedAgentBatches(): void {
|
||||||
|
const batches = this.queuedAgentBatches.splice(0);
|
||||||
|
batches.forEach((batch) => this.writeAgentBatch(batch));
|
||||||
|
}
|
||||||
|
|
||||||
|
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||||
|
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||||
|
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||||
|
const pendingAgentCount = requestedAgentCount - available;
|
||||||
|
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(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.clampReplacementCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampReplacementCursor(): void {
|
||||||
|
this.replacementCursor =
|
||||||
|
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampAndEnsureAdaptiveCap(value: number): number {
|
||||||
|
const runtimeMaxCap =
|
||||||
|
settings.maxAgentCount === Number.POSITIVE_INFINITY
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: Number.isFinite(settings.maxAgentCount)
|
||||||
|
? Math.max(0, Math.floor(settings.maxAgentCount))
|
||||||
|
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||||
|
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
|
||||||
|
const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap);
|
||||||
|
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||||
|
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
||||||
|
return Math.min(
|
||||||
|
nextCap,
|
||||||
|
this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStrokeSpawnRate = (): number => {
|
||||||
|
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
|
||||||
|
? settings.spawnPerPixel
|
||||||
|
: 0;
|
||||||
|
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
|
||||||
|
? appConfig.simulation.stroke.densityMultiplier
|
||||||
|
: 0;
|
||||||
|
return Math.max(0, spawnPerPixel * densityMultiplier);
|
||||||
|
};
|
||||||
155
src/game-loop/brush-stroke-smoother.ts
Normal file
155
src/game-loop/brush-stroke-smoother.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||||
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { type StrokeSegment } from './game-loop-types';
|
||||||
|
|
||||||
|
interface BrushStrokeSmootherOptions {
|
||||||
|
getCanvasPixelRatio: () => number;
|
||||||
|
getMirrorSegmentCount: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrushStrokeSmoother {
|
||||||
|
private readonly strokePoints: Array<vec2> = [];
|
||||||
|
private lastBrushPosition: vec2 | null = null;
|
||||||
|
|
||||||
|
public constructor(private readonly options: BrushStrokeSmootherOptions) {}
|
||||||
|
|
||||||
|
public addSample(position: vec2): Array<StrokeSegment> {
|
||||||
|
const previousSample = this.strokePoints[this.strokePoints.length - 1];
|
||||||
|
if (
|
||||||
|
previousSample !== undefined &&
|
||||||
|
vec2.squaredDistance(previousSample, position) <=
|
||||||
|
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.strokePoints.push(vec2.clone(position));
|
||||||
|
|
||||||
|
if (this.strokePoints.length > 3) {
|
||||||
|
this.strokePoints.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.strokePoints.length === 1) {
|
||||||
|
this.lastBrushPosition = vec2.clone(position);
|
||||||
|
return [{ from: position, to: position }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.strokePoints.length === 2) {
|
||||||
|
const [start, end] = this.strokePoints;
|
||||||
|
const midpoint = getMidpoint(start, end);
|
||||||
|
this.lastBrushPosition = midpoint;
|
||||||
|
return [{ from: start, to: midpoint }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [start, control, end] = this.strokePoints;
|
||||||
|
const curveStart = getMidpoint(start, control);
|
||||||
|
const curveEnd = getMidpoint(control, end);
|
||||||
|
this.lastBrushPosition = curveEnd;
|
||||||
|
return this.getQuadraticSegments(curveStart, control, curveEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public finish(): Array<StrokeSegment> {
|
||||||
|
if (this.strokePoints.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSample = this.strokePoints[this.strokePoints.length - 1];
|
||||||
|
if (
|
||||||
|
this.lastBrushPosition !== null &&
|
||||||
|
vec2.squaredDistance(this.lastBrushPosition, finalSample) >
|
||||||
|
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||||
|
) {
|
||||||
|
return [{ from: this.lastBrushPosition, to: finalSample }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
this.strokePoints.length = 0;
|
||||||
|
this.lastBrushPosition = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public scale(scale: vec2): void {
|
||||||
|
this.strokePoints.forEach((point) => {
|
||||||
|
vec2.mul(point, point, scale);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.lastBrushPosition !== null) {
|
||||||
|
vec2.mul(this.lastBrushPosition, this.lastBrushPosition, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getQuadraticSegments(
|
||||||
|
start: vec2,
|
||||||
|
control: vec2,
|
||||||
|
end: vec2
|
||||||
|
): Array<StrokeSegment> {
|
||||||
|
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||||
|
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
|
||||||
|
const brushSize = getRenderQualityBrushSize(
|
||||||
|
settings.brushSize,
|
||||||
|
settings.internalRenderAreaMegapixels
|
||||||
|
);
|
||||||
|
const brushRadius = Math.max(
|
||||||
|
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||||
|
(brushSize * canvasPixelRatio) / 2
|
||||||
|
);
|
||||||
|
const segmentSpacing = Math.max(
|
||||||
|
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||||
|
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||||
|
);
|
||||||
|
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||||
|
const curveResolution = getBrushCurveResolution();
|
||||||
|
const maxCurveSegments = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
curveResolution /
|
||||||
|
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const segmentCount = Math.min(
|
||||||
|
maxCurveSegments,
|
||||||
|
Math.max(1, Math.ceil(curveLength / segmentSpacing))
|
||||||
|
);
|
||||||
|
|
||||||
|
let previousPoint = start;
|
||||||
|
const segments: Array<StrokeSegment> = [];
|
||||||
|
for (let i = 1; i <= segmentCount; i++) {
|
||||||
|
const point = getQuadraticPoint(start, control, end, i / segmentCount);
|
||||||
|
segments.push({ from: previousPoint, to: point });
|
||||||
|
previousPoint = point;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMidpoint = (from: vec2, to: vec2): vec2 =>
|
||||||
|
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
|
||||||
|
|
||||||
|
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
|
||||||
|
const inverseT = 1 - t;
|
||||||
|
return vec2.fromValues(
|
||||||
|
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
|
||||||
|
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBrushCurveResolution = (): number => {
|
||||||
|
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||||
|
? settings.brushCurveResolution
|
||||||
|
: appConfig.defaultSettings.brushCurveResolution;
|
||||||
|
return Math.max(1, Math.floor(resolution));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
|
||||||
|
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||||
|
? settings.brushSmoothingMinSampleDistance
|
||||||
|
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||||
|
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
|
||||||
|
};
|
||||||
122
src/game-loop/eraser-preview.ts
Normal file
122
src/game-loop/eraser-preview.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { settings } from '../settings';
|
||||||
|
|
||||||
|
export class EraserPreview {
|
||||||
|
private previewClientPosition: { x: number; y: number } | null = null;
|
||||||
|
private isErasing = false;
|
||||||
|
private isPointerHoveringCanvas = false;
|
||||||
|
private isSwipeActive = false;
|
||||||
|
private previousSize: number | null = null;
|
||||||
|
private previousLeft = '';
|
||||||
|
private previousTop = '';
|
||||||
|
private isVisible = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly canvas: HTMLCanvasElement,
|
||||||
|
private readonly element: HTMLElement,
|
||||||
|
private readonly getIsSwipeActive: () => boolean
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public attach(): void {
|
||||||
|
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||||
|
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||||
|
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public detach(): void {
|
||||||
|
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||||
|
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||||
|
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEraseMode(isErasing: boolean): void {
|
||||||
|
this.isErasing = isErasing;
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(event?: PointerEvent): void {
|
||||||
|
this.isSwipeActive = this.getIsSwipeActive();
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
this.previewClientPosition = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.previousSize !== settings.eraserSize) {
|
||||||
|
this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`);
|
||||||
|
this.previousSize = settings.eraserSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.isErasing ||
|
||||||
|
this.previewClientPosition === null ||
|
||||||
|
(!this.isPointerHoveringCanvas && !this.isSwipeActive)
|
||||||
|
) {
|
||||||
|
this.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const left = `${this.previewClientPosition.x - rect.left}px`;
|
||||||
|
const top = `${this.previewClientPosition.y - rect.top}px`;
|
||||||
|
if (this.previousLeft !== left) {
|
||||||
|
this.element.style.left = left;
|
||||||
|
this.previousLeft = left;
|
||||||
|
}
|
||||||
|
if (this.previousTop !== top) {
|
||||||
|
this.element.style.top = top;
|
||||||
|
this.previousTop = top;
|
||||||
|
}
|
||||||
|
this.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setVisible(isVisible: boolean): void {
|
||||||
|
if (this.isVisible === isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isVisible = isVisible;
|
||||||
|
this.element.classList.toggle('visible', isVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPointerInsideCanvas(event: PointerEvent): boolean {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
event.clientX >= rect.left &&
|
||||||
|
event.clientX <= rect.right &&
|
||||||
|
event.clientY >= rect.top &&
|
||||||
|
event.clientY <= rect.bottom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onPointerDown = (event: PointerEvent) => {
|
||||||
|
this.isPointerHoveringCanvas = true;
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerMove = (event: PointerEvent) => {
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerUp = (event: PointerEvent) => {
|
||||||
|
this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event);
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||||
|
this.isPointerHoveringCanvas = true;
|
||||||
|
this.update(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerLeave = () => {
|
||||||
|
this.isPointerHoveringCanvas = false;
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
}
|
||||||
204
src/game-loop/export-snapshot-renderer.ts
Normal file
204
src/game-loop/export-snapshot-renderer.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
|
import type { VibeId } from '../vibes';
|
||||||
|
|
||||||
|
interface ExportSnapshotRendererOptions {
|
||||||
|
device: GPUDevice;
|
||||||
|
renderPipeline: RenderPipeline;
|
||||||
|
canvasFormat: GPUTextureFormat;
|
||||||
|
statusElement: HTMLElement;
|
||||||
|
seed: string;
|
||||||
|
getSourceSize: () => { width: number; height: number };
|
||||||
|
getColorTextureView: () => GPUTextureView;
|
||||||
|
getSourceTextureView: () => GPUTextureView;
|
||||||
|
getSourceActive?: () => boolean;
|
||||||
|
getVibeId: () => VibeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotLayout {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
unpaddedBytesPerRow: number;
|
||||||
|
bytesPerRow: number;
|
||||||
|
readbackBufferBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExportSnapshotRenderer {
|
||||||
|
private isExporting = false;
|
||||||
|
|
||||||
|
public constructor(private readonly options: ExportSnapshotRendererOptions) {}
|
||||||
|
|
||||||
|
public async export(): Promise<void> {
|
||||||
|
if (this.isExporting) {
|
||||||
|
this.statusElement.textContent = 'Snapshot already saving...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isExporting = true;
|
||||||
|
this.statusElement.textContent = 'Saving snapshot...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceSize = this.options.getSourceSize();
|
||||||
|
await this.renderSnapshot(getSnapshotLayout(sourceSize.width, sourceSize.height));
|
||||||
|
this.statusElement.textContent = '';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusElement.textContent = 'Snapshot failed';
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
|
||||||
|
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
|
||||||
|
let texture: GPUTexture | null = null;
|
||||||
|
let output: GPUBuffer | null = null;
|
||||||
|
let isOutputMapped = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
texture = this.device.createTexture({
|
||||||
|
size: { width, height },
|
||||||
|
format: this.options.canvasFormat,
|
||||||
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||||
|
});
|
||||||
|
output = this.device.createBuffer({
|
||||||
|
size: layout.readbackBufferBytes,
|
||||||
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
this.options.renderPipeline.executeToView(
|
||||||
|
commandEncoder,
|
||||||
|
this.options.getColorTextureView(),
|
||||||
|
this.options.getSourceTextureView(),
|
||||||
|
texture.createView(),
|
||||||
|
this.options.getSourceActive?.() ?? true
|
||||||
|
);
|
||||||
|
commandEncoder.copyTextureToBuffer(
|
||||||
|
{ texture },
|
||||||
|
{ buffer: output, bytesPerRow, rowsPerImage: height },
|
||||||
|
{ width, height }
|
||||||
|
);
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
||||||
|
await output.mapAsync(GPUMapMode.READ);
|
||||||
|
isOutputMapped = true;
|
||||||
|
const pixels = readSnapshotPixels({
|
||||||
|
mapped: new Uint8Array(output.getMappedRange()),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
isBgra: this.options.canvasFormat === 'bgra8unorm',
|
||||||
|
});
|
||||||
|
output.unmap();
|
||||||
|
isOutputMapped = false;
|
||||||
|
output.destroy();
|
||||||
|
output = null;
|
||||||
|
texture.destroy();
|
||||||
|
texture = null;
|
||||||
|
|
||||||
|
await this.downloadPixels(pixels, width, height);
|
||||||
|
} finally {
|
||||||
|
if (output && isOutputMapped) {
|
||||||
|
output.unmap();
|
||||||
|
}
|
||||||
|
output?.destroy();
|
||||||
|
texture?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadPixels(
|
||||||
|
pixels: Uint8ClampedArray<ArrayBuffer>,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<void> {
|
||||||
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Could not create export canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||||
|
const blob = await canvas.convertToBlob({
|
||||||
|
type: appConfig.exportSnapshot.mimeType,
|
||||||
|
});
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
|
||||||
|
this.options.seed
|
||||||
|
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
|
||||||
|
link.click();
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get device(): GPUDevice {
|
||||||
|
return this.options.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get statusElement(): HTMLElement {
|
||||||
|
return this.options.statusElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignTo = (value: number, alignment: number): number =>
|
||||||
|
Math.ceil(value / alignment) * alignment;
|
||||||
|
|
||||||
|
const getSnapshotDimension = (value: number): number =>
|
||||||
|
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1;
|
||||||
|
|
||||||
|
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
|
||||||
|
const width = getSnapshotDimension(sourceWidth);
|
||||||
|
const height = getSnapshotDimension(sourceHeight);
|
||||||
|
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
|
||||||
|
const bytesPerRow = alignTo(
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
appConfig.exportSnapshot.rowAlignmentBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
readbackBufferBytes: bytesPerRow * height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSnapshotPixels = ({
|
||||||
|
mapped,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
unpaddedBytesPerRow,
|
||||||
|
bytesPerRow,
|
||||||
|
isBgra,
|
||||||
|
}: {
|
||||||
|
mapped: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
unpaddedBytesPerRow: number;
|
||||||
|
bytesPerRow: number;
|
||||||
|
isBgra: boolean;
|
||||||
|
}): Uint8ClampedArray<ArrayBuffer> => {
|
||||||
|
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
|
||||||
|
unpaddedBytesPerRow * height
|
||||||
|
);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const sourceOffset = y * bytesPerRow;
|
||||||
|
const targetOffset = y * unpaddedBytesPerRow;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||||
|
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||||
|
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||||
|
pixels[target + 1] = mapped[source + 1];
|
||||||
|
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||||
|
pixels[target + 3] = mapped[source + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pixels;
|
||||||
|
};
|
||||||
61
src/game-loop/frame-performance.ts
Normal file
61
src/game-loop/frame-performance.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { settings } from '../settings';
|
||||||
|
|
||||||
|
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
|
||||||
|
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000;
|
||||||
|
const FRAME_GAP_RESET_SECONDS = 1;
|
||||||
|
const FPS_HEADROOM = 0.9;
|
||||||
|
const FPS_SMOOTHING_NEW = 0.06;
|
||||||
|
const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW;
|
||||||
|
|
||||||
|
export class FramePerformance {
|
||||||
|
public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
|
||||||
|
public measuredFps = 0;
|
||||||
|
public frameDeltaSeconds = 0;
|
||||||
|
public measuredFrameTimeMs = 0;
|
||||||
|
|
||||||
|
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||||
|
|
||||||
|
public get adaptiveCapInitial(): number {
|
||||||
|
return settings.adaptiveCapInitial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get adaptiveCapMin(): number {
|
||||||
|
return settings.adaptiveCapMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get hasAdaptiveCapHeadroom(): boolean {
|
||||||
|
return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get adaptiveCapDecreaseAgents(): number {
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(time: DOMHighResTimeStamp): void {
|
||||||
|
const previous = this.previousFrameTime;
|
||||||
|
this.previousFrameTime = time;
|
||||||
|
if (previous === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaSeconds = (time - previous) / 1000;
|
||||||
|
if (deltaSeconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
||||||
|
const fps = 1 / deltaSeconds;
|
||||||
|
this.measuredFps = fps;
|
||||||
|
if (deltaSeconds > FRAME_GAP_RESET_SECONDS) {
|
||||||
|
this.frameDeltaSeconds = 0;
|
||||||
|
this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.frameDeltaSeconds = deltaSeconds;
|
||||||
|
this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/game-loop/game-loop-resources.ts
Normal file
186
src/game-loop/game-loop-resources.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig, type GardenRuntimeSettings } from '../config';
|
||||||
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
|
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||||
|
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { CommonState } from '../pipelines/common-state/common-state';
|
||||||
|
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||||
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
|
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
|
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||||
|
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
|
||||||
|
import { GpuProfiler } from './gpu-profiler';
|
||||||
|
import { SimulationFrameRenderer } from './simulation-frame';
|
||||||
|
import { SimulationTextures } from './simulation-textures';
|
||||||
|
|
||||||
|
interface FrameParameters extends RenderInputs {
|
||||||
|
time: number;
|
||||||
|
deltaTime: number;
|
||||||
|
canvasSize: vec2;
|
||||||
|
activeAgentCount: number;
|
||||||
|
canvasPixelRatio: number;
|
||||||
|
introProgress: number;
|
||||||
|
selectedColorIndex: number;
|
||||||
|
eraserPixelSize: number;
|
||||||
|
runtimeSettings: GardenRuntimeSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameLoopResources {
|
||||||
|
public readonly textures: SimulationTextures;
|
||||||
|
public readonly commonState: CommonState;
|
||||||
|
public readonly agentGenerationPipeline: AgentGenerationPipeline;
|
||||||
|
public readonly agentPipeline: AgentPipeline;
|
||||||
|
public readonly brushPipeline: BrushPipeline;
|
||||||
|
public readonly eraserAgentPipeline: EraserAgentPipeline;
|
||||||
|
public readonly eraserTexturePipeline: EraserTexturePipeline;
|
||||||
|
public readonly diffusionPipeline: DiffusionPipeline;
|
||||||
|
public readonly renderPipeline: RenderPipeline;
|
||||||
|
public readonly gpuProfiler: GpuProfiler | null;
|
||||||
|
|
||||||
|
private readonly frameRenderer: SimulationFrameRenderer;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
private readonly canvasFormat: GPUTextureFormat,
|
||||||
|
canvasSize: vec2,
|
||||||
|
initialAgentCapacity: number,
|
||||||
|
initialMaxAgentCount: number
|
||||||
|
) {
|
||||||
|
const context = initializeContext({ device, canvas, format: canvasFormat });
|
||||||
|
|
||||||
|
this.textures = new SimulationTextures(this.device, canvasSize);
|
||||||
|
|
||||||
|
this.commonState = new CommonState(this.device);
|
||||||
|
this.commonState.setParameters({
|
||||||
|
canvasSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||||
|
this.device,
|
||||||
|
Math.min(initialMaxAgentCount, initialAgentCapacity)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.agentPipeline = new AgentPipeline(
|
||||||
|
this.device,
|
||||||
|
this.commonState,
|
||||||
|
() => this.agentGenerationPipeline.agentsBuffer
|
||||||
|
);
|
||||||
|
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||||
|
this.eraserAgentPipeline = new EraserAgentPipeline(
|
||||||
|
this.device,
|
||||||
|
() => this.agentGenerationPipeline.agentsBuffer
|
||||||
|
);
|
||||||
|
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||||
|
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||||
|
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
|
||||||
|
this.gpuProfiler = GpuProfiler.create(
|
||||||
|
this.device,
|
||||||
|
() => appConfig.tuningPane.showFpsOverlay
|
||||||
|
);
|
||||||
|
|
||||||
|
this.frameRenderer = new SimulationFrameRenderer(
|
||||||
|
this.device,
|
||||||
|
this.textures,
|
||||||
|
{
|
||||||
|
agentPipeline: this.agentPipeline,
|
||||||
|
brushPipeline: this.brushPipeline,
|
||||||
|
eraserAgentPipeline: this.eraserAgentPipeline,
|
||||||
|
eraserTexturePipeline: this.eraserTexturePipeline,
|
||||||
|
diffusionPipeline: this.diffusionPipeline,
|
||||||
|
renderPipeline: this.renderPipeline,
|
||||||
|
},
|
||||||
|
this.gpuProfiler
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resizeSimulationTo(nextSize: vec2): vec2 | null {
|
||||||
|
return this.textures.resizeTo(nextSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSimulation(): void {
|
||||||
|
this.textures.clear();
|
||||||
|
this.frameRenderer.resetSourceMapActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isSourceMapActive(): boolean {
|
||||||
|
return this.frameRenderer.isSourceMapActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get gpuPassTimeMs(): number | undefined {
|
||||||
|
return this.gpuProfiler?.latestTotalPassMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setFrameParameters({
|
||||||
|
time,
|
||||||
|
deltaTime,
|
||||||
|
canvasSize,
|
||||||
|
activeAgentCount,
|
||||||
|
canvasPixelRatio,
|
||||||
|
introProgress,
|
||||||
|
selectedColorIndex,
|
||||||
|
channelColors,
|
||||||
|
backgroundColor,
|
||||||
|
eraserPixelSize,
|
||||||
|
runtimeSettings,
|
||||||
|
}: FrameParameters): void {
|
||||||
|
this.commonState.setParameters({
|
||||||
|
canvasSize,
|
||||||
|
});
|
||||||
|
this.agentPipeline.setParameters({
|
||||||
|
...runtimeSettings,
|
||||||
|
deltaTime,
|
||||||
|
time,
|
||||||
|
agentCount: activeAgentCount,
|
||||||
|
introMoveSpeed: appConfig.simulation.introMoveSpeed,
|
||||||
|
introProgress,
|
||||||
|
});
|
||||||
|
this.brushPipeline.setParameters({
|
||||||
|
...runtimeSettings,
|
||||||
|
pixelRatio: canvasPixelRatio,
|
||||||
|
selectedColorIndex,
|
||||||
|
});
|
||||||
|
this.diffusionPipeline.setParameters(runtimeSettings);
|
||||||
|
this.renderPipeline.setParameters({
|
||||||
|
...runtimeSettings,
|
||||||
|
channelColors,
|
||||||
|
backgroundColor,
|
||||||
|
});
|
||||||
|
this.eraserAgentPipeline.setParameters({
|
||||||
|
agentCount: activeAgentCount,
|
||||||
|
eraserSize: eraserPixelSize,
|
||||||
|
eraserMaskAlphaThreshold: runtimeSettings.eraserMaskAlphaThreshold,
|
||||||
|
maskSize: canvasSize,
|
||||||
|
});
|
||||||
|
this.eraserTexturePipeline.setParameters({
|
||||||
|
eraserSize: eraserPixelSize,
|
||||||
|
eraserLineDistanceEpsilon: runtimeSettings.eraserLineDistanceEpsilon,
|
||||||
|
eraserClearRed: runtimeSettings.eraserClearRed,
|
||||||
|
eraserClearGreen: runtimeSettings.eraserClearGreen,
|
||||||
|
eraserClearBlue: runtimeSettings.eraserClearBlue,
|
||||||
|
eraserClearAlpha: runtimeSettings.eraserClearAlpha,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public executeFrame(
|
||||||
|
isErasing: boolean,
|
||||||
|
canvasReadbackRequest?: CanvasReadbackRequest | null
|
||||||
|
): void {
|
||||||
|
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.agentGenerationPipeline.destroy();
|
||||||
|
this.agentPipeline.destroy();
|
||||||
|
this.brushPipeline.destroy();
|
||||||
|
this.eraserAgentPipeline.destroy();
|
||||||
|
this.eraserTexturePipeline.destroy();
|
||||||
|
this.diffusionPipeline.destroy();
|
||||||
|
this.renderPipeline.destroy();
|
||||||
|
this.gpuProfiler?.destroy();
|
||||||
|
this.commonState.destroy();
|
||||||
|
this.textures.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export interface GameLoopSettings {
|
|
||||||
maxAgentCountUpperLimit: number;
|
|
||||||
agentCount: number;
|
|
||||||
renderSpeed: number;
|
|
||||||
simulatedDelayMs: number;
|
|
||||||
|
|
||||||
startColorHue: number;
|
|
||||||
}
|
|
||||||
26
src/game-loop/game-loop-types.ts
Normal file
26
src/game-loop/game-loop-types.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import type { RgbColor } from '../utils/rgb-color';
|
||||||
|
|
||||||
|
export interface GardenUi {
|
||||||
|
eraserPreview: HTMLElement;
|
||||||
|
exportStatus: HTMLElement;
|
||||||
|
grainOverlay: HTMLElement;
|
||||||
|
prompt: HTMLElement;
|
||||||
|
toolbar: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderInputs {
|
||||||
|
channelColors: [RgbColor, RgbColor, RgbColor];
|
||||||
|
backgroundColor: RgbColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrokeSegment {
|
||||||
|
from: vec2;
|
||||||
|
to: vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasReadbackRequest {
|
||||||
|
encode(commandEncoder: GPUCommandEncoder, texture: GPUTexture): void;
|
||||||
|
afterSubmit(): void;
|
||||||
|
}
|
||||||
|
|
@ -1,265 +1,364 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
import { GardenAudio } from '../audio/garden-audio';
|
||||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
import { appConfig } from '../config';
|
||||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { CommonState } from '../pipelines/common-state/common-state';
|
|
||||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
|
||||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
|
||||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
||||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
import { AgentPopulation } from './agent-population';
|
||||||
import { sleep } from '../utils/sleep';
|
import { EraserPreview } from './eraser-preview';
|
||||||
import { GamePresentation } from './game-presentation';
|
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
|
||||||
import { GameRules } from './game-rules';
|
import { FramePerformance } from './frame-performance';
|
||||||
|
import { GameLoopResources } from './game-loop-resources';
|
||||||
|
import { GardenUi } from './game-loop-types';
|
||||||
|
import { getInternalRenderSize } from './internal-render-size';
|
||||||
|
import { IntroPrompt } from './intro-prompt';
|
||||||
|
import { PerfStatsOverlay } from './perf-stats-overlay';
|
||||||
|
import { GardenPointerInput } from './pointer-input';
|
||||||
|
import { PipelineStrokeOutput } from './stroke-output';
|
||||||
|
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
||||||
|
|
||||||
export default class GameLoop {
|
export default class GameLoop {
|
||||||
private readonly trailMapA: ResizableTexture;
|
private readonly resources: GameLoopResources;
|
||||||
private readonly trailMapB: ResizableTexture;
|
private readonly audio = new GardenAudio(appConfig.audio);
|
||||||
|
private readonly introPrompt: IntroPrompt;
|
||||||
private readonly commonState: CommonState;
|
private readonly eraserPreview: EraserPreview;
|
||||||
private readonly copyPipeline: CopyPipeline;
|
private readonly pointerInput: GardenPointerInput;
|
||||||
private readonly agentGenerationPipeline: AgentGenerationPipeline;
|
private readonly agentPopulation: AgentPopulation;
|
||||||
private readonly agentPipeline: AgentPipeline;
|
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
|
||||||
private readonly renderPipeline: RenderPipeline;
|
private readonly framePerformance = new FramePerformance();
|
||||||
private readonly brushPipeline: BrushPipeline;
|
private perfStatsOverlay: PerfStatsOverlay | null = null;
|
||||||
private readonly diffusionPipeline: DiffusionPipeline;
|
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||||
|
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||||
|
private readonly seed = this.seedValue.toString(16);
|
||||||
|
private readonly _canvasSize: vec2 = vec2.create();
|
||||||
|
|
||||||
|
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||||
|
private previousAccentColor = '';
|
||||||
|
private previousGrainStrength = Number.NaN;
|
||||||
private hasFinished = false;
|
private hasFinished = false;
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private destroyPromise: Promise<void> | null = null;
|
||||||
private readonly finished = Promise.withResolvers<void>();
|
private readonly finished = Promise.withResolvers<void>();
|
||||||
|
|
||||||
private activePointerId: number | null = null;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly canvas: HTMLCanvasElement,
|
private readonly canvas: HTMLCanvasElement,
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
|
private readonly canvasFormat: GPUTextureFormat,
|
||||||
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
private readonly deltaTimeCalculator: DeltaTimeCalculator,
|
||||||
private readonly gameRules: GameRules
|
private readonly ui: GardenUi
|
||||||
) {
|
) {
|
||||||
const context = initializeContext({ device, canvas });
|
|
||||||
|
|
||||||
this.trailMapA = new ResizableTexture(this.device, this.canvasSize);
|
|
||||||
this.trailMapB = new ResizableTexture(this.device, this.canvasSize);
|
|
||||||
this.resize();
|
this.resize();
|
||||||
|
this.resources = new GameLoopResources(
|
||||||
this.copyPipeline = new CopyPipeline(this.device);
|
canvas,
|
||||||
|
device,
|
||||||
this.commonState = new CommonState(this.device);
|
this.canvasFormat,
|
||||||
this.commonState.setParameters({
|
this.canvasSize,
|
||||||
canvasSize: this.canvasSize,
|
this.framePerformance.adaptiveCapInitial,
|
||||||
time: 0,
|
settings.maxAgentCount
|
||||||
deltaTime: 0,
|
);
|
||||||
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
|
||||||
|
canvas,
|
||||||
|
ui.toolbar,
|
||||||
|
device,
|
||||||
|
this.canvasFormat
|
||||||
|
);
|
||||||
|
this.agentPopulation = new AgentPopulation(
|
||||||
|
this.resources.agentGenerationPipeline,
|
||||||
|
this.seedValue,
|
||||||
|
() => this.canvasPixelRatio,
|
||||||
|
this.framePerformance
|
||||||
|
);
|
||||||
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||||
|
this.pointerInput = new GardenPointerInput({
|
||||||
|
canvas,
|
||||||
|
audio: this.audio,
|
||||||
|
strokeOutput: new PipelineStrokeOutput(
|
||||||
|
this.resources.brushPipeline,
|
||||||
|
this.resources.eraserAgentPipeline,
|
||||||
|
this.resources.eraserTexturePipeline
|
||||||
|
),
|
||||||
|
getCanvasPixelRatio: () => this.canvasPixelRatio,
|
||||||
|
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||||
|
onStartDrawing: () => {
|
||||||
|
this.introPrompt.markStartedDrawing();
|
||||||
|
this.agentPopulation.beginStroke();
|
||||||
|
},
|
||||||
|
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||||
|
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||||
|
});
|
||||||
|
this.eraserPreview = new EraserPreview(
|
||||||
|
canvas,
|
||||||
|
ui.eraserPreview,
|
||||||
|
() => this.pointerInput.isSwipeActive
|
||||||
|
);
|
||||||
|
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
||||||
|
device,
|
||||||
|
renderPipeline: this.resources.renderPipeline,
|
||||||
|
canvasFormat: this.canvasFormat,
|
||||||
|
statusElement: ui.exportStatus,
|
||||||
|
seed: this.seed,
|
||||||
|
getSourceSize: () => {
|
||||||
|
const size = this.resources.textures.trailMapA.getSize();
|
||||||
|
return {
|
||||||
|
width: size[0],
|
||||||
|
height: size[1],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
||||||
|
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||||
|
getSourceActive: () => this.resources.isSourceMapActive,
|
||||||
|
getVibeId: () => activeVibe.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
this.syncPerfStatsOverlay();
|
||||||
this.device,
|
|
||||||
this.commonState,
|
|
||||||
settings.maxAgentCountUpperLimit
|
|
||||||
);
|
|
||||||
this.agentGenerationPipeline.spawnFirstGeneration();
|
|
||||||
|
|
||||||
this.agentPipeline = new AgentPipeline(
|
|
||||||
this.device,
|
|
||||||
this.commonState,
|
|
||||||
this.agentGenerationPipeline.agentsBuffer
|
|
||||||
);
|
|
||||||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
|
||||||
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
|
|
||||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
|
||||||
|
|
||||||
window.addEventListener('resize', this.resize.bind(this));
|
|
||||||
canvas.addEventListener('pointerdown', this.onPointerDown.bind(this));
|
|
||||||
canvas.addEventListener('pointermove', this.onPointerMove.bind(this));
|
|
||||||
canvas.addEventListener('pointerup', this.onPointerUp.bind(this));
|
|
||||||
canvas.addEventListener('pointercancel', this.onPointerUp.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerDown(event: PointerEvent) {
|
public attachPointerInput(): void {
|
||||||
if (this.activePointerId !== null) {
|
this.pointerInput.attach();
|
||||||
return;
|
this.eraserPreview.attach();
|
||||||
}
|
|
||||||
this.activePointerId = event.pointerId;
|
|
||||||
this.canvas.setPointerCapture(event.pointerId);
|
|
||||||
this.brushPipeline.clearSwipes();
|
|
||||||
this.addSwipeAt(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerMove(event: PointerEvent) {
|
public setEraseMode(isErasing: boolean): void {
|
||||||
if (event.pointerId !== this.activePointerId) {
|
this.pointerInput.setEraseMode(isErasing);
|
||||||
return;
|
this.eraserPreview.setEraseMode(isErasing);
|
||||||
}
|
|
||||||
this.addSwipeAt(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onPointerUp(event: PointerEvent) {
|
public updateEraserPreview(event?: PointerEvent): void {
|
||||||
if (event.pointerId !== this.activePointerId) {
|
this.eraserPreview.update(event);
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.addSwipeAt(event);
|
|
||||||
this.canvas.releasePointerCapture(event.pointerId);
|
|
||||||
this.activePointerId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private addSwipeAt(event: PointerEvent) {
|
public onVibeChanged(): void {
|
||||||
const position = vec2.fromValues(
|
this.agentPopulation.onVibeChanged();
|
||||||
event.clientX * this.devicePixelRatio,
|
this.syncPerfStatsOverlay();
|
||||||
this.canvas.height - event.clientY * this.devicePixelRatio
|
|
||||||
);
|
|
||||||
this.brushPipeline.addSwipe(position);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get isSwipeActive(): boolean {
|
public setAudioMuted(isMuted: boolean): void {
|
||||||
return this.activePointerId !== null;
|
this.audio.setMuted(isMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setAudioVolume(volume: number): void {
|
||||||
|
this.audio.setMasterVolume(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
public startAudio(userGesture = false): void {
|
||||||
|
this.audio.start(activeVibe, { userGesture });
|
||||||
|
}
|
||||||
|
|
||||||
|
public playVibeChangeAudio(userGesture = false): void {
|
||||||
|
this.audio.changeVibe(activeVibe, { userGesture });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
requestAnimationFrame(this.render.bind(this));
|
if (this.animationFrameId === null && !this.hasFinished) {
|
||||||
requestAnimationFrame(this.updateCounts.bind(this));
|
this.animationFrameId = requestAnimationFrame(this.render);
|
||||||
|
}
|
||||||
return this.finished.promise;
|
return this.finished.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateCounts(): Promise<void> {
|
public async exportSnapshot(): Promise<void> {
|
||||||
if (this.hasFinished) {
|
return this.exportSnapshotRenderer.export();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const generationCounts = await this.agentGenerationPipeline.countAgents(
|
|
||||||
settings.agentCount
|
|
||||||
);
|
|
||||||
this.gameRules.updateGenerationCounts(generationCounts);
|
|
||||||
requestAnimationFrame(this.updateCounts.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get aliveAgentCounts(): {
|
public async destroy(): Promise<void> {
|
||||||
currentGenerationCount: number;
|
this.destroyPromise ??= this.dispose();
|
||||||
nextGenerationCount: number;
|
return this.destroyPromise;
|
||||||
} {
|
|
||||||
return this.gameRules.generationCounts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get maxAgentCount(): number {
|
private async dispose(): Promise<void> {
|
||||||
return this.agentGenerationPipeline.maxAgentCount;
|
this.hasFinished = true;
|
||||||
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
this.pointerInput.detach();
|
||||||
|
this.eraserPreview.detach();
|
||||||
|
this.perfStatsOverlay?.destroy();
|
||||||
|
this.perfStatsOverlay = null;
|
||||||
|
this.toolbarContrastMonitor.destroy();
|
||||||
|
this.introPrompt.destroy();
|
||||||
|
await this.agentPopulation.waitForCompaction();
|
||||||
|
this.resources.destroy();
|
||||||
|
await this.audio.destroy();
|
||||||
|
this.finished.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
private resize() {
|
private readonly render = (time: DOMHighResTimeStamp) => {
|
||||||
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
|
this.animationFrameId = null;
|
||||||
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async render(time: DOMHighResTimeStamp) {
|
|
||||||
if (this.hasFinished) {
|
if (this.hasFinished) {
|
||||||
this.finished.resolve();
|
this.finished.resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accentColor = GamePresentation.getGenerationColor(
|
|
||||||
this.gameRules.nextGenerationId - 1
|
|
||||||
);
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
'--accent-color',
|
|
||||||
`rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||||
|
this.framePerformance.update(time);
|
||||||
|
this.agentPopulation.updateAdaptiveCap();
|
||||||
|
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
|
||||||
|
this.resize();
|
||||||
|
this.resizeSimulationToCanvas(time);
|
||||||
|
this.regenerateIntroAfterSettledResize(time);
|
||||||
|
|
||||||
time *= settings.renderSpeed;
|
const channelColors = activeVibe.colors;
|
||||||
const timeInSeconds = time / 1000;
|
const backgroundColor = activeVibe.backgroundColor;
|
||||||
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
|
const runtimeSettings = { ...settings };
|
||||||
|
const introProgress = this.introPrompt.progress;
|
||||||
|
const canvasPixelRatio = this.canvasPixelRatio;
|
||||||
|
const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
|
||||||
|
const isErasing = this.pointerInput.isEraseMode;
|
||||||
|
const accentColor =
|
||||||
|
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
|
||||||
|
this.updateAccentColor(accentColor);
|
||||||
|
this.updateGrainOverlay(runtimeSettings.backgroundGrainStrength);
|
||||||
|
this.audio.update({
|
||||||
|
vibe: activeVibe,
|
||||||
|
isErasing,
|
||||||
|
});
|
||||||
|
|
||||||
[
|
this.resources.setFrameParameters({
|
||||||
this.commonState,
|
|
||||||
this.agentPipeline,
|
|
||||||
this.brushPipeline,
|
|
||||||
this.diffusionPipeline,
|
|
||||||
this.renderPipeline,
|
|
||||||
].forEach((pipeline) =>
|
|
||||||
pipeline.setParameters({
|
|
||||||
time,
|
time,
|
||||||
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
|
|
||||||
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
|
|
||||||
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
|
|
||||||
infectionProbability: this.gameRules.getInfectionProbability(),
|
|
||||||
deltaTime,
|
deltaTime,
|
||||||
canvasSize: this.canvasSize,
|
canvasSize: this.canvasSize,
|
||||||
brushColor: GamePresentation.getGenerationColor(
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
this.gameRules.nextGenerationId - 1
|
canvasPixelRatio,
|
||||||
),
|
introProgress,
|
||||||
evenGenerationColor: GamePresentation.getGenerationColor(
|
selectedColorIndex: runtimeSettings.selectedColorIndex,
|
||||||
this.gameRules.nextGenerationId % 2 == 0
|
channelColors,
|
||||||
? this.gameRules.nextGenerationId
|
backgroundColor,
|
||||||
: this.gameRules.nextGenerationId - 1
|
eraserPixelSize,
|
||||||
),
|
runtimeSettings,
|
||||||
oddGenerationColor: GamePresentation.getGenerationColor(
|
});
|
||||||
this.gameRules.nextGenerationId % 2 == 1
|
|
||||||
? this.gameRules.nextGenerationId
|
this.resources.executeFrame(
|
||||||
: this.gameRules.nextGenerationId - 1
|
isErasing,
|
||||||
),
|
this.toolbarContrastMonitor.takeReadbackRequest(time)
|
||||||
...settings,
|
|
||||||
center: spawnAction.position,
|
|
||||||
radius: spawnAction.radius,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < settings.renderSpeed; i++) {
|
this.pointerInput.clearSwipesIfIdle();
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
|
||||||
|
this.perfStatsOverlay?.update({
|
||||||
|
time,
|
||||||
|
fps: this.framePerformance.measuredFps,
|
||||||
|
agentCount: this.agentPopulation.activeAgentCount,
|
||||||
|
frameTimeMs: this.framePerformance.measuredFrameTimeMs,
|
||||||
|
gpuPassTimeMs: this.resources.gpuPassTimeMs,
|
||||||
|
renderWidth: this.canvas.width,
|
||||||
|
renderHeight: this.canvas.height,
|
||||||
|
});
|
||||||
|
|
||||||
this.copyPipeline.execute(
|
this.animationFrameId = requestAnimationFrame(this.render);
|
||||||
commandEncoder,
|
};
|
||||||
this.trailMapA.getTextureView(),
|
|
||||||
this.trailMapB.getTextureView()
|
private syncPerfStatsOverlay(): void {
|
||||||
|
if (appConfig.tuningPane.showFpsOverlay) {
|
||||||
|
this.perfStatsOverlay ??= new PerfStatsOverlay(
|
||||||
|
this.canvas.parentElement ?? document.body
|
||||||
);
|
);
|
||||||
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
|
return;
|
||||||
this.agentPipeline.execute(
|
}
|
||||||
commandEncoder,
|
|
||||||
this.trailMapA.getTextureView(),
|
this.perfStatsOverlay?.destroy();
|
||||||
this.trailMapB.getTextureView()
|
this.perfStatsOverlay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateAccentColor(color: RgbColor): void {
|
||||||
|
const accentColor = rgbColorToCss(color);
|
||||||
|
if (this.previousAccentColor === accentColor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previousAccentColor = accentColor;
|
||||||
|
document.documentElement.style.setProperty('--accent-color', accentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateGrainOverlay(strength: number): void {
|
||||||
|
const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0;
|
||||||
|
if (Object.is(this.previousGrainStrength, safeStrength)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previousGrainStrength = safeStrength;
|
||||||
|
this.grainOverlay.hidden = safeStrength <= 0;
|
||||||
|
this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resize(): void {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const { width, height } = getInternalRenderSize({
|
||||||
|
clientHeight: rect.height || this.canvas.clientHeight,
|
||||||
|
clientWidth: rect.width || this.canvas.clientWidth,
|
||||||
|
maxTextureDimension: this.device.limits.maxTextureDimension2D,
|
||||||
|
targetAreaMegapixels: settings.internalRenderAreaMegapixels,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.canvas.width === width && this.canvas.height === height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.width = width;
|
||||||
|
this.canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resizeSimulationToCanvas(time: DOMHighResTimeStamp): void {
|
||||||
|
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
||||||
|
if (!scale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.agentPopulation.resizeAgents(scale);
|
||||||
|
this.pointerInput.scaleLastPointerPosition(scale);
|
||||||
|
|
||||||
|
if (this.introPrompt.shouldRegenerateTitleOnResize) {
|
||||||
|
this.pendingIntroResizeAt = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private regenerateIntroAfterSettledResize(time: DOMHighResTimeStamp): void {
|
||||||
|
if (this.pendingIntroResizeAt === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.introPrompt.shouldRegenerateTitleOnResize) {
|
||||||
|
this.pendingIntroResizeAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.introPrompt.rewindToLeaveRemainingTime(
|
||||||
|
appConfig.simulation.intro.resizeMinimumRemainingSeconds
|
||||||
);
|
);
|
||||||
this.diffusionPipeline.execute(
|
this.resources.clearSimulation();
|
||||||
commandEncoder,
|
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
|
||||||
this.trailMapB.getTextureView(),
|
this.pendingIntroResizeAt = null;
|
||||||
this.trailMapA.getTextureView()
|
|
||||||
);
|
|
||||||
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
|
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isSwipeActive) {
|
|
||||||
this.brushPipeline.clearSwipes();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.simulatedDelayMs > 0) {
|
|
||||||
await sleep(settings.simulatedDelayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// avoid resizing during rendering
|
|
||||||
this.trailMapA.resize(this.canvasSize);
|
|
||||||
this.trailMapB.resize(this.canvasSize);
|
|
||||||
|
|
||||||
requestAnimationFrame(this.render.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async destroy() {
|
|
||||||
this.hasFinished = true;
|
|
||||||
await this.finished.promise;
|
|
||||||
|
|
||||||
this.copyPipeline?.destroy();
|
|
||||||
this.agentGenerationPipeline?.destroy();
|
|
||||||
this.agentPipeline?.destroy();
|
|
||||||
this.brushPipeline?.destroy();
|
|
||||||
this.diffusionPipeline?.destroy();
|
|
||||||
this.renderPipeline?.destroy();
|
|
||||||
this.commonState?.destroy();
|
|
||||||
this.trailMapA?.destroy();
|
|
||||||
this.trailMapB?.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get canvasSize(): vec2 {
|
private get canvasSize(): vec2 {
|
||||||
return vec2.fromValues(this.canvas.width, this.canvas.height);
|
vec2.set(this._canvasSize, this.canvas.width, this.canvas.height);
|
||||||
|
return this._canvasSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get devicePixelRatio(): number {
|
private get canvasPixelRatio(): number {
|
||||||
return window.devicePixelRatio || 1;
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1;
|
||||||
|
const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale;
|
||||||
|
const ratio = (xScale + yScale) / 2;
|
||||||
|
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get mirrorSegmentCount(): number {
|
||||||
|
const count = Number.isFinite(settings.mirrorSegmentCount)
|
||||||
|
? settings.mirrorSegmentCount
|
||||||
|
: appConfig.toolbar.mirror.min;
|
||||||
|
return Math.min(
|
||||||
|
appConfig.toolbar.mirror.max,
|
||||||
|
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get grainOverlay(): HTMLElement {
|
||||||
|
return this.ui.grainOverlay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { vec3 } from 'gl-matrix';
|
|
||||||
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { hsl } from '../utils/hsl';
|
|
||||||
import { Random } from '../utils/random';
|
|
||||||
|
|
||||||
const hues = [settings.startColorHue];
|
|
||||||
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360);
|
|
||||||
}
|
|
||||||
|
|
||||||
const colors = hues.map((hue) =>
|
|
||||||
hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30))
|
|
||||||
);
|
|
||||||
|
|
||||||
export class GamePresentation {
|
|
||||||
public static getGenerationColor(generation: number): vec3 {
|
|
||||||
return colors[generation % colors.length];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
|
|
||||||
import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts';
|
|
||||||
import { settings } from '../settings';
|
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
|
||||||
import { mix } from '../utils/mix';
|
|
||||||
import { Random } from '../utils/random';
|
|
||||||
|
|
||||||
export interface SpawnAction {
|
|
||||||
generation: number;
|
|
||||||
position: vec2;
|
|
||||||
radius: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GameRules {
|
|
||||||
private static readonly DEFAULT_SPAWN_INTERVAL = 8;
|
|
||||||
private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2;
|
|
||||||
private static readonly DEFAULT_SPAWN_RADIUS = 20;
|
|
||||||
|
|
||||||
private lastSpawnTimeInSeconds = 0;
|
|
||||||
private currentSpawnInterval = 0;
|
|
||||||
private currentSpawnRadius = 0;
|
|
||||||
private lastGenerationChangeTimeInSeconds = 0;
|
|
||||||
|
|
||||||
public nextGenerationId = 1;
|
|
||||||
public generationCounts: {
|
|
||||||
currentGenerationCount: number;
|
|
||||||
nextGenerationCount: number;
|
|
||||||
} = {
|
|
||||||
currentGenerationCount: 0,
|
|
||||||
nextGenerationCount: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
public constructor(startingTimeInSeconds: number) {
|
|
||||||
this.lastSpawnTimeInSeconds = startingTimeInSeconds;
|
|
||||||
this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
private lastSpawnAction: SpawnAction | undefined;
|
|
||||||
|
|
||||||
public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction {
|
|
||||||
if (
|
|
||||||
this.lastSpawnAction &&
|
|
||||||
timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH
|
|
||||||
) {
|
|
||||||
return this.lastSpawnAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSpawnInterval = mix(
|
|
||||||
GameRules.DEFAULT_SPAWN_INTERVAL,
|
|
||||||
GameRules.DEFAULT_SPAWN_INTERVAL / 5,
|
|
||||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.currentSpawnRadius = mix(
|
|
||||||
GameRules.DEFAULT_SPAWN_RADIUS,
|
|
||||||
GameRules.DEFAULT_SPAWN_RADIUS * 3,
|
|
||||||
clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120)
|
|
||||||
);
|
|
||||||
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
|
|
||||||
if (
|
|
||||||
timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval ||
|
|
||||||
q > 0.05
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
generation: this.nextGenerationId,
|
|
||||||
position: vec2.create(),
|
|
||||||
radius: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastSpawnTimeInSeconds = timeInSeconds;
|
|
||||||
|
|
||||||
this.lastSpawnAction = {
|
|
||||||
generation: this.nextGenerationId,
|
|
||||||
position: vec2.fromValues(
|
|
||||||
Random.randomBetween(0, canvasSize[0]),
|
|
||||||
Random.randomBetween(0, canvasSize[1])
|
|
||||||
),
|
|
||||||
radius: this.currentSpawnRadius,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.lastSpawnAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
public updateGenerationCounts({
|
|
||||||
evenGenerationCount,
|
|
||||||
oddGenerationCount,
|
|
||||||
}: GenerationCounts): void {
|
|
||||||
const nextGenerationCount =
|
|
||||||
this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount;
|
|
||||||
const currentGenerationCount =
|
|
||||||
this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount;
|
|
||||||
|
|
||||||
const q = currentGenerationCount / settings.agentCount;
|
|
||||||
|
|
||||||
if (currentGenerationCount <= 100 && q < 0.05) {
|
|
||||||
this.nextGenerationId++;
|
|
||||||
this.lastGenerationChangeTimeInSeconds = performance.now() / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.generationCounts = {
|
|
||||||
currentGenerationCount,
|
|
||||||
nextGenerationCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getNextGenerationMoveSpeed(): number {
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getInfectionProbability(): number {
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
return clamp(mix(0.3, 1, q * 5), 0, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getSensorOffset(): number {
|
|
||||||
const q = this.generationCounts.nextGenerationCount / settings.agentCount;
|
|
||||||
return mix(20, settings.sensorOffsetDistance, q);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
173
src/game-loop/gpu-profiler.ts
Normal file
173
src/game-loop/gpu-profiler.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
const PASS_NAMES = [
|
||||||
|
'brush',
|
||||||
|
'eraserTexture',
|
||||||
|
'eraserAgent',
|
||||||
|
'agent',
|
||||||
|
'trailDiffusion',
|
||||||
|
'render',
|
||||||
|
'sourceDiffusion',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GpuPassName = (typeof PASS_NAMES)[number];
|
||||||
|
|
||||||
|
interface GpuProfilerSample {
|
||||||
|
frame: number;
|
||||||
|
passes: Partial<Record<GpuPassName, number>>;
|
||||||
|
totalPassMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivePass {
|
||||||
|
endQueryIndex: number;
|
||||||
|
name: GpuPassName;
|
||||||
|
startQueryIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadbackSlot {
|
||||||
|
buffer: GPUBuffer;
|
||||||
|
state: 'idle' | 'encoding' | 'mapping';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_QUERY_COUNT = PASS_NAMES.length * 2;
|
||||||
|
const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT;
|
||||||
|
const READBACK_SLOT_COUNT = 4;
|
||||||
|
|
||||||
|
export class GpuProfiler {
|
||||||
|
private readonly querySet: GPUQuerySet;
|
||||||
|
private readonly resolveBuffer: GPUBuffer;
|
||||||
|
private readonly readbackSlots: Array<ReadbackSlot>;
|
||||||
|
private readonly isEnabled: () => boolean;
|
||||||
|
private activePasses: Array<ActivePass> = [];
|
||||||
|
private nextQueryIndex = 0;
|
||||||
|
private frame = 0;
|
||||||
|
private latestSample: GpuProfilerSample | null = null;
|
||||||
|
|
||||||
|
public static create(device: GPUDevice, isEnabled: () => boolean): GpuProfiler | null {
|
||||||
|
if (!device.features.has('timestamp-query')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new GpuProfiler(device, isEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(device: GPUDevice, isEnabled: () => boolean) {
|
||||||
|
this.isEnabled = isEnabled;
|
||||||
|
this.querySet = device.createQuerySet({
|
||||||
|
type: 'timestamp',
|
||||||
|
count: MAX_QUERY_COUNT,
|
||||||
|
});
|
||||||
|
this.resolveBuffer = device.createBuffer({
|
||||||
|
size: MAX_QUERY_COUNT * QUERY_BYTES,
|
||||||
|
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
|
||||||
|
});
|
||||||
|
this.readbackSlots = Array.from({ length: READBACK_SLOT_COUNT }, () => ({
|
||||||
|
buffer: device.createBuffer({
|
||||||
|
size: MAX_QUERY_COUNT * QUERY_BYTES,
|
||||||
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||||
|
}),
|
||||||
|
state: 'idle' as const,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public beginFrame(): void {
|
||||||
|
this.frame += 1;
|
||||||
|
this.activePasses = [];
|
||||||
|
this.nextQueryIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public timestampWrites(
|
||||||
|
name: GpuPassName
|
||||||
|
): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startQueryIndex = this.nextQueryIndex;
|
||||||
|
const endQueryIndex = this.nextQueryIndex + 1;
|
||||||
|
this.nextQueryIndex += 2;
|
||||||
|
this.activePasses.push({
|
||||||
|
endQueryIndex,
|
||||||
|
name,
|
||||||
|
startQueryIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
querySet: this.querySet,
|
||||||
|
beginningOfPassWriteIndex: startQueryIndex,
|
||||||
|
endOfPassWriteIndex: endQueryIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolve(commandEncoder: GPUCommandEncoder): (() => void) | null {
|
||||||
|
const queryCount = this.nextQueryIndex;
|
||||||
|
if (queryCount === 0 || this.activePasses.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = this.readbackSlots.find((candidate) => candidate.state === 'idle');
|
||||||
|
if (!slot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byteLength = queryCount * QUERY_BYTES;
|
||||||
|
const passes = this.activePasses.slice();
|
||||||
|
const frame = this.frame;
|
||||||
|
slot.state = 'encoding';
|
||||||
|
commandEncoder.resolveQuerySet(this.querySet, 0, queryCount, this.resolveBuffer, 0);
|
||||||
|
commandEncoder.copyBufferToBuffer(this.resolveBuffer, 0, slot.buffer, 0, byteLength);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
slot.state = 'mapping';
|
||||||
|
void slot.buffer
|
||||||
|
.mapAsync(GPUMapMode.READ, 0, byteLength)
|
||||||
|
.then(() => {
|
||||||
|
this.publishSample(frame, passes, slot.buffer.getMappedRange(0, byteLength));
|
||||||
|
slot.buffer.unmap();
|
||||||
|
slot.state = 'idle';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
slot.state = 'idle';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.querySet.destroy();
|
||||||
|
this.resolveBuffer.destroy();
|
||||||
|
this.readbackSlots.forEach((slot) => {
|
||||||
|
slot.buffer.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get latestTotalPassMs(): number | undefined {
|
||||||
|
return this.latestSample?.totalPassMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishSample(
|
||||||
|
frame: number,
|
||||||
|
passes: Array<ActivePass>,
|
||||||
|
mappedRange: ArrayBuffer
|
||||||
|
): void {
|
||||||
|
const timestamps = new BigUint64Array(mappedRange);
|
||||||
|
const sample: GpuProfilerSample = {
|
||||||
|
frame,
|
||||||
|
passes: {},
|
||||||
|
totalPassMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
passes.forEach(({ endQueryIndex, name, startQueryIndex }) => {
|
||||||
|
const start = timestamps[startQueryIndex];
|
||||||
|
const end = timestamps[endQueryIndex];
|
||||||
|
if (end < start) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = Number(end - start) / 1_000_000;
|
||||||
|
sample.passes[name] = elapsedMs;
|
||||||
|
sample.totalPassMs += elapsedMs;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.latestSample = sample;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/game-loop/internal-render-size.ts
Normal file
45
src/game-loop/internal-render-size.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
const MEGAPIXEL = 1_000_000;
|
||||||
|
|
||||||
|
export interface InternalRenderSizeOptions {
|
||||||
|
clientHeight: number;
|
||||||
|
clientWidth: number;
|
||||||
|
maxTextureDimension: number;
|
||||||
|
targetAreaMegapixels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InternalRenderSize {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): number =>
|
||||||
|
Number.isFinite(targetAreaMegapixels) && targetAreaMegapixels > 0
|
||||||
|
? targetAreaMegapixels
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
export const getInternalRenderSize = ({
|
||||||
|
clientHeight,
|
||||||
|
clientWidth,
|
||||||
|
maxTextureDimension,
|
||||||
|
targetAreaMegapixels,
|
||||||
|
}: InternalRenderSizeOptions): InternalRenderSize => {
|
||||||
|
const safeClientWidth = Math.max(1, clientWidth);
|
||||||
|
const safeClientHeight = Math.max(1, clientHeight);
|
||||||
|
const safeMaxTextureDimension =
|
||||||
|
Number.isFinite(maxTextureDimension) && maxTextureDimension > 0
|
||||||
|
? Math.floor(maxTextureDimension)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const targetArea =
|
||||||
|
getSafeInternalRenderAreaMegapixels(targetAreaMegapixels) * MEGAPIXEL;
|
||||||
|
const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight));
|
||||||
|
const dimensionScale = Math.min(
|
||||||
|
areaScale,
|
||||||
|
safeMaxTextureDimension / safeClientWidth,
|
||||||
|
safeMaxTextureDimension / safeClientHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: Math.max(1, Math.round(safeClientHeight * dimensionScale)),
|
||||||
|
width: Math.max(1, Math.round(safeClientWidth * dimensionScale)),
|
||||||
|
};
|
||||||
|
};
|
||||||
106
src/game-loop/intro-prompt.ts
Normal file
106
src/game-loop/intro-prompt.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
|
const DRAW_HINT_CLASS = 'draw-hint';
|
||||||
|
|
||||||
|
export class IntroPrompt {
|
||||||
|
private introComplete = false;
|
||||||
|
private introElapsedSeconds = 0;
|
||||||
|
private introCompletedAt: number | null = null;
|
||||||
|
private hasStartedDrawing = false;
|
||||||
|
|
||||||
|
public constructor(private readonly prompt: HTMLElement) {}
|
||||||
|
|
||||||
|
public get progress(): number {
|
||||||
|
return this.introComplete
|
||||||
|
? 1
|
||||||
|
: Math.min(
|
||||||
|
1,
|
||||||
|
this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get shouldRegenerateTitleOnResize(): boolean {
|
||||||
|
return !this.introComplete && !this.hasStartedDrawing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public rewindToLeaveRemainingTime(remainingSeconds: number): void {
|
||||||
|
if (this.introComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRemainingSeconds = Number.isFinite(remainingSeconds)
|
||||||
|
? Math.max(0, remainingSeconds)
|
||||||
|
: 0;
|
||||||
|
this.introElapsedSeconds = Math.min(
|
||||||
|
this.introElapsedSeconds,
|
||||||
|
Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(deltaTime: number): void {
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
if (!this.introComplete) {
|
||||||
|
const safeDeltaTime = Number.isFinite(deltaTime) ? Math.max(0, deltaTime) : 0;
|
||||||
|
this.introElapsedSeconds += safeDeltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.introComplete &&
|
||||||
|
this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
|
||||||
|
) {
|
||||||
|
this.complete(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.introComplete ||
|
||||||
|
this.hasStartedDrawing ||
|
||||||
|
this.introCompletedAt === null ||
|
||||||
|
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
public complete(completedAt = performance.now()): void {
|
||||||
|
if (this.introComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.introComplete = true;
|
||||||
|
this.introCompletedAt = completedAt;
|
||||||
|
this.hideDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
public markStartedDrawing(): void {
|
||||||
|
this.hasStartedDrawing = true;
|
||||||
|
this.hideDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.hideDrawHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
private showDrawHint(): void {
|
||||||
|
if (this.prompt.classList.contains(DRAW_HINT_CLASS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prompt.classList.add(DRAW_HINT_CLASS);
|
||||||
|
this.prompt.innerHTML = `
|
||||||
|
<svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false">
|
||||||
|
<path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||||
|
<path class="draw-hint-stroke" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
|
||||||
|
<circle class="draw-hint-start" cx="12" cy="50" r="4" />
|
||||||
|
<circle class="draw-hint-end" cx="116" cy="42" r="7" />
|
||||||
|
</svg>
|
||||||
|
<span>Draw on the screen</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideDrawHint(): void {
|
||||||
|
this.prompt.classList.remove(DRAW_HINT_CLASS);
|
||||||
|
this.prompt.replaceChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
422
src/game-loop/intro-title-agents.ts
Normal file
422
src/game-loop/intro-title-agents.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
import { appConfig, type GardenAppConfig } from '../config';
|
||||||
|
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||||
|
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
||||||
|
|
||||||
|
interface IntroTitlePoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
tangent: number | null;
|
||||||
|
colorIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntroTitleAgentOptions {
|
||||||
|
count: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
progress?: number;
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RandomSource = () => number;
|
||||||
|
type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
|
||||||
|
|
||||||
|
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||||
|
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
|
||||||
|
pathEasing === 'linear';
|
||||||
|
|
||||||
|
export const createIntroTitleAgents = ({
|
||||||
|
count,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
progress = 0,
|
||||||
|
seed,
|
||||||
|
}: IntroTitleAgentOptions): Float32Array => {
|
||||||
|
if (count <= 0) {
|
||||||
|
return new Float32Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
const random = seed === undefined ? Math.random : createSeededRandom(seed);
|
||||||
|
const introProgress = clamp(progress, 0, 1);
|
||||||
|
const safeWidth = Math.max(1, width);
|
||||||
|
const safeHeight = Math.max(1, height);
|
||||||
|
const points = createIntroTitlePoints(safeWidth, safeHeight);
|
||||||
|
if (points.length === 0) {
|
||||||
|
return new Float32Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
|
||||||
|
const minSide = Math.min(safeWidth, safeHeight);
|
||||||
|
const targetJitter = Math.max(
|
||||||
|
appConfig.simulation.intro.minTargetJitterPx,
|
||||||
|
minSide * appConfig.simulation.intro.targetJitterSideRatio
|
||||||
|
);
|
||||||
|
const entryJitter = Math.max(
|
||||||
|
appConfig.simulation.intro.minEntryJitterPx,
|
||||||
|
minSide * appConfig.simulation.intro.entryJitterSideRatio
|
||||||
|
);
|
||||||
|
const titleRadius = points.reduce(
|
||||||
|
(radius, point) =>
|
||||||
|
Math.max(
|
||||||
|
radius,
|
||||||
|
Math.hypot(
|
||||||
|
point.x - safeWidth / 2,
|
||||||
|
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const introCircleRadius = Math.min(
|
||||||
|
Math.max(
|
||||||
|
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
|
||||||
|
minSide * appConfig.simulation.intro.circleMinSideRatio
|
||||||
|
),
|
||||||
|
minSide * appConfig.simulation.intro.circleMaxSideRatio
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const point = points[Math.floor(random() * points.length)];
|
||||||
|
const targetX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(safeWidth - 1, point.x + (random() - 0.5) * targetJitter)
|
||||||
|
);
|
||||||
|
const targetY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(safeHeight - 1, point.y + (random() - 0.5) * targetJitter)
|
||||||
|
);
|
||||||
|
const [startX, startY] = getIntroRadialStart(
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
safeWidth,
|
||||||
|
safeHeight,
|
||||||
|
introCircleRadius,
|
||||||
|
entryJitter,
|
||||||
|
random
|
||||||
|
);
|
||||||
|
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
|
||||||
|
let targetAngle = point.tangent ?? approachAngle;
|
||||||
|
if (Math.cos(targetAngle - approachAngle) < 0) {
|
||||||
|
targetAngle += Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceFraction =
|
||||||
|
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
||||||
|
const introDelay = Math.min(
|
||||||
|
appConfig.simulation.intro.targetDelayMax,
|
||||||
|
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
||||||
|
random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
||||||
|
);
|
||||||
|
const pathProgress = getIntroAgentPathProgress(introProgress, introDelay);
|
||||||
|
const initialAngle =
|
||||||
|
approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
||||||
|
const currentAngle = mixAngle(
|
||||||
|
initialAngle,
|
||||||
|
targetAngle,
|
||||||
|
smoothstep(
|
||||||
|
appConfig.simulation.intro.angleEaseStart,
|
||||||
|
appConfig.simulation.intro.angleEaseEnd,
|
||||||
|
pathProgress
|
||||||
|
)
|
||||||
|
);
|
||||||
|
writeAgentValues(data, i, {
|
||||||
|
positionX: mix(startX, targetX, pathProgress),
|
||||||
|
positionY: mix(startY, targetY, pathProgress),
|
||||||
|
angle: currentAngle,
|
||||||
|
colorIndex: point.colorIndex,
|
||||||
|
targetPositionX: targetX,
|
||||||
|
targetPositionY: targetY,
|
||||||
|
targetAngle,
|
||||||
|
introDelay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroRadialStart = (
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
jitter: number,
|
||||||
|
random: RandomSource
|
||||||
|
): [number, number] => {
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
||||||
|
const offsetX = targetX - centerX;
|
||||||
|
const offsetY = targetY - centerY;
|
||||||
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
|
const angle =
|
||||||
|
length > appConfig.simulation.intro.radialStartEpsilon
|
||||||
|
? Math.atan2(offsetY, offsetX)
|
||||||
|
: random() * Math.PI * 2;
|
||||||
|
const directionX = Math.cos(angle);
|
||||||
|
const directionY = Math.sin(angle);
|
||||||
|
const tangentX = -directionY;
|
||||||
|
const tangentY = directionX;
|
||||||
|
const tangentJitter = (random() - 0.5) * jitter;
|
||||||
|
const radialJitter =
|
||||||
|
(random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
||||||
|
const startX =
|
||||||
|
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
||||||
|
const startY =
|
||||||
|
centerY + directionY * (radius + radialJitter) + tangentY * tangentJitter;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.max(0, Math.min(width - 1, startX)),
|
||||||
|
Math.max(0, Math.min(height - 1, startY)),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createIntroTitlePoints = (
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Array<IntroTitlePoint> => {
|
||||||
|
const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
|
||||||
|
const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height)));
|
||||||
|
const maskWidth = Math.max(1, Math.round(width * maskScale));
|
||||||
|
const maskHeight = Math.max(1, Math.round(height * maskScale));
|
||||||
|
const pointScaleX = width / maskWidth;
|
||||||
|
const pointScaleY = height / maskHeight;
|
||||||
|
const maskCanvas = document.createElement('canvas');
|
||||||
|
maskCanvas.width = maskWidth;
|
||||||
|
maskCanvas.height = maskHeight;
|
||||||
|
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!context) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
|
||||||
|
context.clearRect(0, 0, maskWidth, maskHeight);
|
||||||
|
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
context.fillStyle = '#fff';
|
||||||
|
context.strokeStyle = '#fff';
|
||||||
|
context.lineJoin = 'round';
|
||||||
|
context.lineWidth = Math.max(
|
||||||
|
appConfig.simulation.intro.titleStrokeWidthMinPx,
|
||||||
|
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
|
||||||
|
);
|
||||||
|
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
||||||
|
drawIntroTitleText(
|
||||||
|
context,
|
||||||
|
maskWidth / 2,
|
||||||
|
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||||
|
letterSpacing,
|
||||||
|
'stroke'
|
||||||
|
);
|
||||||
|
drawIntroTitleText(
|
||||||
|
context,
|
||||||
|
maskWidth / 2,
|
||||||
|
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||||
|
letterSpacing,
|
||||||
|
'fill'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
|
||||||
|
const step = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const points: Array<IntroTitlePoint> = [];
|
||||||
|
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
||||||
|
context,
|
||||||
|
maskWidth,
|
||||||
|
letterSpacing
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let y = 0; y < maskHeight; y += step) {
|
||||||
|
for (let x = 0; x < maskWidth; x += step) {
|
||||||
|
const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y);
|
||||||
|
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
x: x * pointScaleX,
|
||||||
|
y: y * pointScaleY,
|
||||||
|
tangent: estimateMaskTangent(data, maskWidth, maskHeight, x, y),
|
||||||
|
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroTitleColorBoundaries = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
letterSpacing: number
|
||||||
|
): [number, number] => {
|
||||||
|
const letters = Array.from(INTRO_TITLE);
|
||||||
|
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||||
|
let x = width / 2 - totalWidth / 2;
|
||||||
|
const cutLetters = appConfig.simulation.intro.titleColorCutLetters
|
||||||
|
.map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter))))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const [firstCutLetter, secondCutLetter] = cutLetters;
|
||||||
|
const letterBoxes = letters.map((letter, index) => {
|
||||||
|
const letterWidth = context.measureText(letter).width;
|
||||||
|
const box = {
|
||||||
|
left: x,
|
||||||
|
right: x + letterWidth,
|
||||||
|
};
|
||||||
|
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||||
|
return box;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBoundaryBetweenLetters = (leftLetterIndex: number) =>
|
||||||
|
(letterBoxes[leftLetterIndex].right + letterBoxes[leftLetterIndex + 1].left) / 2;
|
||||||
|
|
||||||
|
return [
|
||||||
|
getBoundaryBetweenLetters(firstCutLetter - 1),
|
||||||
|
getBoundaryBetweenLetters(secondCutLetter - 1),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawIntroTitleText = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
letterSpacing: number,
|
||||||
|
mode: 'fill' | 'stroke'
|
||||||
|
): void => {
|
||||||
|
const letters = Array.from(INTRO_TITLE);
|
||||||
|
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||||
|
let x = centerX - totalWidth / 2;
|
||||||
|
|
||||||
|
letters.forEach((letter, index) => {
|
||||||
|
const letterWidth = context.measureText(letter).width;
|
||||||
|
const drawX = x + letterWidth / 2;
|
||||||
|
if (mode === 'fill') {
|
||||||
|
context.fillText(letter, drawX, centerY);
|
||||||
|
} else {
|
||||||
|
context.strokeText(letter, drawX, centerY);
|
||||||
|
}
|
||||||
|
x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureIntroTitleText = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
letters: Array<string>,
|
||||||
|
letterSpacing: number
|
||||||
|
): number => {
|
||||||
|
const textWidth = letters.reduce(
|
||||||
|
(width, letter) => width + context.measureText(letter).width,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return textWidth + Math.max(0, letters.length - 1) * letterSpacing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroTitleColorIndex = (x: number, boundaries: [number, number]): number => {
|
||||||
|
if (x < boundaries[0]) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x < boundaries[1]) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroTitleFontSize = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): number => {
|
||||||
|
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
|
||||||
|
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
|
||||||
|
let fontSize = Math.floor(
|
||||||
|
Math.min(
|
||||||
|
height * appConfig.simulation.intro.initialFontHeightRatio,
|
||||||
|
width * appConfig.simulation.intro.initialFontWidthRatio
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
||||||
|
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||||
|
const metrics = context.measureText(INTRO_TITLE);
|
||||||
|
const measuredHeight =
|
||||||
|
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
||||||
|
|
||||||
|
if (metrics.width <= maxWidth && measuredHeight <= maxHeight) {
|
||||||
|
return fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fontSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const estimateMaskTangent = (
|
||||||
|
data: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): number | null => {
|
||||||
|
const gradientX =
|
||||||
|
getMaskAlpha(data, width, height, x + 1, y) -
|
||||||
|
getMaskAlpha(data, width, height, x - 1, y);
|
||||||
|
const gradientY =
|
||||||
|
getMaskAlpha(data, width, height, x, y + 1) -
|
||||||
|
getMaskAlpha(data, width, height, x, y - 1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(gradientX) + Math.abs(gradientY) <
|
||||||
|
appConfig.simulation.intro.maskGradientThreshold
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.atan2(gradientX, -gradientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMaskAlpha = (
|
||||||
|
data: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): number => {
|
||||||
|
const clampedX = Math.max(0, Math.min(width - 1, Math.round(x)));
|
||||||
|
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
|
||||||
|
return data[(clampedY * width + clampedX) * 4 + 3];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntroAgentPathProgress = (introProgress: number, introDelay: number): number => {
|
||||||
|
if (introProgress <= introDelay) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProgress =
|
||||||
|
(introProgress - introDelay) /
|
||||||
|
Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay);
|
||||||
|
return easePathProgress(clamp(activeProgress, 0, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSeededRandom = (seed: number): RandomSource => {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
let value = (state += 0x6d2b79f5);
|
||||||
|
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||||
|
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||||
|
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const easePathProgress = (amount: number): number => {
|
||||||
|
if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return easeOutQuad(amount);
|
||||||
|
};
|
||||||
80
src/game-loop/perf-stats-overlay.ts
Normal file
80
src/game-loop/perf-stats-overlay.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
const PERF_STATS_REFRESH_MS = 200;
|
||||||
|
const UNAVAILABLE_STAT_TEXT = 'n/a';
|
||||||
|
const ZERO_STAT_TEXT = '0';
|
||||||
|
const ZERO_FRAME_TIME_TEXT = '0ms';
|
||||||
|
const ZERO_RESOLUTION_TEXT = '0x0';
|
||||||
|
|
||||||
|
interface PerfStatsSnapshot {
|
||||||
|
time: DOMHighResTimeStamp;
|
||||||
|
fps: number;
|
||||||
|
agentCount: number;
|
||||||
|
frameTimeMs: number;
|
||||||
|
gpuPassTimeMs?: number;
|
||||||
|
renderWidth: number;
|
||||||
|
renderHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerfStatsOverlay {
|
||||||
|
private readonly element: HTMLDivElement;
|
||||||
|
private previousUpdateTime = Number.NEGATIVE_INFINITY;
|
||||||
|
private previousText = '';
|
||||||
|
|
||||||
|
public constructor(parent: HTMLElement) {
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
this.element.className = 'perf-stats-overlay';
|
||||||
|
this.element.setAttribute('aria-hidden', 'true');
|
||||||
|
parent.append(this.element);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update({
|
||||||
|
time,
|
||||||
|
fps,
|
||||||
|
agentCount,
|
||||||
|
frameTimeMs,
|
||||||
|
gpuPassTimeMs,
|
||||||
|
renderWidth,
|
||||||
|
renderHeight,
|
||||||
|
}: PerfStatsSnapshot): void {
|
||||||
|
if (time - this.previousUpdateTime < PERF_STATS_REFRESH_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previousUpdateTime = time;
|
||||||
|
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatOptionalFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
|
||||||
|
if (text !== this.previousText) {
|
||||||
|
this.element.textContent = text;
|
||||||
|
this.previousText = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFps = (fps: number): string =>
|
||||||
|
Number.isFinite(fps) ? Math.max(0, Math.round(fps)).toString() : ZERO_STAT_TEXT;
|
||||||
|
|
||||||
|
const formatAgentCount = (agentCount: number): string =>
|
||||||
|
Number.isFinite(agentCount)
|
||||||
|
? Math.max(0, Math.round(agentCount)).toLocaleString('en-US')
|
||||||
|
: ZERO_STAT_TEXT;
|
||||||
|
|
||||||
|
const formatFrameTime = (frameTimeMs: number | undefined): string => {
|
||||||
|
if (typeof frameTimeMs !== 'number' || !Number.isFinite(frameTimeMs)) {
|
||||||
|
return ZERO_FRAME_TIME_TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeFrameTimeMs = Math.max(0, frameTimeMs);
|
||||||
|
return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOptionalFrameTime = (frameTimeMs: number | undefined): string =>
|
||||||
|
typeof frameTimeMs === 'number' && Number.isFinite(frameTimeMs)
|
||||||
|
? formatFrameTime(frameTimeMs)
|
||||||
|
: UNAVAILABLE_STAT_TEXT;
|
||||||
|
|
||||||
|
const formatResolution = (width: number, height: number): string =>
|
||||||
|
Number.isFinite(width) && Number.isFinite(height)
|
||||||
|
? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}`
|
||||||
|
: ZERO_RESOLUTION_TEXT;
|
||||||
249
src/game-loop/pointer-input.ts
Normal file
249
src/game-loop/pointer-input.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { GardenAudio } from '../audio/garden-audio';
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { activeVibe } from '../settings';
|
||||||
|
import { BrushStrokeSmoother } from './brush-stroke-smoother';
|
||||||
|
import { type StrokeSegment } from './game-loop-types';
|
||||||
|
import { getMirroredStrokeSegments } from './stroke-mirroring';
|
||||||
|
import { type StrokeOutput } from './stroke-output';
|
||||||
|
|
||||||
|
interface GardenPointerInputOptions {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
audio: GardenAudio;
|
||||||
|
strokeOutput: StrokeOutput;
|
||||||
|
getCanvasPixelRatio: () => number;
|
||||||
|
getMirrorSegmentCount: () => number;
|
||||||
|
onStartDrawing: () => void;
|
||||||
|
onEraseGestureEnded: () => void;
|
||||||
|
spawnStrokeAgents: (from: vec2, to: vec2) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PointerSample {
|
||||||
|
position: vec2;
|
||||||
|
previousPosition: vec2;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
timeStamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GardenPointerInput {
|
||||||
|
private readonly brushSmoother: BrushStrokeSmoother;
|
||||||
|
private activePointerId: number | null = null;
|
||||||
|
private lastPointerPosition: vec2 | null = null;
|
||||||
|
private lastPointerEventTimeMs: number | null = null;
|
||||||
|
private isErasing = false;
|
||||||
|
|
||||||
|
public constructor(private readonly options: GardenPointerInputOptions) {
|
||||||
|
this.brushSmoother = new BrushStrokeSmoother({
|
||||||
|
getCanvasPixelRatio: options.getCanvasPixelRatio,
|
||||||
|
getMirrorSegmentCount: options.getMirrorSegmentCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public attach(): void {
|
||||||
|
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.addEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public detach(): void {
|
||||||
|
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||||
|
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||||
|
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||||
|
this.canvas.removeEventListener('pointercancel', this.onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEraseMode(isErasing: boolean): void {
|
||||||
|
this.isErasing = isErasing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipesIfIdle(): void {
|
||||||
|
if (this.isSwipeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.strokeOutput.clearSwipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public scaleLastPointerPosition(scale: vec2): void {
|
||||||
|
if (this.lastPointerPosition !== null) {
|
||||||
|
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.brushSmoother.scale(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isSwipeActive(): boolean {
|
||||||
|
return this.activePointerId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isEraseMode(): boolean {
|
||||||
|
return this.isErasing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get canvas(): HTMLCanvasElement {
|
||||||
|
return this.options.canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (this.activePointerId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.audio.beginGesture();
|
||||||
|
this.options.onStartDrawing();
|
||||||
|
this.activePointerId = event.pointerId;
|
||||||
|
this.canvas.setPointerCapture(event.pointerId);
|
||||||
|
this.lastPointerPosition = null;
|
||||||
|
this.lastPointerEventTimeMs = null;
|
||||||
|
this.brushSmoother.clear();
|
||||||
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (event.pointerId !== this.activePointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => {
|
||||||
|
this.addSwipeAt(coalescedEvent);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly onPointerUp = (event: PointerEvent) => {
|
||||||
|
if (event.pointerId !== this.activePointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
|
this.finishBrushStroke();
|
||||||
|
this.options.audio.endGesture();
|
||||||
|
if (this.isErasing) {
|
||||||
|
this.options.onEraseGestureEnded();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (this.canvas.hasPointerCapture(event.pointerId)) {
|
||||||
|
this.canvas.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.activePointerId = null;
|
||||||
|
this.lastPointerPosition = null;
|
||||||
|
this.lastPointerEventTimeMs = null;
|
||||||
|
this.brushSmoother.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||||
|
const sample = this.getPointerSample(event);
|
||||||
|
|
||||||
|
if (this.isErasing) {
|
||||||
|
this.addEraseSample(sample);
|
||||||
|
} else {
|
||||||
|
this.addBrushSample(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.emitAudio !== false) {
|
||||||
|
this.emitStrokeAudio(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastPointerPosition = sample.position;
|
||||||
|
this.lastPointerEventTimeMs = sample.timeStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPointerSample(event: PointerEvent): PointerSample {
|
||||||
|
const position = this.getCanvasPointerPosition(event);
|
||||||
|
const previousPosition = this.lastPointerPosition ?? position;
|
||||||
|
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||||
|
const elapsedSeconds = Math.max(
|
||||||
|
appConfig.deltaTime.minDeltaTimeSeconds,
|
||||||
|
(event.timeStamp - previousTimeMs) / 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
position,
|
||||||
|
previousPosition,
|
||||||
|
elapsedSeconds,
|
||||||
|
timeStamp: event.timeStamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private addBrushSample(sample: PointerSample): void {
|
||||||
|
this.emitBrushSegments(this.brushSmoother.addSample(sample.position));
|
||||||
|
}
|
||||||
|
|
||||||
|
private addEraseSample(sample: PointerSample): void {
|
||||||
|
this.options.strokeOutput.addEraseSegment(sample.previousPosition, sample.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitStrokeAudio(sample: PointerSample): void {
|
||||||
|
this.options.audio.stroke({
|
||||||
|
vibe: activeVibe,
|
||||||
|
from: sample.previousPosition,
|
||||||
|
to: sample.position,
|
||||||
|
canvasSize: [this.canvas.width, this.canvas.height],
|
||||||
|
isErasing: this.isErasing,
|
||||||
|
elapsedSeconds: sample.elapsedSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const xScale = getSafePixelRatio(this.canvas.width / rect.width);
|
||||||
|
const yScale = getSafePixelRatio(this.canvas.height / rect.height);
|
||||||
|
return vec2.fromValues(
|
||||||
|
(event.clientX - rect.left) * xScale,
|
||||||
|
(event.clientY - rect.top) * yScale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitBrushSegments(segments: Array<StrokeSegment>): void {
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
|
||||||
|
this.options.strokeOutput.addBrushSegment(
|
||||||
|
mirroredSegment.from,
|
||||||
|
mirroredSegment.to
|
||||||
|
);
|
||||||
|
this.options.spawnStrokeAgents(mirroredSegment.from, mirroredSegment.to);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishBrushStroke(): void {
|
||||||
|
if (this.isErasing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitBrushSegments(this.brushSmoother.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
|
||||||
|
const getCoalescedEvents = (
|
||||||
|
event as PointerEvent & { getCoalescedEvents?: () => Array<PointerEvent> }
|
||||||
|
).getCoalescedEvents;
|
||||||
|
const coalescedEvents =
|
||||||
|
typeof getCoalescedEvents === 'function' ? getCoalescedEvents.call(event) : [];
|
||||||
|
|
||||||
|
if (coalescedEvents.length === 0) {
|
||||||
|
return [event];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEvent = coalescedEvents[coalescedEvents.length - 1];
|
||||||
|
return isSamePointerSample(lastEvent, event)
|
||||||
|
? coalescedEvents
|
||||||
|
: [...coalescedEvents, event];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMirroredSegments(from: vec2, to: vec2): Array<StrokeSegment> {
|
||||||
|
return getMirroredStrokeSegments(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
vec2.fromValues(this.canvas.width, this.canvas.height),
|
||||||
|
this.options.getMirrorSegmentCount()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||||
|
left.clientX === right.clientX &&
|
||||||
|
left.clientY === right.clientY &&
|
||||||
|
left.buttons === right.buttons;
|
||||||
144
src/game-loop/simulation-frame.ts
Normal file
144
src/game-loop/simulation-frame.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||||
|
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||||
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
|
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
|
import { settings } from '../settings';
|
||||||
|
import { CanvasReadbackRequest } from './game-loop-types';
|
||||||
|
import { GpuProfiler } from './gpu-profiler';
|
||||||
|
import { SimulationTextures } from './simulation-textures';
|
||||||
|
|
||||||
|
interface SimulationFramePipelines {
|
||||||
|
agentPipeline: AgentPipeline;
|
||||||
|
brushPipeline: BrushPipeline;
|
||||||
|
eraserAgentPipeline: EraserAgentPipeline;
|
||||||
|
eraserTexturePipeline: EraserTexturePipeline;
|
||||||
|
diffusionPipeline: DiffusionPipeline;
|
||||||
|
renderPipeline: RenderPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SimulationFrameRenderer {
|
||||||
|
private sourceActiveFramesRemaining = 0;
|
||||||
|
private sourceMapsCleared = true;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
private readonly textures: SimulationTextures,
|
||||||
|
private readonly pipelines: SimulationFramePipelines,
|
||||||
|
private readonly gpuProfiler: GpuProfiler | null = null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public resetSourceMapActivity(): void {
|
||||||
|
this.sourceActiveFramesRemaining = 0;
|
||||||
|
this.sourceMapsCleared = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isSourceMapActive(): boolean {
|
||||||
|
return this.sourceActiveFramesRemaining > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public execute(
|
||||||
|
isErasing: boolean,
|
||||||
|
canvasReadbackRequest?: CanvasReadbackRequest | null
|
||||||
|
): void {
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
this.gpuProfiler?.beginFrame();
|
||||||
|
|
||||||
|
// Clear the deposit map up-front so agents write fresh deposits each frame
|
||||||
|
// and diffuse sees only this frame's contributions added to trailMapA.
|
||||||
|
this.textures.clearDepositMap(commandEncoder);
|
||||||
|
let wroteSourceMap = false;
|
||||||
|
if (isErasing) {
|
||||||
|
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
||||||
|
const eraserMask = this.textures.eraserMask.getTextureView();
|
||||||
|
// Erase trailMapA directly — it's what agent and diffuse will read.
|
||||||
|
this.pipelines.eraserTexturePipeline.executeCombined(
|
||||||
|
commandEncoder,
|
||||||
|
eraserMask,
|
||||||
|
this.textures.sourceMapA.getTextureView(),
|
||||||
|
this.textures.trailMapA.getTextureView(),
|
||||||
|
this.gpuProfiler?.timestampWrites('eraserTexture')
|
||||||
|
);
|
||||||
|
this.pipelines.eraserAgentPipeline.execute(
|
||||||
|
commandEncoder,
|
||||||
|
eraserMask,
|
||||||
|
this.gpuProfiler?.timestampWrites('eraserAgent')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wroteSourceMap = this.pipelines.brushPipeline.executeSource(
|
||||||
|
commandEncoder,
|
||||||
|
this.textures.sourceMapA.getTextureView(),
|
||||||
|
this.gpuProfiler?.timestampWrites('brush')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wroteSourceMap) {
|
||||||
|
this.sourceActiveFramesRemaining = getSourceActiveFrameCount();
|
||||||
|
this.sourceMapsCleared = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSourceMap = this.isSourceMapActive;
|
||||||
|
if (!useSourceMap && !this.sourceMapsCleared) {
|
||||||
|
this.textures.clearSourceMaps(commandEncoder);
|
||||||
|
this.sourceMapsCleared = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pipelines.agentPipeline.execute(
|
||||||
|
commandEncoder,
|
||||||
|
this.textures.trailMapA.getTextureView(),
|
||||||
|
this.textures.depositMap.getTextureView(),
|
||||||
|
this.gpuProfiler?.timestampWrites('agent')
|
||||||
|
);
|
||||||
|
this.pipelines.diffusionPipeline.execute(
|
||||||
|
commandEncoder,
|
||||||
|
this.textures.trailMapA.getTextureView(),
|
||||||
|
this.textures.trailMapB.getTextureView(),
|
||||||
|
this.textures.trailMapA.getSize(),
|
||||||
|
this.textures.depositMap.getTextureView(),
|
||||||
|
this.gpuProfiler?.timestampWrites('trailDiffusion')
|
||||||
|
);
|
||||||
|
const canvasTexture = this.pipelines.renderPipeline.execute(
|
||||||
|
commandEncoder,
|
||||||
|
this.textures.trailMapB.getTextureView(),
|
||||||
|
this.textures.sourceMapA.getTextureView(),
|
||||||
|
useSourceMap,
|
||||||
|
this.gpuProfiler?.timestampWrites('render')
|
||||||
|
);
|
||||||
|
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
|
||||||
|
|
||||||
|
if (useSourceMap) {
|
||||||
|
this.pipelines.diffusionPipeline.execute(
|
||||||
|
commandEncoder,
|
||||||
|
this.textures.sourceMapA.getTextureView(),
|
||||||
|
this.textures.sourceMapB.getTextureView(),
|
||||||
|
this.textures.sourceMapB.getSize(),
|
||||||
|
null,
|
||||||
|
this.gpuProfiler?.timestampWrites('sourceDiffusion')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const afterGpuProfileSubmit = this.gpuProfiler?.resolve(commandEncoder);
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
afterGpuProfileSubmit?.();
|
||||||
|
canvasReadbackRequest?.afterSubmit();
|
||||||
|
// After this frame's diffuse, trailMapB holds the fresh trail; swap so
|
||||||
|
// trailMapA is "current trail" again for the next frame and any external
|
||||||
|
// readers (e.g. export snapshot).
|
||||||
|
this.textures.swapTrailMaps();
|
||||||
|
if (useSourceMap) {
|
||||||
|
this.textures.swapSourceMaps();
|
||||||
|
this.sourceActiveFramesRemaining -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSourceActiveFrameCount = (): number => {
|
||||||
|
const frameCount =
|
||||||
|
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
|
||||||
|
if (Number.isFinite(frameCount) && frameCount > 0) {
|
||||||
|
return Math.ceil(frameCount);
|
||||||
|
}
|
||||||
|
return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite);
|
||||||
|
};
|
||||||
162
src/game-loop/simulation-textures.ts
Normal file
162
src/game-loop/simulation-textures.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
|
||||||
|
import {
|
||||||
|
ResizableTexture,
|
||||||
|
type PendingTextureResize,
|
||||||
|
} from '../utils/graphics/resizable-texture';
|
||||||
|
|
||||||
|
export class SimulationTextures {
|
||||||
|
// trailMapA holds the current trail (read by agent and diffuse). trailMapB
|
||||||
|
// receives the diffuse output; the two swap each frame so the freshly
|
||||||
|
// diffused texture becomes trailMapA for the next frame.
|
||||||
|
public trailMapA: ResizableTexture;
|
||||||
|
public trailMapB: ResizableTexture;
|
||||||
|
// Per-frame last-writer deposit map: cleared each frame, written sparsely by
|
||||||
|
// agents, then read by diffuse alongside trailMapA.
|
||||||
|
public readonly depositMap: ResizableTexture;
|
||||||
|
public readonly eraserMask: ResizableTexture;
|
||||||
|
public sourceMapA: ResizableTexture;
|
||||||
|
public sourceMapB: ResizableTexture;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
canvasSize: vec2
|
||||||
|
) {
|
||||||
|
this.trailMapA = this.createTexture(canvasSize);
|
||||||
|
this.trailMapB = this.createTexture(canvasSize);
|
||||||
|
this.depositMap = this.createTexture(canvasSize);
|
||||||
|
this.sourceMapA = this.createTexture(canvasSize);
|
||||||
|
this.sourceMapB = this.createTexture(canvasSize);
|
||||||
|
this.eraserMask = this.createEraserMask(canvasSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resizeTo(nextSize: vec2): vec2 | null {
|
||||||
|
const previousSize = this.trailMapA.getSize();
|
||||||
|
if (vec2.equals(previousSize, nextSize)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = vec2.div(vec2.create(), nextSize, previousSize);
|
||||||
|
const resizes = [
|
||||||
|
this.trailMapA,
|
||||||
|
this.trailMapB,
|
||||||
|
this.depositMap,
|
||||||
|
this.sourceMapA,
|
||||||
|
this.sourceMapB,
|
||||||
|
this.eraserMask,
|
||||||
|
]
|
||||||
|
.map((texture): [ResizableTexture, PendingTextureResize] | null => {
|
||||||
|
const resize = texture.prepareResize(nextSize);
|
||||||
|
return resize ? [texture, resize] : null;
|
||||||
|
})
|
||||||
|
.filter((resize): resize is [ResizableTexture, PendingTextureResize] =>
|
||||||
|
Boolean(resize)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resizes.length > 0) {
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
resizes.forEach(([texture, resize]) => {
|
||||||
|
texture.encodeResize(commandEncoder, resize);
|
||||||
|
});
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
resizes.forEach(([texture, resize]) => {
|
||||||
|
texture.commitResize(resize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
[
|
||||||
|
this.trailMapA,
|
||||||
|
this.trailMapB,
|
||||||
|
this.depositMap,
|
||||||
|
this.sourceMapA,
|
||||||
|
this.sourceMapB,
|
||||||
|
this.eraserMask,
|
||||||
|
].forEach((texture) => {
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: texture.getTextureView(),
|
||||||
|
clearValue: appConfig.simulation.clearColor,
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
passEncoder.end();
|
||||||
|
});
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearDepositMap(commandEncoder: GPUCommandEncoder): void {
|
||||||
|
// Hardware fast-clear via a render pass with loadOp 'clear' and an empty
|
||||||
|
// body. Cheaper than copyTextureToTexture and writes no actual color data
|
||||||
|
// on tile-based GPUs.
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: this.depositMap.getTextureView(),
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
passEncoder.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
public swapTrailMaps(): void {
|
||||||
|
[this.trailMapA, this.trailMapB] = [this.trailMapB, this.trailMapA];
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
|
||||||
|
// Only sourceMapA needs clearing — sourceMapB gets fully overwritten by
|
||||||
|
// the diffusion pass on the next active frame before it's ever sampled.
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: this.sourceMapA.getTextureView(),
|
||||||
|
clearValue: appConfig.simulation.clearColor,
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
passEncoder.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
public swapSourceMaps(): void {
|
||||||
|
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.trailMapA.destroy();
|
||||||
|
this.trailMapB.destroy();
|
||||||
|
this.depositMap.destroy();
|
||||||
|
this.sourceMapA.destroy();
|
||||||
|
this.sourceMapB.destroy();
|
||||||
|
this.eraserMask.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTexture(size: vec2): ResizableTexture {
|
||||||
|
return new ResizableTexture(this.device, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEraserMask(size: vec2): ResizableTexture {
|
||||||
|
return new ResizableTexture(this.device, size, {
|
||||||
|
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
format: ERASER_MASK_TEXTURE_FORMAT,
|
||||||
|
usage:
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||||
|
GPUTextureUsage.COPY_SRC |
|
||||||
|
GPUTextureUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/game-loop/stroke-mirroring.ts
Normal file
42
src/game-loop/stroke-mirroring.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { type StrokeSegment } from './game-loop-types';
|
||||||
|
|
||||||
|
export const getMirroredStrokeSegments = (
|
||||||
|
from: vec2,
|
||||||
|
to: vec2,
|
||||||
|
canvasSize: vec2,
|
||||||
|
segmentCount: number
|
||||||
|
): Array<StrokeSegment> => {
|
||||||
|
if (segmentCount <= 1) {
|
||||||
|
return [{ from, to }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = vec2.fromValues(canvasSize[0] / 2, canvasSize[1] / 2);
|
||||||
|
const angleStep = (Math.PI * 2) / segmentCount;
|
||||||
|
const segments: Array<StrokeSegment> = [];
|
||||||
|
for (let i = 0; i < segmentCount; i++) {
|
||||||
|
const angle = angleStep * i;
|
||||||
|
segments.push({
|
||||||
|
from: rotatePointAround(from, center, angle),
|
||||||
|
to: rotatePointAround(to, center, angle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||||
|
if (angle === 0) {
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetX = point[0] - center[0];
|
||||||
|
const offsetY = point[1] - center[1];
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
return vec2.fromValues(
|
||||||
|
center[0] + offsetX * cos - offsetY * sin,
|
||||||
|
center[1] + offsetX * sin + offsetY * cos
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/game-loop/stroke-output.ts
Normal file
34
src/game-loop/stroke-output.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { type BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
|
import { type EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
|
import { type EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
|
|
||||||
|
export interface StrokeOutput {
|
||||||
|
addBrushSegment(from: vec2, to: vec2): void;
|
||||||
|
addEraseSegment(from: vec2, to: vec2): void;
|
||||||
|
clearSwipes(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PipelineStrokeOutput implements StrokeOutput {
|
||||||
|
public constructor(
|
||||||
|
private readonly brushPipeline: BrushPipeline,
|
||||||
|
private readonly eraserAgentPipeline: EraserAgentPipeline,
|
||||||
|
private readonly eraserTexturePipeline: EraserTexturePipeline
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public addBrushSegment(from: vec2, to: vec2): void {
|
||||||
|
this.brushPipeline.addSwipeSegment(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEraseSegment(from: vec2, to: vec2): void {
|
||||||
|
this.eraserAgentPipeline.addSwipeSegment(from, to);
|
||||||
|
this.eraserTexturePipeline.addSwipeSegment(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipes(): void {
|
||||||
|
this.brushPipeline.clearSwipes();
|
||||||
|
this.eraserAgentPipeline.clearSwipes();
|
||||||
|
this.eraserTexturePipeline.clearSwipes();
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/game-loop/toolbar-contrast-monitor.ts
Normal file
365
src/game-loop/toolbar-contrast-monitor.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { clamp01 } from '../utils/math';
|
||||||
|
import type { CanvasReadbackRequest } from './game-loop-types';
|
||||||
|
|
||||||
|
interface CanvasSamplePoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CanvasSampleRegion {
|
||||||
|
bytesPerRow: number;
|
||||||
|
height: number;
|
||||||
|
origin: CanvasSamplePoint;
|
||||||
|
sampleOffsets: Array<number>;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolbarContrastMetrics {
|
||||||
|
averageLuminance: number;
|
||||||
|
backgroundOpacity: number;
|
||||||
|
brightRatio: number;
|
||||||
|
lowContrastRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
||||||
|
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
||||||
|
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
|
||||||
|
|
||||||
|
const getLinearChannel = (channel: number): number => {
|
||||||
|
const normalized = channel / 255;
|
||||||
|
return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
|
||||||
|
? normalized / appConfig.toolbar.contrast.linearChannelDivisor
|
||||||
|
: ((normalized + appConfig.toolbar.contrast.linearChannelOffset) /
|
||||||
|
appConfig.toolbar.contrast.linearChannelScale) **
|
||||||
|
appConfig.toolbar.contrast.linearChannelGamma;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
|
||||||
|
appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) +
|
||||||
|
appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
|
||||||
|
appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
|
||||||
|
|
||||||
|
const getToolbarContrastMetrics = (
|
||||||
|
pixels: Uint8Array,
|
||||||
|
sampleOffsets: ReadonlyArray<number>,
|
||||||
|
isBgra: boolean
|
||||||
|
): ToolbarContrastMetrics => {
|
||||||
|
const count = sampleOffsets.filter(
|
||||||
|
(offset) =>
|
||||||
|
offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
|
||||||
|
).length;
|
||||||
|
if (count === 0) {
|
||||||
|
return {
|
||||||
|
averageLuminance: 0,
|
||||||
|
backgroundOpacity: 0,
|
||||||
|
brightRatio: 0,
|
||||||
|
lowContrastRatio: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let luminanceTotal = 0;
|
||||||
|
let brightCount = 0;
|
||||||
|
let lowContrastCount = 0;
|
||||||
|
|
||||||
|
sampleOffsets.forEach((offset) => {
|
||||||
|
if (
|
||||||
|
offset < 0 ||
|
||||||
|
offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const red = pixels[offset + (isBgra ? 2 : 0)];
|
||||||
|
const green = pixels[offset + 1];
|
||||||
|
const blue = pixels[offset + (isBgra ? 0 : 2)];
|
||||||
|
const luminance = getRelativeLuminance(red, green, blue);
|
||||||
|
const contrastWithWhite =
|
||||||
|
appConfig.toolbar.contrast.whiteContrastNumerator /
|
||||||
|
(luminance + appConfig.toolbar.contrast.contrastOffset);
|
||||||
|
|
||||||
|
luminanceTotal += luminance;
|
||||||
|
if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) {
|
||||||
|
brightCount++;
|
||||||
|
}
|
||||||
|
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
|
||||||
|
lowContrastCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const averageLuminance = luminanceTotal / count;
|
||||||
|
const brightRatio = brightCount / count;
|
||||||
|
const lowContrastRatio = lowContrastCount / count;
|
||||||
|
const backgroundStrength = clamp01(
|
||||||
|
Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) /
|
||||||
|
appConfig.toolbar.contrast.luminanceRange +
|
||||||
|
brightRatio * appConfig.toolbar.contrast.brightWeight +
|
||||||
|
lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight
|
||||||
|
);
|
||||||
|
const backgroundOpacity =
|
||||||
|
backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax;
|
||||||
|
|
||||||
|
return {
|
||||||
|
averageLuminance,
|
||||||
|
backgroundOpacity,
|
||||||
|
brightRatio,
|
||||||
|
lowContrastRatio,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ToolbarContrastMonitor {
|
||||||
|
private readonly isBgra: boolean;
|
||||||
|
private isDestroyed = false;
|
||||||
|
private isReadbackPending = false;
|
||||||
|
private lastSampleAt = Number.NEGATIVE_INFINITY;
|
||||||
|
private readbackBuffer: GPUBuffer | null = null;
|
||||||
|
private readbackBufferSize = 0;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly canvas: HTMLCanvasElement,
|
||||||
|
private readonly toolbar: HTMLElement,
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
canvasFormat: GPUTextureFormat
|
||||||
|
) {
|
||||||
|
this.isBgra = canvasFormat === 'bgra8unorm';
|
||||||
|
}
|
||||||
|
|
||||||
|
public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null {
|
||||||
|
if (
|
||||||
|
this.isDestroyed ||
|
||||||
|
this.isReadbackPending ||
|
||||||
|
time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRegion = this.getSampleRegion();
|
||||||
|
if (sampleRegion.sampleOffsets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bufferSize = sampleRegion.bytesPerRow * sampleRegion.height;
|
||||||
|
const buffer = this.getReadbackBuffer(bufferSize);
|
||||||
|
if (!buffer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isReadbackPending = true;
|
||||||
|
this.lastSampleAt = time;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
let isEncoded = false;
|
||||||
|
const cancel = () => {
|
||||||
|
if (isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCancelled = true;
|
||||||
|
this.isReadbackPending = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
encode: (commandEncoder, texture) => {
|
||||||
|
if (isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
commandEncoder.copyTextureToBuffer(
|
||||||
|
{
|
||||||
|
origin: sampleRegion.origin,
|
||||||
|
texture,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buffer,
|
||||||
|
bytesPerRow: sampleRegion.bytesPerRow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
depthOrArrayLayers: 1,
|
||||||
|
height: sampleRegion.height,
|
||||||
|
width: sampleRegion.width,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
isEncoded = true;
|
||||||
|
} catch {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterSubmit: () => {
|
||||||
|
if (isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEncoded) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.readBuffer(buffer, sampleRegion.sampleOffsets);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.isDestroyed = true;
|
||||||
|
this.readbackBuffer?.destroy();
|
||||||
|
this.readbackBuffer = null;
|
||||||
|
this.readbackBufferSize = 0;
|
||||||
|
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
|
||||||
|
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
|
||||||
|
const safeBackgroundOpacity = Math.min(
|
||||||
|
appConfig.toolbar.contrast.backgroundOpacityMax,
|
||||||
|
Math.max(0, backgroundOpacity)
|
||||||
|
);
|
||||||
|
const backgroundStrength =
|
||||||
|
appConfig.toolbar.contrast.backgroundOpacityMax > 0
|
||||||
|
? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
this.toolbar.style.setProperty(
|
||||||
|
TOOLBAR_BACKGROUND_OPACITY_PROPERTY,
|
||||||
|
`${(safeBackgroundOpacity * 100).toFixed(1)}%`
|
||||||
|
);
|
||||||
|
this.toolbar.style.setProperty(
|
||||||
|
TOOLBAR_BACKGROUND_STRENGTH_PROPERTY,
|
||||||
|
backgroundStrength.toFixed(3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSampleRegion(): CanvasSampleRegion {
|
||||||
|
const emptyRegion = {
|
||||||
|
bytesPerRow: 0,
|
||||||
|
height: 0,
|
||||||
|
origin: { x: 0, y: 0 },
|
||||||
|
sampleOffsets: [],
|
||||||
|
width: 0,
|
||||||
|
};
|
||||||
|
const canvasRect = this.canvas.getBoundingClientRect();
|
||||||
|
const toolbarRect = this.toolbar.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
canvasRect.width <= 0 ||
|
||||||
|
canvasRect.height <= 0 ||
|
||||||
|
toolbarRect.width <= 0 ||
|
||||||
|
toolbarRect.height <= 0
|
||||||
|
) {
|
||||||
|
return emptyRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = Math.max(canvasRect.left, toolbarRect.left);
|
||||||
|
const right = Math.min(canvasRect.right, toolbarRect.right);
|
||||||
|
const top = Math.max(canvasRect.top, toolbarRect.top);
|
||||||
|
const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom);
|
||||||
|
if (left >= right || top >= bottom) {
|
||||||
|
return emptyRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xScale = this.canvas.width / canvasRect.width;
|
||||||
|
const yScale = this.canvas.height / canvasRect.height;
|
||||||
|
const cssWidth = right - left;
|
||||||
|
const cssHeight = bottom - top;
|
||||||
|
const origin = {
|
||||||
|
x: Math.max(0, Math.floor((left - canvasRect.left) * xScale)),
|
||||||
|
y: Math.max(0, Math.floor((top - canvasRect.top) * yScale)),
|
||||||
|
};
|
||||||
|
const regionRight = Math.min(
|
||||||
|
this.canvas.width,
|
||||||
|
Math.ceil((right - canvasRect.left) * xScale)
|
||||||
|
);
|
||||||
|
const regionBottom = Math.min(
|
||||||
|
this.canvas.height,
|
||||||
|
Math.ceil((bottom - canvasRect.top) * yScale)
|
||||||
|
);
|
||||||
|
const width = Math.max(0, regionRight - origin.x);
|
||||||
|
const height = Math.max(0, regionBottom - origin.y);
|
||||||
|
if (width === 0 || height === 0) {
|
||||||
|
return emptyRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesPerRow = alignTo(
|
||||||
|
width * appConfig.toolbar.contrast.bytesPerSample,
|
||||||
|
GPU_COPY_BYTES_PER_ROW_ALIGNMENT
|
||||||
|
);
|
||||||
|
const points = new Map<string, CanvasSamplePoint>();
|
||||||
|
|
||||||
|
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
|
||||||
|
const cssY =
|
||||||
|
top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
|
||||||
|
const y = Math.min(
|
||||||
|
this.canvas.height - 1,
|
||||||
|
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
|
||||||
|
const cssX =
|
||||||
|
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
|
||||||
|
const x = Math.min(
|
||||||
|
this.canvas.width - 1,
|
||||||
|
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
|
||||||
|
);
|
||||||
|
points.set(`${x}:${y}`, { x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytesPerRow,
|
||||||
|
height,
|
||||||
|
origin,
|
||||||
|
sampleOffsets: [...points.values()].map(
|
||||||
|
(point) =>
|
||||||
|
(point.y - origin.y) * bytesPerRow +
|
||||||
|
(point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReadbackBuffer(size: number): GPUBuffer | null {
|
||||||
|
if (this.readbackBuffer && this.readbackBufferSize >= size) {
|
||||||
|
return this.readbackBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readbackBuffer?.destroy();
|
||||||
|
try {
|
||||||
|
this.readbackBuffer = this.device.createBuffer({
|
||||||
|
size,
|
||||||
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||||
|
});
|
||||||
|
this.readbackBufferSize = size;
|
||||||
|
return this.readbackBuffer;
|
||||||
|
} catch {
|
||||||
|
this.readbackBuffer = null;
|
||||||
|
this.readbackBufferSize = 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readBuffer(
|
||||||
|
buffer: GPUBuffer,
|
||||||
|
sampleOffsets: Array<number>
|
||||||
|
): Promise<void> {
|
||||||
|
let isMapped = false;
|
||||||
|
try {
|
||||||
|
await buffer.mapAsync(GPUMapMode.READ);
|
||||||
|
isMapped = true;
|
||||||
|
|
||||||
|
if (!this.isDestroyed) {
|
||||||
|
const pixels = new Uint8Array(buffer.getMappedRange());
|
||||||
|
const metrics = getToolbarContrastMetrics(pixels, sampleOffsets, this.isBgra);
|
||||||
|
this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Readback is an enhancement; leave rendering alone if the GPU rejects it.
|
||||||
|
} finally {
|
||||||
|
if (isMapped) {
|
||||||
|
buffer.unmap();
|
||||||
|
}
|
||||||
|
this.isReadbackPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignTo = (value: number, alignment: number): number =>
|
||||||
|
Math.ceil(value / alignment) * alignment;
|
||||||
49
src/pipelines/agents/agent-dispatch.ts
Normal file
49
src/pipelines/agents/agent-dispatch.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const;
|
||||||
|
|
||||||
|
export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number];
|
||||||
|
|
||||||
|
const AGENT_WORKGROUP_SIZE_TARGETS = {
|
||||||
|
// Keep shader-specific targets conservative. Using the device maximum can
|
||||||
|
// hurt occupancy and makes compaction's workgroup scan more expensive.
|
||||||
|
simulation: 256,
|
||||||
|
eraser: 256,
|
||||||
|
resize: 256,
|
||||||
|
compaction: 256,
|
||||||
|
} satisfies Record<AgentWorkgroupKind, number>;
|
||||||
|
|
||||||
|
export const getAgentWorkgroupSize = (
|
||||||
|
device: GPUDevice,
|
||||||
|
kind: AgentWorkgroupKind = 'simulation'
|
||||||
|
): number => {
|
||||||
|
const deviceLimit = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
Math.min(
|
||||||
|
device.limits.maxComputeInvocationsPerWorkgroup,
|
||||||
|
device.limits.maxComputeWorkgroupSizeX
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinAgentWorkgroupSize = (device: GPUDevice): number =>
|
||||||
|
Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind)));
|
||||||
|
|
||||||
|
export const substituteAgentWorkgroupSize = (
|
||||||
|
device: GPUDevice,
|
||||||
|
shaderCode: string,
|
||||||
|
kind: AgentWorkgroupKind = 'simulation'
|
||||||
|
): string =>
|
||||||
|
shaderCode.replaceAll(
|
||||||
|
'__AGENT_WORKGROUP_SIZE__',
|
||||||
|
String(getAgentWorkgroupSize(device, kind))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dispatchAgentWorkgroups = (
|
||||||
|
passEncoder: GPUComputePassEncoder,
|
||||||
|
workgroupSize: number,
|
||||||
|
agentCount: number
|
||||||
|
): void => {
|
||||||
|
passEncoder.dispatchWorkgroups(Math.ceil(agentCount / workgroupSize), 1);
|
||||||
|
};
|
||||||
97
src/pipelines/agents/agent-generation/agent-compaction.wgsl
Normal file
97
src/pipelines/agents/agent-generation/agent-compaction.wgsl
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
struct Settings {
|
||||||
|
agentCount: u32,
|
||||||
|
padding0: u32,
|
||||||
|
padding1: u32,
|
||||||
|
padding2: u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Counters {
|
||||||
|
aliveAgentCount: atomic<u32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u;
|
||||||
|
|
||||||
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
||||||
|
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
|
||||||
|
|
||||||
|
var<workgroup> workgroupCompactedOffset: u32;
|
||||||
|
var<workgroup> scanData: array<u32, agentWorkgroupSize>;
|
||||||
|
var<workgroup> clearAliveAgentCount: u32;
|
||||||
|
|
||||||
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
|
fn main(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(local_invocation_id) local_id: vec3<u32>
|
||||||
|
) {
|
||||||
|
let id = get_id(global_id);
|
||||||
|
let lid = local_id.x;
|
||||||
|
|
||||||
|
var isAlive = false;
|
||||||
|
var agent: Agent;
|
||||||
|
if id < settings.agentCount {
|
||||||
|
isAlive = agents[id].colorIndex >= 0.0 && agents[id].colorIndex < 2.5;
|
||||||
|
if isAlive {
|
||||||
|
agent = agents[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hillis-Steele inclusive prefix sum across the workgroup. Replaces a
|
||||||
|
// per-thread atomicAdd to a workgroup counter, eliminating serialization
|
||||||
|
// on dense workgroups.
|
||||||
|
scanData[lid] = select(0u, 1u, isAlive);
|
||||||
|
workgroupBarrier();
|
||||||
|
|
||||||
|
var offset: u32 = 1u;
|
||||||
|
while offset < agentWorkgroupSize {
|
||||||
|
let own = scanData[lid];
|
||||||
|
var contribution: u32 = 0u;
|
||||||
|
if lid >= offset {
|
||||||
|
contribution = scanData[lid - offset];
|
||||||
|
}
|
||||||
|
workgroupBarrier();
|
||||||
|
scanData[lid] = own + contribution;
|
||||||
|
workgroupBarrier();
|
||||||
|
offset = offset * 2u;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inclusivePrefix = scanData[lid];
|
||||||
|
let workgroupAliveTotal = scanData[agentWorkgroupSize - 1u];
|
||||||
|
let exclusivePrefix = inclusivePrefix - select(0u, 1u, isAlive);
|
||||||
|
|
||||||
|
if lid == 0u {
|
||||||
|
if workgroupAliveTotal > 0u {
|
||||||
|
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, workgroupAliveTotal);
|
||||||
|
} else {
|
||||||
|
workgroupCompactedOffset = 0u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroupBarrier();
|
||||||
|
|
||||||
|
if isAlive {
|
||||||
|
compactedAgents[workgroupCompactedOffset + exclusivePrefix] = agent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
|
fn clearCompactedTail(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(local_invocation_id) local_id: vec3<u32>
|
||||||
|
) {
|
||||||
|
let id = get_id(global_id);
|
||||||
|
|
||||||
|
if local_id.x == 0u {
|
||||||
|
clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroupBarrier();
|
||||||
|
|
||||||
|
let firstClearId = clearAliveAgentCount + id * clearCompactedTailStride;
|
||||||
|
for (var offset = 0u; offset < clearCompactedTailStride; offset += 1u) {
|
||||||
|
let clearId = firstClearId + offset;
|
||||||
|
if clearId < settings.agentCount {
|
||||||
|
compactedAgents[clearId].colorIndex = -1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
struct Settings {
|
|
||||||
agentCount: u32 // might be smaller than the length of the agents array
|
|
||||||
};
|
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
|
||||||
|
|
||||||
struct Counters {
|
|
||||||
evenGenerationAlive: atomic<u32>,
|
|
||||||
oddGenerationAlive: atomic<u32>,
|
|
||||||
};
|
|
||||||
|
|
||||||
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
|
||||||
|
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
|
||||||
fn main(
|
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
|
||||||
@builtin(num_workgroups) workgroup_count: vec3<u32>
|
|
||||||
) {
|
|
||||||
let id = global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
|
|
||||||
|
|
||||||
if id >= settings.agentCount {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if agents[id].generation % 2 == 0 {
|
|
||||||
atomicAdd(&counters.evenGenerationAlive, 1);
|
|
||||||
} else {
|
|
||||||
atomicAdd(&counters.oddGenerationAlive, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
@compute @workgroup_size(64)
|
|
||||||
fn main(
|
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
|
||||||
@builtin(num_workgroups) workgroup_count: vec3<u32>
|
|
||||||
) {
|
|
||||||
let id = get_id(global_id, workgroup_count);
|
|
||||||
|
|
||||||
if id >= arrayLength(&agents) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let clusterId = f32(id % 1000);
|
|
||||||
|
|
||||||
let random = textureSampleLevel(
|
|
||||||
noise,
|
|
||||||
noiseSampler,
|
|
||||||
vec2(f32(id % 1999) / 2000, f32(id) / 1999 / 2000),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
let randomPosition = textureSampleLevel(
|
|
||||||
noise,
|
|
||||||
noiseSampler,
|
|
||||||
vec2(clusterId / 2000, clusterId / 2000),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
agents[id] = Agent(
|
|
||||||
randomPosition.xz * state.size,
|
|
||||||
random.r * 3.14 * 2,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +1,65 @@
|
||||||
import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
|
||||||
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../../common-state/common-state';
|
import {
|
||||||
import { AGENT_SIZE_IN_BYTES } from './agent';
|
dispatchAgentWorkgroups,
|
||||||
import countingShader from './agent-counting.wgsl?raw';
|
getAgentWorkgroupSize,
|
||||||
import firstGenerationShader from './agent-first-generation.wgsl?raw';
|
substituteAgentWorkgroupSize,
|
||||||
|
} from '../agent-dispatch';
|
||||||
|
import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits';
|
||||||
|
import compactionShader from './agent-compaction.wgsl?raw';
|
||||||
|
import resizeShader from './agent-resize.wgsl?raw';
|
||||||
import agentSchema from './agent-schema.wgsl?raw';
|
import agentSchema from './agent-schema.wgsl?raw';
|
||||||
import { GenerationCounts } from './generation-counts';
|
|
||||||
|
|
||||||
export class AgentGenerationPipeline {
|
export class AgentGenerationPipeline {
|
||||||
private static readonly WORKGROUP_SIZE = 64;
|
private static readonly UNIFORM_COUNT = 4;
|
||||||
private static readonly UNIFORM_COUNT = 1;
|
private static readonly COUNTER_COUNT = 1;
|
||||||
private static readonly COUNTER_COUNT = 3;
|
private static readonly CLEAR_COMPACTED_TAIL_STRIDE = 4;
|
||||||
|
private static readonly ALLOCATION_GROWTH_FACTOR = 1.25;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUBuffer]>(
|
||||||
|
(active, inactive) =>
|
||||||
|
this.device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||||
|
{ binding: 1, resource: { buffer: active } },
|
||||||
|
{ binding: 2, resource: { buffer: this.countersBuffer } },
|
||||||
|
{ binding: 3, resource: { buffer: inactive } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
private readonly firstGenerationPipeline: GPUComputePipeline;
|
private readonly resizePipeline: GPUComputePipeline;
|
||||||
private readonly countingPipeline: GPUComputePipeline;
|
private readonly compactionPipeline: GPUComputePipeline;
|
||||||
|
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
||||||
|
private readonly resizeWorkgroupSize: number;
|
||||||
|
private readonly compactionWorkgroupSize: number;
|
||||||
|
|
||||||
public readonly agentsBuffer: GPUBuffer;
|
private activeAgentsBuffer: GPUBuffer;
|
||||||
public readonly countersBuffer: GPUBuffer;
|
private inactiveAgentsBuffer: GPUBuffer;
|
||||||
public readonly countersStagingBuffer: GPUBuffer;
|
private allocatedMaxAgentCount: number;
|
||||||
|
private readonly countersBuffer: GPUBuffer;
|
||||||
|
private readonly countersStagingBuffer: GPUBuffer;
|
||||||
|
private readonly agentCountUniformValues = new Uint32Array(
|
||||||
|
AgentGenerationPipeline.UNIFORM_COUNT
|
||||||
|
);
|
||||||
|
private readonly resizeUniformBuffer = new ArrayBuffer(
|
||||||
|
AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
private readonly resizeUniformFloatValues = new Float32Array(this.resizeUniformBuffer);
|
||||||
|
private readonly resizeUniformUintValues = new Uint32Array(this.resizeUniformBuffer);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly commonState: CommonState,
|
initialMaxAgentCount: number,
|
||||||
private readonly maxAgentCountUpperLimit: number
|
private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
|
||||||
) {
|
) {
|
||||||
|
this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount);
|
||||||
|
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
|
|
@ -51,13 +83,18 @@ export class AgentGenerationPipeline {
|
||||||
type: 'storage',
|
type: 'storage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
binding: 3,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
buffer: {
|
||||||
|
type: 'storage',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.agentsBuffer = this.device.createBuffer({
|
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||||
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||||
usage: GPUBufferUsage.STORAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.countersBuffer = this.device.createBuffer({
|
this.countersBuffer = this.device.createBuffer({
|
||||||
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -74,99 +111,182 @@ export class AgentGenerationPipeline {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroup = this.device.createBindGroup({
|
this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize');
|
||||||
layout: this.bindGroupLayout,
|
this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction');
|
||||||
entries: [
|
const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize');
|
||||||
{
|
const compactionSchema = substituteAgentWorkgroupSize(
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: {
|
|
||||||
buffer: this.agentsBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
resource: {
|
|
||||||
buffer: this.countersBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.firstGenerationPipeline = device.createComputePipeline({
|
|
||||||
layout: device.createPipelineLayout({
|
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
|
||||||
}),
|
|
||||||
compute: {
|
|
||||||
module: smartCompile(
|
|
||||||
device,
|
device,
|
||||||
CommonState.shaderCode,
|
|
||||||
agentSchema,
|
agentSchema,
|
||||||
firstGenerationShader
|
'compaction'
|
||||||
),
|
);
|
||||||
|
|
||||||
|
this.resizePipeline = device.createComputePipeline({
|
||||||
|
layout: device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
|
}),
|
||||||
|
compute: {
|
||||||
|
module: smartCompile(device, resizeSchema, resizeShader),
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.countingPipeline = device.createComputePipeline({
|
const compactionModule = smartCompile(
|
||||||
|
device,
|
||||||
|
compactionSchema,
|
||||||
|
compactionShader.replaceAll(
|
||||||
|
'__CLEAR_COMPACTED_TAIL_STRIDE__',
|
||||||
|
AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE.toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.compactionPipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(device, CommonState.shaderCode, agentSchema, countingShader),
|
module: compactionModule,
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.clearCompactedTailPipeline = device.createComputePipeline({
|
||||||
|
layout: device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
|
}),
|
||||||
|
compute: {
|
||||||
|
module: compactionModule,
|
||||||
|
entryPoint: 'clearCompactedTail',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get agentsBuffer(): GPUBuffer {
|
||||||
|
return this.activeAgentsBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAgentsBuffer(): GPUBuffer {
|
||||||
|
return this.device.createBuffer({
|
||||||
|
size: this.allocatedMaxAgentCount * AGENT_SIZE_IN_BYTES,
|
||||||
|
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public get maxAgentCount(): number {
|
public get maxAgentCount(): number {
|
||||||
|
return this.allocatedMaxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get maxSupportedAgentCount(): number {
|
||||||
|
return this.clampMaxAgentCount(Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ensureMaxAgentCount(
|
||||||
|
requestedMaxAgentCount: number,
|
||||||
|
activeAgentCount: number
|
||||||
|
): number {
|
||||||
|
const requestedClampedMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
|
||||||
|
if (requestedClampedMaxAgentCount <= this.allocatedMaxAgentCount) {
|
||||||
|
return this.allocatedMaxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMaxAgentCount = this.clampMaxAgentCount(
|
||||||
|
Math.max(
|
||||||
|
requestedClampedMaxAgentCount,
|
||||||
|
Math.ceil(
|
||||||
|
this.allocatedMaxAgentCount * AgentGenerationPipeline.ALLOCATION_GROWTH_FACTOR
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
|
||||||
|
const previousInactiveAgentsBuffer = this.inactiveAgentsBuffer;
|
||||||
|
const previousMaxAgentCount = this.allocatedMaxAgentCount;
|
||||||
|
this.allocatedMaxAgentCount = nextMaxAgentCount;
|
||||||
|
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||||
|
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||||
|
|
||||||
|
const copyAgentCount = Math.min(
|
||||||
|
Math.max(0, Math.floor(activeAgentCount)),
|
||||||
|
previousMaxAgentCount,
|
||||||
|
nextMaxAgentCount
|
||||||
|
);
|
||||||
|
if (copyAgentCount > 0) {
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
commandEncoder.copyBufferToBuffer(
|
||||||
|
previousActiveAgentsBuffer,
|
||||||
|
0,
|
||||||
|
this.activeAgentsBuffer,
|
||||||
|
0,
|
||||||
|
copyAgentCount * AGENT_SIZE_IN_BYTES
|
||||||
|
);
|
||||||
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPUBuffer.destroy() defers actual freeing until pending submissions
|
||||||
|
// finish, so calling it synchronously after submit is safe.
|
||||||
|
previousActiveAgentsBuffer.destroy();
|
||||||
|
previousInactiveAgentsBuffer.destroy();
|
||||||
|
return this.allocatedMaxAgentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clampMaxAgentCount(value: number): number {
|
||||||
|
const requestedMaxAgentCount =
|
||||||
|
value === Number.POSITIVE_INFINITY
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: Number.isFinite(value)
|
||||||
|
? Math.floor(value)
|
||||||
|
: 0;
|
||||||
return Math.min(
|
return Math.min(
|
||||||
this.maxAgentCountUpperLimit,
|
getMaxSupportedAgentCount(this.device, this.maxAgentCountUpperLimit),
|
||||||
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
|
Math.max(0, requestedMaxAgentCount)
|
||||||
this.device.limits.maxComputeWorkgroupsPerDimension ** 3
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public spawnFirstGeneration(): void {
|
public writeAgents(agentOffset: number, data: Float32Array): void {
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
this.device.queue.writeBuffer(
|
||||||
|
this.activeAgentsBuffer,
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
agentOffset * AGENT_SIZE_IN_BYTES,
|
||||||
this.commonState.execute(passEncoder);
|
data
|
||||||
passEncoder.setPipeline(this.firstGenerationPipeline);
|
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
|
||||||
passEncoder.dispatchWorkgroups(
|
|
||||||
...getWorkgroupCounts(
|
|
||||||
this.device,
|
|
||||||
this.maxAgentCount,
|
|
||||||
AgentGenerationPipeline.WORKGROUP_SIZE
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resizeAgents(agentCount: number, scale: vec2): void {
|
||||||
|
if (agentCount <= 0 || vec2.equals(scale, vec2.fromValues(1, 1))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resizeUniformFloatValues[0] = scale[0];
|
||||||
|
this.resizeUniformFloatValues[1] = scale[1];
|
||||||
|
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
|
||||||
|
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
|
||||||
|
|
||||||
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
|
passEncoder.setPipeline(this.resizePipeline);
|
||||||
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
|
dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async countAgents(agentCount: number): Promise<GenerationCounts> {
|
public async compactAgents(agentCount: number): Promise<number> {
|
||||||
this.device.queue.writeBuffer(this.countersBuffer, 0, new Uint32Array([0, 0]));
|
if (agentCount <= 0) {
|
||||||
this.device.queue.writeBuffer(this.uniforms, 0, new Uint32Array([agentCount]));
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.agentCountUniformValues[0] = agentCount;
|
||||||
|
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
||||||
|
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
commandEncoder.clearBuffer(this.countersBuffer, 0, Uint32Array.BYTES_PER_ELEMENT);
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.countingPipeline);
|
passEncoder.setPipeline(this.compactionPipeline);
|
||||||
this.commonState.execute(passEncoder);
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount);
|
||||||
passEncoder.dispatchWorkgroups(
|
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
||||||
...getWorkgroupCounts(
|
dispatchAgentWorkgroups(
|
||||||
this.device,
|
passEncoder,
|
||||||
agentCount,
|
this.compactionWorkgroupSize,
|
||||||
AgentGenerationPipeline.WORKGROUP_SIZE
|
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
|
|
@ -175,25 +295,39 @@ export class AgentGenerationPipeline {
|
||||||
0,
|
0,
|
||||||
this.countersStagingBuffer,
|
this.countersStagingBuffer,
|
||||||
0,
|
0,
|
||||||
AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT
|
Uint32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
this.swapAgentBuffers();
|
||||||
|
|
||||||
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
||||||
|
const compactedCount = new Uint32Array(
|
||||||
const data = new Uint32Array(this.countersStagingBuffer.getMappedRange().slice(0));
|
this.countersStagingBuffer.getMappedRange(),
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
)[0];
|
||||||
this.countersStagingBuffer.unmap();
|
this.countersStagingBuffer.unmap();
|
||||||
return {
|
|
||||||
evenGenerationCount: data[0],
|
return compactedCount;
|
||||||
oddGenerationCount: data[1],
|
}
|
||||||
};
|
|
||||||
|
private getBindGroup(): GPUBindGroup {
|
||||||
|
return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private swapAgentBuffers(): void {
|
||||||
|
[this.activeAgentsBuffer, this.inactiveAgentsBuffer] = [
|
||||||
|
this.inactiveAgentsBuffer,
|
||||||
|
this.activeAgentsBuffer,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
this.countersBuffer.destroy();
|
this.countersBuffer.destroy();
|
||||||
this.countersStagingBuffer.destroy();
|
this.countersStagingBuffer.destroy();
|
||||||
this.agentsBuffer.destroy();
|
this.inactiveAgentsBuffer.destroy();
|
||||||
|
this.activeAgentsBuffer.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/pipelines/agents/agent-generation/agent-resize.wgsl
Normal file
21
src/pipelines/agents/agent-generation/agent-resize.wgsl
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
struct ResizeSettings {
|
||||||
|
scale: vec2<f32>,
|
||||||
|
agentCount: u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
|
||||||
|
|
||||||
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
|
fn main(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
|
) {
|
||||||
|
let id = get_id(global_id);
|
||||||
|
|
||||||
|
if id >= resizeSettings.agentCount {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = resizeSettings.scale;
|
||||||
|
agents[id].position = agents[id].position * scale;
|
||||||
|
agents[id].targetPosition = agents[id].targetPosition * scale;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
struct Agent {
|
struct Agent {
|
||||||
position: vec2<f32>,
|
position: vec2<f32>,
|
||||||
angle: f32,
|
angle: f32,
|
||||||
generation: f32,
|
colorIndex: f32,
|
||||||
|
targetPosition: vec2<f32>,
|
||||||
|
targetAngle: f32,
|
||||||
|
introDelay: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
|
||||||
|
|
||||||
fn get_id(global_id: vec3<u32>, workgroup_count: vec3<u32>) -> u32 {
|
const agentWorkgroupSize = __AGENT_WORKGROUP_SIZE__u;
|
||||||
return global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
|
|
||||||
|
fn get_id(global_id: vec3<u32>) -> u32 {
|
||||||
|
return global_id.x;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
|
|
||||||
export interface Agent {
|
|
||||||
position: vec2;
|
|
||||||
angle: number;
|
|
||||||
generation: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AGENT_SIZE_IN_BYTES = 4 * Float32Array.BYTES_PER_ELEMENT;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export interface GenerationCounts {
|
|
||||||
evenGenerationCount: number;
|
|
||||||
oddGenerationCount: number;
|
|
||||||
}
|
|
||||||
64
src/pipelines/agents/agent-limits.ts
Normal file
64
src/pipelines/agents/agent-limits.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { getMinAgentWorkgroupSize } from './agent-dispatch';
|
||||||
|
|
||||||
|
export const AGENT_FLOAT_COUNT = 8;
|
||||||
|
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
|
||||||
|
const AGENT_LAYOUT = {
|
||||||
|
positionX: 0,
|
||||||
|
positionY: 1,
|
||||||
|
angle: 2,
|
||||||
|
colorIndex: 3,
|
||||||
|
targetPositionX: 4,
|
||||||
|
targetPositionY: 5,
|
||||||
|
targetAngle: 6,
|
||||||
|
introDelay: 7,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface AgentLayoutValues {
|
||||||
|
angle: number;
|
||||||
|
colorIndex: number;
|
||||||
|
introDelay: number;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
targetAngle: number;
|
||||||
|
targetPositionX: number;
|
||||||
|
targetPositionY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const writeAgentValues = (
|
||||||
|
target: Float32Array,
|
||||||
|
agentIndex: number,
|
||||||
|
values: AgentLayoutValues
|
||||||
|
): void => {
|
||||||
|
const base = agentIndex * AGENT_FLOAT_COUNT;
|
||||||
|
target[base + AGENT_LAYOUT.positionX] = values.positionX;
|
||||||
|
target[base + AGENT_LAYOUT.positionY] = values.positionY;
|
||||||
|
target[base + AGENT_LAYOUT.angle] = values.angle;
|
||||||
|
target[base + AGENT_LAYOUT.colorIndex] = values.colorIndex;
|
||||||
|
target[base + AGENT_LAYOUT.targetPositionX] = values.targetPositionX;
|
||||||
|
target[base + AGENT_LAYOUT.targetPositionY] = values.targetPositionY;
|
||||||
|
target[base + AGENT_LAYOUT.targetAngle] = values.targetAngle;
|
||||||
|
target[base + AGENT_LAYOUT.introDelay] = values.introDelay;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxSupportedAgentCount = (
|
||||||
|
device: GPUDevice,
|
||||||
|
maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
|
||||||
|
): number => {
|
||||||
|
const storageBufferBindingSize =
|
||||||
|
device.limits.maxStorageBufferBindingSize ?? device.limits.maxBufferSize;
|
||||||
|
const upperLimit = Number.isFinite(maxAgentCountUpperLimit)
|
||||||
|
? Math.floor(maxAgentCountUpperLimit)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
upperLimit,
|
||||||
|
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
||||||
|
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
|
||||||
|
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
|
||||||
|
getMinAgentWorkgroupSize(device)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,197 +1,255 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
|
import {
|
||||||
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
|
createCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
|
||||||
|
import {
|
||||||
|
dispatchAgentWorkgroups,
|
||||||
|
getAgentWorkgroupSize,
|
||||||
|
substituteAgentWorkgroupSize,
|
||||||
|
} from './agent-dispatch';
|
||||||
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
|
||||||
import { AgentSettings } from './agent-settings';
|
|
||||||
import shader from './agent.wgsl?raw';
|
import shader from './agent.wgsl?raw';
|
||||||
|
|
||||||
export class AgentPipeline {
|
export interface AgentSettings {
|
||||||
private static readonly WORKGROUP_SIZE = 64;
|
color1ToColor1: number;
|
||||||
private static readonly UNIFORM_COUNT = 19;
|
color1ToColor2: number;
|
||||||
|
color1ToColor3: number;
|
||||||
|
color2ToColor1: number;
|
||||||
|
color2ToColor2: number;
|
||||||
|
color2ToColor3: number;
|
||||||
|
color3ToColor1: number;
|
||||||
|
color3ToColor2: number;
|
||||||
|
color3ToColor3: number;
|
||||||
|
moveSpeed: number;
|
||||||
|
turnSpeed: number;
|
||||||
|
sensorOffsetAngle: number;
|
||||||
|
sensorOffsetDistance: number;
|
||||||
|
turnWhenLost: number;
|
||||||
|
individualTrailWeight: number;
|
||||||
|
forwardRotationScale: number;
|
||||||
|
introNearDistanceMin: number;
|
||||||
|
introNearSensorOffsetMultiplier: number;
|
||||||
|
introTargetAngleBlend: number;
|
||||||
|
introProgressCutoff: number;
|
||||||
|
introNearDistanceInner: number;
|
||||||
|
introTurnRateMultiplier: number;
|
||||||
|
introRandomTurnMultiplier: number;
|
||||||
|
introStepStopDistance: number;
|
||||||
|
randomTimeScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Settings struct in WGSL starts with a mat3x3<f32> reactionMatrix.
|
||||||
|
// In uniform layout each of its 3 columns is stored as a vec3<f32> padded to
|
||||||
|
// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused
|
||||||
|
// padding). Remaining scalars pack tightly from float 12 onward.
|
||||||
|
const UNIFORM_COUNT = 32;
|
||||||
|
const REACTION_MATRIX_COL0 = 0;
|
||||||
|
const REACTION_MATRIX_COL1 = 4;
|
||||||
|
const REACTION_MATRIX_COL2 = 8;
|
||||||
|
const SCALAR_BASE = 12;
|
||||||
|
|
||||||
|
export class AgentPipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipelineFull: GPUComputePipeline;
|
||||||
|
private readonly pipelineSteady: GPUComputePipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private bindGroup?: GPUBindGroup;
|
private readonly workgroupSize: number;
|
||||||
private previousTrailMapIn?: GPUTextureView;
|
private useSteadyPipeline = false;
|
||||||
private previousTrailMapOut?: GPUTextureView;
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
|
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||||
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
private readonly bindGroupCache = createBindGroupCache<
|
||||||
|
[GPUBuffer, GPUTextureView, GPUTextureView]
|
||||||
|
>((agentsBuffer, trailMapIn, trailMapOut) =>
|
||||||
|
this.device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||||
|
{ binding: 1, resource: { buffer: agentsBuffer } },
|
||||||
|
{ binding: 2, resource: trailMapIn },
|
||||||
|
{ binding: 3, resource: trailMapOut },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
private agentCount = 0;
|
private agentCount = 0;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly commonState: CommonState,
|
private readonly commonState: CommonState,
|
||||||
private readonly agentsBuffer: GPUBuffer // doesn't get destroyed
|
private readonly getAgentsBuffer: () => GPUBuffer
|
||||||
) {
|
) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout);
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 1,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
buffer: { type: 'storage' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
texture: { sampleType: 'float' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 3,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
storageTexture: { format: TRAIL_SOURCE_TEXTURE_FORMAT },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
this.pipeline = device.createComputePipeline({
|
this.workgroupSize = getAgentWorkgroupSize(device, 'simulation');
|
||||||
layout: device.createPipelineLayout({
|
const shaderModule = smartCompile(
|
||||||
|
device,
|
||||||
|
CommonState.shaderCode,
|
||||||
|
substituteAgentWorkgroupSize(device, agentSchema, 'simulation'),
|
||||||
|
shader
|
||||||
|
);
|
||||||
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||||
}),
|
});
|
||||||
|
this.pipelineFull = device.createComputePipeline({
|
||||||
|
layout: pipelineLayout,
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
|
module: shaderModule,
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.pipelineSteady = device.createComputePipeline({
|
||||||
|
layout: pipelineLayout,
|
||||||
|
compute: {
|
||||||
|
module: shaderModule,
|
||||||
|
entryPoint: 'mainSteady',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
this.uniforms = device.createBuffer({
|
||||||
size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({
|
public setParameters({
|
||||||
deltaTime,
|
deltaTime,
|
||||||
center,
|
|
||||||
radius,
|
|
||||||
brushTrailWeight,
|
|
||||||
moveSpeed,
|
moveSpeed,
|
||||||
turnSpeed,
|
turnSpeed,
|
||||||
sensorOffsetAngle,
|
sensorOffsetAngle,
|
||||||
sensorOffsetDistance,
|
sensorOffsetDistance,
|
||||||
nextGenerationSensorOffsetDistance,
|
|
||||||
currentGenerationAggression,
|
|
||||||
nextGenerationAggression,
|
|
||||||
nextGenerationSpeed,
|
|
||||||
isNextGenerationOdd,
|
|
||||||
turnWhenLost,
|
turnWhenLost,
|
||||||
individualTrailWeight,
|
individualTrailWeight,
|
||||||
infectionProbability,
|
color1ToColor1,
|
||||||
|
color1ToColor2,
|
||||||
|
color1ToColor3,
|
||||||
|
color2ToColor1,
|
||||||
|
color2ToColor2,
|
||||||
|
color2ToColor3,
|
||||||
|
color3ToColor1,
|
||||||
|
color3ToColor2,
|
||||||
|
color3ToColor3,
|
||||||
|
forwardRotationScale,
|
||||||
|
introNearDistanceInner,
|
||||||
|
introNearDistanceMin,
|
||||||
|
introNearSensorOffsetMultiplier,
|
||||||
|
introTargetAngleBlend,
|
||||||
|
introProgressCutoff,
|
||||||
|
introTurnRateMultiplier,
|
||||||
|
introRandomTurnMultiplier,
|
||||||
|
introMoveSpeed,
|
||||||
|
introStepStopDistance,
|
||||||
|
randomTimeScale,
|
||||||
|
time,
|
||||||
agentCount,
|
agentCount,
|
||||||
|
introProgress,
|
||||||
}: AgentSettings & {
|
}: AgentSettings & {
|
||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
currentGenerationAggression: number;
|
time: number;
|
||||||
nextGenerationAggression: number;
|
|
||||||
nextGenerationSensorOffsetDistance: number;
|
|
||||||
nextGenerationSpeed: number;
|
|
||||||
isNextGenerationOdd: number;
|
|
||||||
center: vec2;
|
|
||||||
radius: number;
|
|
||||||
infectionProbability: number;
|
|
||||||
agentCount: number;
|
agentCount: number;
|
||||||
|
introMoveSpeed: number;
|
||||||
|
introProgress?: number;
|
||||||
}) {
|
}) {
|
||||||
this.agentCount = agentCount;
|
this.agentCount = agentCount;
|
||||||
this.device.queue.writeBuffer(
|
const resolvedIntroProgress = introProgress ?? 1;
|
||||||
|
// Once the intro target phase ends nothing reads intro fields again, so the
|
||||||
|
// steady-only pipeline can replace the full one for the rest of the session.
|
||||||
|
this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff;
|
||||||
|
// Reaction matrix: column N holds the weights for source colorIndex == N.
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3;
|
||||||
|
this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime;
|
||||||
|
this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime;
|
||||||
|
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
|
||||||
|
this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle);
|
||||||
|
this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle);
|
||||||
|
this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance;
|
||||||
|
this.uniformValues[SCALAR_BASE + 5] = turnWhenLost;
|
||||||
|
this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight;
|
||||||
|
this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount));
|
||||||
|
this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress;
|
||||||
|
this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale;
|
||||||
|
this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner;
|
||||||
|
this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin;
|
||||||
|
this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier;
|
||||||
|
this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend;
|
||||||
|
this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff;
|
||||||
|
this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier;
|
||||||
|
this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier;
|
||||||
|
this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime;
|
||||||
|
this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance;
|
||||||
|
this.uniformUintValues[SCALAR_BASE + 19] =
|
||||||
|
Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
||||||
|
writeBufferIfChanged(
|
||||||
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
0,
|
this.uniformValues,
|
||||||
new Float32Array([
|
this.uniformCache
|
||||||
...center,
|
|
||||||
radius,
|
|
||||||
|
|
||||||
brushTrailWeight,
|
|
||||||
moveSpeed * deltaTime,
|
|
||||||
turnSpeed * deltaTime,
|
|
||||||
|
|
||||||
(sensorOffsetAngle * Math.PI) / 180,
|
|
||||||
sensorOffsetDistance,
|
|
||||||
|
|
||||||
currentGenerationAggression,
|
|
||||||
nextGenerationAggression,
|
|
||||||
nextGenerationSensorOffsetDistance,
|
|
||||||
nextGenerationSpeed * deltaTime,
|
|
||||||
isNextGenerationOdd,
|
|
||||||
|
|
||||||
turnWhenLost,
|
|
||||||
individualTrailWeight,
|
|
||||||
infectionProbability,
|
|
||||||
|
|
||||||
agentCount,
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public execute(
|
public execute(
|
||||||
commandEncoder: GPUCommandEncoder,
|
commandEncoder: GPUCommandEncoder,
|
||||||
trailMapIn: GPUTextureView,
|
trailMapIn: GPUTextureView,
|
||||||
trailMapOut: GPUTextureView
|
trailMapOut: GPUTextureView,
|
||||||
|
timestampWrites?: GPUComputePassTimestampWrites
|
||||||
) {
|
) {
|
||||||
this.ensureBindGroupExists(trailMapIn, trailMapOut);
|
if (this.agentCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass(
|
||||||
passEncoder.setPipeline(this.pipeline);
|
timestampWrites ? { timestampWrites } : undefined
|
||||||
this.commonState.execute(passEncoder);
|
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
|
||||||
passEncoder.dispatchWorkgroups(
|
|
||||||
...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE)
|
|
||||||
);
|
);
|
||||||
|
passEncoder.setPipeline(
|
||||||
|
this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull
|
||||||
|
);
|
||||||
|
this.commonState.execute(passEncoder);
|
||||||
|
passEncoder.setBindGroup(
|
||||||
|
1,
|
||||||
|
this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
|
||||||
|
);
|
||||||
|
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureBindGroupExists(trailMapIn: GPUTextureView, trailMapOut: GPUTextureView) {
|
|
||||||
if (
|
|
||||||
this.previousTrailMapIn !== trailMapIn ||
|
|
||||||
this.previousTrailMapOut !== trailMapOut
|
|
||||||
) {
|
|
||||||
this.bindGroup = this.device.createBindGroup({
|
|
||||||
layout: this.bindGroupLayout,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: {
|
|
||||||
buffer: this.agentsBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
resource: trailMapIn,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 3,
|
|
||||||
resource: trailMapOut,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.previousTrailMapIn = trailMapIn;
|
|
||||||
this.previousTrailMapOut = trailMapOut;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
|
||||||
return {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
visibility: GPUShaderStage.COMPUTE,
|
|
||||||
buffer: {
|
|
||||||
type: 'uniform',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
visibility: GPUShaderStage.COMPUTE,
|
|
||||||
buffer: {
|
|
||||||
type: 'storage',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
visibility: GPUShaderStage.COMPUTE,
|
|
||||||
texture: {
|
|
||||||
sampleType: 'float',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 3,
|
|
||||||
visibility: GPUShaderStage.COMPUTE,
|
|
||||||
storageTexture: {
|
|
||||||
format: 'rgba16float',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export interface AgentSettings {
|
|
||||||
brushTrailWeight: number;
|
|
||||||
moveSpeed: number;
|
|
||||||
turnSpeed: number;
|
|
||||||
sensorOffsetAngle: number;
|
|
||||||
sensorOffsetDistance: number;
|
|
||||||
turnWhenLost: number;
|
|
||||||
individualTrailWeight: number;
|
|
||||||
currentGenerationAggression: number;
|
|
||||||
nextGenerationAggression: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +1,273 @@
|
||||||
|
const PI: f32 = 3.14159265359;
|
||||||
|
const TAU: f32 = 6.28318530718;
|
||||||
|
const INV_TAU: f32 = 0.15915494309;
|
||||||
|
|
||||||
|
const CHANNEL_MASKS = array<vec3<f32>, 3>(
|
||||||
|
vec3<f32>(1.0, 0.0, 0.0),
|
||||||
|
vec3<f32>(0.0, 1.0, 0.0),
|
||||||
|
vec3<f32>(0.0, 0.0, 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
struct Settings {
|
struct Settings {
|
||||||
center: vec2<f32>,
|
// Columns are indexed by source colorIndex; each column holds the per-target
|
||||||
radius: f32,
|
// weights (colorXToColor1, colorXToColor2, colorXToColor3).
|
||||||
|
reactionMatrix: mat3x3<f32>,
|
||||||
brushTrailWeight: f32,
|
moveRate: f32,
|
||||||
currentGenerationMoveRate: f32,
|
|
||||||
turnRate: f32,
|
turnRate: f32,
|
||||||
|
sensorAngleSin: f32,
|
||||||
sensorAngle: f32,
|
sensorAngleCos: f32,
|
||||||
sensorOffset: f32,
|
sensorOffset: f32,
|
||||||
|
|
||||||
currentGenerationAggression: f32,
|
|
||||||
nextGenerationAggression: f32,
|
|
||||||
nextGenerationSensorOffsetDistance: f32,
|
|
||||||
nextGenerationMoveRate: f32,
|
|
||||||
isNextGenerationOdd: f32,
|
|
||||||
|
|
||||||
turnWhenLost: f32,
|
turnWhenLost: f32,
|
||||||
individualTrailWeight: f32,
|
individualTrailWeight: f32,
|
||||||
infectionProbability: f32,
|
agentCount: u32,
|
||||||
|
introProgress: f32,
|
||||||
agentCount: f32 // might be smaller than the length of the agents array
|
forwardRotationScale: f32,
|
||||||
|
introNearDistanceInner: f32,
|
||||||
|
introNearDistanceMin: f32,
|
||||||
|
introNearSensorOffsetMultiplier: f32,
|
||||||
|
introTargetAngleBlend: f32,
|
||||||
|
introProgressCutoff: f32,
|
||||||
|
introTurnRateMultiplier: f32,
|
||||||
|
introRandomTurnMultiplier: f32,
|
||||||
|
introMoveRate: f32,
|
||||||
|
introStepStopDistance: f32,
|
||||||
|
randomTimeSeed: u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
||||||
// even generation's trail -> red channel
|
|
||||||
// odd generation's trail -> green channel
|
|
||||||
// unused -> blue channel
|
|
||||||
// brush -> alpha channel
|
|
||||||
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
||||||
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
|
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
|
||||||
|
struct AgentMovement {
|
||||||
|
rotation: f32,
|
||||||
|
step: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
@builtin(num_workgroups) workgroup_count: vec3<u32>
|
|
||||||
) {
|
) {
|
||||||
let id = get_id(global_id, workgroup_count);
|
let id = get_id(global_id);
|
||||||
|
|
||||||
if id >= u32(settings.agentCount) {
|
if id >= settings.agentCount {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var agent = agents[id];
|
let colorIndex = agents[id].colorIndex;
|
||||||
|
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let random = textureSampleLevel(
|
let position = agents[id].position;
|
||||||
noise,
|
let angle = agents[id].angle;
|
||||||
noiseSampler,
|
var targetPosition = vec2<f32>(-1.0, -1.0);
|
||||||
vec2(
|
var hasIntroTarget = false;
|
||||||
f32(id) % 23647 / 2000,
|
if settings.introProgress < settings.introProgressCutoff {
|
||||||
state.time % 3243 / 2000
|
targetPosition = agents[id].targetPosition;
|
||||||
),
|
hasIntroTarget = targetPosition.x >= 0.0 && targetPosition.y >= 0.0;
|
||||||
0
|
if hasIntroTarget && settings.introProgress < agents[id].introDelay {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let channelMask = get_channel_mask(colorIndex);
|
||||||
|
let reactionMask = get_reaction_mask(colorIndex);
|
||||||
|
let randomSeed = random_seed(id);
|
||||||
|
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||||
|
|
||||||
|
var movement = AgentMovement(0.0, vec2<f32>(0.0, 0.0));
|
||||||
|
if hasIntroTarget {
|
||||||
|
movement = intro_decide(id, position, angle, targetPosition, randomSeed);
|
||||||
|
} else {
|
||||||
|
movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady-state-only entry point used after introProgress >= introProgressCutoff.
|
||||||
|
// Drops the intro target reads, atan2/smoothstep math, and introDelay check —
|
||||||
|
// once intro completes those paths are dead for the rest of the session.
|
||||||
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
|
fn mainSteady(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
|
) {
|
||||||
|
let id = get_id(global_id);
|
||||||
|
|
||||||
|
if id >= settings.agentCount {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorIndex = agents[id].colorIndex;
|
||||||
|
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = agents[id].position;
|
||||||
|
let angle = agents[id].angle;
|
||||||
|
let channelMask = get_channel_mask(colorIndex);
|
||||||
|
let reactionMask = get_reaction_mask(colorIndex);
|
||||||
|
let randomSeed = random_seed(id);
|
||||||
|
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||||
|
|
||||||
|
let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
|
||||||
|
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn steady_decide(
|
||||||
|
position: vec2<f32>,
|
||||||
|
angle: f32,
|
||||||
|
reactionMask: vec3<f32>,
|
||||||
|
randomSeed: u32,
|
||||||
|
maxPosition: vec2<f32>
|
||||||
|
) -> AgentMovement {
|
||||||
|
let randomTurn = random_float(randomSeed);
|
||||||
|
let direction = vec2(cos(angle), sin(angle));
|
||||||
|
|
||||||
|
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
|
||||||
|
let leftSensor = sensor_position(
|
||||||
|
position,
|
||||||
|
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
|
||||||
|
settings.sensorOffset,
|
||||||
|
maxPosition
|
||||||
|
);
|
||||||
|
let rightSensor = sensor_position(
|
||||||
|
position,
|
||||||
|
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
|
||||||
|
settings.sensorOffset,
|
||||||
|
maxPosition
|
||||||
);
|
);
|
||||||
|
|
||||||
let isFromCurrentGeneration = abs(agent.generation - settings.isNextGenerationOdd);
|
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
|
||||||
let isFromNextGeneration = 1.0 - isFromCurrentGeneration;
|
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
|
||||||
let isFromOddGeneration = agent.generation % 2;
|
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
|
||||||
|
|
||||||
let sensorOffset = mix(settings.sensorOffset, settings.nextGenerationSensorOffsetDistance, isFromNextGeneration);
|
let weightForward = dot(trailForward.rgb, reactionMask);
|
||||||
let moveRate = mix(settings.currentGenerationMoveRate, settings.nextGenerationMoveRate, isFromNextGeneration);
|
let weightLeft = dot(trailLeft.rgb, reactionMask);
|
||||||
let brushWeight = mix(settings.brushTrailWeight, 0, isFromNextGeneration);
|
let weightRight = dot(trailRight.rgb, reactionMask);
|
||||||
|
|
||||||
let trailForward = sense(agent.position, agent.angle, sensorOffset, 0);
|
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
|
||||||
let trailLeft = sense(agent.position, agent.angle, sensorOffset, settings.sensorAngle);
|
|
||||||
let trailRight = sense(agent.position, agent.angle, sensorOffset, -settings.sensorAngle);
|
|
||||||
|
|
||||||
var weightForward = brushWeight * trailForward.a;
|
|
||||||
var weightLeft = brushWeight * trailLeft.a;
|
|
||||||
var weightRight = brushWeight * trailRight.a;
|
|
||||||
|
|
||||||
let agression = mix(settings.currentGenerationAggression, settings.nextGenerationAggression, isFromNextGeneration) + weightForward;
|
|
||||||
|
|
||||||
weightForward += mix(trailForward.r + agression * trailForward.g, trailForward.g + agression * trailForward.r, isFromOddGeneration);
|
|
||||||
weightLeft += mix(trailLeft.r + agression * trailLeft.g, trailLeft.g + agression * trailLeft.r, isFromOddGeneration);
|
|
||||||
weightRight += mix(trailRight.r + agression * trailRight.g, trailRight.g + agression * trailRight.r, isFromOddGeneration);
|
|
||||||
|
|
||||||
var rotation: f32;
|
|
||||||
if weightForward >= weightLeft && weightForward >= weightRight {
|
if weightForward >= weightLeft && weightForward >= weightRight {
|
||||||
rotation = 0;
|
rotation = rotation * settings.forwardRotationScale;
|
||||||
} else {
|
} else {
|
||||||
rotation = sign(weightLeft - weightRight) * settings.turnRate;
|
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextPosition = clamp(
|
return AgentMovement(rotation, direction * settings.moveRate);
|
||||||
agent.position + vec2(cos(agent.angle), sin(agent.angle)) * moveRate,
|
}
|
||||||
vec2<f32>(0, 0),
|
|
||||||
state.size
|
fn intro_decide(
|
||||||
|
id: u32,
|
||||||
|
position: vec2<f32>,
|
||||||
|
angle: f32,
|
||||||
|
targetPosition: vec2<f32>,
|
||||||
|
randomSeed: u32
|
||||||
|
) -> AgentMovement {
|
||||||
|
let introTargetOffset = targetPosition - position;
|
||||||
|
let introTargetDistance = length(introTargetOffset);
|
||||||
|
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
||||||
|
let nearTitle = 1.0 - smoothstep(
|
||||||
|
settings.introNearDistanceInner,
|
||||||
|
max(
|
||||||
|
settings.introNearDistanceMin,
|
||||||
|
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
|
||||||
|
),
|
||||||
|
introTargetDistance
|
||||||
);
|
);
|
||||||
if nextPosition.x == 0 || nextPosition.x == state.size.x || nextPosition.y == 0 || nextPosition.y == state.size.y {
|
let desiredAngle = mix(
|
||||||
rotation = 3.14159265359 + random.a - 0.5;
|
targetAngle,
|
||||||
|
agents[id].targetAngle,
|
||||||
|
nearTitle * settings.introTargetAngleBlend
|
||||||
|
);
|
||||||
|
let introTurn = angle_delta(angle, desiredAngle);
|
||||||
|
|
||||||
|
let rotation = clamp(
|
||||||
|
introTurn,
|
||||||
|
-settings.turnRate * settings.introTurnRateMultiplier,
|
||||||
|
settings.turnRate * settings.introTurnRateMultiplier
|
||||||
|
)
|
||||||
|
+ (random_float(randomSeed + 1013904223u) - 0.5) *
|
||||||
|
settings.turnWhenLost *
|
||||||
|
settings.introRandomTurnMultiplier;
|
||||||
|
let moveRate = min(settings.introMoveRate, introTargetDistance);
|
||||||
|
var step = vec2<f32>(0.0, 0.0);
|
||||||
|
if introTargetDistance > settings.introStepStopDistance {
|
||||||
|
step = introTargetOffset / introTargetDistance * moveRate;
|
||||||
|
}
|
||||||
|
return AgentMovement(rotation, step);
|
||||||
}
|
}
|
||||||
|
|
||||||
var trail = vec4<f32>(settings.individualTrailWeight, 0, 0, 0);
|
fn agent_finalize(
|
||||||
if isFromOddGeneration == 1.0 {
|
id: u32,
|
||||||
trail = vec4<f32>(0, settings.individualTrailWeight, 0, 0);
|
position: vec2<f32>,
|
||||||
|
angle: f32,
|
||||||
|
channelMask: vec3<f32>,
|
||||||
|
randomSeed: u32,
|
||||||
|
maxPosition: vec2<f32>,
|
||||||
|
movement: AgentMovement
|
||||||
|
) {
|
||||||
|
let nextPosition = clamp(position + movement.step, vec2<f32>(0, 0), maxPosition);
|
||||||
|
var rotation = movement.rotation;
|
||||||
|
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
|
||||||
|
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
// Writes only this agent's last-writer-wins deposit into a per-frame-cleared
|
||||||
|
// depositMap. Storage textures do not blend concurrent compute writes, so
|
||||||
agent.angle += rotation;
|
// overlapping agents intentionally collapse to whichever write wins. The
|
||||||
trailBelow += trail;
|
// diffusion pass then sums trailMap + depositMap at tile-load time.
|
||||||
|
textureStore(
|
||||||
if settings.radius > 0 && length(settings.center - agent.position) < settings.radius {
|
trailMapOut,
|
||||||
agent.generation = settings.isNextGenerationOdd;
|
vec2<i32>(nextPosition),
|
||||||
|
vec4<f32>(channelMask * settings.individualTrailWeight, 0.0)
|
||||||
// clear trail map below so the agent won't die immediately
|
);
|
||||||
// trailBelow.r = (1 - settings.isNextGenerationOdd) * (trailBelow.r + trailBelow.g);
|
agents[id].angle = angle + rotation;
|
||||||
// trailBelow.g = settings.isNextGenerationOdd * (trailBelow.r + trailBelow.g);
|
agents[id].position = nextPosition;
|
||||||
} else {
|
|
||||||
let relativeWeight = mix(trailBelow.g - trailBelow.r, trailBelow.r - trailBelow.g, isFromOddGeneration);
|
|
||||||
if (relativeWeight > 0 && (
|
|
||||||
(isFromCurrentGeneration == 1.0 && trailBelow.a == 0 && random.b < settings.infectionProbability)
|
|
||||||
|| (isFromCurrentGeneration == 0.0 && trailBelow.a > 0)
|
|
||||||
)) || (trailBelow.a > 0 && isFromCurrentGeneration == 0.0){
|
|
||||||
// trailBelow.r = isFromOddGeneration * (trailBelow.r + trailBelow.g);
|
|
||||||
// trailBelow.g = (1 - isFromOddGeneration) * (trailBelow.r + trailBelow.g);
|
|
||||||
agent.generation = (agent.generation + 1) % 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
|
fn sensor_position(
|
||||||
agent.position = nextPosition;
|
agentPosition: vec2<f32>,
|
||||||
agents[id] = agent;
|
direction: vec2<f32>,
|
||||||
|
sensorOffset: f32,
|
||||||
|
maxPosition: vec2<f32>
|
||||||
|
) -> vec2<i32> {
|
||||||
|
return vec2<i32>(clamp(
|
||||||
|
agentPosition + direction * sensorOffset,
|
||||||
|
vec2<f32>(0, 0),
|
||||||
|
maxPosition
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sense(agentPosition: vec2<f32>, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4<f32> {
|
fn rotate_direction(direction: vec2<f32>, angleSin: f32, angleCos: f32) -> vec2<f32> {
|
||||||
let sensorAngle = agentAngle + sensorOffsetAngle;
|
return vec2<f32>(
|
||||||
let sensorPosition = vec2<i32>(agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset);
|
direction.x * angleCos - direction.y * angleSin,
|
||||||
return textureLoad(trailMapIn, sensorPosition, 0);
|
direction.x * angleSin + direction.y * angleCos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
|
return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
|
return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))];
|
||||||
|
}
|
||||||
|
|
||||||
|
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
||||||
|
// Wraps to (-π, π] via fract(); replaces atan2(sin(d), cos(d)).
|
||||||
|
return (fract((targetAngle - sourceAngle) * INV_TAU + 0.5) - 0.5) * TAU;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_seed(id: u32) -> u32 {
|
||||||
|
return id * 747796405u + settings.randomTimeSeed * 2891336453u;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_float(seed: u32) -> f32 {
|
||||||
|
return f32(hash_u32(seed) >> 8u) * (1.0 / 16777216.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_u32(seed: u32) -> u32 {
|
||||||
|
let value = seed * 747796405u + 2891336453u;
|
||||||
|
let word = ((value >> ((value >> 28u) + 4u)) ^ value) * 277803737u;
|
||||||
|
return (word >> 22u) ^ word;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,261 +1,206 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { clamp } from '../../utils/clamp';
|
import { appConfig } from '../../config';
|
||||||
|
import { getRenderQualityBrushSize } from '../../config/brush-size';
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { CommonState } from '../common-state/common-state';
|
||||||
import { BrushSettings } from './brush-settings';
|
import {
|
||||||
|
LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
|
||||||
|
LINE_SEGMENT_VERTICES,
|
||||||
|
LineSegmentBuffer,
|
||||||
|
} from '../common/line-segment-buffer';
|
||||||
|
import lineSegmentShader from '../common/line-segment.wgsl?raw';
|
||||||
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
|
||||||
import shader from './brush.wgsl?raw';
|
import shader from './brush.wgsl?raw';
|
||||||
|
|
||||||
export class BrushPipeline {
|
export interface BrushSettings {
|
||||||
private static readonly UNIFORM_COUNT = 2;
|
brushSize: number;
|
||||||
private static readonly MAX_LINE_COUNT = 20;
|
brushAlpha: number;
|
||||||
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
|
brushDiscardThreshold: number;
|
||||||
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
|
brushGrainNoiseScale: number;
|
||||||
|
brushGrainNoiseOffsetX: number;
|
||||||
|
brushGrainNoiseOffsetY: number;
|
||||||
|
brushGrainMinStrength: number;
|
||||||
|
brushGrainMaxStrength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrushParameters extends BrushSettings {
|
||||||
|
internalRenderAreaMegapixels: number;
|
||||||
|
pixelRatio?: number;
|
||||||
|
selectedColorIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
|
||||||
|
typeof pixelRatio === 'number' && Number.isFinite(pixelRatio) && pixelRatio > 0
|
||||||
|
? pixelRatio
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const UNIFORM_COUNT = 16;
|
||||||
|
|
||||||
|
const setBrushUniformValues = (
|
||||||
|
target: Float32Array,
|
||||||
|
{
|
||||||
|
brushSize,
|
||||||
|
brushAlpha,
|
||||||
|
brushDiscardThreshold,
|
||||||
|
brushGrainNoiseScale,
|
||||||
|
brushGrainNoiseOffsetX,
|
||||||
|
brushGrainNoiseOffsetY,
|
||||||
|
brushGrainMinStrength,
|
||||||
|
brushGrainMaxStrength,
|
||||||
|
internalRenderAreaMegapixels,
|
||||||
|
selectedColorIndex,
|
||||||
|
pixelRatio,
|
||||||
|
}: BrushParameters
|
||||||
|
): void => {
|
||||||
|
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
||||||
|
const brushRadius =
|
||||||
|
(getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) *
|
||||||
|
safePixelRatio) /
|
||||||
|
2;
|
||||||
|
|
||||||
|
target[0] = brushRadius;
|
||||||
|
target[1] = brushRadius * brushRadius;
|
||||||
|
// target[2], target[3] are WGSL alignment padding for brushValue:vec4 — never read by the shader.
|
||||||
|
target[4] = selectedColorIndex === 0 ? 1 : 0;
|
||||||
|
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||||
|
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||||
|
target[7] = brushAlpha;
|
||||||
|
target[8] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
|
||||||
|
target[9] = brushGrainNoiseOffsetX;
|
||||||
|
target[10] = brushGrainNoiseOffsetY;
|
||||||
|
target[11] = brushDiscardThreshold;
|
||||||
|
target[12] = brushGrainMinStrength;
|
||||||
|
target[13] = brushGrainMaxStrength;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BrushPipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly renderPipeline: GPURenderPipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
private linePoints: Array<vec2> = [];
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
private actualPoints: Array<vec2> = [];
|
);
|
||||||
|
private readonly segments: LineSegmentBuffer;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly commonState: CommonState
|
private readonly commonState: CommonState
|
||||||
) {
|
) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout);
|
this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
|
||||||
|
|
||||||
this.vertexBuffer = device.createBuffer({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
size:
|
entries: [
|
||||||
BrushPipeline.MAX_LINE_COUNT *
|
{
|
||||||
BrushPipeline.VERTICES_PER_LINE_SEGMENT *
|
binding: 0,
|
||||||
BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT *
|
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
||||||
Float32Array.BYTES_PER_ELEMENT,
|
buffer: { type: 'uniform' },
|
||||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
const shaderModule = smartCompile(
|
||||||
|
device,
|
||||||
|
CommonState.shaderCode,
|
||||||
|
lineSegmentShader,
|
||||||
|
shader
|
||||||
|
);
|
||||||
|
this.renderPipeline = device.createRenderPipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
vertex: {
|
vertex: {
|
||||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
module: shaderModule,
|
||||||
entryPoint: 'vertex',
|
entryPoint: 'vertex',
|
||||||
buffers: [
|
buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
|
||||||
{
|
|
||||||
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
shaderLocation: 0,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shaderLocation: 1,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shaderLocation: 2,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
fragment: {
|
fragment: {
|
||||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
module: shaderModule,
|
||||||
entryPoint: 'fragment',
|
entryPoint: 'fragment',
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: 'rgba16float',
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
blend: {
|
blend: {
|
||||||
color: {
|
color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
|
||||||
operation: 'add',
|
alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
|
||||||
srcFactor: 'zero',
|
|
||||||
dstFactor: 'one',
|
|
||||||
},
|
|
||||||
alpha: {
|
|
||||||
operation: 'max',
|
|
||||||
srcFactor: 'one',
|
|
||||||
dstFactor: 'one',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
primitive: {
|
primitive: { topology: 'triangle-list' },
|
||||||
topology: 'triangle-list',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
this.uniforms = device.createBuffer({
|
||||||
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroup = this.bindGroup = this.device.createBindGroup({
|
this.bindGroup = device.createBindGroup({
|
||||||
layout: this.bindGroupLayout,
|
layout: this.bindGroupLayout,
|
||||||
entries: [
|
entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public addSwipe(position: vec2) {
|
public addSwipeSegment(from: vec2, to: vec2): void {
|
||||||
this.linePoints.push(position);
|
this.segments.add(from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearSwipes() {
|
public clearSwipes(): void {
|
||||||
this.linePoints.length = 0;
|
this.segments.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({ brushSize, brushSizeVariation }: BrushSettings) {
|
public setParameters(parameters: BrushParameters): void {
|
||||||
this.device.queue.writeBuffer(
|
setBrushUniformValues(this.uniformValues, parameters);
|
||||||
|
writeBufferIfChanged(
|
||||||
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
0,
|
this.uniformValues,
|
||||||
new Float32Array([brushSize / 2, Math.floor((brushSize / 2) * brushSizeVariation)])
|
this.uniformCache
|
||||||
);
|
);
|
||||||
|
this.segments.flush();
|
||||||
|
}
|
||||||
|
|
||||||
this.actualPoints = this.linePoints.slice();
|
public executeSource(
|
||||||
this.linePoints.splice(0, this.linePoints.length - 1);
|
commandEncoder: GPUCommandEncoder,
|
||||||
|
sourceMapOut: GPUTextureView,
|
||||||
|
timestampWrites?: GPURenderPassTimestampWrites
|
||||||
|
): boolean {
|
||||||
|
const lineCount = this.segments.activeCount;
|
||||||
|
if (lineCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.actualPoints.length === 0) {
|
recordBrushPassForE2e();
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
|
||||||
|
timestampWrites,
|
||||||
|
});
|
||||||
|
passEncoder.setPipeline(this.renderPipeline);
|
||||||
|
this.commonState.execute(passEncoder);
|
||||||
|
passEncoder.setBindGroup(1, this.bindGroup);
|
||||||
|
passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
|
||||||
|
passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
|
||||||
|
passEncoder.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.segments.destroy();
|
||||||
|
this.uniforms.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordBrushPassForE2e = (): void => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.actualPoints.length === 1) {
|
const state = window as Window & { __fleetingGardenBrushPasses?: number };
|
||||||
this.actualPoints.push(this.actualPoints[0]); // allow single point swipes
|
state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1;
|
||||||
}
|
|
||||||
|
|
||||||
if (this.actualPoints.length > BrushPipeline.MAX_LINE_COUNT + 1) {
|
|
||||||
this.actualPoints = BrushPipeline.subsampleLinePoints(this.actualPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.device.queue.writeBuffer(
|
|
||||||
this.vertexBuffer,
|
|
||||||
0,
|
|
||||||
new Float32Array(
|
|
||||||
new Array(this.lineCount).fill(0).flatMap((_, i) => {
|
|
||||||
const from = this.actualPoints[i];
|
|
||||||
const to = this.actualPoints[i + 1];
|
|
||||||
const [a, b, c, d] = this.getSegmentBoundingBox(from, to, brushSize / 2);
|
|
||||||
return [a, b, c, b, c, d].flatMap((v) => [...v, ...from, ...to]);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static subsampleLinePoints(points: Array<vec2>): Array<vec2> {
|
|
||||||
const lines = [];
|
|
||||||
for (let i = 0; i < points.length - 2; i++) {
|
|
||||||
lines.push({
|
|
||||||
from: points[i],
|
|
||||||
to: points[i + 1],
|
|
||||||
length: vec2.dist(points[i], points[i + 1]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sumLength = lines.reduce((sum, line) => sum + line.length, 0);
|
|
||||||
|
|
||||||
let currentLineIndex = 0;
|
|
||||||
let lineLengthSoFar = 0;
|
|
||||||
const result: Array<vec2> = [points[0]];
|
|
||||||
for (let i = 1; i < BrushPipeline.MAX_LINE_COUNT; i++) {
|
|
||||||
const t = (i * sumLength) / (BrushPipeline.MAX_LINE_COUNT + 1);
|
|
||||||
while (lineLengthSoFar + lines[currentLineIndex].length < t) {
|
|
||||||
lineLengthSoFar += lines[currentLineIndex].length;
|
|
||||||
currentLineIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = lines[currentLineIndex];
|
|
||||||
const position = vec2.lerp(
|
|
||||||
vec2.create(),
|
|
||||||
line.from,
|
|
||||||
line.to,
|
|
||||||
(t - lineLengthSoFar) / line.length
|
|
||||||
);
|
|
||||||
|
|
||||||
result.push(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(points[points.length - 1]);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSegmentBoundingBox(from: vec2, to: vec2, width: number): Array<vec2> {
|
|
||||||
let dir = vec2.sub(vec2.create(), to, from);
|
|
||||||
vec2.normalize(dir, dir);
|
|
||||||
|
|
||||||
if (vec2.len(dir) === 0) {
|
|
||||||
dir = vec2.fromValues(1, 0); // allow single point swipes
|
|
||||||
}
|
|
||||||
|
|
||||||
const perp = vec2.fromValues(dir[1], -dir[0]);
|
|
||||||
|
|
||||||
vec2.scale(dir, dir, width);
|
|
||||||
vec2.scale(perp, perp, width);
|
|
||||||
|
|
||||||
const offsetStart = vec2.sub(vec2.create(), from, dir);
|
|
||||||
const offsetEnd = vec2.add(vec2.create(), to, dir);
|
|
||||||
|
|
||||||
return [
|
|
||||||
vec2.add(vec2.create(), offsetStart, perp),
|
|
||||||
vec2.sub(vec2.create(), offsetStart, perp),
|
|
||||||
vec2.add(vec2.create(), offsetEnd, perp),
|
|
||||||
vec2.sub(vec2.create(), offsetEnd, perp),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
|
||||||
colorAttachments: [
|
|
||||||
{
|
|
||||||
view: trailMapOut,
|
|
||||||
loadOp: 'load',
|
|
||||||
storeOp: 'store',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
|
||||||
passEncoder.setPipeline(this.pipeline);
|
|
||||||
this.commonState.execute(passEncoder);
|
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
|
||||||
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
|
|
||||||
passEncoder.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
this.vertexBuffer.destroy();
|
|
||||||
this.uniforms.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
|
||||||
return {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
buffer: {
|
|
||||||
type: 'uniform',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private get lineCount() {
|
|
||||||
return clamp(this.actualPoints.length - 1, 0, BrushPipeline.MAX_LINE_COUNT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export interface BrushSettings {
|
|
||||||
brushSize: number;
|
|
||||||
brushSizeVariation: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
|
const SEGMENT_LENGTH_EPSILON: f32 = 0.0001;
|
||||||
|
|
||||||
struct Settings {
|
struct Settings {
|
||||||
brushSize: f32,
|
brushRadius: f32,
|
||||||
brushSizeVariation: f32
|
brushRadiusSquared: f32,
|
||||||
|
// padding to 16-byte alignment for the following vec4
|
||||||
|
_pad0: f32,
|
||||||
|
_pad1: f32,
|
||||||
|
brushValue: vec4<f32>,
|
||||||
|
brushGrainNoiseScale: f32,
|
||||||
|
brushGrainNoiseOffsetX: f32,
|
||||||
|
brushGrainNoiseOffsetY: f32,
|
||||||
|
brushDiscardThreshold: f32,
|
||||||
|
brushGrainMinStrength: f32,
|
||||||
|
brushGrainMaxStrength: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
@ -8,41 +20,100 @@ struct Settings {
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@builtin(position) position: vec4<f32>,
|
@builtin(position) position: vec4<f32>,
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||||
|
@location(3) @interpolate(flat) inverseLengthSquared: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrushTargets {
|
||||||
|
@location(0) source: vec4<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vertex(
|
fn vertex(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@builtin(vertex_index) vertexIndex: u32,
|
||||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
@location(0) start: vec2<f32>,
|
||||||
@location(2) @interpolate(flat) end: vec2<f32>
|
@location(1) end: vec2<f32>
|
||||||
) -> VertexOutput {
|
) -> VertexOutput {
|
||||||
|
let direction = end - start;
|
||||||
|
let denominator = dot(direction, direction);
|
||||||
|
var inverseLengthSquared = 0.0;
|
||||||
|
var normalizedDirection = vec2<f32>(1.0, 0.0);
|
||||||
|
if denominator > SEGMENT_LENGTH_EPSILON {
|
||||||
|
inverseLengthSquared = 1.0 / denominator;
|
||||||
|
normalizedDirection = direction * inverseSqrt(denominator);
|
||||||
|
}
|
||||||
|
let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.brushRadius);
|
||||||
let uv = screenPosition / state.size;
|
let uv = screenPosition / state.size;
|
||||||
let position = uv * 2.0 - 1.0;
|
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
|
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(
|
fn fragment(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||||
) -> @location(0) vec4<f32> {
|
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||||
var distance = distanceFromLine(screenPosition, start, end);
|
) -> BrushTargets {
|
||||||
let noise = textureSample(noise, noiseSampler, screenPosition / state.size / 50);
|
let strength = brushStrength(screenPosition, start, direction, inverseLengthSquared);
|
||||||
distance += noise.r * settings.brushSizeVariation;
|
|
||||||
|
|
||||||
if(distance > settings.brushSize) {
|
if(strength < settings.brushDiscardThreshold) {
|
||||||
discard;
|
discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
return vec4(0, 0, 0, 1);
|
let color = brushOutput(strength);
|
||||||
|
return BrushTargets(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn distanceFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
fn brushStrength(
|
||||||
let pa = position - start;
|
screenPosition: vec2<f32>,
|
||||||
let direction = end - start;
|
start: vec2<f32>,
|
||||||
let q = clamp(dot(pa, direction) / dot(direction, direction), 0, 1);
|
direction: vec2<f32>,
|
||||||
return length(pa - direction * q);
|
inverseLengthSquared: f32
|
||||||
|
) -> f32 {
|
||||||
|
let distanceSquared = distance_squared_from_segment(
|
||||||
|
screenPosition,
|
||||||
|
start,
|
||||||
|
direction,
|
||||||
|
inverseLengthSquared
|
||||||
|
);
|
||||||
|
if distanceSquared > settings.brushRadiusSquared {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxGrainStrength = max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength);
|
||||||
|
if maxGrainStrength < settings.brushDiscardThreshold {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// smoothstep(0.35, 1.0, sqrt(d²/r²)) reparameterized to squared distance:
|
||||||
|
// squaring the edges gives smoothstep(0.1225·r², r², d²), avoiding the sqrt.
|
||||||
|
let safeRadiusSquared = max(settings.brushRadiusSquared, 0.0001);
|
||||||
|
let feather = 1.0 - smoothstep(0.1225 * safeRadiusSquared, safeRadiusSquared, distanceSquared);
|
||||||
|
if feather <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
|
||||||
|
return settings.brushGrainMinStrength * feather;
|
||||||
|
}
|
||||||
|
|
||||||
|
let grainNoise = textureSampleLevel(
|
||||||
|
noise,
|
||||||
|
noiseSampler,
|
||||||
|
screenPosition * settings.brushGrainNoiseScale +
|
||||||
|
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
|
||||||
|
0.0
|
||||||
|
).r;
|
||||||
|
let grainStrength = mix(
|
||||||
|
settings.brushGrainMinStrength,
|
||||||
|
settings.brushGrainMaxStrength,
|
||||||
|
grainNoise
|
||||||
|
);
|
||||||
|
return grainStrength * feather;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brushOutput(strength: f32) -> vec4<f32> {
|
||||||
|
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../../config';
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { generateNoise } from '../../utils/graphics/noise';
|
import { generateNoise } from '../../utils/graphics/noise';
|
||||||
|
|
||||||
export class CommonState {
|
export class CommonState {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 4;
|
||||||
|
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly noise: GPUTextureView;
|
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
|
||||||
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
CommonState.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
private readonly noise: GPUTexture;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
|
|
||||||
public readonly bindGroupLayout: GPUBindGroupLayout;
|
public readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
|
@ -14,8 +23,7 @@ export class CommonState {
|
||||||
public static readonly shaderCode = /* wgsl */ `
|
public static readonly shaderCode = /* wgsl */ `
|
||||||
struct State {
|
struct State {
|
||||||
size: vec2<f32>,
|
size: vec2<f32>,
|
||||||
deltaTime: f32,
|
_padding: vec2<f32>,
|
||||||
time: f32,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> state: State;
|
@group(0) @binding(0) var<uniform> state: State;
|
||||||
|
|
@ -29,11 +37,12 @@ export class CommonState {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.noise = generateNoise({
|
const noise = generateNoise({
|
||||||
device,
|
device,
|
||||||
width: 2048,
|
width: appConfig.pipelines.common.noiseTextureSize,
|
||||||
height: 2048,
|
height: appConfig.pipelines.common.noiseTextureSize,
|
||||||
});
|
});
|
||||||
|
this.noise = noise.texture;
|
||||||
|
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
entries: [
|
entries: [
|
||||||
|
|
@ -74,31 +83,28 @@ export class CommonState {
|
||||||
{
|
{
|
||||||
binding: 1,
|
binding: 1,
|
||||||
resource: this.device.createSampler({
|
resource: this.device.createSampler({
|
||||||
|
addressModeU: 'repeat',
|
||||||
|
addressModeV: 'repeat',
|
||||||
magFilter: 'linear',
|
magFilter: 'linear',
|
||||||
minFilter: 'linear',
|
minFilter: 'linear',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 2,
|
||||||
resource: this.noise,
|
resource: noise.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({
|
public setParameters({ canvasSize }: { canvasSize: vec2 }) {
|
||||||
canvasSize,
|
this.uniformValues[0] = canvasSize[0];
|
||||||
deltaTime,
|
this.uniformValues[1] = canvasSize[1];
|
||||||
time,
|
writeBufferIfChanged(
|
||||||
}: {
|
this.device,
|
||||||
canvasSize: vec2;
|
|
||||||
deltaTime: number;
|
|
||||||
time: number;
|
|
||||||
}) {
|
|
||||||
this.device.queue.writeBuffer(
|
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
0,
|
this.uniformValues,
|
||||||
new Float32Array([...canvasSize, deltaTime, time])
|
this.uniformCache
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,5 +114,6 @@ export class CommonState {
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
|
this.noise.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
src/pipelines/common/line-segment-buffer.ts
Normal file
92
src/pipelines/common/line-segment-buffer.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
export interface LineSegment {
|
||||||
|
from: vec2;
|
||||||
|
to: vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LINE_SEGMENT_VERTICES = 6;
|
||||||
|
const LINE_SEGMENT_ATTRIBUTES = 4;
|
||||||
|
|
||||||
|
export const LINE_SEGMENT_VERTEX_BUFFER_LAYOUT: GPUVertexBufferLayout = {
|
||||||
|
arrayStride: Float32Array.BYTES_PER_ELEMENT * LINE_SEGMENT_ATTRIBUTES,
|
||||||
|
stepMode: 'instance',
|
||||||
|
attributes: [
|
||||||
|
{ shaderLocation: 0, format: 'float32x2', offset: 0 },
|
||||||
|
{
|
||||||
|
shaderLocation: 1,
|
||||||
|
format: 'float32x2',
|
||||||
|
offset: Float32Array.BYTES_PER_ELEMENT * 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LineSegmentBuffer {
|
||||||
|
public readonly vertexBuffer: GPUBuffer;
|
||||||
|
|
||||||
|
private readonly device: GPUDevice;
|
||||||
|
private readonly maxSegments: number;
|
||||||
|
private readonly uploadData: Float32Array;
|
||||||
|
|
||||||
|
private pending: Array<LineSegment> = [];
|
||||||
|
private active: Array<LineSegment> = [];
|
||||||
|
|
||||||
|
public constructor(device: GPUDevice, maxSegments: number) {
|
||||||
|
this.device = device;
|
||||||
|
this.maxSegments = maxSegments;
|
||||||
|
this.uploadData = new Float32Array(maxSegments * LINE_SEGMENT_ATTRIBUTES);
|
||||||
|
this.vertexBuffer = device.createBuffer({
|
||||||
|
size: this.uploadData.byteLength,
|
||||||
|
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public add(from: vec2, to: vec2): void {
|
||||||
|
this.pending.push({ from: vec2.clone(from), to: vec2.clone(to) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
this.pending.length = 0;
|
||||||
|
this.active.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get activeCount(): number {
|
||||||
|
return this.active.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public flush(): void {
|
||||||
|
this.active = this.pending.slice();
|
||||||
|
this.pending.length = 0;
|
||||||
|
|
||||||
|
if (this.active.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.active.length > this.maxSegments) {
|
||||||
|
this.active = subsample(this.active, this.maxSegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
for (const segment of this.active) {
|
||||||
|
this.uploadData[offset++] = segment.from[0];
|
||||||
|
this.uploadData[offset++] = segment.from[1];
|
||||||
|
this.uploadData[offset++] = segment.to[0];
|
||||||
|
this.uploadData[offset++] = segment.to[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.device.queue.writeBuffer(this.vertexBuffer, 0, this.uploadData, 0, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.vertexBuffer.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subsample = (segments: Array<LineSegment>, count: number): Array<LineSegment> => {
|
||||||
|
const result: Array<LineSegment> = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const index = Math.round((i * (segments.length - 1)) / (count - 1));
|
||||||
|
result.push(segments[index]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
35
src/pipelines/common/line-segment.wgsl
Normal file
35
src/pipelines/common/line-segment.wgsl
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Six corners forming two triangles for an instanced segment quad.
|
||||||
|
// X spans [-1, 1] along the segment direction, Y spans [-1, 1] perpendicular.
|
||||||
|
fn segment_vertex_corner(index: u32) -> vec2<f32> {
|
||||||
|
let isRight = index == 2u || index >= 4u;
|
||||||
|
let isTop = index == 0u || index == 2u || index == 4u;
|
||||||
|
return vec2<f32>(
|
||||||
|
select(-1.0, 1.0, isRight),
|
||||||
|
select(-1.0, 1.0, isTop)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_vertex_position(
|
||||||
|
vertexIndex: u32,
|
||||||
|
start: vec2<f32>,
|
||||||
|
end: vec2<f32>,
|
||||||
|
direction: vec2<f32>,
|
||||||
|
radius: f32
|
||||||
|
) -> vec2<f32> {
|
||||||
|
let perpendicular = vec2<f32>(direction.y, -direction.x);
|
||||||
|
let corner = segment_vertex_corner(vertexIndex % 6u);
|
||||||
|
let center = mix(start, end, (corner.x + 1.0) * 0.5);
|
||||||
|
return center + direction * corner.x * radius + perpendicular * corner.y * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distance_squared_from_segment(
|
||||||
|
position: vec2<f32>,
|
||||||
|
start: vec2<f32>,
|
||||||
|
direction: vec2<f32>,
|
||||||
|
inverseLengthSquared: f32
|
||||||
|
) -> f32 {
|
||||||
|
let pa = position - start;
|
||||||
|
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
|
||||||
|
let nearestOffset = pa - direction * q;
|
||||||
|
return dot(nearestOffset, nearestOffset);
|
||||||
|
}
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
|
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
|
||||||
import shader from './copy.wgsl?raw';
|
|
||||||
|
|
||||||
export class CopyPipeline {
|
|
||||||
private static readonly UNIFORM_COUNT = 2;
|
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
|
||||||
private readonly pipeline: GPURenderPipeline;
|
|
||||||
private readonly uniforms: GPUBuffer;
|
|
||||||
|
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
|
||||||
|
|
||||||
private bindGroup?: GPUBindGroup;
|
|
||||||
private previousTrailMapIn?: GPUTextureView;
|
|
||||||
|
|
||||||
public constructor(private readonly device: GPUDevice) {
|
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(CopyPipeline.bindGroupLayout);
|
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
|
||||||
size: CopyPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.vertexBuffer = device.createBuffer({
|
|
||||||
size: 2 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec2<f32>
|
|
||||||
usage: GPUBufferUsage.VERTEX,
|
|
||||||
mappedAtCreation: true,
|
|
||||||
});
|
|
||||||
// prettier-ignore
|
|
||||||
const vertexData = [
|
|
||||||
// U V
|
|
||||||
0.0, 1.0,
|
|
||||||
1.0, 1.0,
|
|
||||||
0.0, 0.0,
|
|
||||||
1.0, 0.0,
|
|
||||||
];
|
|
||||||
new Float32Array(this.vertexBuffer.getMappedRange()).set(vertexData);
|
|
||||||
this.vertexBuffer.unmap();
|
|
||||||
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
|
||||||
layout: device.createPipelineLayout({
|
|
||||||
bindGroupLayouts: [this.bindGroupLayout],
|
|
||||||
}),
|
|
||||||
vertex: {
|
|
||||||
module: smartCompile(device, shader),
|
|
||||||
entryPoint: 'vertex',
|
|
||||||
buffers: [
|
|
||||||
{
|
|
||||||
arrayStride: 2 * Float32Array.BYTES_PER_ELEMENT,
|
|
||||||
stepMode: 'vertex',
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
shaderLocation: 0,
|
|
||||||
offset: 0,
|
|
||||||
format: 'float32x2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fragment: {
|
|
||||||
module: smartCompile(device, shader),
|
|
||||||
entryPoint: 'fragment',
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
format: 'rgba16float',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
primitive: {
|
|
||||||
topology: 'triangle-strip',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public execute(
|
|
||||||
commandEncoder: GPUCommandEncoder,
|
|
||||||
trailMapIn: GPUTextureView,
|
|
||||||
trailMapOut: GPUTextureView,
|
|
||||||
scale: vec2 = vec2.fromValues(1, 1)
|
|
||||||
) {
|
|
||||||
this.device.queue.writeBuffer(this.uniforms, 0, new Float32Array(scale));
|
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
|
||||||
colorAttachments: [
|
|
||||||
{
|
|
||||||
view: trailMapOut,
|
|
||||||
loadOp: 'clear',
|
|
||||||
storeOp: 'store',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ensureBindGroupExists(trailMapIn);
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
|
||||||
passEncoder.setPipeline(this.pipeline);
|
|
||||||
passEncoder.setBindGroup(0, this.bindGroup);
|
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
|
||||||
passEncoder.draw(4, 1);
|
|
||||||
passEncoder.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
this.vertexBuffer.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureBindGroupExists(trailMapIn: GPUTextureView) {
|
|
||||||
if (this.previousTrailMapIn !== trailMapIn) {
|
|
||||||
this.bindGroup = this.device.createBindGroup({
|
|
||||||
layout: this.bindGroupLayout,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: this.device.createSampler({
|
|
||||||
magFilter: 'linear',
|
|
||||||
minFilter: 'linear',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
resource: trailMapIn,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.previousTrailMapIn = trailMapIn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
|
||||||
return {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
visibility: GPUShaderStage.VERTEX,
|
|
||||||
buffer: {
|
|
||||||
type: 'uniform',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
sampler: {
|
|
||||||
type: 'filtering',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
texture: {
|
|
||||||
sampleType: 'float',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
struct VertexOutput {
|
|
||||||
@builtin(position) position: vec4<f32>,
|
|
||||||
@location(0) uv: vec2<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
@vertex
|
|
||||||
fn vertex(@location(0) uv: vec2<f32>) -> VertexOutput {
|
|
||||||
let ndc = uv * sourceScaler * vec2(2) - vec2(1);
|
|
||||||
return VertexOutput(vec4(ndc.x, -ndc.y, 0, 1), uv);
|
|
||||||
}
|
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> sourceScaler: vec2<f32>;
|
|
||||||
@group(0) @binding(1) var Sampler: sampler;
|
|
||||||
@group(0) @binding(2) var original: texture_2d<f32>;
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
|
||||||
return textureSample(original, Sampler, uv);
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +1,171 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
inverseDiffusionRateTrails: f32,
|
inverseDiffusionRateTrails: f32,
|
||||||
decayRateTrails: f32,
|
decayRateTrails: f32,
|
||||||
inverseDiffusionRateBrush: f32,
|
diffusionNeighborScale: f32,
|
||||||
decayRateBrush: f32,
|
brushDecayAlphaMultiplier: f32,
|
||||||
|
brushDecayAlphaSubtract: f32,
|
||||||
|
padding0: f32,
|
||||||
|
padding1: f32,
|
||||||
|
padding2: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORKGROUP_SIZE_X = __WORKGROUP_SIZE__u;
|
||||||
|
const WORKGROUP_SIZE_Y = __WORKGROUP_SIZE__u;
|
||||||
|
// Half a quantization step of rgba8unorm (1/255 ≈ 0.00392). Subtracted from
|
||||||
|
// RGB each frame so multiplicative decay can fall through the unorm
|
||||||
|
// quantization floor; without it, the smallest nonzero level (1/255) is a
|
||||||
|
// fixed point and trails never reach pure black.
|
||||||
|
const TRAIL_RGB_DECAY_SUBTRACT: f32 = 0.00196;
|
||||||
|
// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass
|
||||||
|
// can be served from workgroup memory without bounds checks for interior tiles.
|
||||||
|
const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
|
||||||
|
const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u;
|
||||||
|
const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y;
|
||||||
|
// 1.0 / 2^32, used to map a 32-bit hash to [0, 1).
|
||||||
|
const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(1) @binding(1) var Sampler: sampler;
|
@group(0) @binding(1) var trailMap: texture_2d<f32>;
|
||||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
// Per-frame deposit accumulator written sparsely by agents. Summed with
|
||||||
|
// trailMap at tile-load so deposits propagate through the diffusion kernel
|
||||||
|
// in the same frame.
|
||||||
|
@group(0) @binding(3) var depositMap: texture_2d<f32>;
|
||||||
|
|
||||||
|
var<workgroup> tile: array<vec4<f32>, TILE_TEXEL_COUNT>;
|
||||||
|
var<workgroup> tileTrailStrength: array<f32, TILE_TEXEL_COUNT>;
|
||||||
|
|
||||||
@fragment
|
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
fn main(
|
||||||
var current = textureSample(trailMap, Sampler, uv);
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||||
|
@builtin(workgroup_id) workgroup_id: vec3<u32>
|
||||||
|
) {
|
||||||
|
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
||||||
|
let textureBound = textureSize - vec2<i32>(1, 1);
|
||||||
|
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
|
||||||
|
let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
|
||||||
|
|
||||||
current += (
|
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
|
||||||
propagate(uv, vec2(-1.0, -1.0), current)
|
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
|
||||||
+ propagate(uv, vec2(-1.0, 1.0), current)
|
let sourcePixel = clamp(
|
||||||
+ propagate(uv, vec2(1.0, -1.0), current)
|
vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1),
|
||||||
+ propagate(uv, vec2(1.0, 1.0), current)
|
vec2<i32>(0, 0),
|
||||||
|
textureBound
|
||||||
|
);
|
||||||
|
let texel = textureLoad(trailMap, sourcePixel, 0)
|
||||||
|
+ textureLoad(depositMap, sourcePixel, 0);
|
||||||
|
tile[tileIndex] = texel;
|
||||||
|
tileTrailStrength[tileIndex] = length(texel.rgb);
|
||||||
|
}
|
||||||
|
|
||||||
+ propagate(uv, vec2(-1.0, 0.0), current)
|
workgroupBarrier();
|
||||||
+ propagate(uv, vec2(0.0, -1.0), current)
|
|
||||||
+ propagate(uv, vec2(1.0, 0.0), current)
|
|
||||||
+ propagate(uv, vec2(0.0, 1.0), current)
|
|
||||||
) / 8;
|
|
||||||
|
|
||||||
|
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
|
||||||
|
if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u);
|
||||||
|
let c = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
|
||||||
|
let rowNorth = c - TILE_SIZE_X;
|
||||||
|
let rowSouth = c + TILE_SIZE_X;
|
||||||
|
|
||||||
|
// Batch-load all 8 neighbour texels and strengths into registers up front
|
||||||
|
// so the compiler can schedule LDS reads in parallel.
|
||||||
|
let current = tile[c];
|
||||||
|
let nTL = tile[rowNorth - 1u];
|
||||||
|
let nT = tile[rowNorth];
|
||||||
|
let nTR = tile[rowNorth + 1u];
|
||||||
|
let nL = tile[c - 1u];
|
||||||
|
let nR = tile[c + 1u];
|
||||||
|
let nBL = tile[rowSouth - 1u];
|
||||||
|
let nB = tile[rowSouth];
|
||||||
|
let nBR = tile[rowSouth + 1u];
|
||||||
|
|
||||||
|
let sTL = tileTrailStrength[rowNorth - 1u];
|
||||||
|
let sT = tileTrailStrength[rowNorth];
|
||||||
|
let sTR = tileTrailStrength[rowNorth + 1u];
|
||||||
|
let sL = tileTrailStrength[c - 1u];
|
||||||
|
let sR = tileTrailStrength[c + 1u];
|
||||||
|
let sBL = tileTrailStrength[rowSouth - 1u];
|
||||||
|
let sB = tileTrailStrength[rowSouth];
|
||||||
|
let sBR = tileTrailStrength[rowSouth + 1u];
|
||||||
|
|
||||||
|
let random = random_from_pixel(pixel);
|
||||||
|
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
|
||||||
|
|
||||||
|
let propagated =
|
||||||
|
propagate_value(nTL, sTL, current, trailWeight)
|
||||||
|
+ propagate_value(nT, sT, current, trailWeight)
|
||||||
|
+ propagate_value(nTR, sTR, current, trailWeight)
|
||||||
|
+ propagate_value(nL, sL, current, trailWeight)
|
||||||
|
+ propagate_value(nR, sR, current, trailWeight)
|
||||||
|
+ propagate_value(nBL, sBL, current, trailWeight)
|
||||||
|
+ propagate_value(nB, sB, current, trailWeight)
|
||||||
|
+ propagate_value(nBR, sBR, current, trailWeight);
|
||||||
|
|
||||||
|
let updated = current + propagated * settings.diffusionNeighborScale;
|
||||||
let decayed = clamp(vec4(
|
let decayed = clamp(vec4(
|
||||||
current.rgb * settings.decayRateTrails,
|
updated.rgb * settings.decayRateTrails - vec3(TRAIL_RGB_DECAY_SUBTRACT),
|
||||||
max(0, current.a + (current.a - 1.001) * settings.decayRateBrush)
|
updated.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract
|
||||||
), vec4(0), vec4(1));
|
), vec4(0), vec4(1));
|
||||||
|
|
||||||
return decayed;
|
textureStore(trailMapOut, pixel, decayed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn propagate_value(
|
||||||
fn propagate(uv: vec2<f32>, offset: vec2<f32>, currentColor: vec4<f32>) -> vec4<f32> {
|
neighbour: vec4<f32>,
|
||||||
let neighbour = textureSample(trailMap, Sampler, uv + offset / state.size);
|
neighbourStrength: f32,
|
||||||
var random = textureSample(noise, noiseSampler, uv + offset / state.size * 0.5).r;
|
current: vec4<f32>,
|
||||||
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
|
trailWeight: f32
|
||||||
|
) -> vec4<f32> {
|
||||||
|
let difference = clamp(neighbour - current, vec4(0), vec4(1));
|
||||||
return vec4(
|
return vec4(
|
||||||
vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)),
|
vec3(neighbourStrength * trailWeight),
|
||||||
length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush)
|
neighbour.a * trailWeight
|
||||||
) * difference;
|
) * difference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn random_from_pixel(pixel: vec2<i32>) -> f32 {
|
||||||
|
let p = vec2<u32>(pixel);
|
||||||
|
var hash = p.x * 1664525u + p.y * 1013904223u + 374761393u;
|
||||||
|
hash = (hash ^ (hash >> 16u)) * 2246822519u;
|
||||||
|
hash = (hash ^ (hash >> 13u)) * 3266489917u;
|
||||||
|
hash = hash ^ (hash >> 16u);
|
||||||
|
return f32(hash) * HASH_TO_UNIT_FLOAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximates pow(r, inverseRate) piecewise between powers (r, r^2, r^4, r^8, r^16)
|
||||||
|
// so we can vary diffusion sharpness without paying for a real pow() per pixel.
|
||||||
|
fn diffusion_weight(
|
||||||
|
r: f32,
|
||||||
|
inverseRate: f32
|
||||||
|
) -> f32 {
|
||||||
|
if inverseRate < 1.0 {
|
||||||
|
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
|
||||||
|
return mix(
|
||||||
|
rootApproximation,
|
||||||
|
r,
|
||||||
|
clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let r2 = r * r;
|
||||||
|
if inverseRate < 2.0 {
|
||||||
|
return mix(r, r2, inverseRate - 1.0);
|
||||||
|
}
|
||||||
|
let r4 = r2 * r2;
|
||||||
|
if inverseRate < 4.0 {
|
||||||
|
// (inverseRate - 2.0) / (4.0 - 2.0)
|
||||||
|
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
|
||||||
|
}
|
||||||
|
let r8 = r4 * r4;
|
||||||
|
if inverseRate < 8.0 {
|
||||||
|
// (inverseRate - 4.0) / (8.0 - 4.0)
|
||||||
|
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
|
||||||
|
}
|
||||||
|
let r16 = r8 * r8;
|
||||||
|
// (inverseRate - 8.0) / (16.0 - 8.0); past 16, falls off as 16/inverseRate.
|
||||||
|
return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
|
||||||
|
* min(1.0, 16.0 / inverseRate);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,109 @@
|
||||||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../../config';
|
||||||
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
|
||||||
import shader from './diffuse.wgsl?raw';
|
import shader from './diffuse.wgsl?raw';
|
||||||
import { DiffusionSettings } from './diffusion-settings';
|
|
||||||
|
export interface DiffusionSettings {
|
||||||
|
diffusionRateTrails: number;
|
||||||
|
decayRateTrails: number;
|
||||||
|
decayRateBrush: number;
|
||||||
|
diffusionDecayRateDivisor: number;
|
||||||
|
diffusionNeighborDivisor: number;
|
||||||
|
brushDecayAlphaOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffusionUniformSettings = Pick<
|
||||||
|
DiffusionSettings,
|
||||||
|
| 'diffusionRateTrails'
|
||||||
|
| 'decayRateTrails'
|
||||||
|
| 'decayRateBrush'
|
||||||
|
| 'diffusionDecayRateDivisor'
|
||||||
|
| 'diffusionNeighborDivisor'
|
||||||
|
| 'brushDecayAlphaOffset'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
|
||||||
|
1 /
|
||||||
|
(Number.isFinite(diffusionRate) &&
|
||||||
|
diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
|
||||||
|
? diffusionRate
|
||||||
|
: appConfig.pipelines.diffusion.minDiffusionRate);
|
||||||
|
|
||||||
|
const setDiffusionUniformValues = (
|
||||||
|
target: Float32Array,
|
||||||
|
{
|
||||||
|
diffusionRateTrails,
|
||||||
|
decayRateTrails,
|
||||||
|
decayRateBrush,
|
||||||
|
diffusionDecayRateDivisor,
|
||||||
|
diffusionNeighborDivisor,
|
||||||
|
brushDecayAlphaOffset,
|
||||||
|
}: DiffusionUniformSettings
|
||||||
|
): void => {
|
||||||
|
const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
|
||||||
|
const brushDecayRate = decayRateBrush / decayDivisor;
|
||||||
|
const neighborDivisor = Number.isFinite(diffusionNeighborDivisor)
|
||||||
|
? Math.max(1, diffusionNeighborDivisor)
|
||||||
|
: 1;
|
||||||
|
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
|
||||||
|
target[1] = decayRateTrails / decayDivisor;
|
||||||
|
target[2] = 1 / neighborDivisor;
|
||||||
|
target[3] = 1 + brushDecayRate;
|
||||||
|
target[4] = brushDecayAlphaOffset * brushDecayRate;
|
||||||
|
target[5] = 0;
|
||||||
|
target[6] = 0;
|
||||||
|
target[7] = 0;
|
||||||
|
};
|
||||||
|
|
||||||
export class DiffusionPipeline {
|
export class DiffusionPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly WORKGROUP_SIZE = 16;
|
||||||
|
private static readonly UNIFORM_COUNT = 8;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
// 1x1 zero texture used as the depositMap binding when callers don't supply
|
||||||
|
// one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for
|
||||||
|
// out-of-bounds coordinates, so the diffusion shader sums in zeros.
|
||||||
|
private readonly emptyDepositTexture: GPUTexture;
|
||||||
|
private readonly emptyDepositTextureView: GPUTextureView;
|
||||||
|
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
||||||
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
private readonly getBindGroup = createBindGroupCache<
|
||||||
|
[GPUTextureView, GPUTextureView, GPUTextureView]
|
||||||
|
>((trailMapIn, trailMapOut, depositMap) =>
|
||||||
|
this.device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||||
|
{ binding: 1, resource: trailMapIn },
|
||||||
|
{ binding: 2, resource: trailMapOut },
|
||||||
|
{ binding: 3, resource: depositMap },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
private bindGroup?: GPUBindGroup;
|
public constructor(private readonly device: GPUDevice) {
|
||||||
private previousTrailMapIn?: GPUTextureView;
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private readonly device: GPUDevice,
|
|
||||||
private readonly commonState: CommonState
|
|
||||||
) {
|
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(
|
this.bindGroupLayout = device.createBindGroupLayout(
|
||||||
DiffusionPipeline.bindGroupLayout
|
DiffusionPipeline.bindGroupLayout
|
||||||
);
|
);
|
||||||
|
|
||||||
const { buffer, vertex } = setUpFullScreenQuad(device);
|
this.pipeline = device.createComputePipeline({
|
||||||
this.vertexBuffer = buffer;
|
|
||||||
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
vertex,
|
compute: {
|
||||||
fragment: {
|
module: smartCompile(device, this.shaderCode),
|
||||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
entryPoint: 'main',
|
||||||
entryPoint: 'fragment',
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
format: 'rgba16float',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
primitive: {
|
|
||||||
topology: 'triangle-strip',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -49,85 +111,81 @@ export class DiffusionPipeline {
|
||||||
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.emptyDepositTexture = device.createTexture({
|
||||||
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
size: { width: 1, height: 1 },
|
||||||
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
|
});
|
||||||
|
this.emptyDepositTextureView = this.emptyDepositTexture.createView();
|
||||||
|
const clearEncoder = device.createCommandEncoder();
|
||||||
|
const clearPass = clearEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: this.emptyDepositTextureView,
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
clearPass.end();
|
||||||
|
device.queue.submit([clearEncoder.finish()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({
|
public setParameters({
|
||||||
diffusionRateTrails,
|
diffusionRateTrails,
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
|
diffusionDecayRateDivisor,
|
||||||
|
diffusionNeighborDivisor,
|
||||||
|
brushDecayAlphaOffset,
|
||||||
}: DiffusionSettings) {
|
}: DiffusionSettings) {
|
||||||
this.device.queue.writeBuffer(
|
setDiffusionUniformValues(this.uniformValues, {
|
||||||
|
diffusionRateTrails,
|
||||||
|
decayRateTrails,
|
||||||
|
decayRateBrush,
|
||||||
|
diffusionDecayRateDivisor,
|
||||||
|
diffusionNeighborDivisor,
|
||||||
|
brushDecayAlphaOffset,
|
||||||
|
});
|
||||||
|
writeBufferIfChanged(
|
||||||
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
0,
|
this.uniformValues,
|
||||||
new Float32Array([
|
this.uniformCache
|
||||||
1 / diffusionRateTrails,
|
|
||||||
decayRateTrails / 1000,
|
|
||||||
1 / diffusionRateBrush,
|
|
||||||
decayRateBrush / 1000,
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public execute(
|
public execute(
|
||||||
commandEncoder: GPUCommandEncoder,
|
commandEncoder: GPUCommandEncoder,
|
||||||
trailMapIn: GPUTextureView,
|
trailMapIn: GPUTextureView,
|
||||||
trailMapOut: GPUTextureView
|
trailMapOut: GPUTextureView,
|
||||||
|
size: vec2,
|
||||||
|
depositMap: GPUTextureView | null,
|
||||||
|
timestampWrites?: GPUComputePassTimestampWrites
|
||||||
) {
|
) {
|
||||||
this.ensureBindGroupExists(trailMapIn);
|
const bindGroup = this.getBindGroup(
|
||||||
|
trailMapIn,
|
||||||
|
trailMapOut,
|
||||||
|
depositMap ?? this.emptyDepositTextureView
|
||||||
|
);
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
const passEncoder = commandEncoder.beginComputePass(
|
||||||
colorAttachments: [
|
timestampWrites ? { timestampWrites } : undefined
|
||||||
{
|
);
|
||||||
view: trailMapOut,
|
|
||||||
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
||||||
loadOp: 'clear',
|
|
||||||
storeOp: 'store',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
passEncoder.setBindGroup(0, bindGroup);
|
||||||
this.commonState.execute(passEncoder);
|
passEncoder.dispatchWorkgroups(
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE),
|
||||||
passEncoder.draw(4, 1);
|
Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE)
|
||||||
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureBindGroupExists(trailMapIn: GPUTextureView) {
|
|
||||||
if (this.previousTrailMapIn !== trailMapIn) {
|
|
||||||
this.bindGroup = this.device.createBindGroup({
|
|
||||||
layout: this.bindGroupLayout,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: this.device.createSampler({
|
|
||||||
magFilter: 'linear',
|
|
||||||
minFilter: 'linear',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
resource: trailMapIn,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.previousTrailMapIn = trailMapIn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.vertexBuffer.destroy();
|
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
|
this.emptyDepositTexture.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
||||||
|
|
@ -135,21 +193,29 @@ export class DiffusionPipeline {
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
binding: 0,
|
binding: 0,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
buffer: {
|
buffer: {
|
||||||
type: 'uniform',
|
type: 'uniform',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
binding: 1,
|
binding: 1,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
sampler: {
|
texture: {
|
||||||
type: 'filtering',
|
sampleType: 'float',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 2,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
storageTexture: {
|
||||||
|
access: 'write-only',
|
||||||
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 3,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
texture: {
|
texture: {
|
||||||
sampleType: 'float',
|
sampleType: 'float',
|
||||||
},
|
},
|
||||||
|
|
@ -157,4 +223,11 @@ export class DiffusionPipeline {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get shaderCode(): string {
|
||||||
|
return shader.replaceAll(
|
||||||
|
'__WORKGROUP_SIZE__',
|
||||||
|
DiffusionPipeline.WORKGROUP_SIZE.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export interface DiffusionSettings {
|
|
||||||
diffusionRateTrails: number;
|
|
||||||
decayRateTrails: number;
|
|
||||||
diffusionRateBrush: number;
|
|
||||||
decayRateBrush: number;
|
|
||||||
}
|
|
||||||
208
src/pipelines/eraser/eraser-agent-pipeline.ts
Normal file
208
src/pipelines/eraser/eraser-agent-pipeline.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
|
import {
|
||||||
|
dispatchAgentWorkgroups,
|
||||||
|
getAgentWorkgroupSize,
|
||||||
|
substituteAgentWorkgroupSize,
|
||||||
|
} from '../agents/agent-dispatch';
|
||||||
|
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
|
||||||
|
import shader from './eraser-agent.wgsl?raw';
|
||||||
|
|
||||||
|
interface Bounds {
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EraserAgentPipeline {
|
||||||
|
private static readonly UNIFORM_COUNT = 8;
|
||||||
|
|
||||||
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
private readonly pipeline: GPUComputePipeline;
|
||||||
|
private readonly uniforms: GPUBuffer;
|
||||||
|
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
|
||||||
|
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||||
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>(
|
||||||
|
(agentsBuffer, eraserMask) =>
|
||||||
|
this.device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||||
|
{ binding: 1, resource: { buffer: agentsBuffer } },
|
||||||
|
{ binding: 2, resource: eraserMask },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
private pendingSegmentCount = 0;
|
||||||
|
private activeSegmentCount = 0;
|
||||||
|
private pendingBounds: Bounds | null = null;
|
||||||
|
private agentCount = 0;
|
||||||
|
private readonly workgroupSize: number;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
private readonly getAgentsBuffer: () => GPUBuffer
|
||||||
|
) {
|
||||||
|
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
|
||||||
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
buffer: {
|
||||||
|
type: 'uniform',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 1,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
buffer: {
|
||||||
|
type: 'storage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
texture: {
|
||||||
|
sampleType: 'float',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.uniforms = this.device.createBuffer({
|
||||||
|
size: EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.workgroupSize = getAgentWorkgroupSize(device, 'eraser');
|
||||||
|
this.pipeline = device.createComputePipeline({
|
||||||
|
layout: device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
|
}),
|
||||||
|
compute: {
|
||||||
|
module: smartCompile(
|
||||||
|
device,
|
||||||
|
substituteAgentWorkgroupSize(device, agentSchema, 'eraser'),
|
||||||
|
shader
|
||||||
|
),
|
||||||
|
entryPoint: 'main',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSwipeSegment(from: vec2, to: vec2): void {
|
||||||
|
this.pendingSegmentCount += 1;
|
||||||
|
this.pendingBounds = includeSegment(this.pendingBounds, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipes(): void {
|
||||||
|
this.pendingSegmentCount = 0;
|
||||||
|
this.activeSegmentCount = 0;
|
||||||
|
this.pendingBounds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setParameters({
|
||||||
|
agentCount,
|
||||||
|
eraserMaskAlphaThreshold,
|
||||||
|
eraserSize,
|
||||||
|
maskSize,
|
||||||
|
}: {
|
||||||
|
agentCount: number;
|
||||||
|
eraserMaskAlphaThreshold: number;
|
||||||
|
eraserSize: number;
|
||||||
|
maskSize: vec2;
|
||||||
|
}): void {
|
||||||
|
this.agentCount = agentCount;
|
||||||
|
this.activeSegmentCount = this.pendingSegmentCount;
|
||||||
|
const activeBounds = expandBoundsToMask(this.pendingBounds, eraserSize / 2, maskSize);
|
||||||
|
this.pendingSegmentCount = 0;
|
||||||
|
this.pendingBounds = null;
|
||||||
|
|
||||||
|
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
|
||||||
|
this.uniformValues[1] = eraserMaskAlphaThreshold;
|
||||||
|
this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0]));
|
||||||
|
this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1]));
|
||||||
|
this.uniformValues[4] = activeBounds.minX;
|
||||||
|
this.uniformValues[5] = activeBounds.minY;
|
||||||
|
this.uniformValues[6] = activeBounds.maxX;
|
||||||
|
this.uniformValues[7] = activeBounds.maxY;
|
||||||
|
writeBufferIfChanged(
|
||||||
|
this.device,
|
||||||
|
this.uniforms,
|
||||||
|
this.uniformValues,
|
||||||
|
this.uniformCache
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasActiveMask(): boolean {
|
||||||
|
return this.activeSegmentCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public execute(
|
||||||
|
commandEncoder: GPUCommandEncoder,
|
||||||
|
eraserMask: GPUTextureView,
|
||||||
|
timestampWrites?: GPUComputePassTimestampWrites
|
||||||
|
): void {
|
||||||
|
if (!this.hasActiveMask() || this.agentCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passEncoder = commandEncoder.beginComputePass(
|
||||||
|
timestampWrites ? { timestampWrites } : undefined
|
||||||
|
);
|
||||||
|
passEncoder.setPipeline(this.pipeline);
|
||||||
|
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
|
||||||
|
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
|
||||||
|
passEncoder.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.uniforms.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeSegment = (bounds: Bounds | null, from: vec2, to: vec2): Bounds => {
|
||||||
|
const minX = Math.min(from[0], to[0]);
|
||||||
|
const minY = Math.min(from[1], to[1]);
|
||||||
|
const maxX = Math.max(from[0], to[0]);
|
||||||
|
const maxY = Math.max(from[1], to[1]);
|
||||||
|
if (!bounds) {
|
||||||
|
return { maxX, maxY, minX, minY };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maxX: Math.max(bounds.maxX, maxX),
|
||||||
|
maxY: Math.max(bounds.maxY, maxY),
|
||||||
|
minX: Math.min(bounds.minX, minX),
|
||||||
|
minY: Math.min(bounds.minY, minY),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandBoundsToMask = (
|
||||||
|
bounds: Bounds | null,
|
||||||
|
radius: number,
|
||||||
|
maskSize: vec2
|
||||||
|
): Bounds => {
|
||||||
|
const maxX = Math.max(0, maskSize[0] - 1);
|
||||||
|
const maxY = Math.max(0, maskSize[1] - 1);
|
||||||
|
if (!bounds) {
|
||||||
|
return { maxX, maxY, minX: 0, minY: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
maxX: Math.min(maxX, bounds.maxX + radius),
|
||||||
|
maxY: Math.min(maxY, bounds.maxY + radius),
|
||||||
|
minX: Math.max(0, bounds.minX - radius),
|
||||||
|
minY: Math.max(0, bounds.minY - radius),
|
||||||
|
};
|
||||||
|
};
|
||||||
44
src/pipelines/eraser/eraser-agent.wgsl
Normal file
44
src/pipelines/eraser/eraser-agent.wgsl
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
struct Settings {
|
||||||
|
agentCount: u32,
|
||||||
|
eraserMaskAlphaThreshold: f32,
|
||||||
|
maskWidth: u32,
|
||||||
|
maskHeight: u32,
|
||||||
|
boundsMin: vec2<f32>,
|
||||||
|
boundsMax: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
@group(1) @binding(2) var eraserMask: texture_2d<f32>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
|
fn main(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
|
) {
|
||||||
|
let id = get_id(global_id);
|
||||||
|
|
||||||
|
if id >= settings.agentCount {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorIndex = agents[id].colorIndex;
|
||||||
|
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = agents[id].position;
|
||||||
|
if any(position < settings.boundsMin) || any(position > settings.boundsMax) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maskSize = vec2<i32>(i32(settings.maskWidth), i32(settings.maskHeight));
|
||||||
|
let maskPosition = clamp(
|
||||||
|
vec2<i32>(position),
|
||||||
|
vec2<i32>(0, 0),
|
||||||
|
maskSize - vec2<i32>(1, 1)
|
||||||
|
);
|
||||||
|
let maskSample = textureLoad(eraserMask, maskPosition, 0);
|
||||||
|
|
||||||
|
if maskSample.r < settings.eraserMaskAlphaThreshold {
|
||||||
|
agents[id].colorIndex = -1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/pipelines/eraser/eraser-texture-pipeline.ts
Normal file
186
src/pipelines/eraser/eraser-texture-pipeline.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../../config';
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
|
import { CommonState } from '../common-state/common-state';
|
||||||
|
import {
|
||||||
|
LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
|
||||||
|
LINE_SEGMENT_VERTICES,
|
||||||
|
LineSegmentBuffer,
|
||||||
|
} from '../common/line-segment-buffer';
|
||||||
|
import lineSegmentShader from '../common/line-segment.wgsl?raw';
|
||||||
|
import {
|
||||||
|
ERASER_MASK_TEXTURE_FORMAT,
|
||||||
|
TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
} from '../texture-formats';
|
||||||
|
import shader from './eraser-texture.wgsl?raw';
|
||||||
|
|
||||||
|
interface EraserTextureParameters {
|
||||||
|
eraserSize: number;
|
||||||
|
eraserLineDistanceEpsilon: number;
|
||||||
|
eraserClearRed: number;
|
||||||
|
eraserClearGreen: number;
|
||||||
|
eraserClearBlue: number;
|
||||||
|
eraserClearAlpha: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIFORM_COUNT = 8;
|
||||||
|
const TARGET_FORMATS: Array<GPUTextureFormat> = [
|
||||||
|
ERASER_MASK_TEXTURE_FORMAT,
|
||||||
|
TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
];
|
||||||
|
|
||||||
|
export class EraserTexturePipeline {
|
||||||
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
|
private readonly bindGroup: GPUBindGroup;
|
||||||
|
private readonly combinedPipeline: GPURenderPipeline;
|
||||||
|
private readonly uniforms: GPUBuffer;
|
||||||
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
private readonly segments: LineSegmentBuffer;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly device: GPUDevice,
|
||||||
|
private readonly commonState: CommonState
|
||||||
|
) {
|
||||||
|
this.segments = new LineSegmentBuffer(
|
||||||
|
device,
|
||||||
|
appConfig.pipelines.eraser.maxTextureLineCount
|
||||||
|
);
|
||||||
|
|
||||||
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const shaderModule = smartCompile(
|
||||||
|
device,
|
||||||
|
CommonState.shaderCode,
|
||||||
|
lineSegmentShader,
|
||||||
|
shader
|
||||||
|
);
|
||||||
|
this.combinedPipeline = device.createRenderPipeline({
|
||||||
|
layout: device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
||||||
|
}),
|
||||||
|
vertex: {
|
||||||
|
module: shaderModule,
|
||||||
|
entryPoint: 'vertex',
|
||||||
|
buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
|
||||||
|
},
|
||||||
|
fragment: {
|
||||||
|
module: shaderModule,
|
||||||
|
entryPoint: 'fragmentCombined',
|
||||||
|
targets: TARGET_FORMATS.map((format) => ({ format })),
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.uniforms = device.createBuffer({
|
||||||
|
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bindGroup = device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSwipeSegment(from: vec2, to: vec2): void {
|
||||||
|
this.segments.add(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSwipes(): void {
|
||||||
|
this.segments.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setParameters({
|
||||||
|
eraserSize,
|
||||||
|
eraserLineDistanceEpsilon,
|
||||||
|
eraserClearRed,
|
||||||
|
eraserClearGreen,
|
||||||
|
eraserClearBlue,
|
||||||
|
eraserClearAlpha,
|
||||||
|
}: EraserTextureParameters): void {
|
||||||
|
const eraserRadius = eraserSize / 2;
|
||||||
|
|
||||||
|
this.uniformValues[0] = eraserRadius * eraserRadius;
|
||||||
|
this.uniformValues[1] = eraserLineDistanceEpsilon;
|
||||||
|
this.uniformValues[2] = eraserClearRed;
|
||||||
|
this.uniformValues[3] = eraserClearGreen;
|
||||||
|
this.uniformValues[4] = eraserClearBlue;
|
||||||
|
this.uniformValues[5] = eraserClearAlpha;
|
||||||
|
this.uniformValues[6] = eraserRadius;
|
||||||
|
writeBufferIfChanged(
|
||||||
|
this.device,
|
||||||
|
this.uniforms,
|
||||||
|
this.uniformValues,
|
||||||
|
this.uniformCache
|
||||||
|
);
|
||||||
|
|
||||||
|
this.segments.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public executeCombined(
|
||||||
|
commandEncoder: GPUCommandEncoder,
|
||||||
|
eraserMaskOut: GPUTextureView,
|
||||||
|
sourceMapOut: GPUTextureView,
|
||||||
|
trailMapOut: GPUTextureView,
|
||||||
|
timestampWrites?: GPURenderPassTimestampWrites
|
||||||
|
): void {
|
||||||
|
const lineCount = this.segments.activeCount;
|
||||||
|
if (lineCount === 0) {
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: eraserMaskOut,
|
||||||
|
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestampWrites,
|
||||||
|
});
|
||||||
|
passEncoder.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: eraserMaskOut,
|
||||||
|
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' },
|
||||||
|
{ view: trailMapOut, loadOp: 'load', storeOp: 'store' },
|
||||||
|
],
|
||||||
|
timestampWrites,
|
||||||
|
});
|
||||||
|
passEncoder.setPipeline(this.combinedPipeline);
|
||||||
|
this.commonState.execute(passEncoder);
|
||||||
|
passEncoder.setBindGroup(1, this.bindGroup);
|
||||||
|
passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
|
||||||
|
passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
|
||||||
|
passEncoder.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.segments.destroy();
|
||||||
|
this.uniforms.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/pipelines/eraser/eraser-texture.wgsl
Normal file
79
src/pipelines/eraser/eraser-texture.wgsl
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
struct Settings {
|
||||||
|
eraserRadiusSquared: f32,
|
||||||
|
lineDistanceEpsilon: f32,
|
||||||
|
clearRed: f32,
|
||||||
|
clearGreen: f32,
|
||||||
|
clearBlue: f32,
|
||||||
|
clearAlpha: f32,
|
||||||
|
eraserRadius: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) screenPosition: vec2<f32>,
|
||||||
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
|
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||||
|
@location(3) @interpolate(flat) inverseLengthSquared: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EraserCombinedTargets {
|
||||||
|
@location(0) mask: vec4<f32>,
|
||||||
|
@location(1) source: vec4<f32>,
|
||||||
|
@location(2) trail: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vertex(
|
||||||
|
@builtin(vertex_index) vertexIndex: u32,
|
||||||
|
@location(0) start: vec2<f32>,
|
||||||
|
@location(1) end: vec2<f32>
|
||||||
|
) -> VertexOutput {
|
||||||
|
let direction = end - start;
|
||||||
|
let denominator = dot(direction, direction);
|
||||||
|
var inverseLengthSquared = 0.0;
|
||||||
|
var normalizedDirection = vec2<f32>(1.0, 0.0);
|
||||||
|
if denominator > settings.lineDistanceEpsilon {
|
||||||
|
inverseLengthSquared = 1.0 / denominator;
|
||||||
|
normalizedDirection = direction * inverseSqrt(denominator);
|
||||||
|
}
|
||||||
|
let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.eraserRadius);
|
||||||
|
let uv = screenPosition / state.size;
|
||||||
|
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||||
|
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragmentCombined(
|
||||||
|
@location(0) screenPosition: vec2<f32>,
|
||||||
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
|
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||||
|
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||||
|
) -> EraserCombinedTargets {
|
||||||
|
let distanceSquared = distance_squared_from_segment(
|
||||||
|
screenPosition,
|
||||||
|
start,
|
||||||
|
direction,
|
||||||
|
inverseLengthSquared
|
||||||
|
);
|
||||||
|
if distanceSquared > settings.eraserRadiusSquared {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleared = getEraserClearValue();
|
||||||
|
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getEraserMaskValue() -> vec4<f32> {
|
||||||
|
return vec4<f32>(settings.clearAlpha, 0.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getEraserClearValue() -> vec4<f32> {
|
||||||
|
return vec4<f32>(
|
||||||
|
settings.clearRed,
|
||||||
|
settings.clearGreen,
|
||||||
|
settings.clearBlue,
|
||||||
|
settings.clearAlpha
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,162 +1,220 @@
|
||||||
import { vec3 } from 'gl-matrix';
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { CommonState } from '../common-state/common-state';
|
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
|
||||||
import { RenderSettings } from './render-settings';
|
|
||||||
import shader from './render.wgsl?raw';
|
import shader from './render.wgsl?raw';
|
||||||
|
|
||||||
export class RenderPipeline {
|
export interface RenderSettings {
|
||||||
private static readonly UNIFORM_COUNT = 13;
|
clarity: number;
|
||||||
|
renderTraceNormalizationFloor: number;
|
||||||
|
renderBrushColorBase: number;
|
||||||
|
renderBrushColorStrengthMultiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 4 scalars,
|
||||||
|
// rounded up to 20 floats for 16-byte uniform alignment.
|
||||||
|
const UNIFORM_COUNT = 20;
|
||||||
|
|
||||||
|
export class RenderPipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPURenderPipeline;
|
||||||
|
private readonly noSourcePipeline: GPURenderPipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
);
|
||||||
|
|
||||||
private bindGroup?: GPUBindGroup;
|
private readonly getBindGroup = createBindGroupCache<[GPUTextureView, GPUTextureView]>(
|
||||||
private previousColorTexture?: GPUTextureView;
|
(colorTexture, sourceTexture) =>
|
||||||
|
this.device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||||
|
{ binding: 2, resource: colorTexture },
|
||||||
|
{ binding: 3, resource: sourceTexture },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly context: GPUCanvasContext,
|
private readonly context: GPUCanvasContext,
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly commonState: CommonState
|
private readonly canvasFormat: GPUTextureFormat
|
||||||
) {
|
) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout);
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
entries: [
|
||||||
const { buffer, vertex } = setUpFullScreenQuad(device);
|
|
||||||
this.vertexBuffer = buffer;
|
|
||||||
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
|
||||||
layout: device.createPipelineLayout({
|
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
|
||||||
}),
|
|
||||||
vertex,
|
|
||||||
fragment: {
|
|
||||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
|
||||||
entryPoint: 'fragment',
|
|
||||||
targets: [
|
|
||||||
{
|
{
|
||||||
format: navigator.gpu.getPreferredCanvasFormat(),
|
binding: 0,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
buffer: { type: 'uniform' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
texture: { sampleType: 'float' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 3,
|
||||||
|
visibility: GPUShaderStage.FRAGMENT,
|
||||||
|
texture: { sampleType: 'float' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
primitive: {
|
|
||||||
topology: 'triangle-strip',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
const shaderModule = smartCompile(device, shader);
|
||||||
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
const vertex = setUpFullScreenQuad(device);
|
||||||
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [this.bindGroupLayout],
|
||||||
|
});
|
||||||
|
this.pipeline = this.createPipeline(
|
||||||
|
pipelineLayout,
|
||||||
|
vertex,
|
||||||
|
shaderModule,
|
||||||
|
this.canvasFormat,
|
||||||
|
'fragment'
|
||||||
|
);
|
||||||
|
this.noSourcePipeline = this.createPipeline(
|
||||||
|
pipelineLayout,
|
||||||
|
vertex,
|
||||||
|
shaderModule,
|
||||||
|
this.canvasFormat,
|
||||||
|
'fragmentNoSource'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.uniforms = device.createBuffer({
|
||||||
|
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createPipeline(
|
||||||
|
layout: GPUPipelineLayout,
|
||||||
|
vertex: GPUVertexState,
|
||||||
|
shaderModule: GPUShaderModule,
|
||||||
|
format: GPUTextureFormat,
|
||||||
|
fragmentEntryPoint: string
|
||||||
|
): GPURenderPipeline {
|
||||||
|
return this.device.createRenderPipeline({
|
||||||
|
layout,
|
||||||
|
vertex,
|
||||||
|
fragment: {
|
||||||
|
module: shaderModule,
|
||||||
|
entryPoint: fragmentEntryPoint,
|
||||||
|
targets: [{ format }],
|
||||||
|
},
|
||||||
|
primitive: { topology: 'triangle-list' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public setParameters({
|
public setParameters({
|
||||||
brushColor,
|
channelColors,
|
||||||
evenGenerationColor,
|
backgroundColor,
|
||||||
oddGenerationColor,
|
|
||||||
clarity,
|
clarity,
|
||||||
|
renderTraceNormalizationFloor,
|
||||||
|
renderBrushColorBase,
|
||||||
|
renderBrushColorStrengthMultiplier,
|
||||||
}: RenderSettings & {
|
}: RenderSettings & {
|
||||||
brushColor: vec3;
|
channelColors: [RgbColor, RgbColor, RgbColor];
|
||||||
evenGenerationColor: vec3;
|
backgroundColor: RgbColor;
|
||||||
oddGenerationColor: vec3;
|
|
||||||
}) {
|
}) {
|
||||||
this.device.queue.writeBuffer(
|
const [a, b, c] = channelColors;
|
||||||
|
this.uniformValues[0] = rgbChannelToUnit(a[0]);
|
||||||
|
this.uniformValues[1] = rgbChannelToUnit(a[1]);
|
||||||
|
this.uniformValues[2] = rgbChannelToUnit(a[2]);
|
||||||
|
// uniformValues[3], [7], [11] are WGSL vec3→vec4 alignment padding.
|
||||||
|
this.uniformValues[4] = rgbChannelToUnit(b[0]);
|
||||||
|
this.uniformValues[5] = rgbChannelToUnit(b[1]);
|
||||||
|
this.uniformValues[6] = rgbChannelToUnit(b[2]);
|
||||||
|
this.uniformValues[8] = rgbChannelToUnit(c[0]);
|
||||||
|
this.uniformValues[9] = rgbChannelToUnit(c[1]);
|
||||||
|
this.uniformValues[10] = rgbChannelToUnit(c[2]);
|
||||||
|
this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
|
||||||
|
this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
|
||||||
|
this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
|
||||||
|
this.uniformValues[15] = clarity;
|
||||||
|
this.uniformValues[16] = renderTraceNormalizationFloor;
|
||||||
|
this.uniformValues[17] = renderBrushColorBase;
|
||||||
|
this.uniformValues[18] = renderBrushColorStrengthMultiplier;
|
||||||
|
writeBufferIfChanged(
|
||||||
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
0,
|
this.uniformValues,
|
||||||
new Float32Array([
|
this.uniformCache
|
||||||
...brushColor,
|
|
||||||
0, //padding
|
|
||||||
...evenGenerationColor,
|
|
||||||
0, //padding
|
|
||||||
...oddGenerationColor,
|
|
||||||
clarity,
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public execute(commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView) {
|
public execute(
|
||||||
this.ensureBindGroupExists(colorTexture);
|
commandEncoder: GPUCommandEncoder,
|
||||||
|
colorTexture: GPUTextureView,
|
||||||
|
sourceTexture: GPUTextureView,
|
||||||
|
useSourceTexture = true,
|
||||||
|
timestampWrites?: GPURenderPassTimestampWrites
|
||||||
|
): GPUTexture {
|
||||||
|
const canvasTexture = this.context.getCurrentTexture();
|
||||||
|
this.encodePass(
|
||||||
|
commandEncoder,
|
||||||
|
colorTexture,
|
||||||
|
sourceTexture,
|
||||||
|
canvasTexture.createView(),
|
||||||
|
useSourceTexture,
|
||||||
|
timestampWrites
|
||||||
|
);
|
||||||
|
return canvasTexture;
|
||||||
|
}
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
public executeToView(
|
||||||
|
commandEncoder: GPUCommandEncoder,
|
||||||
|
colorTexture: GPUTextureView,
|
||||||
|
sourceTexture: GPUTextureView,
|
||||||
|
outputTexture: GPUTextureView,
|
||||||
|
useSourceTexture = true,
|
||||||
|
timestampWrites?: GPURenderPassTimestampWrites
|
||||||
|
) {
|
||||||
|
this.encodePass(
|
||||||
|
commandEncoder,
|
||||||
|
colorTexture,
|
||||||
|
sourceTexture,
|
||||||
|
outputTexture,
|
||||||
|
useSourceTexture,
|
||||||
|
timestampWrites
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodePass(
|
||||||
|
commandEncoder: GPUCommandEncoder,
|
||||||
|
colorTexture: GPUTextureView,
|
||||||
|
sourceTexture: GPUTextureView,
|
||||||
|
output: GPUTextureView,
|
||||||
|
useSourceTexture: boolean,
|
||||||
|
timestampWrites?: GPURenderPassTimestampWrites
|
||||||
|
) {
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
colorAttachments: [
|
colorAttachments: [
|
||||||
{
|
{
|
||||||
view: this.context.getCurrentTexture().createView(),
|
view: output,
|
||||||
clearValue: { r: 0, g: 1, b: 1, a: 1 },
|
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
loadOp: 'clear',
|
loadOp: 'clear',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
timestampWrites,
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
});
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.getPipeline(useSourceTexture));
|
||||||
this.commonState.execute(passEncoder);
|
passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture));
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
passEncoder.draw(3, 1);
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
|
||||||
passEncoder.draw(4, 1);
|
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureBindGroupExists(colorTexture: GPUTextureView) {
|
private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
|
||||||
if (this.previousColorTexture !== colorTexture) {
|
return useSourceTexture ? this.pipeline : this.noSourcePipeline;
|
||||||
this.bindGroup = this.device.createBindGroup({
|
|
||||||
layout: this.bindGroupLayout,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: this.device.createSampler({
|
|
||||||
magFilter: 'linear',
|
|
||||||
minFilter: 'linear',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
resource: colorTexture,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.previousColorTexture = colorTexture;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.vertexBuffer.destroy();
|
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
|
||||||
return {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
buffer: {
|
|
||||||
type: 'uniform',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
sampler: {
|
|
||||||
type: 'filtering',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
texture: {
|
|
||||||
sampleType: 'float',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export interface RenderSettings {
|
|
||||||
clarity: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +1,158 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
brushColor: vec3<f32>,
|
colorA: vec3<f32>,
|
||||||
evenGenerationColor: vec3<f32>,
|
_colorAPadding: f32,
|
||||||
oddGenerationColor: vec3<f32>,
|
colorB: vec3<f32>,
|
||||||
|
_colorBPadding: f32,
|
||||||
|
colorC: vec3<f32>,
|
||||||
|
_colorCPadding: f32,
|
||||||
|
backgroundColor: vec3<f32>,
|
||||||
clarity: f32,
|
clarity: f32,
|
||||||
|
traceNormalizationFloor: f32,
|
||||||
|
brushColorBase: f32,
|
||||||
|
brushColorStrengthMultiplier: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
const COMMON_CHANNEL_REDUCTION: f32 = 0.75;
|
||||||
@group(1) @binding(1) var Sampler: sampler;
|
const OVERLAP_SATURATION_BOOST: f32 = 1.35;
|
||||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
const LOW_SATURATION_RESCUE_AMOUNT: f32 = 0.65;
|
||||||
|
const LOW_SATURATION_RESCUE_MIN: f32 = 0.08;
|
||||||
|
const LOW_SATURATION_RESCUE_MAX: f32 = 0.22;
|
||||||
|
const COLOR_WEIGHT_EPSILON: f32 = 0.0001;
|
||||||
|
const LUMA_WEIGHTS: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||||
|
@group(0) @binding(2) var trailMap: texture_2d<f32>;
|
||||||
|
@group(0) @binding(3) var sourceMap: texture_2d<f32>;
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
let traces = textureSample(trailMap, Sampler, uv);
|
let pixel = vec2<i32>(position.xy);
|
||||||
let random = textureSample(noise, noiseSampler, uv);
|
let traces = textureLoad(trailMap, pixel, 0);
|
||||||
|
let sources = textureLoad(sourceMap, pixel, 0);
|
||||||
|
return renderColor(traces, sources, getFlatBackground());
|
||||||
|
}
|
||||||
|
|
||||||
let backgroundColor = vec3(0.9) + 0.075 * random.r;
|
@fragment
|
||||||
|
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
|
let pixel = vec2<i32>(position.xy);
|
||||||
|
let traces = textureLoad(trailMap, pixel, 0);
|
||||||
|
return renderColor(traces, vec4<f32>(0.0), getFlatBackground());
|
||||||
|
}
|
||||||
|
|
||||||
let evenGenerationStrength = clarity(traces.r);
|
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
|
||||||
let oddGenerationStrength = clarity(traces.g);
|
let traceStrengths = clarity(traces.rgb);
|
||||||
let brushStrength = traces.a;
|
let sourceStrengths = clarity(sources.rgb);
|
||||||
|
let traceStrength = maxComponent(traceStrengths);
|
||||||
|
let brushStrength = maxComponent(sourceStrengths);
|
||||||
|
if max(traceStrength, brushStrength) <= 0.0 {
|
||||||
|
return vec4(background, 1);
|
||||||
|
}
|
||||||
|
|
||||||
let color = max(
|
if brushStrength <= 0.0 {
|
||||||
mix(
|
let traceColor = colorFromChannelStrengths(traceStrengths);
|
||||||
evenGenerationStrength * settings.evenGenerationColor,
|
return vec4(mix(background, clamp(traceColor, vec3(0), vec3(1)), traceStrength), 1);
|
||||||
oddGenerationStrength * settings.oddGenerationColor,
|
}
|
||||||
oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
|
|
||||||
|
let strengths = max(traceStrengths, sourceStrengths);
|
||||||
|
let traceColor = colorFromChannelStrengths(strengths);
|
||||||
|
let brushColor = colorFromChannelStrengths(sourceStrengths);
|
||||||
|
let brushVisibility = clamp(
|
||||||
|
brushStrength * (
|
||||||
|
settings.brushColorBase +
|
||||||
|
brushStrength * settings.brushColorStrengthMultiplier
|
||||||
),
|
),
|
||||||
brushStrength * settings.brushColor
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let color = mix(traceColor, brushColor, brushVisibility);
|
||||||
|
|
||||||
|
let strength = max(maxComponent(strengths), brushVisibility);
|
||||||
|
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maxComponent(v: vec3<f32>) -> f32 {
|
||||||
|
return max(max(v.r, v.g), v.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minComponent(v: vec3<f32>) -> f32 {
|
||||||
|
return min(min(v.r, v.g), v.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn componentSum(v: vec3<f32>) -> f32 {
|
||||||
|
return v.r + v.g + v.b;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clarity(strength: vec3<f32>) -> vec3<f32> {
|
||||||
|
return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn colorFromChannelStrengths(strengths: vec3<f32>) -> vec3<f32> {
|
||||||
|
if maxComponent(strengths) <= 0.0 {
|
||||||
|
return vec3<f32>(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let weights = colorWeights(strengths);
|
||||||
|
let color =
|
||||||
|
weights.r * settings.colorA
|
||||||
|
+ weights.g * settings.colorB
|
||||||
|
+ weights.b * settings.colorC;
|
||||||
|
return preserveOverlapVibrancy(normalizeColorIntensity(color), strengths);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn colorWeights(strengths: vec3<f32>) -> vec3<f32> {
|
||||||
|
let commonStrength = minComponent(strengths);
|
||||||
|
var weightBase = max(
|
||||||
|
strengths - vec3<f32>(commonStrength * COMMON_CHANNEL_REDUCTION),
|
||||||
|
vec3<f32>(0.0)
|
||||||
|
);
|
||||||
|
if componentSum(weightBase) <= COLOR_WEIGHT_EPSILON {
|
||||||
|
weightBase = strengths;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharpenedWeights = weightBase * weightBase;
|
||||||
|
return sharpenedWeights / max(COLOR_WEIGHT_EPSILON, componentSum(sharpenedWeights));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preserveOverlapVibrancy(color: vec3<f32>, strengths: vec3<f32>) -> vec3<f32> {
|
||||||
|
let strongest = maxComponent(strengths);
|
||||||
|
let overlapAmount = clamp(
|
||||||
|
(componentSum(strengths) - strongest) / max(COLOR_WEIGHT_EPSILON, strongest),
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
);
|
);
|
||||||
|
|
||||||
let strength = max(evenGenerationStrength, max(oddGenerationStrength, brushStrength));
|
let luminance = dot(color, LUMA_WEIGHTS);
|
||||||
|
var vibrantColor = clamp(
|
||||||
|
vec3<f32>(luminance) +
|
||||||
|
(color - vec3<f32>(luminance)) *
|
||||||
|
mix(1.0, OVERLAP_SATURATION_BOOST, overlapAmount),
|
||||||
|
vec3<f32>(0.0),
|
||||||
|
vec3<f32>(1.0)
|
||||||
|
);
|
||||||
|
|
||||||
return vec4(mix(backgroundColor, color, strength), 1);
|
let saturation = maxComponent(vibrantColor) - minComponent(vibrantColor);
|
||||||
|
let rescueAmount =
|
||||||
|
overlapAmount *
|
||||||
|
(1.0 - smoothstep(LOW_SATURATION_RESCUE_MIN, LOW_SATURATION_RESCUE_MAX, saturation)) *
|
||||||
|
LOW_SATURATION_RESCUE_AMOUNT;
|
||||||
|
return mix(vibrantColor, dominantColor(strengths), rescueAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clarity(strength: f32) -> f32 {
|
fn dominantColor(strengths: vec3<f32>) -> vec3<f32> {
|
||||||
return pow(strength, settings.clarity);
|
if strengths.r >= strengths.g && strengths.r >= strengths.b {
|
||||||
|
return normalizeColorIntensity(settings.colorA);
|
||||||
|
}
|
||||||
|
if strengths.g >= strengths.b {
|
||||||
|
return normalizeColorIntensity(settings.colorB);
|
||||||
|
}
|
||||||
|
return normalizeColorIntensity(settings.colorC);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
||||||
|
let brightestChannel = maxComponent(color);
|
||||||
|
return color / max(settings.traceNormalizationFloor, brightestChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getFlatBackground() -> vec3<f32> {
|
||||||
|
return clamp(settings.backgroundColor, vec3(0), vec3(1));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
src/pipelines/texture-formats.ts
Normal file
2
src/pipelines/texture-formats.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat;
|
||||||
|
export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat;
|
||||||
|
|
@ -1,37 +1,26 @@
|
||||||
import { clamp } from './clamp';
|
import { appConfig } from '../config';
|
||||||
import { exponentialDecay } from './exponential-decay';
|
import { clamp } from './math';
|
||||||
|
|
||||||
export class DeltaTimeCalculator {
|
export class DeltaTimeCalculator {
|
||||||
private static FPS_EXPONENTIAL_DECAY_STRENGTH = 0.01;
|
|
||||||
|
|
||||||
private previousTime: DOMHighResTimeStamp | null = null;
|
private previousTime: DOMHighResTimeStamp | null = null;
|
||||||
private deltaTimeAccumulator: number | null = null;
|
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
|
||||||
|
|
||||||
constructor(
|
constructor() {
|
||||||
private readonly maxDeltaTimeInSeconds: number = 1 / 30,
|
document.addEventListener('visibilitychange', this.visibilityChangeListener);
|
||||||
private readonly minDeltaTimeInSeconds: number = 1 / 240
|
|
||||||
) {
|
|
||||||
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public calculateDeltaTimeInSeconds(
|
public calculateDeltaTimeInSeconds(currentTime: DOMHighResTimeStamp): number {
|
||||||
currentTime: DOMHighResTimeStamp
|
|
||||||
): DOMHighResTimeStamp {
|
|
||||||
if (this.previousTime === null) {
|
if (this.previousTime === null) {
|
||||||
this.previousTime = currentTime;
|
this.previousTime = currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = currentTime - this.previousTime;
|
const delta = currentTime - this.previousTime;
|
||||||
this.previousTime = currentTime;
|
this.previousTime = currentTime;
|
||||||
const deltaInSeconds = delta / 1000;
|
return clamp(
|
||||||
|
delta / 1000,
|
||||||
this.deltaTimeAccumulator = exponentialDecay({
|
appConfig.deltaTime.minDeltaTimeSeconds,
|
||||||
accumulator: this.deltaTimeAccumulator ?? deltaInSeconds,
|
appConfig.deltaTime.maxDeltaTimeSeconds
|
||||||
nextValue: deltaInSeconds,
|
);
|
||||||
biasOfNextValue: DeltaTimeCalculator.FPS_EXPONENTIAL_DECAY_STRENGTH,
|
|
||||||
});
|
|
||||||
|
|
||||||
return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleVisibilityChange() {
|
private handleVisibilityChange() {
|
||||||
|
|
@ -40,7 +29,7 @@ export class DeltaTimeCalculator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get fps() {
|
public destroy(): void {
|
||||||
return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
|
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,42 +4,231 @@ export enum Severity {
|
||||||
ERROR = 'error',
|
ERROR = 'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorHandlerError {
|
export enum ErrorCode {
|
||||||
severity: Severity;
|
WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
|
||||||
message: string;
|
WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
|
||||||
|
WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',
|
||||||
|
WEBGPU_DEVICE_UNAVAILABLE = 'webgpu-device-unavailable',
|
||||||
|
WEBGPU_CONTEXT_UNAVAILABLE = 'webgpu-context-unavailable',
|
||||||
|
WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
|
||||||
|
WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
|
||||||
|
WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
|
||||||
|
DOM_ELEMENT_MISSING = 'dom-element-missing',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ErrorMetadata = { [key: string]: any };
|
type ErrorMetadataPrimitive = string | number | boolean | null;
|
||||||
|
type ErrorMetadataValue =
|
||||||
|
| ErrorMetadataPrimitive
|
||||||
|
| Array<ErrorMetadataValue>
|
||||||
|
| { [key: string]: ErrorMetadataValue };
|
||||||
|
type ErrorMetadata = { [key: string]: ErrorMetadataValue };
|
||||||
|
|
||||||
|
interface RuntimeErrorOptions {
|
||||||
|
cause?: unknown;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RuntimeError extends Error {
|
||||||
|
public readonly code: ErrorCode | string;
|
||||||
|
public readonly details: ErrorMetadata;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
code: ErrorCode | string,
|
||||||
|
message: string,
|
||||||
|
{ cause, details = {} }: RuntimeErrorOptions = {}
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'RuntimeError';
|
||||||
|
this.code = code;
|
||||||
|
this.details = serializeMetadataValue(details) as ErrorMetadata;
|
||||||
|
|
||||||
|
if (cause !== undefined) {
|
||||||
|
this.cause = cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorHandlerError {
|
||||||
|
severity: Severity;
|
||||||
|
message: string;
|
||||||
|
code?: ErrorCode | string;
|
||||||
|
details?: ErrorMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorHandlerErrorOptions {
|
||||||
|
code?: ErrorCode | string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
|
||||||
|
fallbackMessage?: string;
|
||||||
|
severity?: Severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_METADATA_DEPTH = 4;
|
||||||
|
const UNREADABLE_VALUE = '[Unreadable]';
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
|
const safelyRead = (value: Record<string, unknown>, key: string): unknown => {
|
||||||
|
try {
|
||||||
|
return value[key];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIterable = (value: unknown): value is Iterable<unknown> =>
|
||||||
|
isRecord(value) && Symbol.iterator in value;
|
||||||
|
|
||||||
|
const serializeMetadataValue = (value: unknown, depth = 0): ErrorMetadataValue => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (typeof value) {
|
||||||
|
case 'string':
|
||||||
|
case 'boolean':
|
||||||
|
return value;
|
||||||
|
case 'number':
|
||||||
|
return Number.isFinite(value) ? value : value.toString();
|
||||||
|
case 'bigint':
|
||||||
|
return value.toString();
|
||||||
|
case 'undefined':
|
||||||
|
return null;
|
||||||
|
case 'symbol':
|
||||||
|
return value.toString();
|
||||||
|
case 'function':
|
||||||
|
return `[Function ${value.name || 'anonymous'}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= MAX_METADATA_DEPTH) {
|
||||||
|
return '[Object]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => serializeMetadataValue(item, depth + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIterable(value)) {
|
||||||
|
try {
|
||||||
|
return Array.from(value, (item) => serializeMetadataValue(item, depth + 1));
|
||||||
|
} catch {
|
||||||
|
return UNREADABLE_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized: ErrorMetadata = {};
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
try {
|
||||||
|
serialized[key] = serializeMetadataValue(record[key], depth + 1);
|
||||||
|
} catch {
|
||||||
|
serialized[key] = UNREADABLE_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getErrorMessage = (
|
||||||
|
exception: unknown,
|
||||||
|
fallbackMessage = 'Unknown error'
|
||||||
|
): string => {
|
||||||
|
if (typeof exception === 'string') {
|
||||||
|
return exception || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof Error) {
|
||||||
|
const record = exception as unknown as Record<string, unknown>;
|
||||||
|
const message = safelyRead(record, 'message');
|
||||||
|
if (typeof message === 'string' && message.length > 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = safelyRead(record, 'name');
|
||||||
|
if (typeof name === 'string' && name.length > 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(exception)) {
|
||||||
|
const message = safelyRead(exception, 'message');
|
||||||
|
if (typeof message === 'string' && message.length > 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof exception === 'number' ||
|
||||||
|
typeof exception === 'boolean' ||
|
||||||
|
typeof exception === 'bigint' ||
|
||||||
|
typeof exception === 'symbol'
|
||||||
|
) {
|
||||||
|
return exception.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage;
|
||||||
|
};
|
||||||
|
|
||||||
export class ErrorHandler {
|
export class ErrorHandler {
|
||||||
private static readonly errors: Array<ErrorHandlerError> = [];
|
|
||||||
private static metadata: ErrorMetadata = {};
|
private static metadata: ErrorMetadata = {};
|
||||||
private static onErrorListeners: Array<
|
private static onErrorListeners: Array<
|
||||||
(error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
(error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
public static addException(exception: Error) {
|
public static addException(
|
||||||
ErrorHandler.addError(Severity.ERROR, exception.message);
|
exception: unknown,
|
||||||
|
{
|
||||||
|
severity = Severity.ERROR,
|
||||||
|
fallbackMessage,
|
||||||
|
code,
|
||||||
|
details,
|
||||||
|
}: ErrorHandlerExceptionOptions = {}
|
||||||
|
) {
|
||||||
|
const runtimeError = exception instanceof RuntimeError ? exception : undefined;
|
||||||
|
ErrorHandler.addError(severity, getErrorMessage(exception, fallbackMessage), {
|
||||||
|
code: code ?? runtimeError?.code,
|
||||||
|
details: {
|
||||||
|
...(runtimeError?.details ?? {}),
|
||||||
|
...(details ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static addError(severity: Severity, message: string) {
|
public static addError(
|
||||||
ErrorHandler.errors.push({ severity, message });
|
severity: Severity,
|
||||||
|
message: string,
|
||||||
|
{ code, details }: ErrorHandlerErrorOptions = {}
|
||||||
|
) {
|
||||||
|
const error: ErrorHandlerError = {
|
||||||
|
severity,
|
||||||
|
message,
|
||||||
|
...(code === undefined ? {} : { code }),
|
||||||
|
...(details === undefined
|
||||||
|
? {}
|
||||||
|
: { details: serializeMetadataValue(details) as ErrorMetadata }),
|
||||||
|
};
|
||||||
ErrorHandler.onErrorListeners.forEach((listener) =>
|
ErrorHandler.onErrorListeners.forEach((listener) =>
|
||||||
listener({ severity, message }, ErrorHandler.metadata)
|
listener(error, ErrorHandler.metadata)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static addMetadata(key: string, value: any) {
|
public static addMetadata(key: string, value: unknown) {
|
||||||
const serialized: Record<string, any> = {};
|
ErrorHandler.metadata[key] = serializeMetadataValue(value);
|
||||||
for (const k in value) {
|
|
||||||
serialized[k] = value[k];
|
|
||||||
}
|
|
||||||
ErrorHandler.metadata[key] = serialized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static addOnErrorListener(
|
public static addOnErrorListener(
|
||||||
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
||||||
) {
|
): () => void {
|
||||||
ErrorHandler.onErrorListeners.push(listener);
|
ErrorHandler.onErrorListeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter(
|
||||||
|
(registeredListener) => registeredListener !== listener
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/utils/graphics/bind-group-cache.ts
Normal file
38
src/utils/graphics/bind-group-cache.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
type BindGroupCacheKeys = readonly [object, ...object[]];
|
||||||
|
|
||||||
|
interface BindGroupCacheNode {
|
||||||
|
bindGroup?: GPUBindGroup;
|
||||||
|
children: WeakMap<object, BindGroupCacheNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNode = (): BindGroupCacheNode => ({
|
||||||
|
children: new WeakMap(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getOrCreateNode = (
|
||||||
|
children: WeakMap<object, BindGroupCacheNode>,
|
||||||
|
key: object
|
||||||
|
): BindGroupCacheNode => {
|
||||||
|
let node = children.get(key);
|
||||||
|
if (!node) {
|
||||||
|
node = createNode();
|
||||||
|
children.set(key, node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBindGroupCache = <Keys extends BindGroupCacheKeys>(
|
||||||
|
factory: (...keys: Keys) => GPUBindGroup
|
||||||
|
): ((...keys: Keys) => GPUBindGroup) => {
|
||||||
|
const root = new WeakMap<object, BindGroupCacheNode>();
|
||||||
|
|
||||||
|
return (...keys) => {
|
||||||
|
let node = getOrCreateNode(root, keys[0]);
|
||||||
|
for (const key of keys.slice(1)) {
|
||||||
|
node = getOrCreateNode(node.children, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.bindGroup ??= factory(...keys);
|
||||||
|
return node.bindGroup;
|
||||||
|
};
|
||||||
|
};
|
||||||
38
src/utils/graphics/cached-buffer-write.test.ts
Normal file
38
src/utils/graphics/cached-buffer-write.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCachedBufferWrite,
|
||||||
|
updateCachedBufferWrite,
|
||||||
|
writeBufferIfChanged,
|
||||||
|
} from './cached-buffer-write';
|
||||||
|
|
||||||
|
describe('cached buffer writes', () => {
|
||||||
|
it('compares raw bytes so aliased uint changes are detected', () => {
|
||||||
|
const values = new Float32Array(1);
|
||||||
|
const uintValues = new Uint32Array(values.buffer);
|
||||||
|
const cache = createCachedBufferWrite(values.byteLength);
|
||||||
|
|
||||||
|
uintValues[0] = 0x7fc00001;
|
||||||
|
expect(updateCachedBufferWrite(values, cache)).toBe(true);
|
||||||
|
expect(updateCachedBufferWrite(values, cache)).toBe(false);
|
||||||
|
|
||||||
|
uintValues[0] = 0x7fc00002;
|
||||||
|
expect(Number.isNaN(values[0])).toBe(true);
|
||||||
|
expect(updateCachedBufferWrite(values, cache)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes to the GPU queue only when the raw buffer changed', () => {
|
||||||
|
const values = new Uint32Array([1, 2, 3, 4]);
|
||||||
|
const writeBuffer = vi.fn();
|
||||||
|
const device = { queue: { writeBuffer } } as unknown as GPUDevice;
|
||||||
|
const buffer = {} as GPUBuffer;
|
||||||
|
const cache = createCachedBufferWrite(values.byteLength);
|
||||||
|
|
||||||
|
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
|
||||||
|
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(false);
|
||||||
|
|
||||||
|
values[2] = 5;
|
||||||
|
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
|
||||||
|
expect(writeBuffer).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/utils/graphics/cached-buffer-write.ts
Normal file
46
src/utils/graphics/cached-buffer-write.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
interface CachedBufferWrite {
|
||||||
|
hasValue: boolean;
|
||||||
|
previous: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCachedBufferWrite = (byteLength: number): CachedBufferWrite => ({
|
||||||
|
hasValue: false,
|
||||||
|
previous: new Uint8Array(byteLength),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateCachedBufferWrite = (
|
||||||
|
values: ArrayBufferView,
|
||||||
|
cache: CachedBufferWrite
|
||||||
|
): boolean => {
|
||||||
|
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||||
|
if (bytes.length !== cache.previous.length) {
|
||||||
|
throw new Error('Cached buffer write length mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasChanged = !cache.hasValue;
|
||||||
|
for (let i = 0; i < bytes.length && !hasChanged; i++) {
|
||||||
|
hasChanged = bytes[i] !== cache.previous[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanged) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.previous.set(bytes);
|
||||||
|
cache.hasValue = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeBufferIfChanged = (
|
||||||
|
device: GPUDevice,
|
||||||
|
buffer: GPUBuffer,
|
||||||
|
values: ArrayBufferView,
|
||||||
|
cache: CachedBufferWrite
|
||||||
|
): boolean => {
|
||||||
|
if (!updateCachedBufferWrite(values, cache)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.queue.writeBuffer(buffer, 0, values);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
@ -1,30 +1,6 @@
|
||||||
import { smartCompile } from './smart-compile';
|
import { smartCompile } from './smart-compile';
|
||||||
|
|
||||||
export const setUpFullScreenQuad = (
|
export const setUpFullScreenQuad = (device: GPUDevice): GPUVertexState => ({
|
||||||
device: GPUDevice
|
|
||||||
): {
|
|
||||||
buffer: GPUBuffer;
|
|
||||||
vertex: GPUVertexState;
|
|
||||||
} => {
|
|
||||||
const buffer = device.createBuffer({
|
|
||||||
size: 4 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec4<f32>
|
|
||||||
usage: GPUBufferUsage.VERTEX,
|
|
||||||
mappedAtCreation: true,
|
|
||||||
});
|
|
||||||
// prettier-ignore
|
|
||||||
const vertexData = [
|
|
||||||
// posX posY U V
|
|
||||||
-1.0, -1.0, 0.0, 1.0,
|
|
||||||
+1.0, -1.0, 1.0, 1.0,
|
|
||||||
-1.0, +1.0, 0.0, 0.0,
|
|
||||||
+1.0, +1.0, 1.0, 0.0,
|
|
||||||
];
|
|
||||||
new Float32Array(buffer.getMappedRange()).set(vertexData);
|
|
||||||
buffer.unmap();
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer,
|
|
||||||
vertex: {
|
|
||||||
module: smartCompile(
|
module: smartCompile(
|
||||||
device,
|
device,
|
||||||
/* wgsl */ `
|
/* wgsl */ `
|
||||||
|
|
@ -34,32 +10,16 @@ export const setUpFullScreenQuad = (
|
||||||
}
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vertex(
|
fn vertex(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
||||||
@location(0) position: vec2<f32>,
|
let positions = array<vec2<f32>, 3>(
|
||||||
@location(1) uv: vec2<f32>
|
vec2<f32>(-1.0, -1.0),
|
||||||
) -> VertexOutput {
|
vec2<f32>(3.0, -1.0),
|
||||||
|
vec2<f32>(-1.0, 3.0)
|
||||||
|
);
|
||||||
|
let position = positions[vertexIndex];
|
||||||
|
let uv = vec2<f32>(position.x * 0.5 + 0.5, 0.5 - position.y * 0.5);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), uv);
|
return VertexOutput(vec4(position, 0.0, 1.0), uv);
|
||||||
}`
|
}`
|
||||||
),
|
),
|
||||||
entryPoint: 'vertex',
|
entryPoint: 'vertex',
|
||||||
buffers: [
|
});
|
||||||
{
|
|
||||||
arrayStride: 4 * Float32Array.BYTES_PER_ELEMENT,
|
|
||||||
stepMode: 'vertex',
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
shaderLocation: 0,
|
|
||||||
offset: 0,
|
|
||||||
format: 'float32x2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shaderLocation: 1,
|
|
||||||
offset: 8,
|
|
||||||
format: 'float32x2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
export const getWorkgroupCounts = (
|
|
||||||
device: GPUDevice,
|
|
||||||
invocationCount: number,
|
|
||||||
workgroupSize: number
|
|
||||||
): [number, number, number] => {
|
|
||||||
const workgroupCount = Math.ceil(invocationCount / workgroupSize);
|
|
||||||
|
|
||||||
const workgroupCountX = Math.min(
|
|
||||||
device.limits.maxComputeWorkgroupsPerDimension,
|
|
||||||
workgroupCount
|
|
||||||
);
|
|
||||||
|
|
||||||
const workgroupCountY = Math.min(
|
|
||||||
device.limits.maxComputeWorkgroupsPerDimension,
|
|
||||||
Math.ceil(workgroupCount / workgroupCountX)
|
|
||||||
);
|
|
||||||
|
|
||||||
const workgroupCountZ = Math.min(
|
|
||||||
device.limits.maxComputeWorkgroupsPerDimension,
|
|
||||||
Math.ceil(workgroupCount / workgroupCountX / workgroupCountY)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (workgroupCountX * workgroupCountY * workgroupCountZ < workgroupCount) {
|
|
||||||
throw new Error('Cannot have this many invocations');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [workgroupCountX, workgroupCountY, workgroupCountZ];
|
|
||||||
};
|
|
||||||
|
|
@ -1,17 +1,50 @@
|
||||||
|
import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
|
||||||
|
|
||||||
export const initializeContext = ({
|
export const initializeContext = ({
|
||||||
device,
|
device,
|
||||||
canvas,
|
canvas,
|
||||||
|
format,
|
||||||
}: {
|
}: {
|
||||||
device: GPUDevice;
|
device: GPUDevice;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
|
format: GPUTextureFormat;
|
||||||
}): GPUCanvasContext => {
|
}): GPUCanvasContext => {
|
||||||
const context = canvas.getContext('webgpu') as any as GPUCanvasContext;
|
const context = canvas.getContext('webgpu');
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_CONTEXT_UNAVAILABLE,
|
||||||
|
'Could not create a WebGPU canvas context.',
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
context.configure({
|
context.configure({
|
||||||
device: device,
|
device: device,
|
||||||
format: navigator.gpu.getPreferredCanvasFormat(),
|
format,
|
||||||
alphaMode: 'premultiplied',
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||||
|
alphaMode: 'opaque',
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_CONTEXT_CONFIGURATION_FAILED,
|
||||||
|
'Could not configure the WebGPU canvas context.',
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
details: {
|
||||||
|
causeMessage: getErrorMessage(error),
|
||||||
|
canvasHeight: canvas.height,
|
||||||
|
canvasWidth: canvas.width,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,150 @@
|
||||||
import { ErrorHandler, Severity } from '../error-handler';
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
ErrorHandler,
|
||||||
|
getErrorMessage,
|
||||||
|
RuntimeError,
|
||||||
|
Severity,
|
||||||
|
} from '../error-handler';
|
||||||
|
|
||||||
|
const WEBGPU_BROWSER_SUPPORT_MESSAGE =
|
||||||
|
'Fleeting Garden needs WebGPU. Try the latest Chrome, Edge, or another browser with WebGPU enabled.';
|
||||||
|
|
||||||
|
const REQUESTED_LIMIT_NAMES = [
|
||||||
|
'maxBufferSize',
|
||||||
|
'maxStorageBufferBindingSize',
|
||||||
|
'maxComputeWorkgroupsPerDimension',
|
||||||
|
] as const satisfies ReadonlyArray<keyof GPUSupportedLimits>;
|
||||||
|
|
||||||
|
const getRequiredLimits = (
|
||||||
|
limits: GPUSupportedLimits
|
||||||
|
): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
|
||||||
|
Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
|
||||||
|
(typeof REQUESTED_LIMIT_NAMES)[number],
|
||||||
|
number
|
||||||
|
>;
|
||||||
|
|
||||||
|
const getAdapterInfo = (adapter: GPUAdapter): Record<string, unknown> => {
|
||||||
|
try {
|
||||||
|
const info = adapter.info;
|
||||||
|
return {
|
||||||
|
architecture: info.architecture,
|
||||||
|
description: info.description,
|
||||||
|
device: info.device,
|
||||||
|
isFallbackAdapter: info.isFallbackAdapter,
|
||||||
|
subgroupMaxSize: info.subgroupMaxSize,
|
||||||
|
subgroupMinSize: info.subgroupMinSize,
|
||||||
|
vendor: info.vendor,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
unavailableReason: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestAdapter = async (
|
||||||
|
gpu: GPU,
|
||||||
|
options?: GPURequestAdapterOptions
|
||||||
|
): Promise<GPUAdapter | null> => {
|
||||||
|
try {
|
||||||
|
return await gpu.requestAdapter(options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
|
||||||
|
'Could not request a WebGPU adapter.',
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
details: {
|
||||||
|
causeMessage: getErrorMessage(error),
|
||||||
|
powerPreference: options?.powerPreference ?? 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const initializeGpu = async (): Promise<GPUDevice> => {
|
export const initializeGpu = async (): Promise<GPUDevice> => {
|
||||||
|
if (window.isSecureContext === false) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_INSECURE_CONTEXT,
|
||||||
|
'WebGPU requires a secure context. Open Fleeting Garden over HTTPS or from localhost.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const gpu = navigator.gpu;
|
const gpu = navigator.gpu;
|
||||||
if (!gpu) {
|
if (!gpu) {
|
||||||
throw new Error('WebGPU is not supported in your browser');
|
throw new RuntimeError(ErrorCode.WEBGPU_UNSUPPORTED, WEBGPU_BROWSER_SUPPORT_MESSAGE, {
|
||||||
}
|
details: {
|
||||||
|
hasNavigatorGpu: false,
|
||||||
const adapter = await gpu.requestAdapter({
|
isSecureContext: window.isSecureContext,
|
||||||
powerPreference: 'high-performance',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!adapter) {
|
|
||||||
throw new Error('Could not request adatper');
|
|
||||||
}
|
|
||||||
|
|
||||||
ErrorHandler.addMetadata('features', adapter.features);
|
|
||||||
ErrorHandler.addMetadata('limits', adapter.limits);
|
|
||||||
|
|
||||||
const gpuDevice = await adapter.requestDevice({
|
|
||||||
requiredLimits: {
|
|
||||||
maxBufferSize: adapter.limits.maxBufferSize,
|
|
||||||
maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
|
|
||||||
maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter =
|
||||||
|
(await requestAdapter(gpu, {
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
})) ?? (await requestAdapter(gpu));
|
||||||
|
|
||||||
|
if (!adapter) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
|
||||||
|
'WebGPU is available, but this browser could not provide a compatible GPU adapter.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredLimits = getRequiredLimits(adapter.limits);
|
||||||
|
const requiredFeatures: Array<GPUFeatureName> = [];
|
||||||
|
if (adapter.features.has('timestamp-query')) {
|
||||||
|
requiredFeatures.push('timestamp-query');
|
||||||
|
}
|
||||||
|
ErrorHandler.addMetadata('webgpuAdapter', {
|
||||||
|
features: Array.from(adapter.features).sort(),
|
||||||
|
info: getAdapterInfo(adapter),
|
||||||
|
requiredFeatures,
|
||||||
|
requiredLimits,
|
||||||
|
});
|
||||||
|
|
||||||
|
let gpuDevice: GPUDevice;
|
||||||
|
try {
|
||||||
|
gpuDevice = await adapter.requestDevice({
|
||||||
|
requiredFeatures,
|
||||||
|
requiredLimits,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new RuntimeError(
|
||||||
|
ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
|
||||||
|
'Could not create a WebGPU device for this adapter.',
|
||||||
|
{
|
||||||
|
cause: error,
|
||||||
|
details: {
|
||||||
|
causeMessage: getErrorMessage(error),
|
||||||
|
requiredFeatures,
|
||||||
|
requiredLimits,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
|
gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
|
||||||
ErrorHandler.addError(Severity.ERROR, event.error.message)
|
ErrorHandler.addException(event.error, {
|
||||||
|
code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
|
||||||
|
severity: Severity.ERROR,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
gpuDevice.lost.then((info) => {
|
||||||
|
if (info.reason === 'destroyed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorHandler.addError(Severity.ERROR, info.message || 'The WebGPU device was lost.', {
|
||||||
|
code: ErrorCode.WEBGPU_DEVICE_LOST,
|
||||||
|
details: {
|
||||||
|
reason: info.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return gpuDevice;
|
return gpuDevice;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
import { appConfig } from '../../config';
|
||||||
import { setUpFullScreenQuad } from './full-screen-quad';
|
import { setUpFullScreenQuad } from './full-screen-quad';
|
||||||
import { smartCompile } from './smart-compile';
|
import { smartCompile } from './smart-compile';
|
||||||
|
|
||||||
const textureCache = new Map<string, GPUTexture>();
|
export interface GeneratedNoiseTexture {
|
||||||
|
texture: GPUTexture;
|
||||||
|
view: GPUTextureView;
|
||||||
|
}
|
||||||
|
|
||||||
export const generateNoise = ({
|
export const generateNoise = ({
|
||||||
device,
|
device,
|
||||||
|
|
@ -11,15 +15,8 @@ export const generateNoise = ({
|
||||||
device: GPUDevice;
|
device: GPUDevice;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}): GPUTextureView => {
|
}): GeneratedNoiseTexture => {
|
||||||
const cacheKey = `${width}x${height}`;
|
const vertex = setUpFullScreenQuad(device);
|
||||||
const cached = textureCache.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached.createView();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { buffer, vertex } = setUpFullScreenQuad(device);
|
|
||||||
const vertexBuffer = buffer;
|
|
||||||
|
|
||||||
const pipeline = device.createRenderPipeline({
|
const pipeline = device.createRenderPipeline({
|
||||||
layout: 'auto',
|
layout: 'auto',
|
||||||
|
|
@ -29,28 +26,34 @@ export const generateNoise = ({
|
||||||
device,
|
device,
|
||||||
/* wgsl */ `
|
/* wgsl */ `
|
||||||
fn random_with_seed(uv: vec2<f32>, seed: f32) -> f32 {
|
fn random_with_seed(uv: vec2<f32>, seed: f32) -> f32 {
|
||||||
return fract(sin(dot(uv, vec2(12.9898 + seed, 78.233 + seed)))* 43758.5453123 + seed);
|
return fract(sin(dot(
|
||||||
|
uv,
|
||||||
|
vec2(
|
||||||
|
${appConfig.pipelines.common.noiseHashX} + seed,
|
||||||
|
${appConfig.pipelines.common.noiseHashY} + seed
|
||||||
|
)
|
||||||
|
)) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
return vec4(
|
return vec4(
|
||||||
random_with_seed(uv, 0),
|
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
|
||||||
random_with_seed(uv, 1),
|
0.0,
|
||||||
random_with_seed(uv, 2),
|
0.0,
|
||||||
random_with_seed(uv, 3),
|
1.0,
|
||||||
);
|
);
|
||||||
}`
|
}`
|
||||||
),
|
),
|
||||||
entryPoint: 'fragment',
|
entryPoint: 'fragment',
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: 'rgba16float',
|
format: appConfig.pipelines.common.noiseTextureFormat,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
primitive: {
|
primitive: {
|
||||||
topology: 'triangle-strip',
|
topology: 'triangle-list',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,7 +63,7 @@ export const generateNoise = ({
|
||||||
height,
|
height,
|
||||||
depthOrArrayLayers: 1,
|
depthOrArrayLayers: 1,
|
||||||
},
|
},
|
||||||
format: 'rgba16float',
|
format: appConfig.pipelines.common.noiseTextureFormat,
|
||||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -68,7 +71,7 @@ export const generateNoise = ({
|
||||||
colorAttachments: [
|
colorAttachments: [
|
||||||
{
|
{
|
||||||
view: colorTexture.createView(),
|
view: colorTexture.createView(),
|
||||||
clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
|
clearValue: appConfig.pipelines.common.noiseClearValue,
|
||||||
loadOp: 'clear',
|
loadOp: 'clear',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
},
|
},
|
||||||
|
|
@ -79,11 +82,15 @@ export const generateNoise = ({
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||||
passEncoder.setPipeline(pipeline);
|
passEncoder.setPipeline(pipeline);
|
||||||
passEncoder.setVertexBuffer(0, vertexBuffer);
|
passEncoder.draw(
|
||||||
passEncoder.draw(4, 1);
|
appConfig.pipelines.common.noiseDrawVertexCount,
|
||||||
|
appConfig.pipelines.common.noiseDrawInstanceCount
|
||||||
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
device.queue.submit([commandEncoder.finish()]);
|
device.queue.submit([commandEncoder.finish()]);
|
||||||
textureCache.set(cacheKey, colorTexture);
|
return {
|
||||||
return colorTexture.createView();
|
texture: colorTexture,
|
||||||
|
view: colorTexture.createView(),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,124 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
|
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../../pipelines/texture-formats';
|
||||||
|
|
||||||
|
interface ResizableTextureOptions {
|
||||||
|
clearValue?: GPUColor;
|
||||||
|
format?: GPUTextureFormat;
|
||||||
|
usage?: GPUTextureUsageFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingTextureResize {
|
||||||
|
copySize: GPUExtent3DStrict;
|
||||||
|
newSize: vec2;
|
||||||
|
newTexture: GPUTexture;
|
||||||
|
newTextureView: GPUTextureView;
|
||||||
|
oldTexture: GPUTexture;
|
||||||
|
}
|
||||||
|
|
||||||
export class ResizableTexture {
|
export class ResizableTexture {
|
||||||
private texture: GPUTexture;
|
private texture: GPUTexture;
|
||||||
private textureView: GPUTextureView;
|
private textureView: GPUTextureView;
|
||||||
private size: vec2;
|
private size: vec2;
|
||||||
private readonly copyPipeline: CopyPipeline;
|
private readonly clearValue: GPUColor;
|
||||||
|
private readonly format: GPUTextureFormat;
|
||||||
|
private readonly usage: GPUTextureUsageFlags;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
size: vec2
|
size: vec2,
|
||||||
|
{
|
||||||
|
clearValue = { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
format = TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
usage = defaultTextureUsage,
|
||||||
|
}: ResizableTextureOptions = {}
|
||||||
) {
|
) {
|
||||||
this.copyPipeline = new CopyPipeline(this.device);
|
this.size = vec2.clone(size);
|
||||||
this.size = size;
|
this.clearValue = clearValue;
|
||||||
|
this.format = format;
|
||||||
|
this.usage = usage;
|
||||||
this.texture = this.createTexture(size);
|
this.texture = this.createTexture(size);
|
||||||
this.textureView = this.texture.createView();
|
this.textureView = this.texture.createView();
|
||||||
}
|
}
|
||||||
|
|
||||||
public resize(size: vec2): void {
|
public prepareResize(size: vec2): PendingTextureResize | null {
|
||||||
if (vec2.equals(this.size, size)) {
|
if (vec2.equals(this.size, size)) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTexture = this.createTexture(size);
|
const newTexture = this.createTexture(size);
|
||||||
const newTextureView = newTexture.createView();
|
const newTextureView = newTexture.createView();
|
||||||
|
const copySize = {
|
||||||
|
width: Math.min(this.size[0], size[0]),
|
||||||
|
height: Math.min(this.size[1], size[1]),
|
||||||
|
};
|
||||||
|
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
return {
|
||||||
this.copyPipeline.execute(
|
copySize,
|
||||||
commandEncoder,
|
newSize: vec2.clone(size),
|
||||||
this.textureView,
|
newTexture,
|
||||||
newTextureView,
|
newTextureView,
|
||||||
vec2.div(vec2.create(), this.size, size)
|
oldTexture: this.texture,
|
||||||
);
|
};
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
}
|
||||||
this.texture.destroy();
|
|
||||||
|
|
||||||
this.size = size;
|
public encodeResize(
|
||||||
this.texture = newTexture;
|
commandEncoder: GPUCommandEncoder,
|
||||||
this.textureView = newTextureView;
|
resize: PendingTextureResize
|
||||||
|
): void {
|
||||||
|
const clearPass = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: resize.newTextureView,
|
||||||
|
clearValue: this.clearValue,
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
clearPass.end();
|
||||||
|
commandEncoder.copyTextureToTexture(
|
||||||
|
{ texture: resize.oldTexture },
|
||||||
|
{ texture: resize.newTexture },
|
||||||
|
resize.copySize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public commitResize(resize: PendingTextureResize): void {
|
||||||
|
resize.oldTexture.destroy();
|
||||||
|
this.size = resize.newSize;
|
||||||
|
this.texture = resize.newTexture;
|
||||||
|
this.textureView = resize.newTextureView;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSize(): vec2 {
|
||||||
|
return vec2.clone(this.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTextureView(): GPUTextureView {
|
public getTextureView(): GPUTextureView {
|
||||||
return this.textureView;
|
return this.textureView;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getTexture(): GPUTexture {
|
||||||
|
return this.texture;
|
||||||
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.texture.destroy();
|
this.texture.destroy();
|
||||||
this.copyPipeline.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private createTexture(size: vec2): GPUTexture {
|
private createTexture(size: vec2): GPUTexture {
|
||||||
return this.device.createTexture({
|
return this.device.createTexture({
|
||||||
format: 'rgba16float',
|
format: this.format,
|
||||||
size: { width: size[0], height: size[1] },
|
size: { width: size[0], height: size[1] },
|
||||||
usage:
|
usage: this.usage,
|
||||||
GPUTextureUsage.STORAGE_BINDING |
|
|
||||||
GPUTextureUsage.TEXTURE_BINDING |
|
|
||||||
GPUTextureUsage.RENDER_ATTACHMENT,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultTextureUsage =
|
||||||
|
GPUTextureUsage.STORAGE_BINDING |
|
||||||
|
GPUTextureUsage.TEXTURE_BINDING |
|
||||||
|
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||||
|
GPUTextureUsage.COPY_SRC |
|
||||||
|
GPUTextureUsage.COPY_DST;
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,25 @@ export const smartCompile = (
|
||||||
code: concatenated,
|
code: concatenated,
|
||||||
});
|
});
|
||||||
|
|
||||||
module.getCompilationInfo().then((info) =>
|
module.getCompilationInfo().then((info) => {
|
||||||
info.messages.forEach((message) =>
|
if (info.messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = concatenated.split('\n');
|
||||||
|
info.messages.forEach((message) => {
|
||||||
|
const sourceLine = lines[message.lineNum - 1] ?? '';
|
||||||
|
const fullSource = import.meta.env.DEV ? `\n\nCode:\n${concatenated}\n` : '';
|
||||||
ErrorHandler.addError(
|
ErrorHandler.addError(
|
||||||
{
|
{
|
||||||
info: Severity.INFO,
|
info: Severity.INFO,
|
||||||
warning: Severity.WARNING,
|
warning: Severity.WARNING,
|
||||||
error: Severity.ERROR,
|
error: Severity.ERROR,
|
||||||
}[message.type],
|
}[message.type],
|
||||||
`${message.message}\n${
|
`${message.message}\n${sourceLine}${fullSource}`
|
||||||
concatenated.split('\n')[message.lineNum - 1]
|
|
||||||
}\n\nCode:\n${concatenated}\n`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return module;
|
return module;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue