diff --git a/.gitignore b/.gitignore index f06235c..0f59a68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules dist +test-results diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index f74b88d..19f0efd 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -171,3 +171,57 @@ test('keeps audio focus outlines scoped to the active control', async ({ page }) await expect(volumeSlider).toHaveCSS('outline-style', 'solid'); await expect(volumeSlider).toHaveCSS('outline-offset', '-4px'); }); + +test('keeps the config overlay scrollable and dismissible on mobile', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 640 }); + await page.goto('/'); + + const startButton = page.getByRole('button', { name: 'Start' }); + await expect(startButton).toBeEnabled({ timeout: 30_000 }); + await startButton.click(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/, { + timeout: 30_000, + }); + + const settingsButton = page.locator('button.settings'); + await settingsButton.click(); + + const pane = page.locator('.config-pane'); + const closeButton = page.locator('.config-pane-close'); + await expect(pane).toBeVisible(); + await expect(closeButton).toBeVisible(); + + const paneMetrics = await pane.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + bottom: rect.bottom, + clientHeight: element.clientHeight, + overflowY: style.overflowY, + scrollHeight: element.scrollHeight, + top: rect.top, + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + width: rect.width, + }; + }); + + expect(paneMetrics.top).toBeGreaterThanOrEqual(0); + expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight); + expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8)); + expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight); + expect(['auto', 'scroll']).toContain(paneMetrics.overflowY); + + await pane.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect + .poll(() => pane.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + + await closeButton.click(); + await expect(pane).toBeHidden(); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); +}); diff --git a/index.html b/index.html index 9c3e01a..25ae55e 100644 --- a/index.html +++ b/index.html @@ -7,24 +7,64 @@ content="width=device-width,initial-scale=1,viewport-fit=cover" /> + + + + + + + - - - - + + + + + + + + + + + + + + - + + + + Fleeting Garden @@ -43,6 +83,7 @@ to paint coloured paths, then use the toolbar to change colours, erase, export, adjust the config overlay, restart, or open more information.

+
@@ -50,7 +91,7 @@

Fleeting Garden

- Draw coloured paths and watch them bloom into a living WebGPU garden. + Tend it while you can. The garden returns to weather either way.

@@ -85,22 +126,24 @@

Fleeting Garden

- A living sketchpad where each stroke becomes a trail that agents follow, - branch from, and weave into the scene. + A garden is what we tend; the wild is what we get the moment we look away. + Both happen here at once. Your strokes plant colour, small agents follow them, + branch off, and slowly rewrite the patch you laid down into something you + didn't quite plan.

- Paint with the three colour swatches, carve space with the eraser, and raise - the mirror control when you want radial patterns instead of a single line. + Three swatches plant the line. The eraser carves a clearing. The mirror folds + one gesture into many, like footpaths around a hidden well.

- Switch vibes to recolour the whole garden without clearing your drawing. Add - or mute the generated piano, restart for a blank canvas, or export the current - frame as an internal buffer snapshot. + Switch vibes to change the season; your shapes stay, the light moves. Add or + quiet the piano. Restart when you want a fresh field. Take a snapshot if you + want to keep one particular instant of weather.

- Built with WebGPU and running locally in your browser. Source on - GitHubschmelczer.dev.

diff --git a/package.json b/package.json index dbc6789..4a6d8f7 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.2.0", "private": true, "type": "module", - "description": "A WebGPU drawing garden where coloured paths grow into organic agent trails.", + "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.", "scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build", diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 23a137e..c61d444 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,8 +1,9 @@ { "name": "Fleeting Garden", "short_name": "Garden", - "description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.", + "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.", "start_url": "./", + "scope": "./", "display": "fullscreen", "background_color": "#10151f", "theme_color": "#10151f", diff --git a/public/og-image.jpg b/public/og-image.jpg index 98ae6a5..03c8939 100644 Binary files a/public/og-image.jpg and b/public/og-image.jpg differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..0775d05 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://schmelczer.dev/fleeting/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..d349665 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://schmelczer.dev/fleeting/ + + diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index 6aeedc4..7fb3c3b 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,4 +1,4 @@ -import { DEFAULT_AUDIO_VOLUME } from '../app-constants'; +import { DEFAULT_AUDIO_VOLUME } from '../consts'; import type { PianoNoteRole } from './garden-audio-types'; export interface GardenAudioChord { diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index 3f59886..951268b 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -38,6 +38,7 @@ export class GardenAudio { private stopPianoAt: number | null = null; private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; + private startRequestId = 0; public constructor(private readonly config: GardenAudioConfig) { this.masterVolume = clamp01(config.masterVolume); @@ -56,6 +57,14 @@ export class GardenAudio { return; } + if ( + this.lifecycle === 'started' && + this.currentVibeId === vibe.id && + this.graph.context?.state === 'running' + ) { + return; + } + const context = this.graph.ensureContext(isUserGesture); if (!context) { return; @@ -108,25 +117,49 @@ export class GardenAudio { return; } + const startRequestId = ++this.startRequestId; + void this.piano + .load(context) + .then(() => { + if (!this.canCompleteStart(context, startRequestId)) { + return; + } + + this.activateStart(vibe, context, startupRampSeconds, true); + }) + .catch((error) => { + if (this.canCompleteStart(context, startRequestId)) { + this.activateStart(vibe, context, startupRampSeconds, false); + } + ErrorHandler.addException(error, { + fallbackMessage: 'Could not load piano samples.', + severity: Severity.WARNING, + }); + }); + } + + private canCompleteStart(context: AudioContext, startRequestId: number): boolean { + return ( + this.graph.context === context && + this.lifecycle !== 'destroyed' && + !this.isMuted && + this.startRequestId === startRequestId + ); + } + + private activateStart( + vibe: VibePreset, + context: AudioContext, + startupRampSeconds: number, + cuePiano: boolean + ): void { this.lifecycle = 'started'; - this.applyVibe(vibe); - this.pianoEngine.prime(context.currentTime, getVibeProfile(vibe)); + this.currentVibeId = vibe.id; + this.graph.applyDelayProfile(); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); - const pianoLoad = this.piano.loadIfIdle(context); - if (pianoLoad) { - void pianoLoad - .then(() => { - if (this.graph.context === context && this.lifecycle !== 'destroyed') { - this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); - } - }) - .catch((error) => { - ErrorHandler.addException(error, { - fallbackMessage: 'Could not load piano samples. Using synthesized audio.', - severity: Severity.WARNING, - }); - }); + if (cuePiano) { + this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); } } diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index 3ccb46c..49f469f 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -46,13 +46,6 @@ const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): numb type GardenAudioStyleIndex = 0 | 1 | 2; -interface TouchDownRequest { - vibe: VibePreset; - now: number; - strength: number; - maniaAmount?: number; -} - interface PitchCandidate { midi: number; preference: number; @@ -152,7 +145,12 @@ export class GenerativePianoEngine { now, strength, maniaAmount = 0, - }: TouchDownRequest): void { + }: { + vibe: VibePreset; + now: number; + strength: number; + maniaAmount?: number; + }): void { const normalizedStrength = clamp01(strength); const normalizedManiaAmount = clamp01(maniaAmount); const styleIndex = this.getStyleIndex(now); diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 142fcda..a8b7110 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -21,9 +21,6 @@ const pianoSamplerTuning = { minDurationSeconds: 0.08, minFadeSeconds: 0.08, minGain: 0.0001, - synthGainScale: 0.34, - synthMaxDurationSeconds: 1.8, - synthOscillatorType: 'triangle' as OscillatorType, tailStopExtraSeconds: 0.05, voiceStealFadeSeconds: 0.025, voiceStealStopSeconds: 0.05, @@ -39,9 +36,9 @@ export class PianoSampler { private readonly graph: GardenAudioGraph ) {} - public loadIfIdle(context: BaseAudioContext): Promise | null { - if (this.loadState !== 'idle') { - return null; + public load(context: BaseAudioContext): Promise { + if (this.loadState === 'loaded') { + return Promise.resolve(); } const loadedSamples = getLoadedPianoSamples(); @@ -80,82 +77,32 @@ export class PianoSampler { return; } + const sample = this.findNearestSample(midi); + if (!sample) { + return; + } + const scheduledStart = Math.max( context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, startTime ); const noteVelocity = clamp01(velocity); - const sample = this.findNearestSample(midi); - - if (sample) { - const noteGainValue = this.computeNoteGain(noteVelocity); - const sustainSeconds = - profileSustainSeconds * - (this.config.piano.sustainBase + - noteVelocity * this.config.piano.sustainVelocityRange); - const sustainAt = - scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); - const releaseAt = sustainAt + sustainSeconds; - const stopAt = releaseAt + this.config.piano.releaseSeconds; - const source = context.createBufferSource(); - - source.buffer = sample.buffer; - source.playbackRate.setValueAtTime( - Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), - scheduledStart - ); - - this.scheduleVoice({ - source, - scheduledStart, - stopAt, - pan, - lowpassHz, - delaySend, - eventBus, - configureGainEnvelope: (gain) => { - gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); - gain.gain.exponentialRampToValueAtTime( - noteGainValue, - scheduledStart + this.config.piano.gainAttackSeconds - ); - gain.gain.setTargetAtTime( - Math.max( - pianoSamplerTuning.minGain, - noteGainValue * this.config.piano.sustainLevel - ), - sustainAt, - Math.max( - pianoSamplerTuning.minFadeSeconds, - sustainSeconds * this.config.piano.sustainBase - ) - ); - gain.gain.setTargetAtTime( - pianoSamplerTuning.minGain, - releaseAt, - this.config.piano.releaseSeconds - ); - }, - }); - return; - } - - const noteGainValue = this.computeNoteGain( - noteVelocity, - pianoSamplerTuning.synthGainScale - ); - const releaseAt = - scheduledStart + - clamp( - durationSeconds + profileSustainSeconds * 0.5, - pianoSamplerTuning.minDurationSeconds, - pianoSamplerTuning.synthMaxDurationSeconds - ); + const noteGainValue = this.computeNoteGain(noteVelocity); + const sustainSeconds = + profileSustainSeconds * + (this.config.piano.sustainBase + + noteVelocity * this.config.piano.sustainVelocityRange); + const sustainAt = + scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); + const releaseAt = sustainAt + sustainSeconds; const stopAt = releaseAt + this.config.piano.releaseSeconds; - const source = context.createOscillator(); + const source = context.createBufferSource(); - source.type = pianoSamplerTuning.synthOscillatorType; - source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart); + source.buffer = sample.buffer; + source.playbackRate.setValueAtTime( + Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), + scheduledStart + ); this.scheduleVoice({ source, @@ -171,6 +118,17 @@ export class PianoSampler { noteGainValue, scheduledStart + this.config.piano.gainAttackSeconds ); + gain.gain.setTargetAtTime( + Math.max( + pianoSamplerTuning.minGain, + noteGainValue * this.config.piano.sustainLevel + ), + sustainAt, + Math.max( + pianoSamplerTuning.minFadeSeconds, + sustainSeconds * this.config.piano.sustainBase + ) + ); gain.gain.setTargetAtTime( pianoSamplerTuning.minGain, releaseAt, @@ -312,6 +270,3 @@ export class PianoSampler { this.samples = samples.slice().sort((a, b) => a.midi - b.midi); } } - -const getMidiFrequency = (midi: number): number => - 440 * Math.pow(2, (midi - 69) / PITCH_SEMITONES_PER_OCTAVE); diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index e132697..8b3b6c4 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -1,7 +1,38 @@ import type { LoadedPianoSample } from './garden-audio-types'; +import a0SampleUrl from './samples/A0v12.m4a?url&no-inline'; +import a1SampleUrl from './samples/A1v12.m4a?url&no-inline'; +import a2SampleUrl from './samples/A2v12.m4a?url&no-inline'; +import a3SampleUrl from './samples/A3v12.m4a?url&no-inline'; +import a4SampleUrl from './samples/A4v12.m4a?url&no-inline'; +import a5SampleUrl from './samples/A5v12.m4a?url&no-inline'; +import a6SampleUrl from './samples/A6v12.m4a?url&no-inline'; +import a7SampleUrl from './samples/A7v12.m4a?url&no-inline'; +import c1SampleUrl from './samples/C1v12.m4a?url&no-inline'; +import c2SampleUrl from './samples/C2v12.m4a?url&no-inline'; +import c3SampleUrl from './samples/C3v12.m4a?url&no-inline'; +import c4SampleUrl from './samples/C4v12.m4a?url&no-inline'; +import c5SampleUrl from './samples/C5v12.m4a?url&no-inline'; +import c6SampleUrl from './samples/C6v12.m4a?url&no-inline'; +import c7SampleUrl from './samples/C7v12.m4a?url&no-inline'; +import c8SampleUrl from './samples/C8v12.m4a?url&no-inline'; +import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline'; +import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline'; +import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline'; +import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline'; +import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline'; +import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline'; +import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline'; +import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline'; +import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline'; +import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline'; +import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline'; +import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline'; +import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline'; +import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline'; interface PianoSampleDefinition { midi: number; + path: string; url: string; } @@ -10,48 +41,39 @@ export interface PianoSampleLoadProgress { totalCount: number; } -const sampleFiles: Array<[fileName: string, midi: number]> = [ - ['A0v12.m4a', 21], - ['C1v12.m4a', 24], - ['Dsharp1v12.m4a', 27], - ['Fsharp1v12.m4a', 30], - ['A1v12.m4a', 33], - ['C2v12.m4a', 36], - ['Dsharp2v12.m4a', 39], - ['Fsharp2v12.m4a', 42], - ['A2v12.m4a', 45], - ['C3v12.m4a', 48], - ['Dsharp3v12.m4a', 51], - ['Fsharp3v12.m4a', 54], - ['A3v12.m4a', 57], - ['C4v12.m4a', 60], - ['Dsharp4v12.m4a', 63], - ['Fsharp4v12.m4a', 66], - ['A4v12.m4a', 69], - ['C5v12.m4a', 72], - ['Dsharp5v12.m4a', 75], - ['Fsharp5v12.m4a', 78], - ['A5v12.m4a', 81], - ['C6v12.m4a', 84], - ['Dsharp6v12.m4a', 87], - ['Fsharp6v12.m4a', 90], - ['A6v12.m4a', 93], - ['C7v12.m4a', 96], - ['Dsharp7v12.m4a', 99], - ['Fsharp7v12.m4a', 102], - ['A7v12.m4a', 105], - ['C8v12.m4a', 108], +const pianoSampleDefinitions: Array = [ + { url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 }, + { url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 }, + { url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 }, + { url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 }, + { url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 }, + { url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 }, + { url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 }, + { url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 }, + { url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 }, + { url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 }, + { url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 }, + { url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 }, + { url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 }, + { url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 }, + { url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 }, + { url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 }, + { url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 }, + { url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 }, + { url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 }, + { url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 }, + { url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 }, + { url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 }, + { url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 }, + { url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 }, + { url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 }, + { url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 }, + { url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 }, + { url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 }, + { url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 }, + { url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 }, ]; -const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`; - -const pianoSampleDefinitions: Array = sampleFiles - .map(([fileName, midi]) => ({ - midi, - url: `${sampleBaseUrl}${fileName}`, - })) - .sort((a, b) => a.midi - b.midi); - let loadedPianoSamples: Array | null = null; let pianoSampleLoadPromise: Promise> | null = null; @@ -111,11 +133,11 @@ export const loadPianoSamples = ( } ).then( (samples) => { - loadedPianoSamples = samples - .filter((sample): sample is LoadedPianoSample => sample !== null) - .sort((a, b) => a.midi - b.midi); - if (loadedPianoSamples.length === 0) { - throw new Error('Unable to load any piano samples.'); + loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi); + if (loadedPianoSamples.length !== pianoSampleDefinitions.length) { + throw new Error( + `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.` + ); } return [...loadedPianoSamples]; }, @@ -138,7 +160,7 @@ const loadPianoSample = async ( ): Promise => { const response = await fetch(sample.url, { signal }); if (!response.ok) { - throw new Error(`Unable to load piano sample ${sample.url}`); + throw new Error(`Unable to load piano sample ${sample.path}`); } const audioData = await response.arrayBuffer(); @@ -148,17 +170,13 @@ const loadPianoSample = async ( const loadPianoSampleBatch = async ( samples: Array, - loadSample: ( - sample: PianoSampleDefinition - ) => Promise -): Promise> => { - const results: Array = []; + loadSample: (sample: PianoSampleDefinition) => Promise +): Promise> => { + const results: Array = []; for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) { const batch = samples.slice(index, index + sampleLoadTuning.concurrency); - const batchResults = await Promise.all( - batch.map((sample) => loadSample(sample).catch(() => null)) - ); + const batchResults = await Promise.all(batch.map((sample) => loadSample(sample))); results.push(...batchResults); } diff --git a/public/audio/A0v12.m4a b/src/audio/samples/A0v12.m4a similarity index 100% rename from public/audio/A0v12.m4a rename to src/audio/samples/A0v12.m4a diff --git a/public/audio/A1v12.m4a b/src/audio/samples/A1v12.m4a similarity index 100% rename from public/audio/A1v12.m4a rename to src/audio/samples/A1v12.m4a diff --git a/public/audio/A2v12.m4a b/src/audio/samples/A2v12.m4a similarity index 100% rename from public/audio/A2v12.m4a rename to src/audio/samples/A2v12.m4a diff --git a/public/audio/A3v12.m4a b/src/audio/samples/A3v12.m4a similarity index 100% rename from public/audio/A3v12.m4a rename to src/audio/samples/A3v12.m4a diff --git a/public/audio/A4v12.m4a b/src/audio/samples/A4v12.m4a similarity index 100% rename from public/audio/A4v12.m4a rename to src/audio/samples/A4v12.m4a diff --git a/public/audio/A5v12.m4a b/src/audio/samples/A5v12.m4a similarity index 100% rename from public/audio/A5v12.m4a rename to src/audio/samples/A5v12.m4a diff --git a/public/audio/A6v12.m4a b/src/audio/samples/A6v12.m4a similarity index 100% rename from public/audio/A6v12.m4a rename to src/audio/samples/A6v12.m4a diff --git a/public/audio/A7v12.m4a b/src/audio/samples/A7v12.m4a similarity index 100% rename from public/audio/A7v12.m4a rename to src/audio/samples/A7v12.m4a diff --git a/public/audio/C1v12.m4a b/src/audio/samples/C1v12.m4a similarity index 100% rename from public/audio/C1v12.m4a rename to src/audio/samples/C1v12.m4a diff --git a/public/audio/C2v12.m4a b/src/audio/samples/C2v12.m4a similarity index 100% rename from public/audio/C2v12.m4a rename to src/audio/samples/C2v12.m4a diff --git a/public/audio/C3v12.m4a b/src/audio/samples/C3v12.m4a similarity index 100% rename from public/audio/C3v12.m4a rename to src/audio/samples/C3v12.m4a diff --git a/public/audio/C4v12.m4a b/src/audio/samples/C4v12.m4a similarity index 100% rename from public/audio/C4v12.m4a rename to src/audio/samples/C4v12.m4a diff --git a/public/audio/C5v12.m4a b/src/audio/samples/C5v12.m4a similarity index 100% rename from public/audio/C5v12.m4a rename to src/audio/samples/C5v12.m4a diff --git a/public/audio/C6v12.m4a b/src/audio/samples/C6v12.m4a similarity index 100% rename from public/audio/C6v12.m4a rename to src/audio/samples/C6v12.m4a diff --git a/public/audio/C7v12.m4a b/src/audio/samples/C7v12.m4a similarity index 100% rename from public/audio/C7v12.m4a rename to src/audio/samples/C7v12.m4a diff --git a/public/audio/C8v12.m4a b/src/audio/samples/C8v12.m4a similarity index 100% rename from public/audio/C8v12.m4a rename to src/audio/samples/C8v12.m4a diff --git a/public/audio/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a similarity index 100% rename from public/audio/Dsharp1v12.m4a rename to src/audio/samples/Dsharp1v12.m4a diff --git a/public/audio/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a similarity index 100% rename from public/audio/Dsharp2v12.m4a rename to src/audio/samples/Dsharp2v12.m4a diff --git a/public/audio/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a similarity index 100% rename from public/audio/Dsharp3v12.m4a rename to src/audio/samples/Dsharp3v12.m4a diff --git a/public/audio/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a similarity index 100% rename from public/audio/Dsharp4v12.m4a rename to src/audio/samples/Dsharp4v12.m4a diff --git a/public/audio/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a similarity index 100% rename from public/audio/Dsharp5v12.m4a rename to src/audio/samples/Dsharp5v12.m4a diff --git a/public/audio/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a similarity index 100% rename from public/audio/Dsharp6v12.m4a rename to src/audio/samples/Dsharp6v12.m4a diff --git a/public/audio/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a similarity index 100% rename from public/audio/Dsharp7v12.m4a rename to src/audio/samples/Dsharp7v12.m4a diff --git a/public/audio/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a similarity index 100% rename from public/audio/Fsharp1v12.m4a rename to src/audio/samples/Fsharp1v12.m4a diff --git a/public/audio/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a similarity index 100% rename from public/audio/Fsharp2v12.m4a rename to src/audio/samples/Fsharp2v12.m4a diff --git a/public/audio/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a similarity index 100% rename from public/audio/Fsharp3v12.m4a rename to src/audio/samples/Fsharp3v12.m4a diff --git a/public/audio/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a similarity index 100% rename from public/audio/Fsharp4v12.m4a rename to src/audio/samples/Fsharp4v12.m4a diff --git a/public/audio/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a similarity index 100% rename from public/audio/Fsharp5v12.m4a rename to src/audio/samples/Fsharp5v12.m4a diff --git a/public/audio/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a similarity index 100% rename from public/audio/Fsharp6v12.m4a rename to src/audio/samples/Fsharp6v12.m4a diff --git a/public/audio/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a similarity index 100% rename from public/audio/Fsharp7v12.m4a rename to src/audio/samples/Fsharp7v12.m4a diff --git a/public/audio/README.md b/src/audio/samples/README.md similarity index 100% rename from public/audio/README.md rename to src/audio/samples/README.md diff --git a/src/config.ts b/src/config.ts index 7e8375c..e9dab15 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,9 @@ -import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './app-constants'; import { createGardenAudioConfig } from './audio/garden-audio-config'; import { defaultSettings } from './config/default-settings'; import { runtimeControls } from './config/runtime-controls'; import type { GardenAppConfig } from './config/types'; import { defaultVibeId, vibePresets } from './config/vibe-presets'; +import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts'; export type { GardenAppConfig, @@ -60,7 +60,9 @@ export const appConfig = { brushEffectFramesPerSecond: 60, clearColor: { r: 0, g: 0, b: 0, a: 0 }, initialAgentCount: 180_000, - maxDevicePixelRatio: 2, + // How long the source map continues to be diffused after a brush stroke ends. + // 600 frames at ~60 FPS ≈ 10 seconds. + sourceActiveFramesAfterWrite: 600, intro: { angleJitterRadians: Math.PI * 0.08, angleEaseEnd: 1, @@ -103,7 +105,6 @@ export const appConfig = { introMoveSpeedBaseMultiplier: 1.8, introMoveSpeedProgressMultiplier: 0.35, stroke: { - angleJitterRadians: Math.PI * 0.7, densityMultiplier: 110, maxAgentCount: 2_400, minAgentCount: 140, @@ -178,7 +179,7 @@ export const appConfig = { tuningPane: { showFpsOverlay: import.meta.env.DEV, startHidden: true, - title: 'Garden Config', + title: 'Garden Settings', }, vibes: { defaultVibeId, diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index 0ca5d70..4e3ebc6 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -19,8 +19,8 @@ export const colorInteractionControl = (label: string): NumberControlConfig => ( max: 1, step: 1, options: { - Follow: 1, - Avoid: -1, + 'Move Toward': 1, Ignore: 0, + 'Move Away': -1, }, }); diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index 77b3b36..a1ee786 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -1,6 +1,33 @@ +import { colorInteractionSettings } from './color-interactions'; +import { runtimeControls } from './runtime-controls'; import type { GardenAppConfig } from './types'; +// Mirrors the historical render-scale cap so the default render area stays +// roughly equivalent to native rendering on high-DPR phones without the +// pipeline applying its own clamp. The slider can override freely. +const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2; +const INTERNAL_RENDER_AREA_BOUNDS = { + min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5, + max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6, +}; + +const computeDefaultInternalRenderAreaMegapixels = (): number => { + const rawDpr = + typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio) + ? window.devicePixelRatio + : 1; + const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP); + const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920; + const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080; + const cssMegapixels = Math.max(cssWidth, 1) * Math.max(cssHeight, 1) / 1_000_000; + return Math.min( + INTERNAL_RENDER_AREA_BOUNDS.max, + Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels) + ); +}; + export const defaultSettings: GardenAppConfig['defaultSettings'] = { + ...colorInteractionSettings, selectedColorIndex: 0, turnWhenLost: 0.8, @@ -31,6 +58,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { brushCurveMirrorResolutionExponent: 0.5, brushCurveSegmentBrushRadiusRatio: 0.65, brushSmoothingMinSampleDistance: 0.5, + strokeAngleJitterRadians: Math.PI * 0.7, brushAlpha: 1, brushDiscardThreshold: 0.02, @@ -49,7 +77,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { adaptiveCapInitial: 1_000_000, adaptiveCapMin: 50_000, - internalRenderAreaMegapixels: 8.3, + internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(), maxAgentCount: 700_000, renderTraceNormalizationFloor: 1, diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index 4e6a429..8962456 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -2,49 +2,59 @@ import { colorInteractionControl } from './color-interactions'; import type { GardenAppConfig } from './types'; const formatPercent = (value: number): string => `${Math.round(value * 100)}%`; +const formatRadiansAsDegrees = (value: number): string => + `${Math.round((value * 180) / Math.PI)} deg`; export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { - color1ToColor1: colorInteractionControl('1 -> 1'), - color1ToColor2: colorInteractionControl('1 -> 2'), - color1ToColor3: colorInteractionControl('1 -> 3'), - color2ToColor1: colorInteractionControl('2 -> 1'), - color2ToColor2: colorInteractionControl('2 -> 2'), - color2ToColor3: colorInteractionControl('2 -> 3'), - color3ToColor1: colorInteractionControl('3 -> 1'), - color3ToColor2: colorInteractionControl('3 -> 2'), - color3ToColor3: colorInteractionControl('3 -> 3'), + color1ToColor1: colorInteractionControl('Primary Follows Primary'), + color1ToColor2: colorInteractionControl('Primary Follows Secondary'), + color1ToColor3: colorInteractionControl('Primary Follows Accent'), + color2ToColor1: colorInteractionControl('Secondary Follows Primary'), + color2ToColor2: colorInteractionControl('Secondary Follows Secondary'), + color2ToColor3: colorInteractionControl('Secondary Follows Accent'), + color3ToColor1: colorInteractionControl('Accent Follows Primary'), + color3ToColor2: colorInteractionControl('Accent Follows Secondary'), + color3ToColor3: colorInteractionControl('Accent Follows Accent'), brushSize: { folder: 'Brush', - label: 'brush size', + label: 'Brush Size', min: 1, max: 60, step: 0.25, }, spawnPerPixel: { folder: 'Brush', - label: 'agents per brush pixel', + label: 'Agent Density', min: 0.01, max: 1, step: 0.001, }, + strokeAngleJitterRadians: { + folder: 'Brush', + format: formatRadiansAsDegrees, + label: 'Spawn Spread', + min: 0, + max: Math.PI * 2, + step: 0.01, + }, sensorOffsetDistance: { folder: 'Agents', - label: 'sensor distance', + label: 'Sensor Reach', min: 0, max: 200, step: 1, }, moveSpeed: { folder: 'Agents', - label: 'move speed', + label: 'Travel Speed', min: 10, max: 500, step: 1, }, turnSpeed: { folder: 'Agents', - label: 'turn speed', + label: 'Turning Speed', min: 1, max: 200, step: 1, @@ -52,28 +62,28 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { forwardRotationScale: { folder: 'Agents', format: formatPercent, - label: 'following sensor %', + label: 'Forward Focus', min: 0, max: 1, step: 0.01, }, turnWhenLost: { folder: 'Agents', - label: 'turn when lost', + label: 'Wander Turn', min: 0, max: 6.28, step: 0.01, }, individualTrailWeight: { folder: 'Agents', - label: 'individual trail weight', + label: 'Trail Strength', min: 0, max: 1, step: 0.001, }, decayRateTrails: { folder: 'Agents', - label: 'trail decay', + label: 'Trail Fade', min: 800, max: 1000, step: 1, @@ -81,14 +91,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { clarity: { folder: 'Look', - label: 'clarity', + label: 'Sharpness', min: 0.00001, max: 1, step: 0.001, }, backgroundGrainStrength: { folder: 'Look', - label: 'grain strength', + label: 'Background Grain', min: 0, max: 0.12, step: 0.001, @@ -97,14 +107,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { maxAgentCount: { folder: 'Performance', integer: true, - label: 'max agent count', + label: 'Agent Limit', min: 0, - max: 2_000_000, step: 10_000, }, internalRenderAreaMegapixels: { folder: 'Performance', - label: 'internal resolution (MP)', + label: 'Render Quality (MP)', min: 0.5, max: 16.6, step: 0.1, diff --git a/src/config/types.ts b/src/config/types.ts index df58ec4..2429bd0 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -41,6 +41,7 @@ export type GardenRuntimeSettings = { maxAgentCount: number; selectedColorIndex: number; spawnPerPixel: number; + strokeAngleJitterRadians: number; } & AgentSettings & BrushSettings & DiffusionSettings & @@ -55,15 +56,6 @@ type GardenVibeSettings = Pick< | 'backgroundGrainStrength' | 'brushSize' | 'clarity' - | 'color1ToColor1' - | 'color1ToColor2' - | 'color1ToColor3' - | 'color2ToColor1' - | 'color2ToColor2' - | 'color2ToColor3' - | 'color3ToColor1' - | 'color3ToColor2' - | 'color3ToColor3' | 'decayRateTrails' | 'individualTrailWeight' | 'moveSpeed' @@ -144,7 +136,7 @@ export interface GardenAppConfig { brushEffectFramesPerSecond: number; clearColor: GPUColor; initialAgentCount: number; - maxDevicePixelRatio: number; + sourceActiveFramesAfterWrite: number; intro: { angleJitterRadians: number; angleEaseEnd: number; @@ -187,7 +179,6 @@ export interface GardenAppConfig { introMoveSpeedBaseMultiplier: number; introMoveSpeedProgressMultiplier: number; stroke: { - angleJitterRadians: number; densityMultiplier: number; maxAgentCount: number; minAgentCount: number; diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index f05b72b..3eab271 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -1,5 +1,4 @@ import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; -import { colorInteractionSettings } from './color-interactions'; import { VibeId, type VibePreset } from './types'; const defaultAudioSettings = { @@ -24,7 +23,6 @@ export const vibePresets: Array = [ ], backgroundColor: [16, 21, 31], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.018, brushSize: 14, clarity: 0.62, @@ -50,7 +48,6 @@ export const vibePresets: Array = [ ], backgroundColor: [23, 32, 22], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.014, brushSize: 16, clarity: 0.68, @@ -76,7 +73,6 @@ export const vibePresets: Array = [ ], backgroundColor: [15, 24, 34], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.022, brushSize: 13, clarity: 0.58, @@ -102,7 +98,6 @@ export const vibePresets: Array = [ ], backgroundColor: [20, 18, 29], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.018, brushSize: 12, clarity: 0.64, @@ -128,7 +123,6 @@ export const vibePresets: Array = [ ], backgroundColor: [25, 23, 22], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.024, brushSize: 15, clarity: 0.55, @@ -154,7 +148,6 @@ export const vibePresets: Array = [ ], backgroundColor: [16, 24, 32], settings: { - ...colorInteractionSettings, backgroundGrainStrength: 0.012, brushSize: 18, clarity: 0.7, diff --git a/src/app-constants.ts b/src/consts.ts similarity index 100% rename from src/app-constants.ts rename to src/consts.ts diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index 172cdf2..064914f 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -165,9 +165,7 @@ export class AgentPopulation { const t = count === 1 ? 1 : i / (count - 1); const x = from[0] + (to[0] - from[0]) * t; const y = from[1] + (to[1] - from[1]) * t; - const angle = - baseAngle + - (Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians; + const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians; const base = i * AGENT_FLOAT_COUNT; this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread; this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread; diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts index bf6b309..a788bfd 100644 --- a/src/game-loop/export-snapshot-renderer.ts +++ b/src/game-loop/export-snapshot-renderer.ts @@ -10,6 +10,7 @@ interface ExportSnapshotRendererOptions { getSourceSize: () => { width: number; height: number }; getColorTextureView: () => GPUTextureView; getSourceTextureView: () => GPUTextureView; + getSourceActive?: () => boolean; getVibeId: () => VibeId; } @@ -70,7 +71,8 @@ export class ExportSnapshotRenderer { commandEncoder, this.options.getColorTextureView(), this.options.getSourceTextureView(), - texture.createView() + texture.createView(), + this.options.getSourceActive?.() ?? true ); commandEncoder.copyTextureToBuffer( { texture }, diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 55650c2..183189c 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -12,6 +12,7 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { settings } from '../settings'; import { initializeContext } from '../utils/graphics/initialize-context'; import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; +import { GpuProfiler } from './gpu-profiler'; import { SimulationFrameRenderer } from './simulation-frame'; import { SimulationTextures } from './simulation-textures'; @@ -36,6 +37,7 @@ export class GameLoopResources { public readonly eraserTexturePipeline: EraserTexturePipeline; public readonly diffusionPipeline: DiffusionPipeline; public readonly renderPipeline: RenderPipeline; + public readonly gpuProfiler: GpuProfiler | null; private readonly frameRenderer: SimulationFrameRenderer; @@ -52,7 +54,6 @@ export class GameLoopResources { this.commonState = new CommonState(this.device); this.commonState.setParameters({ canvasSize, - time: 0, }); this.agentGenerationPipeline = new AgentGenerationPipeline( @@ -73,15 +74,21 @@ export class GameLoopResources { this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); this.diffusionPipeline = new DiffusionPipeline(this.device); this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); + this.gpuProfiler = GpuProfiler.create(this.device); - this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, { - agentPipeline: this.agentPipeline, - brushPipeline: this.brushPipeline, - eraserAgentPipeline: this.eraserAgentPipeline, - eraserTexturePipeline: this.eraserTexturePipeline, - diffusionPipeline: this.diffusionPipeline, - renderPipeline: this.renderPipeline, - }); + this.frameRenderer = new SimulationFrameRenderer( + this.device, + this.textures, + { + agentPipeline: this.agentPipeline, + brushPipeline: this.brushPipeline, + eraserAgentPipeline: this.eraserAgentPipeline, + eraserTexturePipeline: this.eraserTexturePipeline, + diffusionPipeline: this.diffusionPipeline, + renderPipeline: this.renderPipeline, + }, + this.gpuProfiler + ); } public resizeSimulationTo(nextSize: vec2): vec2 | null { @@ -93,6 +100,10 @@ export class GameLoopResources { this.frameRenderer.resetSourceMapActivity(); } + public get isSourceMapActive(): boolean { + return this.frameRenderer.isSourceMapActive; + } + public setFrameParameters({ time, deltaTime, @@ -107,7 +118,6 @@ export class GameLoopResources { }: FrameParameters): void { this.commonState.setParameters({ canvasSize, - time, }); this.agentPipeline.setParameters({ ...settings, @@ -130,11 +140,13 @@ export class GameLoopResources { this.diffusionPipeline.setParameters(settings); this.renderPipeline.setParameters({ ...settings, + backgroundGrainStrength: 0, channelColors, backgroundColor, }); this.eraserAgentPipeline.setParameters({ agentCount: activeAgentCount, + eraserSize: eraserPixelSize, eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold, maskSize: canvasSize, }); @@ -163,6 +175,7 @@ export class GameLoopResources { this.eraserTexturePipeline.destroy(); this.diffusionPipeline.destroy(); this.renderPipeline.destroy(); + this.gpuProfiler?.destroy(); this.commonState.destroy(); this.textures.destroy(); } diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts index 6b3ecfe..2dba0c9 100644 --- a/src/game-loop/game-loop-types.ts +++ b/src/game-loop/game-loop-types.ts @@ -3,9 +3,10 @@ import { vec2 } from 'gl-matrix'; import type { RgbColor } from '../utils/rgb-color'; export interface GardenUi { - prompt: HTMLElement; eraserPreview: HTMLElement; exportStatus: HTMLElement; + grainOverlay: HTMLElement; + prompt: HTMLElement; toolbar: HTMLElement; } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 2f731af..370cf0a 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -6,7 +6,6 @@ import { activeVibe, settings } from '../settings'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; import { AgentPopulation } from './agent-population'; -import { DevStatsOverlay } from './dev-stats-overlay'; import { EraserPreview } from './eraser-preview'; import { ExportSnapshotRenderer } from './export-snapshot-renderer'; import { FramePerformance } from './frame-performance'; @@ -14,6 +13,7 @@ import { GameLoopResources } from './game-loop-resources'; import { GardenUi } from './game-loop-types'; import { getInternalRenderSize } from './internal-render-size'; import { IntroPrompt } from './intro-prompt'; +import { PerfStatsOverlay } from './perf-stats-overlay'; import { GardenPointerInput } from './pointer-input'; import { PipelineStrokeOutput } from './stroke-output'; import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; @@ -27,7 +27,7 @@ export default class GameLoop { private readonly agentPopulation: AgentPopulation; private readonly exportSnapshotRenderer: ExportSnapshotRenderer; private readonly framePerformance = new FramePerformance(); - private devStatsOverlay: DevStatsOverlay | null = null; + private perfStatsOverlay: PerfStatsOverlay | null = null; private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seed = this.seedValue.toString(16); @@ -36,6 +36,7 @@ export default class GameLoop { private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; private previousAccentColor = ''; + private previousGrainStrength = Number.NaN; private hasFinished = false; private readonly finished = Promise.withResolvers(); @@ -43,7 +44,7 @@ export default class GameLoop { private readonly canvas: HTMLCanvasElement, private readonly device: GPUDevice, private readonly deltaTimeCalculator: DeltaTimeCalculator, - ui: GardenUi + private readonly ui: GardenUi ) { this.resize(); this.resources = new GameLoopResources( @@ -94,12 +95,13 @@ export default class GameLoop { }, getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(), getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), + getSourceActive: () => this.resources.isSourceMapActive, getVibeId: () => activeVibe.id, }); window.addEventListener('resize', this.resizeListener); this.eraserPreview.attach(); - this.syncDevStatsOverlay(); + this.syncPerfStatsOverlay(); } public attachPointerInput(): void { @@ -117,7 +119,7 @@ export default class GameLoop { public onVibeChanged(): void { this.agentPopulation.onVibeChanged(); - this.syncDevStatsOverlay(); + this.syncPerfStatsOverlay(); } public setAudioMuted(isMuted: boolean): void { @@ -152,8 +154,8 @@ export default class GameLoop { window.removeEventListener('resize', this.resizeListener); this.pointerInput.detach(); this.eraserPreview.detach(); - this.devStatsOverlay?.destroy(); - this.devStatsOverlay = null; + this.perfStatsOverlay?.destroy(); + this.perfStatsOverlay = null; this.toolbarContrastMonitor.destroy(); this.introPrompt.destroy(); await this.agentPopulation.waitForCompaction(); @@ -183,6 +185,7 @@ export default class GameLoop { const isErasing = this.pointerInput.isEraseMode; const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; this.updateAccentColor(accentColor); + this.updateGrainOverlay(settings.backgroundGrainStrength); this.audio.update({ vibe: activeVibe, isErasing, @@ -208,7 +211,7 @@ export default class GameLoop { this.pointerInput.clearSwipesIfIdle(); this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); - this.devStatsOverlay?.update({ + this.perfStatsOverlay?.update({ time, fps: this.framePerformance.measuredFps, agentCount: this.agentPopulation.activeAgentCount, @@ -220,16 +223,16 @@ export default class GameLoop { requestAnimationFrame(this.render); }; - private syncDevStatsOverlay(): void { + private syncPerfStatsOverlay(): void { if (appConfig.tuningPane.showFpsOverlay) { - this.devStatsOverlay ??= new DevStatsOverlay( + this.perfStatsOverlay ??= new PerfStatsOverlay( this.canvas.parentElement ?? document.body ); return; } - this.devStatsOverlay?.destroy(); - this.devStatsOverlay = null; + this.perfStatsOverlay?.destroy(); + this.perfStatsOverlay = null; } private updateAccentColor(color: RgbColor): void { @@ -242,12 +245,22 @@ export default class GameLoop { document.documentElement.style.setProperty('--accent-color', accentColor); } + private updateGrainOverlay(strength: number): void { + const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0; + if (Object.is(this.previousGrainStrength, safeStrength)) { + return; + } + + this.previousGrainStrength = safeStrength; + this.grainOverlay.hidden = safeStrength <= 0; + this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength)); + } + private resize(): void { const rect = this.canvas.getBoundingClientRect(); const { width, height } = getInternalRenderSize({ clientHeight: rect.height || this.canvas.clientHeight, clientWidth: rect.width || this.canvas.clientWidth, - maxPixelScale: appConfig.simulation.maxDevicePixelRatio, maxTextureDimension: this.device.limits.maxTextureDimension2D, targetAreaMegapixels: settings.internalRenderAreaMegapixels, }); @@ -318,4 +331,8 @@ export default class GameLoop { Math.max(appConfig.toolbar.mirror.min, Math.round(count)) ); } + + private get grainOverlay(): HTMLElement { + return this.ui.grainOverlay; + } } diff --git a/src/game-loop/gpu-profiler.ts b/src/game-loop/gpu-profiler.ts new file mode 100644 index 0000000..7fd0288 --- /dev/null +++ b/src/game-loop/gpu-profiler.ts @@ -0,0 +1,180 @@ +const PASS_NAMES = [ + 'brush', + 'eraserTexture', + 'eraserAgent', + 'agent', + 'trailDiffusion', + 'render', + 'sourceDiffusion', +] as const; + +export type GpuPassName = (typeof PASS_NAMES)[number]; + +interface GpuProfilerSample { + frame: number; + passes: Partial>; + totalPassMs: number; +} + +interface FleetingGardenPerf { + latest?: GpuProfilerSample; + samples: Array; +} + +interface ActivePass { + endQueryIndex: number; + name: GpuPassName; + startQueryIndex: number; +} + +interface ReadbackSlot { + buffer: GPUBuffer; + state: 'idle' | 'encoding' | 'mapping'; +} + +declare global { + interface Window { + __fleetingGardenPerf?: FleetingGardenPerf; + } +} + +const MAX_QUERY_COUNT = PASS_NAMES.length * 2; +const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT; +const READBACK_SLOT_COUNT = 4; +const MAX_SAMPLE_COUNT = 600; + +export class GpuProfiler { + private readonly querySet: GPUQuerySet; + private readonly resolveBuffer: GPUBuffer; + private readonly readbackSlots: Array; + private activePasses: Array = []; + private nextQueryIndex = 0; + private frame = 0; + + public static create(device: GPUDevice): GpuProfiler | null { + if (!device.features.has('timestamp-query')) { + return null; + } + return new GpuProfiler(device); + } + + private constructor(device: GPUDevice) { + this.querySet = device.createQuerySet({ + type: 'timestamp', + count: MAX_QUERY_COUNT, + }); + this.resolveBuffer = device.createBuffer({ + size: MAX_QUERY_COUNT * QUERY_BYTES, + usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC, + }); + this.readbackSlots = Array.from({ length: READBACK_SLOT_COUNT }, () => ({ + buffer: device.createBuffer({ + size: MAX_QUERY_COUNT * QUERY_BYTES, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }), + state: 'idle' as const, + })); + } + + public beginFrame(): void { + this.frame += 1; + this.activePasses = []; + this.nextQueryIndex = 0; + } + + public timestampWrites( + name: GpuPassName + ): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined { + if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) { + return undefined; + } + + const startQueryIndex = this.nextQueryIndex; + const endQueryIndex = this.nextQueryIndex + 1; + this.nextQueryIndex += 2; + this.activePasses.push({ + endQueryIndex, + name, + startQueryIndex, + }); + + return { + querySet: this.querySet, + beginningOfPassWriteIndex: startQueryIndex, + endOfPassWriteIndex: endQueryIndex, + }; + } + + public resolve(commandEncoder: GPUCommandEncoder): (() => void) | null { + const queryCount = this.nextQueryIndex; + if (queryCount === 0 || this.activePasses.length === 0) { + return null; + } + + const slot = this.readbackSlots.find((candidate) => candidate.state === 'idle'); + if (!slot) { + return null; + } + + const byteLength = queryCount * QUERY_BYTES; + const passes = this.activePasses.slice(); + const frame = this.frame; + slot.state = 'encoding'; + commandEncoder.resolveQuerySet(this.querySet, 0, queryCount, this.resolveBuffer, 0); + commandEncoder.copyBufferToBuffer(this.resolveBuffer, 0, slot.buffer, 0, byteLength); + + return () => { + slot.state = 'mapping'; + void slot.buffer + .mapAsync(GPUMapMode.READ, 0, byteLength) + .then(() => { + this.publishSample(frame, passes, slot.buffer.getMappedRange(0, byteLength)); + slot.buffer.unmap(); + slot.state = 'idle'; + }) + .catch(() => { + slot.state = 'idle'; + }); + }; + } + + public destroy(): void { + this.querySet.destroy(); + this.resolveBuffer.destroy(); + this.readbackSlots.forEach((slot) => { + slot.buffer.destroy(); + }); + } + + private publishSample( + frame: number, + passes: Array, + mappedRange: ArrayBuffer + ): void { + const timestamps = new BigUint64Array(mappedRange); + const sample: GpuProfilerSample = { + frame, + passes: {}, + totalPassMs: 0, + }; + + passes.forEach(({ endQueryIndex, name, startQueryIndex }) => { + const start = timestamps[startQueryIndex]; + const end = timestamps[endQueryIndex]; + if (end < start) { + return; + } + + const elapsedMs = Number(end - start) / 1_000_000; + sample.passes[name] = elapsedMs; + sample.totalPassMs += elapsedMs; + }); + + const perf = (window.__fleetingGardenPerf ??= { samples: [] }); + perf.latest = sample; + perf.samples.push(sample); + if (perf.samples.length > MAX_SAMPLE_COUNT) { + perf.samples.splice(0, perf.samples.length - MAX_SAMPLE_COUNT); + } + } +} diff --git a/src/game-loop/internal-render-size.ts b/src/game-loop/internal-render-size.ts index 841416e..5184618 100644 --- a/src/game-loop/internal-render-size.ts +++ b/src/game-loop/internal-render-size.ts @@ -3,7 +3,6 @@ const MEGAPIXEL = 1_000_000; export interface InternalRenderSizeOptions { clientHeight: number; clientWidth: number; - maxPixelScale: number; maxTextureDimension: number; targetAreaMegapixels: number; } @@ -18,15 +17,9 @@ const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): numb ? targetAreaMegapixels : 1; -const getSafeMaxPixelScale = (maxPixelScale: number): number => - Number.isFinite(maxPixelScale) && maxPixelScale > 0 - ? maxPixelScale - : Number.POSITIVE_INFINITY; - export const getInternalRenderSize = ({ clientHeight, clientWidth, - maxPixelScale, maxTextureDimension, targetAreaMegapixels, }: InternalRenderSizeOptions): InternalRenderSize => { @@ -41,7 +34,6 @@ export const getInternalRenderSize = ({ const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight)); const dimensionScale = Math.min( areaScale, - getSafeMaxPixelScale(maxPixelScale), safeMaxTextureDimension / safeClientWidth, safeMaxTextureDimension / safeClientHeight ); diff --git a/src/game-loop/dev-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts similarity index 65% rename from src/game-loop/dev-stats-overlay.ts rename to src/game-loop/perf-stats-overlay.ts index b81ad38..f241017 100644 --- a/src/game-loop/dev-stats-overlay.ts +++ b/src/game-loop/perf-stats-overlay.ts @@ -1,9 +1,9 @@ -const DEV_STATS_REFRESH_MS = 200; +const PERF_STATS_REFRESH_MS = 200; const ZERO_STAT_TEXT = '0'; const ZERO_FRAME_TIME_TEXT = '0ms'; const ZERO_RESOLUTION_TEXT = '0x0'; -interface DevStatsSnapshot { +interface PerfStatsSnapshot { time: DOMHighResTimeStamp; fps: number; agentCount: number; @@ -12,14 +12,14 @@ interface DevStatsSnapshot { renderHeight: number; } -export class DevStatsOverlay { +export class PerfStatsOverlay { private readonly element: HTMLDivElement; private previousUpdateTime = Number.NEGATIVE_INFINITY; private previousText = ''; public constructor(parent: HTMLElement) { this.element = document.createElement('div'); - this.element.className = 'dev-stats-overlay'; + this.element.className = 'perf-stats-overlay'; this.element.setAttribute('aria-hidden', 'true'); parent.append(this.element); } @@ -31,13 +31,14 @@ export class DevStatsOverlay { frameTimeMs, renderWidth, renderHeight, - }: DevStatsSnapshot): void { - if (time - this.previousUpdateTime < DEV_STATS_REFRESH_MS) { + }: PerfStatsSnapshot): void { + if (time - this.previousUpdateTime < PERF_STATS_REFRESH_MS) { return; } this.previousUpdateTime = time; - const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; + const gpuPassTimeMs = window.__fleetingGardenPerf?.latest?.totalPassMs; + const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; if (text !== this.previousText) { this.element.textContent = text; this.previousText = text; @@ -57,10 +58,14 @@ const formatAgentCount = (agentCount: number): string => ? Math.max(0, Math.round(agentCount)).toLocaleString('en-US') : ZERO_STAT_TEXT; -const formatFrameTime = (frameTimeMs: number): string => - Number.isFinite(frameTimeMs) - ? `${Math.max(0, frameTimeMs).toFixed(frameTimeMs < 10 ? 1 : 0)}ms` - : ZERO_FRAME_TIME_TEXT; +const formatFrameTime = (frameTimeMs: number | undefined): string => { + if (typeof frameTimeMs !== 'number' || !Number.isFinite(frameTimeMs)) { + return ZERO_FRAME_TIME_TEXT; + } + + const safeFrameTimeMs = Math.max(0, frameTimeMs); + return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`; +}; const formatResolution = (width: number, height: number): string => Number.isFinite(width) && Number.isFinite(height) diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index f9c068f..538ed45 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -1,10 +1,13 @@ +import { appConfig } from '../config'; import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { settings } from '../settings'; import { CanvasReadbackRequest } from './game-loop-types'; +import { GpuProfiler } from './gpu-profiler'; import { SimulationTextures } from './simulation-textures'; interface SimulationFramePipelines { @@ -17,15 +20,14 @@ interface SimulationFramePipelines { } export class SimulationFrameRenderer { - private static readonly SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600; - private sourceActiveFramesRemaining = 0; private sourceMapsCleared = true; public constructor( private readonly device: GPUDevice, private readonly textures: SimulationTextures, - private readonly pipelines: SimulationFramePipelines + private readonly pipelines: SimulationFramePipelines, + private readonly gpuProfiler: GpuProfiler | null = null ) {} public resetSourceMapActivity(): void { @@ -33,11 +35,16 @@ export class SimulationFrameRenderer { this.sourceMapsCleared = true; } + public get isSourceMapActive(): boolean { + return this.sourceActiveFramesRemaining > 0; + } + public execute( isErasing: boolean, canvasReadbackRequest?: CanvasReadbackRequest | null ): void { const commandEncoder = this.device.createCommandEncoder(); + this.gpuProfiler?.beginFrame(); this.textures.copyTrailMapAToB(commandEncoder); let wroteSourceMap = false; @@ -48,24 +55,29 @@ export class SimulationFrameRenderer { commandEncoder, eraserMask, this.textures.sourceMapA.getTextureView(), - this.textures.trailMapB.getTextureView() + this.textures.trailMapB.getTextureView(), + this.gpuProfiler?.timestampWrites('eraserTexture') + ); + this.pipelines.eraserAgentPipeline.execute( + commandEncoder, + eraserMask, + this.gpuProfiler?.timestampWrites('eraserAgent') ); - this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask); } } else { wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget( commandEncoder, - this.textures.sourceMapA.getTextureView() + this.textures.sourceMapA.getTextureView(), + this.gpuProfiler?.timestampWrites('brush') ); } if (wroteSourceMap) { - this.sourceActiveFramesRemaining = - SimulationFrameRenderer.SOURCE_ACTIVE_FRAMES_AFTER_WRITE; + this.sourceActiveFramesRemaining = getSourceActiveFrameCount(); this.sourceMapsCleared = false; } - const useSourceMap = this.sourceActiveFramesRemaining > 0; + const useSourceMap = this.isSourceMapActive; if (!useSourceMap && !this.sourceMapsCleared) { this.textures.clearSourceMaps(commandEncoder); this.sourceMapsCleared = true; @@ -74,19 +86,22 @@ export class SimulationFrameRenderer { this.pipelines.agentPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView() + this.textures.trailMapB.getTextureView(), + this.gpuProfiler?.timestampWrites('agent') ); this.pipelines.diffusionPipeline.execute( commandEncoder, this.textures.trailMapB.getTextureView(), this.textures.trailMapA.getTextureView(), - this.textures.trailMapA.getSize() + this.textures.trailMapA.getSize(), + this.gpuProfiler?.timestampWrites('trailDiffusion') ); const canvasTexture = this.pipelines.renderPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), this.textures.sourceMapA.getTextureView(), - useSourceMap + useSourceMap, + this.gpuProfiler?.timestampWrites('render') ); canvasReadbackRequest?.encode(commandEncoder, canvasTexture); @@ -95,10 +110,13 @@ export class SimulationFrameRenderer { commandEncoder, this.textures.sourceMapA.getTextureView(), this.textures.sourceMapB.getTextureView(), - this.textures.sourceMapB.getSize() + this.textures.sourceMapB.getSize(), + this.gpuProfiler?.timestampWrites('sourceDiffusion') ); } + const afterGpuProfileSubmit = this.gpuProfiler?.resolve(commandEncoder); this.device.queue.submit([commandEncoder.finish()]); + afterGpuProfileSubmit?.(); canvasReadbackRequest?.afterSubmit(); if (useSourceMap) { this.textures.swapSourceMaps(); @@ -106,3 +124,12 @@ export class SimulationFrameRenderer { } } } + +const getSourceActiveFrameCount = (): number => { + const frameCount = + settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond; + if (Number.isFinite(frameCount) && frameCount > 0) { + return Math.ceil(frameCount); + } + return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite); +}; diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index 90eb9be..4c75928 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -1,14 +1,18 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; -import { ResizableTexture } from '../utils/graphics/resizable-texture'; +import { + ResizableTexture, + type PendingTextureResize, +} from '../utils/graphics/resizable-texture'; export class SimulationTextures { public readonly trailMapA: ResizableTexture; public readonly trailMapB: ResizableTexture; + public readonly eraserMask: ResizableTexture; + // A/B are swapped each frame to ping-pong the diffusion pass. public sourceMapA: ResizableTexture; public sourceMapB: ResizableTexture; - public eraserMask: ResizableTexture; public constructor( private readonly device: GPUDevice, @@ -28,11 +32,31 @@ export class SimulationTextures { } const scale = vec2.div(vec2.create(), nextSize, previousSize); - this.trailMapA.resize(nextSize); - this.trailMapB.resize(nextSize); - this.sourceMapA.resize(nextSize); - this.sourceMapB.resize(nextSize); - this.eraserMask.resize(nextSize); + const resizes = [ + this.trailMapA, + this.trailMapB, + this.sourceMapA, + this.sourceMapB, + this.eraserMask, + ] + .map((texture): [ResizableTexture, PendingTextureResize] | null => { + const resize = texture.prepareResize(nextSize); + return resize ? [texture, resize] : null; + }) + .filter((resize): resize is [ResizableTexture, PendingTextureResize] => + Boolean(resize) + ); + + if (resizes.length > 0) { + const commandEncoder = this.device.createCommandEncoder(); + resizes.forEach(([texture, resize]) => { + texture.encodeResize(commandEncoder, resize); + }); + this.device.queue.submit([commandEncoder.finish()]); + resizes.forEach(([texture, resize]) => { + texture.commitResize(resize); + }); + } return scale; } diff --git a/src/game-loop/stroke-output.ts b/src/game-loop/stroke-output.ts index 6680525..1e9650c 100644 --- a/src/game-loop/stroke-output.ts +++ b/src/game-loop/stroke-output.ts @@ -22,7 +22,7 @@ export class PipelineStrokeOutput implements StrokeOutput { } public addEraseSegment(from: vec2, to: vec2): void { - this.eraserAgentPipeline.addSwipeSegment(); + this.eraserAgentPipeline.addSwipeSegment(from, to); this.eraserTexturePipeline.addSwipeSegment(from, to); } diff --git a/src/index.scss b/src/index.scss index c9ee7e1..d6d8b85 100644 --- a/src/index.scss +++ b/src/index.scss @@ -6,4 +6,3 @@ @use 'style/config-pane'; @use 'style/panels'; @use 'style/loading'; -@use 'style/motion'; diff --git a/src/index.ts b/src/index.ts index ee47f5b..c840a33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,356 +3,24 @@ import GameLoop from './game-loop/game-loop'; import './index.scss'; import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics'; -import { - APP_STORAGE_KEYS, - DEFAULT_AUDIO_VOLUME, - DISABLED_FLAG_VALUE, - ENABLED_FLAG_VALUE, - UNIT_INTERVAL_INPUT_MAX, - UNIT_INTERVAL_INPUT_MIN, -} from './app-constants'; import { preloadPianoSamples } from './audio/piano-samples'; +import { AudioControl } from './page/audio-control'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { ConfigPane } from './page/config-pane'; +import { EraserSizeControl } from './page/eraser-size-control'; +import { ErrorPresenter } from './page/error-presenter'; import { FullScreenHandler } from './page/full-screen-handler'; import { MenuHider } from './page/menu-hider'; -import { activeVibe, applyVibeSettings, settings } from './settings'; -import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; +import { MirrorSegmentControl } from './page/mirror-segment-control'; +import { PaletteControl } from './page/palette-control'; +import { SplashScreen } from './page/splash-screen'; +import { VibeNavigator } from './page/vibe-navigator'; +import { getMaxSupportedAgentCount } from './pipelines/agents/agent-limits'; +import { activeVibe } from './settings'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; -import { queryRequiredElement, queryRequiredElements } from './utils/dom'; -import { - ErrorHandler, - getErrorMessage, - RuntimeError, - Severity, -} from './utils/error-handler'; +import { queryRequiredElement } from './utils/dom'; +import { ErrorHandler, Severity } from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; -import { clamp01 } from './utils/math'; -import { rgbColorToCss } from './utils/rgb-color'; -import { VIBE_PRESETS } from './vibes'; - -const AUDIO_VOLUME_STEP = 0.01; - -const ERASER_CONTROL_SCALE_MAX = 1.33; -const ERASER_CONTROL_SCALE_MIN = 0.75; -const ERASER_SIZE_DEFAULT = 96; -const ERASER_SIZE_MAX = 240; -const ERASER_SIZE_MIN = 24; -const ERASER_SIZE_STEP = 1; - -const MIRROR_SEGMENT_DEFAULT = 1; -const MIRROR_SEGMENT_MAX = 12; -const MIRROR_SEGMENT_MIN = 1; -const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off'; -const MIRROR_SEGMENT_STEP = 1; -const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices'; - -const APP_SELECTORS = { - aside: 'aside', - canvas: 'canvas', - eraserPreview: '.eraser-preview', - eraserSizeControl: '.eraser-size-control', - eraserSizeSlider: '.eraser-size-slider', - errorContainer: '.errors-container', - export4k: '.export-4k', - exportStatus: '.export-status', - infoButton: 'button.info', - infoElement: '.info-page', - loadingBar: '.loading-bar', - loadingProgress: '.loading-progress', - loadingStatus: '.loading-status', - maximizeFullScreenButton: 'button.maximize-full-screen', - minimizeFullScreenButton: 'button.minimize-full-screen', - mirrorSegmentControl: '.mirror-segment-control', - mirrorSegmentSlider: '.mirror-segment-slider', - nextVibe: '.next-vibe', - previousVibe: '.previous-vibe', - prompt: '.garden-prompt', - restartButton: 'button.restart', - settingsButton: 'button.settings', - soundButton: 'button.sound', - splash: '.splash', - startButton: '.start-button', - swatches: '.color-swatch', - toolbarRow: '.toolbar-row', - volumeControl: '.volume-control', - volumeSlider: '.volume-slider', -} as const; - -const clampEraserSize = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; - return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); -}; - -const getEraserSizeRatio = (size: number): number => - (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN); - -const clampMirrorSegmentCount = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT; - return Math.min( - MIRROR_SEGMENT_MAX, - Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue)) - ); -}; - -const getMirrorSegmentRatio = (count: number): number => - (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN); - -const formatMirrorSegmentCount = (count: number): string => - count === MIRROR_SEGMENT_DEFAULT - ? MIRROR_SEGMENT_OFF_LABEL - : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`; - -const clampAudioVolume = (value: number): number => { - const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME; - return clamp01(safeValue); -}; - -const getAudioVolumePercent = (volume: number): number => - Math.round(clampAudioVolume(volume) * 100); - -const readInitialAudioVolume = (): number => { - const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume); - return storedVolume === null - ? DEFAULT_AUDIO_VOLUME - : clampAudioVolume(Number(storedVolume)); -}; - -const formatStoredAudioVolume = (volume: number): string => - clampAudioVolume(volume).toFixed(2); - -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; - message.setAttribute( - 'role', - error.severity === Severity.ERROR ? 'alert' : 'status' - ); - message.setAttribute( - 'aria-live', - error.severity === Severity.ERROR - ? 'assertive' - : 'polite' - ); - container.append(message); - - if (error.severity === Severity.ERROR) { - message.tabIndex = -1; - message.focus({ preventScroll: true }); - } -}; - -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(APP_SELECTORS.errorContainer); - 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(APP_SELECTORS.aside, HTMLElement), - toolbarRow: queryRequiredElement(APP_SELECTORS.toolbarRow, HTMLElement), - infoButton: queryRequiredElement(APP_SELECTORS.infoButton, HTMLButtonElement), - infoElement: queryRequiredElement(APP_SELECTORS.infoElement, HTMLElement), - minimizeFullScreenButton: queryRequiredElement( - APP_SELECTORS.minimizeFullScreenButton, - HTMLButtonElement - ), - maximizeFullScreenButton: queryRequiredElement( - APP_SELECTORS.maximizeFullScreenButton, - HTMLButtonElement - ), - settingsButton: queryRequiredElement(APP_SELECTORS.settingsButton, HTMLButtonElement), - soundButton: queryRequiredElement(APP_SELECTORS.soundButton, HTMLButtonElement), - volumeControl: queryRequiredElement(APP_SELECTORS.volumeControl, HTMLLabelElement), - volumeSlider: queryRequiredElement(APP_SELECTORS.volumeSlider, HTMLInputElement), - restartButton: queryRequiredElement(APP_SELECTORS.restartButton, HTMLButtonElement), - canvas: queryRequiredElement(APP_SELECTORS.canvas, HTMLCanvasElement), - eraserPreview: queryRequiredElement(APP_SELECTORS.eraserPreview, HTMLDivElement), - errorContainer: queryRequiredElement(APP_SELECTORS.errorContainer, HTMLElement), - previousVibe: queryRequiredElement(APP_SELECTORS.previousVibe, HTMLButtonElement), - nextVibe: queryRequiredElement(APP_SELECTORS.nextVibe, HTMLButtonElement), - swatches: queryRequiredElements(APP_SELECTORS.swatches, HTMLButtonElement), - eraserSizeControl: queryRequiredElement( - APP_SELECTORS.eraserSizeControl, - HTMLLabelElement - ), - eraserSizeSlider: queryRequiredElement( - APP_SELECTORS.eraserSizeSlider, - HTMLInputElement - ), - mirrorSegmentControl: queryRequiredElement( - APP_SELECTORS.mirrorSegmentControl, - HTMLLabelElement - ), - mirrorSegmentSlider: queryRequiredElement( - APP_SELECTORS.mirrorSegmentSlider, - HTMLInputElement - ), - export4k: queryRequiredElement(APP_SELECTORS.export4k, HTMLButtonElement), - exportStatus: queryRequiredElement(APP_SELECTORS.exportStatus, HTMLSpanElement), - prompt: queryRequiredElement(APP_SELECTORS.prompt, HTMLDivElement), - loadingStatus: queryRequiredElement(APP_SELECTORS.loadingStatus, HTMLDivElement), - loadingProgress: queryRequiredElement(APP_SELECTORS.loadingProgress, HTMLDivElement), - splash: queryRequiredElement(APP_SELECTORS.splash, HTMLDivElement), - loadingBar: queryRequiredElement(APP_SELECTORS.loadingBar, HTMLDivElement), - startButton: queryRequiredElement(APP_SELECTORS.startButton, HTMLButtonElement), -}); - -type AppElements = ReturnType; - -let elements: AppElements; - -const setLoadingStage = (label: string, ratio: number) => { - const percent = Math.round(clamp01(ratio) * 100); - elements.loadingStatus.textContent = label; - elements.loadingProgress.style.setProperty( - '--loading-progress', - `${percent}%` - ); - elements.loadingProgress.setAttribute('aria-valuenow', String(percent)); -}; - -let audioVolume = readInitialAudioVolume(); -let isAudioMuted = - readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE || - audioVolume <= 0; -let isEraserActive = false; - -const persistAudioUiState = () => { - writeBrowserStorage( - APP_STORAGE_KEYS.audioMuted, - isAudioMuted ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE - ); - writeBrowserStorage(APP_STORAGE_KEYS.audioVolume, formatStoredAudioVolume(audioVolume)); -}; - -const renderAudioUi = (game: GameLoop | null) => { - audioVolume = clampAudioVolume(audioVolume); - const isEffectivelyMuted = isAudioMuted || audioVolume <= 0; - const volumePercent = getAudioVolumePercent(audioVolume); - - elements.soundButton.classList.toggle('muted', isEffectivelyMuted); - elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted)); - elements.soundButton.setAttribute( - 'aria-label', - isEffectivelyMuted ? 'Unmute audio' : 'Mute audio' - ); - elements.soundButton.title = isEffectivelyMuted - ? 'Unmute audio' - : 'Mute audio'; - - elements.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; - elements.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; - elements.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); - elements.volumeSlider.value = formatStoredAudioVolume(audioVolume); - elements.volumeSlider.setAttribute( - 'aria-valuetext', - isEffectivelyMuted - ? `${'Muted'}, ${volumePercent}%` - : `${volumePercent}%` - ); - elements.volumeControl.classList.toggle('muted', isEffectivelyMuted); - elements.volumeControl.title = isEffectivelyMuted - ? `${'Muted'}, ${volumePercent}% ${'volume'}` - : `${volumePercent}% ${'volume'}`; - elements.volumeControl.style.setProperty( - '--volume-progress', - `${volumePercent}%` - ); - - game?.setAudioVolume(audioVolume); - game?.setAudioMuted(isEffectivelyMuted); -}; - -const renderPaletteUi = (game: GameLoop | null) => { - elements.swatches.forEach((swatch, index) => { - swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); - swatch.classList.toggle( - 'active', - settings.selectedColorIndex === index && !isEraserActive - ); - }); - elements.eraserSizeControl.classList.toggle('active', isEraserActive); - game?.setEraseMode(isEraserActive); - document.documentElement.style.setProperty( - '--garden-background', - rgbColorToCss(activeVibe.backgroundColor) - ); -}; - -const renderEraserSizeUi = (game: GameLoop | null) => { - const size = clampEraserSize(settings.eraserSize); - if (settings.eraserSize !== size) { - settings.eraserSize = size; - } - - elements.eraserSizeSlider.min = ERASER_SIZE_MIN.toString(); - elements.eraserSizeSlider.max = ERASER_SIZE_MAX.toString(); - elements.eraserSizeSlider.step = ERASER_SIZE_STEP.toString(); - elements.eraserSizeSlider.value = size.toString(); - elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`); - - const ratio = getEraserSizeRatio(size); - const scale = - ERASER_CONTROL_SCALE_MIN + - (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; - elements.eraserSizeControl.style.setProperty( - '--eraser-progress', - `${ratio * 100}%` - ); - elements.eraserSizeControl.style.setProperty( - '--eraser-control-scale', - scale.toFixed(3) - ); - game?.updateEraserPreview(); -}; - -const renderMirrorSegmentUi = () => { - const count = clampMirrorSegmentCount(settings.mirrorSegmentCount); - if (settings.mirrorSegmentCount !== count) { - settings.mirrorSegmentCount = count; - } - - elements.mirrorSegmentSlider.min = MIRROR_SEGMENT_MIN.toString(); - elements.mirrorSegmentSlider.max = MIRROR_SEGMENT_MAX.toString(); - elements.mirrorSegmentSlider.step = MIRROR_SEGMENT_STEP.toString(); - elements.mirrorSegmentSlider.value = count.toString(); - - const label = formatMirrorSegmentCount(count); - const ratio = getMirrorSegmentRatio(count); - elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label); - elements.mirrorSegmentControl.title = label; - elements.mirrorSegmentControl.classList.toggle('active', count > 1); - elements.mirrorSegmentControl.style.setProperty( - '--mirror-progress', - `${ratio * 100}%` - ); - elements.mirrorSegmentControl.style.setProperty( - '--mirror-angle', - `${(360 / count).toFixed(3)}deg` - ); -}; const main = async () => { let hasRuntimeErrorListener = false; @@ -363,14 +31,13 @@ const main = async () => { let hasStarted = false; let game: GameLoop | null = null; let configPane: ConfigPane | null = null; + const getGame = () => game; - elements = queryAppElements(); - elements.errorContainer.setAttribute( - 'aria-live', - 'assertive' + const errorPresenter = new ErrorPresenter( + queryRequiredElement('.errors-container', HTMLElement) ); ErrorHandler.addOnErrorListener((error) => { - renderRuntimeMessage(elements.errorContainer, error); + errorPresenter.render(error); if (error.severity === Severity.ERROR) { document.body.classList.remove('is-loading'); game?.destroy(); @@ -379,174 +46,100 @@ const main = async () => { }); hasRuntimeErrorListener = true; - const syncRuntimeUi = (activeGame = game) => { - renderEraserSizeUi(game); - renderMirrorSegmentUi(); - renderPaletteUi(activeGame); + const aside = queryRequiredElement('aside', HTMLElement); + const canvas = queryRequiredElement('canvas', HTMLCanvasElement); + const toolbarRow = queryRequiredElement('.toolbar-row', HTMLElement); + const eraserPreview = queryRequiredElement('.eraser-preview', HTMLDivElement); + const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement); + const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement); + const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement); + const settingsButton = queryRequiredElement('button.settings', HTMLButtonElement); + const restartButton = queryRequiredElement('button.restart', HTMLButtonElement); + const infoButton = queryRequiredElement('button.info', HTMLButtonElement); + const infoElement = queryRequiredElement('.info-page', HTMLElement); + const minimizeFullScreenButton = queryRequiredElement( + 'button.minimize-full-screen', + HTMLButtonElement + ); + const maximizeFullScreenButton = queryRequiredElement( + 'button.maximize-full-screen', + HTMLButtonElement + ); + const export4kButton = queryRequiredElement('.export-4k', HTMLButtonElement); + + const splash = new SplashScreen(); + const paletteControl = new PaletteControl({ + getGame, + onChange: () => configPane?.refresh(), + }); + const eraserSizeControl = new EraserSizeControl({ + getGame, + onActivate: () => paletteControl.setEraserActive(true), + onChange: () => configPane?.refresh(), + }); + const mirrorSegmentControl = new MirrorSegmentControl({ + onChange: () => { + paletteControl.setEraserActive(false); + configPane?.refresh(); + }, + }); + const audioControl = new AudioControl({ + getGame, + hasStarted: () => hasStarted, + startButton: splash.startButton, + }); + + const syncRuntimeUi = () => { + eraserSizeControl.render(); + mirrorSegmentControl.render(); + paletteControl.render(); }; - const infoPageHandler = new CollapsiblePanelAnimator( - elements.infoButton, - elements.infoElement, - elements.aside - ); - + const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside); new MenuHider( - elements.aside, + aside, () => FullScreenHandler.isInFullScreenMode() && !configPane?.isOpen && !infoPageHandler.isOpen ); new FullScreenHandler( - elements.minimizeFullScreenButton, - elements.maximizeFullScreenButton, + minimizeFullScreenButton, + maximizeFullScreenButton, document.documentElement ); - const startAudioFromUserGesture = (event: Event) => { - if ( - !hasStarted || - isAudioMuted || - (event.target instanceof Node && elements.startButton.contains(event.target)) || - (event.target instanceof Node && elements.soundButton.contains(event.target)) - ) { - return; - } - - game?.startAudio(true); - }; - - window.addEventListener('touchstart', startAudioFromUserGesture, { - capture: true, - passive: true, - }); - window.addEventListener('pointerdown', startAudioFromUserGesture, { - capture: true, - passive: 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', () => { - const shouldUnmute = isAudioMuted || audioVolume <= 0; - if (shouldUnmute && audioVolume <= 0) { - audioVolume = DEFAULT_AUDIO_VOLUME; - } - isAudioMuted = !shouldUnmute; - persistAudioUiState(); - renderAudioUi(game); - if (!isAudioMuted) { - game?.startAudio(true); - } - }); - elements.volumeSlider.addEventListener('input', () => { - audioVolume = clampAudioVolume(Number(elements.volumeSlider.value)); - isAudioMuted = audioVolume <= 0; - persistAudioUiState(); - renderAudioUi(game); - if (!isAudioMuted) { - game?.startAudio(true); - } - }); - - const selectRelativeVibe = (offset: number, source: string) => { - const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); - const vibe = - VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; - const activePreset = applyVibeSettings(vibe); - trackVibeChange({ - vibeId: activePreset.id, - vibeName: activePreset.name, - source, - }); - game?.onVibeChanged(); - syncRuntimeUi(); - configPane?.refresh(); - game?.playVibeChangeAudio(true); - }; - - elements.previousVibe.addEventListener('click', () => - selectRelativeVibe(-1, 'previous-button') - ); - - elements.nextVibe.addEventListener('click', () => - selectRelativeVibe(1, 'next-button') - ); - - elements.swatches.forEach((swatch, index) => { - swatch.addEventListener('click', () => { - settings.selectedColorIndex = index; - isEraserActive = false; - renderPaletteUi(game); + new VibeNavigator({ + onChange: ({ vibeId, vibeName, source }) => { + trackVibeChange({ vibeId, vibeName, source }); + game?.onVibeChanged(); + syncRuntimeUi(); configPane?.refresh(); - }); + game?.playVibeChangeAudio(true); + }, }); - const activateEraser = () => { - isEraserActive = true; - renderPaletteUi(game); - }; + restartButton.addEventListener('click', () => game?.destroy()); - elements.eraserSizeControl.addEventListener('pointerdown', activateEraser); - elements.eraserSizeControl.addEventListener('click', activateEraser); - elements.eraserSizeSlider.addEventListener('focus', activateEraser); - - elements.eraserSizeSlider.addEventListener('input', () => { - settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value)); - isEraserActive = true; - renderEraserSizeUi(game); - renderPaletteUi(game); - configPane?.refresh(); - }); - - elements.mirrorSegmentSlider.addEventListener('input', () => { - settings.mirrorSegmentCount = clampMirrorSegmentCount( - Number(elements.mirrorSegmentSlider.value) - ); - isEraserActive = false; - renderMirrorSegmentUi(); - renderPaletteUi(game); - configPane?.refresh(); - }); - - elements.export4k.addEventListener('click', async () => { - if (!game || elements.export4k.disabled) { + export4kButton.addEventListener('click', async () => { + if (!game || export4kButton.disabled) { return; } - elements.export4k.disabled = true; + export4kButton.disabled = true; try { await game.exportSnapshot(); trackExport({ vibeId: activeVibe.id }); } catch (error) { ErrorHandler.addException(error, { severity: Severity.WARNING }); } finally { - elements.export4k.disabled = false; + export4kButton.disabled = false; } }); - renderPaletteUi(game); - renderEraserSizeUi(game); - renderMirrorSegmentUi(); - renderAudioUi(game); - - // Loading runs in the background while the splash (title + description + - // Start button) is shown. The Start tap is the user gesture that unlocks - // the AudioContext on iOS, and gates the intro. + // Samples load before Start is enabled so the first audible piano note + // always uses the sampler. The Start tap still unlocks the AudioContext. + splash.showLoadingBar(); const fontsReady = document.fonts.ready.catch((error) => { ErrorHandler.addException(error, { fallbackMessage: 'Could not load fonts.', @@ -555,17 +148,18 @@ const main = async () => { }); const gpuPromise = initializeGpu(); - let isPreloadComplete = false; const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => { const ratio = totalCount > 0 ? loadedCount / totalCount : 0; - setLoadingStage(`Loading piano samples ${loadedCount}/${totalCount}…`, ratio); + splash.setLoadingStage( + `Loading piano samples ${loadedCount}/${totalCount}…`, + ratio + ); }).then( () => { - isPreloadComplete = true; - setLoadingStage('Ready', 1); + splash.setLoadingStage('Ready', 1); }, (error: unknown) => { - isPreloadComplete = true; + splash.setLoadingStage('Piano unavailable', 1); ErrorHandler.addException(error, { fallbackMessage: 'Could not preload piano samples.', severity: Severity.WARNING, @@ -575,7 +169,8 @@ const main = async () => { const gpu = await gpuPromise; configPane = new ConfigPane({ - settingsButton: elements.settingsButton, + maxSupportedAgentCount: getMaxSupportedAgentCount(gpu), + settingsButton, onConfigChange: () => { game?.onVibeChanged(); syncRuntimeUi(); @@ -584,59 +179,36 @@ const main = async () => { }); infoPageHandler.onOpen = configPane.close.bind(configPane); await fontsReady; + await preloadPromise; + splash.hideLoadingBar(); 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, + game = new GameLoop(canvas, gpu, deltaTimeCalculator, { + toolbar: toolbarRow, + prompt: promptElement, + eraserPreview, + grainOverlay, + exportStatus, }); - renderPaletteUi(game); - renderEraserSizeUi(game); - renderMirrorSegmentUi(); - renderAudioUi(game); + syncRuntimeUi(); + audioControl.render(); if (isFirstStart) { isFirstStart = false; // Splash is in the DOM by default; enable the button now that the // audio system (GameLoop) is constructed and ready to be unlocked. - elements.startButton.disabled = false; - await new Promise((resolve) => { - const onClick = () => { - elements.startButton.removeEventListener('click', onClick); - hasStarted = true; - game?.startAudio(true); - trackStart(); - elements.splash.hidden = true; - resolve(); - }; - elements.startButton.addEventListener('click', onClick); + await splash.awaitStart(() => { + hasStarted = true; + game?.startAudio(true); + trackStart(); }); - if (!isPreloadComplete) { - elements.loadingBar.hidden = false; - void preloadPromise.finally(() => { - elements.loadingBar.hidden = true; - }); - } - - // Keep the dev stats overlay hidden until the user actually starts drawing. - document.body.classList.add('pre-drawing'); - elements.canvas.addEventListener( - 'pointerdown', - () => document.body.classList.remove('pre-drawing'), - { once: true } - ); - requestAnimationFrame(() => - requestAnimationFrame(() => - document.body.classList.remove('is-loading') - ) + requestAnimationFrame(() => document.body.classList.remove('is-loading')) ); } game.attachPointerInput(); @@ -647,7 +219,7 @@ const main = async () => { if (hasRuntimeErrorListener) { ErrorHandler.addException(e); } else { - renderStartupException(e); + ErrorPresenter.renderStartup(e); ErrorHandler.addException(e); } console.error(e); diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts new file mode 100644 index 0000000..278194f --- /dev/null +++ b/src/page/audio-control.ts @@ -0,0 +1,158 @@ +import { + APP_STORAGE_KEYS, + DEFAULT_AUDIO_VOLUME, + DISABLED_FLAG_VALUE, + ENABLED_FLAG_VALUE, + UNIT_INTERVAL_INPUT_MAX, + UNIT_INTERVAL_INPUT_MIN, +} from '../consts'; +import type GameLoop from '../game-loop/game-loop'; +import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage'; +import { queryRequiredElement } from '../utils/dom'; +import { clamp01 } from '../utils/math'; + +const AUDIO_VOLUME_STEP = 0.01; + +const clampAudioVolume = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME; + return clamp01(safeValue); +}; + +const readInitialAudioVolume = (): number => { + const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume); + return storedVolume === null + ? DEFAULT_AUDIO_VOLUME + : clampAudioVolume(Number(storedVolume)); +}; + +const formatStoredAudioVolume = (volume: number): string => + clampAudioVolume(volume).toFixed(2); + +interface AudioControlOptions { + getGame: () => GameLoop | null; + hasStarted: () => boolean; + startButton: HTMLElement; +} + +export class AudioControl { + private readonly soundButton = queryRequiredElement( + 'button.sound', + HTMLButtonElement + ); + private readonly volumeControl = queryRequiredElement( + '.volume-control', + HTMLLabelElement + ); + private readonly volumeSlider = queryRequiredElement( + '.volume-slider', + HTMLInputElement + ); + + private audioVolume = readInitialAudioVolume(); + private isMutedState = + readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE || + this.audioVolume <= 0; + + public constructor(private readonly options: AudioControlOptions) { + this.soundButton.addEventListener('click', this.onToggleMute); + this.volumeSlider.addEventListener('input', this.onVolumeInput); + + const passiveCaptureOptions = { capture: true, passive: true } as const; + const captureOptions = { capture: true } as const; + ( + [ + ['touchstart', passiveCaptureOptions], + ['pointerdown', passiveCaptureOptions], + ['touchend', passiveCaptureOptions], + ['pointerup', passiveCaptureOptions], + ['click', captureOptions], + ['keydown', captureOptions], + ] satisfies Array<[keyof WindowEventMap, AddEventListenerOptions]> + ).forEach(([event, opts]) => { + window.addEventListener(event, this.onUserGesture, opts); + }); + + this.render(); + } + + public get isMuted(): boolean { + return this.isMutedState || this.audioVolume <= 0; + } + + public render(): void { + this.audioVolume = clampAudioVolume(this.audioVolume); + const isEffectivelyMuted = this.isMuted; + const volumePercent = Math.round(this.audioVolume * 100); + + this.soundButton.classList.toggle('muted', isEffectivelyMuted); + this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted)); + const muteLabel = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'; + this.soundButton.setAttribute('aria-label', muteLabel); + this.soundButton.title = muteLabel; + + this.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; + this.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; + this.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); + this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume); + this.volumeSlider.setAttribute( + 'aria-valuetext', + isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%` + ); + this.volumeControl.classList.toggle('muted', isEffectivelyMuted); + this.volumeControl.title = isEffectivelyMuted + ? `Muted, ${volumePercent}% volume` + : `${volumePercent}% volume`; + this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`); + + const game = this.options.getGame(); + game?.setAudioVolume(this.audioVolume); + game?.setAudioMuted(isEffectivelyMuted); + } + + private readonly onToggleMute = () => { + const shouldUnmute = this.isMutedState || this.audioVolume <= 0; + if (shouldUnmute && this.audioVolume <= 0) { + this.audioVolume = DEFAULT_AUDIO_VOLUME; + } + this.isMutedState = !shouldUnmute; + this.persist(); + this.render(); + if (!this.isMutedState) { + this.options.getGame()?.startAudio(true); + } + }; + + private readonly onVolumeInput = () => { + this.audioVolume = clampAudioVolume(Number(this.volumeSlider.value)); + this.isMutedState = this.audioVolume <= 0; + this.persist(); + this.render(); + if (!this.isMutedState) { + this.options.getGame()?.startAudio(true); + } + }; + + private readonly onUserGesture = (event: Event) => { + if ( + !this.options.hasStarted() || + this.isMutedState || + (event.target instanceof Node && + this.options.startButton.contains(event.target)) || + (event.target instanceof Node && this.soundButton.contains(event.target)) + ) { + return; + } + this.options.getGame()?.startAudio(true); + }; + + private persist(): void { + writeBrowserStorage( + APP_STORAGE_KEYS.audioMuted, + this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE + ); + writeBrowserStorage( + APP_STORAGE_KEYS.audioVolume, + formatStoredAudioVolume(this.audioVolume) + ); + } +} diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index b4917a8..ba3f38c 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -28,11 +28,11 @@ interface PaneState extends GardenAudioVibeSettings { color3: string; } -const COLOR_REACTION_LABELS = ['1', '2', '3'] as const; +const COLOR_REACTION_LABELS = ['Primary', 'Secondary', 'Accent'] as const; const COLOR_REACTION_STATES = [ - { id: 'follow', label: 'Follow', value: 1 }, + { id: 'follow', label: 'Move Toward', value: 1 }, { id: 'ignore', label: 'Ignore', value: 0 }, - { id: 'avoid', label: 'Avoid', value: -1 }, + { id: 'avoid', label: 'Move Away', value: -1 }, ] as const; const colorReactionRows = [ @@ -56,6 +56,7 @@ const colorReactionRows = [ const brushControlKeys = [ 'brushSize', 'spawnPerPixel', + 'strokeAngleJitterRadians', ] satisfies Array; const agentControlKeys = [ @@ -85,16 +86,17 @@ const MUSIC_CONTROLS: ReadonlyArray<{ max: number; step: number; }> = [ - { key: 'idleIntensity', label: 'idle intensity', min: 0, max: 1, step: 0.01 }, - { key: 'bpm', label: 'bpm', min: 48, max: 150, step: 1 }, - { key: 'rampUpIntensity', label: 'ramp up intensity', min: 0, max: 2, step: 0.01 }, - { key: 'rampUpTime', label: 'ramp up time', min: 0.01, max: 0.4, step: 0.01 }, - { key: 'noteLength', label: 'note length', min: 0.1, max: 1.8, step: 0.01 }, - { key: 'notePitchOffset', label: 'higher / lower notes', min: -12, max: 12, step: 1 }, - { key: 'brightness', label: 'brightness', min: 0.5, max: 1.5, step: 0.01 }, + { key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 }, + { key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 }, + { key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 }, + { key: 'rampUpTime', label: 'Response Time', min: 0.01, max: 0.4, step: 0.01 }, + { key: 'noteLength', label: 'Note Length', min: 0.1, max: 1.8, step: 0.01 }, + { key: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 }, + { key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 }, ]; interface ConfigPaneOptions { + maxSupportedAgentCount: number; onConfigChange: () => void; onRuntimeChange: () => void; settingsButton: HTMLButtonElement; @@ -152,6 +154,7 @@ const getNextColorReactionState = ( export class ConfigPane { private readonly container: HTMLDivElement; + private readonly closeButton: HTMLButtonElement; private readonly pane: Pane; private readonly colorReactionButtons = new Map< ColorReactionKey, @@ -176,17 +179,15 @@ export class ConfigPane { public constructor(private readonly options: ConfigPaneOptions) { this.container = document.createElement('div'); this.container.className = 'config-pane-container'; - Object.assign(this.container.style, { - boxSizing: 'border-box', - maxHeight: 'calc(100vh - 24px)', - pointerEvents: 'none', - position: 'fixed', - right: 'max(12px, env(safe-area-inset-right, 0px))', - top: 'max(12px, env(safe-area-inset-top, 0px))', - width: - 'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))', - zIndex: '20', - }); + + this.closeButton = document.createElement('button'); + this.closeButton.className = 'config-pane-close'; + this.closeButton.type = 'button'; + this.closeButton.setAttribute('aria-label', 'Hide config overlay'); + this.closeButton.title = 'Hide config overlay'; + this.closeButton.addEventListener('click', () => this.setHidden(true)); + this.container.appendChild(this.closeButton); + document.body.appendChild(this.container); this.pane = new Pane({ @@ -196,13 +197,14 @@ export class ConfigPane { }); this.pane.hidden = appConfig.tuningPane.startHidden; this.pane.element.classList.add('config-pane'); - this.pane.element.style.boxSizing = 'border-box'; - this.pane.element.style.maxHeight = 'calc(100vh - 24px)'; - this.pane.element.style.overflowY = 'auto'; - this.pane.element.style.pointerEvents = 'auto'; - this.pane.element.style.width = '100%'; + this.pane.element.id = 'config-pane'; + this.options.settingsButton.setAttribute('aria-controls', this.pane.element.id); this.options.settingsButton.addEventListener('click', this.toggle); + document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, { + passive: true, + }); + document.addEventListener('keydown', this.dismissOnEscape); this.setUpTuningPane(this.pane); this.syncOpenState(); @@ -228,6 +230,27 @@ export class ConfigPane { this.syncOpenState(); }; + private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => { + if (!this.isOpen || !(event.target instanceof Node)) { + return; + } + + if ( + this.container.contains(event.target) || + this.options.settingsButton.contains(event.target) + ) { + return; + } + + this.setHidden(true); + }; + + private readonly dismissOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.isOpen) { + this.setHidden(true); + } + }; + private setHidden(isHidden: boolean): void { this.pane.hidden = isHidden; this.syncOpenState(); @@ -252,26 +275,26 @@ export class ConfigPane { private setUpVibeSection(container: PaneContainer): void { const folder = container.addFolder({ - title: 'Vibe', + title: 'Colors', expanded: true, }); - this.addColorBinding(folder, 'color1', 'colour 1', (color) => { + this.addColorBinding(folder, 'color1', 'Primary Color', (color) => { activeVibe.colors[0] = color; }); - this.addColorBinding(folder, 'color2', 'colour 2', (color) => { + this.addColorBinding(folder, 'color2', 'Secondary Color', (color) => { activeVibe.colors[1] = color; }); - this.addColorBinding(folder, 'color3', 'colour 3', (color) => { + this.addColorBinding(folder, 'color3', 'Accent Color', (color) => { activeVibe.colors[2] = color; }); - this.addColorBinding(folder, 'backgroundColor', 'overlay / background', (color) => { + this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => { activeVibe.backgroundColor = color; }); if (import.meta.env.DEV) { folder - .addButton({ title: 'Copy vibe preset' }) + .addButton({ title: 'Copy Vibe Preset' }) .on('click', () => void this.copyVibePresetToClipboard()); } } @@ -313,7 +336,7 @@ export class ConfigPane { } private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void { - const config = appConfig.runtimeSettings.controls[key]; + const config = this.getRuntimeControlConfig(key); if (!config) { return; } @@ -332,71 +355,75 @@ export class ConfigPane { }); } + private getRuntimeControlConfig( + key: RuntimeControlKey + ): NumberControlConfig | undefined { + const config = appConfig.runtimeSettings.controls[key]; + if (!config || key !== 'maxAgentCount') { + return config; + } + + return { + ...config, + max: Math.max(config.min ?? 0, Math.floor(this.options.maxSupportedAgentCount)), + }; + } + private addFpsOverlayBinding(container: PaneContainer): void { container .addBinding(appConfig.tuningPane, 'showFpsOverlay', { - label: 'FPS overlay', + label: 'Show FPS', }) .on('change', () => this.options.onConfigChange()); } private addColorReactionMatrix(container: PaneContainer): void { const folder = container.addFolder({ - title: 'Follow / Ignore / Avoid', + title: 'Color Behavior', expanded: true, }); folder.element.classList.add('color-reaction-folder'); - const content = Array.from(folder.element.children).find((child) => - child.classList.contains('tp-fldv_c') - ); - if (!(content instanceof HTMLElement)) { - return; - } - - const doc = folder.element.ownerDocument; - const matrix = doc.createElement('div'); + const matrix = document.createElement('div'); matrix.className = 'color-reaction-matrix'; - matrix.appendChild(this.createColorReactionCorner(doc)); + matrix.appendChild(this.createColorReactionCorner()); colorReactionRows.forEach((row) => { - matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label)); + matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label)); }); colorReactionRows.forEach((row) => { - matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label)); + matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label)); row.keys.forEach((key, columnIndex) => { matrix.appendChild( - this.createColorReactionCell(doc, key, row.colorIndex, columnIndex) + this.createColorReactionCell(key, row.colorIndex, columnIndex) ); }); }); - content.appendChild(matrix); + const matrixBlade = folder.addBlade({ view: 'separator' }); + matrixBlade.element.classList.add('color-reaction-matrix-blade'); + matrixBlade.element.replaceChildren(matrix); this.syncColorReactionMatrix(); } - private createColorReactionCorner(doc: Document): HTMLDivElement { - const corner = doc.createElement('div'); + private createColorReactionCorner(): HTMLDivElement { + const corner = document.createElement('div'); corner.className = 'color-reaction-matrix__corner'; - corner.textContent = 'agent'; + corner.textContent = 'agents'; return corner; } - private createColorReactionHeader( - doc: Document, - colorIndex: number, - label: string - ): HTMLDivElement { - const header = doc.createElement('div'); + private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement { + const header = document.createElement('div'); header.className = 'color-reaction-matrix__header'; - const swatch = doc.createElement('span'); + const swatch = document.createElement('span'); swatch.className = 'color-reaction-matrix__swatch'; this.colorReactionSwatches.push({ colorIndex, element: swatch }); header.appendChild(swatch); - const text = doc.createElement('span'); + const text = document.createElement('span'); text.textContent = label; header.appendChild(text); @@ -404,12 +431,11 @@ export class ConfigPane { } private createColorReactionCell( - doc: Document, key: ColorReactionKey, sourceColorIndex: number, targetColorIndex: number ): HTMLDivElement { - const cell = doc.createElement('div'); + const cell = document.createElement('div'); cell.className = 'color-reaction-matrix__cell'; const config = appConfig.runtimeSettings.controls[key]; @@ -417,11 +443,11 @@ export class ConfigPane { return cell; } - const button = doc.createElement('button'); + const button = document.createElement('button'); button.className = 'color-reaction-matrix__button'; button.type = 'button'; - const icon = doc.createElement('span'); + const icon = document.createElement('span'); icon.className = 'color-reaction-matrix__icon'; button.appendChild(icon); @@ -470,15 +496,15 @@ export class ConfigPane { const state = getColorReactionState(settings[key]); const nextState = getNextColorReactionState(settings[key]); - const sourceLabel = sourceColorIndex + 1; - const targetLabel = targetColorIndex + 1; + const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex]; + const targetLabel = COLOR_REACTION_LABELS[targetColorIndex]; button.dataset.reaction = state.id; button.setAttribute( 'aria-label', - `Color ${sourceLabel} agents ${state.label.toLowerCase()} color ${targetLabel}; click to switch to ${nextState.label.toLowerCase()}` + `${sourceLabel} agents ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}` ); - button.title = state.label; + button.title = `${sourceLabel} agents: ${state.label} ${targetLabel} trails`; } private setUpMusicSection(container: PaneContainer): void { @@ -541,5 +567,7 @@ export class ConfigPane { settingsButton.setAttribute('aria-expanded', String(this.isOpen)); settingsButton.setAttribute('aria-label', label); settingsButton.title = label; + this.container.classList.toggle('config-pane-container--open', this.isOpen); + this.closeButton.hidden = !this.isOpen; } } diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts new file mode 100644 index 0000000..4b4bb5d --- /dev/null +++ b/src/page/eraser-size-control.ts @@ -0,0 +1,68 @@ +import type GameLoop from '../game-loop/game-loop'; +import { settings } from '../settings'; +import { queryRequiredElement } from '../utils/dom'; + +const ERASER_CONTROL_SCALE_MAX = 1.33; +const ERASER_CONTROL_SCALE_MIN = 0.75; +const ERASER_SIZE_DEFAULT = 96; +const ERASER_SIZE_MAX = 240; +const ERASER_SIZE_MIN = 24; +const ERASER_SIZE_STEP = 1; + +const clampEraserSize = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; + return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); +}; + +const getEraserSizeRatio = (size: number): number => + (size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN); + +interface EraserSizeControlOptions { + getGame: () => GameLoop | null; + onActivate: () => void; + onChange: () => void; +} + +export class EraserSizeControl { + private readonly control = queryRequiredElement( + '.eraser-size-control', + HTMLLabelElement + ); + private readonly slider = queryRequiredElement( + '.eraser-size-slider', + HTMLInputElement + ); + + public constructor(private readonly options: EraserSizeControlOptions) { + this.control.addEventListener('pointerdown', this.options.onActivate); + this.control.addEventListener('click', this.options.onActivate); + this.slider.addEventListener('focus', this.options.onActivate); + this.slider.addEventListener('input', () => { + settings.eraserSize = clampEraserSize(Number(this.slider.value)); + this.options.onActivate(); + this.render(); + this.options.onChange(); + }); + } + + public render(): void { + const size = clampEraserSize(settings.eraserSize); + if (settings.eraserSize !== size) { + settings.eraserSize = size; + } + + this.slider.min = ERASER_SIZE_MIN.toString(); + this.slider.max = ERASER_SIZE_MAX.toString(); + this.slider.step = ERASER_SIZE_STEP.toString(); + this.slider.value = size.toString(); + this.slider.setAttribute('aria-valuetext', `${size}px`); + + const ratio = getEraserSizeRatio(size); + const scale = + ERASER_CONTROL_SCALE_MIN + + (ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; + this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); + this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); + this.options.getGame()?.updateEraserPreview(); + } +} diff --git a/src/page/error-presenter.ts b/src/page/error-presenter.ts new file mode 100644 index 0000000..835c902 --- /dev/null +++ b/src/page/error-presenter.ts @@ -0,0 +1,62 @@ +import { + ErrorHandler, + getErrorMessage, + RuntimeError, + Severity, +} from '../utils/error-handler'; + +type RuntimeUiError = Parameters< + Parameters[0] +>[0]; + +const ERROR_CONTAINER_SELECTOR = '.errors-container'; +const ERROR_CONTAINER_CLASS = 'errors-container'; + +const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError): void => { + const message = document.createElement('pre'); + message.className = error.severity; + message.textContent = error.code ? `${error.message}\n${error.code}` : error.message; + message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status'); + message.setAttribute( + 'aria-live', + error.severity === Severity.ERROR ? 'assertive' : 'polite' + ); + container.append(message); + + if (error.severity === Severity.ERROR) { + message.tabIndex = -1; + message.focus({ preventScroll: true }); + } +}; + +const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({ + severity: Severity.ERROR, + message: getErrorMessage(exception), + ...(exception instanceof RuntimeError ? { code: exception.code } : {}), +}); + +export class ErrorPresenter { + public constructor(private readonly container: HTMLElement) { + container.setAttribute('aria-live', 'assertive'); + } + + public render(error: RuntimeUiError): void { + renderRuntimeMessage(this.container, error); + } + + public static renderStartup(exception: unknown): void { + const existingContainer = document.querySelector(ERROR_CONTAINER_SELECTOR); + const container = + existingContainer instanceof HTMLElement + ? existingContainer + : document.createElement('div'); + + if (!(existingContainer instanceof HTMLElement)) { + container.className = ERROR_CONTAINER_CLASS; + document.body.append(container); + } + + container.setAttribute('aria-live', 'assertive'); + renderRuntimeMessage(container, getRuntimeUiError(exception)); + } +} diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts new file mode 100644 index 0000000..c484571 --- /dev/null +++ b/src/page/mirror-segment-control.ts @@ -0,0 +1,68 @@ +import { settings } from '../settings'; +import { queryRequiredElement } from '../utils/dom'; + +const MIRROR_SEGMENT_DEFAULT = 1; +const MIRROR_SEGMENT_MAX = 12; +const MIRROR_SEGMENT_MIN = 1; +const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off'; +const MIRROR_SEGMENT_STEP = 1; +const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices'; + +const clampMirrorSegmentCount = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT; + return Math.min( + MIRROR_SEGMENT_MAX, + Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue)) + ); +}; + +const getMirrorSegmentRatio = (count: number): number => + (count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN); + +const formatMirrorSegmentCount = (count: number): string => + count === MIRROR_SEGMENT_DEFAULT + ? MIRROR_SEGMENT_OFF_LABEL + : `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`; + +interface MirrorSegmentControlOptions { + onChange: () => void; +} + +export class MirrorSegmentControl { + private readonly control = queryRequiredElement( + '.mirror-segment-control', + HTMLLabelElement + ); + private readonly slider = queryRequiredElement( + '.mirror-segment-slider', + HTMLInputElement + ); + + public constructor(private readonly options: MirrorSegmentControlOptions) { + this.slider.addEventListener('input', () => { + settings.mirrorSegmentCount = clampMirrorSegmentCount(Number(this.slider.value)); + this.render(); + this.options.onChange(); + }); + } + + public render(): void { + const count = clampMirrorSegmentCount(settings.mirrorSegmentCount); + if (settings.mirrorSegmentCount !== count) { + settings.mirrorSegmentCount = count; + } + + this.slider.min = MIRROR_SEGMENT_MIN.toString(); + this.slider.max = MIRROR_SEGMENT_MAX.toString(); + this.slider.step = MIRROR_SEGMENT_STEP.toString(); + this.slider.value = count.toString(); + + const label = formatMirrorSegmentCount(count); + const ratio = getMirrorSegmentRatio(count); + this.slider.setAttribute('aria-valuetext', label); + this.control.title = label; + this.control.classList.toggle('active', count > 1); + this.control.style.setProperty('--mirror-progress', `${ratio * 100}%`); + this.control.style.setProperty('--mirror-angle', `${(360 / count).toFixed(3)}deg`); + } +} diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts new file mode 100644 index 0000000..316b11d --- /dev/null +++ b/src/page/palette-control.ts @@ -0,0 +1,54 @@ +import type GameLoop from '../game-loop/game-loop'; +import { activeVibe, settings } from '../settings'; +import { queryRequiredElement, queryRequiredElements } from '../utils/dom'; +import { rgbColorToCss } from '../utils/rgb-color'; + +interface PaletteControlOptions { + getGame: () => GameLoop | null; + onChange: () => void; +} + +export class PaletteControl { + private readonly swatches = queryRequiredElements('.color-swatch', HTMLButtonElement); + private readonly eraserControl = queryRequiredElement( + '.eraser-size-control', + HTMLLabelElement + ); + private isEraserActiveState = false; + + public constructor(private readonly options: PaletteControlOptions) { + this.swatches.forEach((swatch, index) => { + swatch.addEventListener('click', () => { + settings.selectedColorIndex = index; + this.isEraserActiveState = false; + this.render(); + this.options.onChange(); + }); + }); + } + + public get isEraserActive(): boolean { + return this.isEraserActiveState; + } + + public setEraserActive(active: boolean): void { + this.isEraserActiveState = active; + this.render(); + } + + public render(): void { + this.swatches.forEach((swatch, index) => { + swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); + swatch.classList.toggle( + 'active', + settings.selectedColorIndex === index && !this.isEraserActiveState + ); + }); + this.eraserControl.classList.toggle('active', this.isEraserActiveState); + this.options.getGame()?.setEraseMode(this.isEraserActiveState); + document.documentElement.style.setProperty( + '--garden-background', + rgbColorToCss(activeVibe.backgroundColor) + ); + } +} diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts new file mode 100644 index 0000000..fb25225 --- /dev/null +++ b/src/page/splash-screen.ts @@ -0,0 +1,47 @@ +import { queryRequiredElement } from '../utils/dom'; +import { clamp01 } from '../utils/math'; + +export class SplashScreen { + public readonly startButton = queryRequiredElement( + '.start-button', + HTMLButtonElement + ); + private readonly splash = queryRequiredElement('.splash', HTMLDivElement); + private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement); + private readonly loadingStatus = queryRequiredElement( + '.loading-status', + HTMLDivElement + ); + private readonly loadingProgress = queryRequiredElement( + '.loading-progress', + HTMLDivElement + ); + + public setLoadingStage(label: string, ratio: number): void { + const percent = Math.round(clamp01(ratio) * 100); + this.loadingStatus.textContent = label; + this.loadingProgress.style.setProperty('--loading-progress', `${percent}%`); + this.loadingProgress.setAttribute('aria-valuenow', String(percent)); + } + + public awaitStart(onStart: () => void): Promise { + this.startButton.disabled = false; + return new Promise((resolve) => { + const onClick = () => { + this.startButton.removeEventListener('click', onClick); + onStart(); + this.splash.hidden = true; + resolve(); + }; + this.startButton.addEventListener('click', onClick); + }); + } + + public showLoadingBar(): void { + this.loadingBar.hidden = false; + } + + public hideLoadingBar(): void { + this.loadingBar.hidden = true; + } +} diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts new file mode 100644 index 0000000..2e37feb --- /dev/null +++ b/src/page/vibe-navigator.ts @@ -0,0 +1,40 @@ +import { activeVibe, applyVibeSettings } from '../settings'; +import { queryRequiredElement } from '../utils/dom'; +import { VIBE_PRESETS, type VibeId } from '../vibes'; + +interface VibeSelection { + source: string; + vibeId: VibeId; + vibeName: string; +} + +interface VibeNavigatorOptions { + onChange: (selection: VibeSelection) => void; +} + +export class VibeNavigator { + private readonly previousButton = queryRequiredElement( + '.previous-vibe', + HTMLButtonElement + ); + private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement); + + public constructor(private readonly options: VibeNavigatorOptions) { + this.previousButton.addEventListener('click', () => + this.select(-1, 'previous-button') + ); + this.nextButton.addEventListener('click', () => this.select(1, 'next-button')); + } + + private select(offset: number, source: string): void { + const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); + const vibe = + VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; + const activePreset = applyVibeSettings(vibe); + this.options.onChange({ + vibeId: activePreset.id, + vibeName: activePreset.name, + source, + }); + } +} diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts index 3fd4477..bce5e18 100644 --- a/src/pipelines/agents/agent-dispatch.ts +++ b/src/pipelines/agents/agent-dispatch.ts @@ -1,5 +1,4 @@ -const AGENT_WORKGROUP_SIZE = 64; -export const AGENT_MAX_DISPATCHABLE_COUNT = 65_535 * AGENT_WORKGROUP_SIZE; +export const AGENT_WORKGROUP_SIZE = 64; export const dispatchAgentWorkgroups = ( passEncoder: GPUComputePassEncoder, diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index d76015f..a4022e7 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -2,13 +2,13 @@ import { vec2 } from 'gl-matrix'; import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache'; import { smartCompile } from '../../../utils/graphics/smart-compile'; -import { AGENT_MAX_DISPATCHABLE_COUNT, dispatchAgentWorkgroups } from '../agent-dispatch'; +import { dispatchAgentWorkgroups } from '../agent-dispatch'; +import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits'; import compactionShader from './agent-compaction.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw'; -export const AGENT_FLOAT_COUNT = 8; -const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; +export { AGENT_FLOAT_COUNT } from '../agent-limits'; export class AgentGenerationPipeline { private static readonly UNIFORM_COUNT = 4; @@ -224,15 +224,7 @@ export class AgentGenerationPipeline { ? Math.floor(value) : 0; return Math.min( - Number.isFinite(this.maxAgentCountUpperLimit) - ? this.maxAgentCountUpperLimit - : Number.POSITIVE_INFINITY, - Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), - Math.floor( - ((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ?? - this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES - ), - AGENT_MAX_DISPATCHABLE_COUNT, + getMaxSupportedAgentCount(this.device, this.maxAgentCountUpperLimit), Math.max(0, requestedMaxAgentCount) ); } diff --git a/src/pipelines/agents/agent-limits.ts b/src/pipelines/agents/agent-limits.ts new file mode 100644 index 0000000..560ad06 --- /dev/null +++ b/src/pipelines/agents/agent-limits.ts @@ -0,0 +1,25 @@ +import { AGENT_WORKGROUP_SIZE } from './agent-dispatch'; + +export const AGENT_FLOAT_COUNT = 8; +export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; + +export const getMaxSupportedAgentCount = ( + device: GPUDevice, + maxAgentCountUpperLimit = Number.POSITIVE_INFINITY +): number => { + const storageBufferBindingSize = + device.limits.maxStorageBufferBindingSize ?? device.limits.maxBufferSize; + const upperLimit = Number.isFinite(maxAgentCountUpperLimit) + ? Math.floor(maxAgentCountUpperLimit) + : Number.POSITIVE_INFINITY; + + return Math.max( + 0, + Math.min( + upperLimit, + Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), + Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES), + Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * AGENT_WORKGROUP_SIZE + ) + ); +}; diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index 330fafd..7e67b53 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -1,3 +1,4 @@ +import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache'; import { createCachedFloat32BufferWrite, writeFloat32BufferIfChanged, @@ -38,43 +39,91 @@ export interface AgentSettings { randomTimeScale: number; } -export class AgentPipeline { - private static readonly UNIFORM_COUNT = 30; +const UNIFORM_COUNT = 30; +export class AgentPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPUComputePipeline; + private readonly normalPipeline: GPUComputePipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT); + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer); - private readonly uniformCache = createCachedFloat32BufferWrite( - AgentPipeline.UNIFORM_COUNT - ); - private readonly bindGroupsByAgentsBuffer = new WeakMap< + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private readonly bindGroupCache = createBindGroupCache3< GPUBuffer, - WeakMap> - >(); + GPUTextureView, + GPUTextureView + >((agentsBuffer, trailMapIn, trailMapOut) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 1, resource: { buffer: agentsBuffer } }, + { binding: 2, resource: trailMapIn }, + { binding: 3, resource: trailMapOut }, + ], + }) + ); private agentCount = 0; + private useIntroPipeline = true; public constructor( private readonly device: GPUDevice, private readonly commonState: CommonState, - private readonly getAgentsBuffer: () => GPUBuffer // doesn't get destroyed + private readonly getAgentsBuffer: () => GPUBuffer ) { - this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout); + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'uniform' }, + }, + { + binding: 1, + visibility: GPUShaderStage.COMPUTE, + buffer: { type: 'storage' }, + }, + { + binding: 2, + visibility: GPUShaderStage.COMPUTE, + texture: { sampleType: 'float' }, + }, + { + binding: 3, + visibility: GPUShaderStage.COMPUTE, + storageTexture: { format: 'rgba16float' }, + }, + ], + }); + const shaderModule = smartCompile( + device, + CommonState.shaderCode, + agentSchema, + shader + ); + const pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], + }); this.pipeline = device.createComputePipeline({ - layout: device.createPipelineLayout({ - bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], - }), + layout: pipelineLayout, compute: { - module: smartCompile(device, CommonState.shaderCode, agentSchema, shader), + module: shaderModule, entryPoint: 'main', }, }); + this.normalPipeline = device.createComputePipeline({ + layout: pipelineLayout, + compute: { + module: shaderModule, + entryPoint: 'mainNormal', + }, + }); - this.uniforms = this.device.createBuffer({ - size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); } @@ -118,6 +167,7 @@ export class AgentPipeline { introProgress?: number; }) { this.agentCount = agentCount; + this.useIntroPipeline = (introProgress ?? 1) < introProgressCutoff; this.uniformValues[0] = moveSpeed * deltaTime; this.uniformValues[1] = turnSpeed * deltaTime; const sensorAngle = (sensorOffsetAngle * Math.PI) / 180; @@ -160,110 +210,27 @@ export class AgentPipeline { public execute( commandEncoder: GPUCommandEncoder, trailMapIn: GPUTextureView, - trailMapOut: GPUTextureView + trailMapOut: GPUTextureView, + timestampWrites?: GPUComputePassTimestampWrites ) { if (this.agentCount <= 0) { return; } - const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); - - const passEncoder = commandEncoder.beginComputePass(); - passEncoder.setPipeline(this.pipeline); + const passEncoder = commandEncoder.beginComputePass( + timestampWrites ? { timestampWrites } : undefined + ); + passEncoder.setPipeline(this.useIntroPipeline ? this.pipeline : this.normalPipeline); this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, bindGroup); + passEncoder.setBindGroup( + 1, + this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut) + ); dispatchAgentWorkgroups(passEncoder, this.agentCount); passEncoder.end(); } - private getBindGroup( - trailMapIn: GPUTextureView, - trailMapOut: GPUTextureView - ): GPUBindGroup { - const agentsBuffer = this.getAgentsBuffer(); - let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer); - if (!textureCache) { - textureCache = new WeakMap>(); - this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache); - } - - let outputCache = textureCache.get(trailMapIn); - if (!outputCache) { - outputCache = new WeakMap(); - textureCache.set(trailMapIn, outputCache); - } - - const cached = outputCache.get(trailMapOut); - if (cached) { - return cached; - } - - const bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - { - binding: 1, - resource: { - buffer: agentsBuffer, - }, - }, - { - binding: 2, - resource: trailMapIn, - }, - { - binding: 3, - resource: trailMapOut, - }, - ], - }); - - outputCache.set(trailMapOut, bindGroup); - return bindGroup; - } - public destroy() { this.uniforms.destroy(); } - - private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { - return { - entries: [ - { - binding: 0, - visibility: GPUShaderStage.COMPUTE, - buffer: { - type: 'uniform', - }, - }, - { - binding: 1, - visibility: GPUShaderStage.COMPUTE, - buffer: { - type: 'storage', - }, - }, - { - binding: 2, - visibility: GPUShaderStage.COMPUTE, - texture: { - sampleType: 'float', - }, - }, - { - binding: 3, - visibility: GPUShaderStage.COMPUTE, - storageTexture: { - format: 'rgba16float', - }, - }, - ], - }; - } } diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 025fe39..31b2081 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -1,3 +1,5 @@ +const PI: f32 = 3.14159265359; + struct Settings { moveRate: f32, turnRate: f32, @@ -142,7 +144,80 @@ fn main( let nextPosition = clamp(position + step, vec2(0, 0), maxPosition); if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { - rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5; + rotation = PI + random_float(randomSeed + 22695477u) - 0.5; + } + + var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0); + trailBelow = vec4( + trailBelow.rgb + channelMask * settings.individualTrailWeight, + max(trailBelow.a, 0.0) + ); + + textureStore(trailMapOut, vec2(nextPosition), trailBelow); + agents[id].angle = angle + rotation; + agents[id].position = nextPosition; +} + +@compute @workgroup_size(64) +fn mainNormal( + @builtin(global_invocation_id) global_id: vec3 +) { + let id = get_id(global_id); + + if id >= settings.agentCount { + return; + } + + let colorIndex = agents[id].colorIndex; + if colorIndex < 0.0 || colorIndex >= 2.5 { + return; + } + + var position = agents[id].position; + var angle = agents[id].angle; + let channelMask = get_channel_mask(colorIndex); + let reactionMask = get_reaction_mask(colorIndex); + let randomSeed = random_seed(id); + let maxPosition = state.size - vec2(1.0, 1.0); + let randomTurn = random_float(randomSeed); + let direction = vec2(cos(angle), sin(angle)); + + let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition); + let leftSensor = sensor_position( + position, + rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + let rightSensor = sensor_position( + position, + rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + + let trailForward = textureLoad(trailMapIn, forwardSensor, 0); + let trailLeft = textureLoad(trailMapIn, leftSensor, 0); + let trailRight = textureLoad(trailMapIn, rightSensor, 0); + + let weightForward = dot(trailForward.rgb, reactionMask); + let weightLeft = dot(trailLeft.rgb, reactionMask); + let weightRight = dot(trailRight.rgb, reactionMask); + + var rotation = (randomTurn - 0.5) * settings.turnWhenLost; + if weightForward >= weightLeft && weightForward >= weightRight { + rotation = rotation * settings.forwardRotationScale; + } else { + rotation += sign(weightLeft - weightRight) * settings.turnRate; + } + + let nextPosition = clamp( + position + direction * settings.moveRate, + vec2(0, 0), + maxPosition + ); + if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { + rotation = PI + random_float(randomSeed + 22695477u) - 0.5; } var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0); diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index d57b5a2..5192bce 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -7,6 +7,12 @@ import { } from '../../utils/graphics/cached-buffer-write'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; +import { + LINE_SEGMENT_VERTEX_BUFFER_LAYOUT, + LINE_SEGMENT_VERTICES, + LineSegmentBuffer, +} from '../common/line-segment-buffer'; +import lineSegmentShader from '../common/line-segment.wgsl?raw'; import shader from './brush.wgsl?raw'; export interface BrushSettings { @@ -20,12 +26,7 @@ export interface BrushSettings { brushGrainMaxStrength: number; } -interface LineSegment { - from: vec2; - to: vec2; -} - -interface BrushParameterSettings extends BrushSettings { +interface BrushParameters extends BrushSettings { pixelRatio?: number; selectedColorIndex: number; } @@ -35,6 +36,8 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number => ? pixelRatio : 1; +const UNIFORM_COUNT = 16; + const setBrushUniformValues = ( target: Float32Array, { @@ -48,15 +51,14 @@ const setBrushUniformValues = ( brushGrainMaxStrength, selectedColorIndex, pixelRatio, - }: BrushParameterSettings + }: BrushParameters ): void => { const safePixelRatio = getSafePixelRatio(pixelRatio); const brushRadius = (brushSize * safePixelRatio) / 2; target[0] = brushRadius; target[1] = brushRadius * brushRadius; - target[2] = 0; - target[3] = 0; + // target[2], target[3] are WGSL alignment padding for brushValue:vec4 — never read by the shader. target[4] = selectedColorIndex === 0 ? 1 : 0; target[5] = selectedColorIndex === 1 ? 1 : 0; target[6] = selectedColorIndex === 2 ? 1 : 0; @@ -70,78 +72,81 @@ const setBrushUniformValues = ( }; export class BrushPipeline { - private static readonly UNIFORM_COUNT = 16; - private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount; - private static readonly VERTICES_PER_LINE_SEGMENT = 6; - private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4; - private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; - private readonly multiTargetPipeline: GPURenderPipeline; + private readonly renderPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT); - private readonly uniformCache = createCachedFloat32BufferWrite( - BrushPipeline.UNIFORM_COUNT - ); - private readonly vertexBuffer: GPUBuffer; - private readonly vertexUploadData = new Float32Array( - BrushPipeline.MAX_LINE_COUNT * - BrushPipeline.VERTICES_PER_LINE_SEGMENT * - BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT - ); - - private lineSegments: Array = []; - private actualSegments: Array = []; + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private readonly segments: LineSegmentBuffer; public constructor( private readonly device: GPUDevice, private readonly commonState: CommonState ) { - this.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout); + this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount); - this.vertexBuffer = device.createBuffer({ - size: - BrushPipeline.MAX_LINE_COUNT * - BrushPipeline.VERTICES_PER_LINE_SEGMENT * - BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT * - Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, - }); - - const shaderModule = smartCompile(device, CommonState.shaderCode, shader); - this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 1); - - this.uniforms = this.device.createBuffer({ - size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - - this.bindGroup = this.device.createBindGroup({ - layout: this.bindGroupLayout, + this.bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, - resource: { - buffer: this.uniforms, - }, + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { type: 'uniform' }, }, ], }); - } - public addSwipeSegment(from: vec2, to: vec2) { - this.lineSegments.push({ - from: vec2.clone(from), - to: vec2.clone(to), + const shaderModule = smartCompile( + device, + CommonState.shaderCode, + lineSegmentShader, + shader + ); + this.renderPipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'vertex', + buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT], + }, + fragment: { + module: shaderModule, + entryPoint: 'fragmentMrt', + targets: [ + { + format: 'rgba16float', + blend: { + color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' }, + alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' }, + }, + }, + ], + }, + primitive: { topology: 'triangle-list' }, + }); + + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + this.bindGroup = device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [{ binding: 0, resource: { buffer: this.uniforms } }], }); } - public clearSwipes() { - this.lineSegments.length = 0; - this.actualSegments.length = 0; + public addSwipeSegment(from: vec2, to: vec2): void { + this.segments.add(from, to); } - public setParameters(parameters: BrushParameterSettings) { + public clearSwipes(): void { + this.segments.clear(); + } + + public setParameters(parameters: BrushParameters): void { setBrushUniformValues(this.uniformValues, parameters); writeFloat32BufferIfChanged( this.device, @@ -149,188 +154,34 @@ export class BrushPipeline { this.uniformValues, this.uniformCache ); - - this.actualSegments = this.lineSegments.slice(); - this.lineSegments.length = 0; - - if (this.actualSegments.length === 0) { - return; - } - - if (this.actualSegments.length > BrushPipeline.MAX_LINE_COUNT) { - this.actualSegments = BrushPipeline.subsampleSegments(this.actualSegments); - } - - const lineCount = this.lineCount; - let floatOffset = 0; - for (let i = 0; i < lineCount; i++) { - const segment = this.actualSegments[i]; - floatOffset = this.writeSegmentVertices( - this.vertexUploadData, - floatOffset, - segment.from, - segment.to - ); - } - - this.device.queue.writeBuffer( - this.vertexBuffer, - 0, - this.vertexUploadData, - 0, - floatOffset - ); - } - - private static subsampleSegments(segments: Array): Array { - if (segments.length <= BrushPipeline.MAX_LINE_COUNT) { - return segments; - } - - const result: Array = []; - for (let i = 0; i < BrushPipeline.MAX_LINE_COUNT; i++) { - const index = Math.round( - (i * (segments.length - 1)) / (BrushPipeline.MAX_LINE_COUNT - 1) - ); - result.push(segments[index]); - } - - return result; - } - - private writeSegmentVertices( - target: Float32Array, - offset: number, - from: vec2, - to: vec2 - ): number { - target[offset++] = from[0]; - target[offset++] = from[1]; - target[offset++] = to[0]; - target[offset++] = to[1]; - return offset; + this.segments.flush(); } public executeMultiTarget( commandEncoder: GPUCommandEncoder, - sourceMapOut: GPUTextureView + sourceMapOut: GPUTextureView, + timestampWrites?: GPURenderPassTimestampWrites ): boolean { - return this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [ - sourceMapOut, - ]); - } - - private executeWithPipeline( - commandEncoder: GPUCommandEncoder, - pipeline: GPURenderPipeline, - textureViews: Array - ): boolean { - if (this.lineCount === 0) { + const lineCount = this.segments.activeCount; + if (lineCount === 0) { return false; } - const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: textureViews.map((view) => ({ - view, - loadOp: 'load', - storeOp: 'store', - })), - }; - - const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(pipeline); + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }], + timestampWrites, + }); + passEncoder.setPipeline(this.renderPipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); - passEncoder.setVertexBuffer(0, this.vertexBuffer); - passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount); + passEncoder.setVertexBuffer(0, this.segments.vertexBuffer); + passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount); passEncoder.end(); return true; } - public destroy() { - this.vertexBuffer.destroy(); + public destroy(): void { + this.segments.destroy(); this.uniforms.destroy(); } - - private 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 * BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT, - stepMode: 'instance', - attributes: [ - { - shaderLocation: 0, - format: 'float32x2', - offset: 0, - }, - { - shaderLocation: 1, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 2, - }, - ], - }, - ], - }, - 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: [ - { - binding: 0, - visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, - buffer: { - type: 'uniform', - }, - }, - ], - }; - } - - private get lineCount() { - return this.actualSegments.length; - } } diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index 946e8b7..30fb694 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -1,3 +1,5 @@ +const SEGMENT_LENGTH_EPSILON: f32 = 0.0001; + struct Settings { brushRadius: f32, brushRadiusSquared: f32, @@ -36,7 +38,7 @@ fn vertex( let direction = end - start; let denominator = dot(direction, direction); var inverseLengthSquared = 0.0; - if denominator > 0.0001 { + if denominator > SEGMENT_LENGTH_EPSILON { inverseLengthSquared = 1.0 / denominator; } let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius); @@ -68,7 +70,7 @@ fn brushStrength( direction: vec2, inverseLengthSquared: f32 ) -> f32 { - let distanceSquared = distanceSquaredFromLine( + let distanceSquared = distance_squared_from_segment( screenPosition, start, direction, @@ -78,11 +80,15 @@ fn brushStrength( return 0.0; } - let edge = 1.0 - step(settings.brushRadiusSquared, distanceSquared); - if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold { + let maxGrainStrength = max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength); + if maxGrainStrength < settings.brushDiscardThreshold { return 0.0; } + if settings.brushGrainMinStrength == settings.brushGrainMaxStrength { + return settings.brushGrainMinStrength; + } + let grainNoise = textureSampleLevel( noise, noiseSampler, @@ -90,52 +96,9 @@ fn brushStrength( vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY), 0.0 ).r; - return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise); + return mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise); } fn brushOutput(strength: f32) -> vec4 { return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength); } - -fn distanceSquaredFromLine( - position: vec2, - start: vec2, - direction: vec2, - inverseLengthSquared: f32 -) -> f32 { - let pa = position - start; - - let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1); - let nearestOffset = pa - direction * q; - return dot(nearestOffset, nearestOffset); -} - -fn segment_vertex_position( - vertexIndex: u32, - start: vec2, - end: vec2, - radius: f32 -) -> vec2 { - let directionVector = end - start; - let segmentLength = length(directionVector); - var direction = vec2(1.0, 0.0); - if segmentLength > 0.0 { - direction = directionVector / segmentLength; - } - let perpendicular = vec2(direction.y, -direction.x); - let corner = segment_vertex_corner(vertexIndex % 6u); - let center = mix(start, end, (corner.x + 1.0) * 0.5); - return center + direction * corner.x * radius + perpendicular * corner.y * radius; -} - -fn segment_vertex_corner(index: u32) -> vec2 { - let corners = array, 6>( - vec2(-1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(1.0, -1.0), - ); - return corners[index]; -} diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts index 14a710e..97b223b 100644 --- a/src/pipelines/common-state/common-state.ts +++ b/src/pipelines/common-state/common-state.ts @@ -23,7 +23,7 @@ export class CommonState { public static readonly shaderCode = /* wgsl */ ` struct State { size: vec2, - time: f32, + _padding: vec2, }; @group(0) @binding(0) var state: State; @@ -96,10 +96,9 @@ export class CommonState { }); } - public setParameters({ canvasSize, time }: { canvasSize: vec2; time: number }) { + public setParameters({ canvasSize }: { canvasSize: vec2 }) { this.uniformValues[0] = canvasSize[0]; this.uniformValues[1] = canvasSize[1]; - this.uniformValues[2] = time; writeFloat32BufferIfChanged( this.device, this.uniforms, diff --git a/src/pipelines/common/line-segment-buffer.ts b/src/pipelines/common/line-segment-buffer.ts new file mode 100644 index 0000000..417e971 --- /dev/null +++ b/src/pipelines/common/line-segment-buffer.ts @@ -0,0 +1,92 @@ +import { vec2 } from 'gl-matrix'; + +export interface LineSegment { + from: vec2; + to: vec2; +} + +export const LINE_SEGMENT_VERTICES = 6; +const LINE_SEGMENT_ATTRIBUTES = 4; + +export const LINE_SEGMENT_VERTEX_BUFFER_LAYOUT: GPUVertexBufferLayout = { + arrayStride: Float32Array.BYTES_PER_ELEMENT * LINE_SEGMENT_ATTRIBUTES, + stepMode: 'instance', + attributes: [ + { shaderLocation: 0, format: 'float32x2', offset: 0 }, + { + shaderLocation: 1, + format: 'float32x2', + offset: Float32Array.BYTES_PER_ELEMENT * 2, + }, + ], +}; + +export class LineSegmentBuffer { + public readonly vertexBuffer: GPUBuffer; + + private readonly device: GPUDevice; + private readonly maxSegments: number; + private readonly uploadData: Float32Array; + + private pending: Array = []; + private active: Array = []; + + public constructor(device: GPUDevice, maxSegments: number) { + this.device = device; + this.maxSegments = maxSegments; + this.uploadData = new Float32Array(maxSegments * LINE_SEGMENT_ATTRIBUTES); + this.vertexBuffer = device.createBuffer({ + size: this.uploadData.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + } + + public add(from: vec2, to: vec2): void { + this.pending.push({ from: vec2.clone(from), to: vec2.clone(to) }); + } + + public clear(): void { + this.pending.length = 0; + this.active.length = 0; + } + + public get activeCount(): number { + return this.active.length; + } + + public flush(): void { + this.active = this.pending.slice(); + this.pending.length = 0; + + if (this.active.length === 0) { + return; + } + + if (this.active.length > this.maxSegments) { + this.active = subsample(this.active, this.maxSegments); + } + + let offset = 0; + for (const segment of this.active) { + this.uploadData[offset++] = segment.from[0]; + this.uploadData[offset++] = segment.from[1]; + this.uploadData[offset++] = segment.to[0]; + this.uploadData[offset++] = segment.to[1]; + } + + this.device.queue.writeBuffer(this.vertexBuffer, 0, this.uploadData, 0, offset); + } + + public destroy(): void { + this.vertexBuffer.destroy(); + } +} + +const subsample = (segments: Array, count: number): Array => { + const result: Array = []; + for (let i = 0; i < count; i++) { + const index = Math.round((i * (segments.length - 1)) / (count - 1)); + result.push(segments[index]); + } + return result; +}; diff --git a/src/pipelines/common/line-segment.wgsl b/src/pipelines/common/line-segment.wgsl new file mode 100644 index 0000000..8ac3035 --- /dev/null +++ b/src/pipelines/common/line-segment.wgsl @@ -0,0 +1,40 @@ +// Six corners forming two triangles for an instanced segment quad. +// X spans [-1, 1] along the segment direction, Y spans [-1, 1] perpendicular. +fn segment_vertex_corner(index: u32) -> vec2 { + let isRight = index == 2u || index >= 4u; + let isTop = index == 0u || index == 2u || index == 4u; + return vec2( + select(-1.0, 1.0, isRight), + select(-1.0, 1.0, isTop) + ); +} + +fn segment_vertex_position( + vertexIndex: u32, + start: vec2, + end: vec2, + radius: f32 +) -> vec2 { + let directionVector = end - start; + let segmentLength = length(directionVector); + var direction = vec2(1.0, 0.0); + if segmentLength > 0.0 { + direction = directionVector / segmentLength; + } + let perpendicular = vec2(direction.y, -direction.x); + let corner = segment_vertex_corner(vertexIndex % 6u); + let center = mix(start, end, (corner.x + 1.0) * 0.5); + return center + direction * corner.x * radius + perpendicular * corner.y * radius; +} + +fn distance_squared_from_segment( + position: vec2, + start: vec2, + direction: vec2, + inverseLengthSquared: f32 +) -> f32 { + let pa = position - start; + let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0); + let nearestOffset = pa - direction * q; + return dot(nearestOffset, nearestOffset); +} diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 2ecdf0e..8f6db43 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -11,9 +11,13 @@ struct Settings { const WORKGROUP_SIZE_X = 16u; const WORKGROUP_SIZE_Y = 16u; +// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass +// can be served from workgroup memory without bounds checks for interior tiles. const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u; const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u; const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y; +// 1.0 / 2^32, used to map a 32-bit hash to [0, 1). +const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10; @group(0) @binding(0) var settings: Settings; @group(0) @binding(1) var trailMap: texture_2d; @@ -62,16 +66,8 @@ fn main( let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x; var current = tile[centerTileIndex]; let random = random_from_pixel(pixel); - let r2 = random * random; - let r4 = r2 * r2; - let r8 = r4 * r4; - let r16 = r8 * r8; let trailWeight = diffusion_weight( random, - r2, - r4, - r8, - r16, settings.inverseDiffusionRateTrails ); current += ( @@ -118,15 +114,13 @@ fn random_from_pixel(pixel: vec2) -> f32 { hash = (hash ^ (hash >> 16u)) * 2246822519u; hash = (hash ^ (hash >> 13u)) * 3266489917u; hash = hash ^ (hash >> 16u); - return f32(hash) * 2.3283064365386963e-10; + return f32(hash) * HASH_TO_UNIT_FLOAT; } +// Approximates pow(r, inverseRate) piecewise between powers (r, r^2, r^4, r^8, r^16) +// so we can vary diffusion sharpness without paying for a real pow() per pixel. fn diffusion_weight( r: f32, - r2: f32, - r4: f32, - r8: f32, - r16: f32, inverseRate: f32 ) -> f32 { if inverseRate < 1.0 { @@ -137,19 +131,22 @@ fn diffusion_weight( clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0) ); } - + let r2 = r * r; if inverseRate < 2.0 { return mix(r, r2, inverseRate - 1.0); } - + let r4 = r2 * r2; if inverseRate < 4.0 { + // (inverseRate - 2.0) / (4.0 - 2.0) return mix(r2, r4, (inverseRate - 2.0) * 0.5); } - + let r8 = r4 * r4; if inverseRate < 8.0 { + // (inverseRate - 4.0) / (8.0 - 4.0) return mix(r4, r8, (inverseRate - 4.0) * 0.25); } - + let r16 = r8 * r8; + // (inverseRate - 8.0) / (16.0 - 8.0); past 16, falls off as 16/inverseRate. return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0)) * min(1.0, 16.0 / inverseRate); } diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index 75bdae3..3aeb3a2 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -133,11 +133,14 @@ export class DiffusionPipeline { commandEncoder: GPUCommandEncoder, trailMapIn: GPUTextureView, trailMapOut: GPUTextureView, - size: vec2 + size: vec2, + timestampWrites?: GPUComputePassTimestampWrites ) { const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); - const passEncoder = commandEncoder.beginComputePass(); + const passEncoder = commandEncoder.beginComputePass( + timestampWrites ? { timestampWrites } : undefined + ); passEncoder.setPipeline(this.pipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.dispatchWorkgroups( diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index c1dae6a..1f77a9d 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -10,8 +10,15 @@ import { dispatchAgentWorkgroups } from '../agents/agent-dispatch'; import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw'; import shader from './eraser-agent.wgsl?raw'; +interface Bounds { + maxX: number; + maxY: number; + minX: number; + minY: number; +} + export class EraserAgentPipeline { - private static readonly UNIFORM_COUNT = 4; + private static readonly UNIFORM_COUNT = 8; private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPUComputePipeline; @@ -35,6 +42,7 @@ export class EraserAgentPipeline { private pendingSegmentCount = 0; private activeSegmentCount = 0; + private pendingBounds: Bounds | null = null; private agentCount = 0; public constructor( @@ -84,32 +92,42 @@ export class EraserAgentPipeline { }); } - public addSwipeSegment(): void { + public addSwipeSegment(from: vec2, to: vec2): void { this.pendingSegmentCount += 1; + this.pendingBounds = includeSegment(this.pendingBounds, from, to); } public clearSwipes(): void { this.pendingSegmentCount = 0; this.activeSegmentCount = 0; + this.pendingBounds = null; } public setParameters({ agentCount, eraserMaskAlphaThreshold, + eraserSize, maskSize, }: { agentCount: number; eraserMaskAlphaThreshold: number; + eraserSize: number; maskSize: vec2; }): void { this.agentCount = agentCount; this.activeSegmentCount = this.pendingSegmentCount; + const activeBounds = expandBoundsToMask(this.pendingBounds, eraserSize / 2, maskSize); this.pendingSegmentCount = 0; + this.pendingBounds = null; this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount)); this.uniformValues[1] = eraserMaskAlphaThreshold; this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0])); this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1])); + this.uniformValues[4] = activeBounds.minX; + this.uniformValues[5] = activeBounds.minY; + this.uniformValues[6] = activeBounds.maxX; + this.uniformValues[7] = activeBounds.maxY; writeFloat32BufferIfChanged( this.device, this.uniforms, @@ -122,12 +140,18 @@ export class EraserAgentPipeline { return this.activeSegmentCount > 0; } - public execute(commandEncoder: GPUCommandEncoder, eraserMask: GPUTextureView): void { + public execute( + commandEncoder: GPUCommandEncoder, + eraserMask: GPUTextureView, + timestampWrites?: GPUComputePassTimestampWrites + ): void { if (!this.hasActiveMask() || this.agentCount === 0) { return; } - const passEncoder = commandEncoder.beginComputePass(); + const passEncoder = commandEncoder.beginComputePass( + timestampWrites ? { timestampWrites } : undefined + ); passEncoder.setPipeline(this.pipeline); passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask)); dispatchAgentWorkgroups(passEncoder, this.agentCount); @@ -138,3 +162,37 @@ export class EraserAgentPipeline { this.uniforms.destroy(); } } + +const includeSegment = (bounds: Bounds | null, from: vec2, to: vec2): Bounds => { + const minX = Math.min(from[0], to[0]); + const minY = Math.min(from[1], to[1]); + const maxX = Math.max(from[0], to[0]); + const maxY = Math.max(from[1], to[1]); + if (!bounds) { + return { maxX, maxY, minX, minY }; + } + return { + maxX: Math.max(bounds.maxX, maxX), + maxY: Math.max(bounds.maxY, maxY), + minX: Math.min(bounds.minX, minX), + minY: Math.min(bounds.minY, minY), + }; +}; + +const expandBoundsToMask = ( + bounds: Bounds | null, + radius: number, + maskSize: vec2 +): Bounds => { + const maxX = Math.max(0, maskSize[0] - 1); + const maxY = Math.max(0, maskSize[1] - 1); + if (!bounds) { + return { maxX, maxY, minX: 0, minY: 0 }; + } + return { + maxX: Math.min(maxX, bounds.maxX + radius), + maxY: Math.min(maxY, bounds.maxY + radius), + minX: Math.max(0, bounds.minX - radius), + minY: Math.max(0, bounds.minY - radius), + }; +}; diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 63452fe..b1ff6ee 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -3,6 +3,8 @@ struct Settings { eraserMaskAlphaThreshold: f32, maskWidth: u32, maskHeight: u32, + boundsMin: vec2, + boundsMax: vec2, }; @group(1) @binding(0) var settings: Settings; @@ -23,9 +25,18 @@ fn main( return; } + let position = agents[id].position; + let outsideBounds = position.x < settings.boundsMin.x || + position.y < settings.boundsMin.y || + position.x > settings.boundsMax.x || + position.y > settings.boundsMax.y; + if outsideBounds { + return; + } + let maskSize = vec2(i32(settings.maskWidth), i32(settings.maskHeight)); let maskPosition = clamp( - vec2(agents[id].position), + vec2(position), vec2(0, 0), maskSize - vec2(1, 1) ); diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts index 2f6ac26..b6fd9ff 100644 --- a/src/pipelines/eraser/eraser-texture-pipeline.ts +++ b/src/pipelines/eraser/eraser-texture-pipeline.ts @@ -7,97 +7,94 @@ import { } from '../../utils/graphics/cached-buffer-write'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { CommonState } from '../common-state/common-state'; +import { + LINE_SEGMENT_VERTEX_BUFFER_LAYOUT, + LINE_SEGMENT_VERTICES, + LineSegmentBuffer, +} from '../common/line-segment-buffer'; +import lineSegmentShader from '../common/line-segment.wgsl?raw'; import shader from './eraser-texture.wgsl?raw'; -interface LineSegment { - from: vec2; - to: vec2; +interface EraserTextureParameters { + eraserSize: number; + eraserLineDistanceEpsilon: number; + eraserClearRed: number; + eraserClearGreen: number; + eraserClearBlue: number; + eraserClearAlpha: number; } -export class EraserTexturePipeline { - private static readonly UNIFORM_COUNT = 8; - private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount; - private static readonly VERTICES_PER_LINE_SEGMENT = 6; - private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4; +const UNIFORM_COUNT = 8; +const TARGET_FORMATS: Array = ['r8unorm', 'rgba16float', 'rgba16float']; +export class EraserTexturePipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroup: GPUBindGroup; private readonly combinedPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT); - private readonly uniformCache = createCachedFloat32BufferWrite( - EraserTexturePipeline.UNIFORM_COUNT - ); - private readonly vertexBuffer: GPUBuffer; - private readonly vertexUploadData = new Float32Array( - EraserTexturePipeline.MAX_LINE_COUNT * - EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * - EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT - ); - - private lineSegments: Array = []; - private actualSegments: Array = []; + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private readonly segments: LineSegmentBuffer; public constructor( private readonly device: GPUDevice, private readonly commonState: CommonState ) { + this.segments = new LineSegmentBuffer( + device, + appConfig.pipelines.eraser.maxTextureLineCount + ); + this.bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, - buffer: { - type: 'uniform', - }, + buffer: { type: 'uniform' }, }, ], }); - this.vertexBuffer = device.createBuffer({ - size: - EraserTexturePipeline.MAX_LINE_COUNT * - EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * - EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT * - Float32Array.BYTES_PER_ELEMENT, - usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + const shaderModule = smartCompile( + device, + CommonState.shaderCode, + lineSegmentShader, + shader + ); + this.combinedPipeline = device.createRenderPipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }), + vertex: { + module: shaderModule, + entryPoint: 'vertex', + buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT], + }, + fragment: { + module: shaderModule, + entryPoint: 'fragmentCombined', + targets: TARGET_FORMATS.map((format) => ({ format })), + }, + primitive: { topology: 'triangle-list' }, }); - const shaderModule = smartCompile(device, CommonState.shaderCode, shader); - this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [ - 'r8unorm', - 'rgba16float', - 'rgba16float', - ]); - - this.uniforms = this.device.createBuffer({ - size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.bindGroup = this.device.createBindGroup({ + this.bindGroup = device.createBindGroup({ layout: this.bindGroupLayout, - entries: [ - { - binding: 0, - resource: { - buffer: this.uniforms, - }, - }, - ], + entries: [{ binding: 0, resource: { buffer: this.uniforms } }], }); } public addSwipeSegment(from: vec2, to: vec2): void { - this.lineSegments.push({ - from: vec2.clone(from), - to: vec2.clone(to), - }); + this.segments.add(from, to); } public clearSwipes(): void { - this.lineSegments.length = 0; - this.actualSegments.length = 0; + this.segments.clear(); } public setParameters({ @@ -107,14 +104,7 @@ export class EraserTexturePipeline { eraserClearGreen, eraserClearBlue, eraserClearAlpha, - }: { - eraserSize: number; - eraserLineDistanceEpsilon: number; - eraserClearRed: number; - eraserClearGreen: number; - eraserClearBlue: number; - eraserClearAlpha: number; - }): void { + }: EraserTextureParameters): void { const eraserRadius = eraserSize / 2; this.uniformValues[0] = eraserRadius * eraserRadius; @@ -131,45 +121,18 @@ export class EraserTexturePipeline { this.uniformCache ); - this.actualSegments = this.lineSegments.slice(); - this.lineSegments.length = 0; - - if (this.actualSegments.length === 0) { - return; - } - - if (this.actualSegments.length > EraserTexturePipeline.MAX_LINE_COUNT) { - this.actualSegments = EraserTexturePipeline.subsampleSegments(this.actualSegments); - } - - const lineCount = this.lineCount; - let floatOffset = 0; - for (let i = 0; i < lineCount; i++) { - const segment = this.actualSegments[i]; - floatOffset = this.writeSegmentVertices( - this.vertexUploadData, - floatOffset, - segment.from, - segment.to - ); - } - - this.device.queue.writeBuffer( - this.vertexBuffer, - 0, - this.vertexUploadData, - 0, - floatOffset - ); + this.segments.flush(); } public executeCombined( commandEncoder: GPUCommandEncoder, eraserMaskOut: GPUTextureView, sourceMapOut: GPUTextureView, - trailMapOut: GPUTextureView + trailMapOut: GPUTextureView, + timestampWrites?: GPURenderPassTimestampWrites ): void { - if (this.lineCount === 0) { + const lineCount = this.segments.activeCount; + if (lineCount === 0) { const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { @@ -179,12 +142,13 @@ export class EraserTexturePipeline { storeOp: 'store', }, ], + timestampWrites, }); passEncoder.end(); return; } - const renderPassDescriptor: GPURenderPassDescriptor = { + const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { view: eraserMaskOut, @@ -192,107 +156,21 @@ export class EraserTexturePipeline { loadOp: 'clear', storeOp: 'store', }, - { - view: sourceMapOut, - loadOp: 'load', - storeOp: 'store', - }, - { - view: trailMapOut, - loadOp: 'load', - storeOp: 'store', - }, + { view: sourceMapOut, loadOp: 'load', storeOp: 'store' }, + { view: trailMapOut, loadOp: 'load', storeOp: 'store' }, ], - }; - - const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); + timestampWrites, + }); passEncoder.setPipeline(this.combinedPipeline); this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup); - passEncoder.setVertexBuffer(0, this.vertexBuffer); - passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount); + passEncoder.setVertexBuffer(0, this.segments.vertexBuffer); + passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount); passEncoder.end(); } public destroy(): void { - this.vertexBuffer.destroy(); + this.segments.destroy(); this.uniforms.destroy(); } - - private createPipeline( - shaderModule: GPUShaderModule, - fragmentEntryPoint: string, - targetFormats: Array - ): 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 * - EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT, - stepMode: 'instance', - attributes: [ - { - shaderLocation: 0, - format: 'float32x2', - offset: 0, - }, - { - shaderLocation: 1, - format: 'float32x2', - offset: Float32Array.BYTES_PER_ELEMENT * 2, - }, - ], - }, - ], - }, - fragment: { - module: shaderModule, - entryPoint: fragmentEntryPoint, - targets: targetFormats.map((format) => ({ format })), - }, - primitive: { - topology: 'triangle-list', - }, - }); - } - - private static subsampleSegments(segments: Array): Array { - if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) { - return segments; - } - - const result: Array = []; - for (let i = 0; i < EraserTexturePipeline.MAX_LINE_COUNT; i++) { - const index = Math.round( - (i * (segments.length - 1)) / (EraserTexturePipeline.MAX_LINE_COUNT - 1) - ); - result.push(segments[index]); - } - - return result; - } - - private writeSegmentVertices( - target: Float32Array, - offset: number, - from: vec2, - to: vec2 - ): number { - target[offset++] = from[0]; - target[offset++] = from[1]; - target[offset++] = to[0]; - target[offset++] = to[1]; - return offset; - } - - private get lineCount(): number { - return this.actualSegments.length; - } } diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl index 10948ef..0fccc10 100644 --- a/src/pipelines/eraser/eraser-texture.wgsl +++ b/src/pipelines/eraser/eraser-texture.wgsl @@ -49,7 +49,13 @@ fn fragmentCombined( @location(2) @interpolate(flat) direction: vec2, @location(3) @interpolate(flat) inverseLengthSquared: f32 ) -> EraserCombinedTargets { - if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) { + let distanceSquared = distance_squared_from_segment( + screenPosition, + start, + direction, + inverseLengthSquared + ); + if distanceSquared > settings.eraserRadiusSquared { discard; } @@ -69,55 +75,3 @@ fn getEraserClearValue() -> vec4 { settings.clearAlpha ); } - -fn shouldDiscardEraserFragment( - screenPosition: vec2, - start: vec2, - direction: vec2, - inverseLengthSquared: f32 -) -> bool { - return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared; -} - -fn distanceSquaredFromLine( - position: vec2, - start: vec2, - direction: vec2, - inverseLengthSquared: f32 -) -> f32 { - let pa = position - start; - - let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0); - let nearestOffset = pa - direction * q; - return dot(nearestOffset, nearestOffset); -} - -fn segment_vertex_position( - vertexIndex: u32, - start: vec2, - end: vec2, - radius: f32 -) -> vec2 { - let directionVector = end - start; - let segmentLength = length(directionVector); - var direction = vec2(1.0, 0.0); - if segmentLength > 0.0 { - direction = directionVector / segmentLength; - } - let perpendicular = vec2(direction.y, -direction.x); - let corner = segment_vertex_corner(vertexIndex % 6u); - let center = mix(start, end, (corner.x + 1.0) * 0.5); - return center + direction * corner.x * radius + perpendicular * corner.y * radius; -} - -fn segment_vertex_corner(index: u32) -> vec2 { - let corners = array, 6>( - vec2(-1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(-1.0, -1.0), - vec2(1.0, 1.0), - vec2(1.0, -1.0), - ); - return corners[index]; -} diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index df6b56a..dc99434 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -17,17 +17,19 @@ export interface RenderSettings { backgroundGrainStrength: number; } -export class RenderPipeline { - private static readonly UNIFORM_COUNT = 20; +// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 5 scalars = 20 floats. +const UNIFORM_COUNT = 20; +export class RenderPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPURenderPipeline; private readonly noSourcePipeline: GPURenderPipeline; + private readonly noGrainPipeline: GPURenderPipeline; + private readonly noSourceNoGrainPipeline: GPURenderPipeline; private readonly uniforms: GPUBuffer; - private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT); - private readonly uniformCache = createCachedFloat32BufferWrite( - RenderPipeline.UNIFORM_COUNT - ); + private readonly uniformValues = new Float32Array(UNIFORM_COUNT); + private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); + private useBackgroundGrain = true; private readonly getBindGroup = createBindGroupCache( (colorTexture, sourceTexture) => @@ -46,42 +48,83 @@ export class RenderPipeline { private readonly device: GPUDevice, private readonly commonState: CommonState ) { - this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout); + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: 'uniform' }, + }, + { + binding: 2, + visibility: GPUShaderStage.FRAGMENT, + texture: { sampleType: 'float' }, + }, + { + binding: 3, + visibility: GPUShaderStage.FRAGMENT, + texture: { sampleType: 'float' }, + }, + ], + }); + const shaderModule = smartCompile(device, CommonState.shaderCode, shader); const vertex = setUpFullScreenQuad(device); - const format = navigator.gpu.getPreferredCanvasFormat(); - this.pipeline = this.createPipeline(format, vertex, 'fragment'); - this.noSourcePipeline = this.createPipeline(format, vertex, 'fragmentNoSource'); + const pipelineLayout = device.createPipelineLayout({ + bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + }); + this.pipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragment' + ); + this.noSourcePipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragmentNoSource' + ); + this.noGrainPipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragmentNoGrain' + ); + this.noSourceNoGrainPipeline = this.createPipeline( + pipelineLayout, + vertex, + shaderModule, + format, + 'fragmentNoSourceNoGrain' + ); - this.uniforms = this.device.createBuffer({ - size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, + this.uniforms = device.createBuffer({ + size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); } private createPipeline( - format: GPUTextureFormat, + layout: GPUPipelineLayout, vertex: GPUVertexState, + shaderModule: GPUShaderModule, + format: GPUTextureFormat, fragmentEntryPoint: string ): GPURenderPipeline { return this.device.createRenderPipeline({ - layout: this.device.createPipelineLayout({ - bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], - }), + layout, vertex, fragment: { - module: smartCompile(this.device, CommonState.shaderCode, shader), + module: shaderModule, entryPoint: fragmentEntryPoint, - targets: [ - { - format, - }, - ], - }, - primitive: { - topology: 'triangle-list', + targets: [{ format }], }, + primitive: { topology: 'triangle-list' }, }); } @@ -101,15 +144,13 @@ export class RenderPipeline { this.uniformValues[0] = rgbChannelToUnit(a[0]); this.uniformValues[1] = rgbChannelToUnit(a[1]); this.uniformValues[2] = rgbChannelToUnit(a[2]); - this.uniformValues[3] = 0; + // uniformValues[3], [7], [11] are WGSL vec3→vec4 alignment padding. this.uniformValues[4] = rgbChannelToUnit(b[0]); this.uniformValues[5] = rgbChannelToUnit(b[1]); this.uniformValues[6] = rgbChannelToUnit(b[2]); - this.uniformValues[7] = 0; this.uniformValues[8] = rgbChannelToUnit(c[0]); this.uniformValues[9] = rgbChannelToUnit(c[1]); this.uniformValues[10] = rgbChannelToUnit(c[2]); - this.uniformValues[11] = 0; this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]); this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]); this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]); @@ -118,6 +159,7 @@ export class RenderPipeline { this.uniformValues[17] = renderBrushColorBase; this.uniformValues[18] = renderBrushColorStrengthMultiplier; this.uniformValues[19] = backgroundGrainStrength; + this.useBackgroundGrain = backgroundGrainStrength !== 0; writeFloat32BufferIfChanged( this.device, this.uniforms, @@ -130,28 +172,18 @@ export class RenderPipeline { commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView, sourceTexture: GPUTextureView, - useSourceTexture = true + useSourceTexture = true, + timestampWrites?: GPURenderPassTimestampWrites ): GPUTexture { - const bindGroup = this.getBindGroup(colorTexture, sourceTexture); const canvasTexture = this.context.getCurrentTexture(); - - const renderPassDescriptor: GPURenderPassDescriptor = { - colorAttachments: [ - { - view: canvasTexture.createView(), - clearValue: { r: 0, g: 0, b: 0, a: 1 }, - loadOp: 'clear', - storeOp: 'store', - }, - ], - }; - const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); - passEncoder.setPipeline(useSourceTexture ? this.pipeline : this.noSourcePipeline); - this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, bindGroup); - passEncoder.draw(3, 1); - passEncoder.end(); - + this.encodePass( + commandEncoder, + colorTexture, + sourceTexture, + canvasTexture.createView(), + useSourceTexture, + timestampWrites + ); return canvasTexture; } @@ -159,56 +191,54 @@ export class RenderPipeline { commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView, sourceTexture: GPUTextureView, - outputTexture: GPUTextureView + outputTexture: GPUTextureView, + useSourceTexture = true, + timestampWrites?: GPURenderPassTimestampWrites ) { - const bindGroup = this.getBindGroup(colorTexture, sourceTexture); + this.encodePass( + commandEncoder, + colorTexture, + sourceTexture, + outputTexture, + useSourceTexture, + timestampWrites + ); + } + private encodePass( + commandEncoder: GPUCommandEncoder, + colorTexture: GPUTextureView, + sourceTexture: GPUTextureView, + output: GPUTextureView, + useSourceTexture: boolean, + timestampWrites?: GPURenderPassTimestampWrites + ) { const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { - view: outputTexture, + view: output, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store', }, ], + timestampWrites, }); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline(this.getPipeline(useSourceTexture)); this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, bindGroup); + passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture)); passEncoder.draw(3, 1); passEncoder.end(); } + private getPipeline(useSourceTexture: boolean): GPURenderPipeline { + if (useSourceTexture) { + return this.useBackgroundGrain ? this.pipeline : this.noGrainPipeline; + } + return this.useBackgroundGrain ? this.noSourcePipeline : this.noSourceNoGrainPipeline; + } + public destroy() { this.uniforms.destroy(); } - - private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { - return { - entries: [ - { - binding: 0, - visibility: GPUShaderStage.FRAGMENT, - buffer: { - type: 'uniform', - }, - }, - { - binding: 2, - visibility: GPUShaderStage.FRAGMENT, - texture: { - sampleType: 'float', - }, - }, - { - binding: 3, - visibility: GPUShaderStage.FRAGMENT, - texture: { - sampleType: 'float', - }, - }, - ], - }; - } } diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 4495601..67651c0 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -1,10 +1,10 @@ struct Settings { colorA: vec3, - backgroundColorPadding0: f32, + _colorAPadding: f32, colorB: vec3, - backgroundColorPadding1: f32, + _colorBPadding: f32, colorC: vec3, - backgroundColorPadding2: f32, + _colorCPadding: f32, backgroundColor: vec3, clarity: f32, traceNormalizationFloor: f32, @@ -24,18 +24,32 @@ fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { let pixel = vec2(position.xy); let traces = textureLoad(trailMap, pixel, 0); let sources = textureLoad(sourceMap, pixel, 0); - return renderColor(traces, sources, pixel); + return renderColor(traces, sources, getTexturedBackground(pixel)); } @fragment fn fragmentNoSource(@builtin(position) position: vec4) -> @location(0) vec4 { let pixel = vec2(position.xy); let traces = textureLoad(trailMap, pixel, 0); - return renderColor(traces, vec4(0.0), pixel); + return renderColor(traces, vec4(0.0), getTexturedBackground(pixel)); } -fn renderColor(traces: vec4, sources: vec4, pixel: vec2) -> vec4 { - let background = getTexturedBackground(pixel); +@fragment +fn fragmentNoGrain(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let traces = textureLoad(trailMap, pixel, 0); + let sources = textureLoad(sourceMap, pixel, 0); + return renderColor(traces, sources, getFlatBackground()); +} + +@fragment +fn fragmentNoSourceNoGrain(@builtin(position) position: vec4) -> @location(0) vec4 { + let pixel = vec2(position.xy); + let traces = textureLoad(trailMap, pixel, 0); + return renderColor(traces, vec4(0.0), getFlatBackground()); +} + +fn renderColor(traces: vec4, sources: vec4, background: vec3) -> vec4 { let tracesMax = maxComponent(traces.rgb); let sourcesMax = maxComponent(sources.rgb); if max(tracesMax, sourcesMax) <= 0.0 { @@ -93,7 +107,11 @@ fn maxComponent(v: vec3) -> f32 { } fn clarity(strength: f32) -> f32 { - return pow(clamp(strength, 0, 1), settings.clarity); + let clamped = clamp(strength, 0, 1); + if settings.clarity == 1.0 { + return clamped; + } + return pow(clamped, settings.clarity); } fn normalizeColorIntensity(color: vec3) -> vec3 { @@ -101,10 +119,11 @@ fn normalizeColorIntensity(color: vec3) -> vec3 { return color / max(settings.traceNormalizationFloor, brightestChannel); } +fn getFlatBackground() -> vec3 { + return clamp(settings.backgroundColor, vec3(0), vec3(1)); +} + fn getTexturedBackground(pixel: vec2) -> vec3 { - if settings.backgroundGrainStrength == 0.0 { - return clamp(settings.backgroundColor, vec3(0), vec3(1)); - } let noiseCoord = vec2(vec2(pixel) & vec2(NOISE_TEXTURE_MASK)); let grain = textureLoad(noise, noiseCoord, 0).r - 0.5; diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss index acb3ae9..7da1f51 100644 --- a/src/style/_app-shell.scss +++ b/src/style/_app-shell.scss @@ -1,8 +1,10 @@ -html > body.pre-drawing .dev-stats-overlay, -html > body.is-loading .dev-stats-overlay { +html > body.is-loading .perf-stats-overlay { display: none; } +$grain-noise-a: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='257' height='257' viewBox='0 0 257 257'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' seed='17' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='257' height='257' filter='url(%23n)'/%3E%3C/svg%3E"); +$grain-noise-b: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='389' height='389' viewBox='0 0 389 389'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.53' numOctaves='5' seed='41' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='389' height='389' filter='url(%23n)'/%3E%3C/svg%3E"); + html > body { width: 100%; height: 100vh; @@ -20,16 +22,57 @@ html > body { overflow: hidden; > canvas { + position: relative; + z-index: 0; height: 100%; width: 100%; touch-action: none; } + > .garden-grain { + --garden-grain-strength: 0; + + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + contain: strict; + + &::before, + &::after { + content: ''; + position: absolute; + inset: 0; + } + + &::before { + opacity: clamp(0, calc(var(--garden-grain-strength) * 12), 0.44); + background-image: $grain-noise-a; + background-size: 257px 257px; + filter: contrast(190%) brightness(0.66); + mix-blend-mode: multiply; + } + + &::after { + opacity: clamp(0, calc(var(--garden-grain-strength) * 7), 0.24); + background-image: $grain-noise-b; + background-position: 73px 41px; + background-size: 389px 389px; + filter: contrast(170%) brightness(1.02); + mix-blend-mode: screen; + transform: rotate(0.01deg); + } + + &[hidden] { + display: none; + } + } + > .eraser-preview { position: absolute; top: 0; left: 0; - z-index: 1; + z-index: 3; width: var(--eraser-preview-size, 96px); height: var(--eraser-preview-size, 96px); border: 2px solid rgb(255 234 228 / 88%); @@ -52,7 +95,7 @@ html > body { } } - > .dev-stats-overlay { + > .perf-stats-overlay { position: absolute; top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index d21e043..95f6115 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -1,9 +1,150 @@ -.config-pane { - .color-reaction-folder > .tp-fldv_c { - padding: 6px 8px 8px; +@use 'mixins' as *; + +.config-pane-container { + --config-pane-available-height: calc( + 100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ); + + position: fixed; + top: max(12px, env(safe-area-inset-top, 0px)); + right: max(12px, env(safe-area-inset-right, 0px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 4px; + z-index: 20; + width: min( + 420px, + calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)) + ); + max-height: var(--config-pane-available-height); + pointer-events: none; + + @supports (height: 100dvh) { + --config-pane-available-height: calc( + 100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ); } } +.config-pane-container--open { + pointer-events: auto; +} + +.config-pane { + width: 100%; + max-height: calc(var(--config-pane-available-height) - 36px); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + scrollbar-width: thin; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.config-pane-close { + position: relative; + justify-self: end; + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 0; + border-radius: 4px; + background: transparent; + color: rgb(235 238 245 / 82%); + cursor: pointer; + font: inherit; + font-size: 0; + pointer-events: auto; + transition: + background-color var(--transition-time), + color var(--transition-time); + + &::before, + &::after { + content: ''; + position: absolute; + width: 14px; + height: 2px; + border-radius: 999px; + background: currentColor; + } + + &::before { + transform: rotate(45deg); + } + + &::after { + transform: rotate(-45deg); + } + + &:hover { + background: rgb(255 255 255 / 10%); + color: white; + } + + &:focus-visible { + outline: 2px solid white; + outline-offset: -2px; + } + + &[hidden] { + display: none; + } +} + +@mixin mobile-config-pane() { + .config-pane-container { + --config-pane-available-height: min( + 64vh, + calc( + 100vh - 112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ) + ); + + top: max(8px, env(safe-area-inset-top, 0px)); + right: auto; + left: 50%; + width: min(80vw, 420px); + transform: translateX(-50%); + + @supports (height: 100dvh) { + --config-pane-available-height: min( + 64dvh, + calc( + 100dvh - + 112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) + ) + ); + } + } + + .config-pane { + --tp-blade-value-width: min(128px, 38vw); + --tp-container-unit-size: 18px; + + font-size: 11px; + } + + .config-pane-close { + width: 32px; + height: 32px; + } +} + +@include on-small-screen { + @include mobile-config-pane; +} + +@media (hover: none) and (pointer: coarse) { + @include mobile-config-pane; +} + +.color-reaction-matrix-blade { + padding: 6px 8px 8px; +} + .color-reaction-matrix { display: grid; grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr)); diff --git a/src/style/_motion.scss b/src/style/_motion.scss deleted file mode 100644 index 20d1e66..0000000 --- a/src/style/_motion.scss +++ /dev/null @@ -1,16 +0,0 @@ -@media (prefers-reduced-motion: reduce) { - html > body { - > aside.control-dock { - > .toolbar-row { - > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover, - > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover { - transform: none; - } - - > nav.buttons > button:hover::after { - transform: none; - } - } - } - } -} diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss index 2c89027..ef7ca8c 100644 --- a/src/style/_toolbar.scss +++ b/src/style/_toolbar.scss @@ -1,709 +1,4 @@ -@use 'mixins' as *; - -@mixin toolbar-track() { - height: 7px; - border-radius: 999px; - background: linear-gradient( - 90deg, - rgb(var(--control-rgb) / 72%) 0 var(--control-progress), - rgb(255 255 255 / 24%) var(--control-progress) 100% - ); - box-shadow: inset 0 1px 2px rgb(0 0 0 / 24%); - cursor: ew-resize; -} - -@mixin toolbar-thumb() { - width: var(--thumb-width); - height: var(--thumb-height); - border: 2px solid rgb(255 255 255 / 92%); - border-radius: var(--thumb-radius); - background: var(--thumb-background); - box-shadow: - inset 0 1px 2px rgb(255 255 255 / 22%), - 0 4px 12px rgb(0 0 0 / 30%); - cursor: ew-resize; - transform: var(--thumb-transform); -} - -$toolbar-icons: ( - info: 'info', - maximize-full-screen: 'maximize', - minimize-full-screen: 'minimize', - settings: 'settings', - sound: 'sound', - export-4k: 'download', - restart: 'restart', -); - -html > body > aside.control-dock > .toolbar-row { - --toolbar-background-opacity: 0%; - --toolbar-background-strength: 0; - --toolbar-divider-space: clamp(6px, 1.8vw, 14px); - --toolbar-top-max-width: 594px; - - display: grid; - grid-template-areas: - 'previous controls next' - 'previous divider next' - 'previous buttons next'; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: stretch; - justify-content: center; - width: 100%; - max-width: 100%; - margin: 0 auto; - padding-inline: clamp(8px, 1.4vw, 14px); - column-gap: 0; - row-gap: 0; - border-radius: 12px; - color: rgb(245 250 244 / 92%); - background-color: rgb(5 8 13 / var(--toolbar-background-opacity)); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)), - inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)), - 0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%)); - backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px)) - brightness(calc(1 - var(--toolbar-background-strength) * 0.38)) - saturate(calc(1 - var(--toolbar-background-strength) * 0.18)); - font-size: 13px; - font-weight: 400; - line-height: 1; - transition: - backdrop-filter var(--transition-time-long), - background-color var(--transition-time-long), - box-shadow var(--transition-time-long); - - &::after { - content: ''; - grid-area: divider; - align-self: center; - justify-self: center; - width: min(100%, var(--toolbar-top-max-width)); - height: 1px; - margin-block: var(--toolbar-divider-space); - background: rgb(255 255 255 / 12%); - } - - button { - min-width: 44px; - min-height: 44px; - border: 0; - font: inherit; - cursor: pointer; - transition: - background-color var(--transition-time), - border-color var(--transition-time), - color var(--transition-time), - box-shadow var(--transition-time), - opacity var(--transition-time), - transform var(--transition-time); - - &:disabled { - cursor: progress; - opacity: 0.58; - } - - &:focus-visible { - outline: 2px solid white; - outline-offset: 2px; - } - } - - > .toolbar-shell { - grid-area: controls; - display: grid; - grid-template-areas: 'swatches'; - grid-template-columns: minmax(0, 1fr); - align-items: center; - justify-content: center; - justify-self: center; - width: min(100%, var(--toolbar-top-max-width)); - min-width: 0; - padding: 8px 9px; - } - - > .vibe-button { - position: relative; - display: grid; - place-items: center; - width: 52px; - height: auto; - min-height: 66px; - flex: 0 0 auto; - padding: 0; - border-radius: 0; - background: transparent; - color: rgb(255 255 255 / 70%); - font-size: 0; - line-height: 1; - - &::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 18px; - height: 18px; - border-color: currentColor; - border-style: solid; - border-width: 0 0 3px 3px; - transform: translate(-35%, -50%) rotate(45deg); - } - - &.next-vibe::before { - border-width: 3px 3px 0 0; - transform: translate(-65%, -50%) rotate(45deg); - } - - &:hover { - color: color-mix(in srgb, var(--accent-color) 70%, white); - } - - &.previous-vibe:hover { - transform: translateX(-2px); - } - - &.next-vibe:hover { - transform: translateX(2px); - } - } - - > .previous-vibe { - grid-area: previous; - } - - > .next-vibe { - grid-area: next; - } - - > nav.buttons { - grid-area: buttons; - display: flex; - flex-wrap: nowrap; - align-items: center; - justify-content: center; - justify-self: center; - gap: 4px; - width: fit-content; - max-width: 100%; - min-width: 0; - - > button, - > .audio-control > button { - position: relative; - width: 44px; - height: 44px; - flex: 1 1 44px; - max-width: 54px; - min-width: 0; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - - &::after { - content: ''; - position: absolute; - inset: 0; - z-index: 1; - width: 20px; - height: 20px; - margin: auto; - background-color: rgb(245 250 244 / 76%); - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - transition: - background-color var(--transition-time), - transform var(--transition-time); - } - - &:hover { - border-color: rgb(255 255 255 / 10%); - background: rgb(255 255 255 / 9%); - } - - &:hover::after { - transform: scale(1.08); - } - - &.active { - border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%); - background: color-mix(in srgb, var(--accent-color) 30%, transparent); - } - - &.active::after { - background-color: white; - } - - @each $class, $icon in $toolbar-icons { - &.#{$class}::after { - mask-image: url('../../assets/icons/#{$icon}.svg'); - } - } - - &.sound.muted::before { - content: ''; - position: absolute; - inset: 0; - z-index: 2; - width: 2px; - height: 28px; - margin: auto; - border-radius: 999px; - background: white; - transform: rotate(-45deg); - transform-origin: center; - } - - &.sound.muted::after { - background-color: rgb(255 255 255 / 46%); - } - } - - > .audio-control { - display: flex; - align-items: center; - width: 132px; - height: 44px; - flex: 2 1 132px; - max-width: 150px; - min-width: 0; - padding-right: 10px; - border: 1px solid transparent; - border-radius: 8px; - background: rgb(255 255 255 / 4%); - transition: - border-color var(--transition-time), - background-color var(--transition-time), - box-shadow var(--transition-time), - opacity var(--transition-time); - - &:hover { - border-color: rgb(255 255 255 / 10%); - background: rgb(255 255 255 / 7%); - } - - > button { - flex: 0 0 42px; - min-width: 42px; - border-color: transparent; - - &:focus-visible { - outline-offset: -4px; - } - } - - > .volume-control { - position: relative; - display: grid; - align-items: center; - height: 44px; - flex: 1 1 auto; - min-width: 0; - padding-left: 3px; - cursor: ew-resize; - opacity: 0.96; - transition: opacity var(--transition-time); - - &.muted { - opacity: 0.56; - } - } - - > .volume-control input[type='range'] { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - appearance: none; - background: transparent; - cursor: ew-resize; - outline: none; - touch-action: pan-y; - - &:focus-visible { - border-radius: 8px; - outline: 2px solid white; - outline-offset: -4px; - } - - &::-webkit-slider-runnable-track { - height: 4px; - border-radius: 999px; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--accent-color) 62%, white 8%) 0 - var(--volume-progress, 42%), - rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100% - ); - box-shadow: - inset 0 1px 1px rgb(0 0 0 / 24%), - 0 1px 0 rgb(255 255 255 / 8%); - cursor: ew-resize; - } - - &::-webkit-slider-thumb { - width: 12px; - height: 12px; - border: 2px solid rgb(13 18 24); - border-radius: 50%; - background: rgb(245 250 244); - box-shadow: - 0 0 0 1px rgb(255 255 255 / 46%), - 0 3px 8px rgb(0 0 0 / 28%); - margin-top: -4px; - appearance: none; - transition: - box-shadow var(--transition-time), - transform var(--transition-time); - } - - &::-webkit-slider-thumb:hover { - box-shadow: - 0 0 0 1px rgb(255 255 255 / 56%), - 0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent), - 0 4px 10px rgb(0 0 0 / 34%); - transform: scale(1.08); - } - - &::-moz-range-track { - height: 4px; - border: 0; - border-radius: 999px; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--accent-color) 62%, white 8%) 0 - var(--volume-progress, 42%), - rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100% - ); - box-shadow: - inset 0 1px 1px rgb(0 0 0 / 24%), - 0 1px 0 rgb(255 255 255 / 8%); - } - - &::-moz-range-thumb { - width: 12px; - height: 12px; - border: 2px solid rgb(13 18 24); - border-radius: 50%; - background: rgb(245 250 244); - box-shadow: - 0 0 0 1px rgb(255 255 255 / 46%), - 0 3px 8px rgb(0 0 0 / 28%); - cursor: ew-resize; - } - } - } - - > .export-status { - flex: 0 1 140px; - min-height: 20px; - max-width: 140px; - overflow: hidden; - color: rgb(255 255 255 / 82%); - font-size: 13px; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; - - &:empty { - display: none; - } - } - } - - > .toolbar-shell > .garden-controls { - grid-area: swatches; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - min-width: 0; - padding: 0 4px; - - > .swatches { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - min-height: 58px; - padding: 6px 10px; - - > .color-swatch { - position: relative; - width: 44px; - height: 44px; - border: 2px solid rgb(255 255 255 / 54%); - border-radius: 50%; - box-shadow: - inset 0 0 0 1px rgb(0 0 0 / 16%), - 0 3px 10px rgb(0 0 0 / 22%); - - &:hover { - transform: translateY(-2px); - } - - &.active { - outline: 2px solid rgb(255 255 255 / 96%); - outline-offset: 3px; - box-shadow: - inset 0 0 0 1px rgb(0 0 0 / 14%), - 0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent), - 0 7px 18px rgb(0 0 0 / 26%); - } - } - - > .eraser-size-control, - > .mirror-segment-control { - --thumb-hover-transform: scale(1.03); - --thumb-radius: 50%; - --thumb-transform: none; - - position: relative; - display: grid; - align-items: center; - width: 184px; - height: 46px; - flex: 0 0 184px; - padding: 0 12px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 14%); - border-radius: 8px; - background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 6%), - 0 3px 10px rgb(0 0 0 / 18%); - cursor: ew-resize; - transition: - border-color var(--transition-time), - background-color var(--transition-time), - box-shadow var(--transition-time), - transform var(--transition-time); - - &:hover { - border-color: rgb(255 255 255 / 24%); - transform: translateY(-2px); - } - - &.active { - border-color: rgb(var(--control-rgb) / 72%); - background-color: rgb(var(--control-rgb) / 11%); - box-shadow: - inset 0 0 0 1px rgb(255 255 255 / 10%), - 0 0 0 5px rgb(var(--control-rgb) / 28%), - 0 6px 15px rgb(0 0 0 / 22%); - } - - input[type='range'] { - position: relative; - z-index: 1; - width: 100%; - height: 100%; - appearance: none; - background: transparent; - cursor: ew-resize; - outline: none; - touch-action: pan-y; - - &:focus-visible { - border-radius: 8px; - outline: 2px solid white; - outline-offset: 2px; - } - - &::-webkit-slider-runnable-track { - @include toolbar-track(); - } - - &::-webkit-slider-thumb { - @include toolbar-thumb(); - margin-top: calc((7px - var(--thumb-height)) / 2); - appearance: none; - transition: - box-shadow var(--transition-time), - height var(--transition-time), - margin-top var(--transition-time), - transform var(--transition-time), - width var(--transition-time); - } - - &::-webkit-slider-thumb:hover { - box-shadow: - inset 0 1px 2px rgb(255 255 255 / 22%), - 0 0 0 4px rgb(var(--control-rgb) / 22%), - 0 5px 14px rgb(0 0 0 / 34%); - transform: var(--thumb-hover-transform); - } - - &::-moz-range-track { - @include toolbar-track(); - } - - &::-moz-range-thumb { - @include toolbar-thumb(); - } - } - } - - > .eraser-size-control { - --control-progress: var(--eraser-progress, 33%); - --control-rgb: 255 140 117; - --thumb-background: - linear-gradient( - 110deg, - transparent 0 12%, - rgb(255 255 255 / 44%) 13% 20%, - transparent 21% 100% - ), - linear-gradient( - 90deg, - #ff8fa3 0 52%, - rgb(54 46 51 / 78%) 53% 56%, - #f5eee5 57% 100% - ); - --thumb-height: calc(21px * var(--eraser-control-scale, 1)); - --thumb-hover-transform: rotate(-10deg) scale(1.03); - --thumb-radius: calc(6px * var(--eraser-control-scale, 1)); - --thumb-transform: rotate(-10deg); - --thumb-width: calc(34px * var(--eraser-control-scale, 1)); - } - - > .mirror-segment-control { - --control-progress: var(--mirror-progress, 0%); - --control-rgb: 148 233 203; - --thumb-background: - radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px), - repeating-conic-gradient( - from -90deg, - rgb(218 255 241) 0 8deg, - rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg) - ); - --thumb-height: 44px; - --thumb-width: 44px; - } - } - } - - @include on-small-screen { - --toolbar-divider-space: 4px; - --toolbar-top-max-width: 329px; - - grid-template-areas: - 'previous controls next' - '. divider .' - 'buttons buttons buttons'; - width: 100%; - padding-inline: 4px; - column-gap: 0; - row-gap: 0; - - > .vibe-button { - width: 36px; - min-height: 44px; - - &::before { - width: 14px; - height: 14px; - } - } - - > .toolbar-shell { - padding: 4px; - } - - > nav.buttons { - justify-self: stretch; - justify-content: space-between; - gap: clamp(1px, 0.55vw, 2px); - width: auto; - max-width: none; - margin-inline: -4px; - - > button { - width: auto; - height: 38px; - flex: 1 1 clamp(28px, 8vw, 38px); - max-width: 38px; - min-height: 38px; - - &::after { - width: 17px; - height: 17px; - } - } - - > .audio-control { - width: auto; - height: 38px; - flex: 2 1 clamp(58px, 18vw, 118px); - max-width: 118px; - padding-right: clamp(4px, 1.8vw, 9px); - - > button { - width: auto; - flex: 1 1 clamp(28px, 8vw, 38px); - min-width: 0; - } - - > .volume-control { - height: 38px; - } - } - - > .export-status { - flex-basis: 0; - max-width: 0; - text-align: center; - } - } - - > .toolbar-shell { - > .garden-controls { - padding: 2px 4px; - - > .swatches { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - justify-items: center; - justify-content: stretch; - width: 100%; - min-width: 0; - min-height: 48px; - flex: 1 1 100%; - padding: 3px 5px; - column-gap: 6px; - row-gap: 6px; - - > .color-swatch { - width: 38px; - height: 38px; - min-width: 38px; - min-height: 38px; - - grid-column: span 2; - } - - > .eraser-size-control, - > .mirror-segment-control { - justify-self: stretch; - width: 100%; - min-width: 0; - height: 38px; - padding: 0 7px; - } - - > .eraser-size-control { - grid-column: 1 / span 3; - } - - > .mirror-segment-control { - --thumb-height: 34px; - --thumb-width: 34px; - - grid-column: 4 / span 3; - } - } - } - } - } -} +@use 'toolbar/layout'; +@use 'toolbar/buttons'; +@use 'toolbar/garden-controls'; +@use 'toolbar/responsive'; diff --git a/src/style/toolbar/_buttons.scss b/src/style/toolbar/_buttons.scss new file mode 100644 index 0000000..3551a3b --- /dev/null +++ b/src/style/toolbar/_buttons.scss @@ -0,0 +1,157 @@ +@use 'shared' as *; + +html > body > aside.control-dock > .toolbar-row > nav.buttons { + grid-area: buttons; + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + justify-self: center; + gap: 4px; + width: fit-content; + max-width: 100%; + min-width: 0; + + > button, + > .audio-control > button { + position: relative; + width: 44px; + height: 44px; + flex: 1 1 44px; + max-width: 54px; + min-width: 0; + @include toolbar-control-surface(transparent, rgb(255 255 255 / 9%)); + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + width: 20px; + height: 20px; + margin: auto; + background-color: rgb(245 250 244 / 76%); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + transition: + background-color var(--transition-time), + transform var(--transition-time); + } + + &:hover::after { + transform: scale(1.08); + } + + &.active { + border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%); + background: color-mix(in srgb, var(--accent-color) 30%, transparent); + } + + &.active::after { + background-color: white; + } + + @each $class, $icon in $toolbar-icons { + &.#{$class}::after { + mask-image: url('../../../assets/icons/#{$icon}.svg'); + } + } + + &.sound.muted::before { + content: ''; + position: absolute; + inset: 0; + z-index: 2; + width: 2px; + height: 28px; + margin: auto; + border-radius: 999px; + background: white; + transform: rotate(-45deg); + transform-origin: center; + } + + &.sound.muted::after { + background-color: rgb(255 255 255 / 46%); + } + } + + > .audio-control { + display: flex; + align-items: center; + width: 132px; + height: 44px; + flex: 2 1 132px; + max-width: 150px; + min-width: 0; + padding-right: 10px; + @include toolbar-control-surface(rgb(255 255 255 / 4%), rgb(255 255 255 / 7%)); + + > button { + flex: 0 0 42px; + min-width: 42px; + border-color: transparent; + + &:focus-visible { + outline-offset: -4px; + } + } + + > .volume-control { + --range-progress: var(--volume-progress, 42%); + --range-track-height: 4px; + --range-fill: color-mix(in srgb, var(--accent-color) 62%, white 8%); + --range-empty: rgb(255 255 255 / 18%); + --range-track-shadow: + inset 0 1px 1px rgb(0 0 0 / 24%), 0 1px 0 rgb(255 255 255 / 8%); + --range-thumb-width: 12px; + --range-thumb-height: 12px; + --range-thumb-border: 2px solid rgb(13 18 24); + --range-thumb-radius: 50%; + --range-thumb-background: rgb(245 250 244); + --range-thumb-shadow: 0 0 0 1px rgb(255 255 255 / 46%), 0 3px 8px rgb(0 0 0 / 28%); + --range-thumb-hover-shadow: + 0 0 0 1px rgb(255 255 255 / 56%), + 0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent), + 0 4px 10px rgb(0 0 0 / 34%); + --range-thumb-hover-transform: scale(1.08); + --range-focus-outline-offset: -4px; + + position: relative; + display: grid; + align-items: center; + height: 44px; + flex: 1 1 auto; + min-width: 0; + padding-left: 3px; + cursor: ew-resize; + opacity: 0.96; + transition: opacity var(--transition-time); + + &.muted { + opacity: 0.56; + } + } + + > .volume-control input[type='range'] { + @include toolbar-range-input(); + } + } + + > .export-status { + flex: 0 1 140px; + min-height: 20px; + max-width: 140px; + overflow: hidden; + color: rgb(255 255 255 / 82%); + font-size: 13px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + + &:empty { + display: none; + } + } +} diff --git a/src/style/toolbar/_garden-controls.scss b/src/style/toolbar/_garden-controls.scss new file mode 100644 index 0000000..263dc50 --- /dev/null +++ b/src/style/toolbar/_garden-controls.scss @@ -0,0 +1,148 @@ +@use 'shared' as *; + +html > body > aside.control-dock > .toolbar-row > .toolbar-shell > .garden-controls { + grid-area: swatches; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0 4px; + + > .swatches { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 58px; + padding: 6px 10px; + + > .color-swatch { + position: relative; + width: 44px; + height: 44px; + border: 2px solid rgb(255 255 255 / 54%); + border-radius: 50%; + box-shadow: + inset 0 0 0 1px rgb(0 0 0 / 16%), + 0 3px 10px rgb(0 0 0 / 22%); + + &:hover { + transform: translateY(-2px); + } + + &.active { + outline: 2px solid rgb(255 255 255 / 96%); + outline-offset: 3px; + box-shadow: + inset 0 0 0 1px rgb(0 0 0 / 14%), + 0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent), + 0 7px 18px rgb(0 0 0 / 26%); + } + } + + > .eraser-size-control, + > .mirror-segment-control { + --control-thumb-hover-transform: scale(1.03); + --control-thumb-radius: 50%; + --control-thumb-transform: none; + --range-progress: var(--control-progress); + --range-track-height: 7px; + --range-fill: rgb(var(--control-rgb) / 72%); + --range-empty: rgb(255 255 255 / 24%); + --range-track-shadow: inset 0 1px 2px rgb(0 0 0 / 24%); + --range-thumb-width: var(--control-thumb-width); + --range-thumb-height: var(--control-thumb-height); + --range-thumb-border: 2px solid rgb(255 255 255 / 92%); + --range-thumb-radius: var(--control-thumb-radius); + --range-thumb-background: var(--control-thumb-background); + --range-thumb-shadow: + inset 0 1px 2px rgb(255 255 255 / 22%), 0 4px 12px rgb(0 0 0 / 30%); + --range-thumb-hover-shadow: + inset 0 1px 2px rgb(255 255 255 / 22%), 0 0 0 4px rgb(var(--control-rgb) / 22%), + 0 5px 14px rgb(0 0 0 / 34%); + --range-thumb-hover-transform: var(--control-thumb-hover-transform); + --range-thumb-transform: var(--control-thumb-transform); + --range-thumb-transition: + box-shadow var(--transition-time), height var(--transition-time), + margin-top var(--transition-time), transform var(--transition-time), + width var(--transition-time); + + position: relative; + display: grid; + align-items: center; + width: 184px; + height: 46px; + flex: 0 0 184px; + padding: 0 12px; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 8px; + background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%)); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 6%), + 0 3px 10px rgb(0 0 0 / 18%); + cursor: ew-resize; + transition: + border-color var(--transition-time), + background-color var(--transition-time), + box-shadow var(--transition-time), + transform var(--transition-time); + + &:hover { + border-color: rgb(255 255 255 / 24%); + transform: translateY(-2px); + } + + &.active { + border-color: rgb(var(--control-rgb) / 72%); + background-color: rgb(var(--control-rgb) / 11%); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 10%), + 0 0 0 5px rgb(var(--control-rgb) / 28%), + 0 6px 15px rgb(0 0 0 / 22%); + } + + input[type='range'] { + @include toolbar-range-input(); + } + } + + > .eraser-size-control { + --control-progress: var(--eraser-progress, 33%); + --control-rgb: 255 140 117; + --control-thumb-background: + linear-gradient( + 110deg, + transparent 0 12%, + rgb(255 255 255 / 44%) 13% 20%, + transparent 21% 100% + ), + linear-gradient( + 90deg, + #ff8fa3 0 52%, + rgb(54 46 51 / 78%) 53% 56%, + #f5eee5 57% 100% + ); + --control-thumb-height: calc(21px * var(--eraser-control-scale, 1)); + --control-thumb-hover-transform: rotate(-10deg) scale(1.03); + --control-thumb-radius: calc(6px * var(--eraser-control-scale, 1)); + --control-thumb-transform: rotate(-10deg); + --control-thumb-width: calc(34px * var(--eraser-control-scale, 1)); + } + + > .mirror-segment-control { + --control-progress: var(--mirror-progress, 0%); + --control-rgb: 148 233 203; + --control-thumb-background: + radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px), + repeating-conic-gradient( + from -90deg, + rgb(218 255 241) 0 8deg, + rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg) + ); + --control-thumb-height: 44px; + --control-thumb-width: 44px; + } + } +} diff --git a/src/style/toolbar/_layout.scss b/src/style/toolbar/_layout.scss new file mode 100644 index 0000000..8ee7737 --- /dev/null +++ b/src/style/toolbar/_layout.scss @@ -0,0 +1,137 @@ +@use 'shared' as *; + +html > body > aside.control-dock > .toolbar-row { + --toolbar-background-opacity: 0%; + --toolbar-background-strength: 0; + --toolbar-divider-space: clamp(6px, 1.8vw, 14px); + --toolbar-top-max-width: 594px; + + display: grid; + grid-template-areas: + 'previous controls next' + 'previous divider next' + 'previous buttons next'; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: stretch; + justify-content: center; + width: 100%; + max-width: 100%; + margin: 0 auto; + padding-inline: clamp(8px, 1.4vw, 14px); + column-gap: 0; + row-gap: 0; + border-radius: 12px; + color: rgb(245 250 244 / 92%); + background-color: rgb(5 8 13 / var(--toolbar-background-opacity)); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)), + inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)), + 0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%)); + backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px)) + brightness(calc(1 - var(--toolbar-background-strength) * 0.38)) + saturate(calc(1 - var(--toolbar-background-strength) * 0.18)); + font-size: 13px; + font-weight: 400; + line-height: 1; + transition: + backdrop-filter var(--transition-time-long), + background-color var(--transition-time-long), + box-shadow var(--transition-time-long); + + &::after { + content: ''; + grid-area: divider; + align-self: center; + justify-self: center; + width: min(100%, var(--toolbar-top-max-width)); + height: 1px; + margin-block: var(--toolbar-divider-space); + background: rgb(255 255 255 / 12%); + } + + button { + min-width: 44px; + min-height: 44px; + border: 0; + font: inherit; + cursor: pointer; + @include toolbar-button-transition(); + + &:disabled { + cursor: progress; + opacity: 0.58; + } + + &:focus-visible { + outline: 2px solid white; + outline-offset: 2px; + } + } + + > .toolbar-shell { + grid-area: controls; + display: grid; + grid-template-areas: 'swatches'; + grid-template-columns: minmax(0, 1fr); + align-items: center; + justify-content: center; + justify-self: center; + width: min(100%, var(--toolbar-top-max-width)); + min-width: 0; + padding: 8px 9px; + } + + > .vibe-button { + position: relative; + display: grid; + place-items: center; + width: 52px; + height: auto; + min-height: 66px; + flex: 0 0 auto; + padding: 0; + border-radius: 0; + background: transparent; + color: rgb(255 255 255 / 70%); + font-size: 0; + line-height: 1; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 18px; + border-color: currentColor; + border-style: solid; + border-width: 0 0 3px 3px; + transform: translate(-35%, -50%) rotate(45deg); + } + + &.next-vibe::before { + border-width: 3px 3px 0 0; + transform: translate(-65%, -50%) rotate(45deg); + } + + &:hover { + color: color-mix(in srgb, var(--accent-color) 70%, white); + } + + &.previous-vibe:hover { + transform: translateX(-2px); + } + + &.next-vibe:hover { + transform: translateX(2px); + } + } + + > .previous-vibe { + grid-area: previous; + } + + > .next-vibe { + grid-area: next; + } +} diff --git a/src/style/toolbar/_responsive.scss b/src/style/toolbar/_responsive.scss new file mode 100644 index 0000000..2e66635 --- /dev/null +++ b/src/style/toolbar/_responsive.scss @@ -0,0 +1,156 @@ +@use '../mixins' as *; + +html > body > aside.control-dock > .toolbar-row { + @include on-small-screen { + --toolbar-divider-space: 4px; + --toolbar-top-max-width: 329px; + + grid-template-areas: + 'previous controls next' + '. divider .' + 'buttons buttons buttons'; + width: 100%; + padding-inline: 4px; + column-gap: 0; + row-gap: 0; + + > .vibe-button { + width: 36px; + min-height: 44px; + + &::before { + width: 14px; + height: 14px; + } + } + + > .toolbar-shell { + padding: 4px; + } + + > nav.buttons { + justify-self: stretch; + justify-content: space-between; + gap: clamp(1px, 0.55vw, 2px); + width: auto; + max-width: none; + margin-inline: -4px; + + > button { + width: auto; + height: 38px; + flex: 1 1 clamp(28px, 8vw, 38px); + max-width: 38px; + min-height: 38px; + + &::after { + width: 17px; + height: 17px; + } + } + + > .audio-control { + width: auto; + height: 38px; + flex: 2 1 clamp(58px, 18vw, 118px); + max-width: 118px; + padding-right: clamp(4px, 1.8vw, 9px); + + > button { + width: auto; + flex: 1 1 clamp(28px, 8vw, 38px); + min-width: 0; + } + + > .volume-control { + height: 38px; + } + } + + > .export-status { + flex-basis: 0; + max-width: 0; + text-align: center; + } + } + + > .toolbar-shell > .garden-controls { + padding: 2px 4px; + + > .swatches { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + justify-items: center; + justify-content: stretch; + width: 100%; + min-width: 0; + min-height: 48px; + flex: 1 1 100%; + padding: 3px 5px; + column-gap: 6px; + row-gap: 6px; + + > .color-swatch { + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + + grid-column: span 2; + } + + > .eraser-size-control, + > .mirror-segment-control { + justify-self: stretch; + width: 100%; + min-width: 0; + height: 38px; + padding: 0 7px; + } + + > .eraser-size-control { + grid-column: 1 / span 3; + } + + > .mirror-segment-control { + --control-thumb-height: 34px; + --control-thumb-width: 34px; + + grid-column: 4 / span 3; + } + } + } + } +} + +@media (prefers-reduced-motion: reduce) { + html > body > aside.control-dock > .toolbar-row { + > .vibe-button.previous-vibe:hover, + > .vibe-button.next-vibe:hover, + > .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover, + > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover, + > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover { + transform: none; + } + + > nav.buttons > button:hover::after, + > nav.buttons > .audio-control > button:hover::after { + transform: none; + } + + > nav.buttons > .audio-control > .volume-control input[type='range'] { + &::-webkit-slider-thumb:hover { + transform: none; + } + } + + > .toolbar-shell > .garden-controls > .swatches { + > .eraser-size-control input[type='range'], + > .mirror-segment-control input[type='range'] { + &::-webkit-slider-thumb:hover { + transform: var(--range-thumb-transform, none); + } + } + } + } +} diff --git a/src/style/toolbar/_shared.scss b/src/style/toolbar/_shared.scss new file mode 100644 index 0000000..ce4db3d --- /dev/null +++ b/src/style/toolbar/_shared.scss @@ -0,0 +1,105 @@ +$toolbar-icons: ( + info: 'info', + maximize-full-screen: 'maximize', + minimize-full-screen: 'minimize', + settings: 'settings', + sound: 'sound', + export-4k: 'download', + restart: 'restart', +); + +@mixin toolbar-button-transition() { + transition: + background-color var(--transition-time), + border-color var(--transition-time), + color var(--transition-time), + box-shadow var(--transition-time), + opacity var(--transition-time), + transform var(--transition-time); +} + +@mixin toolbar-control-surface($background, $hover-background) { + border: 1px solid transparent; + border-radius: 8px; + background: $background; + transition: + border-color var(--transition-time), + background-color var(--transition-time), + box-shadow var(--transition-time), + opacity var(--transition-time); + + &:hover { + border-color: rgb(255 255 255 / 10%); + background: $hover-background; + } +} + +@mixin toolbar-range-track() { + height: var(--range-track-height); + border: var(--range-track-border, 0); + border-radius: 999px; + background: linear-gradient( + 90deg, + var(--range-fill) 0 var(--range-progress), + var(--range-empty) var(--range-progress) 100% + ); + box-shadow: var(--range-track-shadow); + cursor: ew-resize; +} + +@mixin toolbar-range-thumb() { + width: var(--range-thumb-width); + height: var(--range-thumb-height); + border: var(--range-thumb-border); + border-radius: var(--range-thumb-radius); + background: var(--range-thumb-background); + box-shadow: var(--range-thumb-shadow); + cursor: ew-resize; + transform: var(--range-thumb-transform, none); +} + +@mixin toolbar-range-input() { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + appearance: none; + background: transparent; + cursor: ew-resize; + outline: none; + touch-action: pan-y; + + &:focus-visible { + border-radius: 8px; + outline: 2px solid white; + outline-offset: var(--range-focus-outline-offset, 2px); + } + + &::-webkit-slider-runnable-track { + @include toolbar-range-track(); + } + + &::-webkit-slider-thumb { + @include toolbar-range-thumb(); + margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) / 2); + appearance: none; + transition: var( + --range-thumb-transition, + box-shadow var(--transition-time), + transform var(--transition-time) + ); + } + + &::-webkit-slider-thumb:hover { + box-shadow: var(--range-thumb-hover-shadow, var(--range-thumb-shadow)); + transform: var(--range-thumb-hover-transform, var(--range-thumb-transform, none)); + } + + &::-moz-range-track { + @include toolbar-range-track(); + } + + &::-moz-range-thumb { + @include toolbar-range-thumb(); + } +} diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts index 200a7f6..759f04f 100644 --- a/src/utils/delta-time-calculator.ts +++ b/src/utils/delta-time-calculator.ts @@ -4,10 +4,7 @@ import { clamp } from './math'; export class DeltaTimeCalculator { private previousTime: DOMHighResTimeStamp | null = null; - constructor( - private readonly maxDeltaTimeInSeconds?: number, - private readonly minDeltaTimeInSeconds?: number - ) { + constructor() { document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); } @@ -20,7 +17,11 @@ export class DeltaTimeCalculator { const delta = currentTime - this.previousTime; this.previousTime = currentTime; - return clamp(delta / 1000, this.minDeltaTime, this.maxDeltaTime); + return clamp( + delta / 1000, + appConfig.deltaTime.minDeltaTimeSeconds, + appConfig.deltaTime.maxDeltaTimeSeconds + ); } private handleVisibilityChange() { @@ -28,12 +29,4 @@ export class DeltaTimeCalculator { this.previousTime = null; } } - - private get maxDeltaTime(): number { - return this.maxDeltaTimeInSeconds ?? appConfig.deltaTime.maxDeltaTimeSeconds; - } - - private get minDeltaTime(): number { - return this.minDeltaTimeInSeconds ?? appConfig.deltaTime.minDeltaTimeSeconds; - } } diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts index d4fe444..28ecb84 100644 --- a/src/utils/graphics/bind-group-cache.ts +++ b/src/utils/graphics/bind-group-cache.ts @@ -17,3 +17,32 @@ export const createBindGroupCache = ( return bindGroup; }; }; + +export const createBindGroupCache3 = < + K1 extends object, + K2 extends object, + K3 extends object, +>( + factory: (key1: K1, key2: K2, key3: K3) => GPUBindGroup +): ((key1: K1, key2: K2, key3: K3) => GPUBindGroup) => { + const outer = new WeakMap>>(); + return (key1, key2, key3) => { + let mid = outer.get(key1); + if (!mid) { + mid = new WeakMap(); + outer.set(key1, mid); + } + let inner = mid.get(key2); + if (!inner) { + inner = new WeakMap(); + mid.set(key2, inner); + } + const cached = inner.get(key3); + if (cached) { + return cached; + } + const bindGroup = factory(key1, key2, key3); + inner.set(key3, bindGroup); + return bindGroup; + }; +}; diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts index 48df957..9108060 100644 --- a/src/utils/graphics/initialize-gpu.ts +++ b/src/utils/graphics/initialize-gpu.ts @@ -94,15 +94,21 @@ export const initializeGpu = async (): Promise => { } const requiredLimits = getRequiredLimits(adapter.limits); + const requiredFeatures: Array = []; + if (adapter.features.has('timestamp-query')) { + requiredFeatures.push('timestamp-query'); + } ErrorHandler.addMetadata('webgpuAdapter', { features: Array.from(adapter.features).sort(), info: getAdapterInfo(adapter), + requiredFeatures, requiredLimits, }); let gpuDevice: GPUDevice; try { gpuDevice = await adapter.requestDevice({ + requiredFeatures, requiredLimits, }); } catch (error) { @@ -113,6 +119,7 @@ export const initializeGpu = async (): Promise => { cause: error, details: { causeMessage: getErrorMessage(error), + requiredFeatures, requiredLimits, }, } diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts index d975edc..bd6ef19 100644 --- a/src/utils/graphics/resizable-texture.ts +++ b/src/utils/graphics/resizable-texture.ts @@ -6,6 +6,14 @@ interface ResizableTextureOptions { usage?: GPUTextureUsageFlags; } +export interface PendingTextureResize { + copySize: GPUExtent3DStrict; + newSize: vec2; + newTexture: GPUTexture; + newTextureView: GPUTextureView; + oldTexture: GPUTexture; +} + export class ResizableTexture { private texture: GPUTexture; private textureView: GPUTextureView; @@ -32,10 +40,22 @@ export class ResizableTexture { } public resize(size: vec2): void { - if (vec2.equals(this.size, size)) { + const resize = this.prepareResize(size); + if (!resize) { return; } + const commandEncoder = this.device.createCommandEncoder(); + this.encodeResize(commandEncoder, resize); + this.device.queue.submit([commandEncoder.finish()]); + this.commitResize(resize); + } + + public prepareResize(size: vec2): PendingTextureResize | null { + if (vec2.equals(this.size, size)) { + return null; + } + const newTexture = this.createTexture(size); const newTextureView = newTexture.createView(); const copySize = { @@ -43,11 +63,23 @@ export class ResizableTexture { height: Math.min(this.size[1], size[1]), }; - const commandEncoder = this.device.createCommandEncoder(); + return { + copySize, + newSize: vec2.clone(size), + newTexture, + newTextureView, + oldTexture: this.texture, + }; + } + + public encodeResize( + commandEncoder: GPUCommandEncoder, + resize: PendingTextureResize + ): void { const clearPass = commandEncoder.beginRenderPass({ colorAttachments: [ { - view: newTextureView, + view: resize.newTextureView, clearValue: this.clearValue, loadOp: 'clear', storeOp: 'store', @@ -56,16 +88,17 @@ export class ResizableTexture { }); clearPass.end(); commandEncoder.copyTextureToTexture( - { texture: this.texture }, - { texture: newTexture }, - copySize + { texture: resize.oldTexture }, + { texture: resize.newTexture }, + resize.copySize ); - this.device.queue.submit([commandEncoder.finish()]); - this.texture.destroy(); + } - this.size = vec2.clone(size); - this.texture = newTexture; - this.textureView = newTextureView; + public commitResize(resize: PendingTextureResize): void { + resize.oldTexture.destroy(); + this.size = resize.newSize; + this.texture = resize.newTexture; + this.textureView = resize.newTextureView; } public getSize(): vec2 { diff --git a/vite.config.ts b/vite.config.ts index cfe208b..6c4b293 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,10 @@ const esbuildTargets = browserslistToEsbuild(); export default defineConfig(({ command }) => ({ base: './', plugins: [ - viteSingleFile({ useRecommendedBuildConfig: false }), + viteSingleFile({ + inlinePattern: ['index-*.js', 'style-*.css'], + useRecommendedBuildConfig: false, + }), ...(command === 'serve' ? [basicSsl()] : []), ], css: {