diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 3bb5949..bbf03a4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -32,6 +32,7 @@ jobs: - name: Test run: | npm run lint:check + npm run format:check npm run typecheck npm run typecheck:e2e npm test @@ -40,6 +41,10 @@ jobs: run: | npm run test:e2e + - name: Build + run: | + npm run build + - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 0f59a68..db39815 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules dist test-results +.DS_Store +*.log diff --git a/.nvmrc b/.nvmrc index 2bd5a0a..6fa8dec 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +22.13.0 diff --git a/.prettierrc b/.prettierrc index 2a3ea53..fef36af 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,5 +6,5 @@ "endOfLine": "lf", "plugins": ["@ianvs/prettier-plugin-sort-imports"], "importOrder": ["", "", "", "^[./]"], - "importOrderTypeScriptVersion": "5.0.0" + "importOrderTypeScriptVersion": "6.0.3" } diff --git a/assets/icons/download.svg b/assets/icons/download.svg index 423bc7d..eb27c41 100644 --- a/assets/icons/download.svg +++ b/assets/icons/download.svg @@ -2,9 +2,9 @@ diff --git a/assets/icons/info.svg b/assets/icons/info.svg index 3573e28..19e44e5 100644 --- a/assets/icons/info.svg +++ b/assets/icons/info.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg index 7239da3..bf1f311 100644 --- a/assets/icons/maximize.svg +++ b/assets/icons/maximize.svg @@ -1,7 +1,7 @@ - + - \ No newline at end of file + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg index 2ffe1f7..19e8e7f 100644 --- a/assets/icons/minimize.svg +++ b/assets/icons/minimize.svg @@ -1,7 +1,7 @@ - + - \ No newline at end of file + diff --git a/assets/icons/restart.svg b/assets/icons/restart.svg index a58d2a6..d3dce2c 100644 --- a/assets/icons/restart.svg +++ b/assets/icons/restart.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg index ba7478f..9d5e6fd 100644 --- a/assets/icons/settings.svg +++ b/assets/icons/settings.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/sound.svg b/assets/icons/sound.svg index d440a9c..4f382b5 100644 --- a/assets/icons/sound.svg +++ b/assets/icons/sound.svg @@ -1,3 +1,3 @@ - + diff --git a/definitions.d.ts b/definitions.d.ts deleted file mode 100644 index c90ad44..0000000 --- a/definitions.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module '*.wgsl?raw' { - const content: string; - export default content; -} - -interface HTMLCanvasElement { - getContext(contextId: 'webgpu'): GPUCanvasContext | null; -} diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 19f0efd..1caa3f2 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,7 +1,12 @@ -import { expect, test, type Page } from '@playwright/test'; +import { test as base, expect, type Page } from '@playwright/test'; const canvasName = 'Interactive generative garden canvas'; +interface BrowserDiagnostics { + browserFailures: Array; + consoleErrors: Array; +} + const isLocalUrl = (url: string) => { const { hostname } = new URL(url); return hostname === '127.0.0.1' || hostname === 'localhost'; @@ -29,6 +34,27 @@ const collectLocalBrowserFailures = (page: Page) => { return failures; }; +const test = base.extend<{ browserDiagnostics: BrowserDiagnostics }>({ + browserDiagnostics: [ + async ({ page }, use) => { + const browserFailures = collectLocalBrowserFailures(page); + const consoleErrors: Array = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await use({ browserFailures, consoleErrors }); + + expect(consoleErrors).toEqual([]); + expect(browserFailures).toEqual([]); + }, + { auto: true }, + ], +}); + const disableWebGpu = async (page: Page) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'gpu', { @@ -39,14 +65,6 @@ const disableWebGpu = async (page: Page) => { }; test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { - const browserFailures = collectLocalBrowserFailures(page); - const consoleErrors: Array = []; - page.on('console', (message) => { - if (message.type() === 'error') { - consoleErrors.push(message.text()); - } - }); - await page.addInitScript((expectedCanvasName) => { const captureState = { count: 0 }; Object.defineProperty(window, '__fleetingGardenPointerCaptures', { @@ -68,10 +86,10 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { }, canvasName); await page.goto('/'); - const startButton = page.getByRole('button', { name: 'Start' }); + const startButton = page.getByRole('button', { exact: true, name: 'Start' }); await expect(startButton).toBeVisible(); await expect(startButton).toBeEnabled({ timeout: 30_000 }); - await startButton.click(); + await page.keyboard.press('Enter'); await expect(page.locator('body')).not.toHaveClass(/is-loading/, { timeout: 30_000, }); @@ -117,13 +135,21 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { ) .toBeGreaterThan(0); - expect(consoleErrors).toEqual([]); - expect(browserFailures).toEqual([]); + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __fleetingGardenBrushPasses?: number; + } + ).__fleetingGardenBrushPasses ?? 0 + ) + ) + .toBeGreaterThan(0); }); test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => { - const browserFailures = collectLocalBrowserFailures(page); - await disableWebGpu(page); await page.goto('/'); @@ -135,7 +161,19 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => { const fallback = page.getByRole('alert'); await expect(fallback).toContainText('Fleeting Garden needs WebGPU'); await expect(fallback).toContainText('webgpu-unsupported'); - expect(browserFailures).toEqual([]); +}); + +test('syncs the selected vibe with the URI', async ({ page }) => { + await disableWebGpu(page); + await page.goto('/?vibe=Aurora%20Mycelium'); + + await expect(page).toHaveURL(/vibe=aurora-mycelium/); + + await page.getByRole('button', { name: 'Next vibe' }).click(); + await expect(page).toHaveURL(/vibe=velvet-observatory/); + + await page.goBack(); + await expect(page).toHaveURL(/vibe=aurora-mycelium/); }); test('keeps audio focus outlines scoped to the active control', async ({ page }) => { @@ -144,8 +182,8 @@ test('keeps audio focus outlines scoped to the active control', async ({ page }) await expect(page.locator('body')).not.toHaveClass(/is-loading/); const audioControl = page.locator('.audio-control'); - const soundButton = page.locator('button.sound'); - const volumeSlider = page.locator('.volume-slider'); + const soundButton = page.getByRole('button', { name: /audio/i }); + const volumeSlider = page.getByRole('slider', { name: 'Master volume' }); await soundButton.click(); await expect(audioControl).toHaveCSS('outline-style', 'none'); @@ -178,14 +216,14 @@ test('keeps the config overlay scrollable and dismissible on mobile', async ({ await page.setViewportSize({ width: 390, height: 640 }); await page.goto('/'); - const startButton = page.getByRole('button', { name: 'Start' }); + const startButton = page.getByRole('button', { exact: true, 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'); + const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); await settingsButton.click(); const pane = page.locator('.config-pane'); diff --git a/package-lock.json b/package-lock.json index 89cdaff..7c0cc30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,13 @@ "license": "Unlicense", "dependencies": { "@plausible-analytics/tracker": "^0.4.5", - "tweakpane": "^4.0.5" + "tweakpane": "~4.0.5" }, "devDependencies": { "@eslint/js": "^10.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@playwright/test": "^1.60.0", - "@tweakpane/core": "^2.0.5", + "@tweakpane/core": "~2.0.5", "@types/node": "^25.6.0", "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-basic-ssl": "^2.3.0", @@ -38,7 +38,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" } }, "node_modules/@babel/code-frame": { @@ -2774,9 +2774,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b8cbfec..a65ec2a 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "generate-icons": "pwa-assets-generator" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" }, "repository": { "type": "git", @@ -48,7 +48,7 @@ "@eslint/js": "^10.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@playwright/test": "^1.60.0", - "@tweakpane/core": "^2.0.5", + "@tweakpane/core": "~2.0.5", "@types/node": "^25.6.0", "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-basic-ssl": "^2.3.0", @@ -71,6 +71,6 @@ }, "dependencies": { "@plausible-analytics/tracker": "^0.4.5", - "tweakpane": "^4.0.5" + "tweakpane": "~4.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts index e62296a..01f7ce9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: isCi, retries: isCi ? 2 : 0, - workers: isCi ? 1 : undefined, + workers: 1, reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list', use: { baseURL, diff --git a/public/og-image.jpg b/public/og-image.jpg index 03c8939..0ced8fc 100644 Binary files a/public/og-image.jpg and b/public/og-image.jpg differ diff --git a/src/analytics.ts b/src/analytics.ts index ddfb650..b19958a 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -4,6 +4,7 @@ import { type PlausibleEventOptions, } from '@plausible-analytics/tracker'; +import { appConfig } from './config'; import type { VibeId } from './vibes'; let isInitialized = false; @@ -23,10 +24,10 @@ export const initAnalytics = () => { try { plausibleInit({ - domain: 'schmelczer.dev/floating', - endpoint: 'https://stats.schmelczer.dev/status', - autoCapturePageviews: true, - logging: true, + domain: appConfig.analytics.domain, + endpoint: appConfig.analytics.endpoint, + autoCapturePageviews: appConfig.analytics.autoCapturePageviews, + logging: appConfig.analytics.logging, }); isInitialized = true; } catch (error) { diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts index d5c8d70..b5fbc1d 100644 --- a/src/audio/garden-audio-config.ts +++ b/src/audio/garden-audio-config.ts @@ -1,9 +1,13 @@ -import { DEFAULT_AUDIO_VOLUME } from '../consts'; import type { PianoNoteRole } from './garden-audio-types'; +export const DEFAULT_AUDIO_VOLUME = 0.5; +export const SILENT_AUDIO_GAIN = 0.0001; + +type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4'; + export interface GardenAudioChord { rootOffset: number; - quality: 'major' | 'minor'; + quality: GardenAudioChordQuality; } export interface GardenAudioVibeSettings { @@ -14,6 +18,8 @@ export interface GardenAudioVibeSettings { noteLength: number; notePitchOffset: number; brightness: number; + scale?: Array; + progression?: Array; } export interface GardenAudioVibeProfile extends GardenAudioVibeSettings { @@ -37,7 +43,9 @@ export const createGardenAudioConfig = () => ({ fadeInSeconds: 0.45, updateRampSeconds: 0.08, delay: { - timeSeconds: 0.405, + timeBeats: 0.5, + timeMinSeconds: 0.18, + timeMaxSeconds: 0.72, feedback: 0.12, wetGain: 0.044, erasingActivity: 0.12, diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts index 88010ea..a288465 100644 --- a/src/audio/garden-audio-graph.ts +++ b/src/audio/garden-audio-graph.ts @@ -1,7 +1,9 @@ import { clamp } from '../utils/math'; -import type { GardenAudioConfig } from './garden-audio-config'; +import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config'; import type { PianoNoteRole } from './garden-audio-types'; +type AudioSessionType = NonNullable['type']; + type NavigatorWithAudioSession = Navigator & { audioSession?: { type: @@ -17,14 +19,14 @@ type NavigatorWithAudioSession = Navigator & { const outputHighPassFrequencyHz = 45; const noiseBufferDurationSeconds = 1; const graphTuning = { - closeGain: 0.0001, + closeGain: SILENT_AUDIO_GAIN, closeRampSeconds: 0.015, delayMaxSeconds: 2, eventBusGain: 1, noiseMax: 1, noiseMin: -1, - latencyHint: 'interactive' as AudioContextLatencyCategory, - outputFilterType: 'highpass' as BiquadFilterType, + latencyHint: 'interactive', + outputFilterType: 'highpass', compressor: { thresholdDb: -18, kneeDb: 18, @@ -32,7 +34,7 @@ const graphTuning = { attackSeconds: 0.018, releaseSeconds: 0.18, }, -}; +} as const; const delayFilterTuning = { feedbackHighPassHz: 180, feedbackLowPassHz: 5200, @@ -54,6 +56,7 @@ export class GardenAudioGraph { private pianoBusGainScale = 1; private pianoBusGainScaleAutomationUntil = 0; private pianoBusGainScaleTimeConstantSeconds = 0; + private previousAudioSessionType: AudioSessionType | null = null; private readonly pianoBuses = new Map(); public constructor(private readonly config: GardenAudioConfig) {} @@ -77,6 +80,7 @@ export class GardenAudioGraph { // Audio Session API. const audioSession = (navigator as NavigatorWithAudioSession).audioSession; if (audioSession) { + this.previousAudioSessionType ??= audioSession.type; audioSession.type = 'playback'; } @@ -121,19 +125,19 @@ export class GardenAudioGraph { ); } - public applyDelayProfile(): void { + public applyDelayProfile(bpm: number): void { if (!this.context || !this.delayNode) { return; } this.delayNode.delayTime.setTargetAtTime( - this.config.delay.timeSeconds, + this.getDelayTimeSecondsForBpm(bpm), this.context.currentTime, this.config.delay.timeRampSeconds ); } - public updateDelay(activity: number): void { + public updateDelay(activity: number, bpm: number): void { if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { return; } @@ -141,7 +145,7 @@ export class GardenAudioGraph { const now = this.context.currentTime; const normalizedActivity = clamp(activity, 0, 1); this.delayNode.delayTime.setTargetAtTime( - this.config.delay.timeSeconds, + this.getDelayTimeSecondsForBpm(bpm), now, this.config.delay.timeRampSeconds ); @@ -203,6 +207,21 @@ export class GardenAudioGraph { if (context.state !== 'closed') { await context.close().catch(() => undefined); } + + this.restoreAudioSessionType(); + } + + private restoreAudioSessionType(): void { + const previousType = this.previousAudioSessionType; + this.previousAudioSessionType = null; + if (previousType === null) { + return; + } + + const audioSession = (navigator as NavigatorWithAudioSession).audioSession; + if (audioSession) { + audioSession.type = previousType; + } } private createDelay(context: AudioContext, masterGain: GainNode): void { @@ -214,7 +233,7 @@ export class GardenAudioGraph { const feedbackLowPass = context.createBiquadFilter(); const returnLowPass = context.createBiquadFilter(); - delayNode.delayTime.value = this.config.delay.timeSeconds; + delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm); delayFeedback.gain.value = this.config.delay.feedback; delayOutput.gain.value = this.config.delay.wetGain; feedbackHighPass.type = 'highpass'; @@ -283,6 +302,15 @@ export class GardenAudioGraph { }); } + private getDelayTimeSecondsForBpm(bpm: number): number { + const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm; + return clamp( + (60 / safeBpm) * this.config.delay.timeBeats, + this.config.delay.timeMinSeconds, + this.config.delay.timeMaxSeconds + ); + } + private createNoiseBuffer(context: AudioContext): AudioBuffer { const buffer = context.createBuffer( 1, diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts index 67be4bf..a0e5e7b 100644 --- a/src/audio/garden-audio-music.ts +++ b/src/audio/garden-audio-music.ts @@ -13,22 +13,21 @@ const DEFAULT_PROGRESSION: ReadonlyArray = [ const DEFAULT_ROOT_MIDI = 57; const DEFAULT_SCALE: ReadonlyArray = [0, 2, 4, 7, 9]; -const profileCache = new WeakMap(); +const getProfileScale = (vibe: VibePreset): Array => { + const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE; + return [...scale]; +}; + +const getProfileProgression = (vibe: VibePreset): Array => + (vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map( + (chord) => ({ ...chord }) + ); export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => { - let profile = profileCache.get(vibe); - if (!profile) { - profile = { - ...vibe.audio, - rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset, - scale: DEFAULT_SCALE as Array, - progression: DEFAULT_PROGRESSION as Array, - }; - profileCache.set(vibe, profile); - return profile; - } - - Object.assign(profile, vibe.audio); - profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset; - return profile; + return { + ...vibe.audio, + rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset, + scale: getProfileScale(vibe), + progression: getProfileProgression(vibe), + }; }; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts index 3a0a592..fecbcf8 100644 --- a/src/audio/garden-audio-types.ts +++ b/src/audio/garden-audio-types.ts @@ -1,4 +1,4 @@ -import { VibePreset } from '../vibes'; +import type { VibePreset } from '../vibes'; export interface GardenAudioSnapshot { vibe: VibePreset; diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts index fa92c10..3aa4c88 100644 --- a/src/audio/garden-audio.ts +++ b/src/audio/garden-audio.ts @@ -1,7 +1,11 @@ import { ErrorHandler, Severity } from '../utils/error-handler'; import { clamp01 } from '../utils/math'; import type { VibeId, VibePreset } from '../vibes'; -import type { GardenAudioConfig } from './garden-audio-config'; +import { + SILENT_AUDIO_GAIN, + type GardenAudioConfig, + type GardenAudioVibeProfile, +} from './garden-audio-config'; import { GardenAudioEnergy } from './garden-audio-energy'; import { GardenAudioGestureState } from './garden-audio-gesture-state'; import { GardenAudioGraph } from './garden-audio-graph'; @@ -13,8 +17,12 @@ import { NoiseBurstPlayer } from './noise-burst-player'; import { PianoSampler } from './piano-sampler'; type AudioLifecycle = 'idle' | 'started' | 'destroyed'; +type PianoReleasePhase = + | { kind: 'idle' } + | { kind: 'awaiting-fade' } + | { kind: 'scheduled-fade'; fadeAt: number } + | { kind: 'settling'; stopAt: number }; -const muteGain = 0.0001; const muteRampSeconds = 0.02; const brushUpPianoBusFadeSeconds = 2.4; const brushUpPianoBusFadeSettleSeconds = 3.2; @@ -29,16 +37,16 @@ export class GardenAudio { private readonly pianoEngine: GenerativePianoEngine; private currentVibeId: VibeId | null = null; + private currentVibe: VibePreset | null = null; private lifecycle: AudioLifecycle = 'idle'; - private isReleasingPiano = false; + private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' }; private isMuted = false; private isGestureActive = false; - private fadePianoAt: number | null = null; private masterVolume: number; - private stopPianoAt: number | null = null; private lastEraserAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY; private startRequestId = 0; + private hasLoadedPiano = false; public constructor(private readonly config: GardenAudioConfig) { this.masterVolume = clamp01(config.masterVolume); @@ -60,7 +68,8 @@ export class GardenAudio { if ( this.lifecycle === 'started' && this.currentVibeId === vibe.id && - this.graph.context?.state === 'running' + this.graph.context?.state === 'running' && + this.hasLoadedPiano ) { return; } @@ -74,6 +83,7 @@ export class GardenAudio { ? muteRampSeconds : this.config.fadeInSeconds; const needsResume = context.state !== 'running' && context.state !== 'closed'; + const startRequestId = ++this.startRequestId; if (needsResume) { if (!isUserGesture) { @@ -83,7 +93,7 @@ export class GardenAudio { .resume() .then(() => { if (this.graph.context === context && this.lifecycle !== 'destroyed') { - this.completeStart(vibe, { context, startupRampSeconds }); + this.completeStart(vibe, { context, startupRampSeconds, startRequestId }); } }) .catch((error) => { @@ -95,16 +105,18 @@ export class GardenAudio { return; } - this.completeStart(vibe, { context, startupRampSeconds }); + this.completeStart(vibe, { context, startupRampSeconds, startRequestId }); } private completeStart( vibe: VibePreset, { context, + startRequestId, startupRampSeconds, }: { context: AudioContext; + startRequestId: number; startupRampSeconds: number; } ): void { @@ -113,11 +125,11 @@ export class GardenAudio { } if (this.isMuted) { - this.graph.setMasterGain(muteGain, muteRampSeconds); + this.activateMutedStart(vibe, context); + this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds); return; } - const startRequestId = ++this.startRequestId; void this.piano .load(context) .then(() => { @@ -155,11 +167,25 @@ export class GardenAudio { ): void { this.lifecycle = 'started'; this.currentVibeId = vibe.id; - this.graph.applyDelayProfile(); + this.currentVibe = vibe; + const profile = getVibeProfile(vibe); + this.graph.applyDelayProfile(profile.bpm); this.graph.setMasterGain(this.masterVolume, startupRampSeconds); if (cuePiano) { - this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); + this.hasLoadedPiano = true; + this.pianoEngine.cue(context.currentTime, profile); + } + } + + private activateMutedStart(vibe: VibePreset, context: AudioContext): void { + this.lifecycle = 'started'; + this.currentVibeId = vibe.id; + this.currentVibe = vibe; + this.hasLoadedPiano = false; + this.graph.applyDelayProfile(getVibeProfile(vibe).bpm); + if (this.graph.context === context) { + this.pianoEngine.reset(); } } @@ -170,6 +196,7 @@ export class GardenAudio { if (didChangeVibe) { this.piano.stopAll(); + this.hasLoadedPiano = false; } const context = this.graph.context; @@ -191,9 +218,13 @@ export class GardenAudio { this.isMuted = isMuted; this.graph.setMasterGain( - isMuted ? muteGain : this.masterVolume, + isMuted ? SILENT_AUDIO_GAIN : this.masterVolume, isMuted ? muteRampSeconds : this.config.fadeInSeconds ); + + if (!isMuted && this.currentVibe && !this.hasLoadedPiano) { + this.start(this.currentVibe); + } } public setMasterVolume(masterVolume: number): void { @@ -210,9 +241,7 @@ export class GardenAudio { } this.isGestureActive = true; - this.isReleasingPiano = false; - this.fadePianoAt = null; - this.stopPianoAt = null; + this.pianoReleasePhase = { kind: 'idle' }; this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds); this.gestureState.reset(); this.energy.beginGesture(context.currentTime); @@ -222,9 +251,7 @@ export class GardenAudio { public endGesture(): void { this.gestureState.reset(); this.isGestureActive = false; - this.isReleasingPiano = true; - this.fadePianoAt = null; - this.stopPianoAt = null; + this.pianoReleasePhase = { kind: 'awaiting-fade' }; this.energy.endGesture(); this.pianoEngine.endGesture(); } @@ -243,9 +270,9 @@ export class GardenAudio { this.energy.silence(); } - if (!this.isGestureActive && this.isReleasingPiano) { + if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') { this.updatePianoRelease(snapshot.vibe, context.currentTime); - this.updateDelay(snapshot); + this.updateDelay(snapshot, profile); return; } @@ -256,7 +283,7 @@ export class GardenAudio { ? this.config.eraser.pianoActivity : this.energy.getLevel(), }); - this.updateDelay(snapshot); + this.updateDelay(snapshot, profile); } public stroke(stroke: GardenAudioStroke): void { @@ -298,14 +325,14 @@ export class GardenAudio { await this.graph.close(); this.piano.reset(); + this.hasLoadedPiano = false; this.energy.reset(); this.gestureState.reset(); this.pianoEngine.reset(); this.currentVibeId = null; + this.currentVibe = null; this.isGestureActive = false; - this.isReleasingPiano = false; - this.fadePianoAt = null; - this.stopPianoAt = null; + this.pianoReleasePhase = { kind: 'idle' }; this.lastEraserAt = Number.NEGATIVE_INFINITY; this.lastVibeStingerAt = Number.NEGATIVE_INFINITY; } @@ -326,21 +353,41 @@ export class GardenAudio { } private updatePianoRelease(vibe: VibePreset, now: number): void { - if (this.fadePianoAt === null && this.stopPianoAt === null) { - this.fadePianoAt = this.pianoEngine.release(vibe, now); - } + if (this.pianoReleasePhase.kind === 'awaiting-fade') { + const fadeAt = this.pianoEngine.release(vibe, now); + if (now < fadeAt) { + this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt }; + return; + } - if (this.fadePianoAt !== null && now >= this.fadePianoAt) { this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds); - this.fadePianoAt = null; - this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds; + this.pianoReleasePhase = { + kind: 'settling', + stopAt: now + brushUpPianoBusFadeSettleSeconds, + }; + return; } - if (this.stopPianoAt !== null && now >= this.stopPianoAt) { + if ( + this.pianoReleasePhase.kind === 'scheduled-fade' && + now >= this.pianoReleasePhase.fadeAt + ) { + this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds); + this.pianoReleasePhase = { + kind: 'settling', + stopAt: now + brushUpPianoBusFadeSettleSeconds, + }; + return; + } + + if ( + this.pianoReleasePhase.kind === 'settling' && + now >= this.pianoReleasePhase.stopAt + ) { this.piano.stopAll(); this.pianoEngine.reset(); - this.stopPianoAt = null; - this.isReleasingPiano = false; + this.hasLoadedPiano = false; + this.pianoReleasePhase = { kind: 'idle' }; } } @@ -371,7 +418,10 @@ export class GardenAudio { } } - private updateDelay(snapshot: GardenAudioSnapshot): void { + private updateDelay( + snapshot: GardenAudioSnapshot, + profile: GardenAudioVibeProfile + ): void { const context = this.graph.context; if (!context) { return; @@ -380,7 +430,7 @@ export class GardenAudio { const activity = snapshot.isErasing ? this.config.delay.erasingActivity : this.energy.getLevel(); - this.graph.updateDelay(activity); + this.graph.updateDelay(activity, profile.bpm); } private applyVibe(vibe: VibePreset): void { @@ -389,8 +439,10 @@ export class GardenAudio { } this.currentVibeId = vibe.id; + this.currentVibe = vibe; const profile = getVibeProfile(vibe); - this.graph.applyDelayProfile(); + this.graph.applyDelayProfile(profile.bpm); this.pianoEngine.cue(this.graph.context.currentTime, profile); + this.hasLoadedPiano = true; } } diff --git a/src/audio/generative-piano-tuning.ts b/src/audio/generative-piano-tuning.ts index 185cbee..e76d6f4 100644 --- a/src/audio/generative-piano-tuning.ts +++ b/src/audio/generative-piano-tuning.ts @@ -152,6 +152,12 @@ interface GenerativePianoTuning { min: number; max: number; }; + stereoWidth: { + idle: number; + active: number; + intense: number; + intenseThreshold: number; + }; stylePanOffsetScale: number; lowpass: { midiBase: number; @@ -371,6 +377,12 @@ export const generativePianoTuning: GenerativePianoTuning = { min: -3, max: 3, }, + stereoWidth: { + idle: 0.46, + active: 0.9, + intense: 1.16, + intenseThreshold: 0.72, + }, stylePanOffsetScale: 0.35, lowpass: { midiBase: 48, diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts index e2505c5..fbe1201 100644 --- a/src/audio/generative-piano.ts +++ b/src/audio/generative-piano.ts @@ -17,24 +17,37 @@ import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler'; const GENERATIVE_LOOKAHEAD_SECONDS = 0.3; const GENERATIVE_START_DELAY_SECONDS = 0.02; +const TEXTURE_ONSET_EXPRESSION = 0.15; +const SUPPORT_ONSET_EXPRESSION = 0.4; -const chordVoicings = { - majorOpen: [0, 7, 12, 16], - minorOpen: [0, 7, 12, 15], - majorClosed: [0, 4, 7, 12, 16], - minorClosed: [0, 3, 7, 12, 15], +const chordVoicings: Record< + GardenAudioChord['quality'], + { closed: Array; open: Array } +> = { + major: { + closed: [0, 4, 7, 12, 16], + open: [0, 7, 12, 16], + }, + minor: { + closed: [0, 3, 7, 12, 15], + open: [0, 7, 12, 15], + }, + sus2: { + closed: [0, 2, 7, 12, 14], + open: [0, 7, 12, 14], + }, + sus4: { + closed: [0, 5, 7, 12, 17], + open: [0, 7, 12, 17], + }, }; const getChordIntervals = ( chord: GardenAudioChord, openVoicing: boolean ): Array => { - if (openVoicing) { - return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen; - } - return chord.quality === 'major' - ? chordVoicings.majorClosed - : chordVoicings.minorClosed; + const voicing = chordVoicings[chord.quality]; + return openVoicing ? voicing.open : voicing.closed; }; const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => { @@ -406,7 +419,7 @@ export class GenerativePianoEngine { velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight, startTime, durationSeconds, - pan: register.pan, + pan: this.getActivityPan(register.pan, expression), role: 'pad', delaySend: generativePianoTuning.padChord.delaySend, lowpassHz: this.getLowpassHz( @@ -446,7 +459,7 @@ export class GenerativePianoEngine { velocity: release.velocities[index], startTime: startTime + index * release.strumSeconds, durationSeconds: release.durationSeconds, - pan: register.pan, + pan: this.getActivityPan(register.pan, 0), role: 'pad', delaySend: release.delaySend, lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression), @@ -487,7 +500,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.supportNote.durationBaseSeconds + expression * generativePianoTuning.supportNote.durationExpressionSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, expression), role: 'support', delaySend: generativePianoTuning.supportNote.delaySendBase + @@ -533,7 +546,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.textureNote.durationBaseSeconds + expression * generativePianoTuning.textureNote.durationExpressionSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, expression), role: 'texture', delaySend: generativePianoTuning.textureNote.delaySendBase + @@ -582,7 +595,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.gestureAccent.durationBaseSeconds + strength * generativePianoTuning.gestureAccent.durationStrengthSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, strength), role: 'gesture', delaySend: generativePianoTuning.gestureAccent.delaySend, lowpassHz: this.getLowpassHz(profile, midi, strength), @@ -627,7 +640,7 @@ export class GenerativePianoEngine { durationSeconds: generativePianoTuning.touchNote.durationBaseSeconds + strength * generativePianoTuning.touchNote.durationStrengthSeconds, - pan: this.getStylePan(styleIndex), + pan: this.getStylePan(styleIndex, strength), role: 'gesture', delaySend: generativePianoTuning.touchNote.delaySend, lowpassHz: this.getLowpassHz( @@ -813,7 +826,7 @@ export class GenerativePianoEngine { chordOffsets: this.getChordOffsets(chord, chordIntervals), }; const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); - const pan = this.getStylePan(styleIndex); + const pan = this.getStylePan(styleIndex, intensity); const durationSeconds = clamp( generativePianoTuning.brushStream.durationBaseSeconds + intensity * generativePianoTuning.brushStream.durationIntensitySeconds - @@ -1076,6 +1089,9 @@ export class GenerativePianoEngine { } private shouldPlaySupport(expression: number, barIndex: number): boolean { + if (expression < SUPPORT_ONSET_EXPRESSION) { + return false; + } if (expression >= generativePianoTuning.supportNote.expressionThreshold) { return true; } @@ -1087,19 +1103,17 @@ export class GenerativePianoEngine { } private shouldPlayTexture(expression: number, barIndex: number): boolean { + if (expression < TEXTURE_ONSET_EXPRESSION) { + return false; + } + if (expression >= generativePianoTuning.textureNote.mediumExpressionThreshold) { + return barIndex % generativePianoTuning.textureNote.intenseSpacing === 0; + } const spacing = expression < generativePianoTuning.textureNote.idleExpressionThreshold ? generativePianoTuning.idleTextureBarSpacing - : expression < generativePianoTuning.textureNote.mediumExpressionThreshold - ? generativePianoTuning.mediumTextureBarSpacing - : generativePianoTuning.textureNote.intenseSpacing; - - return ( - barIndex % spacing === - (spacing === generativePianoTuning.textureNote.intenseSpacing - ? 0 - : generativePianoTuning.textureNote.idlePhase) - ); + : generativePianoTuning.mediumTextureBarSpacing; + return barIndex % spacing === generativePianoTuning.textureNote.idlePhase % spacing; } private getSupportOffsets( @@ -1128,16 +1142,29 @@ export class GenerativePianoEngine { styleCount) as GardenAudioStyleIndex; } - private getStylePan(styleIndex: GardenAudioStyleIndex): number { + private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number { const pool = generativePianoTuning.stylePools[styleIndex]; const styleVoice = styleVoices[styleIndex]; - return clamp( + return this.getActivityPan( pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale, - -1, - 1 + activity ); } + private getActivityPan(pan: number, activity: number): number { + const { active, idle, intense, intenseThreshold } = generativePianoTuning.stereoWidth; + const normalizedActivity = clamp01(activity); + const safeThreshold = clamp(intenseThreshold, 0.001, 0.999); + const width = + normalizedActivity < safeThreshold + ? idle + ((active - idle) * normalizedActivity) / safeThreshold + : active + + ((intense - active) * (normalizedActivity - safeThreshold)) / + (1 - safeThreshold); + + return clamp(pan * width, -1, 1); + } + private getLowpassHz( profile: GardenAudioVibeProfile, midi: number, diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts index 19cc45b..f9ab2bf 100644 --- a/src/audio/noise-burst-player.ts +++ b/src/audio/noise-burst-player.ts @@ -8,8 +8,8 @@ const noiseBurstTuning = { offsetRandomSeconds: 0.4, scheduleAheadSeconds: 0.002, silentGain: 0.0001, - filterType: 'bandpass' as BiquadFilterType, -}; + filterType: 'bandpass', +} as const; export class NoiseBurstPlayer { public constructor(private readonly graph: GardenAudioGraph) {} @@ -45,7 +45,10 @@ export class NoiseBurstPlayer { filter.connect(envelope); envelope.connect(panner); panner.connect(noiseBus); - source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds); + const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds); + const offsetSeconds = + Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds); + source.start(scheduledStart, offsetSeconds); source.stop(stopAt); source.addEventListener( 'ended', diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts index 1960b3b..1a0c77c 100644 --- a/src/audio/piano-sampler.ts +++ b/src/audio/piano-sampler.ts @@ -14,15 +14,16 @@ interface ActivePianoVoice { } const pianoSamplerTuning = { - filterType: 'lowpass' as BiquadFilterType, + filterType: 'lowpass', filterQ: 0.7, minDurationSeconds: 0.08, minFadeSeconds: 0.08, minGain: 0.0001, + releaseTimeConstantCount: 5, tailStopExtraSeconds: 0.05, voiceStealFadeSeconds: 0.025, voiceStealStopSeconds: 0.05, -}; +} as const; export class PianoSampler { private samples: Array = []; @@ -84,7 +85,9 @@ export class PianoSampler { const sustainAt = scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); const releaseAt = sustainAt + sustainSeconds; - const stopAt = releaseAt + this.config.piano.releaseSeconds; + const stopAt = + releaseAt + + this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount; const source = context.createBufferSource(); source.buffer = sample.buffer; @@ -178,7 +181,11 @@ export class PianoSampler { this.trimActiveVoices(scheduledStart); while (this.activeVoices.length >= this.config.piano.maxVoices) { - this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart); + const oldest = this.activeVoices.shift(); + if (!oldest) { + break; + } + this.stopVoice(oldest, scheduledStart); } filter.type = pianoSamplerTuning.filterType; @@ -220,11 +227,8 @@ export class PianoSampler { ); } - private computeNoteGain(velocity: number, scale = 1): number { - return Math.max( - pianoSamplerTuning.minGain, - this.config.piano.gain * velocity * scale - ); + private computeNoteGain(velocity: number): number { + return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity); } private findNearestSample(midi: number): LoadedPianoSample | null { diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts index 8b3b6c4..569eca4 100644 --- a/src/audio/piano-samples.ts +++ b/src/audio/piano-samples.ts @@ -31,51 +31,56 @@ import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline'; import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline'; interface PianoSampleDefinition { - midi: number; - path: string; + note: string; url: string; } export interface PianoSampleLoadProgress { + failedCount: number; loadedCount: number; + settledCount: number; totalCount: number; } 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 }, + { url: a0SampleUrl, note: 'A0' }, + { url: c1SampleUrl, note: 'C1' }, + { url: dSharp1SampleUrl, note: 'Dsharp1' }, + { url: fSharp1SampleUrl, note: 'Fsharp1' }, + { url: a1SampleUrl, note: 'A1' }, + { url: c2SampleUrl, note: 'C2' }, + { url: dSharp2SampleUrl, note: 'Dsharp2' }, + { url: fSharp2SampleUrl, note: 'Fsharp2' }, + { url: a2SampleUrl, note: 'A2' }, + { url: c3SampleUrl, note: 'C3' }, + { url: dSharp3SampleUrl, note: 'Dsharp3' }, + { url: fSharp3SampleUrl, note: 'Fsharp3' }, + { url: a3SampleUrl, note: 'A3' }, + { url: c4SampleUrl, note: 'C4' }, + { url: dSharp4SampleUrl, note: 'Dsharp4' }, + { url: fSharp4SampleUrl, note: 'Fsharp4' }, + { url: a4SampleUrl, note: 'A4' }, + { url: c5SampleUrl, note: 'C5' }, + { url: dSharp5SampleUrl, note: 'Dsharp5' }, + { url: fSharp5SampleUrl, note: 'Fsharp5' }, + { url: a5SampleUrl, note: 'A5' }, + { url: c6SampleUrl, note: 'C6' }, + { url: dSharp6SampleUrl, note: 'Dsharp6' }, + { url: fSharp6SampleUrl, note: 'Fsharp6' }, + { url: a6SampleUrl, note: 'A6' }, + { url: c7SampleUrl, note: 'C7' }, + { url: dSharp7SampleUrl, note: 'Dsharp7' }, + { url: fSharp7SampleUrl, note: 'Fsharp7' }, + { url: a7SampleUrl, note: 'A7' }, + { url: c8SampleUrl, note: 'C8' }, ]; let loadedPianoSamples: Array | null = null; let pianoSampleLoadPromise: Promise> | null = null; +let lastPianoSampleProgress: PianoSampleLoadProgress | null = null; +const pianoSampleProgressListeners = new Set< + (progress: PianoSampleLoadProgress) => void +>(); const sampleLoadTuning = { concurrency: 4, @@ -102,50 +107,65 @@ export const loadPianoSamples = ( decodeContext: BaseAudioContext, onProgress?: (progress: PianoSampleLoadProgress) => void ): Promise> => { + const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress); + if (loadedPianoSamples) { - onProgress?.({ + emitPianoSampleProgress({ + failedCount: 0, loadedCount: loadedPianoSamples.length, + settledCount: loadedPianoSamples.length, totalCount: pianoSampleDefinitions.length, }); + unsubscribeProgress(); return Promise.resolve([...loadedPianoSamples]); } if (pianoSampleLoadPromise) { - return pianoSampleLoadPromise; + return pianoSampleLoadPromise.finally(unsubscribeProgress); } let loadedCount = 0; + let failedCount = 0; + let settledCount = 0; const totalCount = pianoSampleDefinitions.length; - onProgress?.({ loadedCount, totalCount }); + emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount }); pianoSampleLoadPromise = loadPianoSampleBatch( pianoSampleDefinitions, async (sample) => { try { - return await withTimeout( + const loadedSample = await withTimeout( (signal) => loadPianoSample(decodeContext, sample, signal), sampleLoadTuning.sampleTimeoutMs ); - } finally { loadedCount += 1; - onProgress?.({ loadedCount, totalCount }); + return loadedSample; + } catch (error) { + failedCount += 1; + throw error; + } finally { + settledCount += 1; + emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount }); } } - ).then( - (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.` - ); + ) + .then( + (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]; + }, + (error: unknown) => { + pianoSampleLoadPromise = null; + pianoSampleProgressListeners.clear(); + throw error; } - return [...loadedPianoSamples]; - }, - (error: unknown) => { - pianoSampleLoadPromise = null; - throw error; - } - ); + ) + .finally(unsubscribeProgress); return pianoSampleLoadPromise; }; @@ -160,12 +180,12 @@ const loadPianoSample = async ( ): Promise => { const response = await fetch(sample.url, { signal }); if (!response.ok) { - throw new Error(`Unable to load piano sample ${sample.path}`); + throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`); } const audioData = await response.arrayBuffer(); const buffer = await decodeContext.decodeAudioData(audioData); - return { midi: sample.midi, buffer }; + return { midi: getMidiForPianoSample(sample), buffer }; }; const loadPianoSampleBatch = async ( @@ -205,3 +225,47 @@ const withTimeout = ( } ); }); + +const subscribeToPianoSampleProgress = ( + onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined +): (() => void) => { + if (!onProgress) { + return () => undefined; + } + + pianoSampleProgressListeners.add(onProgress); + if (lastPianoSampleProgress) { + onProgress(lastPianoSampleProgress); + } + return () => { + pianoSampleProgressListeners.delete(onProgress); + }; +}; + +const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => { + lastPianoSampleProgress = progress; + pianoSampleProgressListeners.forEach((listener) => listener(progress)); +}; + +const getPianoSamplePath = (sample: PianoSampleDefinition): string => + `./samples/${sample.note}v12.m4a`; + +const getMidiForPianoSample = (sample: PianoSampleDefinition): number => { + const match = /^(?[A-G])(?sharp)?(?\d+)$/.exec(sample.note); + if (!match?.groups) { + throw new Error(`Invalid piano sample note ${sample.note}`); + } + + const semitoneByName: Record = { + C: 0, + D: 2, + E: 4, + F: 5, + G: 7, + A: 9, + B: 11, + }; + const octave = Number(match.groups.octave); + const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0); + return (octave + 1) * 12 + semitone; +}; diff --git a/src/audio/samples/README.md b/src/audio/samples/README.md index ab6ee8c..bdde746 100644 --- a/src/audio/samples/README.md +++ b/src/audio/samples/README.md @@ -5,3 +5,12 @@ under CC BY 3.0. Source package: @audio-samples/piano-velocity12 Source recording: https://archive.org/details/SalamanderGrandPianoV3 License: https://creativecommons.org/licenses/by/3.0/ + +Checked-in subset: velocity layer `v12`, every minor-third anchor from A0 +through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8. +The app derives MIDI values from those note names in `piano-samples.ts`. + +Repro notes: start from the matching `v12` OGG files in the source package and +transcode each selected sample to AAC/M4A without renaming the note/velocity +stem. The expected output filenames are `v12.m4a`, for example +`C4v12.m4a`. diff --git a/src/config.ts b/src/config.ts index d458f1e..e0e6b19 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,11 @@ -import { createGardenAudioConfig } from './audio/garden-audio-config'; +import { + createGardenAudioConfig, + DEFAULT_AUDIO_VOLUME, +} 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 { normalizeNumberControlValue, @@ -18,6 +20,12 @@ export type { export const appConfig = { audio: createGardenAudioConfig(), + analytics: { + autoCapturePageviews: true, + domain: 'fleeting.garden', + endpoint: 'https://stats.schmelczer.dev/status', + logging: import.meta.env.DEV, + }, deltaTime: { maxDeltaTimeSeconds: 1 / 30, minDeltaTimeSeconds: 1 / 240, @@ -84,13 +92,14 @@ export const appConfig = { letterSpacingEm: 0.07, maskAlphaThreshold: 32, maskGradientThreshold: 8, + maskMaxPixels: 1_000_000, maskSampleDensity: 540, maxHeightRatio: 0.25, maxWidthRatio: 0.76, minEntryJitterPx: 6, minFontSizePx: 18, minTargetJitterPx: 1, - pathEasing: 'easeOutQuad' as GardenAppConfig['simulation']['intro']['pathEasing'], + pathEasing: 'easeOutQuad', pathProgressEpsilon: 0.001, radialJitterRatio: 0.35, radialStartEpsilon: 0.001, @@ -107,29 +116,28 @@ export const appConfig = { titleStrokeWidthRatio: 0.11, verticalAnchor: 0.47, }, - introMoveSpeedBaseMultiplier: 1.8, - introMoveSpeedProgressMultiplier: 0.35, + introMoveSpeed: 280, stroke: { densityMultiplier: 110, maxAgentCount: 2_400, }, }, storage: { - audioMutedKey: APP_STORAGE_KEYS.audioMuted, - audioVolumeKey: APP_STORAGE_KEYS.audioVolume, - vibeKey: APP_STORAGE_KEYS.vibe, + audioMutedKey: 'fleeting-garden:audio-muted', + audioVolumeKey: 'fleeting-garden:audio-volume', + vibeKey: 'fleeting-garden:vibe', }, toolbar: { eraser: { controlScaleMax: 1.34, controlScaleMin: 0.74, default: 96, - max: 240, + max: 480, min: 24, step: 1, }, mirror: { - default: 1, + default: 8, fallbackSegmentName: 'slices', max: 12, min: 1, diff --git a/src/config/brush-size.test.ts b/src/config/brush-size.test.ts new file mode 100644 index 0000000..9039e62 --- /dev/null +++ b/src/config/brush-size.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { + BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS, + getBrushRenderQualityScale, + getRenderQualityBrushSize, +} from './brush-size'; + +describe('render-quality brush sizing', () => { + it('keeps brush sizes unchanged at the 7.3 MP baseline', () => { + expect( + getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS) + ).toBe(21); + }); + + it('scales linear brush size with the square root of render area', () => { + const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4; + + expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2); + expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5); + }); + + it('falls back to baseline scaling for invalid render areas', () => { + expect(getBrushRenderQualityScale(0)).toBe(1); + expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5); + }); +}); diff --git a/src/config/brush-size.ts b/src/config/brush-size.ts new file mode 100644 index 0000000..fc77697 --- /dev/null +++ b/src/config/brush-size.ts @@ -0,0 +1,19 @@ +export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3; + +const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number => + Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0 + ? renderAreaMegapixels + : BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS; + +export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number => + Math.sqrt( + getSafeRenderAreaMegapixels(renderAreaMegapixels) / + BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS + ); + +export const getRenderQualityBrushSize = ( + brushSize: number, + renderAreaMegapixels: number +): number => + Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) * + getBrushRenderQualityScale(renderAreaMegapixels); diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts index 4e3ebc6..c84e61d 100644 --- a/src/config/color-interactions.ts +++ b/src/config/color-interactions.ts @@ -1,17 +1,5 @@ import type { NumberControlConfig } from './types'; -export const colorInteractionSettings = { - color1ToColor1: 1, - color1ToColor2: 0, - color1ToColor3: 0, - color2ToColor1: 0, - color2ToColor2: 1, - color2ToColor3: 0, - color3ToColor1: 0, - color3ToColor2: 0, - color3ToColor3: 1, -}; - export const colorInteractionControl = (label: string): NumberControlConfig => ({ folder: 'Color Reactions', label, diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts index 033a723..37ce510 100644 --- a/src/config/default-settings.ts +++ b/src/config/default-settings.ts @@ -1,15 +1,10 @@ -import { colorInteractionSettings } from './color-interactions'; -import { runtimeControls } from './runtime-controls'; +import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds'; 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 = @@ -21,18 +16,14 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => { 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) + INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max, + Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels) ); }; export const defaultSettings: GardenAppConfig['defaultSettings'] = { - ...colorInteractionSettings, selectedColorIndex: 0, - turnWhenLost: 0.8, - forwardRotationScale: 0.25, - sensorOffsetAngle: 32, introNearDistanceMin: 28, introNearDistanceInner: 4, introNearSensorOffsetMultiplier: 0.75, @@ -40,8 +31,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { introProgressCutoff: 0.999, introTurnRateMultiplier: 3.4, introRandomTurnMultiplier: 0.18, - introFarMoveMultiplier: 2.65, - introNearMoveMultiplier: 0.01, introStepStopDistance: 0.5, randomTimeScale: 0.34816, @@ -58,7 +47,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { brushCurveMirrorResolutionExponent: 0.5, brushCurveSegmentBrushRadiusRatio: 0.65, brushSmoothingMinSampleDistance: 0.5, - strokeAngleJitterRadians: Math.PI * 0.7, brushAlpha: 1, brushDiscardThreshold: 0.02, @@ -78,7 +66,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = { adaptiveCapInitial: 1_000_000, adaptiveCapMin: 50_000, internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(), - maxAgentCount: 700_000, + maxAgentCount: 1_500_000, renderTraceNormalizationFloor: 1, renderBrushColorBase: 1.2, diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts index a839746..e07a259 100644 --- a/src/config/runtime-controls.ts +++ b/src/config/runtime-controls.ts @@ -1,9 +1,20 @@ import { colorInteractionControl } from './color-interactions'; +import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds'; 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`; +const formatCompactNumber = (value: number): string => { + if (value >= 1_000_000) { + const millions = value / 1_000_000; + return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`; + } + if (value >= 1_000) { + return `${Math.round(value / 1_000)}k`; + } + return `${value}`; +}; export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'), @@ -20,14 +31,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { folder: 'Brush', label: 'Brush Size', min: 1, - max: 60, + max: 36, step: 0.25, }, spawnPerPixel: { folder: 'Brush', label: 'Density', min: 0.01, - max: 1, + max: 0.38, step: 0.001, }, strokeAngleJitterRadians: { @@ -35,7 +46,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { format: formatRadiansAsDegrees, label: 'Spawn Spread', min: 0, - max: Math.PI * 2, + max: Math.PI, step: 0.01, }, sensorOffsetDistance: { @@ -45,6 +56,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { max: 200, step: 1, }, + sensorOffsetAngle: { + folder: 'Movement', + label: 'Sensor Angle', + min: 0, + max: 180, + step: 1, + }, moveSpeed: { folder: 'Movement', label: 'Travel Speed', @@ -71,7 +89,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { folder: 'Movement', label: 'Wander Turn', min: 0, - max: 6.28, + max: Math.PI * 2, step: 0.01, }, individualTrailWeight: { @@ -81,6 +99,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { max: 1, step: 0.001, }, + diffusionRateTrails: { + folder: 'Movement', + label: 'Diffusion Rate', + min: 0.01, + max: 1, + step: 0.01, + }, decayRateTrails: { folder: 'Movement', label: 'Trail Fade', @@ -106,6 +131,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { maxAgentCount: { folder: 'Performance', + format: formatCompactNumber, integer: true, label: 'Population Limit', min: 0, @@ -114,8 +140,8 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { internalRenderAreaMegapixels: { folder: 'Performance', label: 'Render Quality (MP)', - min: 0.5, - max: 16.6, + min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, + max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max, step: 0.1, }, }; diff --git a/src/config/types.ts b/src/config/types.ts index ad80e83..456dec8 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -52,17 +52,30 @@ type RuntimeSettingControlConfig = Partial< Record >; -type GardenVibeSettings = Pick< +export type GardenVibeSettings = Pick< GardenRuntimeSettings, | 'backgroundGrainStrength' | 'brushSize' | 'clarity' + | 'color1ToColor1' + | 'color1ToColor2' + | 'color1ToColor3' + | 'color2ToColor1' + | 'color2ToColor2' + | 'color2ToColor3' + | 'color3ToColor1' + | 'color3ToColor2' + | 'color3ToColor3' | 'decayRateTrails' + | 'forwardRotationScale' | 'individualTrailWeight' | 'moveSpeed' + | 'sensorOffsetAngle' | 'sensorOffsetDistance' | 'spawnPerPixel' + | 'strokeAngleJitterRadians' | 'turnSpeed' + | 'turnWhenLost' >; type GardenDefaultSettings = Omit< @@ -72,10 +85,8 @@ type GardenDefaultSettings = Omit< export enum VibeId { AuroraMycelium = 'aurora-mycelium', - EmberCircuit = 'ember-circuit', VelvetObservatory = 'velvet-observatory', LichenSignal = 'lichen-signal', - UltravioletSiren = 'ultraviolet-siren', TidepoolLantern = 'tidepool-lantern', PaperLanternFog = 'paper-lantern-fog', ChromePollen = 'chrome-pollen', @@ -92,6 +103,12 @@ export interface VibePreset { export interface GardenAppConfig { audio: GardenAudioConfig; + analytics: { + autoCapturePageviews: boolean; + domain: string; + endpoint: string; + logging: boolean; + }; deltaTime: { maxDeltaTimeSeconds: number; minDeltaTimeSeconds: number; @@ -156,6 +173,7 @@ export interface GardenAppConfig { letterSpacingEm: number; maskAlphaThreshold: number; maskGradientThreshold: number; + maskMaxPixels: number; maskSampleDensity: number; maxHeightRatio: number; maxWidthRatio: number; @@ -179,8 +197,7 @@ export interface GardenAppConfig { titleStrokeWidthRatio: number; verticalAnchor: number; }; - introMoveSpeedBaseMultiplier: number; - introMoveSpeedProgressMultiplier: number; + introMoveSpeed: number; stroke: { densityMultiplier: number; maxAgentCount: number; diff --git a/src/config/vibe-presets.test.ts b/src/config/vibe-presets.test.ts new file mode 100644 index 0000000..a69d524 --- /dev/null +++ b/src/config/vibe-presets.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { runtimeControls } from './runtime-controls'; +import { vibePresets } from './vibe-presets'; + +const FINAL_VIBE_NAMES = [ + 'Aurora Mycelium Copy', + 'Velvet Observatory Copy', + 'Lichen Signal', + 'Tidepool Lantern', + 'Paper Lantern Fog', + 'Chrome Pollen', +]; + +const BLENDED_BRUSH_SIZE_MIN = 17; +const BLENDED_CLARITY_MAX = 0.56; +const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5; +const SOFT_PARTICLE_CLARITY_MAX = 0.2; + +// Performance guardrails — bumping any of these is an explicit perf trade-off. +const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38; +const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36; +const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28; +const HIGH_DENSITY_DECAY_LIMIT = 940; +const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14; +const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055; + +describe('vibePresets', () => { + it('keeps the classic preset set distinct', () => { + expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES); + + const ids = vibePresets.map((preset) => preset.id); + expect(new Set(ids).size).toBe(vibePresets.length); + }); + + it('includes both blended and visibly particulate styles', () => { + const blendedNames = vibePresets + .filter( + (preset) => + preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN && + preset.settings.clarity <= BLENDED_CLARITY_MAX + ) + .map((preset) => preset.name); + const softParticleNames = vibePresets + .filter( + (preset) => + preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX && + preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX + ) + .map((preset) => preset.name); + + expect(blendedNames).toEqual(['Tidepool Lantern']); + expect(softParticleNames).toEqual(['Chrome Pollen']); + }); + + it('stays inside interactive performance guardrails', () => { + const violations = vibePresets.flatMap((preset) => { + const { name, settings } = preset; + const presetViolations: Array = []; + + if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) { + presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`); + } + if (settings.brushSize > MAX_BRUSH_SIZE) { + presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`); + } + if ( + settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD && + (settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT || + settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT || + settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT) + ) { + presetViolations.push(`${name} combines high density with too much persistence`); + } + + return presetViolations; + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts index a95a0a3..7a30359 100644 --- a/src/config/vibe-presets.ts +++ b/src/config/vibe-presets.ts @@ -1,162 +1,252 @@ -import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config'; -import { VibeId, type VibePreset } from './types'; +import { + defaultGardenAudioVibeSettings, + type GardenAudioChord, +} from '../audio/garden-audio-config'; +import { VibeId, type GardenVibeSettings, type VibePreset } from './types'; + +type ColorReactionSettings = Pick< + GardenVibeSettings, + | 'color1ToColor1' + | 'color1ToColor2' + | 'color1ToColor3' + | 'color2ToColor1' + | 'color2ToColor2' + | 'color2ToColor3' + | 'color3ToColor1' + | 'color3ToColor2' + | 'color3ToColor3' +>; + +const colorReactions = { + auroraMycelium: { + color1ToColor1: 1, + color1ToColor2: 0, + color1ToColor3: 0, + color2ToColor1: -1, + color2ToColor2: 1, + color2ToColor3: 0, + color3ToColor1: -1, + color3ToColor2: -1, + color3ToColor3: 1, + }, + velvetObservatory: { + color1ToColor1: 1, + color1ToColor2: -1, + color1ToColor3: -1, + color2ToColor1: -1, + color2ToColor2: 1, + color2ToColor3: -1, + color3ToColor1: -1, + color3ToColor2: -1, + color3ToColor3: 1, + }, + lichenSignal: { + color1ToColor1: 0, + color1ToColor2: -1, + color1ToColor3: 1, + color2ToColor1: -1, + color2ToColor2: 0, + color2ToColor3: -1, + color3ToColor1: 1, + color3ToColor2: -1, + color3ToColor3: 1, + }, + tidepoolLantern: { + color1ToColor1: 0, + color1ToColor2: 1, + color1ToColor3: 0, + color2ToColor1: 0, + color2ToColor2: 0, + color2ToColor3: 1, + color3ToColor1: 1, + color3ToColor2: 0, + color3ToColor3: 0, + }, + paperLanternFog: { + color1ToColor1: 1, + color1ToColor2: 1, + color1ToColor3: 1, + color2ToColor1: 1, + color2ToColor2: 1, + color2ToColor3: 1, + color3ToColor1: 1, + color3ToColor2: 1, + color3ToColor3: 1, + }, + chromePollen: { + color1ToColor1: 1, + color1ToColor2: 0, + color1ToColor3: 1, + color2ToColor1: -1, + color2ToColor2: 1, + color2ToColor3: 0, + color3ToColor1: 1, + color3ToColor2: 0, + color3ToColor3: 1, + }, +} satisfies Record; + +const musicScales = { + dorian: [0, 2, 3, 5, 7, 9, 10], + lydian: [0, 2, 4, 6, 7, 9, 11], + mixolydian: [0, 2, 4, 5, 7, 9, 10], + naturalMinor: [0, 2, 3, 5, 7, 8, 10], +} satisfies Record>; + +const musicProgressions = { + aurora: [ + { rootOffset: 0, quality: 'sus2' }, + { rootOffset: 7, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 5, quality: 'sus4' }, + ], + chrome: [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 2, quality: 'major' }, + { rootOffset: 7, quality: 'sus2' }, + { rootOffset: 9, quality: 'minor' }, + ], + lichen: [ + { rootOffset: 0, quality: 'minor' }, + { rootOffset: 5, quality: 'major' }, + { rootOffset: 10, quality: 'major' }, + { rootOffset: 3, quality: 'major' }, + ], + paperLantern: [ + { rootOffset: 0, quality: 'minor' }, + { rootOffset: 8, quality: 'major' }, + { rootOffset: 5, quality: 'minor' }, + { rootOffset: 10, quality: 'sus4' }, + ], + tidepool: [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 10, quality: 'major' }, + { rootOffset: 5, quality: 'sus2' }, + { rootOffset: 9, quality: 'minor' }, + ], + velvet: [ + { rootOffset: 0, quality: 'minor' }, + { rootOffset: 8, quality: 'major' }, + { rootOffset: 3, quality: 'major' }, + { rootOffset: 5, quality: 'sus4' }, + ], +} satisfies Record>; export const defaultVibeId = VibeId.AuroraMycelium; export const vibePresets: Array = [ { id: VibeId.AuroraMycelium, - name: 'Aurora Mycelium', + name: 'Aurora Mycelium Copy', colors: [ - [78, 255, 176], + [251, 210, 94], [154, 99, 255], - [169, 238, 255], + [255, 31, 199], ], backgroundColor: [6, 13, 22], settings: { - backgroundGrainStrength: 0.016, - brushSize: 20, - clarity: 0.52, - decayRateTrails: 988, - individualTrailWeight: 0.085, - moveSpeed: 54, - sensorOffsetDistance: 72, - spawnPerPixel: 0.13, - turnSpeed: 35, + ...colorReactions.auroraMycelium, + backgroundGrainStrength: 0.003, + brushSize: 8.75, + clarity: 1, + decayRateTrails: 973, + forwardRotationScale: 0.37, + individualTrailWeight: 0.053000000000000005, + moveSpeed: 144, + sensorOffsetAngle: 35, + sensorOffsetDistance: 52, + spawnPerPixel: 0.13999999999999999, + strokeAngleJitterRadians: 0.45, + turnSpeed: 13, + turnWhenLost: 0, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.12, + idleIntensity: 0.12000000000000002, bpm: 60, rampUpIntensity: 0.7, rampUpTime: 0.14, - noteLength: 0.86, + noteLength: 0.8599999999999999, notePitchOffset: -2, brightness: 0.84, - }, - }, - { - id: VibeId.EmberCircuit, - name: 'Ember Circuit', - colors: [ - [255, 95, 38], - [255, 43, 132], - [43, 219, 255], - ], - backgroundColor: [17, 10, 8], - settings: { - backgroundGrainStrength: 0.03, - brushSize: 8, - clarity: 0.82, - decayRateTrails: 918, - individualTrailWeight: 0.04, - moveSpeed: 150, - sensorOffsetDistance: 24, - spawnPerPixel: 0.31, - turnSpeed: 130, - }, - audio: { - ...defaultGardenAudioVibeSettings, - idleIntensity: 0.03, - bpm: 124, - rampUpIntensity: 1.35, - rampUpTime: 0.04, - noteLength: 0.18, - notePitchOffset: 7, - brightness: 1.34, + scale: musicScales.lydian, + progression: musicProgressions.aurora, }, }, { id: VibeId.VelvetObservatory, - name: 'Velvet Observatory', + name: 'Velvet Observatory Copy', colors: [ - [72, 98, 255], - [255, 89, 176], - [235, 236, 255], + [178, 76, 62], + [2, 174, 255], + [213, 193, 9], ], - backgroundColor: [7, 8, 20], + backgroundColor: [7, 4, 22], settings: { - backgroundGrainStrength: 0.01, - brushSize: 24, - clarity: 0.45, - decayRateTrails: 992, - individualTrailWeight: 0.095, - moveSpeed: 45, - sensorOffsetDistance: 86, - spawnPerPixel: 0.1, - turnSpeed: 24, + ...colorReactions.velvetObservatory, + backgroundGrainStrength: 0.005, + brushSize: 9.75, + clarity: 1, + decayRateTrails: 974, + forwardRotationScale: 0, + individualTrailWeight: 0.232, + moveSpeed: 121, + sensorOffsetAngle: 24, + sensorOffsetDistance: 17, + spawnPerPixel: 0.11499999999999999, + strokeAngleJitterRadians: 0.17, + turnSpeed: 33, + turnWhenLost: 0.42, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.14, - bpm: 56, - rampUpIntensity: 0.6, - rampUpTime: 0.16, - noteLength: 1.15, - notePitchOffset: -5, - brightness: 0.72, + idleIntensity: 0.24000000000000002, + bpm: 72, + rampUpIntensity: 1.42, + rampUpTime: 0.07, + noteLength: 0.7, + notePitchOffset: 0, + brightness: 0.94, + scale: musicScales.naturalMinor, + progression: musicProgressions.velvet, }, }, { id: VibeId.LichenSignal, name: 'Lichen Signal', colors: [ - [174, 205, 91], - [71, 162, 126], - [229, 117, 71], + [183, 216, 92], + [65, 166, 128], + [238, 120, 76], ], - backgroundColor: [18, 24, 17], - settings: { - backgroundGrainStrength: 0.028, - brushSize: 17, - clarity: 0.66, - decayRateTrails: 974, - individualTrailWeight: 0.065, - moveSpeed: 68, - sensorOffsetDistance: 52, - spawnPerPixel: 0.19, - turnSpeed: 38, - }, - audio: { - ...defaultGardenAudioVibeSettings, - idleIntensity: 0.1, - bpm: 68, - rampUpIntensity: 0.8, - rampUpTime: 0.1, - noteLength: 0.62, - notePitchOffset: -3, - brightness: 0.82, - }, - }, - { - id: VibeId.UltravioletSiren, - name: 'Ultraviolet Siren', - colors: [ - [184, 75, 255], - [0, 224, 255], - [214, 255, 72], - ], - backgroundColor: [13, 9, 31], + backgroundColor: [0, 0, 0], settings: { + ...colorReactions.lichenSignal, backgroundGrainStrength: 0.02, - brushSize: 11, - clarity: 0.72, - decayRateTrails: 946, + brushSize: 6.5, + clarity: 0.74, + decayRateTrails: 962, + forwardRotationScale: 0.3, individualTrailWeight: 0.052, - moveSpeed: 118, - sensorOffsetDistance: 30, - spawnPerPixel: 0.28, - turnSpeed: 96, + moveSpeed: 72, + sensorOffsetAngle: 42, + sensorOffsetDistance: 54, + spawnPerPixel: 0.16, + strokeAngleJitterRadians: 3.14, + turnSpeed: 44, + turnWhenLost: 0.92, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.04, - bpm: 112, - rampUpIntensity: 1.2, - rampUpTime: 0.05, - noteLength: 0.25, - notePitchOffset: 5, - brightness: 1.22, + idleIntensity: 0.13, + bpm: 68, + rampUpIntensity: 1.46, + rampUpTime: 0.1, + noteLength: 0.6, + notePitchOffset: -3, + brightness: 1.21, + scale: musicScales.dorian, + progression: musicProgressions.lichen, }, }, { @@ -167,89 +257,110 @@ export const vibePresets: Array = [ [61, 118, 255], [255, 191, 91], ], - backgroundColor: [5, 20, 28], + backgroundColor: [4, 18, 29], settings: { + ...colorReactions.tidepoolLantern, backgroundGrainStrength: 0.018, - brushSize: 15, - clarity: 0.6, - decayRateTrails: 963, - individualTrailWeight: 0.058, + brushSize: 17, + clarity: 0.56, + decayRateTrails: 968, + forwardRotationScale: 0.38, + individualTrailWeight: 0.06, moveSpeed: 88, - sensorOffsetDistance: 44, + sensorOffsetAngle: 64, + sensorOffsetDistance: 46, spawnPerPixel: 0.22, - turnSpeed: 60, + strokeAngleJitterRadians: 1.8, + turnSpeed: 66, + turnWhenLost: 1.05, }, audio: { ...defaultGardenAudioVibeSettings, idleIntensity: 0.08, - bpm: 82, + bpm: 84, rampUpIntensity: 0.95, rampUpTime: 0.08, - noteLength: 0.48, + noteLength: 0.46, notePitchOffset: 0, brightness: 0.98, + scale: musicScales.mixolydian, + progression: musicProgressions.tidepool, }, }, { id: VibeId.PaperLanternFog, name: 'Paper Lantern Fog', colors: [ - [255, 174, 104], - [242, 102, 107], - [132, 211, 185], + [255, 176, 108], + [239, 90, 108], + [128, 213, 184], ], - backgroundColor: [31, 23, 20], + backgroundColor: [30, 23, 20], settings: { - backgroundGrainStrength: 0.036, - brushSize: 22, - clarity: 0.5, - decayRateTrails: 984, - individualTrailWeight: 0.08, - moveSpeed: 56, - sensorOffsetDistance: 64, - spawnPerPixel: 0.14, - turnSpeed: 32, + ...colorReactions.paperLanternFog, + backgroundGrainStrength: 0.038, + brushSize: 3.5, + clarity: 1, + decayRateTrails: 999, + forwardRotationScale: 0.24, + individualTrailWeight: 0.937, + moveSpeed: 28, + sensorOffsetAngle: 34, + sensorOffsetDistance: 66, + spawnPerPixel: 0.055, + strokeAngleJitterRadians: 0, + turnSpeed: 30, + turnWhenLost: 1.52, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.13, - bpm: 64, - rampUpIntensity: 0.72, - rampUpTime: 0.12, - noteLength: 0.9, - notePitchOffset: -4, - brightness: 0.76, + idleIntensity: 0.33, + bpm: 127, + rampUpIntensity: 0.66, + rampUpTime: 0.03, + noteLength: 0.92, + notePitchOffset: 10, + brightness: 1.42, + scale: musicScales.naturalMinor, + progression: musicProgressions.paperLantern, }, }, { id: VibeId.ChromePollen, name: 'Chrome Pollen', colors: [ - [235, 255, 238], + [178, 34, 34], [255, 214, 48], [77, 240, 157], ], - backgroundColor: [9, 13, 12], + backgroundColor: [7, 12, 11], settings: { + ...colorReactions.chromePollen, backgroundGrainStrength: 0.012, - brushSize: 10, - clarity: 0.9, - decayRateTrails: 935, - individualTrailWeight: 0.045, - moveSpeed: 104, - sensorOffsetDistance: 36, - spawnPerPixel: 0.24, - turnSpeed: 78, + brushSize: 4.5, + clarity: 0.1, + decayRateTrails: 922, + forwardRotationScale: 0.5, + individualTrailWeight: 0.026, + moveSpeed: 86, + sensorOffsetAngle: 46, + sensorOffsetDistance: 14, + spawnPerPixel: 0.36, + strokeAngleJitterRadians: 3, + turnSpeed: 34, + turnWhenLost: 1.35, }, audio: { ...defaultGardenAudioVibeSettings, - idleIntensity: 0.05, - bpm: 96, - rampUpIntensity: 1.05, - rampUpTime: 0.07, - noteLength: 0.3, - notePitchOffset: 3, - brightness: 1.18, + idleIntensity: 0.11, + bpm: 150, + rampUpIntensity: 2, + rampUpTime: 0.06, + noteLength: 1.8, + notePitchOffset: -12, + brightness: 0.5, + scale: musicScales.lydian, + progression: musicProgressions.chrome, }, }, ]; diff --git a/src/consts.ts b/src/consts.ts deleted file mode 100644 index 8991f08..0000000 --- a/src/consts.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const ENABLED_FLAG_VALUE = '1'; -export const DISABLED_FLAG_VALUE = '0'; - -export const DEFAULT_AUDIO_VOLUME = 0.5; - -export const APP_STORAGE_KEYS = { - audioMuted: 'fleeting-garden:audio-muted', - audioVolume: 'fleeting-garden:audio-volume', - vibe: 'fleeting-garden:vibe', -} as const; diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts index f49a3ab..2bc0e26 100644 --- a/src/game-loop/agent-population.test.ts +++ b/src/game-loop/agent-population.test.ts @@ -145,6 +145,18 @@ describe('AgentPopulation stroke spawning', () => { expect(pipeline.writtenBatches[0][2]).toBe(0); }); + it('clears active agents when an intro replacement has no generated agents', () => { + const { population } = createPopulation(); + + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0)); + expect(population.activeAgentCount).toBe(3); + + settings.maxAgentCount = 0; + population.replaceIntroAgents(vec2.fromValues(100, 100), 0); + + expect(population.activeAgentCount).toBe(0); + }); + it('queues stroke writes while async compaction is in flight', async () => { const { pipeline, population } = createPopulation(); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts index c00d7c7..1d0390f 100644 --- a/src/game-loop/agent-population.ts +++ b/src/game-loop/agent-population.ts @@ -1,6 +1,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; +import { getRenderQualityBrushSize } from '../config/brush-size'; import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; @@ -58,6 +59,8 @@ export class AgentPopulation { }); if (data.length === 0) { + this.activeCount = 0; + this.replacementCursor = 0; return; } @@ -162,7 +165,11 @@ export class AgentPopulation { } const baseAngle = Math.atan2(deltaY, deltaX); - const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio()); + const spread = + getRenderQualityBrushSize( + settings.brushSize, + settings.internalRenderAreaMegapixels + ) * getSafePixelRatio(this.getCanvasPixelRatio()); const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT; if (batchCapacity <= 0) { return; diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts index 80dd4b9..3d45283 100644 --- a/src/game-loop/brush-stroke-smoother.ts +++ b/src/game-loop/brush-stroke-smoother.ts @@ -1,6 +1,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../config'; +import { getRenderQualityBrushSize } from '../config/brush-size'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { settings } from '../settings'; import { type StrokeSegment } from './game-loop-types'; @@ -90,9 +91,13 @@ export class BrushStrokeSmoother { ): Array { const curveLength = vec2.distance(start, control) + vec2.distance(control, end); const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio()); + const brushSize = getRenderQualityBrushSize( + settings.brushSize, + settings.internalRenderAreaMegapixels + ); const brushRadius = Math.max( settings.brushCurveMinBrushRadius * canvasPixelRatio, - (settings.brushSize * canvasPixelRatio) / 2 + (brushSize * canvasPixelRatio) / 2 ); const segmentSpacing = Math.max( settings.brushCurveMinSegmentSpacing * canvasPixelRatio, diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts index abdb795..9fc249e 100644 --- a/src/game-loop/frame-performance.ts +++ b/src/game-loop/frame-performance.ts @@ -1,19 +1,18 @@ import { settings } from '../settings'; -export class FramePerformance { - private readonly adaptiveRefreshTargetFps = 60; - private readonly initialFps = this.adaptiveRefreshTargetFps; - public smoothedFps = this.initialFps; +const ADAPTIVE_REFRESH_TARGET_FPS = 60; +const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000; +const FRAME_GAP_RESET_SECONDS = 1; +const FPS_HEADROOM = 0.9; +const FPS_SMOOTHING_NEW = 0.06; +const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW; +export class FramePerformance { + public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS; public measuredFps = 0; public frameDeltaSeconds = 0; public measuredFrameTimeMs = 0; - private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000; - private readonly frameGapResetSeconds = 1; - private readonly fpsHeadroom = 0.9; - private readonly fpsSmoothingNew = 0.06; - private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew; private previousFrameTime: DOMHighResTimeStamp | null = null; public get adaptiveCapInitial(): number { @@ -25,13 +24,13 @@ export class FramePerformance { } public get hasAdaptiveCapHeadroom(): boolean { - return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom; + return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM; } public get adaptiveCapDecreaseAgents(): number { return Math.max( 1, - Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds) + Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds) ); } @@ -47,15 +46,16 @@ export class FramePerformance { return; } - const fps = 1 / deltaSeconds; - this.frameDeltaSeconds = deltaSeconds; this.measuredFrameTimeMs = deltaSeconds * 1000; + const fps = 1 / deltaSeconds; this.measuredFps = fps; - if (deltaSeconds > this.frameGapResetSeconds) { + if (deltaSeconds > FRAME_GAP_RESET_SECONDS) { + this.frameDeltaSeconds = 0; + this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS; return; } - this.smoothedFps = - this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew; + this.frameDeltaSeconds = deltaSeconds; + this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW; } } diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts index 492204f..64c9680 100644 --- a/src/game-loop/game-loop-resources.ts +++ b/src/game-loop/game-loop-resources.ts @@ -1,6 +1,6 @@ import { vec2 } from 'gl-matrix'; -import { appConfig } from '../config'; +import { appConfig, type GardenRuntimeSettings } from '../config'; import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; @@ -9,7 +9,6 @@ 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 { initializeContext } from '../utils/graphics/initialize-context'; import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; import { GpuProfiler } from './gpu-profiler'; @@ -25,6 +24,7 @@ interface FrameParameters extends RenderInputs { introProgress: number; selectedColorIndex: number; eraserPixelSize: number; + runtimeSettings: GardenRuntimeSettings; } export class GameLoopResources { @@ -46,7 +46,8 @@ export class GameLoopResources { private readonly device: GPUDevice, private readonly canvasFormat: GPUTextureFormat, canvasSize: vec2, - initialAgentCapacity: number + initialAgentCapacity: number, + initialMaxAgentCount: number ) { const context = initializeContext({ device, canvas, format: canvasFormat }); @@ -59,7 +60,7 @@ export class GameLoopResources { this.agentGenerationPipeline = new AgentGenerationPipeline( this.device, - Math.min(settings.maxAgentCount, initialAgentCapacity) + Math.min(initialMaxAgentCount, initialAgentCapacity) ); this.agentPipeline = new AgentPipeline( @@ -74,12 +75,7 @@ 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.canvasFormat - ); + this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat); this.gpuProfiler = GpuProfiler.create( this.device, () => appConfig.tuningPane.showFpsOverlay @@ -128,47 +124,43 @@ export class GameLoopResources { channelColors, backgroundColor, eraserPixelSize, + runtimeSettings, }: FrameParameters): void { this.commonState.setParameters({ canvasSize, }); this.agentPipeline.setParameters({ - ...settings, + ...runtimeSettings, deltaTime, time, agentCount: activeAgentCount, - moveSpeed: - settings.moveSpeed * - (introProgress >= 1 - ? 1 - : appConfig.simulation.introMoveSpeedBaseMultiplier + - introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier), + introMoveSpeed: appConfig.simulation.introMoveSpeed, introProgress, }); this.brushPipeline.setParameters({ - ...settings, + ...runtimeSettings, pixelRatio: canvasPixelRatio, selectedColorIndex, }); - this.diffusionPipeline.setParameters(settings); + this.diffusionPipeline.setParameters(runtimeSettings); this.renderPipeline.setParameters({ - ...settings, + ...runtimeSettings, channelColors, backgroundColor, }); this.eraserAgentPipeline.setParameters({ agentCount: activeAgentCount, eraserSize: eraserPixelSize, - eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold, + eraserMaskAlphaThreshold: runtimeSettings.eraserMaskAlphaThreshold, maskSize: canvasSize, }); this.eraserTexturePipeline.setParameters({ eraserSize: eraserPixelSize, - eraserLineDistanceEpsilon: settings.eraserLineDistanceEpsilon, - eraserClearRed: settings.eraserClearRed, - eraserClearGreen: settings.eraserClearGreen, - eraserClearBlue: settings.eraserClearBlue, - eraserClearAlpha: settings.eraserClearAlpha, + eraserLineDistanceEpsilon: runtimeSettings.eraserLineDistanceEpsilon, + eraserClearRed: runtimeSettings.eraserClearRed, + eraserClearGreen: runtimeSettings.eraserClearGreen, + eraserClearBlue: runtimeSettings.eraserClearBlue, + eraserClearAlpha: runtimeSettings.eraserClearAlpha, }); } diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index 6cabd79..aed5289 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -31,7 +31,6 @@ export default class GameLoop { private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seed = this.seedValue.toString(16); - private readonly resizeListener = this.resize.bind(this); private readonly _canvasSize: vec2 = vec2.create(); private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; @@ -55,7 +54,8 @@ export default class GameLoop { device, this.canvasFormat, this.canvasSize, - this.framePerformance.adaptiveCapInitial + this.framePerformance.adaptiveCapInitial, + settings.maxAgentCount ); this.introPrompt = new IntroPrompt(ui.prompt); this.toolbarContrastMonitor = new ToolbarContrastMonitor( @@ -112,13 +112,12 @@ export default class GameLoop { getVibeId: () => activeVibe.id, }); - window.addEventListener('resize', this.resizeListener); - this.eraserPreview.attach(); this.syncPerfStatsOverlay(); } public attachPointerInput(): void { this.pointerInput.attach(); + this.eraserPreview.attach(); } public setEraseMode(isErasing: boolean): void { @@ -173,9 +172,6 @@ export default class GameLoop { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } - this.finished.resolve(); - - window.removeEventListener('resize', this.resizeListener); this.pointerInput.detach(); this.eraserPreview.detach(); this.perfStatsOverlay?.destroy(); @@ -185,6 +181,7 @@ export default class GameLoop { await this.agentPopulation.waitForCompaction(); this.resources.destroy(); await this.audio.destroy(); + this.finished.resolve(); } private readonly render = (time: DOMHighResTimeStamp) => { @@ -204,13 +201,15 @@ export default class GameLoop { const channelColors = activeVibe.colors; const backgroundColor = activeVibe.backgroundColor; + const runtimeSettings = { ...settings }; const introProgress = this.introPrompt.progress; const canvasPixelRatio = this.canvasPixelRatio; - const eraserPixelSize = settings.eraserSize * canvasPixelRatio; + const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio; const isErasing = this.pointerInput.isEraseMode; - const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; + const accentColor = + channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0]; this.updateAccentColor(accentColor); - this.updateGrainOverlay(settings.backgroundGrainStrength); + this.updateGrainOverlay(runtimeSettings.backgroundGrainStrength); this.audio.update({ vibe: activeVibe, isErasing, @@ -223,10 +222,11 @@ export default class GameLoop { activeAgentCount: this.agentPopulation.activeAgentCount, canvasPixelRatio, introProgress, - selectedColorIndex: settings.selectedColorIndex, + selectedColorIndex: runtimeSettings.selectedColorIndex, channelColors, backgroundColor, eraserPixelSize, + runtimeSettings, }); this.resources.executeFrame( diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts index 2d530cb..a34bbf4 100644 --- a/src/game-loop/intro-title-agents.ts +++ b/src/game-loop/intro-title-agents.ts @@ -1,4 +1,4 @@ -import { appConfig } from '../config'; +import { appConfig, type GardenAppConfig } from '../config'; import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math'; @@ -18,8 +18,11 @@ interface IntroTitleAgentOptions { } type RandomSource = () => number; +type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing']; const INTRO_TITLE = appConfig.simulation.intro.title; +const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean => + pathEasing === 'linear'; export const createIntroTitleAgents = ({ count, @@ -169,16 +172,22 @@ const createIntroTitlePoints = ( width: number, height: number ): Array => { + const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels); + const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height))); + const maskWidth = Math.max(1, Math.round(width * maskScale)); + const maskHeight = Math.max(1, Math.round(height * maskScale)); + const pointScaleX = width / maskWidth; + const pointScaleY = height / maskHeight; const maskCanvas = document.createElement('canvas'); - maskCanvas.width = width; - maskCanvas.height = height; + maskCanvas.width = maskWidth; + maskCanvas.height = maskHeight; const context = maskCanvas.getContext('2d', { willReadFrequently: true }); if (!context) { return []; } - const fontSize = getIntroTitleFontSize(context, width, height); - context.clearRect(0, 0, width, height); + const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight); + context.clearRect(0, 0, maskWidth, maskHeight); context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`; context.textAlign = 'center'; context.textBaseline = 'middle'; @@ -192,42 +201,44 @@ const createIntroTitlePoints = ( const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm; drawIntroTitleText( context, - width / 2, - height * appConfig.simulation.intro.verticalAnchor, + maskWidth / 2, + maskHeight * appConfig.simulation.intro.verticalAnchor, letterSpacing, 'stroke' ); drawIntroTitleText( context, - width / 2, - height * appConfig.simulation.intro.verticalAnchor, + maskWidth / 2, + maskHeight * appConfig.simulation.intro.verticalAnchor, letterSpacing, 'fill' ); - const { data } = context.getImageData(0, 0, width, height); + const { data } = context.getImageData(0, 0, maskWidth, maskHeight); const step = Math.max( 1, - Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity) + Math.floor( + Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity + ) ); const points: Array = []; const characterColorBoundaries = getIntroTitleColorBoundaries( context, - width, + maskWidth, letterSpacing ); - for (let y = 0; y < height; y += step) { - for (let x = 0; x < width; x += step) { - const alpha = getMaskAlpha(data, width, height, x, y); + for (let y = 0; y < maskHeight; y += step) { + for (let x = 0; x < maskWidth; x += step) { + const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y); if (alpha < appConfig.simulation.intro.maskAlphaThreshold) { continue; } points.push({ - x, - y, - tangent: estimateMaskTangent(data, width, height, x, y), + x: x * pointScaleX, + y: y * pointScaleY, + tangent: estimateMaskTangent(data, maskWidth, maskHeight, x, y), colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries), }); } @@ -244,8 +255,10 @@ const getIntroTitleColorBoundaries = ( const letters = Array.from(INTRO_TITLE); const totalWidth = measureIntroTitleText(context, letters, letterSpacing); let x = width / 2 - totalWidth / 2; - const [firstCutLetter, secondCutLetter] = - appConfig.simulation.intro.titleColorCutLetters; + const cutLetters = appConfig.simulation.intro.titleColorCutLetters + .map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter)))) + .sort((a, b) => a - b); + const [firstCutLetter, secondCutLetter] = cutLetters; const letterBoxes = letters.map((letter, index) => { const letterWidth = context.measureText(letter).width; const box = { @@ -401,7 +414,7 @@ const createSeededRandom = (seed: number): RandomSource => { }; const easePathProgress = (amount: number): number => { - if (appConfig.simulation.intro.pathEasing === 'linear') { + if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) { return amount; } diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts index 6ffd55b..9e6717f 100644 --- a/src/game-loop/perf-stats-overlay.ts +++ b/src/game-loop/perf-stats-overlay.ts @@ -1,4 +1,5 @@ const PERF_STATS_REFRESH_MS = 200; +const UNAVAILABLE_STAT_TEXT = 'n/a'; const ZERO_STAT_TEXT = '0'; const ZERO_FRAME_TIME_TEXT = '0ms'; const ZERO_RESOLUTION_TEXT = '0x0'; @@ -39,7 +40,7 @@ export class PerfStatsOverlay { } this.previousUpdateTime = time; - const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; + const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatOptionalFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; if (text !== this.previousText) { this.element.textContent = text; this.previousText = text; @@ -68,6 +69,11 @@ const formatFrameTime = (frameTimeMs: number | undefined): string => { return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`; }; +const formatOptionalFrameTime = (frameTimeMs: number | undefined): string => + typeof frameTimeMs === 'number' && Number.isFinite(frameTimeMs) + ? formatFrameTime(frameTimeMs) + : UNAVAILABLE_STAT_TEXT; + const formatResolution = (width: number, height: number): string => Number.isFinite(width) && Number.isFinite(height) ? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}` diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts index ae5fed0..bee83ef 100644 --- a/src/game-loop/pointer-input.ts +++ b/src/game-loop/pointer-input.ts @@ -96,7 +96,6 @@ export class GardenPointerInput { this.options.onStartDrawing(); this.activePointerId = event.pointerId; this.canvas.setPointerCapture(event.pointerId); - this.options.strokeOutput.clearSwipes(); this.lastPointerPosition = null; this.lastPointerEventTimeMs = null; this.brushSmoother.clear(); @@ -122,11 +121,16 @@ export class GardenPointerInput { if (this.isErasing) { this.options.onEraseGestureEnded(); } - this.canvas.releasePointerCapture(event.pointerId); - this.activePointerId = null; - this.lastPointerPosition = null; - this.lastPointerEventTimeMs = null; - this.brushSmoother.clear(); + try { + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + } finally { + this.activePointerId = null; + this.lastPointerPosition = null; + this.lastPointerEventTimeMs = null; + this.brushSmoother.clear(); + } }; private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts index 538ed45..36629e8 100644 --- a/src/game-loop/simulation-frame.ts +++ b/src/game-loop/simulation-frame.ts @@ -46,16 +46,19 @@ export class SimulationFrameRenderer { const commandEncoder = this.device.createCommandEncoder(); this.gpuProfiler?.beginFrame(); - this.textures.copyTrailMapAToB(commandEncoder); + // Clear the deposit map up-front so agents write fresh deposits each frame + // and diffuse sees only this frame's contributions added to trailMapA. + this.textures.clearDepositMap(commandEncoder); let wroteSourceMap = false; if (isErasing) { if (this.pipelines.eraserAgentPipeline.hasActiveMask()) { const eraserMask = this.textures.eraserMask.getTextureView(); + // Erase trailMapA directly — it's what agent and diffuse will read. this.pipelines.eraserTexturePipeline.executeCombined( commandEncoder, eraserMask, this.textures.sourceMapA.getTextureView(), - this.textures.trailMapB.getTextureView(), + this.textures.trailMapA.getTextureView(), this.gpuProfiler?.timestampWrites('eraserTexture') ); this.pipelines.eraserAgentPipeline.execute( @@ -65,7 +68,7 @@ export class SimulationFrameRenderer { ); } } else { - wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget( + wroteSourceMap = this.pipelines.brushPipeline.executeSource( commandEncoder, this.textures.sourceMapA.getTextureView(), this.gpuProfiler?.timestampWrites('brush') @@ -86,19 +89,20 @@ export class SimulationFrameRenderer { this.pipelines.agentPipeline.execute( commandEncoder, this.textures.trailMapA.getTextureView(), - this.textures.trailMapB.getTextureView(), + this.textures.depositMap.getTextureView(), this.gpuProfiler?.timestampWrites('agent') ); this.pipelines.diffusionPipeline.execute( commandEncoder, - this.textures.trailMapB.getTextureView(), this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), this.textures.trailMapA.getSize(), + this.textures.depositMap.getTextureView(), this.gpuProfiler?.timestampWrites('trailDiffusion') ); const canvasTexture = this.pipelines.renderPipeline.execute( commandEncoder, - this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), this.textures.sourceMapA.getTextureView(), useSourceMap, this.gpuProfiler?.timestampWrites('render') @@ -111,6 +115,7 @@ export class SimulationFrameRenderer { this.textures.sourceMapA.getTextureView(), this.textures.sourceMapB.getTextureView(), this.textures.sourceMapB.getSize(), + null, this.gpuProfiler?.timestampWrites('sourceDiffusion') ); } @@ -118,6 +123,10 @@ export class SimulationFrameRenderer { this.device.queue.submit([commandEncoder.finish()]); afterGpuProfileSubmit?.(); canvasReadbackRequest?.afterSubmit(); + // After this frame's diffuse, trailMapB holds the fresh trail; swap so + // trailMapA is "current trail" again for the next frame and any external + // readers (e.g. export snapshot). + this.textures.swapTrailMaps(); if (useSourceMap) { this.textures.swapSourceMaps(); this.sourceActiveFramesRemaining -= 1; diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts index 7a07aa0..7166595 100644 --- a/src/game-loop/simulation-textures.ts +++ b/src/game-loop/simulation-textures.ts @@ -8,10 +8,15 @@ import { } from '../utils/graphics/resizable-texture'; export class SimulationTextures { - public readonly trailMapA: ResizableTexture; - public readonly trailMapB: ResizableTexture; + // trailMapA holds the current trail (read by agent and diffuse). trailMapB + // receives the diffuse output; the two swap each frame so the freshly + // diffused texture becomes trailMapA for the next frame. + public trailMapA: ResizableTexture; + public trailMapB: ResizableTexture; + // Per-frame last-writer deposit map: cleared each frame, written sparsely by + // agents, then read by diffuse alongside trailMapA. + public readonly depositMap: ResizableTexture; public readonly eraserMask: ResizableTexture; - // A/B are swapped each frame to ping-pong the diffusion pass. public sourceMapA: ResizableTexture; public sourceMapB: ResizableTexture; @@ -21,6 +26,7 @@ export class SimulationTextures { ) { this.trailMapA = this.createTexture(canvasSize); this.trailMapB = this.createTexture(canvasSize); + this.depositMap = this.createTexture(canvasSize); this.sourceMapA = this.createTexture(canvasSize); this.sourceMapB = this.createTexture(canvasSize); this.eraserMask = this.createEraserMask(canvasSize); @@ -36,6 +42,7 @@ export class SimulationTextures { const resizes = [ this.trailMapA, this.trailMapB, + this.depositMap, this.sourceMapA, this.sourceMapB, this.eraserMask, @@ -67,6 +74,7 @@ export class SimulationTextures { [ this.trailMapA, this.trailMapB, + this.depositMap, this.sourceMapA, this.sourceMapB, this.eraserMask, @@ -86,14 +94,25 @@ export class SimulationTextures { this.device.queue.submit([commandEncoder.finish()]); } - public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void { - const size = this.trailMapA.getSize(); + public clearDepositMap(commandEncoder: GPUCommandEncoder): void { + // Hardware fast-clear via a render pass with loadOp 'clear' and an empty + // body. Cheaper than copyTextureToTexture and writes no actual color data + // on tile-based GPUs. + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this.depositMap.getTextureView(), + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + passEncoder.end(); + } - commandEncoder.copyTextureToTexture( - { texture: this.trailMapA.getTexture() }, - { texture: this.trailMapB.getTexture() }, - { width: size[0], height: size[1] } - ); + public swapTrailMaps(): void { + [this.trailMapA, this.trailMapB] = [this.trailMapB, this.trailMapA]; } public clearSourceMaps(commandEncoder: GPUCommandEncoder): void { @@ -119,6 +138,7 @@ export class SimulationTextures { public destroy(): void { this.trailMapA.destroy(); this.trailMapB.destroy(); + this.depositMap.destroy(); this.sourceMapA.destroy(); this.sourceMapB.destroy(); this.eraserMask.destroy(); diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts index 9c908fe..8898123 100644 --- a/src/game-loop/toolbar-contrast-monitor.ts +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -7,6 +7,14 @@ interface CanvasSamplePoint { y: number; } +interface CanvasSampleRegion { + bytesPerRow: number; + height: number; + origin: CanvasSamplePoint; + sampleOffsets: Array; + width: number; +} + interface ToolbarContrastMetrics { averageLuminance: number; backgroundOpacity: number; @@ -16,6 +24,7 @@ interface ToolbarContrastMetrics { const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity'; const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength'; +const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256; const getLinearChannel = (channel: number): number => { const normalized = channel / 255; @@ -33,16 +42,13 @@ const getRelativeLuminance = (red: number, green: number, blue: number): number const getToolbarContrastMetrics = ( pixels: Uint8Array, - sampleCount: number, + sampleOffsets: ReadonlyArray, isBgra: boolean ): ToolbarContrastMetrics => { - const count = Math.max( - 0, - Math.min( - sampleCount, - Math.floor(pixels.length / appConfig.toolbar.contrast.bytesPerSample) - ) - ); + const count = sampleOffsets.filter( + (offset) => + offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length + ).length; if (count === 0) { return { averageLuminance: 0, @@ -56,8 +62,14 @@ const getToolbarContrastMetrics = ( let brightCount = 0; let lowContrastCount = 0; - for (let i = 0; i < count; i++) { - const offset = i * appConfig.toolbar.contrast.bytesPerSample; + sampleOffsets.forEach((offset) => { + if ( + offset < 0 || + offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length + ) { + return; + } + const red = pixels[offset + (isBgra ? 2 : 0)]; const green = pixels[offset + 1]; const blue = pixels[offset + (isBgra ? 0 : 2)]; @@ -73,7 +85,7 @@ const getToolbarContrastMetrics = ( if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) { lowContrastCount++; } - } + }); const averageLuminance = luminanceTotal / count; const brightRatio = brightCount / count; @@ -100,6 +112,8 @@ export class ToolbarContrastMonitor { private isDestroyed = false; private isReadbackPending = false; private lastSampleAt = Number.NEGATIVE_INFINITY; + private readbackBuffer: GPUBuffer | null = null; + private readbackBufferSize = 0; public constructor( private readonly canvas: HTMLCanvasElement, @@ -119,45 +133,29 @@ export class ToolbarContrastMonitor { return null; } - const samplePoints = this.getSamplePoints(); - if (samplePoints.length === 0) { + const sampleRegion = this.getSampleRegion(); + if (sampleRegion.sampleOffsets.length === 0) { return null; } - let buffer: GPUBuffer; - try { - buffer = this.device.createBuffer({ - size: samplePoints.length * appConfig.toolbar.contrast.bytesPerSample, - usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, - }); - } catch { + const bufferSize = sampleRegion.bytesPerRow * sampleRegion.height; + const buffer = this.getReadbackBuffer(bufferSize); + if (!buffer) { return null; } this.isReadbackPending = true; this.lastSampleAt = time; - let isBufferDestroyed = false; let isCancelled = false; let isEncoded = false; - const destroyBuffer = () => { - if (isBufferDestroyed) { - return; - } - - isBufferDestroyed = true; - buffer.destroy(); - }; - const cancel = (destroyNow = true) => { + const cancel = () => { if (isCancelled) { return; } isCancelled = true; this.isReadbackPending = false; - if (destroyNow) { - destroyBuffer(); - } }; return { @@ -167,31 +165,28 @@ export class ToolbarContrastMonitor { } try { - samplePoints.forEach((point, index) => { - commandEncoder.copyTextureToBuffer( - { - origin: point, - texture, - }, - { - buffer, - offset: index * appConfig.toolbar.contrast.bytesPerSample, - }, - { - depthOrArrayLayers: 1, - height: 1, - width: 1, - } - ); - }); + commandEncoder.copyTextureToBuffer( + { + origin: sampleRegion.origin, + texture, + }, + { + buffer, + bytesPerRow: sampleRegion.bytesPerRow, + }, + { + depthOrArrayLayers: 1, + height: sampleRegion.height, + width: sampleRegion.width, + } + ); isEncoded = true; } catch { - cancel(false); + cancel(); } }, afterSubmit: () => { if (isCancelled) { - destroyBuffer(); return; } @@ -200,13 +195,16 @@ export class ToolbarContrastMonitor { return; } - void this.readBuffer(buffer, samplePoints.length); + void this.readBuffer(buffer, sampleRegion.sampleOffsets); }, }; } public destroy(): void { this.isDestroyed = true; + this.readbackBuffer?.destroy(); + this.readbackBuffer = null; + this.readbackBufferSize = 0; this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY); this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY); } @@ -231,7 +229,14 @@ export class ToolbarContrastMonitor { ); } - private getSamplePoints(): Array { + private getSampleRegion(): CanvasSampleRegion { + const emptyRegion = { + bytesPerRow: 0, + height: 0, + origin: { x: 0, y: 0 }, + sampleOffsets: [], + width: 0, + }; const canvasRect = this.canvas.getBoundingClientRect(); const toolbarRect = this.toolbar.getBoundingClientRect(); if ( @@ -240,7 +245,7 @@ export class ToolbarContrastMonitor { toolbarRect.width <= 0 || toolbarRect.height <= 0 ) { - return []; + return emptyRegion; } const left = Math.max(canvasRect.left, toolbarRect.left); @@ -248,17 +253,40 @@ export class ToolbarContrastMonitor { const top = Math.max(canvasRect.top, toolbarRect.top); const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom); if (left >= right || top >= bottom) { - return []; + return emptyRegion; } const xScale = this.canvas.width / canvasRect.width; const yScale = this.canvas.height / canvasRect.height; - const width = right - left; - const height = bottom - top; + const cssWidth = right - left; + const cssHeight = bottom - top; + const origin = { + x: Math.max(0, Math.floor((left - canvasRect.left) * xScale)), + y: Math.max(0, Math.floor((top - canvasRect.top) * yScale)), + }; + const regionRight = Math.min( + this.canvas.width, + Math.ceil((right - canvasRect.left) * xScale) + ); + const regionBottom = Math.min( + this.canvas.height, + Math.ceil((bottom - canvasRect.top) * yScale) + ); + const width = Math.max(0, regionRight - origin.x); + const height = Math.max(0, regionBottom - origin.y); + if (width === 0 || height === 0) { + return emptyRegion; + } + + const bytesPerRow = alignTo( + width * appConfig.toolbar.contrast.bytesPerSample, + GPU_COPY_BYTES_PER_ROW_ALIGNMENT + ); const points = new Map(); for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) { - const cssY = top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * height; + const cssY = + top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight; const y = Math.min( this.canvas.height - 1, Math.max(0, Math.floor((cssY - canvasRect.top) * yScale)) @@ -266,7 +294,7 @@ export class ToolbarContrastMonitor { for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) { const cssX = - left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * width; + left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth; const x = Math.min( this.canvas.width - 1, Math.max(0, Math.floor((cssX - canvasRect.left) * xScale)) @@ -275,10 +303,43 @@ export class ToolbarContrastMonitor { } } - return [...points.values()]; + return { + bytesPerRow, + height, + origin, + sampleOffsets: [...points.values()].map( + (point) => + (point.y - origin.y) * bytesPerRow + + (point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample + ), + width, + }; } - private async readBuffer(buffer: GPUBuffer, sampleCount: number): Promise { + private getReadbackBuffer(size: number): GPUBuffer | null { + if (this.readbackBuffer && this.readbackBufferSize >= size) { + return this.readbackBuffer; + } + + this.readbackBuffer?.destroy(); + try { + this.readbackBuffer = this.device.createBuffer({ + size, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + this.readbackBufferSize = size; + return this.readbackBuffer; + } catch { + this.readbackBuffer = null; + this.readbackBufferSize = 0; + return null; + } + } + + private async readBuffer( + buffer: GPUBuffer, + sampleOffsets: Array + ): Promise { let isMapped = false; try { await buffer.mapAsync(GPUMapMode.READ); @@ -286,7 +347,7 @@ export class ToolbarContrastMonitor { if (!this.isDestroyed) { const pixels = new Uint8Array(buffer.getMappedRange()); - const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra); + const metrics = getToolbarContrastMetrics(pixels, sampleOffsets, this.isBgra); this.setToolbarBackgroundOpacity(metrics.backgroundOpacity); } } catch { @@ -295,8 +356,10 @@ export class ToolbarContrastMonitor { if (isMapped) { buffer.unmap(); } - buffer.destroy(); this.isReadbackPending = false; } } } + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; diff --git a/src/index.ts b/src/index.ts index fa06833..74b98d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,11 +82,13 @@ const main = async () => { ); const splash = new SplashScreen(); + let eraserSizeControl: EraserSizeControl | null = null; const paletteControl = new PaletteControl({ getGame, onChange: () => configPane?.refresh(), + onModeChange: (isEraserActive) => eraserSizeControl?.setActive(isEraserActive), }); - const eraserSizeControl = new EraserSizeControl({ + eraserSizeControl = new EraserSizeControl({ getGame, onActivate: () => paletteControl.setEraserActive(true), onChange: () => configPane?.refresh(), @@ -104,7 +106,8 @@ const main = async () => { }); const syncRuntimeUi = () => { - eraserSizeControl.render(); + eraserSizeControl?.render(); + eraserSizeControl?.setActive(paletteControl.isEraserActive); mirrorSegmentControl.render(); paletteControl.render(); }; @@ -120,12 +123,12 @@ const main = async () => { new FullScreenHandler(fullScreenButton, document.documentElement); new VibeNavigator({ - onChange: ({ vibeId, vibeName, source }) => { + onChange: ({ vibeId, vibeName, source, userGesture }) => { trackVibeChange({ vibeId, vibeName, source }); game?.onVibeChanged(); syncRuntimeUi(); configPane?.refresh(); - game?.playVibeChangeAudio(true); + game?.playVibeChangeAudio(userGesture); }, }); @@ -243,7 +246,6 @@ const main = async () => { ErrorPresenter.renderStartup(e); ErrorHandler.addException(e); } - console.error(e); } }; diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts index c96fb1d..7677bad 100644 --- a/src/page/audio-control.ts +++ b/src/page/audio-control.ts @@ -1,5 +1,4 @@ import { appConfig } from '../config'; -import { DISABLED_FLAG_VALUE, ENABLED_FLAG_VALUE } from '../consts'; import type GameLoop from '../game-loop/game-loop'; import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage'; import { queryRequiredElement } from '../utils/dom'; @@ -21,6 +20,9 @@ const readInitialAudioVolume = (): number => { const formatStoredAudioVolume = (volume: number): string => clampAudioVolume(volume).toFixed(2); +const STORED_MUTED_TRUE = '1'; +const STORED_MUTED_FALSE = '0'; + interface AudioControlOptions { getGame: () => GameLoop | null; hasStarted: () => boolean; @@ -43,7 +45,7 @@ export class AudioControl { private audioVolume = readInitialAudioVolume(); private isMutedState = - readBrowserStorage(appConfig.storage.audioMutedKey) === ENABLED_FLAG_VALUE || + readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE || this.audioVolume <= 0; public constructor(private readonly options: AudioControlOptions) { @@ -140,7 +142,7 @@ export class AudioControl { private persist(): void { writeBrowserStorage( appConfig.storage.audioMutedKey, - this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE + this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE ); writeBrowserStorage( appConfig.storage.audioVolumeKey, diff --git a/src/page/collapsible-panel-animator.ts b/src/page/collapsible-panel-animator.ts index 4b10e52..a86a637 100644 --- a/src/page/collapsible-panel-animator.ts +++ b/src/page/collapsible-panel-animator.ts @@ -1,6 +1,7 @@ export class CollapsiblePanelAnimator { private _isOpen = false; private focusBeforeOpen: HTMLElement | null = null; + private readonly abortController = new AbortController(); public onOpen?: () => void; public constructor( @@ -8,17 +9,23 @@ export class CollapsiblePanelAnimator { private readonly collapsibleContent: HTMLElement, ignoreForCloseOnClick: HTMLElement ) { - toggleButton.addEventListener('click', this.toggle.bind(this)); + const { signal } = this.abortController; + toggleButton.addEventListener('click', this.toggle, { signal }); window.addEventListener( 'click', - (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close() + (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close(), + { signal } + ); + window.addEventListener( + 'keydown', + (event) => { + if (this._isOpen && event.key === 'Escape') { + event.preventDefault(); + this.close(); + } + }, + { signal } ); - window.addEventListener('keydown', (event) => { - if (this._isOpen && event.key === 'Escape') { - event.preventDefault(); - this.close(); - } - }); this.syncAccessibility(); } @@ -49,12 +56,16 @@ export class CollapsiblePanelAnimator { } } - public toggle() { + public readonly toggle = () => { if (this._isOpen) { this.close(); } else { this.open(); } + }; + + public destroy(): void { + this.abortController.abort(); } public get isOpen() { diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts index 52bf872..3e6c397 100644 --- a/src/page/config-pane.ts +++ b/src/page/config-pane.ts @@ -9,18 +9,17 @@ import { type NumberControlConfig, } from '../config'; import { activeVibe, settings } from '../settings'; -import { - hexColorToRgbColor, - rgbColorToCss, - rgbColorToHex, - type RgbColor, -} from '../utils/rgb-color'; +import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color'; +import { ColorReactionMatrixControl } from './color-reaction-matrix-control'; type PaneContainer = Pick; -type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number]; type RuntimeControlKey = keyof GardenRuntimeSettings & string; type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor'; -type VibeNumberKey = keyof GardenAudioVibeSettings; +type NumberPropertyKey = { + [Key in keyof T]-?: T[Key] extends number ? Key : never; +}[keyof T] & + string; +type VibeNumberKey = NumberPropertyKey; interface PaneState extends GardenAudioVibeSettings { backgroundColor: string; @@ -29,31 +28,6 @@ interface PaneState extends GardenAudioVibeSettings { color3: string; } -const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const; -const COLOR_REACTION_STATES = [ - { id: 'follow', label: 'Move Toward', value: 1 }, - { id: 'ignore', label: 'Ignore', value: 0 }, - { id: 'avoid', label: 'Move Away', value: -1 }, -] as const; - -const colorReactionRows = [ - { - colorIndex: 0, - label: COLOR_REACTION_LABELS[0], - keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'], - }, - { - colorIndex: 1, - label: COLOR_REACTION_LABELS[1], - keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'], - }, - { - colorIndex: 2, - label: COLOR_REACTION_LABELS[2], - keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'], - }, -] as const; - const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const; const MUSIC_CONTROLS: ReadonlyArray<{ @@ -107,37 +81,11 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => { return params; }; -const getColorReactionStateIndex = (value: number): number => - COLOR_REACTION_STATES.findIndex((state) => state.value === value); - -const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] => - COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1]; - -const getNextColorReactionState = ( - value: number -): (typeof COLOR_REACTION_STATES)[number] => { - const index = getColorReactionStateIndex(value); - return COLOR_REACTION_STATES[ - ((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length - ]; -}; - export class ConfigPane { private readonly container: HTMLDivElement; private readonly closeButton: HTMLButtonElement; + private readonly colorReactionMatrix: ColorReactionMatrixControl; private readonly pane: Pane; - private readonly colorReactionButtons = new Map< - ColorReactionKey, - { - element: HTMLButtonElement; - sourceColorIndex: number; - targetColorIndex: number; - } - >(); - private readonly colorReactionSwatches: Array<{ - colorIndex: number; - element: HTMLElement; - }> = []; private readonly state: PaneState = { backgroundColor: rgbColorToHex(activeVibe.backgroundColor), color1: rgbColorToHex(activeVibe.colors[0]), @@ -147,6 +95,9 @@ export class ConfigPane { }; public constructor(private readonly options: ConfigPaneOptions) { + this.colorReactionMatrix = new ColorReactionMatrixControl( + this.options.onRuntimeChange + ); this.container = document.createElement('div'); this.container.className = 'config-pane-container'; @@ -187,7 +138,7 @@ export class ConfigPane { public refresh(): void { this.syncVibeState(); this.pane.refresh(); - this.syncColorReactionMatrix(); + this.colorReactionMatrix.sync(); this.syncOpenState(); } @@ -233,7 +184,7 @@ export class ConfigPane { this.setUpVibeSection(container); this.addRuntimeSection(container, runtimeFolderOrder[0], true); this.addRuntimeSection(container, runtimeFolderOrder[1], true); - this.addColorReactionMatrix(container); + this.colorReactionMatrix.addTo(container); this.addRuntimeSection(container, runtimeFolderOrder[2], true); const performanceFolder = this.addRuntimeSection( container, @@ -242,7 +193,7 @@ export class ConfigPane { ); this.addFpsOverlayBinding(performanceFolder); this.setUpMusicSection(container); - this.syncColorReactionMatrix(); + this.colorReactionMatrix.sync(); } private setUpVibeSection(container: PaneContainer): void { @@ -291,7 +242,7 @@ export class ConfigPane { } updateColor(color); - this.syncColorReactionMatrix(); + this.colorReactionMatrix.sync(); this.options.onConfigChange(); }); } @@ -348,132 +299,6 @@ export class ConfigPane { .on('change', () => this.options.onConfigChange()); } - private addColorReactionMatrix(container: PaneContainer): void { - const folder = container.addFolder({ - title: 'Color Behavior', - expanded: true, - }); - - const matrix = document.createElement('div'); - matrix.className = 'color-reaction-matrix'; - - matrix.appendChild(this.createColorReactionCorner()); - colorReactionRows.forEach((row) => { - matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label)); - }); - - colorReactionRows.forEach((row) => { - matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label)); - row.keys.forEach((key, columnIndex) => { - matrix.appendChild( - this.createColorReactionCell(key, row.colorIndex, columnIndex) - ); - }); - }); - - const matrixBlade = folder.addBlade({ view: 'separator' }); - matrixBlade.element.classList.add('color-reaction-matrix-blade'); - matrixBlade.element.replaceChildren(matrix); - this.syncColorReactionMatrix(); - } - - private createColorReactionCorner(): HTMLDivElement { - const corner = document.createElement('div'); - corner.className = 'color-reaction-matrix__corner'; - return corner; - } - - private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement { - const header = document.createElement('div'); - header.className = 'color-reaction-matrix__header'; - header.setAttribute('aria-label', label); - header.title = label; - - const swatch = document.createElement('span'); - swatch.className = 'color-reaction-matrix__swatch'; - this.colorReactionSwatches.push({ colorIndex, element: swatch }); - header.appendChild(swatch); - - return header; - } - - private createColorReactionCell( - key: ColorReactionKey, - sourceColorIndex: number, - targetColorIndex: number - ): HTMLDivElement { - const cell = document.createElement('div'); - cell.className = 'color-reaction-matrix__cell'; - - const config = appConfig.runtimeSettings.controls[key]; - if (!config) { - return cell; - } - - const button = document.createElement('button'); - button.className = 'color-reaction-matrix__button'; - button.type = 'button'; - - const icon = document.createElement('span'); - icon.className = 'color-reaction-matrix__icon'; - button.appendChild(icon); - - button.addEventListener('click', () => { - const currentValue = normalizeNumberControlValue(settings[key], config); - const nextState = getNextColorReactionState(currentValue); - settings[key] = nextState.value; - this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex); - this.options.onRuntimeChange(); - }); - - this.colorReactionButtons.set(key, { - element: button, - sourceColorIndex, - targetColorIndex, - }); - cell.appendChild(button); - - return cell; - } - - private syncColorReactionMatrix(): void { - this.colorReactionButtons.forEach( - ({ element, sourceColorIndex, targetColorIndex }, key) => { - this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex); - } - ); - - this.colorReactionSwatches.forEach(({ colorIndex, element }) => { - element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]); - }); - } - - private syncColorReactionButton( - button: HTMLButtonElement, - key: ColorReactionKey, - sourceColorIndex: number, - targetColorIndex: number - ): void { - const config = appConfig.runtimeSettings.controls[key]; - if (!config) { - return; - } - - settings[key] = normalizeNumberControlValue(settings[key], config); - - const state = getColorReactionState(settings[key]); - const nextState = getNextColorReactionState(settings[key]); - const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex]; - const targetLabel = COLOR_REACTION_LABELS[targetColorIndex]; - - button.dataset.reaction = state.id; - button.setAttribute( - 'aria-label', - `${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}` - ); - button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`; - } - private setUpMusicSection(container: PaneContainer): void { const folder = container.addFolder({ title: 'Music', expanded: true }); MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => { diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts index b5de8f7..1099821 100644 --- a/src/page/eraser-size-control.ts +++ b/src/page/eraser-size-control.ts @@ -26,14 +26,15 @@ export class EraserSizeControl { HTMLLabelElement ); private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement); + private isActive = false; 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.control.addEventListener('pointerdown', this.activate); + this.control.addEventListener('click', this.activate); + this.slider.addEventListener('focus', this.activate); this.slider.addEventListener('input', () => { settings.eraserSize = clampEraserSize(Number(this.slider.value)); - this.options.onActivate(); + this.activate(); this.render(); this.options.onChange(); }); @@ -59,6 +60,25 @@ export class EraserSizeControl { ratio; this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); + this.syncActiveState(); this.options.getGame()?.updateEraserPreview(); } + + public setActive(isActive: boolean): void { + this.isActive = isActive; + this.syncActiveState(); + } + + private readonly activate = () => { + this.setActive(true); + this.options.onActivate(); + }; + + private syncActiveState(): void { + this.control.classList.toggle('active', this.isActive); + this.slider.setAttribute( + 'aria-label', + this.isActive ? 'Eraser size, active' : 'Eraser size' + ); + } } diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts index 1167543..498c08c 100644 --- a/src/page/full-screen-handler.ts +++ b/src/page/full-screen-handler.ts @@ -1,4 +1,6 @@ export class FullScreenHandler { + private readonly abortController = new AbortController(); + public constructor( private readonly toggleButton: HTMLElement, target: HTMLElement @@ -10,26 +12,35 @@ export class FullScreenHandler { this.updateButtons(); - addEventListener('fullscreenchange', this.updateButtons.bind(this)); - toggleButton.addEventListener('click', () => { - if (FullScreenHandler.isInFullScreenMode()) { - void document.exitFullscreen(); - return; - } + const { signal } = this.abortController; + addEventListener('fullscreenchange', this.updateButtons, { signal }); + toggleButton.addEventListener( + 'click', + () => { + if (FullScreenHandler.isInFullScreenMode()) { + void document.exitFullscreen(); + return; + } - void target.requestFullscreen().catch(() => undefined); - }); + void target.requestFullscreen().catch(() => undefined); + }, + { signal } + ); } public static isInFullScreenMode(): boolean { return document.fullscreenElement !== null; } - private updateButtons(): void { + public destroy(): void { + this.abortController.abort(); + } + + private readonly updateButtons = (): void => { const isInFullScreenMode = FullScreenHandler.isInFullScreenMode(); const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen'; this.toggleButton.classList.toggle('active', isInFullScreenMode); this.toggleButton.setAttribute('aria-label', label); this.toggleButton.title = label; - } + }; } diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts index 841ac48..ed8aa59 100644 --- a/src/page/menu-hider.ts +++ b/src/page/menu-hider.ts @@ -99,18 +99,18 @@ export class MenuHider { } private reveal(): void { + if (!this.isHidden && this.hideTimeout === undefined) { + return; + } + this.clearHideTimeout(); this.isHidden = false; this.element.classList.remove('menu-hidden'); - this.element.setAttribute('aria-hidden', 'false'); - this.element.inert = false; } private hide(): void { this.isHidden = true; this.element.classList.add('menu-hidden'); - this.element.setAttribute('aria-hidden', 'true'); - this.element.inert = true; } private clearHideTimeout(): void { diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts index 98f6b75..1ea8ddc 100644 --- a/src/page/mirror-segment-control.ts +++ b/src/page/mirror-segment-control.ts @@ -14,7 +14,7 @@ const getMirrorSegmentRatio = (count: number): number => { }; const formatMirrorSegmentCount = (count: number): string => - count === appConfig.toolbar.mirror.default + count <= 1 ? appConfig.toolbar.mirror.offLabel : `${count} ${ appConfig.toolbar.mirror.names[ diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts index 615625f..9007d8f 100644 --- a/src/page/palette-control.ts +++ b/src/page/palette-control.ts @@ -1,20 +1,16 @@ import type GameLoop from '../game-loop/game-loop'; import { activeVibe, settings } from '../settings'; -import { queryRequiredElement } from '../utils/dom'; import { ErrorCode, RuntimeError } from '../utils/error-handler'; import { rgbColorToCss } from '../utils/rgb-color'; interface PaletteControlOptions { getGame: () => GameLoop | null; onChange: () => void; + onModeChange?: (isEraserActive: boolean) => void; } export class PaletteControl { private readonly swatches = queryRequiredColorSwatches(); - private readonly eraserControl = queryRequiredElement( - '.eraser-size-control', - HTMLLabelElement - ); private isEraserActiveState = false; public constructor(private readonly options: PaletteControlOptions) { @@ -23,6 +19,7 @@ export class PaletteControl { settings.selectedColorIndex = index; this.isEraserActiveState = false; this.render(); + this.options.onModeChange?.(false); this.options.onChange(); }); }); @@ -35,17 +32,16 @@ export class PaletteControl { public setEraserActive(active: boolean): void { this.isEraserActiveState = active; this.render(); + this.options.onModeChange?.(active); } public render(): void { this.swatches.forEach((swatch, index) => { swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); - swatch.classList.toggle( - 'active', - settings.selectedColorIndex === index && !this.isEraserActiveState - ); + const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState; + swatch.classList.toggle('active', isActive); + swatch.setAttribute('aria-pressed', String(isActive)); }); - this.eraserControl.classList.toggle('active', this.isEraserActiveState); this.options.getGame()?.setEraseMode(this.isEraserActiveState); document.documentElement.style.setProperty( '--garden-background', diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts index 50dd211..00d44a4 100644 --- a/src/page/splash-screen.ts +++ b/src/page/splash-screen.ts @@ -30,13 +30,24 @@ export class SplashScreen { public awaitStart(onStart: () => void): Promise { this.startButton.disabled = false; return new Promise((resolve) => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || event.defaultPrevented) { + return; + } + + event.preventDefault(); + this.startButton.click(); + }; const onClick = () => { this.startButton.removeEventListener('click', onClick); + document.removeEventListener('keydown', onKeyDown); onStart(); this.setVisible(this.splash, false); resolve(); }; + this.startButton.addEventListener('click', onClick); + document.addEventListener('keydown', onKeyDown); }); } diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts index 2e37feb..fc6ecc0 100644 --- a/src/page/vibe-navigator.ts +++ b/src/page/vibe-navigator.ts @@ -1,9 +1,11 @@ -import { activeVibe, applyVibeSettings } from '../settings'; +import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings'; import { queryRequiredElement } from '../utils/dom'; -import { VIBE_PRESETS, type VibeId } from '../vibes'; +import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri'; +import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes'; interface VibeSelection { source: string; + userGesture: boolean; vibeId: VibeId; vibeName: string; } @@ -13,6 +15,7 @@ interface VibeNavigatorOptions { } export class VibeNavigator { + private readonly abortController = new AbortController(); private readonly previousButton = queryRequiredElement( '.previous-vibe', HTMLButtonElement @@ -20,18 +23,60 @@ export class VibeNavigator { private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement); public constructor(private readonly options: VibeNavigatorOptions) { - this.previousButton.addEventListener('click', () => - this.select(-1, 'previous-button') + rememberActiveVibeSelection(); + writeCurrentVibeUri(activeVibe.id, 'replace'); + + const { signal } = this.abortController; + this.previousButton.addEventListener( + 'click', + () => this.select(-1, 'previous-button'), + { signal } ); - this.nextButton.addEventListener('click', () => this.select(1, 'next-button')); + this.nextButton.addEventListener('click', () => this.select(1, 'next-button'), { + signal, + }); + window.addEventListener('popstate', this.selectFromCurrentUri, { signal }); + } + + public destroy(): void { + this.abortController.abort(); } private select(offset: number, source: string): void { const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); + const currentIndex = current >= 0 ? current : 0; const vibe = - VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; + VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; const activePreset = applyVibeSettings(vibe); + writeCurrentVibeUri(activePreset.id, 'push'); + this.notifyChange(activePreset, source, true); + } + + private readonly selectFromCurrentUri = (): void => { + const vibeId = getCurrentUriVibeId(); + if (!vibeId || vibeId === activeVibe.id) { + writeCurrentVibeUri(activeVibe.id, 'replace'); + return; + } + + const vibe = getVibeById(vibeId); + if (!vibe) { + writeCurrentVibeUri(activeVibe.id, 'replace'); + return; + } + + const activePreset = applyVibeSettings(vibe); + writeCurrentVibeUri(activePreset.id, 'replace'); + this.notifyChange(activePreset, 'uri-popstate', false); + }; + + private notifyChange( + activePreset: typeof activeVibe, + source: string, + userGesture: boolean + ): void { this.options.onChange({ + userGesture, vibeId: activePreset.id, vibeName: activePreset.name, source, diff --git a/src/pipelines/agents/agent-dispatch.ts b/src/pipelines/agents/agent-dispatch.ts index a5f97ff..158d556 100644 --- a/src/pipelines/agents/agent-dispatch.ts +++ b/src/pipelines/agents/agent-dispatch.ts @@ -1,15 +1,43 @@ -// Use the device's max workgroup size so we get full SIMD/wave occupancy on -// hardware that supports more than the WebGPU minimum of 256. -export const getAgentWorkgroupSize = (device: GPUDevice): number => - device.limits.maxComputeInvocationsPerWorkgroup; +const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const; + +export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number]; + +const AGENT_WORKGROUP_SIZE_TARGETS = { + // Keep shader-specific targets conservative. Using the device maximum can + // hurt occupancy and makes compaction's workgroup scan more expensive. + simulation: 256, + eraser: 256, + resize: 256, + compaction: 256, +} satisfies Record; + +export const getAgentWorkgroupSize = ( + device: GPUDevice, + kind: AgentWorkgroupKind = 'simulation' +): number => { + const deviceLimit = Math.max( + 1, + Math.floor( + Math.min( + device.limits.maxComputeInvocationsPerWorkgroup, + device.limits.maxComputeWorkgroupSizeX + ) + ) + ); + return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit); +}; + +export const getMinAgentWorkgroupSize = (device: GPUDevice): number => + Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind))); export const substituteAgentWorkgroupSize = ( device: GPUDevice, - shaderCode: string + shaderCode: string, + kind: AgentWorkgroupKind = 'simulation' ): string => shaderCode.replaceAll( '__AGENT_WORKGROUP_SIZE__', - String(getAgentWorkgroupSize(device)) + String(getAgentWorkgroupSize(device, kind)) ); export const dispatchAgentWorkgroups = ( diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl index 86a3447..ae9336c 100644 --- a/src/pipelines/agents/agent-generation/agent-compaction.wgsl +++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl @@ -9,7 +9,7 @@ struct Counters { aliveAgentCount: atomic, }; -const clearCompactedTailStride = 4u; +const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u; @group(1) @binding(0) var settings: Settings; @group(1) @binding(2) var counters: Counters; @@ -30,7 +30,7 @@ fn main( var isAlive = false; var agent: Agent; if id < settings.agentCount { - isAlive = agents[id].colorIndex >= 0.0; + isAlive = agents[id].colorIndex >= 0.0 && agents[id].colorIndex < 2.5; if isAlive { agent = agents[id]; } diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts index b747e02..524a5c2 100644 --- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts +++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts @@ -20,7 +20,7 @@ export class AgentGenerationPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly uniforms: GPUBuffer; - private readonly bindGroupCache = createBindGroupCache( + private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUBuffer]>( (active, inactive) => this.device.createBindGroup({ layout: this.bindGroupLayout, @@ -36,7 +36,8 @@ export class AgentGenerationPipeline { private readonly resizePipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline; private readonly clearCompactedTailPipeline: GPUComputePipeline; - private readonly workgroupSize: number; + private readonly resizeWorkgroupSize: number; + private readonly compactionWorkgroupSize: number; private activeAgentsBuffer: GPUBuffer; private inactiveAgentsBuffer: GPUBuffer; @@ -110,20 +111,33 @@ export class AgentGenerationPipeline { usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.workgroupSize = getAgentWorkgroupSize(device); - const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema); + this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize'); + this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction'); + const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize'); + const compactionSchema = substituteAgentWorkgroupSize( + device, + agentSchema, + 'compaction' + ); this.resizePipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], }), compute: { - module: smartCompile(device, sizedSchema, resizeShader), + module: smartCompile(device, resizeSchema, resizeShader), entryPoint: 'main', }, }); - const compactionModule = smartCompile(device, sizedSchema, compactionShader); + const compactionModule = smartCompile( + device, + compactionSchema, + compactionShader.replaceAll( + '__CLEAR_COMPACTED_TAIL_STRIDE__', + AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE.toString() + ) + ); this.compactionPipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ @@ -248,7 +262,7 @@ export class AgentGenerationPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.resizePipeline); passEncoder.setBindGroup(1, this.getBindGroup()); - dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount); + dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount); passEncoder.end(); this.device.queue.submit([commandEncoder.finish()]); @@ -267,11 +281,11 @@ export class AgentGenerationPipeline { const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(this.compactionPipeline); passEncoder.setBindGroup(1, this.getBindGroup()); - dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount); + dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount); passEncoder.setPipeline(this.clearCompactedTailPipeline); dispatchAgentWorkgroups( passEncoder, - this.workgroupSize, + this.compactionWorkgroupSize, Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE) ); passEncoder.end(); diff --git a/src/pipelines/agents/agent-limits.ts b/src/pipelines/agents/agent-limits.ts index fdf379a..405b37d 100644 --- a/src/pipelines/agents/agent-limits.ts +++ b/src/pipelines/agents/agent-limits.ts @@ -1,4 +1,4 @@ -import { getAgentWorkgroupSize } from './agent-dispatch'; +import { getMinAgentWorkgroupSize } from './agent-dispatch'; export const AGENT_FLOAT_COUNT = 8; export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; @@ -58,7 +58,7 @@ export const getMaxSupportedAgentCount = ( Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES), Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * - getAgentWorkgroupSize(device) + getMinAgentWorkgroupSize(device) ) ); }; diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts index e647f77..6138935 100644 --- a/src/pipelines/agents/agent-pipeline.ts +++ b/src/pipelines/agents/agent-pipeline.ts @@ -1,4 +1,4 @@ -import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache'; +import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createCachedBufferWrite, writeBufferIfChanged, @@ -38,28 +38,34 @@ export interface AgentSettings { introNearDistanceInner: number; introTurnRateMultiplier: number; introRandomTurnMultiplier: number; - introFarMoveMultiplier: number; - introNearMoveMultiplier: number; introStepStopDistance: number; randomTimeScale: number; } -const UNIFORM_COUNT = 30; +// The Settings struct in WGSL starts with a mat3x3 reactionMatrix. +// In uniform layout each of its 3 columns is stored as a vec3 padded to +// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused +// padding). Remaining scalars pack tightly from float 12 onward. +const UNIFORM_COUNT = 32; +const REACTION_MATRIX_COL0 = 0; +const REACTION_MATRIX_COL1 = 4; +const REACTION_MATRIX_COL2 = 8; +const SCALAR_BASE = 12; export class AgentPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; - private readonly pipeline: GPUComputePipeline; + private readonly pipelineFull: GPUComputePipeline; + private readonly pipelineSteady: GPUComputePipeline; private readonly uniforms: GPUBuffer; private readonly workgroupSize: number; + private useSteadyPipeline = false; private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer); private readonly uniformCache = createCachedBufferWrite( UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT ); - private readonly bindGroupCache = createBindGroupCache3< - GPUBuffer, - GPUTextureView, - GPUTextureView + private readonly bindGroupCache = createBindGroupCache< + [GPUBuffer, GPUTextureView, GPUTextureView] >((agentsBuffer, trailMapIn, trailMapOut) => this.device.createBindGroup({ layout: this.bindGroupLayout, @@ -104,23 +110,30 @@ export class AgentPipeline { ], }); - this.workgroupSize = getAgentWorkgroupSize(device); + this.workgroupSize = getAgentWorkgroupSize(device, 'simulation'); const shaderModule = smartCompile( device, CommonState.shaderCode, - substituteAgentWorkgroupSize(device, agentSchema), + substituteAgentWorkgroupSize(device, agentSchema, 'simulation'), shader ); const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], }); - this.pipeline = device.createComputePipeline({ + this.pipelineFull = device.createComputePipeline({ layout: pipelineLayout, compute: { module: shaderModule, entryPoint: 'main', }, }); + this.pipelineSteady = device.createComputePipeline({ + layout: pipelineLayout, + compute: { + module: shaderModule, + entryPoint: 'mainSteady', + }, + }); this.uniforms = device.createBuffer({ size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, @@ -153,8 +166,7 @@ export class AgentPipeline { introProgressCutoff, introTurnRateMultiplier, introRandomTurnMultiplier, - introFarMoveMultiplier, - introNearMoveMultiplier, + introMoveSpeed, introStepStopDistance, randomTimeScale, time, @@ -164,40 +176,46 @@ export class AgentPipeline { deltaTime: number; time: number; agentCount: number; + introMoveSpeed: number; introProgress?: number; }) { this.agentCount = agentCount; - this.uniformValues[0] = moveSpeed * deltaTime; - this.uniformValues[1] = turnSpeed * deltaTime; + const resolvedIntroProgress = introProgress ?? 1; + // Once the intro target phase ends nothing reads intro fields again, so the + // steady-only pipeline can replace the full one for the rest of the session. + this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff; + // Reaction matrix: column N holds the weights for source colorIndex == N. + this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1; + this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2; + this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3; + this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1; + this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2; + this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3; + this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1; + this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2; + this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3; + this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime; + this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime; const sensorAngle = (sensorOffsetAngle * Math.PI) / 180; - this.uniformValues[2] = Math.sin(sensorAngle); - this.uniformValues[3] = Math.cos(sensorAngle); - this.uniformValues[4] = sensorOffsetDistance; - this.uniformValues[5] = turnWhenLost; - this.uniformValues[6] = individualTrailWeight; - this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount)); - this.uniformValues[8] = introProgress ?? 1; - this.uniformValues[9] = color1ToColor1; - this.uniformValues[10] = color1ToColor2; - this.uniformValues[11] = color1ToColor3; - this.uniformValues[12] = color2ToColor1; - this.uniformValues[13] = color2ToColor2; - this.uniformValues[14] = color2ToColor3; - this.uniformValues[15] = color3ToColor1; - this.uniformValues[16] = color3ToColor2; - this.uniformValues[17] = color3ToColor3; - this.uniformValues[18] = forwardRotationScale; - this.uniformValues[19] = introNearDistanceInner; - this.uniformValues[20] = introNearDistanceMin; - this.uniformValues[21] = introNearSensorOffsetMultiplier; - this.uniformValues[22] = introTargetAngleBlend; - this.uniformValues[23] = introProgressCutoff; - this.uniformValues[24] = introTurnRateMultiplier; - this.uniformValues[25] = introRandomTurnMultiplier; - this.uniformValues[26] = introFarMoveMultiplier; - this.uniformValues[27] = introNearMoveMultiplier; - this.uniformValues[28] = introStepStopDistance; - this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0; + this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle); + this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle); + this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance; + this.uniformValues[SCALAR_BASE + 5] = turnWhenLost; + this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight; + this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount)); + this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress; + this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale; + this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner; + this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin; + this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier; + this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend; + this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff; + this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier; + this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier; + this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime; + this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance; + this.uniformUintValues[SCALAR_BASE + 19] = + Math.max(0, Math.floor(time * randomTimeScale)) >>> 0; writeBufferIfChanged( this.device, this.uniforms, @@ -219,7 +237,9 @@ export class AgentPipeline { const passEncoder = commandEncoder.beginComputePass( timestampWrites ? { timestampWrites } : undefined ); - passEncoder.setPipeline(this.pipeline); + passEncoder.setPipeline( + this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull + ); this.commonState.execute(passEncoder); passEncoder.setBindGroup( 1, diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl index 43fe63f..912e2a0 100644 --- a/src/pipelines/agents/agent.wgsl +++ b/src/pipelines/agents/agent.wgsl @@ -2,7 +2,16 @@ const PI: f32 = 3.14159265359; const TAU: f32 = 6.28318530718; const INV_TAU: f32 = 0.15915494309; +const CHANNEL_MASKS = array, 3>( + vec3(1.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + vec3(0.0, 0.0, 1.0), +); + struct Settings { + // Columns are indexed by source colorIndex; each column holds the per-target + // weights (colorXToColor1, colorXToColor2, colorXToColor3). + reactionMatrix: mat3x3, moveRate: f32, turnRate: f32, sensorAngleSin: f32, @@ -12,15 +21,6 @@ struct Settings { individualTrailWeight: f32, agentCount: u32, introProgress: f32, - color1ToColor1: f32, - color1ToColor2: f32, - color1ToColor3: f32, - color2ToColor1: f32, - color2ToColor2: f32, - color2ToColor3: f32, - color3ToColor1: f32, - color3ToColor2: f32, - color3ToColor3: f32, forwardRotationScale: f32, introNearDistanceInner: f32, introNearDistanceMin: f32, @@ -29,8 +29,7 @@ struct Settings { introProgressCutoff: f32, introTurnRateMultiplier: f32, introRandomTurnMultiplier: f32, - introFarMoveMultiplier: f32, - introNearMoveMultiplier: f32, + introMoveRate: f32, introStepStopDistance: f32, randomTimeSeed: u32, }; @@ -39,6 +38,11 @@ struct Settings { @group(1) @binding(2) var trailMapIn: texture_2d; @group(1) @binding(3) var trailMapOut: texture_storage_2d; +struct AgentMovement { + rotation: f32, + step: vec2, +} + @compute @workgroup_size(agentWorkgroupSize) fn main( @builtin(global_invocation_id) global_id: vec3 @@ -54,8 +58,8 @@ fn main( return; } - var position = agents[id].position; - var angle = agents[id].angle; + let position = agents[id].position; + let angle = agents[id].angle; var targetPosition = vec2(-1.0, -1.0); var hasIntroTarget = false; if settings.introProgress < settings.introProgressCutoff { @@ -70,92 +74,153 @@ fn main( let reactionMask = get_reaction_mask(colorIndex); let randomSeed = random_seed(id); let maxPosition = state.size - vec2(1.0, 1.0); - var rotation = 0.0; - var step = vec2(0.0, 0.0); + var movement = AgentMovement(0.0, vec2(0.0, 0.0)); if hasIntroTarget { - let introTargetOffset = targetPosition - position; - let introTargetDistance = length(introTargetOffset); - let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x); - let nearTitle = 1.0 - smoothstep( - settings.introNearDistanceInner, - max( - settings.introNearDistanceMin, - settings.sensorOffset * settings.introNearSensorOffsetMultiplier - ), - introTargetDistance - ); - let desiredAngle = mix( - targetAngle, - agents[id].targetAngle, - nearTitle * settings.introTargetAngleBlend - ); - let introTurn = angle_delta(angle, desiredAngle); - - rotation = clamp( - introTurn, - -settings.turnRate * settings.introTurnRateMultiplier, - settings.turnRate * settings.introTurnRateMultiplier - ) - + (random_float(randomSeed + 1013904223u) - 0.5) * - settings.turnWhenLost * - settings.introRandomTurnMultiplier; - let moveRate = min( - settings.moveRate * - mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle), - introTargetDistance - ); - if introTargetDistance > settings.introStepStopDistance { - step = introTargetOffset / introTargetDistance * moveRate; - } + movement = intro_decide(id, position, angle, targetPosition, randomSeed); } else { - 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); - - rotation = (randomTurn - 0.5) * settings.turnWhenLost; - if weightForward >= weightLeft && weightForward >= weightRight { - rotation = rotation * settings.forwardRotationScale; - } else { - rotation += sign(weightLeft - weightRight) * settings.turnRate; - } - - step = direction * settings.moveRate; + movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition); } - let nextPosition = clamp(position + step, vec2(0, 0), maxPosition); + agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement); +} + +// Steady-state-only entry point used after introProgress >= introProgressCutoff. +// Drops the intro target reads, atan2/smoothstep math, and introDelay check — +// once intro completes those paths are dead for the rest of the session. +@compute @workgroup_size(agentWorkgroupSize) +fn mainSteady( + @builtin(global_invocation_id) global_id: vec3 +) { + let id = get_id(global_id); + + if id >= settings.agentCount { + return; + } + + let colorIndex = agents[id].colorIndex; + if colorIndex < 0.0 || colorIndex >= 2.5 { + return; + } + + let position = agents[id].position; + let angle = agents[id].angle; + let channelMask = get_channel_mask(colorIndex); + let reactionMask = get_reaction_mask(colorIndex); + let randomSeed = random_seed(id); + let maxPosition = state.size - vec2(1.0, 1.0); + + let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition); + agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement); +} + +fn steady_decide( + position: vec2, + angle: f32, + reactionMask: vec3, + randomSeed: u32, + maxPosition: vec2 +) -> AgentMovement { + let randomTurn = random_float(randomSeed); + let direction = vec2(cos(angle), sin(angle)); + + let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition); + let leftSensor = sensor_position( + position, + rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + let rightSensor = sensor_position( + position, + rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos), + settings.sensorOffset, + maxPosition + ); + + let trailForward = textureLoad(trailMapIn, forwardSensor, 0); + let trailLeft = textureLoad(trailMapIn, leftSensor, 0); + let trailRight = textureLoad(trailMapIn, rightSensor, 0); + + let weightForward = dot(trailForward.rgb, reactionMask); + let weightLeft = dot(trailLeft.rgb, reactionMask); + let weightRight = dot(trailRight.rgb, reactionMask); + + var rotation = (randomTurn - 0.5) * settings.turnWhenLost; + if weightForward >= weightLeft && weightForward >= weightRight { + rotation = rotation * settings.forwardRotationScale; + } else { + rotation += sign(weightLeft - weightRight) * settings.turnRate; + } + + return AgentMovement(rotation, direction * settings.moveRate); +} + +fn intro_decide( + id: u32, + position: vec2, + angle: f32, + targetPosition: vec2, + randomSeed: u32 +) -> AgentMovement { + let introTargetOffset = targetPosition - position; + let introTargetDistance = length(introTargetOffset); + let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x); + let nearTitle = 1.0 - smoothstep( + settings.introNearDistanceInner, + max( + settings.introNearDistanceMin, + settings.sensorOffset * settings.introNearSensorOffsetMultiplier + ), + introTargetDistance + ); + let desiredAngle = mix( + targetAngle, + agents[id].targetAngle, + nearTitle * settings.introTargetAngleBlend + ); + let introTurn = angle_delta(angle, desiredAngle); + + let rotation = clamp( + introTurn, + -settings.turnRate * settings.introTurnRateMultiplier, + settings.turnRate * settings.introTurnRateMultiplier + ) + + (random_float(randomSeed + 1013904223u) - 0.5) * + settings.turnWhenLost * + settings.introRandomTurnMultiplier; + let moveRate = min(settings.introMoveRate, introTargetDistance); + var step = vec2(0.0, 0.0); + if introTargetDistance > settings.introStepStopDistance { + step = introTargetOffset / introTargetDistance * moveRate; + } + return AgentMovement(rotation, step); +} + +fn agent_finalize( + id: u32, + position: vec2, + angle: f32, + channelMask: vec3, + randomSeed: u32, + maxPosition: vec2, + movement: AgentMovement +) { + let nextPosition = clamp(position + movement.step, vec2(0, 0), maxPosition); + var rotation = movement.rotation; if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { rotation = PI + random_float(randomSeed + 22695477u) - 0.5; } - var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0); - trailBelow = vec4( - trailBelow.rgb + channelMask * settings.individualTrailWeight, - max(trailBelow.a, 0.0) + // Writes only this agent's last-writer-wins deposit into a per-frame-cleared + // depositMap. Storage textures do not blend concurrent compute writes, so + // overlapping agents intentionally collapse to whichever write wins. The + // diffusion pass then sums trailMap + depositMap at tile-load time. + textureStore( + trailMapOut, + vec2(nextPosition), + vec4(channelMask * settings.individualTrailWeight, 0.0) ); - - textureStore(trailMapOut, vec2(nextPosition), trailBelow); agents[id].angle = angle + rotation; agents[id].position = nextPosition; } @@ -181,41 +246,11 @@ fn rotate_direction(direction: vec2, angleSin: f32, angleCos: f32) -> vec2< } fn get_channel_mask(colorIndex: f32) -> vec3 { - if colorIndex < 0.5 { - return vec3(1, 0, 0); - } - if colorIndex < 1.5 { - return vec3(0, 1, 0); - } - if colorIndex < 2.5 { - return vec3(0, 0, 1); - } - return vec3(0.0, 0.0, 0.0); + return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))]; } fn get_reaction_mask(colorIndex: f32) -> vec3 { - if colorIndex < 0.5 { - return vec3( - settings.color1ToColor1, - settings.color1ToColor2, - settings.color1ToColor3 - ); - } - if colorIndex < 1.5 { - return vec3( - settings.color2ToColor1, - settings.color2ToColor2, - settings.color2ToColor3 - ); - } - if colorIndex < 2.5 { - return vec3( - settings.color3ToColor1, - settings.color3ToColor2, - settings.color3ToColor3 - ); - } - return vec3(0.0, 0.0, 0.0); + return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))]; } fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 { diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts index 32584fa..b759c4f 100644 --- a/src/pipelines/brush/brush-pipeline.ts +++ b/src/pipelines/brush/brush-pipeline.ts @@ -1,6 +1,7 @@ import { vec2 } from 'gl-matrix'; import { appConfig } from '../../config'; +import { getRenderQualityBrushSize } from '../../config/brush-size'; import { createCachedBufferWrite, writeBufferIfChanged, @@ -28,6 +29,7 @@ export interface BrushSettings { } interface BrushParameters extends BrushSettings { + internalRenderAreaMegapixels: number; pixelRatio?: number; selectedColorIndex: number; } @@ -50,12 +52,16 @@ const setBrushUniformValues = ( brushGrainNoiseOffsetY, brushGrainMinStrength, brushGrainMaxStrength, + internalRenderAreaMegapixels, selectedColorIndex, pixelRatio, }: BrushParameters ): void => { const safePixelRatio = getSafePixelRatio(pixelRatio); - const brushRadius = (brushSize * safePixelRatio) / 2; + const brushRadius = + (getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) * + safePixelRatio) / + 2; target[0] = brushRadius; target[1] = brushRadius * brushRadius; @@ -116,7 +122,7 @@ export class BrushPipeline { }, fragment: { module: shaderModule, - entryPoint: 'fragmentMrt', + entryPoint: 'fragment', targets: [ { format: TRAIL_SOURCE_TEXTURE_FORMAT, @@ -160,7 +166,7 @@ export class BrushPipeline { this.segments.flush(); } - public executeMultiTarget( + public executeSource( commandEncoder: GPUCommandEncoder, sourceMapOut: GPUTextureView, timestampWrites?: GPURenderPassTimestampWrites @@ -170,6 +176,7 @@ export class BrushPipeline { return false; } + recordBrushPassForE2e(); const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }], timestampWrites, @@ -188,3 +195,12 @@ export class BrushPipeline { this.uniforms.destroy(); } } + +const recordBrushPassForE2e = (): void => { + if (typeof window === 'undefined') { + return; + } + + const state = window as Window & { __fleetingGardenBrushPasses?: number }; + state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1; +}; diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl index b12f376..8152986 100644 --- a/src/pipelines/brush/brush.wgsl +++ b/src/pipelines/brush/brush.wgsl @@ -50,7 +50,7 @@ fn vertex( } @fragment -fn fragmentMrt( +fn fragment( @location(0) screenPosition: vec2, @location(1) @interpolate(flat) start: vec2, @location(2) @interpolate(flat) direction: vec2, diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl index 684b37a..92ae448 100644 --- a/src/pipelines/diffusion/diffuse.wgsl +++ b/src/pipelines/diffusion/diffuse.wgsl @@ -27,9 +27,13 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10; @group(0) @binding(0) var settings: Settings; @group(0) @binding(1) var trailMap: texture_2d; @group(0) @binding(2) var trailMapOut: texture_storage_2d; +// Per-frame deposit accumulator written sparsely by agents. Summed with +// trailMap at tile-load so deposits propagate through the diffusion kernel +// in the same frame. +@group(0) @binding(3) var depositMap: texture_2d; -var tile: array, 324>; -var tileTrailStrength: array; +var tile: array, TILE_TEXEL_COUNT>; +var tileTrailStrength: array; @compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__) fn main( @@ -49,7 +53,8 @@ fn main( vec2(0, 0), textureBound ); - let texel = textureLoad(trailMap, sourcePixel, 0); + let texel = textureLoad(trailMap, sourcePixel, 0) + + textureLoad(depositMap, sourcePixel, 0); tile[tileIndex] = texel; tileTrailStrength[tileIndex] = length(texel.rgb); } diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts index eb92128..b1c763d 100644 --- a/src/pipelines/diffusion/diffusion-pipeline.ts +++ b/src/pipelines/diffusion/diffusion-pipeline.ts @@ -69,20 +69,27 @@ export class DiffusionPipeline { private readonly bindGroupLayout: GPUBindGroupLayout; private readonly pipeline: GPUComputePipeline; private readonly uniforms: GPUBuffer; + // 1x1 zero texture used as the depositMap binding when callers don't supply + // one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for + // out-of-bounds coordinates, so the diffusion shader sums in zeros. + private readonly emptyDepositTexture: GPUTexture; + private readonly emptyDepositTextureView: GPUTextureView; private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT); private readonly uniformCache = createCachedBufferWrite( DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT ); - private readonly getBindGroup = createBindGroupCache( - (trailMapIn, trailMapOut) => - this.device.createBindGroup({ - layout: this.bindGroupLayout, - entries: [ - { binding: 0, resource: { buffer: this.uniforms } }, - { binding: 1, resource: trailMapIn }, - { binding: 2, resource: trailMapOut }, - ], - }) + private readonly getBindGroup = createBindGroupCache< + [GPUTextureView, GPUTextureView, GPUTextureView] + >((trailMapIn, trailMapOut, depositMap) => + this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniforms } }, + { binding: 1, resource: trailMapIn }, + { binding: 2, resource: trailMapOut }, + { binding: 3, resource: depositMap }, + ], + }) ); public constructor(private readonly device: GPUDevice) { @@ -104,6 +111,26 @@ export class DiffusionPipeline { size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); + + this.emptyDepositTexture = device.createTexture({ + format: TRAIL_SOURCE_TEXTURE_FORMAT, + size: { width: 1, height: 1 }, + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT, + }); + this.emptyDepositTextureView = this.emptyDepositTexture.createView(); + const clearEncoder = device.createCommandEncoder(); + const clearPass = clearEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this.emptyDepositTextureView, + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + clearPass.end(); + device.queue.submit([clearEncoder.finish()]); } public setParameters({ @@ -135,9 +162,14 @@ export class DiffusionPipeline { trailMapIn: GPUTextureView, trailMapOut: GPUTextureView, size: vec2, + depositMap: GPUTextureView | null, timestampWrites?: GPUComputePassTimestampWrites ) { - const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); + const bindGroup = this.getBindGroup( + trailMapIn, + trailMapOut, + depositMap ?? this.emptyDepositTextureView + ); const passEncoder = commandEncoder.beginComputePass( timestampWrites ? { timestampWrites } : undefined @@ -153,6 +185,7 @@ export class DiffusionPipeline { public destroy() { this.uniforms.destroy(); + this.emptyDepositTexture.destroy(); } private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { @@ -180,6 +213,13 @@ export class DiffusionPipeline { format: TRAIL_SOURCE_TEXTURE_FORMAT, }, }, + { + binding: 3, + visibility: GPUShaderStage.COMPUTE, + texture: { + sampleType: 'float', + }, + }, ], }; } diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts index faef27c..5cc9b32 100644 --- a/src/pipelines/eraser/eraser-agent-pipeline.ts +++ b/src/pipelines/eraser/eraser-agent-pipeline.ts @@ -32,7 +32,7 @@ export class EraserAgentPipeline { private readonly uniformCache = createCachedBufferWrite( EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT ); - private readonly bindGroupCache = createBindGroupCache( + private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>( (agentsBuffer, eraserMask) => this.device.createBindGroup({ layout: this.bindGroupLayout, @@ -86,7 +86,7 @@ export class EraserAgentPipeline { usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - this.workgroupSize = getAgentWorkgroupSize(device); + this.workgroupSize = getAgentWorkgroupSize(device, 'eraser'); this.pipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], @@ -94,7 +94,7 @@ export class EraserAgentPipeline { compute: { module: smartCompile( device, - substituteAgentWorkgroupSize(device, agentSchema), + substituteAgentWorkgroupSize(device, agentSchema, 'eraser'), shader ), entryPoint: 'main', diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl index 273ffbc..f98d551 100644 --- a/src/pipelines/eraser/eraser-agent.wgsl +++ b/src/pipelines/eraser/eraser-agent.wgsl @@ -21,7 +21,7 @@ fn main( } let colorIndex = agents[id].colorIndex; - if colorIndex < 0.0 { + if colorIndex < 0.0 || colorIndex >= 2.5 { return; } diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts index 57f0b77..12d7ada 100644 --- a/src/pipelines/render/render-pipeline.ts +++ b/src/pipelines/render/render-pipeline.ts @@ -6,7 +6,6 @@ import { import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad'; import { smartCompile } from '../../utils/graphics/smart-compile'; import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color'; -import { CommonState } from '../common-state/common-state'; import shader from './render.wgsl?raw'; export interface RenderSettings { @@ -30,7 +29,7 @@ export class RenderPipeline { UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT ); - private readonly getBindGroup = createBindGroupCache( + private readonly getBindGroup = createBindGroupCache<[GPUTextureView, GPUTextureView]>( (colorTexture, sourceTexture) => this.device.createBindGroup({ layout: this.bindGroupLayout, @@ -45,7 +44,6 @@ export class RenderPipeline { public constructor( private readonly context: GPUCanvasContext, private readonly device: GPUDevice, - private readonly commonState: CommonState, private readonly canvasFormat: GPUTextureFormat ) { this.bindGroupLayout = device.createBindGroupLayout({ @@ -68,10 +66,10 @@ export class RenderPipeline { ], }); - const shaderModule = smartCompile(device, CommonState.shaderCode, shader); + const shaderModule = smartCompile(device, shader); const vertex = setUpFullScreenQuad(device); const pipelineLayout = device.createPipelineLayout({ - bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], + bindGroupLayouts: [this.bindGroupLayout], }); this.pipeline = this.createPipeline( pipelineLayout, @@ -207,8 +205,7 @@ export class RenderPipeline { timestampWrites, }); passEncoder.setPipeline(this.getPipeline(useSourceTexture)); - this.commonState.execute(passEncoder); - passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture)); + passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture)); passEncoder.draw(3, 1); passEncoder.end(); } diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl index 65e9229..7e1ab26 100644 --- a/src/pipelines/render/render.wgsl +++ b/src/pipelines/render/render.wgsl @@ -12,9 +12,17 @@ struct Settings { brushColorStrengthMultiplier: f32, }; -@group(1) @binding(0) var settings: Settings; -@group(1) @binding(2) var trailMap: texture_2d; -@group(1) @binding(3) var sourceMap: texture_2d; +const COMMON_CHANNEL_REDUCTION: f32 = 0.75; +const OVERLAP_SATURATION_BOOST: f32 = 1.35; +const LOW_SATURATION_RESCUE_AMOUNT: f32 = 0.65; +const LOW_SATURATION_RESCUE_MIN: f32 = 0.08; +const LOW_SATURATION_RESCUE_MAX: f32 = 0.22; +const COLOR_WEIGHT_EPSILON: f32 = 0.0001; +const LUMA_WEIGHTS: vec3 = vec3(0.2126, 0.7152, 0.0722); + +@group(0) @binding(0) var settings: Settings; +@group(0) @binding(2) var trailMap: texture_2d; +@group(0) @binding(3) var sourceMap: texture_2d; @fragment fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { @@ -41,25 +49,13 @@ fn renderColor(traces: vec4, sources: vec4, background: vec3) -> } if brushStrength <= 0.0 { - let traceColor = - traceStrengths.r * settings.colorA - + traceStrengths.g * settings.colorB - + traceStrengths.b * settings.colorC; - let normalizedTraceColor = normalizeColorIntensity(traceColor); - return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1); + let traceColor = colorFromChannelStrengths(traceStrengths); + return vec4(mix(background, clamp(traceColor, vec3(0), vec3(1)), traceStrength), 1); } let strengths = max(traceStrengths, sourceStrengths); - let traceColor = - strengths.r * settings.colorA - + strengths.g * settings.colorB - + strengths.b * settings.colorC; - let normalizedTraceColor = normalizeColorIntensity(traceColor); - let brushColor = - sourceStrengths.r * settings.colorA - + sourceStrengths.g * settings.colorB - + sourceStrengths.b * settings.colorC; - let normalizedBrushColor = normalizeColorIntensity(brushColor); + let traceColor = colorFromChannelStrengths(strengths); + let brushColor = colorFromChannelStrengths(sourceStrengths); let brushVisibility = clamp( brushStrength * ( settings.brushColorBase + @@ -68,7 +64,7 @@ fn renderColor(traces: vec4, sources: vec4, background: vec3) -> 0, 1 ); - let color = max(normalizedTraceColor, normalizedBrushColor); + let color = mix(traceColor, brushColor, brushVisibility); let strength = max(maxComponent(strengths), brushVisibility); return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1); @@ -78,10 +74,80 @@ fn maxComponent(v: vec3) -> f32 { return max(max(v.r, v.g), v.b); } +fn minComponent(v: vec3) -> f32 { + return min(min(v.r, v.g), v.b); +} + +fn componentSum(v: vec3) -> f32 { + return v.r + v.g + v.b; +} + fn clarity(strength: vec3) -> vec3 { return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity)); } +fn colorFromChannelStrengths(strengths: vec3) -> vec3 { + if maxComponent(strengths) <= 0.0 { + return vec3(0.0); + } + + let weights = colorWeights(strengths); + let color = + weights.r * settings.colorA + + weights.g * settings.colorB + + weights.b * settings.colorC; + return preserveOverlapVibrancy(normalizeColorIntensity(color), strengths); +} + +fn colorWeights(strengths: vec3) -> vec3 { + let commonStrength = minComponent(strengths); + var weightBase = max( + strengths - vec3(commonStrength * COMMON_CHANNEL_REDUCTION), + vec3(0.0) + ); + if componentSum(weightBase) <= COLOR_WEIGHT_EPSILON { + weightBase = strengths; + } + + let sharpenedWeights = weightBase * weightBase; + return sharpenedWeights / max(COLOR_WEIGHT_EPSILON, componentSum(sharpenedWeights)); +} + +fn preserveOverlapVibrancy(color: vec3, strengths: vec3) -> vec3 { + let strongest = maxComponent(strengths); + let overlapAmount = clamp( + (componentSum(strengths) - strongest) / max(COLOR_WEIGHT_EPSILON, strongest), + 0.0, + 1.0 + ); + + let luminance = dot(color, LUMA_WEIGHTS); + var vibrantColor = clamp( + vec3(luminance) + + (color - vec3(luminance)) * + mix(1.0, OVERLAP_SATURATION_BOOST, overlapAmount), + vec3(0.0), + vec3(1.0) + ); + + let saturation = maxComponent(vibrantColor) - minComponent(vibrantColor); + let rescueAmount = + overlapAmount * + (1.0 - smoothstep(LOW_SATURATION_RESCUE_MIN, LOW_SATURATION_RESCUE_MAX, saturation)) * + LOW_SATURATION_RESCUE_AMOUNT; + return mix(vibrantColor, dominantColor(strengths), rescueAmount); +} + +fn dominantColor(strengths: vec3) -> vec3 { + if strengths.r >= strengths.g && strengths.r >= strengths.b { + return normalizeColorIntensity(settings.colorA); + } + if strengths.g >= strengths.b { + return normalizeColorIntensity(settings.colorB); + } + return normalizeColorIntensity(settings.colorC); +} + fn normalizeColorIntensity(color: vec3) -> vec3 { let brightestChannel = maxComponent(color); return color / max(settings.traceNormalizationFloor, brightestChannel); diff --git a/src/settings.ts b/src/settings.ts index ee3d541..c91ac83 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -18,12 +18,20 @@ const preservedRuntimeSettingKeys = [ const cloneRgbColor = (color: T): T => [...color] as T; +const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({ + ...audio, + ...(audio.scale ? { scale: [...audio.scale] } : {}), + ...(audio.progression + ? { progression: audio.progression.map((chord) => ({ ...chord })) } + : {}), +}); + const cloneVibePreset = (vibe: VibePreset): VibePreset => ({ ...vibe, colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'], backgroundColor: cloneRgbColor(vibe.backgroundColor), settings: { ...vibe.settings }, - audio: { ...vibe.audio }, + audio: cloneVibeAudio(vibe.audio), }); const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => @@ -43,6 +51,10 @@ export const settings: GardenRuntimeSettings = { ...buildSettings(activeVibe), }; +export const rememberActiveVibeSelection = (): void => { + writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id); +}; + export const applyVibeSettings = (vibe: VibePreset) => { activeVibe = cloneVibePreset(vibe); const nextSettings = buildSettings(activeVibe); @@ -59,7 +71,7 @@ export const applyVibeSettings = (vibe: VibePreset) => { normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls) ); - writeBrowserStorage(appConfig.storage.vibeKey, vibe.id); + rememberActiveVibeSelection(); return activeVibe; }; diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss index f677b4b..ad5402f 100644 --- a/src/style/_config-pane.scss +++ b/src/style/_config-pane.scss @@ -1,3 +1,5 @@ +@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) @@ -41,6 +43,9 @@ touch-action: pan-y; -webkit-overflow-scrolling: touch; + // Tweakpane v4 internal classes — re-verify on upgrade. + // No public theming hook exists for label padding or the slider/number + // flex ratio; if a fourth override appears here, switch to a custom plugin. .tp-lblv_l { padding-right: 10px; } @@ -139,6 +144,7 @@ font-size: 11px; + // Tweakpane v4 internal class — re-verify on upgrade. .tp-sldtxtv_t { flex-basis: 48px; } @@ -150,7 +156,7 @@ } } -@media (max-width: 599px), (hover: none) and (pointer: coarse) { +@include on-mobile-input { @include mobile-config-pane; } diff --git a/src/style/common.scss b/src/style/common.scss index c579827..23c82aa 100644 --- a/src/style/common.scss +++ b/src/style/common.scss @@ -16,6 +16,7 @@ html { height: 100%; + touch-action: manipulation; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; @@ -23,6 +24,7 @@ html { body { font-family: 'Open Sans', sans-serif; + touch-action: manipulation; } .visually-hidden { diff --git a/src/style/mixins.scss b/src/style/mixins.scss index 46f2dd9..9197605 100644 --- a/src/style/mixins.scss +++ b/src/style/mixins.scss @@ -5,3 +5,9 @@ $breakpoint-width: 600px !default; @content; } } + +@mixin on-mobile-input() { + @media (max-width: ($breakpoint-width - 1px)), (hover: none) and (pointer: coarse) { + @content; + } +} diff --git a/src/style/toolbar/_layout.scss b/src/style/toolbar/_layout.scss index 6bae351..f2c8930 100644 --- a/src/style/toolbar/_layout.scss +++ b/src/style/toolbar/_layout.scss @@ -5,16 +5,20 @@ --toolbar-background-strength: 0; --toolbar-divider-space: clamp(6px, 1.8vw, 14px); --toolbar-top-max-width: 594px; + --vibe-button-hit-size: 64px; display: grid; grid-template-areas: 'previous controls next' 'previous divider next' 'previous buttons next'; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: + var(--vibe-button-hit-size) + minmax(0, var(--toolbar-top-max-width)) + var(--vibe-button-hit-size); align-items: stretch; justify-content: center; - width: 100%; + width: fit-content; max-width: 100%; margin: 0 auto; padding-inline: clamp(8px, 1.4vw, 14px); @@ -82,40 +86,72 @@ } > .vibe-button { + --vibe-button-surface-inset-block: 10px; + --vibe-button-surface-inset-inline: 8px; + --vibe-chevron-size: 22px; + --vibe-chevron-stroke: 4px; + position: relative; + isolation: isolate; display: grid; place-items: center; - width: 52px; + width: var(--vibe-button-hit-size); height: auto; - min-height: 66px; + min-height: 72px; flex: 0 0 auto; padding: 0; border-radius: 0; background: transparent; - color: rgb(255 255 255 / 70%); + color: rgb(255 255 255 / 88%); font-size: 0; line-height: 1; + &::after { + content: ''; + position: absolute; + z-index: 0; + inset: var(--vibe-button-surface-inset-block) + var(--vibe-button-surface-inset-inline); + border-radius: 8px; + background: rgb(255 255 255 / calc(9% + var(--toolbar-background-strength) * 10%)); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 18%), + 0 8px 18px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 22%)); + transition: + background var(--transition-time), + box-shadow var(--transition-time), + opacity var(--transition-time); + } + &::before { content: ''; position: absolute; + z-index: 1; top: 50%; left: 50%; - width: 18px; - height: 18px; + width: var(--vibe-chevron-size); + height: var(--vibe-chevron-size); border-color: currentColor; border-style: solid; - border-width: 0 0 3px 3px; + border-width: 0 0 var(--vibe-chevron-stroke) var(--vibe-chevron-stroke); + filter: drop-shadow(0 1px 3px rgb(0 0 0 / 70%)); transform: translate(-35%, -50%) rotate(45deg); } &.next-vibe::before { - border-width: 3px 3px 0 0; + border-width: var(--vibe-chevron-stroke) var(--vibe-chevron-stroke) 0 0; transform: translate(-65%, -50%) rotate(45deg); } &:hover { - color: color-mix(in srgb, var(--accent-color) 70%, white); + color: white; + } + + &:hover::after { + background: color-mix(in srgb, var(--accent-color) 34%, rgb(255 255 255 / 18%)); + box-shadow: + inset 0 0 0 1px rgb(255 255 255 / 28%), + 0 10px 22px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 30%)); } &.previous-vibe:hover { diff --git a/src/style/toolbar/_responsive.scss b/src/style/toolbar/_responsive.scss index 5cd2963..2aeba6c 100644 --- a/src/style/toolbar/_responsive.scss +++ b/src/style/toolbar/_responsive.scss @@ -4,6 +4,7 @@ @include on-small-screen { --toolbar-divider-space: 4px; --toolbar-top-max-width: 329px; + --vibe-button-hit-size: 44px; grid-template-areas: 'previous controls next' @@ -15,13 +16,13 @@ row-gap: 0; > .vibe-button { - width: 36px; - min-height: 44px; + --vibe-button-surface-inset-block: 5px; + --vibe-button-surface-inset-inline: 3px; + --vibe-chevron-size: 17px; + --vibe-chevron-stroke: 3px; - &::before { - width: 14px; - height: 14px; - } + width: var(--vibe-button-hit-size); + min-height: 44px; } > .toolbar-shell { diff --git a/src/style/vars.scss b/src/style/vars.scss index 24df21b..319d82e 100644 --- a/src/style/vars.scss +++ b/src/style/vars.scss @@ -1,7 +1,7 @@ :root { --transition-time: 200ms; --transition-time-long: 350ms; - --accent-color: rgb(255, 93, 162); + --accent-color: rgb(255 93 162); --main-color: #aaa; --normal-margin: 2rem; --small-margin: 1rem; diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index 328f329..cffd60c 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -223,7 +223,12 @@ export class ErrorHandler { public static addOnErrorListener( listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void - ) { + ): () => void { ErrorHandler.onErrorListeners.push(listener); + return () => { + ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter( + (registeredListener) => registeredListener !== listener + ); + }; } } diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts index 28ecb84..40ebce0 100644 --- a/src/utils/graphics/bind-group-cache.ts +++ b/src/utils/graphics/bind-group-cache.ts @@ -1,48 +1,38 @@ -export const createBindGroupCache = ( - factory: (key1: K1, key2: K2) => GPUBindGroup -): ((key1: K1, key2: K2) => GPUBindGroup) => { - const outer = new WeakMap>(); - return (key1, key2) => { - let inner = outer.get(key1); - if (!inner) { - inner = new WeakMap(); - outer.set(key1, inner); - } - const cached = inner.get(key2); - if (cached) { - return cached; - } - const bindGroup = factory(key1, key2); - inner.set(key2, bindGroup); - return bindGroup; - }; +type BindGroupCacheKeys = readonly [object, ...object[]]; + +interface BindGroupCacheNode { + bindGroup?: GPUBindGroup; + children: WeakMap; +} + +const createNode = (): BindGroupCacheNode => ({ + children: new WeakMap(), +}); + +const getOrCreateNode = ( + children: WeakMap, + key: object +): BindGroupCacheNode => { + let node = children.get(key); + if (!node) { + node = createNode(); + children.set(key, node); + } + return node; }; -export const 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); +export const createBindGroupCache = ( + factory: (...keys: Keys) => GPUBindGroup +): ((...keys: Keys) => GPUBindGroup) => { + const root = new WeakMap(); + + return (...keys) => { + let node = getOrCreateNode(root, keys[0]); + for (const key of keys.slice(1)) { + node = getOrCreateNode(node.children, key); } - 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; + + node.bindGroup ??= factory(...keys); + return node.bindGroup; }; }; diff --git a/src/vibe-uri.test.ts b/src/vibe-uri.test.ts new file mode 100644 index 0000000..fe55d5e --- /dev/null +++ b/src/vibe-uri.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { VibeId } from './config/types'; +import { createVibeUri, getVibeIdFromUri } from './vibe-uri'; + +describe('vibe URI handling', () => { + it('loads vibes from slug IDs and display names', () => { + expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe( + VibeId.AuroraMycelium + ); + expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe( + VibeId.AuroraMycelium + ); + expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe( + VibeId.VelvetObservatory + ); + }); + + it('uses query values before path or hash fallbacks', () => { + expect( + getVibeIdFromUri( + 'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium' + ) + ).toBe(VibeId.LichenSignal); + }); + + it('accepts explicit path segments and hash fallbacks', () => { + expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe( + VibeId.TidepoolLantern + ); + expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe( + VibeId.PaperLanternFog + ); + }); + + it('ignores unknown or malformed vibe values', () => { + expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull(); + expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull(); + expect(getVibeIdFromUri('not a url')).toBeNull(); + }); + + it('creates a canonical query URI without dropping other URL parts', () => { + expect( + createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen) + ).toBe('/garden?debug=1&vibe=chrome-pollen#panel'); + + expect( + createVibeUri( + 'https://example.test/garden?vibe=aurora-mycelium&debug=1', + VibeId.LichenSignal + ) + ).toBe('/garden?vibe=lichen-signal&debug=1'); + }); +}); diff --git a/src/vibe-uri.ts b/src/vibe-uri.ts new file mode 100644 index 0000000..3a4024c --- /dev/null +++ b/src/vibe-uri.ts @@ -0,0 +1,148 @@ +import type { VibeId } from './config/types'; +import { getVibeById, VIBE_PRESETS } from './vibe-registry'; + +const VIBE_URI_QUERY_PARAM = 'vibe'; +const FALLBACK_URL_ORIGIN = 'https://fleeting.garden'; + +const slugifyVibeName = (value: string): string => + value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +const safeDecodeURIComponent = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const normalizeVibeIdentifier = (value: string): string => + slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, '')); + +const vibeIdByIdentifier = new Map(); + +for (const vibe of VIBE_PRESETS) { + vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id); + vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id); +} + +const toUrl = (url: string | URL): URL | null => { + try { + return new URL(url, FALLBACK_URL_ORIGIN); + } catch { + return null; + } +}; + +const resolveVibeId = (value: string | null | undefined): VibeId | null => { + if (!value) { + return null; + } + + return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null; +}; + +const getHashSearchParam = (hash: string): string | null => { + const hashValue = hash.replace(/^#/, ''); + if (!hashValue.includes('=')) { + return null; + } + + const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue; + try { + return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM); + } catch { + return null; + } +}; + +const getPathVibeCandidates = (pathname: string): Array => { + const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean); + const explicitVibeIndex = segments.findIndex((segment) => + ['vibe', 'vibes'].includes(segment.toLowerCase()) + ); + + return [ + explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined, + segments.at(-1), + ].filter((candidate): candidate is string => typeof candidate === 'string'); +}; + +export const getVibeIdFromUri = (url: string | URL): VibeId | null => { + const parsedUrl = toUrl(url); + if (!parsedUrl) { + return null; + } + + const candidates = [ + parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM), + getHashSearchParam(parsedUrl.hash), + ...getPathVibeCandidates(parsedUrl.pathname), + parsedUrl.hash.replace(/^#/, ''), + ]; + + for (const candidate of candidates) { + const vibeId = resolveVibeId(candidate); + if (vibeId) { + return vibeId; + } + } + + return null; +}; + +export const getCurrentUriVibeId = (): VibeId | null => { + if (typeof window === 'undefined') { + return null; + } + + return getVibeIdFromUri(window.location.href); +}; + +const getVibeSlug = (vibeId: VibeId): string => { + const vibe = getVibeById(vibeId); + return vibe ? vibe.id : vibeId; +}; + +export const createVibeUri = (url: string | URL, vibeId: VibeId): string => { + const parsedUrl = toUrl(url); + if (!parsedUrl) { + return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`; + } + + parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId)); + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; +}; + +export const writeCurrentVibeUri = ( + vibeId: VibeId, + mode: 'push' | 'replace' = 'replace' +): void => { + if (typeof window === 'undefined') { + return; + } + + const nextUri = createVibeUri(window.location.href, vibeId); + const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextUri === currentUri) { + return; + } + + const nextState = + typeof window.history.state === 'object' && window.history.state !== null + ? { ...window.history.state, vibeId } + : { vibeId }; + + if (mode === 'push') { + window.history.pushState(nextState, '', nextUri); + return; + } + + window.history.replaceState(nextState, '', nextUri); +}; diff --git a/src/vibes.ts b/src/vibes.ts index b6f1576..95d1f7c 100644 --- a/src/vibes.ts +++ b/src/vibes.ts @@ -1,21 +1,26 @@ import { appConfig } from './config'; import { VibeId, type VibePreset } from './config/types'; import { readBrowserStorage } from './utils/browser-storage'; +import { getVibeById, VIBE_PRESETS } from './vibe-registry'; +import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri'; export { VibeId }; +export { getVibeById, VIBE_PRESETS }; export type { VibePreset }; -export const VIBE_PRESETS: Array = appConfig.vibes.presets; const VIBE_IDS = new Set(VIBE_PRESETS.map((vibe) => vibe.id)); const isVibeId = (value: unknown): value is VibeId => typeof value === 'string' && VIBE_IDS.has(value as VibeId); export const getInitialVibe = (): VibePreset => { + const uriVibeId = getCurrentUriVibeId(); const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey); - const initialVibeId = isVibeId(storedVibeId) + const storedOrLegacyVibeId = isVibeId(storedVibeId) ? storedVibeId - : appConfig.vibes.defaultVibeId; + : getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`); + const initialVibeId = + uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId; - return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0]; + return getVibeById(initialVibeId) ?? VIBE_PRESETS[0]; }; diff --git a/tsconfig.json b/tsconfig.json index 09374bb..fbd104c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "include": ["src/**/*", "definitions.d.ts", "vite.config.ts"] + "include": ["src/**/*", "pwa-assets.config.ts", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 1818b2a..b063e3c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,9 +21,7 @@ export default defineConfig(({ command }) => ({ cssMinify: 'lightningcss', }, server: { - open: true, host: true, - hmr: false, }, test: { environment: 'node',