Rework garden simulation pipelines

This commit is contained in:
Andras Schmelczer 2026-05-24 10:58:11 +01:00
parent c2efb33683
commit 018f8c9d4d
67 changed files with 6544 additions and 1727 deletions

View 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]);
});
});

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

View 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;
};

View 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();
};
}

View 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;
};

View 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;
}
}

View 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();
}
}

View file

@ -1,8 +0,0 @@
export interface GameLoopSettings {
maxAgentCountUpperLimit: number;
agentCount: number;
renderSpeed: number;
simulatedDelayMs: number;
startColorHue: number;
}

View 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;
}

View file

@ -1,265 +1,364 @@
import { vec2 } from 'gl-matrix';
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 { 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 { GardenAudio } from '../audio/garden-audio';
import { appConfig } from '../config';
import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { initializeContext } from '../utils/graphics/initialize-context';
import { ResizableTexture } from '../utils/graphics/resizable-texture';
import { sleep } from '../utils/sleep';
import { GamePresentation } from './game-presentation';
import { GameRules } from './game-rules';
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
import { AgentPopulation } from './agent-population';
import { EraserPreview } from './eraser-preview';
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
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 {
private readonly trailMapA: ResizableTexture;
private readonly trailMapB: ResizableTexture;
private readonly commonState: CommonState;
private readonly copyPipeline: CopyPipeline;
private readonly agentGenerationPipeline: AgentGenerationPipeline;
private readonly agentPipeline: AgentPipeline;
private readonly renderPipeline: RenderPipeline;
private readonly brushPipeline: BrushPipeline;
private readonly diffusionPipeline: DiffusionPipeline;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(appConfig.audio);
private readonly introPrompt: IntroPrompt;
private readonly eraserPreview: EraserPreview;
private readonly pointerInput: GardenPointerInput;
private readonly agentPopulation: AgentPopulation;
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
private readonly framePerformance = new FramePerformance();
private perfStatsOverlay: PerfStatsOverlay | null = null;
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 animationFrameId: number | null = null;
private destroyPromise: Promise<void> | null = null;
private readonly finished = Promise.withResolvers<void>();
private activePointerId: number | null = null;
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice,
private readonly canvasFormat: GPUTextureFormat,
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.copyPipeline = new CopyPipeline(this.device);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
canvasSize: this.canvasSize,
time: 0,
deltaTime: 0,
this.resources = new GameLoopResources(
canvas,
device,
this.canvasFormat,
this.canvasSize,
this.framePerformance.adaptiveCapInitial,
settings.maxAgentCount
);
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.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));
this.syncPerfStatsOverlay();
}
private onPointerDown(event: PointerEvent) {
if (this.activePointerId !== null) {
return;
}
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
this.brushPipeline.clearSwipes();
this.addSwipeAt(event);
public attachPointerInput(): void {
this.pointerInput.attach();
this.eraserPreview.attach();
}
private onPointerMove(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
public setEraseMode(isErasing: boolean): void {
this.pointerInput.setEraseMode(isErasing);
this.eraserPreview.setEraseMode(isErasing);
}
private onPointerUp(event: PointerEvent) {
if (event.pointerId !== this.activePointerId) {
return;
}
this.addSwipeAt(event);
this.canvas.releasePointerCapture(event.pointerId);
this.activePointerId = null;
public updateEraserPreview(event?: PointerEvent): void {
this.eraserPreview.update(event);
}
private addSwipeAt(event: PointerEvent) {
const position = vec2.fromValues(
event.clientX * this.devicePixelRatio,
this.canvas.height - event.clientY * this.devicePixelRatio
);
this.brushPipeline.addSwipe(position);
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.syncPerfStatsOverlay();
}
private get isSwipeActive(): boolean {
return this.activePointerId !== null;
public setAudioMuted(isMuted: boolean): void {
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> {
requestAnimationFrame(this.render.bind(this));
requestAnimationFrame(this.updateCounts.bind(this));
if (this.animationFrameId === null && !this.hasFinished) {
this.animationFrameId = requestAnimationFrame(this.render);
}
return this.finished.promise;
}
private async updateCounts(): Promise<void> {
if (this.hasFinished) {
return;
public async exportSnapshot(): Promise<void> {
return this.exportSnapshotRenderer.export();
}
public async destroy(): Promise<void> {
this.destroyPromise ??= this.dispose();
return this.destroyPromise;
}
private async dispose(): Promise<void> {
this.hasFinished = true;
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
const generationCounts = await this.agentGenerationPipeline.countAgents(
settings.agentCount
);
this.gameRules.updateGenerationCounts(generationCounts);
requestAnimationFrame(this.updateCounts.bind(this));
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();
}
public get aliveAgentCounts(): {
currentGenerationCount: number;
nextGenerationCount: number;
} {
return this.gameRules.generationCounts;
}
public get maxAgentCount(): number {
return this.agentGenerationPipeline.maxAgentCount;
}
private resize() {
this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio;
this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio;
}
private async render(time: DOMHighResTimeStamp) {
private readonly render = (time: DOMHighResTimeStamp) => {
this.animationFrameId = null;
if (this.hasFinished) {
this.finished.resolve();
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);
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 timeInSeconds = time / 1000;
const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize);
const channelColors = activeVibe.colors;
const backgroundColor = activeVibe.backgroundColor;
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.commonState,
this.agentPipeline,
this.brushPipeline,
this.diffusionPipeline,
this.renderPipeline,
].forEach((pipeline) =>
pipeline.setParameters({
time,
isNextGenerationOdd: this.gameRules.nextGenerationId % 2,
nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(),
nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(),
infectionProbability: this.gameRules.getInfectionProbability(),
deltaTime,
canvasSize: this.canvasSize,
brushColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId - 1
),
evenGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 0
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
oddGenerationColor: GamePresentation.getGenerationColor(
this.gameRules.nextGenerationId % 2 == 1
? this.gameRules.nextGenerationId
: this.gameRules.nextGenerationId - 1
),
...settings,
center: spawnAction.position,
radius: spawnAction.radius,
})
this.resources.setFrameParameters({
time,
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
canvasPixelRatio,
introProgress,
selectedColorIndex: runtimeSettings.selectedColorIndex,
channelColors,
backgroundColor,
eraserPixelSize,
runtimeSettings,
});
this.resources.executeFrame(
isErasing,
this.toolbarContrastMonitor.takeReadbackRequest(time)
);
for (let i = 0; i < settings.renderSpeed; i++) {
const commandEncoder = this.device.createCommandEncoder();
this.pointerInput.clearSwipesIfIdle();
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(
commandEncoder,
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView());
this.agentPipeline.execute(
commandEncoder,
this.trailMapA.getTextureView(),
this.trailMapB.getTextureView()
);
this.diffusionPipeline.execute(
commandEncoder,
this.trailMapB.getTextureView(),
this.trailMapA.getTextureView()
);
this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView());
this.animationFrameId = requestAnimationFrame(this.render);
};
this.device.queue.submit([commandEncoder.finish()]);
private syncPerfStatsOverlay(): void {
if (appConfig.tuningPane.showFpsOverlay) {
this.perfStatsOverlay ??= new PerfStatsOverlay(
this.canvas.parentElement ?? document.body
);
return;
}
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));
this.perfStatsOverlay?.destroy();
this.perfStatsOverlay = null;
}
public async destroy() {
this.hasFinished = true;
await this.finished.promise;
private updateAccentColor(color: RgbColor): void {
const accentColor = rgbColorToCss(color);
if (this.previousAccentColor === accentColor) {
return;
}
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();
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.resources.clearSimulation();
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
this.pendingIntroResizeAt = null;
}
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 {
return window.devicePixelRatio || 1;
private get canvasPixelRatio(): number {
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;
}
}

View file

@ -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];
}
}

View file

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

View 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;
}
}

View 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)),
};
};

View 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();
}
}

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

View 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;

View 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;

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

View 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,
});
}
}

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

View 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();
}
}

View 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;

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

View 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;
}
}
}

View file

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

View file

@ -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,
);
}

View file

@ -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 { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import {
dispatchAgentWorkgroups,
getAgentWorkgroupSize,
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 { GenerationCounts } from './generation-counts';
export class AgentGenerationPipeline {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 1;
private static readonly COUNTER_COUNT = 3;
private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 1;
private static readonly CLEAR_COMPACTED_TAIL_STRIDE = 4;
private static readonly ALLOCATION_GROWTH_FACTOR = 1.25;
private readonly bindGroupLayout: GPUBindGroupLayout;
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 countingPipeline: GPUComputePipeline;
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
private readonly clearCompactedTailPipeline: GPUComputePipeline;
private readonly resizeWorkgroupSize: number;
private readonly compactionWorkgroupSize: number;
public readonly agentsBuffer: GPUBuffer;
public readonly countersBuffer: GPUBuffer;
public readonly countersStagingBuffer: GPUBuffer;
private activeAgentsBuffer: GPUBuffer;
private inactiveAgentsBuffer: 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(
private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly maxAgentCountUpperLimit: number
initialMaxAgentCount: number,
private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
) {
this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount);
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
@ -51,13 +83,18 @@ export class AgentGenerationPipeline {
type: 'storage',
},
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
],
});
this.agentsBuffer = this.device.createBuffer({
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
usage: GPUBufferUsage.STORAGE,
});
this.activeAgentsBuffer = this.createAgentsBuffer();
this.inactiveAgentsBuffer = this.createAgentsBuffer();
this.countersBuffer = this.device.createBuffer({
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
@ -74,99 +111,182 @@ export class AgentGenerationPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.countersBuffer,
},
},
],
});
this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize');
this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction');
const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize');
const compactionSchema = substituteAgentWorkgroupSize(
device,
agentSchema,
'compaction'
);
this.firstGenerationPipeline = device.createComputePipeline({
this.resizePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(
device,
CommonState.shaderCode,
agentSchema,
firstGenerationShader
),
module: smartCompile(device, resizeSchema, resizeShader),
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({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, countingShader),
module: compactionModule,
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 {
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(
this.maxAgentCountUpperLimit,
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
this.device.limits.maxComputeWorkgroupsPerDimension ** 3
getMaxSupportedAgentCount(this.device, this.maxAgentCountUpperLimit),
Math.max(0, requestedMaxAgentCount)
);
}
public spawnFirstGeneration(): void {
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
this.commonState.execute(passEncoder);
passEncoder.setPipeline(this.firstGenerationPipeline);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
this.maxAgentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
public writeAgents(agentOffset: number, data: Float32Array): void {
this.device.queue.writeBuffer(
this.activeAgentsBuffer,
agentOffset * AGENT_SIZE_IN_BYTES,
data
);
}
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();
this.device.queue.submit([commandEncoder.finish()]);
}
public async countAgents(agentCount: number): Promise<GenerationCounts> {
this.device.queue.writeBuffer(this.countersBuffer, 0, new Uint32Array([0, 0]));
this.device.queue.writeBuffer(this.uniforms, 0, new Uint32Array([agentCount]));
public async compactAgents(agentCount: number): Promise<number> {
if (agentCount <= 0) {
return 0;
}
this.agentCountUniformValues[0] = agentCount;
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
const commandEncoder = this.device.createCommandEncoder();
commandEncoder.clearBuffer(this.countersBuffer, 0, Uint32Array.BYTES_PER_ELEMENT);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.countingPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
passEncoder.setPipeline(this.compactionPipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount);
passEncoder.setPipeline(this.clearCompactedTailPipeline);
dispatchAgentWorkgroups(
passEncoder,
this.compactionWorkgroupSize,
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
);
passEncoder.end();
@ -175,25 +295,39 @@ export class AgentGenerationPipeline {
0,
this.countersStagingBuffer,
0,
AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT
Uint32Array.BYTES_PER_ELEMENT
);
this.device.queue.submit([commandEncoder.finish()]);
this.swapAgentBuffers();
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
const data = new Uint32Array(this.countersStagingBuffer.getMappedRange().slice(0));
const compactedCount = new Uint32Array(
this.countersStagingBuffer.getMappedRange(),
0,
1
)[0];
this.countersStagingBuffer.unmap();
return {
evenGenerationCount: data[0],
oddGenerationCount: data[1],
};
return compactedCount;
}
private getBindGroup(): GPUBindGroup {
return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer);
}
private swapAgentBuffers(): void {
[this.activeAgentsBuffer, this.inactiveAgentsBuffer] = [
this.inactiveAgentsBuffer,
this.activeAgentsBuffer,
];
}
public destroy() {
this.uniforms.destroy();
this.countersBuffer.destroy();
this.countersStagingBuffer.destroy();
this.agentsBuffer.destroy();
this.inactiveAgentsBuffer.destroy();
this.activeAgentsBuffer.destroy();
}
}

View 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;
}

View file

@ -1,11 +1,16 @@
struct Agent {
position: vec2<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>;
fn get_id(global_id: vec3<u32>, workgroup_count: vec3<u32>) -> u32 {
return global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
}
const agentWorkgroupSize = __AGENT_WORKGROUP_SIZE__u;
fn get_id(global_id: vec3<u32>) -> u32 {
return global_id.x;
}

View file

@ -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;

View file

@ -1,4 +0,0 @@
export interface GenerationCounts {
evenGenerationCount: number;
oddGenerationCount: number;
}

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

View file

@ -1,197 +1,255 @@
import { vec2 } from 'gl-matrix';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
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 { 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 { AgentSettings } from './agent-settings';
import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 19;
export interface AgentSettings {
color1ToColor1: number;
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 pipeline: GPUComputePipeline;
private readonly pipelineFull: GPUComputePipeline;
private readonly pipelineSteady: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private bindGroup?: GPUBindGroup;
private previousTrailMapIn?: GPUTextureView;
private previousTrailMapOut?: GPUTextureView;
private readonly workgroupSize: number;
private useSteadyPipeline = false;
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;
public constructor(
private readonly device: GPUDevice,
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({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
this.workgroupSize = getAgentWorkgroupSize(device, 'simulation');
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
substituteAgentWorkgroupSize(device, agentSchema, 'simulation'),
shader
);
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
});
this.pipelineFull = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
module: shaderModule,
entryPoint: 'main',
},
});
this.pipelineSteady = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: shaderModule,
entryPoint: 'mainSteady',
},
});
this.uniforms = this.device.createBuffer({
size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
public setParameters({
deltaTime,
center,
radius,
brushTrailWeight,
moveSpeed,
turnSpeed,
sensorOffsetAngle,
sensorOffsetDistance,
nextGenerationSensorOffsetDistance,
currentGenerationAggression,
nextGenerationAggression,
nextGenerationSpeed,
isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
infectionProbability,
color1ToColor1,
color1ToColor2,
color1ToColor3,
color2ToColor1,
color2ToColor2,
color2ToColor3,
color3ToColor1,
color3ToColor2,
color3ToColor3,
forwardRotationScale,
introNearDistanceInner,
introNearDistanceMin,
introNearSensorOffsetMultiplier,
introTargetAngleBlend,
introProgressCutoff,
introTurnRateMultiplier,
introRandomTurnMultiplier,
introMoveSpeed,
introStepStopDistance,
randomTimeScale,
time,
agentCount,
introProgress,
}: AgentSettings & {
deltaTime: number;
currentGenerationAggression: number;
nextGenerationAggression: number;
nextGenerationSensorOffsetDistance: number;
nextGenerationSpeed: number;
isNextGenerationOdd: number;
center: vec2;
radius: number;
infectionProbability: number;
time: number;
agentCount: number;
introMoveSpeed: number;
introProgress?: number;
}) {
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,
0,
new Float32Array([
...center,
radius,
brushTrailWeight,
moveSpeed * deltaTime,
turnSpeed * deltaTime,
(sensorOffsetAngle * Math.PI) / 180,
sensorOffsetDistance,
currentGenerationAggression,
nextGenerationAggression,
nextGenerationSensorOffsetDistance,
nextGenerationSpeed * deltaTime,
isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
infectionProbability,
agentCount,
])
this.uniformValues,
this.uniformCache
);
}
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
trailMapOut: GPUTextureView,
timestampWrites?: GPUComputePassTimestampWrites
) {
this.ensureBindGroupExists(trailMapIn, trailMapOut);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE)
);
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;
if (this.agentCount <= 0) {
return;
}
const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
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();
}
public 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',
},
},
],
};
}
}

View file

@ -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;
}

View file

@ -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 {
center: vec2<f32>,
radius: f32,
brushTrailWeight: f32,
currentGenerationMoveRate: f32,
// Columns are indexed by source colorIndex; each column holds the per-target
// weights (colorXToColor1, colorXToColor2, colorXToColor3).
reactionMatrix: mat3x3<f32>,
moveRate: f32,
turnRate: f32,
sensorAngle: f32,
sensorAngleSin: f32,
sensorAngleCos: f32,
sensorOffset: f32,
currentGenerationAggression: f32,
nextGenerationAggression: f32,
nextGenerationSensorOffsetDistance: f32,
nextGenerationMoveRate: f32,
isNextGenerationOdd: f32,
turnWhenLost: f32,
individualTrailWeight: f32,
infectionProbability: f32,
agentCount: f32 // might be smaller than the length of the agents array
agentCount: u32,
introProgress: f32,
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;
// 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(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(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
@builtin(global_invocation_id) global_id: 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;
}
var agent = agents[id];
let random = textureSampleLevel(
noise,
noiseSampler,
vec2(
f32(id) % 23647 / 2000,
state.time % 3243 / 2000
),
0
);
let isFromCurrentGeneration = abs(agent.generation - settings.isNextGenerationOdd);
let isFromNextGeneration = 1.0 - isFromCurrentGeneration;
let isFromOddGeneration = agent.generation % 2;
let sensorOffset = mix(settings.sensorOffset, settings.nextGenerationSensorOffsetDistance, isFromNextGeneration);
let moveRate = mix(settings.currentGenerationMoveRate, settings.nextGenerationMoveRate, isFromNextGeneration);
let brushWeight = mix(settings.brushTrailWeight, 0, isFromNextGeneration);
let trailForward = sense(agent.position, agent.angle, sensorOffset, 0);
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 {
rotation = 0;
} else {
rotation = sign(weightLeft - weightRight) * settings.turnRate;
let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 || colorIndex >= 2.5 {
return;
}
let nextPosition = clamp(
agent.position + vec2(cos(agent.angle), sin(agent.angle)) * moveRate,
vec2<f32>(0, 0),
state.size
);
if nextPosition.x == 0 || nextPosition.x == state.size.x || nextPosition.y == 0 || nextPosition.y == state.size.y {
rotation = 3.14159265359 + random.a - 0.5;
}
var trail = vec4<f32>(settings.individualTrailWeight, 0, 0, 0);
if isFromOddGeneration == 1.0 {
trail = vec4<f32>(0, settings.individualTrailWeight, 0, 0);
}
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
agent.angle += rotation;
trailBelow += trail;
if settings.radius > 0 && length(settings.center - agent.position) < settings.radius {
agent.generation = settings.isNextGenerationOdd;
// clear trail map below so the agent won't die immediately
// trailBelow.r = (1 - settings.isNextGenerationOdd) * (trailBelow.r + trailBelow.g);
// trailBelow.g = settings.isNextGenerationOdd * (trailBelow.r + trailBelow.g);
} 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;
let position = agents[id].position;
let angle = agents[id].angle;
var targetPosition = vec2<f32>(-1.0, -1.0);
var hasIntroTarget = false;
if settings.introProgress < settings.introProgressCutoff {
targetPosition = agents[id].targetPosition;
hasIntroTarget = targetPosition.x >= 0.0 && targetPosition.y >= 0.0;
if hasIntroTarget && settings.introProgress < agents[id].introDelay {
return;
}
}
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
agent.position = nextPosition;
agents[id] = agent;
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);
}
fn sense(agentPosition: vec2<f32>, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4<f32> {
let sensorAngle = agentAngle + sensorOffsetAngle;
let sensorPosition = vec2<i32>(agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset);
return textureLoad(trailMapIn, sensorPosition, 0);
// 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 trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let weightForward = dot(trailForward.rgb, reactionMask);
let weightLeft = dot(trailLeft.rgb, reactionMask);
let weightRight = dot(trailRight.rgb, reactionMask);
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
return AgentMovement(rotation, direction * settings.moveRate);
}
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
);
let desiredAngle = mix(
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);
}
fn agent_finalize(
id: u32,
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;
}
// Writes only this agent's last-writer-wins deposit into a per-frame-cleared
// depositMap. Storage textures do not blend concurrent compute writes, so
// overlapping agents intentionally collapse to whichever write wins. The
// diffusion pass then sums trailMap + depositMap at tile-load time.
textureStore(
trailMapOut,
vec2<i32>(nextPosition),
vec4<f32>(channelMask * settings.individualTrailWeight, 0.0)
);
agents[id].angle = angle + rotation;
agents[id].position = nextPosition;
}
fn sensor_position(
agentPosition: vec2<f32>,
direction: vec2<f32>,
sensorOffset: f32,
maxPosition: vec2<f32>
) -> vec2<i32> {
return vec2<i32>(clamp(
agentPosition + direction * sensorOffset,
vec2<f32>(0, 0),
maxPosition
));
}
fn rotate_direction(direction: vec2<f32>, angleSin: f32, angleCos: f32) -> vec2<f32> {
return vec2<f32>(
direction.x * angleCos - direction.y * angleSin,
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;
}

View file

@ -1,261 +1,206 @@
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 { 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';
export class BrushPipeline {
private static readonly UNIFORM_COUNT = 2;
private static readonly MAX_LINE_COUNT = 20;
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
export interface BrushSettings {
brushSize: number;
brushAlpha: number;
brushDiscardThreshold: number;
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 bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly renderPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly vertexBuffer: GPUBuffer;
private linePoints: Array<vec2> = [];
private actualPoints: Array<vec2> = [];
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.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout);
this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
this.vertexBuffer = device.createBuffer({
size:
BrushPipeline.MAX_LINE_COUNT *
BrushPipeline.VERTICES_PER_LINE_SEGMENT *
BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT *
Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' },
},
],
});
this.pipeline = device.createRenderPipeline({
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
lineSegmentShader,
shader
);
this.renderPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: smartCompile(device, CommonState.shaderCode, shader),
module: shaderModule,
entryPoint: 'vertex',
buffers: [
{
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,
},
],
},
],
buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
},
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
module: shaderModule,
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
format: TRAIL_SOURCE_TEXTURE_FORMAT,
blend: {
color: {
operation: 'add',
srcFactor: 'zero',
dstFactor: 'one',
},
alpha: {
operation: 'max',
srcFactor: 'one',
dstFactor: 'one',
},
color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
},
},
],
},
primitive: {
topology: 'triangle-list',
},
primitive: { topology: 'triangle-list' },
});
this.uniforms = this.device.createBuffer({
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.bindGroup = this.device.createBindGroup({
this.bindGroup = device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
],
entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
});
}
public addSwipe(position: vec2) {
this.linePoints.push(position);
public addSwipeSegment(from: vec2, to: vec2): void {
this.segments.add(from, to);
}
public clearSwipes() {
this.linePoints.length = 0;
public clearSwipes(): void {
this.segments.clear();
}
public setParameters({ brushSize, brushSizeVariation }: BrushSettings) {
this.device.queue.writeBuffer(
public setParameters(parameters: BrushParameters): void {
setBrushUniformValues(this.uniformValues, parameters);
writeBufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([brushSize / 2, Math.floor((brushSize / 2) * brushSizeVariation)])
);
this.actualPoints = this.linePoints.slice();
this.linePoints.splice(0, this.linePoints.length - 1);
if (this.actualPoints.length === 0) {
return;
}
if (this.actualPoints.length === 1) {
this.actualPoints.push(this.actualPoints[0]); // allow single point swipes
}
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]);
})
)
this.uniformValues,
this.uniformCache
);
this.segments.flush();
}
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]),
});
public executeSource(
commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView,
timestampWrites?: GPURenderPassTimestampWrites
): boolean {
const lineCount = this.segments.activeCount;
if (lineCount === 0) {
return false;
}
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);
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.vertexBuffer);
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
passEncoder.end();
return true;
}
public destroy() {
this.vertexBuffer.destroy();
public destroy(): void {
this.segments.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);
}
}
const recordBrushPassForE2e = (): void => {
if (typeof window === 'undefined') {
return;
}
const state = window as Window & { __fleetingGardenBrushPasses?: number };
state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1;
};

View file

@ -1,4 +0,0 @@
export interface BrushSettings {
brushSize: number;
brushSizeVariation: number;
}

View file

@ -1,6 +1,18 @@
const SEGMENT_LENGTH_EPSILON: f32 = 0.0001;
struct Settings {
brushSize: f32,
brushSizeVariation: f32
brushRadius: 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;
@ -8,41 +20,100 @@ struct Settings {
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32,
}
struct BrushTargets {
@location(0) source: vec4<f32>,
}
@vertex
fn vertex(
@location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) end: vec2<f32>
@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 > 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 position = uv * 2.0 - 1.0;
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
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 fragment(
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
var distance = distanceFromLine(screenPosition, start, end);
let noise = textureSample(noise, noiseSampler, screenPosition / state.size / 50);
distance += noise.r * settings.brushSizeVariation;
@location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>,
@location(3) @interpolate(flat) inverseLengthSquared: f32
) -> BrushTargets {
let strength = brushStrength(screenPosition, start, direction, inverseLengthSquared);
if(distance > settings.brushSize) {
discard;
}
if(strength < settings.brushDiscardThreshold) {
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 {
let pa = position - start;
let direction = end - start;
let q = clamp(dot(pa, direction) / dot(direction, direction), 0, 1);
return length(pa - direction * q);
fn brushStrength(
screenPosition: vec2<f32>,
start: vec2<f32>,
direction: vec2<f32>,
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);
}

View file

@ -1,12 +1,21 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import {
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
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;
public readonly bindGroupLayout: GPUBindGroupLayout;
@ -14,10 +23,9 @@ export class CommonState {
public static readonly shaderCode = /* wgsl */ `
struct State {
size: vec2<f32>,
deltaTime: f32,
time: f32,
_padding: vec2<f32>,
};
@group(0) @binding(0) var<uniform> state: State;
@group(0) @binding(1) var noiseSampler: sampler;
@group(0) @binding(2) var noise: texture_2d<f32>;
@ -29,11 +37,12 @@ export class CommonState {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.noise = generateNoise({
const noise = generateNoise({
device,
width: 2048,
height: 2048,
width: appConfig.pipelines.common.noiseTextureSize,
height: appConfig.pipelines.common.noiseTextureSize,
});
this.noise = noise.texture;
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
@ -74,31 +83,28 @@ export class CommonState {
{
binding: 1,
resource: this.device.createSampler({
addressModeU: 'repeat',
addressModeV: 'repeat',
magFilter: 'linear',
minFilter: 'linear',
}),
},
{
binding: 2,
resource: this.noise,
resource: noise.view,
},
],
});
}
public setParameters({
canvasSize,
deltaTime,
time,
}: {
canvasSize: vec2;
deltaTime: number;
time: number;
}) {
this.device.queue.writeBuffer(
public setParameters({ canvasSize }: { canvasSize: vec2 }) {
this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1];
writeBufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([...canvasSize, deltaTime, time])
this.uniformValues,
this.uniformCache
);
}
@ -108,5 +114,6 @@ export class CommonState {
public destroy() {
this.uniforms.destroy();
this.noise.destroy();
}
}

View 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;
};

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

View file

@ -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',
},
},
],
};
}
}

View file

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

View file

@ -1,48 +1,171 @@
struct Settings {
inverseDiffusionRateTrails: f32,
decayRateTrails: f32,
inverseDiffusionRateBrush: f32,
decayRateBrush: f32,
diffusionNeighborScale: 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(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
@group(0) @binding(0) var<uniform> settings: Settings;
@group(0) @binding(1) 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
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
var current = textureSample(trailMap, Sampler, uv);
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
fn main(
@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 += (
propagate(uv, vec2(-1.0, -1.0), current)
+ propagate(uv, vec2(-1.0, 1.0), current)
+ propagate(uv, vec2(1.0, -1.0), current)
+ propagate(uv, vec2(1.0, 1.0), current)
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
let sourcePixel = clamp(
vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1),
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)
+ 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;
workgroupBarrier();
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(
current.rgb * settings.decayRateTrails,
max(0, current.a + (current.a - 1.001) * settings.decayRateBrush)
updated.rgb * settings.decayRateTrails - vec3(TRAIL_RGB_DECAY_SUBTRACT),
updated.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract
), vec4(0), vec4(1));
return decayed;
textureStore(trailMapOut, pixel, decayed);
}
fn propagate(uv: vec2<f32>, offset: vec2<f32>, currentColor: vec4<f32>) -> vec4<f32> {
let neighbour = textureSample(trailMap, Sampler, uv + offset / state.size);
var random = textureSample(noise, noiseSampler, uv + offset / state.size * 0.5).r;
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
fn propagate_value(
neighbour: vec4<f32>,
neighbourStrength: f32,
current: vec4<f32>,
trailWeight: f32
) -> vec4<f32> {
let difference = clamp(neighbour - current, vec4(0), vec4(1));
return vec4(
vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)),
length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush)
vec3(neighbourStrength * trailWeight),
neighbour.a * trailWeight
) * 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);
}

View file

@ -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 { CommonState } from '../common-state/common-state';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
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 {
private static readonly UNIFORM_COUNT = 4;
private static readonly WORKGROUP_SIZE = 16;
private static readonly UNIFORM_COUNT = 8;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly pipeline: GPUComputePipeline;
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;
private previousTrailMapIn?: GPUTextureView;
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
public constructor(private readonly device: GPUDevice) {
this.bindGroupLayout = device.createBindGroupLayout(
DiffusionPipeline.bindGroupLayout
);
const { buffer, vertex } = setUpFullScreenQuad(device);
this.vertexBuffer = buffer;
this.pipeline = device.createRenderPipeline({
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [this.bindGroupLayout],
}),
vertex,
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
},
],
},
primitive: {
topology: 'triangle-strip',
compute: {
module: smartCompile(device, this.shaderCode),
entryPoint: 'main',
},
});
@ -49,85 +111,81 @@ export class DiffusionPipeline {
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
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({
diffusionRateTrails,
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
brushDecayAlphaOffset,
}: DiffusionSettings) {
this.device.queue.writeBuffer(
setDiffusionUniformValues(this.uniformValues, {
diffusionRateTrails,
decayRateTrails,
decayRateBrush,
diffusionDecayRateDivisor,
diffusionNeighborDivisor,
brushDecayAlphaOffset,
});
writeBufferIfChanged(
this.device,
this.uniforms,
0,
new Float32Array([
1 / diffusionRateTrails,
decayRateTrails / 1000,
1 / diffusionRateBrush,
decayRateBrush / 1000,
])
this.uniformValues,
this.uniformCache
);
}
public execute(
commandEncoder: GPUCommandEncoder,
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 = {
colorAttachments: [
{
view: trailMapOut,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
passEncoder.setPipeline(this.pipeline);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.draw(4, 1);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.dispatchWorkgroups(
Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE),
Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE)
);
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() {
this.vertexBuffer.destroy();
this.uniforms.destroy();
this.emptyDepositTexture.destroy();
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
@ -135,21 +193,29 @@ export class DiffusionPipeline {
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
access: 'write-only',
format: TRAIL_SOURCE_TEXTURE_FORMAT,
},
},
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
@ -157,4 +223,11 @@ export class DiffusionPipeline {
],
};
}
private get shaderCode(): string {
return shader.replaceAll(
'__WORKGROUP_SIZE__',
DiffusionPipeline.WORKGROUP_SIZE.toString()
);
}
}

View file

@ -1,6 +0,0 @@
export interface DiffusionSettings {
diffusionRateTrails: number;
decayRateTrails: number;
diffusionRateBrush: number;
decayRateBrush: number;
}

View 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),
};
};

View 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;
}
}

View 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();
}
}

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

View file

@ -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 { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { RenderSettings } from './render-settings';
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import shader from './render.wgsl?raw';
export class RenderPipeline {
private static readonly UNIFORM_COUNT = 13;
export interface RenderSettings {
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 pipeline: GPURenderPipeline;
private readonly noSourcePipeline: GPURenderPipeline;
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 previousColorTexture?: GPUTextureView;
private readonly getBindGroup = createBindGroupCache<[GPUTextureView, 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(
private readonly context: GPUCanvasContext,
private readonly device: GPUDevice,
private readonly commonState: CommonState
private readonly canvasFormat: GPUTextureFormat
) {
this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout);
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(),
},
],
},
primitive: {
topology: 'triangle-strip',
},
});
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
public setParameters({
brushColor,
evenGenerationColor,
oddGenerationColor,
clarity,
}: RenderSettings & {
brushColor: vec3;
evenGenerationColor: vec3;
oddGenerationColor: vec3;
}) {
this.device.queue.writeBuffer(
this.uniforms,
0,
new Float32Array([
...brushColor,
0, //padding
...evenGenerationColor,
0, //padding
...oddGenerationColor,
clarity,
])
);
}
public execute(commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView) {
this.ensureBindGroupExists(colorTexture);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: this.context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
}
private ensureBindGroupExists(colorTexture: GPUTextureView) {
if (this.previousColorTexture !== colorTexture) {
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() {
this.vertexBuffer.destroy();
this.uniforms.destroy();
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
return {
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
},
buffer: { type: 'uniform' },
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: 'float',
},
texture: { sampleType: 'float' },
},
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: 'float' },
},
],
};
});
const shaderModule = smartCompile(device, shader);
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,
});
}
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({
channelColors,
backgroundColor,
clarity,
renderTraceNormalizationFloor,
renderBrushColorBase,
renderBrushColorStrengthMultiplier,
}: RenderSettings & {
channelColors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor;
}) {
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.uniformValues,
this.uniformCache
);
}
public execute(
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;
}
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: [
{
view: output,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
timestampWrites,
});
passEncoder.setPipeline(this.getPipeline(useSourceTexture));
passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture));
passEncoder.draw(3, 1);
passEncoder.end();
}
private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
return useSourceTexture ? this.pipeline : this.noSourcePipeline;
}
public destroy() {
this.uniforms.destroy();
}
}

View file

@ -1,3 +0,0 @@
export interface RenderSettings {
clarity: number;
}

View file

@ -1,39 +1,158 @@
struct Settings {
brushColor: vec3<f32>,
evenGenerationColor: vec3<f32>,
oddGenerationColor: vec3<f32>,
colorA: vec3<f32>,
_colorAPadding: f32,
colorB: vec3<f32>,
_colorBPadding: f32,
colorC: vec3<f32>,
_colorCPadding: f32,
backgroundColor: vec3<f32>,
clarity: f32,
traceNormalizationFloor: f32,
brushColorBase: f32,
brushColorStrengthMultiplier: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
const COMMON_CHANNEL_REDUCTION: f32 = 0.75;
const OVERLAP_SATURATION_BOOST: f32 = 1.35;
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
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let traces = textureSample(trailMap, Sampler, uv);
let random = textureSample(noise, noiseSampler, uv);
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
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);
let oddGenerationStrength = clarity(traces.g);
let brushStrength = traces.a;
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
let traceStrengths = clarity(traces.rgb);
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(
mix(
evenGenerationStrength * settings.evenGenerationColor,
oddGenerationStrength * settings.oddGenerationColor,
oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
if brushStrength <= 0.0 {
let traceColor = colorFromChannelStrengths(traceStrengths);
return vec4(mix(background, clamp(traceColor, vec3(0), vec3(1)), traceStrength), 1);
}
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 {
return pow(strength, settings.clarity);
fn dominantColor(strengths: vec3<f32>) -> vec3<f32> {
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));
}

View file

@ -0,0 +1,2 @@
export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat;
export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat;

View file

@ -1,37 +1,26 @@
import { clamp } from './clamp';
import { exponentialDecay } from './exponential-decay';
import { appConfig } from '../config';
import { clamp } from './math';
export class DeltaTimeCalculator {
private static FPS_EXPONENTIAL_DECAY_STRENGTH = 0.01;
private previousTime: DOMHighResTimeStamp | null = null;
private deltaTimeAccumulator: number | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
constructor(
private readonly maxDeltaTimeInSeconds: number = 1 / 30,
private readonly minDeltaTimeInSeconds: number = 1 / 240
) {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
constructor() {
document.addEventListener('visibilitychange', this.visibilityChangeListener);
}
public calculateDeltaTimeInSeconds(
currentTime: DOMHighResTimeStamp
): DOMHighResTimeStamp {
public calculateDeltaTimeInSeconds(currentTime: DOMHighResTimeStamp): number {
if (this.previousTime === null) {
this.previousTime = currentTime;
}
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
const deltaInSeconds = delta / 1000;
this.deltaTimeAccumulator = exponentialDecay({
accumulator: this.deltaTimeAccumulator ?? deltaInSeconds,
nextValue: deltaInSeconds,
biasOfNextValue: DeltaTimeCalculator.FPS_EXPONENTIAL_DECAY_STRENGTH,
});
return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds);
return clamp(
delta / 1000,
appConfig.deltaTime.minDeltaTimeSeconds,
appConfig.deltaTime.maxDeltaTimeSeconds
);
}
private handleVisibilityChange() {
@ -40,7 +29,7 @@ export class DeltaTimeCalculator {
}
}
public get fps() {
return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
public destroy(): void {
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
}
}

View file

@ -4,42 +4,231 @@ export enum Severity {
ERROR = 'error',
}
export interface ErrorHandlerError {
severity: Severity;
message: string;
export enum ErrorCode {
WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
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 {
private static readonly errors: Array<ErrorHandlerError> = [];
private static metadata: ErrorMetadata = {};
private static onErrorListeners: Array<
(error: ErrorHandlerError, metadata: ErrorMetadata) => void
> = [];
public static addException(exception: Error) {
ErrorHandler.addError(Severity.ERROR, exception.message);
public static addException(
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) {
ErrorHandler.errors.push({ severity, message });
public static addError(
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) =>
listener({ severity, message }, ErrorHandler.metadata)
listener(error, ErrorHandler.metadata)
);
}
public static addMetadata(key: string, value: any) {
const serialized: Record<string, any> = {};
for (const k in value) {
serialized[k] = value[k];
}
ErrorHandler.metadata[key] = serialized;
public static addMetadata(key: string, value: unknown) {
ErrorHandler.metadata[key] = serializeMetadataValue(value);
}
public static addOnErrorListener(
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
) {
): () => void {
ErrorHandler.onErrorListeners.push(listener);
return () => {
ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter(
(registeredListener) => registeredListener !== listener
);
};
}
}

View 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;
};
};

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

View 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;
};

View file

@ -1,65 +1,25 @@
import { smartCompile } from './smart-compile';
export const setUpFullScreenQuad = (
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();
export const setUpFullScreenQuad = (device: GPUDevice): GPUVertexState => ({
module: smartCompile(
device,
/* wgsl */ `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
return {
buffer,
vertex: {
module: smartCompile(
device,
/* wgsl */ `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vertex(
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>
) -> VertexOutput {
return VertexOutput(vec4(position, 0.0, 1.0), uv);
}`
),
entryPoint: 'vertex',
buffers: [
{
arrayStride: 4 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex',
attributes: [
{
shaderLocation: 0,
offset: 0,
format: 'float32x2',
},
{
shaderLocation: 1,
offset: 8,
format: 'float32x2',
},
],
},
],
},
};
};
@vertex
fn vertex(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
let positions = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
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);
}`
),
entryPoint: 'vertex',
});

View file

@ -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];
};

View file

@ -1,17 +1,50 @@
import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
export const initializeContext = ({
device,
canvas,
format,
}: {
device: GPUDevice;
canvas: HTMLCanvasElement;
format: GPUTextureFormat;
}): GPUCanvasContext => {
const context = canvas.getContext('webgpu') as any as GPUCanvasContext;
const context = canvas.getContext('webgpu');
context.configure({
device: device,
format: navigator.gpu.getPreferredCanvasFormat(),
alphaMode: 'premultiplied',
});
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({
device: device,
format,
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;
};

View file

@ -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> => {
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;
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,
isSecureContext: window.isSecureContext,
},
});
}
const adapter = await gpu.requestAdapter({
powerPreference: 'high-performance',
});
const adapter =
(await requestAdapter(gpu, {
powerPreference: 'high-performance',
})) ?? (await requestAdapter(gpu));
if (!adapter) {
throw new Error('Could not request adatper');
throw new RuntimeError(
ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
'WebGPU is available, but this browser could not provide a compatible GPU adapter.'
);
}
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 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) =>
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;
};

View file

@ -1,7 +1,11 @@
import { appConfig } from '../../config';
import { setUpFullScreenQuad } from './full-screen-quad';
import { smartCompile } from './smart-compile';
const textureCache = new Map<string, GPUTexture>();
export interface GeneratedNoiseTexture {
texture: GPUTexture;
view: GPUTextureView;
}
export const generateNoise = ({
device,
@ -11,15 +15,8 @@ export const generateNoise = ({
device: GPUDevice;
width: number;
height: number;
}): GPUTextureView => {
const cacheKey = `${width}x${height}`;
const cached = textureCache.get(cacheKey);
if (cached) {
return cached.createView();
}
const { buffer, vertex } = setUpFullScreenQuad(device);
const vertexBuffer = buffer;
}): GeneratedNoiseTexture => {
const vertex = setUpFullScreenQuad(device);
const pipeline = device.createRenderPipeline({
layout: 'auto',
@ -29,28 +26,34 @@ export const generateNoise = ({
device,
/* wgsl */ `
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
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
return vec4(
random_with_seed(uv, 0),
random_with_seed(uv, 1),
random_with_seed(uv, 2),
random_with_seed(uv, 3),
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
0.0,
0.0,
1.0,
);
}`
),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
format: appConfig.pipelines.common.noiseTextureFormat,
},
],
},
primitive: {
topology: 'triangle-strip',
topology: 'triangle-list',
},
});
@ -60,7 +63,7 @@ export const generateNoise = ({
height,
depthOrArrayLayers: 1,
},
format: 'rgba16float',
format: appConfig.pipelines.common.noiseTextureFormat,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
@ -68,7 +71,7 @@ export const generateNoise = ({
colorAttachments: [
{
view: colorTexture.createView(),
clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
clearValue: appConfig.pipelines.common.noiseClearValue,
loadOp: 'clear',
storeOp: 'store',
},
@ -79,11 +82,15 @@ export const generateNoise = ({
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, vertexBuffer);
passEncoder.draw(4, 1);
passEncoder.draw(
appConfig.pipelines.common.noiseDrawVertexCount,
appConfig.pipelines.common.noiseDrawInstanceCount
);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
textureCache.set(cacheKey, colorTexture);
return colorTexture.createView();
return {
texture: colorTexture,
view: colorTexture.createView(),
};
};

View file

@ -1,63 +1,124 @@
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 {
private texture: GPUTexture;
private textureView: GPUTextureView;
private size: vec2;
private readonly copyPipeline: CopyPipeline;
private readonly clearValue: GPUColor;
private readonly format: GPUTextureFormat;
private readonly usage: GPUTextureUsageFlags;
public constructor(
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 = size;
this.size = vec2.clone(size);
this.clearValue = clearValue;
this.format = format;
this.usage = usage;
this.texture = this.createTexture(size);
this.textureView = this.texture.createView();
}
public resize(size: vec2): void {
public prepareResize(size: vec2): PendingTextureResize | null {
if (vec2.equals(this.size, size)) {
return;
return null;
}
const newTexture = this.createTexture(size);
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();
this.copyPipeline.execute(
commandEncoder,
this.textureView,
return {
copySize,
newSize: vec2.clone(size),
newTexture,
newTextureView,
vec2.div(vec2.create(), this.size, size)
);
this.device.queue.submit([commandEncoder.finish()]);
this.texture.destroy();
oldTexture: this.texture,
};
}
this.size = size;
this.texture = newTexture;
this.textureView = newTextureView;
public encodeResize(
commandEncoder: GPUCommandEncoder,
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 {
return this.textureView;
}
public getTexture(): GPUTexture {
return this.texture;
}
public destroy(): void {
this.texture.destroy();
this.copyPipeline.destroy();
}
private createTexture(size: vec2): GPUTexture {
return this.device.createTexture({
format: 'rgba16float',
format: this.format,
size: { width: size[0], height: size[1] },
usage:
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT,
usage: this.usage,
});
}
}
const defaultTextureUsage =
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.COPY_DST;

View file

@ -10,20 +10,25 @@ export const smartCompile = (
code: concatenated,
});
module.getCompilationInfo().then((info) =>
info.messages.forEach((message) =>
module.getCompilationInfo().then((info) => {
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(
{
info: Severity.INFO,
warning: Severity.WARNING,
error: Severity.ERROR,
}[message.type],
`${message.message}\n${
concatenated.split('\n')[message.lineNum - 1]
}\n\nCode:\n${concatenated}\n`
)
)
);
`${message.message}\n${sourceLine}${fullSource}`
);
});
});
return module;
};