From 1fe5015056328f5be0553945e74e29be8e0025b9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 16 May 2026 15:05:35 +0100 Subject: [PATCH] , --- definitions.d.ts | 2 + package-lock.json | 7 + package.json | 1 + playwright.config.ts | 2 +- src/analytics.ts | 69 +++++ src/audio/garden-audio-config.ts | 11 - src/audio/garden-audio-graph.ts | 65 ++-- src/audio/garden-audio-input.ts | 13 +- src/audio/garden-audio-music.ts | 12 +- src/audio/garden-audio-types.ts | 4 +- src/audio/garden-audio.test.ts | 110 ++++++- src/audio/garden-audio.ts | 74 +++-- src/audio/piano-sampler.test.ts | 126 ++++++++ src/audio/piano-sampler.ts | 76 +++-- src/audio/piano-samples.ts | 101 ++++++- src/config.ts | 17 -- src/config/color-interactions.ts | 41 --- src/config/runtime-settings.ts | 8 - src/config/types.ts | 7 +- src/config/vibe-presets.ts | 14 +- src/game-loop/frame-performance.test.ts | 41 ++- src/game-loop/frame-performance.ts | 20 +- src/game-loop/game-loop-ping-pong.test.ts | 26 +- src/game-loop/game-loop-resources.ts | 14 +- src/game-loop/game-loop-settings.ts | 1 - src/game-loop/game-loop-types.ts | 6 + src/game-loop/game-loop.ts | 19 +- src/game-loop/intro-title-agents.ts | 4 +- src/game-loop/pointer-input.ts | 12 +- src/game-loop/simulation-frame.ts | 107 +++---- src/game-loop/simulation-textures.ts | 31 ++ .../toolbar-contrast-monitor.test.ts | 68 +++++ src/game-loop/toolbar-contrast-monitor.ts | 284 ++++++++++++++++++ src/index.ts | 151 ++++++++-- .../agent-generation/agent-compaction.wgsl | 62 +++- .../agent-generation-pipeline.test.ts | 235 +++++++++++++++ .../agent-generation-pipeline.ts | 45 ++- .../agent-generation/agent-schema.test.ts | 23 +- src/pipelines/agents/agent.wgsl | 48 ++- src/pipelines/brush/brush-pipeline.ts | 163 ++++++---- src/pipelines/brush/brush.wgsl | 59 +++- src/pipelines/diffusion/diffuse.wgsl | 87 +++++- .../diffusion/diffusion-pipeline.test.ts | 8 + src/pipelines/diffusion/diffusion-pipeline.ts | 17 -- src/pipelines/eraser/eraser-agent-pipeline.ts | 251 ++++++---------- src/pipelines/eraser/eraser-agent.wgsl | 52 +--- .../eraser/eraser-texture-pipeline.ts | 130 ++++---- src/pipelines/eraser/eraser-texture.wgsl | 30 +- src/pipelines/render/render-pipeline.ts | 7 +- src/pipelines/wgsl-uniform-layout.test.ts | 8 +- src/utils/error-handler.ts | 1 - src/utils/graphics/initialize-context.ts | 1 + src/utils/graphics/resizable-texture.ts | 8 +- src/vibes.test.ts | 6 +- src/vibes.ts | 18 +- 55 files changed, 2077 insertions(+), 726 deletions(-) create mode 100644 src/analytics.ts create mode 100644 src/audio/piano-sampler.test.ts create mode 100644 src/game-loop/toolbar-contrast-monitor.test.ts create mode 100644 src/game-loop/toolbar-contrast-monitor.ts create mode 100644 src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts diff --git a/definitions.d.ts b/definitions.d.ts index c90ad44..9b4e880 100644 --- a/definitions.d.ts +++ b/definitions.d.ts @@ -6,3 +6,5 @@ declare module '*.wgsl?raw' { interface HTMLCanvasElement { getContext(contextId: 'webgpu'): GPUCanvasContext | null; } + +declare var webkitOfflineAudioContext: typeof OfflineAudioContext | undefined; diff --git a/package-lock.json b/package-lock.json index 054ae44..f0eb6fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "license": "Unlicense", "dependencies": { + "@plausible-analytics/tracker": "^0.4.5", "tweakpane": "^4.0.5" }, "devDependencies": { @@ -1936,6 +1937,12 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", + "integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==", + "license": "MIT" + }, "node_modules/@playwright/test": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", diff --git a/package.json b/package.json index b566e03..16d5168 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "vitest": "^4.1.5" }, "dependencies": { + "@plausible-analytics/tracker": "^0.4.5", "tweakpane": "^4.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts index e252a37..f222fa0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ webServer: { command: `npm run preview -- --host 127.0.0.1 --port ${port}`, ignoreHTTPSErrors: true, - reuseExistingServer: !isCi, + reuseExistingServer: false, timeout: 120_000, url: baseURL, }, diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..d7cccb5 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,69 @@ +import { + init as plausibleInit, + track as plausibleTrack, + type PlausibleEventOptions, +} from '@plausible-analytics/tracker'; + +let isInitialized = false; + +const track = (eventName: string, options: PlausibleEventOptions = {}) => { + try { + plausibleTrack(eventName, options); + } catch (error) { + console.warn(`Could not track analytics event "${eventName}".`, error); + } +}; + +export const initAnalytics = () => { + if (isInitialized) { + return; + } + + try { + plausibleInit({ + domain: 'schmelczer.dev/floating', + endpoint: 'https://stats.schmelczer.dev/status', + autoCapturePageviews: true, + captureOnLocalhost: true, + logging: true, + fileDownloads: true, + outboundLinks: true, + hashBasedRouting: true, + }); + isInitialized = true; + } catch (error) { + console.warn('Could not initialize analytics.', error); + } +}; + +export const trackVibeChange = ({ + vibeId, + vibeName, + source, +}: { + vibeId: string; + vibeName: string; + source: string; +}) => { + track('Vibe Change', { + props: { + vibeId, + vibeName, + source, + }, + }); +}; + +export const trackExport = ({ vibeId }: { vibeId: string }) => { + track('Export', { + props: { + format: 'png', + resolution: '4k', + vibeId, + }, + }); +}; + +export const trackSettingsOpen = () => { + track('Settings Open'); +}; diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index c7a2c99..4751bf4 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -26,14 +26,6 @@ export interface GardenAudioConfig { fadeInSeconds: number; updateRampSeconds: number; highPassFrequencyHz: number; - fallbackVibeId: string; - compressor: { - thresholdDb: number; - kneeDb: number; - ratio: number; - attackSeconds: number; - releaseSeconds: number; - }; delay: { timeSeconds: number; feedback: number; @@ -47,9 +39,6 @@ export interface GardenAudioConfig { releaseSeconds: number; lowpassHz: number; }; - input: { - pressureFallback: number; - }; rhythm: { bpm: number; stepsPerBeat: number; diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index d4bb9ae..61b9cdf 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -2,6 +2,9 @@ import type { GardenAudioEngineConfig } from '../config'; import { clamp } from '../utils/clamp'; import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; +const UNLOCK_TICK_SECONDS = 0.035; +const UNLOCK_TICK_FREQUENCY_HZ = 440; + export class GardenAudioGraph { public context: AudioContext | null = null; public eventBus: GainNode | null = null; @@ -12,7 +15,6 @@ export class GardenAudioGraph { private delayNode: DelayNode | null = null; private delayFeedback: GainNode | null = null; private delayOutput: GainNode | null = null; - private hasUnlocked = false; public constructor( private readonly config: GardenAudioConfig, @@ -28,23 +30,26 @@ export class GardenAudioGraph { return null; } - const context = new AudioContext({ latencyHint: 'interactive' }); + const AudioContextConstructor = globalThis.AudioContext; + if (!AudioContextConstructor) { + return null; + } + + let context: AudioContext; + try { + context = new AudioContextConstructor({ latencyHint: 'interactive' }); + } catch { + context = new AudioContextConstructor(); + } const masterGain = context.createGain(); const highPass = context.createBiquadFilter(); - const compressor = context.createDynamicsCompressor(); masterGain.gain.value = 0; highPass.type = 'highpass'; highPass.frequency.value = this.config.highPassFrequencyHz; - compressor.threshold.value = this.config.compressor.thresholdDb; - compressor.knee.value = this.config.compressor.kneeDb; - compressor.ratio.value = this.config.compressor.ratio; - compressor.attack.value = this.config.compressor.attackSeconds; - compressor.release.value = this.config.compressor.releaseSeconds; masterGain.connect(highPass); - highPass.connect(compressor); - compressor.connect(context.destination); + highPass.connect(context.destination); this.context = context; this.masterGain = masterGain; @@ -55,24 +60,37 @@ export class GardenAudioGraph { return context; } - // iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once - // a buffer source has been started inside a user-gesture handler. Calling - // resume() alone leaves the context "running" but silent. + // iOS WebKit can report "running" while the hardware output is still silent. + // A very short, nearly inaudible oscillator in the gesture stack is more + // reliable than a fully silent buffer on recent Safari versions. public unlock(): void { - if (!this.context || this.hasUnlocked) { + if (!this.context) { return; } - const buffer = this.context.createBuffer( - 1, - this.engineConfig.graph.unlockBufferLength, - this.engineConfig.graph.unlockSampleRate + const now = this.context.currentTime; + const source = this.context.createOscillator(); + const gain = this.context.createGain(); + + source.type = 'sine'; + source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now); + gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now); + gain.gain.exponentialRampToValueAtTime( + this.engineConfig.piano.minGain, + now + UNLOCK_TICK_SECONDS + ); + source.connect(gain); + gain.connect(this.context.destination); + source.start(now); + source.stop(now + UNLOCK_TICK_SECONDS); + source.addEventListener( + 'ended', + () => { + source.disconnect(); + gain.disconnect(); + }, + { once: true } ); - const source = this.context.createBufferSource(); - source.buffer = buffer; - source.connect(this.context.destination); - source.start(0); - this.hasUnlocked = true; } public setMasterGain(targetGain: number, timeConstantSeconds: number): void { @@ -201,6 +219,5 @@ export class GardenAudioGraph { this.delayNode = null; this.delayFeedback = null; this.delayOutput = null; - this.hasUnlocked = false; } } diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts index b3fa64b..6e9ab09 100644 --- a/src/audio/garden-audio-input.ts +++ b/src/audio/garden-audio-input.ts @@ -13,14 +13,13 @@ export interface GardenAudioStrokeMetrics { export const getStrokeMetrics = ( stroke: GardenAudioStroke, speedForFullEnergyPixelsPerSecond: number, - fallbackPressure: number, inputConfig: GardenAudioEngineConfig['input'] ): GardenAudioStrokeMetrics => { const dx = stroke.to[0] - stroke.from[0]; const dy = stroke.to[1] - stroke.from[1]; const distancePixels = Math.hypot(dx, dy); const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig); - const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig); + const pressure = getPressureAmount(stroke); const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond); const strokeEnergy = clamp01( inputConfig.strokeEnergyBase + @@ -58,11 +57,7 @@ const getStrokeVelocity = ( return distancePixels / inputConfig.fallbackFrameSeconds; }; -const getPressureAmount = ( - stroke: GardenAudioStroke, - fallbackPressure: number, - inputConfig: GardenAudioEngineConfig['input'] -): number => { +const getPressureAmount = (stroke: GardenAudioStroke): number => { if ( stroke.pressure !== undefined && Number.isFinite(stroke.pressure) && @@ -71,7 +66,5 @@ const getPressureAmount = ( return clamp01(stroke.pressure); } - return stroke.pointerType === 'pen' - ? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure)) - : clamp01(fallbackPressure); + return 0; }; diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts index 11ee580..602ff05 100644 --- a/src/audio/garden-audio-music.ts +++ b/src/audio/garden-audio-music.ts @@ -12,10 +12,14 @@ export const normalizeColorIndex = (index: number): GardenAudioColorIndex => export const getVibeProfile = ( config: GardenAudioConfig, vibe: VibePreset -): GardenAudioVibeProfile => - config.vibes[vibe.id] ?? - config.vibes[config.fallbackVibeId] ?? - Object.values(config.vibes)[0]; +): GardenAudioVibeProfile => { + const profile = config.vibes[vibe.id]; + if (!profile) { + throw new Error(`Missing audio profile for vibe "${vibe.id}"`); + } + + return profile; +}; export const getChordIntervals = ( chord: GardenAudioChord, diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 71eb314..8f03c41 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -21,7 +21,6 @@ export interface GardenAudioStroke { elapsedSeconds?: number; eraserSizePixels?: number; mirrorSegmentCount?: number; - pointerType?: string; } export interface GardenAudioTouchDown { @@ -31,7 +30,6 @@ export interface GardenAudioTouchDown { canvasSize?: ArrayLike; mirrorSegmentCount?: number; pressure?: number; - pointerType?: string; } export interface GardenAudioStartOptions { @@ -45,7 +43,7 @@ export interface LoadedPianoSample { export interface ActivePianoVoice { gain: GainNode; - source: AudioBufferSourceNode; + source: AudioScheduledSourceNode; startAt: number; stopAt: number; } diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts index 6d4f7da..7b48e60 100644 --- a/src/audio/garden-audio.test.ts +++ b/src/audio/garden-audio.test.ts @@ -1,17 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { appConfig } from '../config'; +import { ErrorHandler, Severity } from '../utils/error-handler'; import { VIBE_PRESETS } from '../vibes'; import { GardenAudio } from './garden-audio'; import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; +import { loadPianoSamples, resetPianoSampleCacheForTest } from './piano-samples'; + +type FakeScheduledSourceNode = { + start: ReturnType; + stop: ReturnType; +}; const calls = { constructed: 0, resumed: 0, sourcesStarted: 0, + sources: [] as Array, }; let contextState: AudioContextState = 'suspended'; +let resumeError: Error | null = null; class FakeAudioParam { public value = 0; @@ -24,6 +33,7 @@ class FakeAudioParam { class FakeAudioNode { public readonly gain = new FakeAudioParam(); public readonly frequency = new FakeAudioParam(); + public readonly playbackRate = new FakeAudioParam(); public readonly Q = new FakeAudioParam(); public readonly threshold = new FakeAudioParam(); public readonly knee = new FakeAudioParam(); @@ -54,6 +64,7 @@ class FakeAudioContext { public readonly currentTime = 1; public readonly sampleRate = 16; public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode; + public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer); public constructor() { calls.constructed += 1; @@ -75,10 +86,6 @@ class FakeAudioContext { return new FakeAudioNode() as unknown as BiquadFilterNode; } - public createDynamicsCompressor(): DynamicsCompressorNode { - return new FakeAudioNode() as unknown as DynamicsCompressorNode; - } - public createDelay(): DelayNode { return new FakeAudioNode() as unknown as DelayNode; } @@ -100,13 +107,30 @@ class FakeAudioContext { node.buffer = null; node.start = vi.fn(() => { calls.sourcesStarted += 1; - }); - node.stop = vi.fn(); + }) as unknown as typeof node.start; + node.stop = vi.fn() as unknown as typeof node.stop; + calls.sources.push(node as unknown as FakeScheduledSourceNode); + return node; + } + + public createOscillator(): OscillatorNode { + const node = new FakeAudioNode() as unknown as OscillatorNode & { + start: () => void; + stop: () => void; + }; + node.start = vi.fn(() => { + calls.sourcesStarted += 1; + }) as unknown as typeof node.start; + node.stop = vi.fn() as unknown as typeof node.stop; + calls.sources.push(node as unknown as FakeScheduledSourceNode); return node; } public async resume(): Promise { calls.resumed += 1; + if (resumeError) { + throw resumeError; + } contextState = 'running'; } } @@ -120,13 +144,18 @@ describe('GardenAudio startup policy', () => { calls.constructed = 0; calls.resumed = 0; calls.sourcesStarted = 0; + calls.sources = []; contextState = 'suspended'; + resumeError = null; + resetPianoSampleCacheForTest(); vi.stubGlobal('AudioContext', FakeAudioContext); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests'))); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); + resetPianoSampleCacheForTest(); }); it('does not create an AudioContext from passive audio paths', () => { @@ -171,7 +200,27 @@ describe('GardenAudio startup policy', () => { expect(calls.resumed).toBe(1); }); - it('skips cold piano fallback while preserving eraser noise', () => { + it('reports AudioContext resume failures as warnings', async () => { + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); + const vibe = VIBE_PRESETS[0]; + resumeError = new Error('resume rejected'); + const addException = vi.spyOn(ErrorHandler, 'addException'); + + audio.start(vibe, { userGesture: true }); + await Promise.resolve(); + await Promise.resolve(); + + expect(addException).toHaveBeenCalledWith(resumeError, { + fallbackMessage: 'Could not resume audio playback.', + severity: Severity.WARNING, + }); + }); + + it('stays silent without piano samples while preserving eraser noise', () => { const audio = new GardenAudio( makeConfig(), appConfig.audioEngine, @@ -217,4 +266,51 @@ describe('GardenAudio startup policy', () => { expect(calls.sourcesStarted).toBe(2); }); + + it('quickly stops active piano voices when the vibe changes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + arrayBuffer: async () => new ArrayBuffer(8), + ok: true, + })) + ); + await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext); + + const audio = new GardenAudio( + makeConfig(), + appConfig.audioEngine, + appConfig.simulation.maxMirrorSegmentCount + ); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe, { userGesture: true }); + audio.beginGesture(); + audio.touchDown({ + vibe, + colorIndex: 1, + position: [30, 40], + canvasSize: [100, 100], + pressure: 0.7, + }); + + const activePianoSources = calls.sources.filter( + (source) => source.stop.mock.calls.length === 1 + ); + expect(activePianoSources.length).toBeGreaterThan(0); + + const stopCounts = activePianoSources.map((source) => source.stop.mock.calls.length); + audio.changeVibe(VIBE_PRESETS[1], { userGesture: true }); + + const stoppedVoices = activePianoSources.filter( + (source, index) => source.stop.mock.calls.length === stopCounts[index] + 1 + ); + expect(stoppedVoices.length).toBeGreaterThan(0); + stoppedVoices.forEach((source) => { + expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo( + 1 + appConfig.audioEngine.piano.voiceStealStopSeconds, + 3 + ); + }); + }); }); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index b736d9c..ea54e63 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,5 +1,6 @@ import type { GardenAudioEngineConfig } from '../config'; import { clamp, clamp01 } from '../utils/clamp'; +import { ErrorHandler, Severity } from '../utils/error-handler'; import { VibePreset } from '../vibes'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; @@ -71,40 +72,66 @@ export class GardenAudio { return; } + const startupRampSeconds = + options.userGesture === true + ? this.engineConfig.muteRampSeconds + : this.config.fadeInSeconds; + const needsResume = context.state !== 'running' && context.state !== 'closed'; + let resumePromise: Promise | null = null; + + if (needsResume) { + if (options.userGesture !== true) { + return; + } + resumePromise = context.resume(); + } + if (options.userGesture === true) { this.graph.unlock(); } - if (context.state === 'suspended') { - if (options.userGesture !== true) { - return; - } - void context.resume().catch(() => undefined); + if (resumePromise) { + void resumePromise + .then(() => { + if (this.graph.context === context && !this.isDestroyed && !this.isMuted) { + this.graph.unlock(); + this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds); + } + }) + .catch((error) => { + ErrorHandler.addException(error, { + fallbackMessage: 'Could not resume audio playback.', + severity: Severity.WARNING, + }); + }); } this.hasStarted = true; this.applyVibe(vibe); this.pianoEngine.prime(context.currentTime); - this.graph.setMasterGain( - this.config.masterVolume, - options.userGesture === true - ? this.engineConfig.muteRampSeconds - : this.config.fadeInSeconds - ); + this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds); if (!this.hasQueuedPianoLoad) { this.hasQueuedPianoLoad = true; - void this.piano.load(context).then(() => { - if (this.graph.context === context && !this.isDestroyed) { - this.pianoEngine.cue(context.currentTime); - } - }); + void this.piano + .load(context) + .then(() => { + if (this.graph.context === context && !this.isDestroyed) { + this.pianoEngine.cue(context.currentTime); + } + }) + .catch(() => undefined); } } public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { const previousVibeId = this.currentVibeId; this.start(vibe, options); + const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id; + + if (didChangeVibe) { + this.piano.stopAll(); + } const context = this.graph.context; if ( @@ -112,8 +139,7 @@ export class GardenAudio { (context.state === 'running' || options.userGesture === true) && !this.isMuted && !this.isDestroyed && - previousVibeId !== null && - previousVibeId !== vibe.id + didChangeVibe ) { this.playVibeChangeStinger(vibe); } @@ -158,7 +184,7 @@ export class GardenAudio { this.selectedColorIndex = normalizeColorIndex(touch.colorIndex); const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1); - const pressure = this.getTouchPressure(touch.pressure, touch.pointerType); + const pressure = this.getPressure(touch.pressure); const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22); const frame = this.gestureState.recordTouchDown({ touch, @@ -225,7 +251,6 @@ export class GardenAudio { const metrics = getStrokeMetrics( stroke, this.config.rhythm.speedForFullEnergyPixelsPerSecond, - this.config.input.pressureFallback, this.engineConfig.input ); const now = context.currentTime; @@ -375,16 +400,11 @@ export class GardenAudio { return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1)); } - private getTouchPressure(pressure: number | undefined, pointerType?: string): number { + private getPressure(pressure: number | undefined): number { if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) { return clamp01(pressure); } - return pointerType === 'pen' - ? Math.max( - this.engineConfig.input.penMinPressure, - this.config.input.pressureFallback - ) - : this.config.input.pressureFallback; + return 0; } } diff --git a/src/audio/piano-sampler.test.ts b/src/audio/piano-sampler.test.ts new file mode 100644 index 0000000..24c71f9 --- /dev/null +++ b/src/audio/piano-sampler.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { appConfig } from '../config'; +import { gardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioGraph } from './garden-audio-graph'; +import { PianoSampler } from './piano-sampler'; +import { pianoSampleDefinitions, resetPianoSampleCacheForTest } from './piano-samples'; + +const calls = { + bufferSourcesStarted: 0, +}; + +class FakeAudioParam { + public value = 0; + public setTargetAtTime = vi.fn(); + public setValueAtTime = vi.fn(); + public exponentialRampToValueAtTime = vi.fn(); + public cancelScheduledValues = vi.fn(); +} + +class FakeAudioNode { + public readonly gain = new FakeAudioParam(); + public readonly frequency = new FakeAudioParam(); + public readonly playbackRate = new FakeAudioParam(); + public readonly Q = new FakeAudioParam(); + public readonly pan = new FakeAudioParam(); + public buffer: AudioBuffer | null = null; + public type = ''; + public addEventListener = vi.fn(); + public connect = vi.fn(); + public disconnect = vi.fn(); + public start = vi.fn(); + public stop = vi.fn(); +} + +class FakeAudioContext { + public readonly currentTime = 1; + public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer); + + public createGain(): GainNode { + return new FakeAudioNode() as unknown as GainNode; + } + + public createBiquadFilter(): BiquadFilterNode { + return new FakeAudioNode() as unknown as BiquadFilterNode; + } + + public createStereoPanner(): StereoPannerNode { + return new FakeAudioNode() as unknown as StereoPannerNode; + } + + public createBufferSource(): AudioBufferSourceNode { + const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & { + start: () => void; + stop: () => void; + }; + node.start = vi.fn(() => { + calls.bufferSourcesStarted += 1; + }); + node.stop = vi.fn(); + return node; + } +} + +const makeSampler = (context: AudioContext): PianoSampler => { + const eventBus = new FakeAudioNode() as unknown as GainNode; + const graph = { + context, + delayInput: null, + eventBus, + } as unknown as GardenAudioGraph; + + return new PianoSampler(gardenAudioConfig, appConfig.audioEngine, graph); +}; + +describe('PianoSampler', () => { + beforeEach(() => { + calls.bufferSourcesStarted = 0; + resetPianoSampleCacheForTest(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + resetPianoSampleCacheForTest(); + }); + + it('loads every piano sample before playback', async () => { + const context = new FakeAudioContext() as unknown as AudioContext; + const sampler = makeSampler(context); + const fetch = vi.fn(async () => { + return { + arrayBuffer: async () => new ArrayBuffer(8), + ok: true, + } as Response; + }); + vi.stubGlobal('fetch', fetch); + + await sampler.load(context); + sampler.play({ + durationSeconds: 0.2, + midi: 60, + pan: 0, + startTime: context.currentTime, + velocity: 0.5, + }); + + expect(fetch).toHaveBeenCalledTimes(pianoSampleDefinitions.length); + expect(context.decodeAudioData).toHaveBeenCalledTimes(pianoSampleDefinitions.length); + expect(calls.bufferSourcesStarted).toBe(1); + }); + + it('stays silent when no decoded sample is available', () => { + const context = new FakeAudioContext() as unknown as AudioContext; + const sampler = makeSampler(context); + + sampler.play({ + durationSeconds: 0.2, + midi: 60, + pan: 0, + startTime: context.currentTime, + velocity: 0.5, + }); + + expect(calls.bufferSourcesStarted).toBe(0); + }); +}); diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index fab0e13..3320b73 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -3,7 +3,7 @@ import { clamp, clamp01 } from '../utils/clamp'; import { GardenAudioConfig } from './garden-audio-config'; import { GardenAudioGraph } from './garden-audio-graph'; import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types'; -import { pianoSampleDefinitions } from './piano-samples'; +import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples'; export class PianoSampler { private sampleLoadPromise: Promise | null = null; @@ -16,28 +16,20 @@ export class PianoSampler { private readonly graph: GardenAudioGraph ) {} - public async load(context: AudioContext): Promise { + public load(context: BaseAudioContext): Promise { + const loadedSamples = getLoadedPianoSamples(); + if (loadedSamples) { + this.setSamples(loadedSamples); + return Promise.resolve(); + } + if (this.sampleLoadPromise) { return this.sampleLoadPromise; } - this.sampleLoadPromise = Promise.all( - pianoSampleDefinitions.map(async (sample) => { - const response = await fetch(sample.url); - if (!response.ok) { - throw new Error(`Unable to load piano sample ${sample.url}`); - } - const audioData = await response.arrayBuffer(); - const buffer = await context.decodeAudioData(audioData); - return { midi: sample.midi, buffer }; - }) - ) - .then((samples) => { - this.samples = samples.sort((a, b) => a.midi - b.midi); - }) - .catch(() => { - this.samples = []; - }); + this.sampleLoadPromise = loadPianoSamples(context).then((samples) => { + this.setSamples(samples); + }); return this.sampleLoadPromise; } @@ -89,13 +81,9 @@ export class PianoSampler { this.trimActiveVoices(scheduledStart); while (this.activeVoices.length >= this.config.piano.maxVoices) { const oldest = this.activeVoices.shift(); - oldest?.gain.gain.cancelScheduledValues(scheduledStart); - oldest?.gain.gain.setTargetAtTime( - this.engineConfig.piano.minGain, - scheduledStart, - this.engineConfig.piano.voiceStealFadeSeconds - ); - oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds); + if (oldest) { + this.stopVoice(oldest, scheduledStart); + } } source.buffer = sample.buffer; @@ -162,6 +150,21 @@ export class PianoSampler { ); } + public stopAll(): void { + const context = this.graph.context; + if (!context) { + this.activeVoices = []; + return; + } + + const now = context.currentTime; + + this.activeVoices.forEach((voice) => { + this.stopVoice(voice, now); + }); + this.activeVoices = []; + } + public reset(): void { this.sampleLoadPromise = null; this.samples = []; @@ -181,4 +184,25 @@ export class PianoSampler { private trimActiveVoices(now: number): void { this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); } + + private stopVoice(voice: ActivePianoVoice, now: number): void { + const stopAt = now + this.engineConfig.piano.voiceStealStopSeconds; + + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setTargetAtTime( + this.engineConfig.piano.minGain, + now, + this.engineConfig.piano.voiceStealFadeSeconds + ); + voice.stopAt = stopAt; + try { + voice.source.stop(stopAt); + } catch { + // The voice may already have ended; either way it is no longer part of the mix. + } + } + + private setSamples(samples: Array): void { + this.samples = samples.slice().sort((a, b) => a.midi - b.midi); + } } diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index 097feab..fb88bf5 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -1,8 +1,16 @@ -interface PianoSampleDefinition { +import type { LoadedPianoSample } from './garden-audio-types'; + +export interface PianoSampleDefinition { midi: number; url: string; } +export interface PianoSampleLoadProgress { + loadedCount: number; + totalCount: number; + sample?: PianoSampleDefinition; +} + const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`; const sampleFiles: Array<[fileName: string, midi: number]> = [ @@ -44,3 +52,94 @@ export const pianoSampleDefinitions: Array = sampleFiles url: `${sampleBaseUrl}${fileName}`, })) .sort((a, b) => a.midi - b.midi); + +let loadedPianoSamples: Array | null = null; +let pianoSampleLoadPromise: Promise> | null = null; + +export const preloadPianoSamples = ( + onProgress?: (progress: PianoSampleLoadProgress) => void +): Promise> => { + const OfflineAudioContextConstructor = + globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext; + + if (!OfflineAudioContextConstructor) { + return Promise.reject( + new Error('OfflineAudioContext is required to preload piano samples.') + ); + } + + const decodeContext = new OfflineAudioContextConstructor(1, 1, 44_100); + return loadPianoSamples(decodeContext, onProgress); +}; + +export const loadPianoSamples = ( + decodeContext: BaseAudioContext, + onProgress?: (progress: PianoSampleLoadProgress) => void +): Promise> => { + if (loadedPianoSamples) { + onProgress?.({ + loadedCount: loadedPianoSamples.length, + totalCount: pianoSampleDefinitions.length, + }); + return Promise.resolve([...loadedPianoSamples]); + } + + if (pianoSampleLoadPromise) { + return pianoSampleLoadPromise; + } + + let loadedCount = 0; + const totalCount = pianoSampleDefinitions.length; + onProgress?.({ loadedCount, totalCount }); + + pianoSampleLoadPromise = Promise.all( + pianoSampleDefinitions.map(async (sample) => { + const loadedSample = await loadPianoSample(decodeContext, sample); + loadedCount += 1; + onProgress?.({ loadedCount, totalCount, sample }); + return loadedSample; + }) + ).then( + (samples) => { + loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi); + return [...loadedPianoSamples]; + }, + (error: unknown) => { + pianoSampleLoadPromise = null; + throw error; + } + ); + + return pianoSampleLoadPromise; +}; + +export const getLoadedPianoSamples = (): Array | null => + loadedPianoSamples ? [...loadedPianoSamples] : null; + +export const resetPianoSampleCacheForTest = (): void => { + loadedPianoSamples = null; + pianoSampleLoadPromise = null; +}; + +const loadPianoSample = async ( + decodeContext: BaseAudioContext, + sample: PianoSampleDefinition +): Promise => { + const response = await fetch(sample.url); + if (!response.ok) { + throw new Error(`Unable to load piano sample ${sample.url}`); + } + + const audioData = await response.arrayBuffer(); + const buffer = await decodeAudioData(decodeContext, audioData); + return { midi: sample.midi, buffer }; +}; + +const decodeAudioData = ( + decodeContext: BaseAudioContext, + audioData: ArrayBuffer +): Promise => + new Promise((resolve, reject) => { + const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject); + decodePromise?.then(resolve, reject); + }); diff --git a/src/config.ts b/src/config.ts index a31e01a..feacf71 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,15 +3,10 @@ import type { GardenAppConfig } from './config/types'; import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets'; export type { - AgentColorInteractionSettings, GardenAppConfig, GardenAudioEngineConfig, GardenRuntimeSettings, - GardenSimulationConfig, - GardenStorageConfig, - GardenVibeSettings, NumberControlConfig, - RuntimeSettingControlConfig, VibePreset, } from './config/types'; @@ -21,14 +16,6 @@ export const appConfig = { fadeInSeconds: 0.45, updateRampSeconds: 0.08, highPassFrequencyHz: 45, - fallbackVibeId: defaultVibeId, - compressor: { - thresholdDb: -18, - kneeDb: 18, - ratio: 2.4, - attackSeconds: 0.006, - releaseSeconds: 0.18, - }, delay: { timeSeconds: 0.46, feedback: 0.12, @@ -42,9 +29,6 @@ export const appConfig = { releaseSeconds: 0.24, lowpassHz: 7600, }, - input: { - pressureFallback: 0.48, - }, rhythm: { bpm: 74, stepsPerBeat: 4, @@ -120,7 +104,6 @@ export const appConfig = { distanceEnergyScale: 0.66, distanceForFullEnergyPixels: 140, fallbackFrameSeconds: 1 / 60, - penMinPressure: 0.56, strokeEnergyBase: 0.18, strokeEnergyPressureWeight: 0.22, strokeEnergySpeedWeight: 0.62, diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index cfb5ff8..671c2a4 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -18,47 +18,6 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = { color3ToColor3: 1, }; -const hashString = (value: string): number => { - let hash = 0x811c9dc5; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -}; - -const createSeededRandom = (seed: number): (() => number) => { - let state = seed; - 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; - }; -}; - -export const createColorInteractionSettings = ( - seedSource: string -): AgentColorInteractionSettings => { - const random = createSeededRandom(hashString(seedSource)); - const values = Object.values(agentInteractionOptions); - const randomInteraction = () => - values[Math.floor(random() * values.length)] ?? - defaultColorInteractionSettings.color1ToColor2; - - return { - color1ToColor1: 1, - color1ToColor2: randomInteraction(), - color1ToColor3: randomInteraction(), - color2ToColor1: randomInteraction(), - color2ToColor2: 1, - color2ToColor3: randomInteraction(), - color3ToColor1: randomInteraction(), - color3ToColor2: randomInteraction(), - color3ToColor3: 1, - }; -}; - export const colorInteractionControl = (label: string): NumberControlConfig => ({ folder: 'Color Reactions', label, diff --git a/src/config/runtime-settings.ts b/src/config/runtime-settings.ts index 2de328f..8e2c9b3 100644 --- a/src/config/runtime-settings.ts +++ b/src/config/runtime-settings.ts @@ -36,7 +36,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { startColorHue: 200, - renderSpeed: 1, simulatedDelayMs: 0, }, controls: { @@ -145,13 +144,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { max: 500, step: 1, }, - renderSpeed: { - folder: 'Runtime', - integer: true, - min: 1, - max: 10, - step: 1, - }, selectedColorIndex: { folder: 'Brush', integer: true, diff --git a/src/config/types.ts b/src/config/types.ts index 0bb8989..44d586b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -27,7 +27,7 @@ export type AgentColorInteractionSettings = Pick< | 'color3ToColor3' >; -export type GardenVibeSettings = Partial< +type GardenVibeSettings = Partial< Pick< GardenRuntimeSettings, | 'agentBudgetMax' @@ -72,7 +72,7 @@ export interface NumberControlConfig { step?: number; } -export type RuntimeSettingControlConfig = { +type RuntimeSettingControlConfig = { [Key in keyof GardenRuntimeSettings]: NumberControlConfig; }; @@ -120,7 +120,6 @@ export interface GardenAppConfig { distanceEnergyScale: number; distanceForFullEnergyPixels: number; fallbackFrameSeconds: number; - penMinPressure: number; strokeEnergyBase: number; strokeEnergyPressureWeight: number; strokeEnergySpeedWeight: number; @@ -280,5 +279,3 @@ export interface GardenAppConfig { } export type GardenAudioEngineConfig = GardenAppConfig['audioEngine']; -export type GardenSimulationConfig = GardenAppConfig['simulation']; -export type GardenStorageConfig = GardenAppConfig['storage']; diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index d4fd90f..e14aadf 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -2,7 +2,7 @@ import type { GardenAudioChord, GardenAudioVibeProfile, } from '../audio/garden-audio-config'; -import { createColorInteractionSettings } from './color-interactions'; +import { defaultColorInteractionSettings } from './color-interactions'; import type { VibePreset } from './types'; const majorProgression: Array = [ @@ -42,7 +42,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 38, spawnPerPixel: 0.22, turnSpeed: 58, - ...createColorInteractionSettings('candy-rain'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 57, @@ -69,7 +69,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 46, spawnPerPixel: 0.18, turnSpeed: 44, - ...createColorInteractionSettings('sunlit-moss'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 53, @@ -101,7 +101,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 35, spawnPerPixel: 0.25, turnSpeed: 62, - ...createColorInteractionSettings('coral-tide'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 50, @@ -128,7 +128,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 42, spawnPerPixel: 0.2, turnSpeed: 52, - ...createColorInteractionSettings('moon-orchid'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 49, @@ -155,7 +155,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 32, spawnPerPixel: 0.24, turnSpeed: 70, - ...createColorInteractionSettings('peach-neon'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 56, @@ -182,7 +182,7 @@ export const vibePresets: Array = [ sensorOffsetDistance: 52, spawnPerPixel: 0.16, turnSpeed: 40, - ...createColorInteractionSettings('frost-bloom'), + ...defaultColorInteractionSettings, }, audio: { rootMidi: 62, diff --git a/src/game-loop/frame-performance.test.ts b/src/game-loop/frame-performance.test.ts index cd74739..35250f0 100644 --- a/src/game-loop/frame-performance.test.ts +++ b/src/game-loop/frame-performance.test.ts @@ -2,44 +2,61 @@ import { describe, expect, it } from 'vitest'; import { FramePerformance } from './frame-performance'; +function createScenario() { + const performance = new FramePerformance(); + let time = 0; + performance.update(time); + const advance = (fps: number): void => { + time += 1000 / fps; + performance.update(time); + }; + return { performance, advance }; +} + describe('FramePerformance refresh target', () => { it('uses 60 FPS as the fixed adaptive budget target', () => { - const performance = new FramePerformance(); + const { performance, advance } = createScenario(); - [123, 126, 130, 121, 60, 30].forEach((fps) => performance.update(1 / fps)); + [123, 126, 130, 121, 60, 30].forEach(advance); expect(performance.refreshTargetFps).toBe(60); }); it('keeps latest and smoothed FPS separate from the fixed target', () => { - const performance = new FramePerformance(); + const { performance, advance } = createScenario(); - performance.update(1 / 120); + advance(120); expect(performance.latestFps).toBe(120); expect(performance.smoothedFps).toBeGreaterThan(60); expect(performance.refreshTargetFps).toBe(60); }); - it('snaps the display refresh estimate to a stable screen frequency', () => { - const performance = new FramePerformance(); + it('reports true FPS even when the simulation delta would clamp', () => { + const { performance, advance } = createScenario(); - [123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) => - performance.update(1 / fps) - ); + [5, 5, 5, 5, 5].forEach(advance); + + expect(performance.latestFps).toBeCloseTo(5, 5); + }); + + it('snaps the display refresh estimate to a stable screen frequency', () => { + const { performance, advance } = createScenario(); + + [123, 126, 130, 121, 124, 127, 125, 122].forEach(advance); expect(performance.refreshTargetFps).toBe(60); expect(performance.displayRefreshFps).toBe(120); }); it('ignores a single startup spike before settling the display refresh estimate', () => { - const performance = new FramePerformance(); + const { performance, advance } = createScenario(); - performance.update(1 / 240); + advance(240); expect(performance.displayRefreshFps).toBe(60); - Array.from({ length: 8 }).forEach(() => performance.update(1 / 120)); + Array.from({ length: 8 }).forEach(() => advance(120)); expect(performance.refreshTargetFps).toBe(60); expect(performance.displayRefreshFps).toBe(120); diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index ea82d71..0127abe 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -7,7 +7,6 @@ interface TelemetrySnapshot { agentBudgetMax: number; canvas: HTMLCanvasElement; devicePixelRatio: number; - renderSpeed: number; } const COMMON_DISPLAY_REFRESH_RATES = [ @@ -15,6 +14,7 @@ const COMMON_DISPLAY_REFRESH_RATES = [ ] as const; const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8; const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15; +const FRAME_GAP_RESET_SECONDS = 1; export class FramePerformance { public latestFps = 60; @@ -23,6 +23,7 @@ export class FramePerformance { public readonly refreshTargetFps = 60; private lastTelemetryAt = 0; + private previousFrameTime: DOMHighResTimeStamp | null = null; private hasConfirmedDisplayRefreshFps = false; private pendingDisplayRefreshFps = 0; private pendingDisplayRefreshFrameCount = 0; @@ -35,8 +36,19 @@ export class FramePerformance { return appConfig.telemetry.enabled ? performance.now() - startedAt : 0; } - public update(deltaTime: number): void { - const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds); + public update(time: DOMHighResTimeStamp): void { + const previous = this.previousFrameTime; + this.previousFrameTime = time; + if (previous === null) { + return; + } + + const deltaSeconds = (time - previous) / 1000; + if (deltaSeconds <= 0 || deltaSeconds > FRAME_GAP_RESET_SECONDS) { + return; + } + + const fps = 1 / deltaSeconds; this.latestFps = fps; this.updateDisplayRefreshEstimate(fps); this.smoothedFps = @@ -51,7 +63,6 @@ export class FramePerformance { agentBudgetMax, canvas, devicePixelRatio, - renderSpeed, }: TelemetrySnapshot): void { if (!appConfig.telemetry.enabled) { return; @@ -73,7 +84,6 @@ export class FramePerformance { canvasWidth: canvas.width, canvasHeight: canvas.height, dpr: devicePixelRatio, - renderSpeed, frameCpuMs: now - frameCpuStartedAt, encodeCpuMs, }); diff --git a/src/game-loop/game-loop-ping-pong.test.ts b/src/game-loop/game-loop-ping-pong.test.ts index 921c8e4..04bbd69 100644 --- a/src/game-loop/game-loop-ping-pong.test.ts +++ b/src/game-loop/game-loop-ping-pong.test.ts @@ -10,31 +10,45 @@ const simulationTexturesSource = readFileSync( join(process.cwd(), 'src/game-loop/simulation-textures.ts'), 'utf8' ); +const resizableTextureSource = readFileSync( + join(process.cwd(), 'src/utils/graphics/resizable-texture.ts'), + 'utf8' +); const getRenderStepSource = () => { - const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)'); + const start = simulationFrameSource.indexOf( + 'const commandEncoder = this.device.createCommandEncoder();' + ); const end = simulationFrameSource.indexOf(' public clearSwipes', start); if (start < 0 || end < 0) { - throw new Error('Could not find the render-speed simulation loop'); + throw new Error('Could not find the simulation frame execution body'); } return simulationFrameSource.slice(start, end); }; describe('GameLoop ping-pong texture flow', () => { - it('copies only the trail map and swaps source/influence references after diffusion', () => { + it('copies only the trail map with a GPU texture copy and swaps source/influence references after diffusion', () => { const renderStepSource = getRenderStepSource(); - expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1); - expect(renderStepSource).toMatch( - /this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/ + expect(renderStepSource).not.toContain('copyPipeline.execute'); + expect(renderStepSource).toContain('this.textures.copyTrailMapAToB(commandEncoder);'); + expect(simulationTexturesSource).toMatch( + /commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/ ); expect(renderStepSource).toMatch( /this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/ ); }); + it('keeps resizable textures usable for render, shader, and GPU copy paths', () => { + expect(resizableTextureSource).toContain('public getTexture(): GPUTexture'); + expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC'); + expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST'); + expect(resizableTextureSource).toContain('this.copyPipeline.execute('); + }); + it('keeps ping-pong texture references mutable and swaps A/B identities', () => { expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;'); expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;'); diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index f1f9d0a..acf65e6 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -5,7 +5,6 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag 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 { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; @@ -13,7 +12,7 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { settings } from '../settings'; import { initializeContext } from '../utils/graphics/initialize-context'; import { GLOBAL_AGENT_CAP } from './agent-population'; -import { RenderInputs } from './game-loop-types'; +import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; import { SimulationFrameRenderer } from './simulation-frame'; import { SimulationTextures } from './simulation-textures'; @@ -33,7 +32,6 @@ interface FrameParameters extends RenderInputs { export class GameLoopResources { public readonly textures: SimulationTextures; public readonly commonState: CommonState; - public readonly copyPipeline: CopyPipeline; public readonly agentGenerationPipeline: AgentGenerationPipeline; public readonly agentPipeline: AgentPipeline; public readonly brushPipeline: BrushPipeline; @@ -53,7 +51,6 @@ export class GameLoopResources { const context = initializeContext({ device, canvas }); this.textures = new SimulationTextures(this.device, canvasSize); - this.copyPipeline = new CopyPipeline(this.device); this.commonState = new CommonState(this.device); this.commonState.setParameters({ @@ -88,7 +85,6 @@ export class GameLoopResources { this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, { - copyPipeline: this.copyPipeline, agentPipeline: this.agentPipeline, brushPipeline: this.brushPipeline, eraserAgentPipeline: this.eraserAgentPipeline, @@ -157,8 +153,11 @@ export class GameLoopResources { this.setBrushEffectDiffusionParameters(); } - public executeFrame(renderSpeed: number, isErasing: boolean): void { - this.frameRenderer.execute(renderSpeed, isErasing); + public executeFrame( + isErasing: boolean, + canvasReadbackRequest?: CanvasReadbackRequest | null + ): void { + this.frameRenderer.execute(isErasing, canvasReadbackRequest); } public clearSwipes(): void { @@ -166,7 +165,6 @@ export class GameLoopResources { } public destroy(): void { - this.copyPipeline.destroy(); this.agentGenerationPipeline.destroy(); this.agentPipeline.destroy(); this.brushPipeline.destroy(); diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts index bd3a756..58ffd55 100644 --- a/src/game-loop/game-loop-settings.ts +++ b/src/game-loop/game-loop-settings.ts @@ -1,7 +1,6 @@ export interface GameLoopSettings { agentBudgetMax: number; agentCount: number; - renderSpeed: number; simulatedDelayMs: number; selectedColorIndex: number; spawnPerPixel: number; diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts index 9ecb9cf..1b5182b 100644 --- a/src/game-loop/game-loop-types.ts +++ b/src/game-loop/game-loop-types.ts @@ -4,6 +4,7 @@ export interface GardenUi { prompt: HTMLElement; eraserPreview: HTMLElement; exportStatus: HTMLElement; + toolbar: HTMLElement; } export interface RenderInputs { @@ -15,3 +16,8 @@ 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 c919112..4b51f10 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -15,6 +15,7 @@ import { GardenUi } from './game-loop-types'; import { IntroPrompt } from './intro-prompt'; import { GardenPointerInput } from './pointer-input'; import { RenderInputCache } from './render-input-cache'; +import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; export default class GameLoop { private static readonly MAX_MIRROR_SEGMENT_COUNT = @@ -34,6 +35,7 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly export4KRenderer: Export4KRenderer; private readonly framePerformance = new FramePerformance(); + private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly devStatsElement: HTMLDivElement | null; private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16); private readonly resizeListener = this.resize.bind(this); @@ -56,6 +58,7 @@ export default class GameLoop { this.resources = new GameLoopResources(canvas, device, this.canvasSize); this.introPrompt = new IntroPrompt(ui.prompt); this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); + this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device); this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline); this.agentPopulation.initializeIntroAgents(this.canvasSize); this.pointerInput = new GardenPointerInput({ @@ -85,8 +88,8 @@ export default class GameLoop { getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), getVibeId: () => activeVibe.id, }); - this.keydownListener = (event: KeyboardEvent) => { - this.audio.start(activeVibe, { userGesture: event.isTrusted }); + this.keydownListener = () => { + this.audio.start(activeVibe, { userGesture: true }); this.introPrompt.complete(); }; @@ -151,6 +154,7 @@ export default class GameLoop { window.removeEventListener('resize', this.resizeListener); window.removeEventListener('keydown', this.keydownListener); this.pointerInput.detach(); + this.toolbarContrastMonitor.destroy(); this.devStatsElement?.remove(); this.introPrompt.destroy(); this.resources.destroy(); @@ -165,7 +169,7 @@ export default class GameLoop { const frameCpuStartedAt = this.framePerformance.markCpuStart(); const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); - this.framePerformance.update(deltaTime); + this.framePerformance.update(time); this.agentPopulation.growBudget( deltaTime, this.framePerformance.smoothedFps, @@ -175,7 +179,6 @@ export default class GameLoop { this.resize(); this.resizeSimulationToCanvas(); - const scaledTime = time * settings.renderSpeed; const { channelColors, backgroundColor } = this.renderInputs.get(); const introProgress = this.introPrompt.progress; const cameraZoom = 1; @@ -195,7 +198,7 @@ export default class GameLoop { }); this.resources.setFrameParameters({ - time: scaledTime, + time, deltaTime, canvasSize: this.canvasSize, activeAgentCount: this.agentPopulation.activeAgentCount, @@ -210,7 +213,10 @@ export default class GameLoop { }); const encodeCpuStartedAt = this.framePerformance.markCpuStart(); - this.resources.executeFrame(settings.renderSpeed, isErasing); + this.resources.executeFrame( + isErasing, + this.toolbarContrastMonitor.takeReadbackRequest(time) + ); const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt); this.pointerInput.clearSwipesIfIdle(); @@ -223,7 +229,6 @@ export default class GameLoop { agentBudgetMax: settings.agentBudgetMax, canvas: this.canvas, devicePixelRatio: this.devicePixelRatio, - renderSpeed: settings.renderSpeed, }); this.updateDevStats(time); diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts index 735393c..a22fc0f 100644 --- a/src/game-loop/intro-title-agents.ts +++ b/src/game-loop/intro-title-agents.ts @@ -154,7 +154,7 @@ const createIntroTitlePoints = ( const fontSize = getIntroTitleFontSize(context, width, height); context.clearRect(0, 0, width, height); - context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`; + context.font = `${fontSize}px "Open Sans", sans-serif`; context.textAlign = 'center'; context.textBaseline = 'middle'; context.fillStyle = '#fff'; @@ -302,7 +302,7 @@ const getIntroTitleFontSize = ( ); while (fontSize > appConfig.simulation.intro.minFontSizePx) { - context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`; + context.font = `${fontSize}px "Open Sans", sans-serif`; const metrics = context.measureText(INTRO_TITLE); const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize; diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index f7c873c..8cee375 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -1,7 +1,6 @@ import { vec2 } from 'gl-matrix'; import { GardenAudio } from '../audio/garden-audio'; -import { gardenAudioConfig } from '../audio/garden-audio-config'; import { appConfig } from '../config'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; @@ -111,7 +110,9 @@ export class GardenPointerInput { } const position = this.getCanvasPointerPosition(event); - this.options.audio.start(activeVibe, { userGesture: event.isTrusted }); + if (event.pointerType !== 'touch') { + this.options.audio.start(activeVibe, { userGesture: true }); + } this.options.audio.beginGesture(); this.options.audio.touchDown({ vibe: activeVibe, @@ -120,7 +121,6 @@ export class GardenPointerInput { canvasSize: this.options.getCanvasSize(), mirrorSegmentCount: this.options.getMirrorSegmentCount(), pressure: this.getPointerPressure(event), - pointerType: event.pointerType, }); this.options.onStartDrawing(); this.activePointerId = event.pointerId; @@ -149,6 +149,7 @@ export class GardenPointerInput { if (event.pointerId !== this.activePointerId) { return; } + this.options.audio.start(activeVibe, { userGesture: true }); this.addSwipeAt(event, { emitAudio: false }); this.finishSmoothedStroke(); this.options.audio.endGesture(); @@ -221,7 +222,6 @@ export class GardenPointerInput { elapsedSeconds, eraserSizePixels: settings.eraserSize * devicePixelRatio, mirrorSegmentCount: this.options.getMirrorSegmentCount(), - pointerType: event.pointerType, }); } this.lastPointerPosition = position; @@ -369,9 +369,7 @@ export class GardenPointerInput { return Math.min(1, Math.max(0, event.pressure)); } - return event.buttons > 0 || event.type === 'pointerdown' - ? gardenAudioConfig.input.pressureFallback - : 0; + return 0; } } diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index 43ad661..25e97c4 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -1,14 +1,13 @@ import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; -import { CopyPipeline } from '../pipelines/copy/copy-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 { CanvasReadbackRequest } from './game-loop-types'; import { SimulationTextures } from './simulation-textures'; interface SimulationFramePipelines { - copyPipeline: CopyPipeline; agentPipeline: AgentPipeline; brushPipeline: BrushPipeline; eraserAgentPipeline: EraserAgentPipeline; @@ -25,70 +24,64 @@ export class SimulationFrameRenderer { private readonly pipelines: SimulationFramePipelines ) {} - public execute(renderSpeed: number, isErasing: boolean): void { - for (let i = 0; i < renderSpeed; i++) { - const commandEncoder = this.device.createCommandEncoder(); + public execute( + isErasing: boolean, + canvasReadbackRequest?: CanvasReadbackRequest | null + ): void { + const commandEncoder = this.device.createCommandEncoder(); - this.pipelines.copyPipeline.execute( - commandEncoder, - this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView() - ); - if (isErasing) { - this.pipelines.eraserTexturePipeline.execute( - commandEncoder, - this.textures.sourceMapA.getTextureView() - ); - this.pipelines.eraserTexturePipeline.execute( - commandEncoder, - this.textures.influenceMapA.getTextureView() - ); - this.pipelines.eraserTexturePipeline.execute( + this.textures.copyTrailMapAToB(commandEncoder); + if (isErasing) { + if (this.pipelines.eraserAgentPipeline.hasActiveMask()) { + const eraserMask = this.textures.clearEraserMask(commandEncoder); + this.pipelines.eraserTexturePipeline.execute(commandEncoder, eraserMask); + this.pipelines.eraserTexturePipeline.executeMultiTarget( commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.textures.influenceMapA.getTextureView(), this.textures.trailMapB.getTextureView() ); - this.pipelines.eraserAgentPipeline.execute(commandEncoder); - } else { - this.pipelines.brushPipeline.execute( - commandEncoder, - this.textures.sourceMapA.getTextureView() - ); - this.pipelines.brushPipeline.execute( - commandEncoder, - this.textures.influenceMapA.getTextureView() - ); + this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask); } - this.pipelines.agentPipeline.execute( - commandEncoder, - this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView(), - this.textures.influenceMapA.getTextureView() - ); - this.pipelines.diffusionPipeline.execute( - commandEncoder, - this.textures.trailMapB.getTextureView(), - this.textures.trailMapA.getTextureView() - ); - this.pipelines.renderPipeline.execute( - commandEncoder, - this.textures.trailMapA.getTextureView(), - this.textures.sourceMapA.getTextureView() - ); - this.pipelines.diffusionPipeline.execute( + } else { + this.pipelines.brushPipeline.executeMultiTarget( commandEncoder, this.textures.sourceMapA.getTextureView(), - this.textures.sourceMapB.getTextureView() + this.textures.influenceMapA.getTextureView() ); - this.pipelines.brushEffectDiffusionPipeline.execute( - commandEncoder, - this.textures.influenceMapA.getTextureView(), - this.textures.influenceMapB.getTextureView() - ); - - this.device.queue.submit([commandEncoder.finish()]); - this.textures.swapSourceMaps(); - this.textures.swapInfluenceMaps(); } + this.pipelines.agentPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), + this.textures.influenceMapA.getTextureView() + ); + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.trailMapB.getTextureView(), + this.textures.trailMapA.getTextureView() + ); + const canvasTexture = this.pipelines.renderPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.sourceMapA.getTextureView() + ); + canvasReadbackRequest?.encode(commandEncoder, canvasTexture); + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.textures.sourceMapB.getTextureView() + ); + this.pipelines.brushEffectDiffusionPipeline.execute( + commandEncoder, + this.textures.influenceMapA.getTextureView(), + this.textures.influenceMapB.getTextureView() + ); + + this.device.queue.submit([commandEncoder.finish()]); + canvasReadbackRequest?.afterSubmit(); + this.textures.swapSourceMaps(); + this.textures.swapInfluenceMaps(); } public clearSwipes(): void { diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index 9309411..1301858 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -9,6 +9,7 @@ export class SimulationTextures { public sourceMapB: ResizableTexture; public influenceMapA: ResizableTexture; public influenceMapB: ResizableTexture; + public eraserMask: ResizableTexture; public constructor( private readonly device: GPUDevice, @@ -20,6 +21,7 @@ export class SimulationTextures { this.sourceMapB = new ResizableTexture(this.device, canvasSize); this.influenceMapA = new ResizableTexture(this.device, canvasSize); this.influenceMapB = new ResizableTexture(this.device, canvasSize); + this.eraserMask = new ResizableTexture(this.device, canvasSize); } public resizeTo(nextSize: vec2): vec2 | null { @@ -35,10 +37,38 @@ export class SimulationTextures { this.sourceMapB.resize(nextSize); this.influenceMapA.resize(nextSize); this.influenceMapB.resize(nextSize); + this.eraserMask.resize(nextSize); return scale; } + public clearEraserMask(commandEncoder: GPUCommandEncoder): GPUTextureView { + const eraserMaskView = this.eraserMask.getTextureView(); + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: eraserMaskView, + clearValue: { r: 1, g: 1, b: 1, a: 1 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + passEncoder.end(); + + return eraserMaskView; + } + + public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void { + const size = this.trailMapA.getSize(); + + commandEncoder.copyTextureToTexture( + { texture: this.trailMapA.getTexture() }, + { texture: this.trailMapB.getTexture() }, + { width: size[0], height: size[1] } + ); + } + public swapSourceMaps(): void { [this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA]; } @@ -54,5 +84,6 @@ export class SimulationTextures { this.sourceMapB.destroy(); this.influenceMapA.destroy(); this.influenceMapB.destroy(); + this.eraserMask.destroy(); } } diff --git a/src/game-loop/toolbar-contrast-monitor.test.ts b/src/game-loop/toolbar-contrast-monitor.test.ts new file mode 100644 index 0000000..25b13d8 --- /dev/null +++ b/src/game-loop/toolbar-contrast-monitor.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + getToolbarContrastMetrics, + shouldDimToolbarBackground, +} from './toolbar-contrast-monitor'; + +const makePixels = ( + samples: ReadonlyArray +): Uint8Array => { + const pixels = new Uint8Array(samples.length * 4); + samples.forEach(([red, green, blue], index) => { + const offset = index * 4; + pixels[offset] = red; + pixels[offset + 1] = green; + pixels[offset + 2] = blue; + pixels[offset + 3] = 255; + }); + return pixels; +}; + +describe('toolbar contrast monitoring', () => { + it('leaves the toolbar transparent over dark canvas samples', () => { + const metrics = getToolbarContrastMetrics( + makePixels(Array.from({ length: 91 }, () => [8, 12, 18])), + 91, + false + ); + + expect(metrics.dimmingStrength).toBe(0); + expect(metrics.lowContrastRatio).toBe(0); + expect(shouldDimToolbarBackground(metrics, false)).toBe(false); + }); + + it('dims the toolbar when enough samples have poor contrast with white controls', () => { + const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const); + const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const); + const metrics = getToolbarContrastMetrics( + makePixels([...darkSamples, ...brightSamples]), + 91, + false + ); + + expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08); + expect(shouldDimToolbarBackground(metrics, false)).toBe(true); + }); + + it('keeps the dimmed state until contrast has clearly recovered', () => { + const metrics = getToolbarContrastMetrics( + makePixels([ + ...Array.from({ length: 86 }, () => [8, 12, 18] as const), + ...Array.from({ length: 5 }, () => [245, 240, 218] as const), + ]), + 91, + false + ); + + expect(shouldDimToolbarBackground(metrics, false)).toBe(false); + expect(shouldDimToolbarBackground(metrics, true)).toBe(true); + }); + + it('reads bgra canvas samples in the correct channel order', () => { + const bgraPixels = new Uint8Array([0, 0, 255, 255]); + const metrics = getToolbarContrastMetrics(bgraPixels, 1, true); + + expect(metrics.averageLuminance).toBeCloseTo(0.2126); + }); +}); diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts new file mode 100644 index 0000000..974a0f5 --- /dev/null +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -0,0 +1,284 @@ +import type { CanvasReadbackRequest } from './game-loop-types'; + +interface CanvasSamplePoint { + x: number; + y: number; +} + +interface ToolbarContrastMetrics { + averageLuminance: number; + brightRatio: number; + dimmingStrength: number; + lowContrastRatio: number; +} + +const BYTES_PER_SAMPLE = 4; +const SAMPLE_COLUMNS = 13; +const SAMPLE_ROWS = 7; +const SAMPLE_INTERVAL_MS = 300; +const LOW_CONTRAST_RATIO_TO_DIM = 0.08; +const LOW_CONTRAST_RATIO_TO_CLEAR = 0.04; +const DIMMING_STRENGTH_TO_DIM = 0.18; +const DIMMING_STRENGTH_TO_CLEAR = 0.1; + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +const getLinearChannel = (channel: number): number => { + const normalized = channel / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; +}; + +const getRelativeLuminance = (red: number, green: number, blue: number): number => + 0.2126 * getLinearChannel(red) + + 0.7152 * getLinearChannel(green) + + 0.0722 * getLinearChannel(blue); + +export const getToolbarContrastMetrics = ( + pixels: Uint8Array, + sampleCount: number, + isBgra: boolean +): ToolbarContrastMetrics => { + const count = Math.max(0, Math.min(sampleCount, Math.floor(pixels.length / 4))); + if (count === 0) { + return { + averageLuminance: 0, + brightRatio: 0, + dimmingStrength: 0, + lowContrastRatio: 0, + }; + } + + let luminanceTotal = 0; + let brightCount = 0; + let lowContrastCount = 0; + + for (let i = 0; i < count; i++) { + const offset = i * BYTES_PER_SAMPLE; + 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 = 1.05 / (luminance + 0.05); + + luminanceTotal += luminance; + if (luminance > 0.32) { + brightCount++; + } + if (contrastWithWhite < 3) { + lowContrastCount++; + } + } + + const averageLuminance = luminanceTotal / count; + const brightRatio = brightCount / count; + const lowContrastRatio = lowContrastCount / count; + const dimmingStrength = clamp01( + Math.max(0, averageLuminance - 0.11) / 0.28 + + brightRatio * 0.65 + + lowContrastRatio * 1.8 + ); + + return { + averageLuminance, + brightRatio, + dimmingStrength, + lowContrastRatio, + }; +}; + +export const shouldDimToolbarBackground = ( + metrics: ToolbarContrastMetrics, + wasDimmed: boolean +): boolean => + wasDimmed + ? metrics.dimmingStrength > DIMMING_STRENGTH_TO_CLEAR || + metrics.lowContrastRatio > LOW_CONTRAST_RATIO_TO_CLEAR + : metrics.dimmingStrength > DIMMING_STRENGTH_TO_DIM || + metrics.lowContrastRatio >= LOW_CONTRAST_RATIO_TO_DIM; + +export class ToolbarContrastMonitor { + private readonly isBgra: boolean; + private isDestroyed = false; + private isDimmed = false; + private isReadbackPending = false; + private lastSampleAt = Number.NEGATIVE_INFINITY; + + public constructor( + private readonly canvas: HTMLCanvasElement, + private readonly toolbar: HTMLElement, + private readonly device: GPUDevice + ) { + this.isBgra = navigator.gpu?.getPreferredCanvasFormat() === 'bgra8unorm'; + } + + public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null { + if ( + this.isDestroyed || + this.isReadbackPending || + time - this.lastSampleAt < SAMPLE_INTERVAL_MS + ) { + return null; + } + + const samplePoints = this.getSamplePoints(); + if (samplePoints.length === 0) { + return null; + } + + let buffer: GPUBuffer; + try { + buffer = this.device.createBuffer({ + size: samplePoints.length * BYTES_PER_SAMPLE, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + } catch { + return null; + } + + this.isReadbackPending = true; + this.lastSampleAt = time; + + let isBufferDestroyed = false; + let isCancelled = false; + let isEncoded = false; + const destroyBuffer = () => { + if (isBufferDestroyed) { + return; + } + + isBufferDestroyed = true; + buffer.destroy(); + }; + const cancel = (destroyNow = true) => { + if (isCancelled) { + return; + } + + isCancelled = true; + this.isReadbackPending = false; + if (destroyNow) { + destroyBuffer(); + } + }; + + return { + encode: (commandEncoder, texture) => { + if (isCancelled) { + return; + } + + try { + samplePoints.forEach((point, index) => { + commandEncoder.copyTextureToBuffer( + { + origin: point, + texture, + }, + { + buffer, + offset: index * BYTES_PER_SAMPLE, + }, + { + depthOrArrayLayers: 1, + height: 1, + width: 1, + } + ); + }); + isEncoded = true; + } catch { + cancel(false); + } + }, + afterSubmit: () => { + if (isCancelled) { + destroyBuffer(); + return; + } + + if (!isEncoded) { + cancel(); + return; + } + + void this.readBuffer(buffer, samplePoints.length); + }, + }; + } + + public destroy(): void { + this.isDestroyed = true; + this.toolbar.classList.remove('needs-contrast-background'); + } + + private getSamplePoints(): Array { + const canvasRect = this.canvas.getBoundingClientRect(); + const toolbarRect = this.toolbar.getBoundingClientRect(); + if ( + canvasRect.width <= 0 || + canvasRect.height <= 0 || + toolbarRect.width <= 0 || + toolbarRect.height <= 0 + ) { + return []; + } + + 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 []; + } + + const xScale = this.canvas.width / canvasRect.width; + const yScale = this.canvas.height / canvasRect.height; + const width = right - left; + const height = bottom - top; + const points = new Map(); + + for (let row = 0; row < SAMPLE_ROWS; row++) { + const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * height; + const y = Math.min( + this.canvas.height - 1, + Math.max(0, Math.floor((cssY - canvasRect.top) * yScale)) + ); + + for (let column = 0; column < SAMPLE_COLUMNS; column++) { + const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * width; + const x = Math.min( + this.canvas.width - 1, + Math.max(0, Math.floor((cssX - canvasRect.left) * xScale)) + ); + points.set(`${x}:${y}`, { x, y }); + } + } + + return [...points.values()]; + } + + private async readBuffer(buffer: GPUBuffer, sampleCount: number): 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, sampleCount, this.isBgra); + this.isDimmed = shouldDimToolbarBackground(metrics, this.isDimmed); + this.toolbar.classList.toggle('needs-contrast-background', this.isDimmed); + } + } catch { + // Readback is an enhancement; leave rendering alone if the GPU rejects it. + } finally { + if (isMapped) { + buffer.unmap(); + } + buffer.destroy(); + this.isReadbackPending = false; + } + } +} diff --git a/src/index.ts b/src/index.ts index c191b60..f1b5164 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,13 @@ import GameLoop from './game-loop/game-loop'; import './index.scss'; +import { + initAnalytics, + trackExport, + trackSettingsOpen, + trackVibeChange, +} from './analytics'; +import { preloadPianoSamples } from './audio/piano-samples'; import { appConfig } from './config'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { ConfigPane } from './page/config-pane'; @@ -11,7 +18,12 @@ import { activeVibe, applyVibeSettings, resetSettings, settings } from './settin import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; import { queryRequiredElement, queryRequiredElements } from './utils/dom'; -import { ErrorHandler, Severity } from './utils/error-handler'; +import { + ErrorHandler, + getErrorMessage, + RuntimeError, + Severity, +} from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; import { VIBE_PRESETS } from './vibes'; @@ -47,10 +59,11 @@ const formatMirrorSegmentCount = (count: number): string => ? 'Mirror off' : `${count} ${mirrorSegmentNames[count] ?? 'slices'}`; -const renderRuntimeMessage = ( - container: HTMLElement, - error: Parameters[0]>[0] -) => { +type RuntimeUiError = Parameters< + Parameters[0] +>[0]; + +const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => { const message = document.createElement('pre'); message.className = error.severity; message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; @@ -67,8 +80,31 @@ const renderRuntimeMessage = ( } }; -const elements = { +const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({ + severity: Severity.ERROR, + message: getErrorMessage(exception), + ...(exception instanceof RuntimeError ? { code: exception.code } : {}), +}); + +const renderStartupException = (exception: unknown) => { + const existingContainer = document.querySelector('.errors-container'); + const container = + existingContainer instanceof HTMLElement + ? existingContainer + : document.createElement('div'); + + if (!(existingContainer instanceof HTMLElement)) { + container.className = 'errors-container'; + document.body.append(container); + } + + container.setAttribute('aria-live', 'assertive'); + renderRuntimeMessage(container, getRuntimeUiError(exception)); +}; + +const queryAppElements = () => ({ aside: queryRequiredElement('aside', HTMLElement), + toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement), infoButton: queryRequiredElement('button.info', HTMLButtonElement), infoElement: queryRequiredElement('.info-page', HTMLElement), minimizeFullScreenButton: queryRequiredElement( @@ -98,7 +134,11 @@ const elements = { loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement), loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement), loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement), -}; +}); + +type AppElements = ReturnType; + +let elements: AppElements; const setLoadingStage = (label: string, ratio: number) => { const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100); @@ -188,10 +228,15 @@ const renderMirrorSegmentUi = () => { }; const main = async () => { + let hasRuntimeErrorListener = false; try { + initAnalytics(); + let shouldStop = false; let game: GameLoop | null = null; + let wasConfigPaneOpen = false; + elements = queryAppElements(); elements.errorContainer.setAttribute('aria-live', 'assertive'); ErrorHandler.addOnErrorListener((error, _metadata) => { renderRuntimeMessage(elements.errorContainer, error); @@ -201,6 +246,7 @@ const main = async () => { shouldStop = true; } }); + hasRuntimeErrorListener = true; const syncRuntimeUi = () => { renderEraserSizeUi(game); @@ -216,7 +262,13 @@ const main = async () => { const configPane = new ConfigPane({ settingsButton: elements.settingsButton, onConfigChange: syncRuntimeUi, - onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen), + onOpenChange: (isOpen) => { + game?.setStatsOverlayPinned(isOpen); + if (isOpen && !wasConfigPaneOpen) { + trackSettingsOpen(); + } + wasConfigPaneOpen = isOpen; + }, onRuntimeChange: syncRuntimeUi, onRuntimeReset: () => { resetSettings(); @@ -224,7 +276,12 @@ const main = async () => { }, onRestart: () => game?.destroy(), onVibeChange: (vibeId) => { - applyVibeSettings(vibeId); + const vibe = applyVibeSettings(vibeId); + trackVibeChange({ + vibeId: vibe.id, + vibeName: vibe.name, + source: 'settings', + }); syncRuntimeUi(); game?.playVibeChangeAudio(false); }, @@ -244,33 +301,65 @@ const main = async () => { document.body ); + const startAudioFromUserGesture = (event: Event) => { + if ( + isAudioMuted || + (event.target instanceof Node && elements.soundButton.contains(event.target)) + ) { + return; + } + + game?.startAudio(true); + }; + + window.addEventListener('touchend', startAudioFromUserGesture, { + capture: true, + passive: true, + }); + window.addEventListener('pointerup', startAudioFromUserGesture, { + capture: true, + passive: true, + }); + window.addEventListener('click', startAudioFromUserGesture, { capture: true }); + window.addEventListener('keydown', startAudioFromUserGesture, { capture: true }); + elements.restartButton.addEventListener('click', () => game?.destroy()); - elements.soundButton.addEventListener('click', (event) => { + elements.soundButton.addEventListener('click', () => { isAudioMuted = !isAudioMuted; writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0'); renderAudioUi(game); if (!isAudioMuted) { - game?.startAudio(event.isTrusted); + game?.startAudio(true); } }); - elements.previousVibe.addEventListener('click', (event) => { + elements.previousVibe.addEventListener('click', () => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length]; - applyVibeSettings(vibe.id); + const activePreset = applyVibeSettings(vibe.id); + trackVibeChange({ + vibeId: activePreset.id, + vibeName: activePreset.name, + source: 'previous-button', + }); configPane.refresh(); syncRuntimeUi(); - game?.playVibeChangeAudio(event.isTrusted); + game?.playVibeChangeAudio(true); }); - elements.nextVibe.addEventListener('click', (event) => { + elements.nextVibe.addEventListener('click', () => { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length]; - applyVibeSettings(vibe.id); + const activePreset = applyVibeSettings(vibe.id); + trackVibeChange({ + vibeId: activePreset.id, + vibeName: activePreset.name, + source: 'next-button', + }); configPane.refresh(); syncRuntimeUi(); - game?.playVibeChangeAudio(event.isTrusted); + game?.playVibeChangeAudio(true); }); elements.swatches.forEach((swatch, index) => { @@ -318,6 +407,7 @@ const main = async () => { elements.export4k.disabled = true; try { await game.export4K(); + trackExport({ vibeId: activeVibe.id }); } catch (error) { ErrorHandler.addException(error, { severity: Severity.WARNING }); } finally { @@ -330,18 +420,32 @@ const main = async () => { renderMirrorSegmentUi(); renderAudioUi(game); - const fontsReady = document.fonts.ready.catch(() => undefined); + const fontsReady = document.fonts.ready.catch((error) => { + ErrorHandler.addException(error, { + fallbackMessage: 'Could not load fonts.', + severity: Severity.WARNING, + }); + }); setLoadingStage('Connecting to GPU…', 0.1); const gpu = await initializeGpu(); - setLoadingStage('Loading fonts…', 0.4); + setLoadingStage('Loading fonts…', 0.3); await fontsReady; - setLoadingStage('Compiling shaders…', 0.7); + setLoadingStage('Loading piano samples…', 0.45); + await preloadPianoSamples(({ loadedCount, totalCount }) => { + const sampleRatio = totalCount > 0 ? loadedCount / totalCount : 1; + setLoadingStage( + `Loading piano samples ${loadedCount}/${totalCount}…`, + 0.45 + sampleRatio * 0.3 + ); + }); + setLoadingStage('Compiling shaders…', 0.8); const deltaTimeCalculator = new DeltaTimeCalculator(); let isFirstStart = true; while (!shouldStop) { game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, { + toolbar: elements.toolbarRow, prompt: elements.prompt, eraserPreview: elements.eraserPreview, exportStatus: elements.exportStatus, @@ -364,7 +468,12 @@ const main = async () => { } } catch (e) { document.body.classList.remove('is-loading'); - ErrorHandler.addException(e); + if (hasRuntimeErrorListener) { + ErrorHandler.addException(e); + } else { + renderStartupException(e); + ErrorHandler.addException(e); + } console.error(e); } }; diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl index 6be9e0e..19a73bf 100644 --- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl +++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl @@ -15,22 +15,70 @@ struct Counters { @group(1) @binding(2) var counters: Counters; @group(1) @binding(3) var compactedAgents: array; +var workgroupAliveCount: atomic; +var workgroupCompactedOffset: u32; +var workgroupCopyCount: u32; + @compute @workgroup_size(64) fn main( @builtin(global_invocation_id) global_id: vec3, + @builtin(local_invocation_id) local_id: vec3, @builtin(num_workgroups) workgroup_count: vec3 ) { let id = get_id(global_id, workgroup_count); - if id >= settings.agentCount { - return; + if local_id.x == 0u { + atomicStore(&workgroupAliveCount, 0u); } - let agent = agents[id]; - if agent.colorIndex < 0.0 { - return; + workgroupBarrier(); + + var localCompactedIndex = 0u; + if id < settings.agentCount { + let agent = agents[id]; + if agent.colorIndex >= 0.0 { + localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u); + } } - let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1); - compactedAgents[compactedIndex] = agent; + workgroupBarrier(); + + if local_id.x == 0u { + let groupAliveCount = atomicLoad(&workgroupAliveCount); + if groupAliveCount > 0u { + workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, groupAliveCount); + } else { + workgroupCompactedOffset = 0u; + } + } + + workgroupBarrier(); + + if id < settings.agentCount { + let agent = agents[id]; + if agent.colorIndex >= 0.0 { + compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent; + } + } +} + +@compute @workgroup_size(64) +fn copyCompactedAgents( + @builtin(global_invocation_id) global_id: vec3, + @builtin(local_invocation_id) local_id: vec3, + @builtin(num_workgroups) workgroup_count: vec3 +) { + let id = get_id(global_id, workgroup_count); + + if local_id.x == 0u { + workgroupCopyCount = atomicLoad(&counters.aliveAgentCount); + } + + workgroupBarrier(); + + if id >= workgroupCopyCount { + return; + } + + agents[id] = compactedAgents[id]; } diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts new file mode 100644 index 0000000..2fde4c8 --- /dev/null +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { CommonState } from '../../common-state/common-state'; +import { AGENT_SIZE_IN_BYTES } from './agent'; +import { AgentGenerationPipeline } from './agent-generation-pipeline'; + +const installGpuConstants = () => { + Object.defineProperties(globalThis, { + GPUBufferUsage: { + configurable: true, + value: { + MAP_READ: 1, + COPY_DST: 2, + COPY_SRC: 4, + STORAGE: 8, + UNIFORM: 16, + }, + }, + GPUMapMode: { + configurable: true, + value: { + READ: 1, + }, + }, + GPUShaderStage: { + configurable: true, + value: { + COMPUTE: 1, + }, + }, + }); +}; + +type CopyCall = { + source: GPUBuffer; + sourceOffset: number; + destination: GPUBuffer; + destinationOffset: number; + size: number; +}; + +type DispatchCall = { + entryPoint: string; + workgroups: [number, number, number]; +}; + +type FakePipeline = { + entryPoint: string; +}; + +class FakeBuffer { + private readonly mappedRange: ArrayBuffer; + + public readonly destroy = vi.fn(); + public readonly mapAsync = vi.fn(async (_mode: number) => undefined); + public readonly getMappedRange = vi.fn(() => this.mappedRange); + public readonly unmap = vi.fn(); + + public constructor( + public readonly label: string, + size: number, + mappedValue = 0 + ) { + this.mappedRange = new ArrayBuffer(Math.max(size, Uint32Array.BYTES_PER_ELEMENT)); + new Uint32Array(this.mappedRange)[0] = mappedValue; + } +} + +class FakeComputePass { + private pipeline: FakePipeline | null = null; + + public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => { + this.pipeline = pipeline as unknown as FakePipeline; + }); + public readonly setBindGroup = vi.fn( + (_index: number, _bindGroup: GPUBindGroup) => undefined + ); + public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => { + this.device.dispatchCalls.push({ + entryPoint: this.pipeline?.entryPoint ?? 'unset', + workgroups: [x, y, z], + }); + }); + public readonly end = vi.fn(); + + public constructor(private readonly device: FakeDevice) {} +} + +class FakeCommandEncoder { + public readonly beginComputePass = vi.fn(() => new FakeComputePass(this.device)); + public readonly copyBufferToBuffer = vi.fn( + ( + source: GPUBuffer, + sourceOffset: number, + destination: GPUBuffer, + destinationOffset: number, + size: number + ) => { + this.device.copyCalls.push({ + source, + sourceOffset, + destination, + destinationOffset, + size, + }); + } + ); + public readonly finish = vi.fn(() => ({}) as GPUCommandBuffer); + + public constructor(private readonly device: FakeDevice) {} +} + +class FakeQueue { + public readonly writeBuffer = vi.fn( + (_buffer: GPUBuffer, _offset: number, _data: BufferSource) => undefined + ); + public readonly submit = vi.fn( + (_commandBuffers: Iterable) => undefined + ); +} + +class FakeShaderModule { + public readonly getCompilationInfo = vi.fn(async () => ({ + messages: [], + })); +} + +class FakeDevice { + public readonly copyCalls: Array = []; + public readonly dispatchCalls: Array = []; + public readonly createdComputeEntryPoints: Array = []; + public readonly limits = { + maxBufferSize: 1024 * 1024 * 1024, + maxComputeWorkgroupsPerDimension: 65_535, + }; + public readonly queue = new FakeQueue(); + + private bufferIndex = 0; + + public readonly createBindGroupLayout = vi.fn( + (_descriptor: GPUBindGroupLayoutDescriptor) => ({}) as GPUBindGroupLayout + ); + public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => { + const label = + ['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][ + this.bufferIndex + ] ?? `buffer${this.bufferIndex}`; + this.bufferIndex += 1; + + const isMappedReadBuffer = (Number(descriptor.usage) & GPUBufferUsage.MAP_READ) !== 0; + + return new FakeBuffer( + label, + Number(descriptor.size), + isMappedReadBuffer ? this.compactedCount : 0 + ) as unknown as GPUBuffer; + }); + public readonly createBindGroup = vi.fn( + (_descriptor: GPUBindGroupDescriptor) => ({}) as GPUBindGroup + ); + public readonly createPipelineLayout = vi.fn( + (_descriptor: GPUPipelineLayoutDescriptor) => ({}) as GPUPipelineLayout + ); + public readonly createShaderModule = vi.fn( + (_descriptor: GPUShaderModuleDescriptor) => + new FakeShaderModule() as unknown as GPUShaderModule + ); + public readonly createComputePipeline = vi.fn( + (descriptor: GPUComputePipelineDescriptor) => { + const pipeline = { + entryPoint: descriptor.compute.entryPoint ?? 'main', + }; + this.createdComputeEntryPoints.push(pipeline.entryPoint); + return pipeline as unknown as GPUComputePipeline; + } + ); + public readonly createCommandEncoder = vi.fn(() => new FakeCommandEncoder(this)); + + public constructor(private readonly compactedCount: number) {} +} + +const createPipeline = (compactedCount: number) => { + installGpuConstants(); + + const device = new FakeDevice(compactedCount); + const commonState = { + bindGroupLayout: {} as GPUBindGroupLayout, + execute: vi.fn(), + } as unknown as CommonState; + + return { + device, + pipeline: new AgentGenerationPipeline( + device as unknown as GPUDevice, + commonState, + 1024 + ), + }; +}; + +describe('AgentGenerationPipeline compaction', () => { + it('copies compacted agents back with compute instead of a full agent buffer copy', async () => { + const agentCount = 10; + const { device, pipeline } = createPipeline(3); + + await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3); + + expect(device.createdComputeEntryPoints).toContain('copyCompactedAgents'); + expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual([ + 'main', + 'copyCompactedAgents', + ]); + expect(device.copyCalls.map((call) => call.size)).toEqual([ + Uint32Array.BYTES_PER_ELEMENT, + ]); + expect( + device.copyCalls.some((call) => call.size === agentCount * AGENT_SIZE_IN_BYTES) + ).toBe(false); + expect(device.queue.submit).toHaveBeenCalledTimes(1); + + pipeline.destroy(); + }); + + it('does not encode work for empty compaction requests', async () => { + const { device, pipeline } = createPipeline(0); + + await expect(pipeline.compactAgents(0)).resolves.toBe(0); + + expect(device.dispatchCalls).toEqual([]); + expect(device.copyCalls).toEqual([]); + expect(device.queue.submit).not.toHaveBeenCalled(); + + pipeline.destroy(); + }); +}); diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index 3626449..de524a7 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -26,6 +26,7 @@ export class AgentGenerationPipeline { private readonly countingPipeline: GPUComputePipeline; private readonly resizePipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline; + private readonly compactedAgentsCopyPipeline: GPUComputePipeline; public readonly agentsBuffer: GPUBuffer; private readonly compactedAgentsBuffer: GPUBuffer; @@ -109,7 +110,7 @@ export class AgentGenerationPipeline { this.compactedAgentsBuffer = this.device.createBuffer({ size: this.maxAgentCount * AGENT_SIZE_IN_BYTES, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, + usage: GPUBufferUsage.STORAGE, }); this.countersBuffer = this.device.createBuffer({ @@ -216,20 +217,32 @@ export class AgentGenerationPipeline { }, }); + const compactionModule = smartCompile( + device, + CommonState.shaderCode, + agentSchema, + compactionShader + ); + this.compactionPipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout], }), compute: { - module: smartCompile( - device, - CommonState.shaderCode, - agentSchema, - compactionShader - ), + module: compactionModule, entryPoint: 'main', }, }); + + this.compactedAgentsCopyPipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout], + }), + compute: { + module: compactionModule, + entryPoint: 'copyCompactedAgents', + }, + }); } public get maxAgentCount(): number { @@ -364,13 +377,19 @@ export class AgentGenerationPipeline { ); passEncoder.end(); - commandEncoder.copyBufferToBuffer( - this.compactedAgentsBuffer, - 0, - this.agentsBuffer, - 0, - agentCount * AGENT_SIZE_IN_BYTES + const copyPassEncoder = commandEncoder.beginComputePass(); + copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline); + this.commonState.execute(copyPassEncoder); + copyPassEncoder.setBindGroup(1, this.compactionBindGroup); + copyPassEncoder.dispatchWorkgroups( + ...getWorkgroupCounts( + this.device, + agentCount, + AgentGenerationPipeline.WORKGROUP_SIZE + ) ); + copyPassEncoder.end(); + commandEncoder.copyBufferToBuffer( this.countersBuffer, 0, diff --git a/src/pipelines/agents/agent-generation/agent-schema.test.ts b/src/pipelines/agents/agent-generation/agent-schema.test.ts index 96a419e..2fc6b38 100644 --- a/src/pipelines/agents/agent-generation/agent-schema.test.ts +++ b/src/pipelines/agents/agent-generation/agent-schema.test.ts @@ -69,6 +69,27 @@ describe('Agent TS/WGSL contract', () => { expect(agentSchema).toContain('workgroup_count.x * 64'); expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64'); expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);'); - expect(compactionShader).toContain('if id >= settings.agentCount'); + expect(compactionShader).toContain('if id < settings.agentCount'); + }); + + it('keeps compaction copy-back bounded by the compacted count', () => { + expect(compactionShader).toContain('fn copyCompactedAgents'); + expect(compactionShader).toContain( + 'workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);' + ); + expect(compactionShader).toContain('if id >= workgroupCopyCount'); + expect(compactionShader).toContain('agents[id] = compactedAgents[id];'); + }); + + it('uses workgroup-local counting before allocating global compacted ranges', () => { + expect(compactionShader).toContain( + 'var workgroupAliveCount: atomic;' + ); + expect(compactionShader).toContain( + 'localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);' + ); + expect( + compactionShader.match(/atomicAdd\(&counters\.aliveAgentCount/g) ?? [] + ).toHaveLength(1); }); }); diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 1516f7a..66726f4 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -35,7 +35,7 @@ fn main( } var agent = agents[id]; - if agent.colorIndex < 0.0 { + if agent.colorIndex < 0.0 || agent.colorIndex >= 2.5 { return; } @@ -48,12 +48,7 @@ fn main( return; } - let random = textureSampleLevel( - noise, - noiseSampler, - fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)), - 0 - ); + let random = random_vec4(id, state.time); let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0); let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle); @@ -154,7 +149,10 @@ fn get_channel_mask(colorIndex: f32) -> vec3 { if colorIndex < 1.5 { return vec3(0, 1, 0); } - return vec3(0, 0, 1); + if colorIndex < 2.5 { + return vec3(0, 0, 1); + } + return vec3(0.0, 0.0, 0.0); } fn get_reaction_mask(colorIndex: f32) -> vec3 { @@ -172,13 +170,37 @@ fn get_reaction_mask(colorIndex: f32) -> vec3 { settings.color2ToColor3 ); } - return vec3( - settings.color3ToColor1, - settings.color3ToColor2, - settings.color3ToColor3 - ); + if colorIndex < 2.5 { + return vec3( + settings.color3ToColor1, + settings.color3ToColor2, + settings.color3ToColor3 + ); + } + return vec3(0.0, 0.0, 0.0); } fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 { return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle)); } + +fn random_vec4(id: u32, time: f32) -> vec4 { + let timeSeed = u32(time * 0.34816); + let seed = id * 747796405u + timeSeed * 2891336453u; + return vec4( + random_float(seed), + random_float(seed + 1013904223u), + random_float(seed + 1664525u), + random_float(seed + 22695477u) + ); +} + +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 6c4987c..865043a 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -26,6 +26,7 @@ export class BrushPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; private readonly pipeline: GPURenderPipeline; + private readonly multiTargetPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedFloat32BufferWrite( @@ -57,68 +58,16 @@ export class BrushPipeline { usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); - this.pipeline = device.createRenderPipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), - vertex: { - module: smartCompile(device, CommonState.shaderCode, shader), - 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, - }, - ], - }, - ], - }, - fragment: { - module: smartCompile(device, CommonState.shaderCode, shader), - entryPoint: 'fragment', - targets: [ - { - format: 'rgba16float', - blend: { - color: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - alpha: { - operation: 'max', - srcFactor: 'one', - dstFactor: 'one', - }, - }, - }, - ], - }, - primitive: { - topology: 'triangle-list', - }, - }); + const shaderModule = smartCompile(device, CommonState.shaderCode, shader); + this.pipeline = this.createPipeline(shaderModule, 'fragment', 1); + this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2); this.uniforms = this.device.createBuffer({ size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.bindGroup = this.bindGroup = this.device.createBindGroup({ + this.bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ { @@ -315,23 +264,40 @@ export class BrushPipeline { return offset; } - public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) { + public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView): void { + this.executeWithPipeline(commandEncoder, this.pipeline, [trailMapOut]); + } + + public executeMultiTarget( + commandEncoder: GPUCommandEncoder, + sourceMapOut: GPUTextureView, + influenceMapOut: GPUTextureView + ): void { + this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ + sourceMapOut, + influenceMapOut, + ]); + } + + private executeWithPipeline( + commandEncoder: GPUCommandEncoder, + pipeline: GPURenderPipeline, + textureViews: Array + ): void { if (this.lineCount === 0) { return; } const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: [ - { - view: trailMapOut, - loadOp: 'load', - storeOp: 'store', - }, - ], + colorAttachments: textureViews.map((view) => ({ + view, + loadOp: 'load', + storeOp: 'store', + })), }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline(pipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); passEncoder.setVertexBuffer(0, this.vertexBuffer); @@ -344,6 +310,73 @@ export class BrushPipeline { this.uniforms.destroy(); } + private createPipeline( + shaderModule: GPUShaderModule, + fragmentEntryPoint: string, + colorTargetCount: number + ): GPURenderPipeline { + return this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + 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, + }, + ], + }, + ], + }, + fragment: { + module: shaderModule, + entryPoint: fragmentEntryPoint, + targets: Array.from( + { length: colorTargetCount }, + () => BrushPipeline.colorTarget + ), + }, + primitive: { + topology: 'triangle-list', + }, + }); + } + + private static get colorTarget(): GPUColorTargetState { + return { + format: 'rgba16float', + blend: { + color: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + alpha: { + operation: 'max', + srcFactor: 'one', + dstFactor: 'one', + }, + }, + }; + } + private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { return { entries: [ diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index 831927f..056579a 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -15,6 +15,11 @@ struct VertexOutput { @location(2) end: vec2 } +struct BrushTargets { + @location(0) source: vec4, + @location(1) influence: vec4, +} + @vertex fn vertex( @location(0) screenPosition: vec2, @@ -32,23 +37,47 @@ fn fragment( @location(1) start: vec2, @location(2) end: vec2 ) -> @location(0) vec4 { - let distance = distanceFromLine(screenPosition, start, end); - let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r; - let grainNoise = textureSample( - noise, - noiseSampler, - fract(screenPosition / 22.0 + vec2(0.31, 0.67)) - ).r; - let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0; - let feather = max(1.0, settings.brushSize * 0.22); - let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance); - let strength = edge * mix(0.45, 1.0, grainNoise); + let strength = brushStrength(screenPosition, start, end); - if(strength < 0.02) { - discard; - } + if(strength < 0.02) { + discard; + } - return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); + return brushOutput(strength); +} + +@fragment +fn fragmentMrt( + @location(0) screenPosition: vec2, + @location(1) start: vec2, + @location(2) end: vec2 +) -> BrushTargets { + let strength = brushStrength(screenPosition, start, end); + + if(strength < 0.02) { + discard; + } + + let color = brushOutput(strength); + return BrushTargets(color, color); +} + +fn brushStrength(screenPosition: vec2, start: vec2, end: vec2) -> f32 { + let distance = distanceFromLine(screenPosition, start, end); + let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r; + let grainNoise = textureSample( + noise, + noiseSampler, + fract(screenPosition / 22.0 + vec2(0.31, 0.67)) + ).r; + let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0; + let feather = max(1.0, settings.brushSize * 0.22); + let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance); + return edge * mix(0.45, 1.0, grainNoise); +} + +fn brushOutput(strength: f32) -> vec4 { + return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); } fn distanceFromLine(position: vec2, start: vec2, end: vec2) -> f32 { diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 8ae9dee..64cdea6 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -7,24 +7,28 @@ struct Settings { @group(1) @binding(0) var settings: Settings; -@group(1) @binding(1) var Sampler: sampler; @group(1) @binding(2) var trailMap: texture_2d; @fragment -fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { - var current = textureSample(trailMap, Sampler, uv); +fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { + let textureSize = vec2(textureDimensions(trailMap, 0)); + let pixel = clamp(vec2(position.xy), vec2(0), textureSize - vec2(1)); + var current = textureLoad(trailMap, pixel, 0); + let random = random_from_pixel(pixel); + let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails); + let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush); 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) + propagate(pixel, textureSize, vec2(-1, -1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(-1, 1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(1, -1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(1, 1), current, trailWeight, brushWeight) - + 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) + + propagate(pixel, textureSize, vec2(-1, 0), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(0, -1), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(1, 0), current, trailWeight, brushWeight) + + propagate(pixel, textureSize, vec2(0, 1), current, trailWeight, brushWeight) ) / 8; let decayed = clamp(vec4( @@ -36,13 +40,64 @@ fn fragment(@location(0) uv: vec2) -> @location(0) vec4 { } -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; +fn propagate( + pixel: vec2, + textureSize: vec2, + offset: vec2, + currentColor: vec4, + trailWeight: f32, + brushWeight: f32 +) -> vec4 { + let neighbour = textureLoad( + trailMap, + clamp(pixel + offset, vec2(0), textureSize - vec2(1)), + 0 + ); let difference = clamp(neighbour - currentColor, vec4(0), vec4(1)); return vec4( - vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)), - length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush) + vec3(length(neighbour.rgb) * trailWeight), + neighbour.a * brushWeight ) * 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) * 2.3283064365386963e-10; +} + +fn diffusion_weight(random: f32, inverseRate: f32) -> f32 { + let r = clamp(random, 0.0, 1.0); + let r2 = r * r; + let r4 = r2 * r2; + let r8 = r4 * r4; + + 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) + ); + } + + if inverseRate < 2.0 { + return mix(r, r2, inverseRate - 1.0); + } + + if inverseRate < 4.0 { + return mix(r2, r4, (inverseRate - 2.0) * 0.5); + } + + if inverseRate < 8.0 { + return mix(r4, r8, (inverseRate - 4.0) * 0.25); + } + + let r16 = r8 * r8; + 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.test.ts b/src/pipelines/diffusion/diffusion-pipeline.test.ts index 87c4e13..db00651 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.test.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import shader from './diffuse.wgsl?raw'; import { getSafeInverseDiffusionRate, setDiffusionUniformValues, @@ -26,4 +27,11 @@ describe('diffusion pipeline parameters', () => { expect(getSafeInverseDiffusionRate(2)).toBe(0.5); expect(getSafeInverseDiffusionRate(0.25)).toBe(4); }); + + it('keeps the diffusion shader on the low-cost trail sampling path', () => { + expect(shader).toContain('textureLoad'); + expect(shader).not.toContain('textureSample'); + expect(shader).not.toContain('pow('); + expect(shader).not.toContain('noise'); + }); }); diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 69875c4..2e35789 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -47,7 +47,6 @@ export class DiffusionPipeline { private readonly uniformCache = createCachedFloat32BufferWrite( DiffusionPipeline.UNIFORM_COUNT ); - private readonly sampler: GPUSampler; private readonly vertexBuffer: GPUBuffer; private readonly bindGroupsByInput = new WeakMap(); @@ -86,11 +85,6 @@ export class DiffusionPipeline { size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - - this.sampler = this.device.createSampler({ - magFilter: 'linear', - minFilter: 'linear', - }); } public setParameters({ @@ -155,10 +149,6 @@ export class DiffusionPipeline { buffer: this.uniforms, }, }, - { - binding: 1, - resource: this.sampler, - }, { binding: 2, resource: trailMapIn, @@ -185,13 +175,6 @@ export class DiffusionPipeline { type: 'uniform', }, }, - { - binding: 1, - visibility: GPUShaderStage.FRAGMENT, - sampler: { - type: 'filtering', - }, - }, { binding: 2, visibility: GPUShaderStage.FRAGMENT, diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index 9e2493f..1629db6 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -11,40 +11,22 @@ import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw'; import { CommonState } from '../common-state/common-state'; import shader from './eraser-agent.wgsl?raw'; -interface LineSegment { - from: vec2; - to: vec2; -} - -const shaderWithConfig = shader.replace( - 'const MAX_SEGMENT_COUNT = 384u;', - `const MAX_SEGMENT_COUNT = ${Math.round(appConfig.pipelines.eraser.maxSegmentCount)}u;` -); - export class EraserAgentPipeline { private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize; private static readonly UNIFORM_COUNT = 4; - private static readonly MAX_SEGMENT_COUNT = appConfig.pipelines.eraser.maxSegmentCount; - private static readonly SEGMENT_FLOAT_COUNT = - appConfig.pipelines.eraser.segmentFloatCount; private readonly bindGroupLayout: GPUBindGroupLayout; - private readonly bindGroup: GPUBindGroup; private readonly pipeline: GPUComputePipeline; private readonly uniforms: GPUBuffer; private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedFloat32BufferWrite( EraserAgentPipeline.UNIFORM_COUNT ); - private readonly segmentsBuffer: GPUBuffer; - private readonly segmentUploadData = new Float32Array( - EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT - ); + private readonly bindGroupsByMask = new WeakMap(); private linePoints: Array = []; - private lineSegments: Array = []; - private actualSegments: Array = []; - private segmentCount = 0; + private pendingSegmentCount = 0; + private activeSegmentCount = 0; private agentCount = 0; public constructor( @@ -71,8 +53,8 @@ export class EraserAgentPipeline { { binding: 2, visibility: GPUShaderStage.COMPUTE, - buffer: { - type: 'read-only-storage', + texture: { + sampleType: 'float', }, }, ], @@ -83,15 +65,93 @@ export class EraserAgentPipeline { usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.segmentsBuffer = this.device.createBuffer({ - size: - EraserAgentPipeline.MAX_SEGMENT_COUNT * - EraserAgentPipeline.SEGMENT_FLOAT_COUNT * - Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], + }), + compute: { + module: smartCompile(device, CommonState.shaderCode, agentSchema, shader), + entryPoint: 'main', + }, }); + } - this.bindGroup = this.device.createBindGroup({ + public addSwipe(position: vec2): void { + const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position; + this.addSwipeSegment(previousPosition, position); + this.linePoints.push(vec2.clone(position)); + } + + public addSwipeSegment(from: vec2, to: vec2): void { + void from; + void to; + this.pendingSegmentCount += 1; + } + + public clearSwipes(): void { + this.linePoints.length = 0; + this.pendingSegmentCount = 0; + this.activeSegmentCount = 0; + } + + public setParameters({ + agentCount, + eraserSize: _eraserSize, + }: { + agentCount: number; + eraserSize: number; + }): void { + void _eraserSize; + this.agentCount = agentCount; + this.activeSegmentCount = this.pendingSegmentCount; + this.pendingSegmentCount = 0; + + this.uniformValues[0] = agentCount; + this.uniformValues[1] = 0; + this.uniformValues[2] = 0; + this.uniformValues[3] = 0; + writeFloat32BufferIfChanged( + this.device, + this.uniforms, + this.uniformValues, + this.uniformCache + ); + } + + public hasActiveMask(): boolean { + return this.activeSegmentCount > 0; + } + + public execute(commandEncoder: GPUCommandEncoder, eraserMask: GPUTextureView): void { + if (!this.hasActiveMask() || this.agentCount === 0) { + return; + } + + const passEncoder = commandEncoder.beginComputePass(); + passEncoder.setPipeline(this.pipeline); + this.commonState.execute(passEncoder); + passEncoder.setBindGroup(1, this.getBindGroup(eraserMask)); + passEncoder.dispatchWorkgroups( + ...getWorkgroupCounts( + this.device, + this.agentCount, + EraserAgentPipeline.WORKGROUP_SIZE + ) + ); + passEncoder.end(); + } + + public destroy(): void { + this.uniforms.destroy(); + } + + private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup { + const cached = this.bindGroupsByMask.get(eraserMask); + if (cached) { + return cached; + } + + const bindGroup = this.device.createBindGroup({ layout: this.bindGroupLayout, entries: [ { @@ -108,137 +168,12 @@ export class EraserAgentPipeline { }, { binding: 2, - resource: { - buffer: this.segmentsBuffer, - }, + resource: eraserMask, }, ], }); - this.pipeline = device.createComputePipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), - compute: { - module: smartCompile( - device, - CommonState.shaderCode, - agentSchema, - shaderWithConfig - ), - entryPoint: 'main', - }, - }); - } - - public addSwipe(position: vec2): void { - const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position; - this.addSwipeSegment(previousPosition, position); - this.linePoints.push(vec2.clone(position)); - } - - public addSwipeSegment(from: vec2, to: vec2): void { - this.lineSegments.push({ - from: vec2.clone(from), - to: vec2.clone(to), - }); - } - - public clearSwipes(): void { - this.linePoints.length = 0; - this.lineSegments.length = 0; - this.actualSegments.length = 0; - this.segmentCount = 0; - } - - public setParameters({ - agentCount, - eraserSize, - }: { - agentCount: number; - eraserSize: number; - }): void { - this.agentCount = agentCount; - this.actualSegments = this.lineSegments.slice(); - this.lineSegments.length = 0; - - if (this.actualSegments.length > EraserAgentPipeline.MAX_SEGMENT_COUNT) { - this.actualSegments = EraserAgentPipeline.subsampleSegments(this.actualSegments); - } - - this.segmentCount = Math.max(0, this.actualSegments.length); - - const eraserRadius = eraserSize / 2; - this.uniformValues[0] = eraserRadius; - this.uniformValues[1] = this.segmentCount; - this.uniformValues[2] = agentCount; - this.uniformValues[3] = eraserRadius * eraserRadius; - writeFloat32BufferIfChanged( - this.device, - this.uniforms, - this.uniformValues, - this.uniformCache - ); - - if (this.segmentCount === 0) { - return; - } - - for (let i = 0; i < this.segmentCount; i++) { - const { from, to } = this.actualSegments[i]; - const offset = i * EraserAgentPipeline.SEGMENT_FLOAT_COUNT; - this.segmentUploadData[offset] = from[0]; - this.segmentUploadData[offset + 1] = from[1]; - this.segmentUploadData[offset + 2] = to[0]; - this.segmentUploadData[offset + 3] = to[1]; - } - - this.device.queue.writeBuffer( - this.segmentsBuffer, - 0, - this.segmentUploadData, - 0, - this.segmentCount * EraserAgentPipeline.SEGMENT_FLOAT_COUNT - ); - } - - public execute(commandEncoder: GPUCommandEncoder): void { - if (this.segmentCount === 0 || this.agentCount === 0) { - return; - } - - const passEncoder = commandEncoder.beginComputePass(); - passEncoder.setPipeline(this.pipeline); - this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, this.bindGroup); - passEncoder.dispatchWorkgroups( - ...getWorkgroupCounts( - this.device, - this.agentCount, - EraserAgentPipeline.WORKGROUP_SIZE - ) - ); - passEncoder.end(); - } - - public destroy(): void { - this.uniforms.destroy(); - this.segmentsBuffer.destroy(); - } - - private static subsampleSegments(segments: Array): Array { - if (segments.length <= EraserAgentPipeline.MAX_SEGMENT_COUNT) { - return segments; - } - - const result: Array = []; - for (let i = 0; i < EraserAgentPipeline.MAX_SEGMENT_COUNT; i++) { - const index = Math.round( - (i * (segments.length - 1)) / (EraserAgentPipeline.MAX_SEGMENT_COUNT - 1) - ); - result.push(segments[index]); - } - - return result; + this.bindGroupsByMask.set(eraserMask, bindGroup); + return bindGroup; } } diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 12048be..b928866 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -1,14 +1,12 @@ struct Settings { - eraserRadius: f32, - segmentCount: f32, agentCount: f32, - eraserRadiusSquared: f32, + padding0: f32, + padding1: f32, + padding2: f32, }; -const MAX_SEGMENT_COUNT = 384u; - @group(1) @binding(0) var settings: Settings; -@group(1) @binding(2) var segments: array>; +@group(1) @binding(2) var eraserMask: texture_2d; @compute @workgroup_size(64) fn main( @@ -26,38 +24,16 @@ fn main( return; } - for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) { - if i >= u32(settings.segmentCount) { - break; - } + let maskSize = vec2(textureDimensions(eraserMask)); + let maskPosition = clamp( + vec2(agent.position), + vec2(0, 0), + maskSize - vec2(1, 1) + ); + let maskSample = textureLoad(eraserMask, maskPosition, 0); - let segment = segments[i]; - let distanceSquared = distanceSquaredFromLine( - agent.position, - segment.xy, - segment.zw - ); - - if distanceSquared <= settings.eraserRadiusSquared { - agent.position = vec2(-1.0, -1.0); - agent.targetPosition = vec2(-1.0, -1.0); - agent.colorIndex = -1.0; - agents[id] = agent; - return; - } + if maskSample.a < 0.5 { + agent.colorIndex = -1.0; + agents[id] = agent; } } - -fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 { - let pa = position - start; - let direction = end - start; - let denominator = dot(direction, direction); - - if denominator <= 0.0001 { - return dot(pa, pa); - } - - let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0); - let nearestOffset = pa - direction * q; - return dot(nearestOffset, nearestOffset); -} diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts index c2db414..3d3ff66 100644 --- a/src/pipelines/eraser/eraser-texture-pipeline.ts +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -24,6 +24,7 @@ export class EraserTexturePipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; private readonly pipeline: GPURenderPipeline; + private readonly multiTargetPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedFloat32BufferWrite( @@ -65,49 +66,9 @@ export class EraserTexturePipeline { usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, }); - this.pipeline = device.createRenderPipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), - vertex: { - module: smartCompile(device, CommonState.shaderCode, shader), - 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, - }, - ], - }, - ], - }, - fragment: { - module: smartCompile(device, CommonState.shaderCode, shader), - entryPoint: 'fragment', - targets: [ - { - format: 'rgba16float', - }, - ], - }, - primitive: { - topology: 'triangle-list', - }, - }); + const shaderModule = smartCompile(device, CommonState.shaderCode, shader); + this.pipeline = this.createPipeline(shaderModule, 'fragment', 1); + this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 3); this.uniforms = this.device.createBuffer({ size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, @@ -194,22 +155,41 @@ export class EraserTexturePipeline { } public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void { + this.executeWithPipeline(commandEncoder, this.pipeline, [textureOut]); + } + + public executeMultiTarget( + commandEncoder: GPUCommandEncoder, + sourceMapOut: GPUTextureView, + influenceMapOut: GPUTextureView, + trailMapOut: GPUTextureView + ): void { + this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ + sourceMapOut, + influenceMapOut, + trailMapOut, + ]); + } + + private executeWithPipeline( + commandEncoder: GPUCommandEncoder, + pipeline: GPURenderPipeline, + textureViews: Array + ): void { if (this.lineCount === 0) { return; } const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: [ - { - view: textureOut, - loadOp: 'load', - storeOp: 'store', - }, - ], + colorAttachments: textureViews.map((view) => ({ + view, + loadOp: 'load', + storeOp: 'store', + })), }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline(pipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); passEncoder.setVertexBuffer(0, this.vertexBuffer); @@ -222,6 +202,54 @@ export class EraserTexturePipeline { this.uniforms.destroy(); } + private createPipeline( + shaderModule: GPUShaderModule, + fragmentEntryPoint: string, + colorTargetCount: number + ): GPURenderPipeline { + return this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + 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, + }, + ], + }, + ], + }, + fragment: { + module: shaderModule, + entryPoint: fragmentEntryPoint, + targets: Array.from({ length: colorTargetCount }, () => ({ + format: 'rgba16float' as const, + })), + }, + primitive: { + topology: 'triangle-list', + }, + }); + } + private static subsampleSegments(segments: Array): Array { if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) { return segments; diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl index c1bfe28..28297f2 100644 --- a/src/pipelines/eraser/eraser-texture.wgsl +++ b/src/pipelines/eraser/eraser-texture.wgsl @@ -14,6 +14,12 @@ struct VertexOutput { @location(2) end: vec2 } +struct EraserTextureTargets { + @location(0) source: vec4, + @location(1) influence: vec4, + @location(2) trail: vec4, +} + @vertex fn vertex( @location(0) screenPosition: vec2, @@ -31,13 +37,35 @@ fn fragment( @location(1) start: vec2, @location(2) end: vec2 ) -> @location(0) vec4 { - if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared { + if shouldDiscardEraserFragment(screenPosition, start, end) { discard; } return vec4(0.0, 0.0, 0.0, 0.0); } +@fragment +fn fragmentMrt( + @location(0) screenPosition: vec2, + @location(1) start: vec2, + @location(2) end: vec2 +) -> EraserTextureTargets { + if shouldDiscardEraserFragment(screenPosition, start, end) { + discard; + } + + let cleared = vec4(0.0, 0.0, 0.0, 0.0); + return EraserTextureTargets(cleared, cleared, cleared); +} + +fn shouldDiscardEraserFragment( + screenPosition: vec2, + start: vec2, + end: vec2 +) -> bool { + return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared; +} + fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 { let pa = position - start; let direction = end - start; diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index 21a4de4..328debc 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -121,13 +121,14 @@ export class RenderPipeline { commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView, sourceTexture: GPUTextureView - ) { + ): GPUTexture { const bindGroup = this.getBindGroup(colorTexture, sourceTexture); + const canvasTexture = this.context.getCurrentTexture(); const renderPassDescriptor: GPURenderPassDescriptor = { colorAttachments: [ { - view: this.context.getCurrentTexture().createView(), + view: canvasTexture.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store', @@ -141,6 +142,8 @@ export class RenderPipeline { passEncoder.setBindGroup(1, bindGroup); passEncoder.draw(4, 1); passEncoder.end(); + + return canvasTexture; } public executeToView( diff --git a/src/pipelines/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts index e611f17..4566ba6 100644 --- a/src/pipelines/wgsl-uniform-layout.test.ts +++ b/src/pipelines/wgsl-uniform-layout.test.ts @@ -169,7 +169,7 @@ describe('WGSL uniform layout contracts', () => { pipeline: EraserAgentPipeline, source: eraserAgentShader, structName: 'Settings', - fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'], + fieldNames: ['agentCount', 'padding0', 'padding1', 'padding2'], }); expectStructUniformLayout({ pipeline: EraserTexturePipeline, @@ -199,4 +199,10 @@ describe('WGSL uniform layout contracts', () => { getUniformCount(AgentGenerationPipeline) ); }); + + it('guards invalid high agent color indexes instead of treating them as color 3', () => { + expect(agentShader).toContain('agent.colorIndex < 0.0 || agent.colorIndex >= 2.5'); + expect(agentShader).toContain('if colorIndex < 2.5'); + expect(agentShader).toContain('return vec3(0.0, 0.0, 0.0);'); + }); }); diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index d969a2e..9bc83dc 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -5,7 +5,6 @@ export enum Severity { } export enum ErrorCode { - UNKNOWN = 'unknown', WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context', WEBGPU_UNSUPPORTED = 'webgpu-unsupported', WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable', diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts index 2a50c9e..fd78952 100644 --- a/src/utils/graphics/initialize-context.ts +++ b/src/utils/graphics/initialize-context.ts @@ -34,6 +34,7 @@ export const initializeContext = ({ context.configure({ device: device, format: gpu.getPreferredCanvasFormat(), + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, alphaMode: 'premultiplied', }); } catch (error) { diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts index 44770e7..dabd343 100644 --- a/src/utils/graphics/resizable-texture.ts +++ b/src/utils/graphics/resizable-texture.ts @@ -49,6 +49,10 @@ export class ResizableTexture { return this.textureView; } + public getTexture(): GPUTexture { + return this.texture; + } + public destroy(): void { this.texture.destroy(); this.copyPipeline.destroy(); @@ -61,7 +65,9 @@ export class ResizableTexture { usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.RENDER_ATTACHMENT, + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST, }); } } diff --git a/src/vibes.test.ts b/src/vibes.test.ts index dd8dd9f..17fd809 100644 --- a/src/vibes.test.ts +++ b/src/vibes.test.ts @@ -60,7 +60,6 @@ describe('vibe and audio config contract', () => { expect(new Set(vibeIds).size).toBe(vibeIds.length); expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true); expect(audioIds.slice().sort()).toEqual(vibeIds.slice().sort()); - expect(vibeIds).toContain(gardenAudioConfig.fallbackVibeId); }); it('keeps each vibe palette and audio profile complete', () => { @@ -97,6 +96,11 @@ describe('vibe and audio config contract', () => { }); }); + it('falls back to finite RGB channels for malformed hex colors', () => { + expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]); + expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]); + }); + it('uses discrete color interaction matrices for every vibe', () => { VIBE_PRESETS.forEach((vibe) => { colorInteractionKeys.forEach((key) => { diff --git a/src/vibes.ts b/src/vibes.ts index 085f10d..5b6bd47 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -1,17 +1,21 @@ import { appConfig, type VibePreset } from './config'; import { readBrowserStorage } from './utils/browser-storage'; -export type { GardenVibeSettings, VibePreset } from './config'; +export type { VibePreset } from './config'; export const VIBE_PRESETS: Array = appConfig.vibes.presets; +const HEX_COLOR_PATTERN = + /^#?(?[0-9a-f]{2})(?[0-9a-f]{2})(?[0-9a-f]{2})$/i; + export const hexToRgb = (hex: string): [number, number, number] => { - const value = hex.replace('#', ''); - return [ - parseInt(value.slice(0, 2), 16) / 255, - parseInt(value.slice(2, 4), 16) / 255, - parseInt(value.slice(4, 6), 16) / 255, - ]; + const match = HEX_COLOR_PATTERN.exec(hex); + if (!match?.groups) { + return [0, 0, 0]; + } + + const { red, green, blue } = match.groups; + return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255]; }; export const getInitialVibe = (): VibePreset => {