diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts new file mode 100644 index 0000000..2bc0e26 --- /dev/null +++ b/src/game-loop/agent-population.test.ts @@ -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 = []; + public readonly writtenAgentOffsets: Array = []; + public readonly writtenBatches: Array = []; + 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 { + 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]); + }); +}); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts new file mode 100644 index 0000000..1d0390f --- /dev/null +++ b/src/game-loop/agent-population.ts @@ -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 | null = null; + private readonly queuedAgentBatches: Array = []; + 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 { + 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); +}; diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts new file mode 100644 index 0000000..3d45283 --- /dev/null +++ b/src/game-loop/brush-stroke-smoother.ts @@ -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 = []; + private lastBrushPosition: vec2 | null = null; + + public constructor(private readonly options: BrushStrokeSmootherOptions) {} + + public addSample(position: vec2): Array { + 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 { + 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 { + 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 = []; + 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; +}; diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts new file mode 100644 index 0000000..fefd93c --- /dev/null +++ b/src/game-loop/eraser-preview.ts @@ -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(); + }; +} diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts new file mode 100644 index 0000000..0be8911 --- /dev/null +++ b/src/game-loop/export-snapshot-renderer.ts @@ -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 { + 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 { + 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, + width: number, + height: number + ): Promise { + 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 => { + const pixels: Uint8ClampedArray = 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; +}; diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts new file mode 100644 index 0000000..9fc249e --- /dev/null +++ b/src/game-loop/frame-performance.ts @@ -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; + } +} diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts new file mode 100644 index 0000000..64c9680 --- /dev/null +++ b/src/game-loop/game-loop-resources.ts @@ -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(); + } +} diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts deleted file mode 100644 index e71ebc8..0000000 --- a/src/game-loop/game-loop-settings.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface GameLoopSettings { - maxAgentCountUpperLimit: number; - agentCount: number; - renderSpeed: number; - simulatedDelayMs: number; - - startColorHue: number; -} diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts new file mode 100644 index 0000000..2dba0c9 --- /dev/null +++ b/src/game-loop/game-loop-types.ts @@ -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; +} diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index b10d843..aed5289 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -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 | null = null; private readonly finished = Promise.withResolvers(); - 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 { - 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 { - if (this.hasFinished) { - return; + public async exportSnapshot(): Promise { + return this.exportSnapshotRenderer.export(); + } + + public async destroy(): Promise { + this.destroyPromise ??= this.dispose(); + return this.destroyPromise; + } + + private async dispose(): Promise { + 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; } } diff --git a/src/game-loop/game-presentation.ts b/src/game-loop/game-presentation.ts deleted file mode 100644 index 7332da3..0000000 --- a/src/game-loop/game-presentation.ts +++ /dev/null @@ -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]; - } -} diff --git a/src/game-loop/game-rules.ts b/src/game-loop/game-rules.ts deleted file mode 100644 index 87bf9e4..0000000 --- a/src/game-loop/game-rules.ts +++ /dev/null @@ -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); - } -} diff --git a/src/game-loop/gpu-profiler.ts b/src/game-loop/gpu-profiler.ts new file mode 100644 index 0000000..bc2318d --- /dev/null +++ b/src/game-loop/gpu-profiler.ts @@ -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>; + 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; + private readonly isEnabled: () => boolean; + private activePasses: Array = []; + 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, + 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; + } +} diff --git a/src/game-loop/internal-render-size.ts b/src/game-loop/internal-render-size.ts new file mode 100644 index 0000000..5184618 --- /dev/null +++ b/src/game-loop/internal-render-size.ts @@ -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)), + }; +}; diff --git a/src/game-loop/intro-prompt.ts b/src/game-loop/intro-prompt.ts new file mode 100644 index 0000000..cd9468d --- /dev/null +++ b/src/game-loop/intro-prompt.ts @@ -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 = ` + + Draw on the screen + `; + } + + private hideDrawHint(): void { + this.prompt.classList.remove(DRAW_HINT_CLASS); + this.prompt.replaceChildren(); + } +} diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts new file mode 100644 index 0000000..a34bbf4 --- /dev/null +++ b/src/game-loop/intro-title-agents.ts @@ -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 => { + 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 = []; + 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, + 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); +}; diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts new file mode 100644 index 0000000..9e6717f --- /dev/null +++ b/src/game-loop/perf-stats-overlay.ts @@ -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; diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts new file mode 100644 index 0000000..bee83ef --- /dev/null +++ b/src/game-loop/pointer-input.ts @@ -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): 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 { + const getCoalescedEvents = ( + event as PointerEvent & { getCoalescedEvents?: () => Array } + ).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 { + 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; diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts new file mode 100644 index 0000000..36629e8 --- /dev/null +++ b/src/game-loop/simulation-frame.ts @@ -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); +}; diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts new file mode 100644 index 0000000..7166595 --- /dev/null +++ b/src/game-loop/simulation-textures.ts @@ -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, + }); + } +} diff --git a/src/game-loop/stroke-mirroring.ts b/src/game-loop/stroke-mirroring.ts new file mode 100644 index 0000000..9bfb8d4 --- /dev/null +++ b/src/game-loop/stroke-mirroring.ts @@ -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 => { + 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 = []; + 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 + ); +}; diff --git a/src/game-loop/stroke-output.ts b/src/game-loop/stroke-output.ts new file mode 100644 index 0000000..1e9650c --- /dev/null +++ b/src/game-loop/stroke-output.ts @@ -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(); + } +} diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts new file mode 100644 index 0000000..8898123 --- /dev/null +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -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; + 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, + 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(); + + 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 + ): Promise { + 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; diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts new file mode 100644 index 0000000..158d556 --- /dev/null +++ b/src/pipelines/agents/agent-dispatch.ts @@ -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; + +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); +}; diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl new file mode 100644 index 0000000..ae9336c --- /dev/null +++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl @@ -0,0 +1,97 @@ +struct Settings { + agentCount: u32, + padding0: u32, + padding1: u32, + padding2: u32, +}; + +struct Counters { + aliveAgentCount: atomic, +}; + +const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u; + +@group(1) @binding(0) var settings: Settings; +@group(1) @binding(2) var counters: Counters; +@group(1) @binding(3) var compactedAgents: array; + +var workgroupCompactedOffset: u32; +var scanData: array; +var clearAliveAgentCount: u32; + +@compute @workgroup_size(agentWorkgroupSize) +fn main( + @builtin(global_invocation_id) global_id: vec3, + @builtin(local_invocation_id) local_id: vec3 +) { + 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, + @builtin(local_invocation_id) local_id: vec3 +) { + 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; + } + } +} diff --git a/src/pipelines/agents/agent-generation/agent-counting.wgsl b/src/pipelines/agents/agent-generation/agent-counting.wgsl deleted file mode 100644 index 9d4c1b6..0000000 --- a/src/pipelines/agents/agent-generation/agent-counting.wgsl +++ /dev/null @@ -1,31 +0,0 @@ -struct Settings { - agentCount: u32 // might be smaller than the length of the agents array -}; - -@group(1) @binding(0) var settings: Settings; - -struct Counters { - evenGenerationAlive: atomic, - oddGenerationAlive: atomic, -}; - -@group(1) @binding(2) var counters: Counters; - - -@compute @workgroup_size(64) -fn main( - @builtin(global_invocation_id) global_id: vec3, - @builtin(num_workgroups) workgroup_count: vec3 -) { - 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); - } -} diff --git a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl deleted file mode 100644 index 0f2bb34..0000000 --- a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl +++ /dev/null @@ -1,34 +0,0 @@ -@compute @workgroup_size(64) -fn main( - @builtin(global_invocation_id) global_id: vec3, - @builtin(num_workgroups) workgroup_count: vec3 -) { - 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, - ); -} diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index f347d59..524a5c2 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -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 { - 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 { + 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(); } } diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl new file mode 100644 index 0000000..9af7973 --- /dev/null +++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl @@ -0,0 +1,21 @@ +struct ResizeSettings { + scale: vec2, + agentCount: u32, +}; + +@group(1) @binding(0) var resizeSettings: ResizeSettings; + +@compute @workgroup_size(agentWorkgroupSize) +fn main( + @builtin(global_invocation_id) global_id: vec3 +) { + 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; +} diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl index 3b37725..a2744af 100644 --- a/src/pipelines/agents/agent-generation/agent-schema.wgsl +++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl @@ -1,11 +1,16 @@ struct Agent { position: vec2, angle: f32, - generation: f32, + colorIndex: f32, + targetPosition: vec2, + targetAngle: f32, + introDelay: f32, } @group(1) @binding(1) var agents: array; -fn get_id(global_id: vec3, workgroup_count: vec3) -> 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 { + return global_id.x; +} diff --git a/src/pipelines/agents/agent-generation/agent.ts b/src/pipelines/agents/agent-generation/agent.ts deleted file mode 100644 index b950f32..0000000 --- a/src/pipelines/agents/agent-generation/agent.ts +++ /dev/null @@ -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; diff --git a/src/pipelines/agents/agent-generation/generation-counts.ts b/src/pipelines/agents/agent-generation/generation-counts.ts deleted file mode 100644 index 28a82a5..0000000 --- a/src/pipelines/agents/agent-generation/generation-counts.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GenerationCounts { - evenGenerationCount: number; - oddGenerationCount: number; -} diff --git a/src/pipelines/agents/agent-limits.ts b/src/pipelines/agents/agent-limits.ts new file mode 100644 index 0000000..405b37d --- /dev/null +++ b/src/pipelines/agents/agent-limits.ts @@ -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) + ) + ); +}; diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index efcf9ed..6138935 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -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 reactionMatrix. +// In uniform layout each of its 3 columns is stored as a vec3 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', - }, - }, - ], - }; - } } diff --git a/src/pipelines/agents/agent-settings.ts b/src/pipelines/agents/agent-settings.ts deleted file mode 100644 index 53b639c..0000000 --- a/src/pipelines/agents/agent-settings.ts +++ /dev/null @@ -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; -} diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 576b3cc..912e2a0 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -1,134 +1,273 @@ +const PI: f32 = 3.14159265359; +const TAU: f32 = 6.28318530718; +const INV_TAU: f32 = 0.15915494309; + +const CHANNEL_MASKS = array, 3>( + vec3(1.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + vec3(0.0, 0.0, 1.0), +); + struct Settings { - center: vec2, - radius: f32, - - brushTrailWeight: f32, - currentGenerationMoveRate: f32, + // Columns are indexed by source colorIndex; each column holds the per-target + // weights (colorXToColor1, colorXToColor2, colorXToColor3). + reactionMatrix: mat3x3, + 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 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; -@group(1) @binding(3) var trailMapOut: texture_storage_2d; +@group(1) @binding(3) var trailMapOut: texture_storage_2d; +struct AgentMovement { + rotation: f32, + step: vec2, +} -@compute @workgroup_size(64) +@compute @workgroup_size(agentWorkgroupSize) fn main( - @builtin(global_invocation_id) global_id: vec3, - @builtin(num_workgroups) workgroup_count: vec3 + @builtin(global_invocation_id) global_id: vec3 ) { - 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(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(settings.individualTrailWeight, 0, 0, 0); - if isFromOddGeneration == 1.0 { - trail = vec4(0, settings.individualTrailWeight, 0, 0); - } - - var trailBelow = textureLoad(trailMapIn, vec2(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(-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(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(1.0, 1.0); + + var movement = AgentMovement(0.0, vec2(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, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4 { - let sensorAngle = agentAngle + sensorOffsetAngle; - let sensorPosition = vec2(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 +) { + 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(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, + angle: f32, + reactionMask: vec3, + randomSeed: u32, + maxPosition: vec2 +) -> 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, + angle: f32, + targetPosition: vec2, + 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(0.0, 0.0); + if introTargetDistance > settings.introStepStopDistance { + step = introTargetOffset / introTargetDistance * moveRate; + } + return AgentMovement(rotation, step); +} + +fn agent_finalize( + id: u32, + position: vec2, + angle: f32, + channelMask: vec3, + randomSeed: u32, + maxPosition: vec2, + movement: AgentMovement +) { + let nextPosition = clamp(position + movement.step, vec2(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(nextPosition), + vec4(channelMask * settings.individualTrailWeight, 0.0) + ); + agents[id].angle = angle + rotation; + agents[id].position = nextPosition; +} + +fn sensor_position( + agentPosition: vec2, + direction: vec2, + sensorOffset: f32, + maxPosition: vec2 +) -> vec2 { + return vec2(clamp( + agentPosition + direction * sensorOffset, + vec2(0, 0), + maxPosition + )); +} + +fn rotate_direction(direction: vec2, angleSin: f32, angleCos: f32) -> vec2 { + return vec2( + direction.x * angleCos - direction.y * angleSin, + direction.x * angleSin + direction.y * angleCos + ); +} + +fn get_channel_mask(colorIndex: f32) -> vec3 { + return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))]; +} + +fn get_reaction_mask(colorIndex: f32) -> vec3 { + 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; } diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index 93a0cd3..b759c4f 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -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 = []; - private actualPoints: Array = []; + 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): Array { - 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 = [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 { - 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; +}; diff --git a/src/pipelines/brush/brush-settings.ts b/src/pipelines/brush/brush-settings.ts deleted file mode 100644 index cecb7a1..0000000 --- a/src/pipelines/brush/brush-settings.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface BrushSettings { - brushSize: number; - brushSizeVariation: number; -} diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index f705ead..8152986 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -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, + brushGrainNoiseScale: f32, + brushGrainNoiseOffsetX: f32, + brushGrainNoiseOffsetY: f32, + brushDiscardThreshold: f32, + brushGrainMinStrength: f32, + brushGrainMaxStrength: f32, }; @group(1) @binding(0) var settings: Settings; @@ -8,41 +20,100 @@ struct Settings { struct VertexOutput { @builtin(position) position: vec4, @location(0) screenPosition: vec2, - @location(1) start: vec2, - @location(2) end: vec2 + @location(1) @interpolate(flat) start: vec2, + @location(2) @interpolate(flat) direction: vec2, + @location(3) @interpolate(flat) inverseLengthSquared: f32, +} + +struct BrushTargets { + @location(0) source: vec4, } @vertex fn vertex( - @location(0) screenPosition: vec2, - @location(1) @interpolate(flat) start: vec2, - @location(2) @interpolate(flat) end: vec2 + @builtin(vertex_index) vertexIndex: u32, + @location(0) start: vec2, + @location(1) end: vec2 ) -> VertexOutput { + let direction = end - start; + let denominator = dot(direction, direction); + var inverseLengthSquared = 0.0; + var normalizedDirection = vec2(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, - @location(1) start: vec2, - @location(2) end: vec2 -) -> @location(0) vec4 { - 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, + @location(2) @interpolate(flat) direction: vec2, + @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, start: vec2, end: vec2) -> 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, + start: vec2, + direction: vec2, + 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 { + return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); } diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts index 1dda653..5157459 100644 --- a/src/pipelines/common-state/common-state.ts +++ b/src/pipelines/common-state/common-state.ts @@ -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, - deltaTime: f32, - time: f32, + _padding: vec2, }; - + @group(0) @binding(0) var state: State; @group(0) @binding(1) var noiseSampler: sampler; @group(0) @binding(2) var noise: texture_2d; @@ -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(); } } diff --git a/src/pipelines/common/line-segment-buffer.ts b/src/pipelines/common/line-segment-buffer.ts new file mode 100644 index 0000000..417e971 --- /dev/null +++ b/src/pipelines/common/line-segment-buffer.ts @@ -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 = []; + private active: Array = []; + + 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, count: number): Array => { + const result: Array = []; + for (let i = 0; i < count; i++) { + const index = Math.round((i * (segments.length - 1)) / (count - 1)); + result.push(segments[index]); + } + return result; +}; diff --git a/src/pipelines/common/line-segment.wgsl b/src/pipelines/common/line-segment.wgsl new file mode 100644 index 0000000..9a4fb20 --- /dev/null +++ b/src/pipelines/common/line-segment.wgsl @@ -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 { + let isRight = index == 2u || index >= 4u; + let isTop = index == 0u || index == 2u || index == 4u; + return vec2( + select(-1.0, 1.0, isRight), + select(-1.0, 1.0, isTop) + ); +} + +fn segment_vertex_position( + vertexIndex: u32, + start: vec2, + end: vec2, + direction: vec2, + radius: f32 +) -> vec2 { + let perpendicular = vec2(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, + start: vec2, + direction: vec2, + 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); +} diff --git a/src/pipelines/copy/copy-pipeline.ts b/src/pipelines/copy/copy-pipeline.ts deleted file mode 100644 index b64cedf..0000000 --- a/src/pipelines/copy/copy-pipeline.ts +++ /dev/null @@ -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 - 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', - }, - }, - ], - }; - } -} diff --git a/src/pipelines/copy/copy.wgsl b/src/pipelines/copy/copy.wgsl deleted file mode 100644 index 94b017f..0000000 --- a/src/pipelines/copy/copy.wgsl +++ /dev/null @@ -1,19 +0,0 @@ -struct VertexOutput { - @builtin(position) position: vec4, - @location(0) uv: vec2, -} - -@vertex -fn vertex(@location(0) uv: vec2) -> VertexOutput { - let ndc = uv * sourceScaler * vec2(2) - vec2(1); - return VertexOutput(vec4(ndc.x, -ndc.y, 0, 1), uv); -} - -@group(0) @binding(0) var sourceScaler: vec2; -@group(0) @binding(1) var Sampler: sampler; -@group(0) @binding(2) var original: texture_2d; - -@fragment -fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { - return textureSample(original, Sampler, uv); -} diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 8ae9dee..92ae448 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -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 settings: Settings; -@group(1) @binding(1) var Sampler: sampler; -@group(1) @binding(2) var trailMap: texture_2d; +@group(0) @binding(0) var settings: Settings; +@group(0) @binding(1) var trailMap: texture_2d; +@group(0) @binding(2) var trailMapOut: texture_storage_2d; +// 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; +var tile: array, TILE_TEXEL_COUNT>; +var tileTrailStrength: array; -@fragment -fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { - var current = textureSample(trailMap, Sampler, uv); +@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__) +fn main( + @builtin(global_invocation_id) global_id: vec3, + @builtin(local_invocation_id) local_id: vec3, + @builtin(workgroup_id) workgroup_id: vec3 +) { + let textureSize = vec2(textureDimensions(trailMap, 0)); + let textureBound = textureSize - vec2(1, 1); + let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x; + let workgroupOrigin = workgroup_id.xy * vec2(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(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X); + let sourcePixel = clamp( + vec2(workgroupOrigin + tilePosition) - vec2(1, 1), + vec2(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(global_id.x), i32(global_id.y)); + if pixel.x >= textureSize.x || pixel.y >= textureSize.y { + return; + } + + let centerTilePosition = local_id.xy + vec2(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, offset: vec2, currentColor: vec4) -> vec4 { - 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, + neighbourStrength: f32, + current: vec4, + trailWeight: f32 +) -> vec4 { + 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) -> f32 { + let p = vec2(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); +} diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 3bb3422..b1c763d 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -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() + ); + } } diff --git a/src/pipelines/diffusion/diffusion-settings.ts b/src/pipelines/diffusion/diffusion-settings.ts deleted file mode 100644 index 909101b..0000000 --- a/src/pipelines/diffusion/diffusion-settings.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface DiffusionSettings { - diffusionRateTrails: number; - decayRateTrails: number; - diffusionRateBrush: number; - decayRateBrush: number; -} diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts new file mode 100644 index 0000000..5cc9b32 --- /dev/null +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -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), + }; +}; diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl new file mode 100644 index 0000000..f98d551 --- /dev/null +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -0,0 +1,44 @@ +struct Settings { + agentCount: u32, + eraserMaskAlphaThreshold: f32, + maskWidth: u32, + maskHeight: u32, + boundsMin: vec2, + boundsMax: vec2, +}; + +@group(1) @binding(0) var settings: Settings; +@group(1) @binding(2) var eraserMask: texture_2d; + +@compute @workgroup_size(agentWorkgroupSize) +fn main( + @builtin(global_invocation_id) global_id: vec3 +) { + 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(settings.maskWidth), i32(settings.maskHeight)); + let maskPosition = clamp( + vec2(position), + vec2(0, 0), + maskSize - vec2(1, 1) + ); + let maskSample = textureLoad(eraserMask, maskPosition, 0); + + if maskSample.r < settings.eraserMaskAlphaThreshold { + agents[id].colorIndex = -1.0; + } +} diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts new file mode 100644 index 0000000..694777f --- /dev/null +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -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 = [ + 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(); + } +} diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl new file mode 100644 index 0000000..635258e --- /dev/null +++ b/src/pipelines/eraser/eraser-texture.wgsl @@ -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 settings: Settings; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) screenPosition: vec2, + @location(1) @interpolate(flat) start: vec2, + @location(2) @interpolate(flat) direction: vec2, + @location(3) @interpolate(flat) inverseLengthSquared: f32, +} + +struct EraserCombinedTargets { + @location(0) mask: vec4, + @location(1) source: vec4, + @location(2) trail: vec4, +} + +@vertex +fn vertex( + @builtin(vertex_index) vertexIndex: u32, + @location(0) start: vec2, + @location(1) end: vec2 +) -> VertexOutput { + let direction = end - start; + let denominator = dot(direction, direction); + var inverseLengthSquared = 0.0; + var normalizedDirection = vec2(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, + @location(1) @interpolate(flat) start: vec2, + @location(2) @interpolate(flat) direction: vec2, + @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 { + return vec4(settings.clearAlpha, 0.0, 0.0, 1.0); +} + +fn getEraserClearValue() -> vec4 { + return vec4( + settings.clearRed, + settings.clearGreen, + settings.clearBlue, + settings.clearAlpha + ); +} diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index 73ac288..12d7ada 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -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(); } } diff --git a/src/pipelines/render/render-settings.ts b/src/pipelines/render/render-settings.ts deleted file mode 100644 index c329309..0000000 --- a/src/pipelines/render/render-settings.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface RenderSettings { - clarity: number; -} diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 8607d7c..7e1ab26 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -1,39 +1,158 @@ struct Settings { - brushColor: vec3, - evenGenerationColor: vec3, - oddGenerationColor: vec3, + colorA: vec3, + _colorAPadding: f32, + colorB: vec3, + _colorBPadding: f32, + colorC: vec3, + _colorCPadding: f32, + backgroundColor: vec3, clarity: f32, + traceNormalizationFloor: f32, + brushColorBase: f32, + brushColorStrengthMultiplier: f32, }; -@group(1) @binding(0) var settings: Settings; -@group(1) @binding(1) var Sampler: sampler; -@group(1) @binding(2) var trailMap: texture_2d; +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 = vec3(0.2126, 0.7152, 0.0722); + +@group(0) @binding(0) var settings: Settings; +@group(0) @binding(2) var trailMap: texture_2d; +@group(0) @binding(3) var sourceMap: texture_2d; @fragment -fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { - let traces = textureSample(trailMap, Sampler, uv); - let random = textureSample(noise, noiseSampler, uv); +fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(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) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let traces = textureLoad(trailMap, pixel, 0); + return renderColor(traces, vec4(0.0), getFlatBackground()); +} - let evenGenerationStrength = clarity(traces.r); - let oddGenerationStrength = clarity(traces.g); - let brushStrength = traces.a; +fn renderColor(traces: vec4, sources: vec4, background: vec3) -> vec4 { + 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 { + return max(max(v.r, v.g), v.b); +} + +fn minComponent(v: vec3) -> f32 { + return min(min(v.r, v.g), v.b); +} + +fn componentSum(v: vec3) -> f32 { + return v.r + v.g + v.b; +} + +fn clarity(strength: vec3) -> vec3 { + return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity)); +} + +fn colorFromChannelStrengths(strengths: vec3) -> vec3 { + if maxComponent(strengths) <= 0.0 { + return vec3(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) -> vec3 { + let commonStrength = minComponent(strengths); + var weightBase = max( + strengths - vec3(commonStrength * COMMON_CHANNEL_REDUCTION), + vec3(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, strengths: vec3) -> vec3 { + 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(luminance) + + (color - vec3(luminance)) * + mix(1.0, OVERLAP_SATURATION_BOOST, overlapAmount), + vec3(0.0), + vec3(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) -> vec3 { + 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) -> vec3 { + let brightestChannel = maxComponent(color); + return color / max(settings.traceNormalizationFloor, brightestChannel); +} + +fn getFlatBackground() -> vec3 { + return clamp(settings.backgroundColor, vec3(0), vec3(1)); } diff --git a/src/pipelines/texture-formats.ts b/src/pipelines/texture-formats.ts new file mode 100644 index 0000000..568e03d --- /dev/null +++ b/src/pipelines/texture-formats.ts @@ -0,0 +1,2 @@ +export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat; +export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat; diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts index fb1ef5a..78489c2 100644 --- a/src/utils/delta-time-calculator.ts +++ b/src/utils/delta-time-calculator.ts @@ -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); } } diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index ca91a8a..cffd60c 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -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 + | { [key: string]: ErrorMetadataValue }; +type ErrorMetadata = { [key: string]: ErrorMetadataValue }; + +interface RuntimeErrorOptions { + cause?: unknown; + details?: Record; +} + +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; +} + +interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions { + fallbackMessage?: string; + severity?: Severity; +} + +const MAX_METADATA_DEPTH = 4; +const UNREADABLE_VALUE = '[Unreadable]'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const safelyRead = (value: Record, key: string): unknown => { + try { + return value[key]; + } catch { + return undefined; + } +}; + +const isIterable = (value: unknown): value is Iterable => + 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; + 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; + 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 = []; 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 = {}; - 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 + ); + }; } } diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts new file mode 100644 index 0000000..40ebce0 --- /dev/null +++ b/src/utils/graphics/bind-group-cache.ts @@ -0,0 +1,38 @@ +type BindGroupCacheKeys = readonly [object, ...object[]]; + +interface BindGroupCacheNode { + bindGroup?: GPUBindGroup; + children: WeakMap; +} + +const createNode = (): BindGroupCacheNode => ({ + children: new WeakMap(), +}); + +const getOrCreateNode = ( + children: WeakMap, + key: object +): BindGroupCacheNode => { + let node = children.get(key); + if (!node) { + node = createNode(); + children.set(key, node); + } + return node; +}; + +export const createBindGroupCache = ( + factory: (...keys: Keys) => GPUBindGroup +): ((...keys: Keys) => GPUBindGroup) => { + const root = new WeakMap(); + + 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; + }; +}; diff --git a/src/utils/graphics/cached-buffer-write.test.ts b/src/utils/graphics/cached-buffer-write.test.ts new file mode 100644 index 0000000..a816f61 --- /dev/null +++ b/src/utils/graphics/cached-buffer-write.test.ts @@ -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); + }); +}); diff --git a/src/utils/graphics/cached-buffer-write.ts b/src/utils/graphics/cached-buffer-write.ts new file mode 100644 index 0000000..774da8f --- /dev/null +++ b/src/utils/graphics/cached-buffer-write.ts @@ -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; +}; diff --git a/src/utils/graphics/full-screen-quad.ts b/src/utils/graphics/full-screen-quad.ts index 3e9dbec..6a38f2a 100644 --- a/src/utils/graphics/full-screen-quad.ts +++ b/src/utils/graphics/full-screen-quad.ts @@ -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 - 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, + @location(0) uv: vec2, + } - return { - buffer, - vertex: { - module: smartCompile( - device, - /* wgsl */ ` - struct VertexOutput { - @builtin(position) position: vec4, - @location(0) uv: vec2, - } - - @vertex - fn vertex( - @location(0) position: vec2, - @location(1) uv: vec2 - ) -> 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, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + let position = positions[vertexIndex]; + let uv = vec2(position.x * 0.5 + 0.5, 0.5 - position.y * 0.5); + return VertexOutput(vec4(position, 0.0, 1.0), uv); + }` + ), + entryPoint: 'vertex', +}); diff --git a/src/utils/graphics/get-workgroup-counts.ts b/src/utils/graphics/get-workgroup-counts.ts deleted file mode 100644 index fe016e7..0000000 --- a/src/utils/graphics/get-workgroup-counts.ts +++ /dev/null @@ -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]; -}; diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts index 5e21820..da8b994 100644 --- a/src/utils/graphics/initialize-context.ts +++ b/src/utils/graphics/initialize-context.ts @@ -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; }; diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts index 18ba035..9108060 100644 --- a/src/utils/graphics/initialize-gpu.ts +++ b/src/utils/graphics/initialize-gpu.ts @@ -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; + +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 => { + 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 => { + 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 => { + 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 = []; + 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; }; diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts index 720af9c..c7cdc96 100644 --- a/src/utils/graphics/noise.ts +++ b/src/utils/graphics/noise.ts @@ -1,7 +1,11 @@ +import { appConfig } from '../../config'; import { setUpFullScreenQuad } from './full-screen-quad'; import { smartCompile } from './smart-compile'; -const textureCache = new Map(); +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, 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) -> @location(0) vec4 { 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(), + }; }; diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts index 54b21ed..5624edc 100644 --- a/src/utils/graphics/resizable-texture.ts +++ b/src/utils/graphics/resizable-texture.ts @@ -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; diff --git a/src/utils/graphics/smart-compile.ts b/src/utils/graphics/smart-compile.ts index 044ec24..191380e 100644 --- a/src/utils/graphics/smart-compile.ts +++ b/src/utils/graphics/smart-compile.ts @@ -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; };