diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 9030888..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "frontend-design@claude-plugins-official": true - } -} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 232fbf9..bbf03a4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to Pages +name: Check & deploy on: push: @@ -25,20 +25,38 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: | + npm ci + npx playwright install --with-deps chromium - - name: Lint - run: npm run lint -- --check || true + - name: Test + run: | + npm run lint:check + npm run format:check + npm run typecheck + npm run typecheck:e2e + npm test - - name: Typecheck - run: npm run typecheck + - name: Test E2E + run: | + npm run test:e2e - name: Build - run: npm run build + run: | + npm run build + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 - name: Copy build to host pages mount if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | apt update && apt install -y rsync - mkdir -p /pages - rsync -a --delete dist/ /pages/fleeting-garden + rsync -a --delete dist/ /pages/fleeting diff --git a/.gitignore b/.gitignore index 14d1e17..db39815 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,5 @@ -# Dependency directory node_modules -modules/ -ts-node--*/ -rss.xml - dist - -# Logs -logs +test-results +.DS_Store *.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed -*.ssh -*.ppk -v8-compile-cache-0/ -Thumbs.db - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release -bin -ts-node - -# Personal Scripts -*.bat -*.ssh -*.sh -!system.min.js - -# Editors -.vscode -.markdownlint.json - -# Build Files -temp -*.js -*.map -!webpack.* 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/README.md b/README.md index a55e624..ec6f5d7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ -# Just a bunch of blobs +# Fleeting Garden -[![Deploy to GitHub Pages](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml/badge.svg)](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml) +Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette, +draw persistent coloured paths, spawn agents from those strokes, erase locally, +and export the scene as an internal render buffer snapshot. -## todo +Check out the [agent logic](./src/pipelines/agents/agent.wgsl). -- add info page description -- add share link -- settings page - add reset link -- shareable settings -- graceful error messages when no support -- fix up generation id automatically +## Testing -Check out the [agent's logic](./src/pipelines/agents/agent.wgsl). +- `npm test` runs the Vitest unit suite. +- `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright + config builds the production bundle before serving it. +- `npx playwright install chromium` installs the local browser binary when needed. diff --git a/assets/fonts/comfortaa-v40-latin-regular.woff b/assets/fonts/comfortaa-v40-latin-regular.woff deleted file mode 100644 index c54393b..0000000 Binary files a/assets/fonts/comfortaa-v40-latin-regular.woff and /dev/null differ diff --git a/assets/fonts/comfortaa-v40-latin-regular.woff2 b/assets/fonts/comfortaa-v40-latin-regular.woff2 deleted file mode 100644 index bc4da8b..0000000 Binary files a/assets/fonts/comfortaa-v40-latin-regular.woff2 and /dev/null differ diff --git a/assets/fonts/open-sans-v34-latin-regular.woff b/assets/fonts/open-sans-v34-latin-regular.woff deleted file mode 100644 index b083626..0000000 Binary files a/assets/fonts/open-sans-v34-latin-regular.woff and /dev/null differ diff --git a/assets/icons/brush.svg b/assets/icons/brush.svg deleted file mode 100644 index c3a203e..0000000 --- a/assets/icons/brush.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/assets/icons/download.svg b/assets/icons/download.svg new file mode 100644 index 0000000..eb27c41 --- /dev/null +++ b/assets/icons/download.svg @@ -0,0 +1,10 @@ + + + diff --git a/assets/icons/info.svg b/assets/icons/info.svg index a047bf5..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 9c8558e..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 05d54a8..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 f87e22b..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 176c63c..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 new file mode 100644 index 0000000..4f382b5 --- /dev/null +++ b/assets/icons/sound.svg @@ -0,0 +1,3 @@ + + + diff --git a/definitions.d.ts b/definitions.d.ts deleted file mode 100644 index 934370e..0000000 --- a/definitions.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.wgsl?raw' { - const content: string; - export default content; -} diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..1caa3f2 --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,265 @@ +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'; +}; + +const collectLocalBrowserFailures = (page: Page) => { + const failures: Array = []; + + page.on('requestfailed', (request) => { + if (!isLocalUrl(request.url())) { + return; + } + + const failure = request.failure(); + failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); + }); + page.on('response', (response) => { + if (response.status() < 400 || !isLocalUrl(response.url())) { + return; + } + + failures.push(`${response.status()} ${response.url()}`); + }); + + 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', { + configurable: true, + value: undefined, + }); + }); +}; + +test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { + await page.addInitScript((expectedCanvasName) => { + const captureState = { count: 0 }; + Object.defineProperty(window, '__fleetingGardenPointerCaptures', { + configurable: true, + value: captureState, + }); + + const originalSetPointerCapture = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = function setPointerCapture(pointerId) { + if ( + this instanceof HTMLCanvasElement && + this.getAttribute('aria-label') === expectedCanvasName + ) { + captureState.count += 1; + } + + return originalSetPointerCapture.call(this, pointerId); + }; + }, canvasName); + + await page.goto('/'); + const startButton = page.getByRole('button', { exact: true, name: 'Start' }); + await expect(startButton).toBeVisible(); + await expect(startButton).toBeEnabled({ timeout: 30_000 }); + await page.keyboard.press('Enter'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/, { + timeout: 30_000, + }); + + await expect(page.getByRole('alert')).toHaveCount(0); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + + const canvas = page.getByRole('img', { name: canvasName }); + await expect(canvas).toBeVisible(); + const canvasSize = await canvas.evaluate((element) => { + const canvasElement = element as HTMLCanvasElement; + return { + height: canvasElement.height, + width: canvasElement.width, + }; + }); + expect(canvasSize.width).toBeGreaterThan(0); + expect(canvasSize.height).toBeGreaterThan(0); + + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (!box) { + return; + } + + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, { + steps: 16, + }); + await page.mouse.up(); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __fleetingGardenPointerCaptures?: { count: number }; + } + ).__fleetingGardenPointerCaptures?.count ?? 0 + ) + ) + .toBeGreaterThan(0); + + 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 }) => { + await disableWebGpu(page); + await page.goto('/'); + + await expect(page).toHaveTitle('Fleeting Garden'); + await expect(page.getByRole('img', { name: canvasName })).toBeVisible(); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const fallback = page.getByRole('alert'); + await expect(fallback).toContainText('Fleeting Garden needs WebGPU'); + await expect(fallback).toContainText('webgpu-unsupported'); +}); + +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 }) => { + await disableWebGpu(page); + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const audioControl = page.locator('.audio-control'); + 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'); + await expect(soundButton).toHaveCSS('outline-style', 'none'); + + await page.mouse.click(10, 10); + for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) { + await page.keyboard.press('Tab'); + const activeClass = await page.evaluate(() => + String(document.activeElement?.className ?? '') + ); + if (activeClass.includes('sound')) { + break; + } + } + + await expect(soundButton).toBeFocused(); + await expect(soundButton).toHaveCSS('outline-style', 'solid'); + await expect(soundButton).toHaveCSS('outline-offset', '-4px'); + + await page.keyboard.press('Tab'); + await expect(volumeSlider).toBeFocused(); + await expect(volumeSlider).toHaveCSS('outline-style', 'solid'); + await expect(volumeSlider).toHaveCSS('outline-offset', '-4px'); +}); + +test('keeps the config overlay scrollable and dismissible on mobile', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 640 }); + await page.goto('/'); + + const startButton = page.getByRole('button', { 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.getByRole('button', { name: 'Show config overlay' }); + await settingsButton.click(); + + const pane = page.locator('.config-pane'); + const closeButton = page.locator('.config-pane-close'); + await expect(pane).toBeVisible(); + await expect(closeButton).toBeVisible(); + + const paneMetrics = await pane.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + bottom: rect.bottom, + clientHeight: element.clientHeight, + overflowY: style.overflowY, + scrollHeight: element.scrollHeight, + top: rect.top, + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + width: rect.width, + }; + }); + + expect(paneMetrics.top).toBeGreaterThanOrEqual(0); + expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight); + expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8)); + expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight); + expect(['auto', 'scroll']).toContain(paneMetrics.overflowY); + + await pane.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect + .poll(() => pane.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + + await closeButton.click(); + await expect(pane).toBeHidden(); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..db8b127 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js'; +import prettierConfig from 'eslint-config-prettier'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['node_modules/**', 'dist/**', 'public/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.browser, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/ban-ts-comment': 'error', + 'prefer-const': 'error', + }, + } +); diff --git a/index.html b/index.html index 17b5847..fadc28f 100644 --- a/index.html +++ b/index.html @@ -6,76 +6,243 @@ name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" /> - + + + - + + + + + + - - - - + + + + + + - - - - + + + + + - Just a bunch of blobs + + + + + + + + + + Fleeting Garden - +
- + + Your browser cannot display the interactive WebGPU garden canvas. Use a browser + with WebGPU support to draw coloured paths and watch the garden grow. + +

+ Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene + to paint coloured paths, then use the toolbar to change colours, erase, export, + adjust the config overlay, restart, or open more information. +

+ + +
+ +
+
+

Fleeting Garden

+

+ Tend it while you can. The garden returns to weather either way. +

+ +
+ +
- diff --git a/package-lock.json b/package-lock.json index fc94ae1..7c0cc30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,34 @@ { - "name": "webgpu-seed", + "name": "fleeting-garden", "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "webgpu-seed", + "name": "fleeting-garden", "version": "0.2.0", "license": "Unlicense", "dependencies": { - "gl-matrix": "^3.4.4" + "@plausible-analytics/tracker": "^0.4.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", "@types/node": "^25.6.0", "@vite-pwa/assets-generator": "^1.0.2", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@webgpu/types": "^0.1.69", "browserslist": "^4.28.2", + "browserslist-to-esbuild": "^2.1.1", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-unused-imports": "^4.4.1", + "gl-matrix": "^3.4.4", "globals": "^17.6.0", + "knip": "^6.14.1", "lightningcss": "^1.32.0", - "npm-check-updates": "^22.1.0", "prettier": "^3.8.3", "sass": "^1.99.0", "typescript": "^6.0.3", @@ -34,7 +38,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" } }, "node_modules/@babel/code-frame": { @@ -907,6 +911,372 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.130.0.tgz", + "integrity": "sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.130.0.tgz", + "integrity": "sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.130.0.tgz", + "integrity": "sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.130.0.tgz", + "integrity": "sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.130.0.tgz", + "integrity": "sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.130.0.tgz", + "integrity": "sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.130.0.tgz", + "integrity": "sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.130.0.tgz", + "integrity": "sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.130.0.tgz", + "integrity": "sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.130.0.tgz", + "integrity": "sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.130.0.tgz", + "integrity": "sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.130.0.tgz", + "integrity": "sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.130.0.tgz", + "integrity": "sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.130.0.tgz", + "integrity": "sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.130.0.tgz", + "integrity": "sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.130.0.tgz", + "integrity": "sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.130.0.tgz", + "integrity": "sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.130.0.tgz", + "integrity": "sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.130.0.tgz", + "integrity": "sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.130.0.tgz", + "integrity": "sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -917,6 +1287,313 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", + "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", + "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", + "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", + "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", + "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", + "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", + "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", + "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", + "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", + "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", + "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", + "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", + "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", + "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", + "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", + "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", + "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", + "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", + "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", + "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -1245,17 +1922,26 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", + "integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" }, - "funding": { - "url": "https://opencollective.com/pkgr" + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@quansync/fs": { @@ -1560,6 +2246,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweakpane/core": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@tweakpane/core/-/core-2.0.5.tgz", + "integrity": "sha512-punBgD5rKCF5vcNo6BsSOXiDR/NSs9VM7SG65QSLJIxfRaGgj54ree9zQW6bO3pNFf3AogiGgaNODUVQRk9YqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1874,6 +2567,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -2068,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": { @@ -2127,6 +2833,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/browserslist-to-esbuild": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz", + "integrity": "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "meow": "^13.0.0" + }, + "bin": { + "browserslist-to-esbuild": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "browserslist": "*" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2448,53 +3173,6 @@ "eslint": ">=7.0.0" } }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-unused-imports": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", - "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", - "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - } - } - }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -2618,13 +3296,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2639,6 +3310,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2721,6 +3402,22 @@ "dev": true, "license": "ISC" }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2736,10 +3433,24 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gl-matrix": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "dev": true, "license": "MIT" }, "node_modules/glob-parent": { @@ -2850,9 +3561,9 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -2910,6 +3621,46 @@ "json-buffer": "3.0.1" } }, + "node_modules/knip": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.14.1.tgz", + "integrity": "sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", + "minimist": "^1.2.8", + "oxc-parser": "^0.130.0", + "oxc-resolver": "^11.19.1", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.16", + "unbash": "^3.0.0", + "yaml": "^2.9.0", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3223,6 +3974,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3266,6 +4030,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3314,21 +4088,6 @@ "dev": true, "license": "MIT" }, - "node_modules/npm-check-updates": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-22.1.0.tgz", - "integrity": "sha512-zKjYAa205S6UyHkNJGmiLFmm6M31175cvUA3OdHvVlCdtyTfkyQbPWoov/GJEc6PWVbCV5e+60c7S2eVp0ybOA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "ncu": "build/cli.js", - "npm-check-updates": "build/cli.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10.0.0" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3358,6 +4117,86 @@ "node": ">= 0.8.0" } }, + "node_modules/oxc-parser": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.130.0.tgz", + "integrity": "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.130.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.130.0", + "@oxc-parser/binding-android-arm64": "0.130.0", + "@oxc-parser/binding-darwin-arm64": "0.130.0", + "@oxc-parser/binding-darwin-x64": "0.130.0", + "@oxc-parser/binding-freebsd-x64": "0.130.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", + "@oxc-parser/binding-linux-arm64-musl": "0.130.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-musl": "0.130.0", + "@oxc-parser/binding-openharmony-arm64": "0.130.0", + "@oxc-parser/binding-wasm32-wasi": "0.130.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", + "@oxc-parser/binding-win32-x64-msvc": "0.130.0" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/oxc-resolver": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", + "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.19.1", + "@oxc-resolver/binding-android-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-x64": "11.19.1", + "@oxc-resolver/binding-freebsd-x64": "11.19.1", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", + "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-musl": "11.19.1", + "@oxc-resolver/binding-openharmony-arm64": "11.19.1", + "@oxc-resolver/binding-wasm32-wasi": "11.19.1", + "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", + "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", + "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3437,6 +4276,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", @@ -3492,19 +4378,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3546,6 +4419,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -3706,6 +4589,19 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3730,20 +4626,17 @@ "dev": true, "license": "MIT" }, - "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=14.16" }, "funding": { - "url": "https://opencollective.com/synckit" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/tinybench": { @@ -3831,6 +4724,15 @@ "license": "0BSD", "optional": true }, + "node_modules/tweakpane": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz", + "integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/cocopon" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3882,6 +4784,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unbash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz", + "integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/unconfig": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.5.0.tgz", @@ -4151,6 +5063,16 @@ } } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4194,6 +5116,22 @@ "node": ">=0.10.0" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4206,6 +5144,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index ce5a93e..a65ec2a 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,28 @@ { - "name": "webgpu-seed", + "name": "fleeting-garden", "version": "0.2.0", "private": true, "type": "module", - "description": "A WebGPU-powered slime-mold-meets-territory-control simulation.", + "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.", "scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview", - "lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"", + "lint:check": "eslint . && npm run unused:check", + "lint:fix": "eslint . --fix", + "format": "prettier --write \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", + "format:check": "prettier --check \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", "typecheck": "tsc --noEmit", + "typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "test:watch": "vitest", - "generate-icons": "pwa-assets-generator", - "update": "ncu" + "unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports", + "generate-icons": "pwa-assets-generator" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" }, "repository": { "type": "git", @@ -33,23 +39,28 @@ "browserslist": [ "supports webgpu and last 2 years" ], - "dependencies": { - "gl-matrix": "^3.4.4" + "knip": { + "ignoreFiles": [ + "pwa-assets.config.ts" + ] }, "devDependencies": { "@eslint/js": "^10.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@playwright/test": "^1.60.0", + "@tweakpane/core": "~2.0.5", "@types/node": "^25.6.0", "@vite-pwa/assets-generator": "^1.0.2", + "@vitejs/plugin-basic-ssl": "^2.3.0", "@webgpu/types": "^0.1.69", "browserslist": "^4.28.2", + "browserslist-to-esbuild": "^2.1.1", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-unused-imports": "^4.4.1", + "gl-matrix": "^3.4.4", "globals": "^17.6.0", + "knip": "^6.14.1", "lightningcss": "^1.32.0", - "npm-check-updates": "^22.1.0", "prettier": "^3.8.3", "sass": "^1.99.0", "typescript": "^6.0.3", @@ -57,5 +68,9 @@ "vite": "^8.0.10", "vite-plugin-singlefile": "^2.3.3", "vitest": "^4.1.5" + }, + "dependencies": { + "@plausible-analytics/tracker": "^0.4.5", + "tweakpane": "~4.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..01f7ce9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = 4173; +const baseURL = `https://127.0.0.1:${port}`; +const isCi = Boolean(process.env.CI); + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: isCi, + retries: isCi ? 2 : 0, + workers: 1, + reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + ignoreHTTPSErrors: true, + trace: 'on-first-retry', + }, + webServer: { + command: `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`, + ignoreHTTPSErrors: true, + reuseExistingServer: false, + timeout: 120_000, + url: baseURL, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + launchOptions: { + args: ['--enable-unsafe-webgpu'], + }, + }, + }, + ], +}); diff --git a/public/404.html b/public/404.html deleted file mode 100644 index 7c62e5c..0000000 --- a/public/404.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - Not found - - - - - -
-

Page not found.

- Go back -
- - diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png index b8bfae3..257c79a 100644 Binary files a/public/apple-touch-icon-180x180.png and b/public/apple-touch-icon-180x180.png differ diff --git a/public/favicon.ico b/public/favicon.ico index e545180..d26cebe 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index 50bb5e6..c0ad1e9 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,6 +1,31 @@ - - - - + + + + + + + + + + + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index f7b70ec..54a6042 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,38 +1,35 @@ { - "name": "Just a bunch of blobs", - "short_name": "Blobs", - "description": "A WebGPU agent simulation: a million blobs leave trails, infect each other across generations, and react to your brush.", - "start_url": "/", - "scope": "/", + "name": "Fleeting Garden", + "short_name": "Garden", + "description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.", + "start_url": "./", + "scope": "./", "display": "fullscreen", - "display_override": ["fullscreen", "standalone", "minimal-ui"], - "orientation": "any", - "background_color": "#b7455e", - "theme_color": "#b7455e", + "background_color": "#10151f", + "theme_color": "#10151f", "icons": [ { - "src": "/favicon.svg", + "src": "favicon.svg", "sizes": "any", - "type": "image/svg+xml", - "purpose": "any" + "type": "image/svg+xml" }, { - "src": "/pwa-64x64.png", + "src": "pwa-64x64.png", "sizes": "64x64", "type": "image/png" }, { - "src": "/pwa-192x192.png", + "src": "pwa-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/pwa-512x512.png", + "src": "pwa-512x512.png", "sizes": "512x512", "type": "image/png" }, { - "src": "/maskable-icon-512x512.png", + "src": "maskable-icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png index 7e94d56..ea4b3c2 100644 Binary files a/public/maskable-icon-512x512.png and b/public/maskable-icon-512x512.png differ diff --git a/public/og-image.jpg b/public/og-image.jpg new file mode 100644 index 0000000..0ced8fc Binary files /dev/null and b/public/og-image.jpg differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png index 667a104..e05b2d3 100644 Binary files a/public/pwa-192x192.png and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png index 9a5361f..c171454 100644 Binary files a/public/pwa-512x512.png and b/public/pwa-512x512.png differ diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png index 7c93311..cfe8667 100644 Binary files a/public/pwa-64x64.png and b/public/pwa-64x64.png differ diff --git a/public/robots.txt b/public/robots.txt index c2a49f4..0775d05 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,4 @@ User-agent: * Allow: / + +Sitemap: https://schmelczer.dev/fleeting/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..d349665 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://schmelczer.dev/fleeting/ + + diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..b19958a --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,68 @@ +import { + init as plausibleInit, + track as plausibleTrack, + type PlausibleEventOptions, +} from '@plausible-analytics/tracker'; + +import { appConfig } from './config'; +import type { VibeId } from './vibes'; + +let isInitialized = false; + +const track = (eventName: string, options: PlausibleEventOptions = {}) => { + try { + plausibleTrack(eventName, options); + } catch (error) { + console.warn(`Could not track analytics event "${eventName}".`, error); + } +}; + +export const initAnalytics = () => { + if (isInitialized) { + return; + } + + try { + plausibleInit({ + domain: appConfig.analytics.domain, + endpoint: appConfig.analytics.endpoint, + autoCapturePageviews: appConfig.analytics.autoCapturePageviews, + logging: appConfig.analytics.logging, + }); + isInitialized = true; + } catch (error) { + console.warn('Could not initialize analytics.', error); + } +}; + +export const trackVibeChange = ({ + vibeId, + vibeName, + source, +}: { + vibeId: VibeId; + vibeName: string; + source: string; +}) => { + track('Vibe Change', { + props: { + vibeId, + vibeName, + source, + }, + }); +}; + +export const trackStart = () => { + track('Start'); +}; + +export const trackExport = ({ vibeId }: { vibeId: VibeId }) => { + track('Export', { + props: { + format: 'png', + resolution: 'internal-buffer', + vibeId, + }, + }); +}; diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts new file mode 100644 index 0000000..b5fbc1d --- /dev/null +++ b/src/audio/garden-audio-config.ts @@ -0,0 +1,127 @@ +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: GardenAudioChordQuality; +} + +export interface GardenAudioVibeSettings { + idleIntensity: number; + bpm: number; + rampUpIntensity: number; + rampUpTime: number; + noteLength: number; + notePitchOffset: number; + brightness: number; + scale?: Array; + progression?: Array; +} + +export interface GardenAudioVibeProfile extends GardenAudioVibeSettings { + rootMidi: number; + scale: Array; + progression: Array; +} + +export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = { + idleIntensity: 0.08, + bpm: 74, + rampUpIntensity: 0.85, + rampUpTime: 0.08, + noteLength: 0.42, + notePitchOffset: 0, + brightness: 1, +}; + +export const createGardenAudioConfig = () => ({ + masterVolume: DEFAULT_AUDIO_VOLUME, + fadeInSeconds: 0.45, + updateRampSeconds: 0.08, + delay: { + timeBeats: 0.5, + timeMinSeconds: 0.18, + timeMaxSeconds: 0.72, + feedback: 0.12, + wetGain: 0.044, + erasingActivity: 0.12, + activityFeedbackWeight: 0.08, + feedbackMax: 0.32, + feedbackMin: 0.04, + outputActivityWeight: 0.5, + outputBase: 0.65, + outputActivityDuck: 0.28, + timeRampSeconds: 0.12, + }, + piano: { + maxVoices: 24, + gain: 0.48, + sustainSeconds: 0.42, + sustainLevel: 0.26, + releaseSeconds: 0.34, + lowpassHz: 7000, + gainAttackSeconds: 0.006, + lowpassMaxHz: 12000, + lowpassMinHz: 1400, + sustainBase: 0.45, + sustainVelocityRange: 0.55, + }, + rhythm: { + idleIntensity: defaultGardenAudioVibeSettings.idleIntensity, + bpm: defaultGardenAudioVibeSettings.bpm, + stepsPerBeat: 4, + stepsPerBar: 16, + sparseActivity: 0.055, + }, + eraser: { + minIntervalSeconds: 0.12, + noiseGain: 0.028, + filterMinHz: 650, + filterMaxHz: 3600, + durationSeconds: 0.08, + pan: 0, + pianoActivity: 0, + }, + energy: { + decaySeconds: 0.9, + releaseSeconds: 1.15, + strokeDecaySeconds: 0.32, + }, + graph: { + pianoBusGains: { + pad: 0.86, + support: 0.94, + texture: 0.88, + gesture: 1, + brush: 0.9, + stinger: 0.92, + } satisfies Record, + pianoBusActivityDucking: { + pad: 0.42, + support: 0.18, + texture: -0.06, + gesture: 0, + brush: -0.08, + stinger: 0, + } satisfies Record, + noiseBusGain: 0.72, + }, + input: { + fullActivitySpeed: 0.86, + activityNoiseFloorSpeed: 0.025, + activityCurve: 0.74, + activitySoftCeiling: 0.96, + activityAttackSeconds: 0.055, + activityReleaseSeconds: 0.2, + minAudibleDistance: 0.0025, + manicActivityThreshold: 0.9, + manicReleaseThreshold: 0.76, + maniaSmoothingSeconds: 0.12, + }, +}); + +export type GardenAudioConfig = ReturnType; diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts new file mode 100644 index 0000000..7df3eb8 --- /dev/null +++ b/src/audio/garden-audio-energy.ts @@ -0,0 +1,66 @@ +import { approach, clamp01 } from '../utils/math'; +import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; + +export class GardenAudioEnergy { + private isGestureActive = false; + private energy = 0; + private targetEnergy = 0; + private lastEnergyUpdateAt = 0; + + public constructor(private readonly config: GardenAudioConfig) {} + + public beginGesture(now: number): void { + this.isGestureActive = true; + this.lastEnergyUpdateAt = now; + } + + public endGesture(): void { + this.isGestureActive = false; + this.targetEnergy = 0; + } + + public recordStroke(strokeEnergy: number, profile: GardenAudioVibeProfile): void { + this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy); + if (this.isGestureActive) { + this.energy = Math.max(this.energy, strokeEnergy * profile.rampUpIntensity); + } + } + + public silence(): void { + this.targetEnergy = 0; + this.energy = 0; + } + + public update(now: number, profile: GardenAudioVibeProfile): void { + if (this.lastEnergyUpdateAt <= 0) { + this.lastEnergyUpdateAt = now; + return; + } + + const elapsedSeconds = now - this.lastEnergyUpdateAt; + this.lastEnergyUpdateAt = now; + this.targetEnergy *= Math.exp( + -elapsedSeconds / this.config.energy.strokeDecaySeconds + ); + + const target = this.isGestureActive ? this.targetEnergy : 0; + let timeConstant = this.config.energy.decaySeconds; + if (!this.isGestureActive) { + timeConstant = this.config.energy.releaseSeconds; + } else if (target > this.energy) { + timeConstant = profile.rampUpTime; + } + this.energy = approach(this.energy, target, elapsedSeconds, timeConstant); + } + + public getLevel(): number { + return clamp01(this.energy); + } + + public reset(): void { + this.isGestureActive = false; + this.energy = 0; + this.targetEnergy = 0; + this.lastEnergyUpdateAt = 0; + } +} diff --git a/src/audio/garden-audio-gesture-state.ts b/src/audio/garden-audio-gesture-state.ts new file mode 100644 index 0000000..abd27f7 --- /dev/null +++ b/src/audio/garden-audio-gesture-state.ts @@ -0,0 +1,75 @@ +import { approach, clamp, clamp01, smoothstep } from '../utils/math'; +import type { GardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioStrokeMetrics } from './garden-audio-input'; + +interface GardenAudioGestureFrame { + activity: number; + maniaAmount: number; +} + +export class GardenAudioGestureState { + private activity = 0; + private maniaAmount = 0; + private isManic = false; + + public constructor(private readonly inputConfig: GardenAudioConfig['input']) {} + + public recordStroke({ + metrics, + }: { + metrics: GardenAudioStrokeMetrics; + }): GardenAudioGestureFrame { + const targetActivity = this.getTargetActivity(metrics); + const activityTimeConstant = + targetActivity > this.activity + ? this.inputConfig.activityAttackSeconds + : this.inputConfig.activityReleaseSeconds; + this.activity = approach( + this.activity, + targetActivity, + metrics.elapsedSeconds, + activityTimeConstant + ); + + if (this.activity >= this.inputConfig.manicActivityThreshold) { + this.isManic = true; + } else if (this.activity <= this.inputConfig.manicReleaseThreshold) { + this.isManic = false; + } + + const maniaTarget = this.isManic + ? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity) + : 0; + this.maniaAmount = approach( + this.maniaAmount, + maniaTarget, + metrics.elapsedSeconds, + this.inputConfig.maniaSmoothingSeconds + ); + + return { + activity: this.activity, + maniaAmount: this.maniaAmount, + }; + } + + public reset(): void { + this.activity = 0; + this.maniaAmount = 0; + this.isManic = false; + } + + private getTargetActivity(metrics: GardenAudioStrokeMetrics): number { + const speedRange = + this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed; + const speedAmount = clamp01( + (metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange + ); + const distanceAmount = clamp01( + metrics.normalizedDistance / this.inputConfig.minAudibleDistance + ); + const activity = Math.pow(speedAmount, this.inputConfig.activityCurve); + + return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling); + } +} diff --git a/src/audio/garden-audio-graph.ts b/src/audio/garden-audio-graph.ts new file mode 100644 index 0000000..a288465 --- /dev/null +++ b/src/audio/garden-audio-graph.ts @@ -0,0 +1,347 @@ +import { clamp } from '../utils/math'; +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: + | 'auto' + | 'playback' + | 'ambient' + | 'transient' + | 'transient-solo' + | 'play-and-record'; + }; +}; + +const outputHighPassFrequencyHz = 45; +const noiseBufferDurationSeconds = 1; +const graphTuning = { + closeGain: SILENT_AUDIO_GAIN, + closeRampSeconds: 0.015, + delayMaxSeconds: 2, + eventBusGain: 1, + noiseMax: 1, + noiseMin: -1, + latencyHint: 'interactive', + outputFilterType: 'highpass', + compressor: { + thresholdDb: -18, + kneeDb: 18, + ratio: 2.1, + attackSeconds: 0.018, + releaseSeconds: 0.18, + }, +} as const; +const delayFilterTuning = { + feedbackHighPassHz: 180, + feedbackLowPassHz: 5200, + returnLowPassHz: 6200, +}; + +export class GardenAudioGraph { + public context: AudioContext | null = null; + public eventBus: GainNode | null = null; + public delayInput: GainNode | null = null; + public noiseBus: GainNode | null = null; + public noiseBuffer: AudioBuffer | null = null; + + private masterGain: GainNode | null = null; + private delayNode: DelayNode | null = null; + private delayFeedback: GainNode | null = null; + private delayOutput: GainNode | null = null; + private lastPianoBusActivity = 0; + 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) {} + + public ensureContext(canCreate: boolean): AudioContext | null { + if (this.context) { + return this.context; + } + + if (!canCreate) { + return null; + } + + const AudioContextConstructor = globalThis.AudioContext; + if (!AudioContextConstructor) { + return null; + } + + // Tells iOS to treat this as media playback, so the hardware ringer/mute + // switch does not silence Web Audio output. No-op on browsers without the + // Audio Session API. + const audioSession = (navigator as NavigatorWithAudioSession).audioSession; + if (audioSession) { + this.previousAudioSessionType ??= audioSession.type; + audioSession.type = 'playback'; + } + + const context = new AudioContextConstructor({ + latencyHint: graphTuning.latencyHint, + }); + const masterGain = context.createGain(); + const highPass = context.createBiquadFilter(); + const compressor = context.createDynamicsCompressor(); + + masterGain.gain.value = 0; + highPass.type = graphTuning.outputFilterType; + highPass.frequency.value = outputHighPassFrequencyHz; + compressor.threshold.value = graphTuning.compressor.thresholdDb; + compressor.knee.value = graphTuning.compressor.kneeDb; + compressor.ratio.value = graphTuning.compressor.ratio; + compressor.attack.value = graphTuning.compressor.attackSeconds; + compressor.release.value = graphTuning.compressor.releaseSeconds; + + masterGain.connect(highPass); + highPass.connect(compressor); + compressor.connect(context.destination); + + this.context = context; + this.masterGain = masterGain; + this.noiseBuffer = this.createNoiseBuffer(context); + this.createDelay(context, masterGain); + this.createBuses(context, masterGain); + + return context; + } + + public setMasterGain(targetGain: number, timeConstantSeconds: number): void { + if (!this.context || !this.masterGain) { + return; + } + + this.masterGain.gain.setTargetAtTime( + targetGain, + this.context.currentTime, + timeConstantSeconds + ); + } + + public applyDelayProfile(bpm: number): void { + if (!this.context || !this.delayNode) { + return; + } + + this.delayNode.delayTime.setTargetAtTime( + this.getDelayTimeSecondsForBpm(bpm), + this.context.currentTime, + this.config.delay.timeRampSeconds + ); + } + + public updateDelay(activity: number, bpm: number): void { + if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { + return; + } + + const now = this.context.currentTime; + const normalizedActivity = clamp(activity, 0, 1); + this.delayNode.delayTime.setTargetAtTime( + this.getDelayTimeSecondsForBpm(bpm), + now, + this.config.delay.timeRampSeconds + ); + this.delayFeedback.gain.setTargetAtTime( + clamp( + this.config.delay.feedback + + normalizedActivity * this.config.delay.activityFeedbackWeight, + this.config.delay.feedbackMin, + this.config.delay.feedbackMax + ), + now, + this.config.updateRampSeconds + ); + this.delayOutput.gain.setTargetAtTime( + this.config.delay.wetGain * + (this.config.delay.outputBase + + normalizedActivity * this.config.delay.outputActivityWeight) * + (1 - normalizedActivity * this.config.delay.outputActivityDuck), + now, + this.config.updateRampSeconds + ); + this.updatePianoBusGains(normalizedActivity, now); + } + + public getPianoBus(role: PianoNoteRole | undefined): GainNode | null { + return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus; + } + + public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void { + if (!this.context) { + this.pianoBusGainScale = clamp(targetScale, 0, 1); + return; + } + + const now = this.context.currentTime; + + this.pianoBusGainScale = clamp(targetScale, 0, 1); + this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds; + this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4; + this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds); + } + + public async close(): Promise { + const context = this.context; + if (!context) { + return; + } + + if (this.masterGain && context.state !== 'closed') { + this.masterGain.gain.setTargetAtTime( + graphTuning.closeGain, + context.currentTime, + graphTuning.closeRampSeconds + ); + } + + this.clearNodes(); + + 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 { + const delayInput = context.createGain(); + const delayNode = context.createDelay(graphTuning.delayMaxSeconds); + const delayFeedback = context.createGain(); + const delayOutput = context.createGain(); + const feedbackHighPass = context.createBiquadFilter(); + const feedbackLowPass = context.createBiquadFilter(); + const returnLowPass = context.createBiquadFilter(); + + 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'; + feedbackHighPass.frequency.value = delayFilterTuning.feedbackHighPassHz; + feedbackLowPass.type = 'lowpass'; + feedbackLowPass.frequency.value = delayFilterTuning.feedbackLowPassHz; + returnLowPass.type = 'lowpass'; + returnLowPass.frequency.value = delayFilterTuning.returnLowPassHz; + + delayInput.connect(delayNode); + delayNode.connect(feedbackHighPass); + feedbackHighPass.connect(feedbackLowPass); + feedbackLowPass.connect(delayFeedback); + delayFeedback.connect(delayNode); + delayNode.connect(returnLowPass); + returnLowPass.connect(delayOutput); + delayOutput.connect(masterGain); + + this.delayInput = delayInput; + this.delayNode = delayNode; + this.delayFeedback = delayFeedback; + this.delayOutput = delayOutput; + } + + private createBuses(context: AudioContext, masterGain: GainNode): void { + const eventBus = context.createGain(); + eventBus.gain.value = graphTuning.eventBusGain; + eventBus.connect(masterGain); + this.eventBus = eventBus; + this.pianoBuses.clear(); + + (Object.keys(this.config.graph.pianoBusGains) as Array).forEach( + (role) => { + const bus = context.createGain(); + bus.gain.value = this.config.graph.pianoBusGains[role]; + bus.connect(eventBus); + this.pianoBuses.set(role, bus); + } + ); + + this.noiseBus = context.createGain(); + this.noiseBus.gain.value = this.config.graph.noiseBusGain; + this.noiseBus.connect(eventBus); + } + + private updatePianoBusGains( + activity: number, + now: number, + timeConstantSeconds?: number + ): void { + const effectiveTimeConstantSeconds = + timeConstantSeconds ?? + (now < this.pianoBusGainScaleAutomationUntil + ? this.pianoBusGainScaleTimeConstantSeconds + : this.config.updateRampSeconds); + + this.lastPianoBusActivity = activity; + this.pianoBuses.forEach((bus, role) => { + const baseGain = this.config.graph.pianoBusGains[role]; + const ducking = this.config.graph.pianoBusActivityDucking[role]; + bus.gain.setTargetAtTime( + Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale), + now, + effectiveTimeConstantSeconds + ); + }); + } + + 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, + Math.floor(context.sampleRate * noiseBufferDurationSeconds), + context.sampleRate + ); + const data = buffer.getChannelData(0); + + for (let index = 0; index < data.length; index++) { + data[index] = + graphTuning.noiseMin + + Math.random() * (graphTuning.noiseMax - graphTuning.noiseMin); + } + + return buffer; + } + + private clearNodes(): void { + this.context = null; + this.eventBus = null; + this.delayInput = null; + this.noiseBus = null; + this.noiseBuffer = null; + this.masterGain = null; + this.delayNode = null; + this.delayFeedback = null; + this.delayOutput = null; + this.lastPianoBusActivity = 0; + this.pianoBusGainScale = 1; + this.pianoBusGainScaleAutomationUntil = 0; + this.pianoBusGainScaleTimeConstantSeconds = 0; + this.pianoBuses.clear(); + } +} diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts new file mode 100644 index 0000000..4c67507 --- /dev/null +++ b/src/audio/garden-audio-input.ts @@ -0,0 +1,27 @@ +import type { GardenAudioStroke } from './garden-audio-types'; + +const minElapsedSeconds = 0.001; + +export interface GardenAudioStrokeMetrics { + elapsedSeconds: number; + normalizedDistance: number; + normalizedSpeed: number; +} + +export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMetrics => { + const dx = stroke.to[0] - stroke.from[0]; + const dy = stroke.to[1] - stroke.from[1]; + const distancePixels = Math.hypot(dx, dy); + const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0); + const normalizationPixels = Math.max( + 1, + Math.min(stroke.canvasSize[0], stroke.canvasSize[1]) + ); + const normalizedDistance = distancePixels / normalizationPixels; + + return { + elapsedSeconds, + normalizedDistance, + normalizedSpeed: normalizedDistance / elapsedSeconds, + }; +}; diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts new file mode 100644 index 0000000..a0e5e7b --- /dev/null +++ b/src/audio/garden-audio-music.ts @@ -0,0 +1,33 @@ +import type { VibePreset } from '../vibes'; +import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config'; + +export const PITCH_SEMITONES_PER_OCTAVE = 12; + +const DEFAULT_PROGRESSION: ReadonlyArray = [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 5, quality: 'major' }, + { rootOffset: 7, quality: 'major' }, +]; + +const DEFAULT_ROOT_MIDI = 57; +const DEFAULT_SCALE: ReadonlyArray = [0, 2, 4, 7, 9]; + +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 => { + 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 new file mode 100644 index 0000000..fecbcf8 --- /dev/null +++ b/src/audio/garden-audio-types.ts @@ -0,0 +1,48 @@ +import type { VibePreset } from '../vibes'; + +export interface GardenAudioSnapshot { + vibe: VibePreset; + isErasing: boolean; +} + +export interface GardenAudioStroke { + vibe: VibePreset; + from: ArrayLike; + to: ArrayLike; + canvasSize: ArrayLike; + isErasing: boolean; + elapsedSeconds: number; +} + +export interface LoadedPianoSample { + midi: number; + buffer: AudioBuffer; +} + +export interface PianoNote { + midi: number; + velocity: number; + startTime: number; + durationSeconds: number; + pan: number; + role?: PianoNoteRole; + delaySend?: number; + lowpassHz?: number; + sustainSeconds?: number; +} + +export type PianoNoteRole = + | 'pad' + | 'support' + | 'texture' + | 'gesture' + | 'brush' + | 'stinger'; + +export interface NoiseBurst { + startTime: number; + durationSeconds: number; + gain: number; + filterHz: number; + pan: number; +} diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts new file mode 100644 index 0000000..3aa4c88 --- /dev/null +++ b/src/audio/garden-audio.ts @@ -0,0 +1,448 @@ +import { ErrorHandler, Severity } from '../utils/error-handler'; +import { clamp01 } from '../utils/math'; +import type { VibeId, VibePreset } from '../vibes'; +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'; +import { getStrokeMetrics } from './garden-audio-input'; +import { getVibeProfile } from './garden-audio-music'; +import type { GardenAudioSnapshot, GardenAudioStroke } from './garden-audio-types'; +import { GenerativePianoEngine } from './generative-piano'; +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 muteRampSeconds = 0.02; +const brushUpPianoBusFadeSeconds = 2.4; +const brushUpPianoBusFadeSettleSeconds = 3.2; +const vibeChangeStingerMinIntervalSeconds = 0.45; + +export class GardenAudio { + private readonly graph: GardenAudioGraph; + private readonly piano: PianoSampler; + private readonly noise: NoiseBurstPlayer; + private readonly energy: GardenAudioEnergy; + private readonly gestureState: GardenAudioGestureState; + private readonly pianoEngine: GenerativePianoEngine; + + private currentVibeId: VibeId | null = null; + private currentVibe: VibePreset | null = null; + private lifecycle: AudioLifecycle = 'idle'; + private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' }; + private isMuted = false; + private isGestureActive = false; + private masterVolume: number; + 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); + this.graph = new GardenAudioGraph(config); + this.piano = new PianoSampler(config, this.graph); + this.noise = new NoiseBurstPlayer(this.graph); + this.energy = new GardenAudioEnergy(config); + this.gestureState = new GardenAudioGestureState(config.input); + this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); + } + + public start(vibe: VibePreset, options: { userGesture?: boolean } = {}): void { + const isUserGesture = options.userGesture === true; + + if (this.lifecycle === 'destroyed') { + return; + } + + if ( + this.lifecycle === 'started' && + this.currentVibeId === vibe.id && + this.graph.context?.state === 'running' && + this.hasLoadedPiano + ) { + return; + } + + const context = this.graph.ensureContext(isUserGesture); + if (!context) { + return; + } + + const startupRampSeconds = isUserGesture + ? muteRampSeconds + : this.config.fadeInSeconds; + const needsResume = context.state !== 'running' && context.state !== 'closed'; + const startRequestId = ++this.startRequestId; + + if (needsResume) { + if (!isUserGesture) { + return; + } + void context + .resume() + .then(() => { + if (this.graph.context === context && this.lifecycle !== 'destroyed') { + this.completeStart(vibe, { context, startupRampSeconds, startRequestId }); + } + }) + .catch((error) => { + ErrorHandler.addException(error, { + fallbackMessage: 'Could not resume audio playback.', + severity: Severity.WARNING, + }); + }); + return; + } + + this.completeStart(vibe, { context, startupRampSeconds, startRequestId }); + } + + private completeStart( + vibe: VibePreset, + { + context, + startRequestId, + startupRampSeconds, + }: { + context: AudioContext; + startRequestId: number; + startupRampSeconds: number; + } + ): void { + if (this.graph.context !== context || this.lifecycle === 'destroyed') { + return; + } + + if (this.isMuted) { + this.activateMutedStart(vibe, context); + this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds); + return; + } + + void this.piano + .load(context) + .then(() => { + if (!this.canCompleteStart(context, startRequestId)) { + return; + } + + this.activateStart(vibe, context, startupRampSeconds, true); + }) + .catch((error) => { + if (this.canCompleteStart(context, startRequestId)) { + this.activateStart(vibe, context, startupRampSeconds, false); + } + ErrorHandler.addException(error, { + fallbackMessage: 'Could not load piano samples.', + severity: Severity.WARNING, + }); + }); + } + + private canCompleteStart(context: AudioContext, startRequestId: number): boolean { + return ( + this.graph.context === context && + this.lifecycle !== 'destroyed' && + !this.isMuted && + this.startRequestId === startRequestId + ); + } + + private activateStart( + vibe: VibePreset, + context: AudioContext, + startupRampSeconds: number, + cuePiano: boolean + ): void { + this.lifecycle = 'started'; + this.currentVibeId = vibe.id; + this.currentVibe = vibe; + const profile = getVibeProfile(vibe); + this.graph.applyDelayProfile(profile.bpm); + this.graph.setMasterGain(this.masterVolume, startupRampSeconds); + + if (cuePiano) { + 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(); + } + } + + public changeVibe(vibe: VibePreset, options: { userGesture?: boolean } = {}): void { + const previousVibeId = this.currentVibeId; + this.start(vibe, options); + const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id; + + if (didChangeVibe) { + this.piano.stopAll(); + this.hasLoadedPiano = false; + } + + const context = this.graph.context; + if ( + context && + (context.state === 'running' || options.userGesture === true) && + !this.isMuted && + this.lifecycle !== 'destroyed' && + didChangeVibe + ) { + this.playVibeChangeStinger(vibe); + } + } + + public setMuted(isMuted: boolean): void { + if (this.isMuted === isMuted) { + return; + } + + this.isMuted = isMuted; + this.graph.setMasterGain( + 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 { + this.masterVolume = clamp01(masterVolume); + if (!this.isMuted) { + this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds); + } + } + + public beginGesture(): void { + const context = this.graph.context; + if (!context) { + return; + } + + this.isGestureActive = true; + this.pianoReleasePhase = { kind: 'idle' }; + this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds); + this.gestureState.reset(); + this.energy.beginGesture(context.currentTime); + this.pianoEngine.beginGesture(); + } + + public endGesture(): void { + this.gestureState.reset(); + this.isGestureActive = false; + this.pianoReleasePhase = { kind: 'awaiting-fade' }; + this.energy.endGesture(); + this.pianoEngine.endGesture(); + } + + public update(snapshot: GardenAudioSnapshot): void { + const context = this.graph.context; + if (this.lifecycle !== 'started' || !context || this.isMuted) { + return; + } + + this.applyVibe(snapshot.vibe); + const profile = getVibeProfile(snapshot.vibe); + this.energy.update(context.currentTime, profile); + + if (snapshot.isErasing) { + this.energy.silence(); + } + + if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') { + this.updatePianoRelease(snapshot.vibe, context.currentTime); + this.updateDelay(snapshot, profile); + return; + } + + this.pianoEngine.renderLookahead({ + vibe: snapshot.vibe, + now: context.currentTime, + activity: snapshot.isErasing + ? this.config.eraser.pianoActivity + : this.energy.getLevel(), + }); + this.updateDelay(snapshot, profile); + } + + public stroke(stroke: GardenAudioStroke): void { + if (this.lifecycle !== 'started' || this.isMuted) { + return; + } + + const context = this.graph.context; + if (!context) { + return; + } + if (!this.isGestureActive) { + return; + } + + const metrics = getStrokeMetrics(stroke); + const now = context.currentTime; + + const frame = this.gestureState.recordStroke({ metrics }); + const strokeEnergy = frame.activity; + + if (stroke.isErasing) { + this.playEraser(strokeEnergy, now); + return; + } + + const profile = getVibeProfile(stroke.vibe); + this.energy.recordStroke(strokeEnergy, profile); + this.pianoEngine.recordStroke({ + vibe: stroke.vibe, + now, + activity: strokeEnergy, + maniaAmount: frame.maniaAmount, + }); + } + + public async destroy(): Promise { + this.lifecycle = 'destroyed'; + 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.pianoReleasePhase = { kind: 'idle' }; + this.lastEraserAt = Number.NEGATIVE_INFINITY; + this.lastVibeStingerAt = Number.NEGATIVE_INFINITY; + } + + private playVibeChangeStinger(vibe: VibePreset): void { + const context = this.graph.context; + if (!context) { + return; + } + + const now = context.currentTime; + if (now - this.lastVibeStingerAt < vibeChangeStingerMinIntervalSeconds) { + return; + } + + this.lastVibeStingerAt = now; + this.pianoEngine.playVibeChangeStinger(vibe, now); + } + + private updatePianoRelease(vibe: VibePreset, now: number): void { + if (this.pianoReleasePhase.kind === 'awaiting-fade') { + const fadeAt = this.pianoEngine.release(vibe, now); + if (now < fadeAt) { + this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt }; + return; + } + + this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds); + this.pianoReleasePhase = { + kind: 'settling', + stopAt: now + brushUpPianoBusFadeSettleSeconds, + }; + return; + } + + 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.hasLoadedPiano = false; + this.pianoReleasePhase = { kind: 'idle' }; + } + } + + private playEraser(activity: number, now: number): void { + if (!this.graph.context) { + return; + } + + const distanceActivity = clamp01(activity); + if (distanceActivity <= 0) { + return; + } + + const filterHz = + this.config.eraser.filterMinHz + + (this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) * + distanceActivity; + + if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) { + this.lastEraserAt = now; + this.noise.play({ + startTime: now, + durationSeconds: this.config.eraser.durationSeconds, + gain: this.config.eraser.noiseGain * distanceActivity, + filterHz, + pan: this.config.eraser.pan, + }); + } + } + + private updateDelay( + snapshot: GardenAudioSnapshot, + profile: GardenAudioVibeProfile + ): void { + const context = this.graph.context; + if (!context) { + return; + } + + const activity = snapshot.isErasing + ? this.config.delay.erasingActivity + : this.energy.getLevel(); + this.graph.updateDelay(activity, profile.bpm); + } + + private applyVibe(vibe: VibePreset): void { + if (!this.graph.context || this.currentVibeId === vibe.id) { + return; + } + + this.currentVibeId = vibe.id; + this.currentVibe = vibe; + const profile = getVibeProfile(vibe); + 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 new file mode 100644 index 0000000..e76d6f4 --- /dev/null +++ b/src/audio/generative-piano-tuning.ts @@ -0,0 +1,443 @@ +export interface GardenAudioRegister { + midiMin: number; + midiMax: number; + preferredMidi: number; + pan: number; +} + +export interface GardenAudioStylePool extends GardenAudioRegister { + scaleDegrees: Array; +} + +interface GardenAudioStyleVoice { + scaleDegreeOffset: number; + velocityMultiplier: number; + panOffset: number; +} + +interface GenerativePianoTuning { + stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool]; + padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister]; + vibeChangeStinger: { + velocities: [number, number, number]; + pans: [number, number, number]; + delaySends: [number, number, number]; + lowpassExpression: number; + noteDurationSeconds: number; + spacingSeconds: number; + }; + releaseResolution: { + durationSeconds: number; + fadeAfterSeconds: number; + velocities: [number, number, number]; + delaySend: number; + lowpassExpression: number; + strumSeconds: number; + }; + highActivityExtra: { + barOffset: number; + expressionMultiplier: number; + }; + padChord: { + velocities: [number, number, number]; + expressionVelocityWeight: number; + delaySend: number; + lowpassExpressionWeight: number; + }; + supportNote: { + velocityBase: number; + velocityExpressionWeight: number; + durationBaseSeconds: number; + durationExpressionSeconds: number; + delaySendBase: number; + delaySendExpressionWeight: number; + lowpassExpressionWeight: number; + expressionThreshold: number; + offsetsByStyle: [Array, Array, Array]; + }; + textureNote: { + velocityBase: number; + velocityExpressionWeight: number; + durationBaseSeconds: number; + durationExpressionSeconds: number; + delaySendBase: number; + delaySendExpressionWeight: number; + idleExpressionThreshold: number; + mediumExpressionThreshold: number; + intenseSpacing: number; + idlePhase: number; + }; + gestureAccent: { + rotationStrengthMultiplier: number; + quantizeStepLookahead: number; + velocityBase: number; + velocityStrengthWeight: number; + durationBaseSeconds: number; + durationStrengthSeconds: number; + delaySend: number; + }; + touchNote: { + velocityBase: number; + velocityStrengthWeight: number; + durationBaseSeconds: number; + durationStrengthSeconds: number; + delaySend: number; + lowpassBaseExpression: number; + lowpassStrengthWeight: number; + }; + brushPhrase: { + initialMotifOffset: number; + energyDecaySeconds: number; + maniaDecaySeconds: number; + layerIntensityBase: number; + layerIntensityManiaWeight: number; + frameActivityWeight: number; + frameManiaWeight: number; + }; + brushStream: { + inferredManiaThreshold: number; + inferredManiaRange: number; + registerManiaShift: number; + chordToneEverySteps: number; + durationBaseSeconds: number; + durationIntensitySeconds: number; + durationManiaSeconds: number; + durationMinSeconds: number; + durationMaxSeconds: number; + delaySendBase: number; + delaySendIntensityWeight: number; + delaySendManiaWeight: number; + delaySendMin: number; + delaySendMax: number; + velocityBase: number; + velocityIntensityWeight: number; + lowpassBaseExpression: number; + lowpassIntensityWeight: number; + lowpassManiaWeight: number; + intenseThreshold: number; + activeThreshold: number; + }; + brushStreamEcho: { + maniaThreshold: number; + stepModulo: number; + stepRemainder: number; + intensityThreshold: number; + octaveSemitones: number; + maxMidi: number; + velocityBase: number; + velocityIntensityWeight: number; + durationMinSeconds: number; + durationScale: number; + panScale: number; + delaySendMin: number; + delaySendScale: number; + lowpassBaseExpression: number; + lowpassManiaWeight: number; + }; + brushMotif: { + highThreshold: number; + mediumThreshold: number; + highOffset: number; + mediumOffset: number; + lowOffset: number; + }; + registerBias: { + maniaShiftSemitones: number; + midiMin: number; + midiMaxForMin: number; + minimumSpan: number; + midiMax: number; + }; + candidateOctaveSearch: { + min: number; + max: number; + }; + stereoWidth: { + idle: number; + active: number; + intense: number; + intenseThreshold: number; + }; + stylePanOffsetScale: number; + lowpass: { + midiBase: number; + midiRange: number; + midiLiftHz: number; + expressionBase: number; + expressionWeight: number; + }; + styleRotationBars: number; + chordBars: number; + supportBarSpacing: number; + supportBarOffset: number; + idleTextureBarSpacing: number; + mediumTextureBarSpacing: number; + textureBeat: number; + highActivityExtraBeat: number; + highActivityExtraThreshold: number; + noteScorePreferenceWeight: number; + noteScoreRegisterWeight: number; + noteScoreChordToneWeight: number; + noteScoreRepeatPenalty: number; + gestureAccentMinIntervalSeconds: number; + strokeAccentMinSteps: number; + strokeAccentThreshold: number; + maxBrushPhraseLayers: number; + maxBrushStreamNotesPerBar: number; + brushLayerBaseSeconds: number; + brushLayerEnergySeconds: number; + brushLayerMinIntensity: number; + brushStreamIdleIntervalBeats: number; + brushStreamActiveIntervalBeats: number; + brushStreamIntenseIntervalBeats: number; + brushMotifMaxSteps: number; + brushMotifCanonDelaySeconds: number; + padDurationBarScale: number; +} + +export const generativePianoTuning: GenerativePianoTuning = { + stylePools: [ + { + midiMin: 48, + midiMax: 67, + preferredMidi: 55, + pan: -0.18, + scaleDegrees: [0, 1, 2, 4], + }, + { + midiMin: 55, + midiMax: 74, + preferredMidi: 63, + pan: 0, + scaleDegrees: [1, 2, 3, 5], + }, + { + midiMin: 62, + midiMax: 78, + preferredMidi: 70, + pan: 0.18, + scaleDegrees: [2, 3, 4, 6], + }, + ], + padRegisters: [ + { + midiMin: 40, + midiMax: 55, + preferredMidi: 48, + pan: -0.12, + }, + { + midiMin: 48, + midiMax: 64, + preferredMidi: 55, + pan: 0.08, + }, + { + midiMin: 58, + midiMax: 76, + preferredMidi: 67, + pan: 0.2, + }, + ], + vibeChangeStinger: { + velocities: [0.1, 0.085, 0.07], + pans: [-0.16, 0, 0.16], + delaySends: [0.012, 0.014, 0.016], + lowpassExpression: 0.35, + noteDurationSeconds: 1.1, + spacingSeconds: 0.08, + }, + releaseResolution: { + durationSeconds: 3.4, + fadeAfterSeconds: 2.4, + velocities: [0.064, 0.05, 0.038], + delaySend: 0.018, + lowpassExpression: 0.34, + strumSeconds: 0.055, + }, + highActivityExtra: { + barOffset: 1, + expressionMultiplier: 0.9, + }, + padChord: { + velocities: [0.046, 0.036, 0.029], + expressionVelocityWeight: 0.018, + delaySend: 0.008, + lowpassExpressionWeight: 0.24, + }, + supportNote: { + velocityBase: 0.105, + velocityExpressionWeight: 0.07, + durationBaseSeconds: 1.35, + durationExpressionSeconds: 0.4, + delaySendBase: 0.016, + delaySendExpressionWeight: 0.006, + lowpassExpressionWeight: 0.7, + expressionThreshold: 0.55, + offsetsByStyle: [ + [0, 2, 12], + [1, 2, 0, 12], + [2, 12, 3, 13], + ], + }, + textureNote: { + velocityBase: 0.09, + velocityExpressionWeight: 0.08, + durationBaseSeconds: 0.62, + durationExpressionSeconds: 0.24, + delaySendBase: 0.016, + delaySendExpressionWeight: 0.006, + idleExpressionThreshold: 0.35, + mediumExpressionThreshold: 0.7, + intenseSpacing: 1, + idlePhase: 1, + }, + gestureAccent: { + rotationStrengthMultiplier: 3, + quantizeStepLookahead: 1, + velocityBase: 0.12, + velocityStrengthWeight: 0.09, + durationBaseSeconds: 0.48, + durationStrengthSeconds: 0.22, + delaySend: 0.012, + }, + touchNote: { + velocityBase: 0.14, + velocityStrengthWeight: 0.11, + durationBaseSeconds: 0.55, + durationStrengthSeconds: 0.18, + delaySend: 0.006, + lowpassBaseExpression: 0.55, + lowpassStrengthWeight: 0.35, + }, + brushPhrase: { + initialMotifOffset: -1, + energyDecaySeconds: 0.72, + maniaDecaySeconds: 0.54, + layerIntensityBase: 0.8, + layerIntensityManiaWeight: 0.42, + frameActivityWeight: 0.42, + frameManiaWeight: 0.18, + }, + brushStream: { + inferredManiaThreshold: 0.82, + inferredManiaRange: 0.18, + registerManiaShift: 0.3, + chordToneEverySteps: 4, + durationBaseSeconds: 0.48, + durationIntensitySeconds: 0.08, + durationManiaSeconds: 0.34, + durationMinSeconds: 0.14, + durationMaxSeconds: 0.62, + delaySendBase: 0.012, + delaySendIntensityWeight: 0.011, + delaySendManiaWeight: 0.006, + delaySendMin: 0.006, + delaySendMax: 0.032, + velocityBase: 0.1, + velocityIntensityWeight: 0.1, + lowpassBaseExpression: 0.39, + lowpassIntensityWeight: 0.48, + lowpassManiaWeight: 0.18, + intenseThreshold: 0.68, + activeThreshold: 0.34, + }, + brushStreamEcho: { + maniaThreshold: 0.92, + stepModulo: 3, + stepRemainder: 1, + intensityThreshold: 0.95, + octaveSemitones: 12, + maxMidi: 84, + velocityBase: 0.035, + velocityIntensityWeight: 0.04, + durationMinSeconds: 0.11, + durationScale: 0.68, + panScale: -0.75, + delaySendMin: 0.006, + delaySendScale: 0.72, + lowpassBaseExpression: 0.62, + lowpassManiaWeight: 0.24, + }, + brushMotif: { + highThreshold: 0.82, + mediumThreshold: 0.55, + highOffset: 1, + mediumOffset: 0, + lowOffset: -1, + }, + registerBias: { + maniaShiftSemitones: 2, + midiMin: 36, + midiMaxForMin: 86, + minimumSpan: 4, + midiMax: 91, + }, + candidateOctaveSearch: { + min: -3, + max: 3, + }, + stereoWidth: { + idle: 0.46, + active: 0.9, + intense: 1.16, + intenseThreshold: 0.72, + }, + stylePanOffsetScale: 0.35, + lowpass: { + midiBase: 48, + midiRange: 33, + midiLiftHz: 500, + expressionBase: 0.58, + expressionWeight: 0.32, + }, + styleRotationBars: 2, + chordBars: 4, + supportBarSpacing: 2, + supportBarOffset: 1, + idleTextureBarSpacing: 2, + mediumTextureBarSpacing: 1, + textureBeat: 2, + highActivityExtraBeat: 3, + highActivityExtraThreshold: 0.45, + noteScorePreferenceWeight: 1.8, + noteScoreRegisterWeight: 0.28, + noteScoreChordToneWeight: 0.75, + noteScoreRepeatPenalty: 3.2, + gestureAccentMinIntervalSeconds: 2.5, + strokeAccentMinSteps: 12, + strokeAccentThreshold: 0.58, + maxBrushPhraseLayers: 3, + maxBrushStreamNotesPerBar: 7, + brushLayerBaseSeconds: 5.5, + brushLayerEnergySeconds: 2.5, + brushLayerMinIntensity: 0.12, + brushStreamIdleIntervalBeats: 2, + brushStreamActiveIntervalBeats: 1, + brushStreamIntenseIntervalBeats: 0.75, + brushMotifMaxSteps: 8, + brushMotifCanonDelaySeconds: 0.055, + padDurationBarScale: 0.82, +}; + +export const styleVoices: [ + GardenAudioStyleVoice, + GardenAudioStyleVoice, + GardenAudioStyleVoice, +] = [ + { + scaleDegreeOffset: 0, + velocityMultiplier: 0.92, + panOffset: -0.14, + }, + { + scaleDegreeOffset: 1, + velocityMultiplier: 1, + panOffset: 0, + }, + { + scaleDegreeOffset: 2, + velocityMultiplier: 0.86, + panOffset: 0.14, + }, +]; diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts new file mode 100644 index 0000000..fbe1201 --- /dev/null +++ b/src/audio/generative-piano.ts @@ -0,0 +1,1349 @@ +import { clamp, clamp01 } from '../utils/math'; +import type { VibePreset } from '../vibes'; +import type { + GardenAudioChord, + GardenAudioConfig, + GardenAudioVibeProfile, +} from './garden-audio-config'; +import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music'; +import type { PianoNote } from './garden-audio-types'; +import { + generativePianoTuning, + styleVoices, + type GardenAudioRegister, + type GardenAudioStylePool, +} from './generative-piano-tuning'; +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: 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 => { + const voicing = chordVoicings[chord.quality]; + return openVoicing ? voicing.open : voicing.closed; +}; + +const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => { + const scaleIndex = + ((degree % profile.scale.length) + profile.scale.length) % profile.scale.length; + const octave = Math.floor(degree / profile.scale.length); + return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE; +}; + +type GardenAudioStyleIndex = 0 | 1 | 2; + +interface PitchCandidate { + midi: number; + preference: number; + chordToneDistance: number; +} + +interface PitchSource { + baseMidi: number; + offsets: ReadonlyArray; + chordOffsets?: ReadonlyArray; +} + +interface BrushPhraseLayer { + vibe: VibePreset; + startedAt: number; + lastUpdatedAt: number; + expiresAt: number; + styleIndex: GardenAudioStyleIndex; + energy: number; + motifOffsets: Array; + maniaAmount: number; +} + +export class GenerativePianoEngine { + private nextBeatStep: number | null = null; + private timelineStartedAt: number | null = null; + private activeProfile: GardenAudioVibeProfile | null = null; + private isWaitingForGestureAccent = false; + private lastGestureAccentAt = Number.NEGATIVE_INFINITY; + private lastStrokeAccentStep = Number.NEGATIVE_INFINITY; + private readonly lastMidiByStyle: [number | null, number | null, number | null] = [ + null, + null, + null, + ]; + private readonly lastPadMidiByVoice: [number | null, number | null, number | null] = [ + null, + null, + null, + ]; + private brushPhraseLayers: Array = []; + private nextBrushStreamStep: number | null = null; + private brushStreamNoteIndex = 0; + private lastBrushStreamMidi: number | null = null; + private readonly brushStreamNoteCountsByBar = new Map(); + + public constructor( + private readonly config: GardenAudioConfig, + private readonly playNote: (note: PianoNote) => void + ) {} + + public prime(now: number, profile: GardenAudioVibeProfile): void { + this.activeProfile = profile; + this.timelineStartedAt ??= now; + this.nextBeatStep ??= 0; + this.nextBrushStreamStep ??= 0; + } + + public cue(now: number, profile?: GardenAudioVibeProfile): void { + if (profile) { + this.activeProfile = profile; + } + this.nextBeatStep = 0; + this.timelineStartedAt = now; + this.nextBrushStreamStep = 0; + this.brushStreamNoteIndex = 0; + this.lastBrushStreamMidi = null; + this.brushStreamNoteCountsByBar.clear(); + } + + public beginGesture(): void { + this.isWaitingForGestureAccent = true; + } + + public endGesture(): void { + this.isWaitingForGestureAccent = false; + } + + public release(vibe: VibePreset, now: number): number { + const profile = getVibeProfile(vibe); + this.prime(now, profile); + this.isWaitingForGestureAccent = false; + const releaseStep = this.getReleaseResolutionStep(now); + const releaseStart = Math.max(now, this.getTimeForStep(releaseStep)); + + this.playReleaseResolution(profile, releaseStep, releaseStart); + this.nextBeatStep = null; + this.nextBrushStreamStep = null; + this.brushPhraseLayers = []; + this.brushStreamNoteCountsByBar.clear(); + + return releaseStart + generativePianoTuning.releaseResolution.fadeAfterSeconds; + } + + private recordTouchDown({ + vibe, + now, + strength, + maniaAmount = 0, + }: { + vibe: VibePreset; + now: number; + strength: number; + maniaAmount?: number; + }): void { + const normalizedStrength = clamp01(strength); + const normalizedManiaAmount = clamp01(maniaAmount); + const styleIndex = this.getStyleIndex(now); + + this.isWaitingForGestureAccent = false; + this.lastGestureAccentAt = now; + this.lastStrokeAccentStep = this.getStepIndexAtTime(now); + this.startBrushPhraseLayer({ + vibe, + now, + strength: normalizedStrength, + styleIndex, + maniaAmount: normalizedManiaAmount, + }); + this.playTouchNote({ + vibe, + now, + styleIndex, + strength: normalizedStrength, + }); + } + + public recordStroke({ + vibe, + now, + activity, + maniaAmount = 0, + }: { + vibe: VibePreset; + now: number; + activity: number; + maniaAmount?: number; + }): void { + const profile = getVibeProfile(vibe); + this.prime(now, profile); + const strength = clamp01(activity); + const normalizedManiaAmount = clamp01(maniaAmount); + const styleIndex = this.getStyleIndex(now); + const accentStep = this.getNextStepIndexAt( + now, + generativePianoTuning.gestureAccent.quantizeStepLookahead + ); + + if ( + this.isWaitingForGestureAccent && + now - this.lastGestureAccentAt >= + generativePianoTuning.gestureAccentMinIntervalSeconds + ) { + this.recordTouchDown({ + vibe, + now, + strength, + maniaAmount: normalizedManiaAmount, + }); + return; + } + + this.isWaitingForGestureAccent = false; + this.updateBrushPhraseLayer({ + now, + strength, + styleIndex, + maniaAmount: normalizedManiaAmount, + }); + if ( + strength >= generativePianoTuning.strokeAccentThreshold && + accentStep - this.lastStrokeAccentStep >= generativePianoTuning.strokeAccentMinSteps + ) { + this.lastStrokeAccentStep = accentStep; + this.playGestureAccent(vibe, accentStep, styleIndex, strength); + } + } + + public renderLookahead({ + vibe, + now, + activity, + lookaheadSeconds = GENERATIVE_LOOKAHEAD_SECONDS, + }: { + vibe: VibePreset; + now: number; + activity: number; + lookaheadSeconds?: number; + }): void { + const profile = getVibeProfile(vibe); + this.prime(now, profile); + this.skipLateBeats(now); + + if (this.nextBeatStep === null) { + return; + } + + const lookaheadEnd = now + lookaheadSeconds; + const expression = this.getExpression(activity); + while (this.getTimeForStep(this.nextBeatStep) <= lookaheadEnd) { + const beatIndex = this.getBeatIndexForStep(this.nextBeatStep); + this.renderBeat({ + profile, + beatIndex, + startTime: this.getTimeForStep(this.nextBeatStep), + expression, + }); + this.nextBeatStep += this.config.rhythm.stepsPerBeat; + } + this.renderBrushPhraseLayers({ + vibe, + now, + lookaheadEnd, + activity: expression, + }); + } + + public playVibeChangeStinger(vibe: VibePreset, now: number): void { + const profile = getVibeProfile(vibe); + const chord = this.getChord(profile, 0); + const intervals = getChordIntervals(chord, true); + const rootMidi = profile.rootMidi + chord.rootOffset; + const stinger = generativePianoTuning.vibeChangeStinger; + const offsetsByVoice: ReadonlyArray> = [ + [0], + [intervals[1], intervals[2]], + [intervals[3], intervals[2]], + ]; + + offsetsByVoice.forEach((offsets, index) => { + const midi = this.chooseMidi( + { baseMidi: rootMidi, offsets }, + generativePianoTuning.padRegisters[index] + ); + this.playProfileNote(profile, { + midi, + velocity: stinger.velocities[index], + pan: stinger.pans[index], + delaySend: stinger.delaySends[index], + durationSeconds: stinger.noteDurationSeconds, + role: 'stinger', + lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression), + startTime: now + index * stinger.spacingSeconds, + }); + }); + } + + public reset(): void { + this.nextBeatStep = null; + this.timelineStartedAt = null; + this.activeProfile = null; + this.isWaitingForGestureAccent = false; + this.lastGestureAccentAt = Number.NEGATIVE_INFINITY; + this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY; + this.lastMidiByStyle.fill(null); + this.lastPadMidiByVoice.fill(null); + this.brushPhraseLayers = []; + this.nextBrushStreamStep = null; + this.brushStreamNoteIndex = 0; + this.lastBrushStreamMidi = null; + this.brushStreamNoteCountsByBar.clear(); + } + + private playProfileNote(profile: GardenAudioVibeProfile, note: PianoNote): void { + this.playNote({ + ...note, + sustainSeconds: profile.noteLength, + }); + } + + private renderBeat({ + profile, + beatIndex, + startTime, + expression, + }: { + profile: GardenAudioVibeProfile; + beatIndex: number; + startTime: number; + expression: number; + }): void { + const beatsPerBar = this.getBeatsPerBar(); + const beatInBar = beatIndex % beatsPerBar; + const barIndex = Math.floor(beatIndex / beatsPerBar); + const styleIndex = this.getStyleIndex(startTime); + + if (beatInBar === 0 && barIndex % generativePianoTuning.chordBars === 0) { + this.playPadChord(profile, barIndex, startTime, expression); + } + + if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) { + this.playSupportNote(profile, barIndex, startTime, expression, styleIndex); + } + + if ( + beatInBar === generativePianoTuning.textureBeat && + this.shouldPlayTexture(expression, barIndex) + ) { + this.playTextureNote(profile, barIndex, startTime, expression, styleIndex); + } + + if ( + beatInBar === generativePianoTuning.highActivityExtraBeat && + expression >= generativePianoTuning.highActivityExtraThreshold + ) { + this.playTextureNote( + profile, + barIndex + generativePianoTuning.highActivityExtra.barOffset, + startTime, + expression * generativePianoTuning.highActivityExtra.expressionMultiplier, + styleIndex + ); + } + } + + private playPadChord( + profile: GardenAudioVibeProfile, + barIndex: number, + startTime: number, + expression: number + ): void { + const chord = this.getChord(profile, barIndex); + const intervals = getChordIntervals(chord, true); + const rootMidi = profile.rootMidi + chord.rootOffset; + const durationSeconds = + this.getBarDurationSeconds() * + generativePianoTuning.chordBars * + generativePianoTuning.padDurationBarScale; + const notes = [ + { + source: { baseMidi: rootMidi, offsets: [0] }, + register: generativePianoTuning.padRegisters[0], + velocity: generativePianoTuning.padChord.velocities[0], + }, + { + source: { baseMidi: rootMidi, offsets: [intervals[1]] }, + register: generativePianoTuning.padRegisters[1], + velocity: generativePianoTuning.padChord.velocities[1], + }, + { + source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, + register: generativePianoTuning.padRegisters[2], + velocity: generativePianoTuning.padChord.velocities[2], + }, + ]; + + notes.forEach(({ source, register, velocity }, index) => { + const midi = this.chooseMidi( + source, + register, + this.lastPadMidiByVoice[index], + false + ); + this.lastPadMidiByVoice[index] = midi; + this.playProfileNote(profile, { + midi, + velocity: + velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight, + startTime, + durationSeconds, + pan: this.getActivityPan(register.pan, expression), + role: 'pad', + delaySend: generativePianoTuning.padChord.delaySend, + lowpassHz: this.getLowpassHz( + profile, + midi, + expression * generativePianoTuning.padChord.lowpassExpressionWeight + ), + }); + }); + } + + private playReleaseResolution( + profile: GardenAudioVibeProfile, + stepIndex: number, + startTime: number + ): void { + const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); + const intervals = getChordIntervals(chord, true); + const rootMidi = profile.rootMidi + chord.rootOffset; + const release = generativePianoTuning.releaseResolution; + const offsetsByVoice: ReadonlyArray> = [ + [0], + [intervals[1], intervals[2]], + [intervals[3], intervals[2]], + ]; + + offsetsByVoice.forEach((offsets, index) => { + const register = generativePianoTuning.padRegisters[index]; + const midi = this.chooseMidi( + { baseMidi: rootMidi, offsets }, + register, + null, + false + ); + this.playProfileNote(profile, { + midi, + velocity: release.velocities[index], + startTime: startTime + index * release.strumSeconds, + durationSeconds: release.durationSeconds, + pan: this.getActivityPan(register.pan, 0), + role: 'pad', + delaySend: release.delaySend, + lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression), + }); + }); + } + + private playSupportNote( + profile: GardenAudioVibeProfile, + barIndex: number, + startTime: number, + expression: number, + styleIndex: GardenAudioStyleIndex + ): void { + const pool = generativePianoTuning.stylePools[styleIndex]; + const chord = this.getChord(profile, barIndex); + const chordIntervals = getChordIntervals(chord, false); + const rootMidi = profile.rootMidi + chord.rootOffset; + const midi = this.chooseMidi( + { + baseMidi: rootMidi, + offsets: this.getSupportOffsets(chordIntervals, styleIndex), + chordOffsets: chordIntervals, + }, + pool, + this.lastMidiByStyle[styleIndex], + true + ); + + this.lastMidiByStyle[styleIndex] = midi; + this.playProfileNote(profile, { + midi, + velocity: + (generativePianoTuning.supportNote.velocityBase + + expression * generativePianoTuning.supportNote.velocityExpressionWeight) * + styleVoices[styleIndex].velocityMultiplier, + startTime, + durationSeconds: + generativePianoTuning.supportNote.durationBaseSeconds + + expression * generativePianoTuning.supportNote.durationExpressionSeconds, + pan: this.getStylePan(styleIndex, expression), + role: 'support', + delaySend: + generativePianoTuning.supportNote.delaySendBase + + expression * generativePianoTuning.supportNote.delaySendExpressionWeight, + lowpassHz: this.getLowpassHz( + profile, + midi, + expression * generativePianoTuning.supportNote.lowpassExpressionWeight + ), + }); + } + + private playTextureNote( + profile: GardenAudioVibeProfile, + barIndex: number, + startTime: number, + expression: number, + styleIndex: GardenAudioStyleIndex + ): void { + const pool = generativePianoTuning.stylePools[styleIndex]; + const chord = this.getChord(profile, barIndex); + const chordIntervals = getChordIntervals(chord, false); + const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex); + const midi = this.chooseMidi( + { + baseMidi: profile.rootMidi, + offsets: degrees.map((degree) => degreeToSemitone(profile, degree)), + chordOffsets: this.getChordOffsets(chord, chordIntervals), + }, + pool, + this.lastMidiByStyle[styleIndex], + true + ); + + this.lastMidiByStyle[styleIndex] = midi; + this.playProfileNote(profile, { + midi, + velocity: + (generativePianoTuning.textureNote.velocityBase + + expression * generativePianoTuning.textureNote.velocityExpressionWeight) * + styleVoices[styleIndex].velocityMultiplier, + startTime, + durationSeconds: + generativePianoTuning.textureNote.durationBaseSeconds + + expression * generativePianoTuning.textureNote.durationExpressionSeconds, + pan: this.getStylePan(styleIndex, expression), + role: 'texture', + delaySend: + generativePianoTuning.textureNote.delaySendBase + + expression * generativePianoTuning.textureNote.delaySendExpressionWeight, + lowpassHz: this.getLowpassHz(profile, midi, expression), + }); + } + + private playGestureAccent( + vibe: VibePreset, + stepIndex: number, + styleIndex: GardenAudioStyleIndex, + strength: number + ): void { + const profile = getVibeProfile(vibe); + const pool = generativePianoTuning.stylePools[styleIndex]; + const startTime = this.getTimeForStep(stepIndex); + const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); + const chordIntervals = getChordIntervals(chord, false); + const degrees = this.rotate( + pool.scaleDegrees, + Math.round( + strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier + ) + ); + + const midi = this.chooseMidi( + { + baseMidi: profile.rootMidi, + offsets: degrees.map((degree) => degreeToSemitone(profile, degree)), + chordOffsets: this.getChordOffsets(chord, chordIntervals), + }, + pool, + this.lastMidiByStyle[styleIndex], + true + ); + + this.lastMidiByStyle[styleIndex] = midi; + this.playProfileNote(profile, { + midi, + velocity: + (generativePianoTuning.gestureAccent.velocityBase + + strength * generativePianoTuning.gestureAccent.velocityStrengthWeight) * + styleVoices[styleIndex].velocityMultiplier, + startTime, + durationSeconds: + generativePianoTuning.gestureAccent.durationBaseSeconds + + strength * generativePianoTuning.gestureAccent.durationStrengthSeconds, + pan: this.getStylePan(styleIndex, strength), + role: 'gesture', + delaySend: generativePianoTuning.gestureAccent.delaySend, + lowpassHz: this.getLowpassHz(profile, midi, strength), + }); + } + + private playTouchNote({ + vibe, + now, + styleIndex, + strength, + }: { + vibe: VibePreset; + now: number; + styleIndex: GardenAudioStyleIndex; + strength: number; + }): void { + const profile = getVibeProfile(vibe); + const pool = generativePianoTuning.stylePools[styleIndex]; + const chord = this.getChord(profile, this.getGlobalBarIndex(now)); + const chordIntervals = getChordIntervals(chord, false); + const rootMidi = profile.rootMidi + chord.rootOffset; + const midi = this.chooseMidi( + { + baseMidi: rootMidi, + offsets: this.getSupportOffsets(chordIntervals, styleIndex), + }, + pool, + this.lastMidiByStyle[styleIndex], + true + ); + + this.lastMidiByStyle[styleIndex] = midi; + this.lastBrushStreamMidi = midi; + this.playProfileNote(profile, { + midi, + velocity: + (generativePianoTuning.touchNote.velocityBase + + strength * generativePianoTuning.touchNote.velocityStrengthWeight) * + styleVoices[styleIndex].velocityMultiplier, + startTime: now, + durationSeconds: + generativePianoTuning.touchNote.durationBaseSeconds + + strength * generativePianoTuning.touchNote.durationStrengthSeconds, + pan: this.getStylePan(styleIndex, strength), + role: 'gesture', + delaySend: generativePianoTuning.touchNote.delaySend, + lowpassHz: this.getLowpassHz( + profile, + midi, + clamp01( + generativePianoTuning.touchNote.lowpassBaseExpression + + strength * generativePianoTuning.touchNote.lowpassStrengthWeight + ) + ), + }); + } + + private startBrushPhraseLayer({ + vibe, + now, + strength, + styleIndex, + maniaAmount, + }: { + vibe: VibePreset; + now: number; + strength: number; + styleIndex: GardenAudioStyleIndex; + maniaAmount: number; + }): void { + const lifetimeSeconds = + generativePianoTuning.brushLayerBaseSeconds + + strength * generativePianoTuning.brushLayerEnergySeconds; + const expiresAt = this.getNextBarTimeAt(now + lifetimeSeconds); + + this.brushPhraseLayers.push({ + vibe, + startedAt: now, + lastUpdatedAt: now, + expiresAt, + styleIndex, + energy: strength, + motifOffsets: [styleIndex + generativePianoTuning.brushPhrase.initialMotifOffset], + maniaAmount, + }); + + if (this.brushPhraseLayers.length > generativePianoTuning.maxBrushPhraseLayers) { + this.brushPhraseLayers = this.brushPhraseLayers.slice( + -generativePianoTuning.maxBrushPhraseLayers + ); + } + } + + private updateBrushPhraseLayer({ + now, + strength, + styleIndex, + maniaAmount, + }: { + now: number; + strength: number; + styleIndex: GardenAudioStyleIndex; + maniaAmount: number; + }): void { + const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1]; + if (!layer || layer.expiresAt <= now) { + return; + } + + const elapsedSeconds = Math.max(0, now - layer.lastUpdatedAt); + layer.lastUpdatedAt = now; + layer.styleIndex = styleIndex; + layer.energy = Math.max( + layer.energy * + Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.energyDecaySeconds), + strength + ); + layer.maniaAmount = Math.max( + layer.maniaAmount * + Math.exp(-elapsedSeconds / generativePianoTuning.brushPhrase.maniaDecaySeconds), + maniaAmount + ); + layer.motifOffsets.push(this.getMotifOffset(strength)); + if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) { + layer.motifOffsets = layer.motifOffsets.slice( + -generativePianoTuning.brushMotifMaxSteps + ); + } + } + + private renderBrushPhraseLayers({ + vibe, + now, + lookaheadEnd, + activity, + }: { + vibe: VibePreset; + now: number; + lookaheadEnd: number; + activity: number; + }): void { + const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS; + this.nextBrushStreamStep ??= 0; + this.pruneBrushStreamNoteCounts(this.getGlobalBarIndex(now) - 1); + + this.brushPhraseLayers = this.brushPhraseLayers.filter( + (layer) => layer.expiresAt > earliestStart + ); + + while (this.getTimeForStep(this.nextBrushStreamStep) < earliestStart) { + const frame = this.getBrushStreamFrame( + this.getTimeForStep(this.nextBrushStreamStep), + activity + ); + this.nextBrushStreamStep += this.getBrushStreamIntervalSteps(frame.intensity); + this.brushStreamNoteIndex += 1; + } + + while (this.getTimeForStep(this.nextBrushStreamStep) <= lookaheadEnd) { + const startTime = this.getTimeForStep(this.nextBrushStreamStep); + const frame = this.getBrushStreamFrame(startTime, activity); + if ( + frame.intensity >= generativePianoTuning.brushLayerMinIntensity && + this.reserveBrushStreamNote(this.nextBrushStreamStep) + ) { + this.playBrushStreamNote({ + vibe, + startTime, + stepIndex: this.nextBrushStreamStep, + intensity: frame.intensity, + styleIndex: this.getStyleIndex(startTime), + layer: frame.layer, + }); + } + this.nextBrushStreamStep += this.getBrushStreamIntervalSteps(frame.intensity); + this.brushStreamNoteIndex += 1; + } + } + + private playBrushStreamNote({ + vibe, + startTime, + stepIndex, + intensity, + styleIndex, + layer, + }: { + vibe: VibePreset; + startTime: number; + stepIndex: number; + intensity: number; + styleIndex: GardenAudioStyleIndex; + layer: BrushPhraseLayer | null; + }): void { + const profile = getVibeProfile(vibe); + const pool = generativePianoTuning.stylePools[styleIndex]; + const maniaAmount = + layer?.maniaAmount ?? + clamp01( + (intensity - generativePianoTuning.brushStream.inferredManiaThreshold) / + generativePianoTuning.brushStream.inferredManiaRange + ); + const register = this.getBiasedRegister( + pool, + maniaAmount * generativePianoTuning.brushStream.registerManiaShift + ); + const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex)); + const chordIntervals = getChordIntervals(chord, false); + const rootMidi = profile.rootMidi + chord.rootOffset; + const useChordTone = + this.brushStreamNoteIndex % + generativePianoTuning.brushStream.chordToneEverySteps === + 0; + const source = useChordTone + ? { + baseMidi: rootMidi, + offsets: this.getSupportOffsets(chordIntervals, styleIndex), + chordOffsets: chordIntervals, + } + : { + baseMidi: profile.rootMidi, + offsets: this.getBrushMotifDegrees({ + layer, + pool, + styleIndex, + }).map((degree) => degreeToSemitone(profile, degree)), + chordOffsets: this.getChordOffsets(chord, chordIntervals), + }; + const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); + const pan = this.getStylePan(styleIndex, intensity); + const durationSeconds = clamp( + generativePianoTuning.brushStream.durationBaseSeconds + + intensity * generativePianoTuning.brushStream.durationIntensitySeconds - + maniaAmount * generativePianoTuning.brushStream.durationManiaSeconds, + generativePianoTuning.brushStream.durationMinSeconds, + generativePianoTuning.brushStream.durationMaxSeconds + ); + const delaySend = clamp( + generativePianoTuning.brushStream.delaySendBase + + intensity * generativePianoTuning.brushStream.delaySendIntensityWeight - + maniaAmount * generativePianoTuning.brushStream.delaySendManiaWeight, + generativePianoTuning.brushStream.delaySendMin, + generativePianoTuning.brushStream.delaySendMax + ); + + this.lastBrushStreamMidi = midi; + this.lastMidiByStyle[styleIndex] = midi; + this.playProfileNote(profile, { + midi, + velocity: + (generativePianoTuning.brushStream.velocityBase + + intensity * generativePianoTuning.brushStream.velocityIntensityWeight) * + styleVoices[styleIndex].velocityMultiplier, + startTime, + durationSeconds, + pan, + role: 'brush', + delaySend, + lowpassHz: this.getLowpassHz( + profile, + midi, + clamp01( + generativePianoTuning.brushStream.lowpassBaseExpression + + intensity * generativePianoTuning.brushStream.lowpassIntensityWeight + + maniaAmount * generativePianoTuning.brushStream.lowpassManiaWeight + ) + ), + }); + + if ( + maniaAmount >= generativePianoTuning.brushStreamEcho.maniaThreshold && + (this.brushStreamNoteIndex % generativePianoTuning.brushStreamEcho.stepModulo === + generativePianoTuning.brushStreamEcho.stepRemainder || + intensity >= generativePianoTuning.brushStreamEcho.intensityThreshold) + ) { + const echoMidi = + midi + generativePianoTuning.brushStreamEcho.octaveSemitones <= + generativePianoTuning.brushStreamEcho.maxMidi + ? midi + generativePianoTuning.brushStreamEcho.octaveSemitones + : midi - generativePianoTuning.brushStreamEcho.octaveSemitones; + this.playProfileNote(profile, { + midi: echoMidi, + velocity: + (generativePianoTuning.brushStreamEcho.velocityBase + + intensity * generativePianoTuning.brushStreamEcho.velocityIntensityWeight) * + styleVoices[styleIndex].velocityMultiplier, + startTime: startTime + generativePianoTuning.brushMotifCanonDelaySeconds, + durationSeconds: Math.max( + generativePianoTuning.brushStreamEcho.durationMinSeconds, + durationSeconds * generativePianoTuning.brushStreamEcho.durationScale + ), + pan: clamp(pan * generativePianoTuning.brushStreamEcho.panScale, -1, 1), + role: 'brush', + delaySend: Math.max( + generativePianoTuning.brushStreamEcho.delaySendMin, + delaySend * generativePianoTuning.brushStreamEcho.delaySendScale + ), + lowpassHz: this.getLowpassHz( + profile, + echoMidi, + generativePianoTuning.brushStreamEcho.lowpassBaseExpression + + maniaAmount * generativePianoTuning.brushStreamEcho.lowpassManiaWeight + ), + }); + } + } + + private getBrushStreamFrame( + startTime: number, + activity: number + ): { + intensity: number; + layer: BrushPhraseLayer | null; + } { + const layerStates = this.brushPhraseLayers.map((layer) => ({ + layer, + intensity: + layer.energy * + this.getBrushPhraseFade(layer, startTime) * + (generativePianoTuning.brushPhrase.layerIntensityBase + + layer.maniaAmount * + generativePianoTuning.brushPhrase.layerIntensityManiaWeight), + })); + const dominant = layerStates.reduce<{ + layer: BrushPhraseLayer; + intensity: number; + } | null>((best, state) => { + if (state.intensity <= 0) { + return best; + } + return best === null || state.intensity > best.intensity ? state : best; + }, null); + const layeredIntensity = layerStates.reduce( + (sum, state) => sum + Math.max(0, state.intensity), + 0 + ); + + return { + intensity: clamp01( + activity * generativePianoTuning.brushPhrase.frameActivityWeight + + layeredIntensity + + (dominant?.layer.maniaAmount ?? 0) * + generativePianoTuning.brushPhrase.frameManiaWeight + ), + layer: dominant?.layer ?? null, + }; + } + + private getBrushStreamIntervalSteps(intensity: number): number { + const intervalBeats = + intensity >= generativePianoTuning.brushStream.intenseThreshold + ? generativePianoTuning.brushStreamIntenseIntervalBeats + : intensity >= generativePianoTuning.brushStream.activeThreshold + ? generativePianoTuning.brushStreamActiveIntervalBeats + : generativePianoTuning.brushStreamIdleIntervalBeats; + return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat)); + } + + private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number { + const lifetimeSeconds = Math.max(0.001, layer.expiresAt - layer.startedAt); + const ageSeconds = startTime - layer.startedAt; + return clamp01(1 - ageSeconds / lifetimeSeconds); + } + + private getMotifOffset(strength: number): number { + return strength >= generativePianoTuning.brushMotif.highThreshold + ? generativePianoTuning.brushMotif.highOffset + : strength >= generativePianoTuning.brushMotif.mediumThreshold + ? generativePianoTuning.brushMotif.mediumOffset + : generativePianoTuning.brushMotif.lowOffset; + } + + private getBrushMotifDegrees({ + layer, + pool, + styleIndex, + }: { + layer: BrushPhraseLayer | null; + pool: GardenAudioStylePool; + styleIndex: GardenAudioStyleIndex; + }): Array { + const styleOffset = styleVoices[styleIndex].scaleDegreeOffset; + if (!layer || layer.motifOffsets.length === 0) { + return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset); + } + + const motifOffset = + layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length]; + const baseOffset = styleOffset + motifOffset; + + return this.rotate( + pool.scaleDegrees.map((degree) => degree + baseOffset), + this.brushStreamNoteIndex + ); + } + + private getBiasedRegister( + register: GardenAudioRegister, + maniaAmount: number + ): GardenAudioRegister { + const shift = Math.round( + maniaAmount * generativePianoTuning.registerBias.maniaShiftSemitones + ); + const midiMin = clamp( + register.midiMin + shift, + generativePianoTuning.registerBias.midiMin, + generativePianoTuning.registerBias.midiMaxForMin + ); + const midiMax = clamp( + register.midiMax + shift, + midiMin + generativePianoTuning.registerBias.minimumSpan, + generativePianoTuning.registerBias.midiMax + ); + + return { + midiMin, + midiMax, + preferredMidi: clamp(register.preferredMidi + shift, midiMin, midiMax), + pan: register.pan, + }; + } + + private chooseMidi( + pitchSource: PitchSource, + register: GardenAudioRegister, + previousMidi: number | null = null, + avoidRepeat = false + ): number { + const candidates = this.getCandidates(pitchSource, register); + const referenceMidi = previousMidi ?? register.preferredMidi; + + if (candidates.length === 0) { + return register.preferredMidi; + } + + return candidates.reduce((best, candidate) => + this.scoreCandidate(candidate, register, referenceMidi, avoidRepeat) < + this.scoreCandidate(best, register, referenceMidi, avoidRepeat) + ? candidate + : best + ).midi; + } + + private getCandidates( + pitchSource: PitchSource, + register: GardenAudioRegister + ): Array { + const candidates: Array = []; + const chordPitchClasses = pitchSource.chordOffsets?.map((offset) => + getPitchClass(pitchSource.baseMidi + offset) + ); + + pitchSource.offsets.forEach((offset, preference) => { + for ( + let octave = generativePianoTuning.candidateOctaveSearch.min; + octave <= generativePianoTuning.candidateOctaveSearch.max; + octave += 1 + ) { + const midi = pitchSource.baseMidi + offset + octave * PITCH_SEMITONES_PER_OCTAVE; + if (midi >= register.midiMin && midi <= register.midiMax) { + const roundedMidi = Math.round(midi); + candidates.push({ + midi: roundedMidi, + preference, + chordToneDistance: getPitchClassDistance(roundedMidi, chordPitchClasses), + }); + } + } + }); + + return candidates; + } + + private scoreCandidate( + candidate: PitchCandidate, + register: GardenAudioRegister, + previousMidi: number, + avoidRepeat: boolean + ): number { + return ( + Math.abs(candidate.midi - previousMidi) + + Math.abs(candidate.midi - register.preferredMidi) * + generativePianoTuning.noteScoreRegisterWeight + + candidate.preference * generativePianoTuning.noteScorePreferenceWeight + + candidate.chordToneDistance * generativePianoTuning.noteScoreChordToneWeight + + (avoidRepeat && candidate.midi === previousMidi + ? generativePianoTuning.noteScoreRepeatPenalty + : 0) + ); + } + + private shouldPlaySupport(expression: number, barIndex: number): boolean { + if (expression < SUPPORT_ONSET_EXPRESSION) { + return false; + } + if (expression >= generativePianoTuning.supportNote.expressionThreshold) { + return true; + } + + return ( + barIndex % generativePianoTuning.supportBarSpacing === + generativePianoTuning.supportBarOffset + ); + } + + 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 + : generativePianoTuning.mediumTextureBarSpacing; + return barIndex % spacing === generativePianoTuning.textureNote.idlePhase % spacing; + } + + private getSupportOffsets( + chordIntervals: ReadonlyArray, + styleIndex: GardenAudioStyleIndex + ): Array { + return generativePianoTuning.supportNote.offsetsByStyle[styleIndex].map((offset) => + getConfiguredChordOffset(chordIntervals, offset) + ); + } + + private getChord(profile: GardenAudioVibeProfile, barIndex: number): GardenAudioChord { + const progressionIndex = + Math.floor(barIndex / generativePianoTuning.chordBars) % profile.progression.length; + return profile.progression[progressionIndex]; + } + + private getGlobalBarIndex(startTime: number): number { + return this.getBarIndexForStep(this.getStepIndexAtTime(startTime)); + } + + private getStyleIndex(startTime: number): GardenAudioStyleIndex { + const styleCount = generativePianoTuning.stylePools.length; + const rotationBars = Math.max(1, Math.round(generativePianoTuning.styleRotationBars)); + return (Math.floor(this.getGlobalBarIndex(startTime) / rotationBars) % + styleCount) as GardenAudioStyleIndex; + } + + private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number { + const pool = generativePianoTuning.stylePools[styleIndex]; + const styleVoice = styleVoices[styleIndex]; + return this.getActivityPan( + pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale, + 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, + expression: number + ): number { + const midiLift = + clamp01( + (midi - generativePianoTuning.lowpass.midiBase) / + generativePianoTuning.lowpass.midiRange + ) * generativePianoTuning.lowpass.midiLiftHz; + return clamp( + this.config.piano.lowpassHz * + profile.brightness * + (generativePianoTuning.lowpass.expressionBase + + expression * generativePianoTuning.lowpass.expressionWeight) + + midiLift, + this.config.piano.lowpassMinHz, + this.config.piano.lowpassMaxHz + ); + } + + private skipLateBeats(now: number): void { + if (this.nextBeatStep === null) { + return; + } + + const earliestStart = now + PIANO_SCHEDULE_AHEAD_SECONDS; + if (this.getTimeForStep(this.nextBeatStep) >= earliestStart) { + return; + } + + const earliestStep = this.getNextStepIndexAt(earliestStart, 0); + const stepsPerBeat = this.config.rhythm.stepsPerBeat; + this.nextBeatStep = Math.ceil(earliestStep / stepsPerBeat) * stepsPerBeat; + } + + private getExpression(activity: number): number { + const activityExpression = clamp01( + (activity - this.config.rhythm.sparseActivity) / + (1 - this.config.rhythm.sparseActivity) + ); + const idleExpression = clamp01( + this.activeProfile?.idleIntensity ?? this.config.rhythm.idleIntensity + ); + return Math.max(activityExpression, idleExpression); + } + + private getBeatDurationSeconds(): number { + return 60 / (this.activeProfile?.bpm ?? this.config.rhythm.bpm); + } + + private getStepDurationSeconds(): number { + return this.getBeatDurationSeconds() / this.config.rhythm.stepsPerBeat; + } + + private getBarDurationSeconds(): number { + return this.getBeatDurationSeconds() * this.getBeatsPerBar(); + } + + private getBeatsPerBar(): number { + return Math.max( + 1, + Math.round(this.config.rhythm.stepsPerBar / this.config.rhythm.stepsPerBeat) + ); + } + + private getTimeForStep(stepIndex: number): number { + return ( + (this.timelineStartedAt ?? 0) + + GENERATIVE_START_DELAY_SECONDS + + stepIndex * this.getStepDurationSeconds() + ); + } + + private getStepIndexAtTime(startTime: number): number { + const timelineStartedAt = this.timelineStartedAt ?? startTime; + const elapsedSeconds = Math.max( + 0, + startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS + ); + return Math.floor(elapsedSeconds / this.getStepDurationSeconds()); + } + + private getNextStepIndexAt(startTime: number, lookaheadSteps: number): number { + const timelineStartedAt = this.timelineStartedAt ?? startTime; + const elapsedSeconds = Math.max( + 0, + startTime - timelineStartedAt - GENERATIVE_START_DELAY_SECONDS + ); + return Math.max( + 0, + Math.ceil(elapsedSeconds / this.getStepDurationSeconds()) + lookaheadSteps + ); + } + + private getReleaseResolutionStep(startTime: number): number { + const currentStep = this.getStepIndexAtTime(startTime); + const nextBeatStep = + Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBeat) * + this.config.rhythm.stepsPerBeat; + const nextBarStep = + Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBar) * + this.config.rhythm.stepsPerBar; + const barIsClose = nextBarStep - currentStep <= this.config.rhythm.stepsPerBeat * 2; + + return barIsClose ? nextBarStep : nextBeatStep; + } + + private getNextBarTimeAt(startTime: number): number { + const nextBarStep = + Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) * + this.config.rhythm.stepsPerBar; + return this.getTimeForStep(nextBarStep); + } + + private getBeatIndexForStep(stepIndex: number): number { + return Math.floor(stepIndex / this.config.rhythm.stepsPerBeat); + } + + private getBarIndexForStep(stepIndex: number): number { + return Math.floor(stepIndex / this.config.rhythm.stepsPerBar); + } + + private reserveBrushStreamNote(stepIndex: number): boolean { + const barIndex = this.getBarIndexForStep(stepIndex); + const noteCount = this.brushStreamNoteCountsByBar.get(barIndex) ?? 0; + if (noteCount >= generativePianoTuning.maxBrushStreamNotesPerBar) { + return false; + } + + this.brushStreamNoteCountsByBar.set(barIndex, noteCount + 1); + return true; + } + + private pruneBrushStreamNoteCounts(earliestBarIndex: number): void { + this.brushStreamNoteCountsByBar.forEach((_, barIndex) => { + if (barIndex < earliestBarIndex) { + this.brushStreamNoteCountsByBar.delete(barIndex); + } + }); + } + + private getChordOffsets( + chord: GardenAudioChord, + chordIntervals: ReadonlyArray + ): Array { + return chordIntervals.map((interval) => chord.rootOffset + interval); + } + + private rotate(values: ReadonlyArray, offset: number): Array { + return values.map((_, index) => values[(index + offset) % values.length]); + } +} + +const getPitchClass = (midi: number): number => ((midi % 12) + 12) % 12; + +const getPitchClassDistance = ( + midi: number, + chordPitchClasses: ReadonlyArray | undefined +): number => { + if (!chordPitchClasses || chordPitchClasses.length === 0) { + return 0; + } + + const pitchClass = getPitchClass(midi); + return chordPitchClasses.reduce((best, chordPitchClass) => { + const distance = Math.abs(pitchClass - chordPitchClass); + return Math.min(best, Math.min(distance, 12 - distance)); + }, 6); +}; + +const getConfiguredChordOffset = ( + chordIntervals: ReadonlyArray, + configuredOffset: number +): number => { + if (configuredOffset >= 12) { + const interval = chordIntervals[configuredOffset - 12] ?? 0; + return interval + 12; + } + + return chordIntervals[configuredOffset] ?? configuredOffset; +}; diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts new file mode 100644 index 0000000..f9ab2bf --- /dev/null +++ b/src/audio/noise-burst-player.ts @@ -0,0 +1,64 @@ +import { clamp } from '../utils/math'; +import type { GardenAudioGraph } from './garden-audio-graph'; +import type { NoiseBurst } from './garden-audio-types'; + +const noiseBurstTuning = { + attackSeconds: 0.004, + filterQ: 1.4, + offsetRandomSeconds: 0.4, + scheduleAheadSeconds: 0.002, + silentGain: 0.0001, + filterType: 'bandpass', +} as const; + +export class NoiseBurstPlayer { + public constructor(private readonly graph: GardenAudioGraph) {} + + public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void { + const { context, noiseBus, noiseBuffer } = this.graph; + if (!context || !noiseBus || !noiseBuffer) { + return; + } + + const scheduledStart = Math.max( + context.currentTime + noiseBurstTuning.scheduleAheadSeconds, + startTime + ); + const source = context.createBufferSource(); + const filter = context.createBiquadFilter(); + const envelope = context.createGain(); + const panner = context.createStereoPanner(); + const stopAt = scheduledStart + durationSeconds; + + source.buffer = noiseBuffer; + filter.type = noiseBurstTuning.filterType; + filter.frequency.setValueAtTime(filterHz, scheduledStart); + filter.Q.value = noiseBurstTuning.filterQ; + envelope.gain.setValueAtTime(noiseBurstTuning.silentGain, scheduledStart); + envelope.gain.exponentialRampToValueAtTime( + Math.max(noiseBurstTuning.silentGain, gain), + scheduledStart + noiseBurstTuning.attackSeconds + ); + envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt); + panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); + source.connect(filter); + filter.connect(envelope); + envelope.connect(panner); + panner.connect(noiseBus); + 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', + () => { + source.disconnect(); + filter.disconnect(); + envelope.disconnect(); + panner.disconnect(); + }, + { once: true } + ); + } +} diff --git a/src/audio/piano-sampler.ts b/src/audio/piano-sampler.ts new file mode 100644 index 0000000..1a0c77c --- /dev/null +++ b/src/audio/piano-sampler.ts @@ -0,0 +1,264 @@ +import { clamp, clamp01 } from '../utils/math'; +import type { GardenAudioConfig } from './garden-audio-config'; +import type { GardenAudioGraph } from './garden-audio-graph'; +import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music'; +import type { LoadedPianoSample, PianoNote } from './garden-audio-types'; +import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples'; + +export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002; + +interface ActivePianoVoice { + gain: GainNode; + source: AudioScheduledSourceNode; + stopAt: number; +} + +const pianoSamplerTuning = { + 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 = []; + private activeVoices: Array = []; + + public constructor( + private readonly config: GardenAudioConfig, + private readonly graph: GardenAudioGraph + ) {} + + public load(context: BaseAudioContext): Promise { + if (this.samples.length > 0) { + return Promise.resolve(); + } + + const loadedSamples = getLoadedPianoSamples(); + if (loadedSamples) { + this.setSamples(loadedSamples); + return Promise.resolve(); + } + + return loadPianoSamples(context).then((samples) => { + this.setSamples(samples); + }); + } + + public play({ + midi, + velocity, + startTime, + durationSeconds, + pan, + role, + delaySend = 0, + lowpassHz = this.config.piano.lowpassHz, + sustainSeconds: profileSustainSeconds = this.config.piano.sustainSeconds, + }: PianoNote): void { + const { context } = this.graph; + const eventBus = this.graph.getPianoBus(role); + if (!context || !eventBus) { + return; + } + + const sample = this.findNearestSample(midi); + if (!sample) { + return; + } + + const scheduledStart = Math.max( + context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS, + startTime + ); + const noteVelocity = clamp01(velocity); + const noteGainValue = this.computeNoteGain(noteVelocity); + const sustainSeconds = + profileSustainSeconds * + (this.config.piano.sustainBase + + noteVelocity * this.config.piano.sustainVelocityRange); + const sustainAt = + scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); + const releaseAt = sustainAt + sustainSeconds; + const stopAt = + releaseAt + + this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount; + const source = context.createBufferSource(); + + source.buffer = sample.buffer; + source.playbackRate.setValueAtTime( + Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE), + scheduledStart + ); + + this.scheduleVoice({ + source, + scheduledStart, + stopAt, + pan, + lowpassHz, + delaySend, + eventBus, + configureGainEnvelope: (gain) => { + gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart); + gain.gain.exponentialRampToValueAtTime( + noteGainValue, + scheduledStart + this.config.piano.gainAttackSeconds + ); + gain.gain.setTargetAtTime( + Math.max( + pianoSamplerTuning.minGain, + noteGainValue * this.config.piano.sustainLevel + ), + sustainAt, + Math.max( + pianoSamplerTuning.minFadeSeconds, + sustainSeconds * this.config.piano.sustainBase + ) + ); + gain.gain.setTargetAtTime( + pianoSamplerTuning.minGain, + releaseAt, + this.config.piano.releaseSeconds + ); + }, + }); + } + + public stopAll(): void { + const context = this.graph.context; + if (!context) { + this.activeVoices = []; + return; + } + + const now = context.currentTime; + + this.activeVoices.forEach((voice) => { + this.stopVoice(voice, now); + }); + this.activeVoices = []; + } + + public reset(): void { + this.samples = []; + this.activeVoices = []; + } + + private scheduleVoice({ + source, + scheduledStart, + stopAt, + pan, + lowpassHz, + delaySend, + eventBus, + configureGainEnvelope, + }: { + source: AudioScheduledSourceNode; + scheduledStart: number; + stopAt: number; + pan: number; + lowpassHz: number; + delaySend: number; + eventBus: GainNode; + configureGainEnvelope: (gain: GainNode) => void; + }): void { + const { context, delayInput } = this.graph; + if (!context) { + return; + } + + const filter = context.createBiquadFilter(); + const gain = context.createGain(); + const panner = context.createStereoPanner(); + let sendGain: GainNode | null = null; + + this.trimActiveVoices(scheduledStart); + while (this.activeVoices.length >= this.config.piano.maxVoices) { + const oldest = this.activeVoices.shift(); + if (!oldest) { + break; + } + this.stopVoice(oldest, scheduledStart); + } + + filter.type = pianoSamplerTuning.filterType; + filter.frequency.setValueAtTime( + clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz), + scheduledStart + ); + filter.Q.value = pianoSamplerTuning.filterQ; + panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); + configureGainEnvelope(gain); + + source.connect(filter); + filter.connect(gain); + gain.connect(panner); + panner.connect(eventBus); + + if (delayInput && delaySend > 0) { + sendGain = context.createGain(); + sendGain.gain.value = delaySend; + panner.connect(sendGain); + sendGain.connect(delayInput); + } + + source.start(scheduledStart); + source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds); + this.activeVoices.push({ gain, source, stopAt }); + + source.addEventListener( + 'ended', + () => { + source.disconnect(); + filter.disconnect(); + gain.disconnect(); + panner.disconnect(); + sendGain?.disconnect(); + this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain); + }, + { once: true } + ); + } + + private computeNoteGain(velocity: number): number { + return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity); + } + + private findNearestSample(midi: number): LoadedPianoSample | null { + if (this.samples.length === 0) { + return null; + } + + return this.samples.reduce((nearest, sample) => + Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest + ); + } + + private trimActiveVoices(now: number): void { + this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now); + } + + private stopVoice(voice: ActivePianoVoice, now: number): void { + const stopAt = now + pianoSamplerTuning.voiceStealStopSeconds; + + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setTargetAtTime( + pianoSamplerTuning.minGain, + now, + pianoSamplerTuning.voiceStealFadeSeconds + ); + voice.stopAt = stopAt; + voice.source.stop(stopAt); + } + + private setSamples(samples: Array): void { + this.samples = samples.slice().sort((a, b) => a.midi - b.midi); + } +} diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts new file mode 100644 index 0000000..569eca4 --- /dev/null +++ b/src/audio/piano-samples.ts @@ -0,0 +1,271 @@ +import type { LoadedPianoSample } from './garden-audio-types'; +import a0SampleUrl from './samples/A0v12.m4a?url&no-inline'; +import a1SampleUrl from './samples/A1v12.m4a?url&no-inline'; +import a2SampleUrl from './samples/A2v12.m4a?url&no-inline'; +import a3SampleUrl from './samples/A3v12.m4a?url&no-inline'; +import a4SampleUrl from './samples/A4v12.m4a?url&no-inline'; +import a5SampleUrl from './samples/A5v12.m4a?url&no-inline'; +import a6SampleUrl from './samples/A6v12.m4a?url&no-inline'; +import a7SampleUrl from './samples/A7v12.m4a?url&no-inline'; +import c1SampleUrl from './samples/C1v12.m4a?url&no-inline'; +import c2SampleUrl from './samples/C2v12.m4a?url&no-inline'; +import c3SampleUrl from './samples/C3v12.m4a?url&no-inline'; +import c4SampleUrl from './samples/C4v12.m4a?url&no-inline'; +import c5SampleUrl from './samples/C5v12.m4a?url&no-inline'; +import c6SampleUrl from './samples/C6v12.m4a?url&no-inline'; +import c7SampleUrl from './samples/C7v12.m4a?url&no-inline'; +import c8SampleUrl from './samples/C8v12.m4a?url&no-inline'; +import dSharp1SampleUrl from './samples/Dsharp1v12.m4a?url&no-inline'; +import dSharp2SampleUrl from './samples/Dsharp2v12.m4a?url&no-inline'; +import dSharp3SampleUrl from './samples/Dsharp3v12.m4a?url&no-inline'; +import dSharp4SampleUrl from './samples/Dsharp4v12.m4a?url&no-inline'; +import dSharp5SampleUrl from './samples/Dsharp5v12.m4a?url&no-inline'; +import dSharp6SampleUrl from './samples/Dsharp6v12.m4a?url&no-inline'; +import dSharp7SampleUrl from './samples/Dsharp7v12.m4a?url&no-inline'; +import fSharp1SampleUrl from './samples/Fsharp1v12.m4a?url&no-inline'; +import fSharp2SampleUrl from './samples/Fsharp2v12.m4a?url&no-inline'; +import fSharp3SampleUrl from './samples/Fsharp3v12.m4a?url&no-inline'; +import fSharp4SampleUrl from './samples/Fsharp4v12.m4a?url&no-inline'; +import fSharp5SampleUrl from './samples/Fsharp5v12.m4a?url&no-inline'; +import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline'; +import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline'; + +interface PianoSampleDefinition { + note: string; + url: string; +} + +export interface PianoSampleLoadProgress { + failedCount: number; + loadedCount: number; + settledCount: number; + totalCount: number; +} + +const pianoSampleDefinitions: Array = [ + { 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, + sampleTimeoutMs: 15_000, +}; + +export const preloadPianoSamples = ( + onProgress?: (progress: PianoSampleLoadProgress) => void +): Promise> => { + const OfflineAudioContextConstructor = globalThis.OfflineAudioContext; + + if (!OfflineAudioContextConstructor) { + return Promise.reject( + new Error('OfflineAudioContext is required to preload piano samples.') + ); + } + + // Decoding ignores these, but the constructor demands real numbers. + const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000); + return loadPianoSamples(decodeContext, onProgress); +}; + +export const loadPianoSamples = ( + decodeContext: BaseAudioContext, + onProgress?: (progress: PianoSampleLoadProgress) => void +): Promise> => { + const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress); + + if (loadedPianoSamples) { + emitPianoSampleProgress({ + failedCount: 0, + loadedCount: loadedPianoSamples.length, + settledCount: loadedPianoSamples.length, + totalCount: pianoSampleDefinitions.length, + }); + unsubscribeProgress(); + return Promise.resolve([...loadedPianoSamples]); + } + + if (pianoSampleLoadPromise) { + return pianoSampleLoadPromise.finally(unsubscribeProgress); + } + + let loadedCount = 0; + let failedCount = 0; + let settledCount = 0; + const totalCount = pianoSampleDefinitions.length; + emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount }); + + pianoSampleLoadPromise = loadPianoSampleBatch( + pianoSampleDefinitions, + async (sample) => { + try { + const loadedSample = await withTimeout( + (signal) => loadPianoSample(decodeContext, sample, signal), + sampleLoadTuning.sampleTimeoutMs + ); + loadedCount += 1; + 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.` + ); + } + return [...loadedPianoSamples]; + }, + (error: unknown) => { + pianoSampleLoadPromise = null; + pianoSampleProgressListeners.clear(); + throw error; + } + ) + .finally(unsubscribeProgress); + + return pianoSampleLoadPromise; +}; + +export const getLoadedPianoSamples = (): Array | null => + loadedPianoSamples ? [...loadedPianoSamples] : null; + +const loadPianoSample = async ( + decodeContext: BaseAudioContext, + sample: PianoSampleDefinition, + signal: AbortSignal +): Promise => { + const response = await fetch(sample.url, { signal }); + if (!response.ok) { + throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`); + } + + const audioData = await response.arrayBuffer(); + const buffer = await decodeContext.decodeAudioData(audioData); + return { midi: getMidiForPianoSample(sample), buffer }; +}; + +const loadPianoSampleBatch = async ( + samples: Array, + loadSample: (sample: PianoSampleDefinition) => Promise +): Promise> => { + const results: Array = []; + + for (let index = 0; index < samples.length; index += sampleLoadTuning.concurrency) { + const batch = samples.slice(index, index + sampleLoadTuning.concurrency); + const batchResults = await Promise.all(batch.map((sample) => loadSample(sample))); + results.push(...batchResults); + } + + return results; +}; + +const withTimeout = ( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number +): Promise => + new Promise((resolve, reject) => { + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => { + controller.abort(); + reject(new Error('Timed out while loading a piano sample.')); + }, timeoutMs); + + operation(controller.signal).then( + (value) => { + globalThis.clearTimeout(timeout); + resolve(value); + }, + (error: unknown) => { + globalThis.clearTimeout(timeout); + reject(error); + } + ); + }); + +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/A0v12.m4a b/src/audio/samples/A0v12.m4a new file mode 100644 index 0000000..db06fc3 Binary files /dev/null and b/src/audio/samples/A0v12.m4a differ diff --git a/src/audio/samples/A1v12.m4a b/src/audio/samples/A1v12.m4a new file mode 100644 index 0000000..f1ed488 Binary files /dev/null and b/src/audio/samples/A1v12.m4a differ diff --git a/src/audio/samples/A2v12.m4a b/src/audio/samples/A2v12.m4a new file mode 100644 index 0000000..52df725 Binary files /dev/null and b/src/audio/samples/A2v12.m4a differ diff --git a/src/audio/samples/A3v12.m4a b/src/audio/samples/A3v12.m4a new file mode 100644 index 0000000..707a766 Binary files /dev/null and b/src/audio/samples/A3v12.m4a differ diff --git a/src/audio/samples/A4v12.m4a b/src/audio/samples/A4v12.m4a new file mode 100644 index 0000000..679bcff Binary files /dev/null and b/src/audio/samples/A4v12.m4a differ diff --git a/src/audio/samples/A5v12.m4a b/src/audio/samples/A5v12.m4a new file mode 100644 index 0000000..4a2c896 Binary files /dev/null and b/src/audio/samples/A5v12.m4a differ diff --git a/src/audio/samples/A6v12.m4a b/src/audio/samples/A6v12.m4a new file mode 100644 index 0000000..abbd605 Binary files /dev/null and b/src/audio/samples/A6v12.m4a differ diff --git a/src/audio/samples/A7v12.m4a b/src/audio/samples/A7v12.m4a new file mode 100644 index 0000000..3fd6829 Binary files /dev/null and b/src/audio/samples/A7v12.m4a differ diff --git a/src/audio/samples/C1v12.m4a b/src/audio/samples/C1v12.m4a new file mode 100644 index 0000000..59d5f61 Binary files /dev/null and b/src/audio/samples/C1v12.m4a differ diff --git a/src/audio/samples/C2v12.m4a b/src/audio/samples/C2v12.m4a new file mode 100644 index 0000000..9b636f9 Binary files /dev/null and b/src/audio/samples/C2v12.m4a differ diff --git a/src/audio/samples/C3v12.m4a b/src/audio/samples/C3v12.m4a new file mode 100644 index 0000000..e891e16 Binary files /dev/null and b/src/audio/samples/C3v12.m4a differ diff --git a/src/audio/samples/C4v12.m4a b/src/audio/samples/C4v12.m4a new file mode 100644 index 0000000..6061dc5 Binary files /dev/null and b/src/audio/samples/C4v12.m4a differ diff --git a/src/audio/samples/C5v12.m4a b/src/audio/samples/C5v12.m4a new file mode 100644 index 0000000..a6d8898 Binary files /dev/null and b/src/audio/samples/C5v12.m4a differ diff --git a/src/audio/samples/C6v12.m4a b/src/audio/samples/C6v12.m4a new file mode 100644 index 0000000..745a4d6 Binary files /dev/null and b/src/audio/samples/C6v12.m4a differ diff --git a/src/audio/samples/C7v12.m4a b/src/audio/samples/C7v12.m4a new file mode 100644 index 0000000..6470854 Binary files /dev/null and b/src/audio/samples/C7v12.m4a differ diff --git a/src/audio/samples/C8v12.m4a b/src/audio/samples/C8v12.m4a new file mode 100644 index 0000000..dfbbfd1 Binary files /dev/null and b/src/audio/samples/C8v12.m4a differ diff --git a/src/audio/samples/Dsharp1v12.m4a b/src/audio/samples/Dsharp1v12.m4a new file mode 100644 index 0000000..22d0924 Binary files /dev/null and b/src/audio/samples/Dsharp1v12.m4a differ diff --git a/src/audio/samples/Dsharp2v12.m4a b/src/audio/samples/Dsharp2v12.m4a new file mode 100644 index 0000000..f25db22 Binary files /dev/null and b/src/audio/samples/Dsharp2v12.m4a differ diff --git a/src/audio/samples/Dsharp3v12.m4a b/src/audio/samples/Dsharp3v12.m4a new file mode 100644 index 0000000..7e09558 Binary files /dev/null and b/src/audio/samples/Dsharp3v12.m4a differ diff --git a/src/audio/samples/Dsharp4v12.m4a b/src/audio/samples/Dsharp4v12.m4a new file mode 100644 index 0000000..d670fbb Binary files /dev/null and b/src/audio/samples/Dsharp4v12.m4a differ diff --git a/src/audio/samples/Dsharp5v12.m4a b/src/audio/samples/Dsharp5v12.m4a new file mode 100644 index 0000000..cdbd7b8 Binary files /dev/null and b/src/audio/samples/Dsharp5v12.m4a differ diff --git a/src/audio/samples/Dsharp6v12.m4a b/src/audio/samples/Dsharp6v12.m4a new file mode 100644 index 0000000..b5ff787 Binary files /dev/null and b/src/audio/samples/Dsharp6v12.m4a differ diff --git a/src/audio/samples/Dsharp7v12.m4a b/src/audio/samples/Dsharp7v12.m4a new file mode 100644 index 0000000..a9b6cda Binary files /dev/null and b/src/audio/samples/Dsharp7v12.m4a differ diff --git a/src/audio/samples/Fsharp1v12.m4a b/src/audio/samples/Fsharp1v12.m4a new file mode 100644 index 0000000..752590f Binary files /dev/null and b/src/audio/samples/Fsharp1v12.m4a differ diff --git a/src/audio/samples/Fsharp2v12.m4a b/src/audio/samples/Fsharp2v12.m4a new file mode 100644 index 0000000..3477cb8 Binary files /dev/null and b/src/audio/samples/Fsharp2v12.m4a differ diff --git a/src/audio/samples/Fsharp3v12.m4a b/src/audio/samples/Fsharp3v12.m4a new file mode 100644 index 0000000..d36f8dd Binary files /dev/null and b/src/audio/samples/Fsharp3v12.m4a differ diff --git a/src/audio/samples/Fsharp4v12.m4a b/src/audio/samples/Fsharp4v12.m4a new file mode 100644 index 0000000..21df1e2 Binary files /dev/null and b/src/audio/samples/Fsharp4v12.m4a differ diff --git a/src/audio/samples/Fsharp5v12.m4a b/src/audio/samples/Fsharp5v12.m4a new file mode 100644 index 0000000..1105dfb Binary files /dev/null and b/src/audio/samples/Fsharp5v12.m4a differ diff --git a/src/audio/samples/Fsharp6v12.m4a b/src/audio/samples/Fsharp6v12.m4a new file mode 100644 index 0000000..d141d41 Binary files /dev/null and b/src/audio/samples/Fsharp6v12.m4a differ diff --git a/src/audio/samples/Fsharp7v12.m4a b/src/audio/samples/Fsharp7v12.m4a new file mode 100644 index 0000000..d69ac59 Binary files /dev/null and b/src/audio/samples/Fsharp7v12.m4a differ diff --git a/src/audio/samples/README.md b/src/audio/samples/README.md new file mode 100644 index 0000000..bdde746 --- /dev/null +++ b/src/audio/samples/README.md @@ -0,0 +1,16 @@ +Piano samples are Salamander Grand Piano V3 samples by Alexander Holm, +transcoded from OGG Vorbis to AAC M4A for iOS browser playback and distributed +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 new file mode 100644 index 0000000..e0e6b19 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,199 @@ +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'; + +export { + normalizeNumberControlValue, + normalizeRuntimeSettings, +} from './config/normalize-runtime-settings'; + +export type { + GardenAppConfig, + GardenRuntimeSettings, + NumberControlConfig, +} from './config/types'; + +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, + }, + exportSnapshot: { + bytesPerPixel: 4, + filenameExtension: 'png', + filenamePrefix: 'fleeting-garden', + filenameSuffix: '-snapshot', + mimeType: 'image/png', + rowAlignmentBytes: 256, + }, + menuHider: { + bottomRevealDistancePx: 96, + desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)', + hideDelayMs: 3000, + }, + pipelines: { + common: { + noiseChannelSeeds: [0, 1, 2, 3], + noiseClearValue: { r: 1, g: 1, b: 1, a: 1 }, + noiseDrawInstanceCount: 1, + noiseDrawVertexCount: 3, + noiseHashMultiplier: 43758.5453123, + noiseHashX: 12.9898, + noiseHashY: 78.233, + noiseTextureFormat: 'r8unorm', + noiseTextureSize: 2048, + }, + brush: { + maxLineCount: 240, + }, + diffusion: { + minDiffusionRate: 0.000001, + }, + eraser: { + maxTextureLineCount: 384, + }, + }, + defaultSettings, + runtimeSettings: { + controls: runtimeControls, + }, + simulation: { + brushEffectFramesPerSecond: 60, + clearColor: { r: 0, g: 0, b: 0, a: 0 }, + initialAgentCount: 180_000, + // How long the source map continues to be diffused after a brush stroke ends. + // 600 frames at ~60 FPS ≈ 10 seconds. + sourceActiveFramesAfterWrite: 600, + intro: { + angleJitterRadians: Math.PI * 0.08, + angleEaseEnd: 1, + angleEaseStart: 0.6, + circleMaxSideRatio: 0.46, + circleMinSideRatio: 0.32, + drawHintDelayMs: 3000, + durationSeconds: 4, + entryJitterSideRatio: 0.035, + fontScaleDown: 0.94, + fontFamily: '"Open Sans", sans-serif', + initialFontHeightRatio: 0.28, + initialFontWidthRatio: 0.19, + 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', + pathProgressEpsilon: 0.001, + radialJitterRatio: 0.35, + radialStartEpsilon: 0.001, + resizeMinimumRemainingSeconds: 1.4, + resizeSettleMs: 120, + targetDelayDistanceMultiplier: 0.12, + targetDelayMax: 0.22, + targetDelayRandomMultiplier: 0.06, + targetJitterSideRatio: 0.0035, + title: 'Fleeting', + titleColorCutLetters: [2, 5], + titleRadiusMultiplier: 1.55, + titleStrokeWidthMinPx: 6, + titleStrokeWidthRatio: 0.11, + verticalAnchor: 0.47, + }, + introMoveSpeed: 280, + stroke: { + densityMultiplier: 110, + maxAgentCount: 2_400, + }, + }, + storage: { + 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: 480, + min: 24, + step: 1, + }, + mirror: { + default: 8, + fallbackSegmentName: 'slices', + max: 12, + min: 1, + names: { + 2: 'halves', + 3: 'thirds', + 4: 'quarters', + 5: 'fifths', + 6: 'sixths', + 7: 'sevenths', + 8: 'eighths', + 9: 'ninths', + 10: 'tenths', + 11: 'elevenths', + 12: 'twelfths', + }, + offLabel: 'Mirror off', + step: 1, + }, + contrast: { + backgroundOpacityMax: 0.82, + brightLuminanceThreshold: 0.32, + brightWeight: 0.65, + bytesPerSample: 4, + contrastOffset: 0.05, + linearChannelBreakpoint: 0.03928, + linearChannelDivisor: 12.92, + linearChannelGamma: 2.4, + linearChannelOffset: 0.055, + linearChannelScale: 1.055, + lowContrastThreshold: 3, + lowContrastWeight: 1.8, + luminanceBase: 0.11, + luminanceBlueWeight: 0.0722, + luminanceGreenWeight: 0.7152, + luminanceRange: 0.28, + luminanceRedWeight: 0.2126, + sampleColumns: 13, + sampleIntervalMs: 300, + sampleRows: 7, + whiteContrastNumerator: 1.05, + }, + volume: { + default: DEFAULT_AUDIO_VOLUME, + max: 1, + min: 0, + step: 0.01, + }, + }, + tuningPane: { + showFpsOverlay: import.meta.env.DEV, + startHidden: true, + title: 'Garden Settings', + }, + vibes: { + defaultVibeId, + presets: vibePresets, + }, +} satisfies GardenAppConfig; 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 new file mode 100644 index 0000000..c84e61d --- /dev/null +++ b/src/config/color-interactions.ts @@ -0,0 +1,14 @@ +import type { NumberControlConfig } from './types'; + +export const colorInteractionControl = (label: string): NumberControlConfig => ({ + folder: 'Color Reactions', + label, + min: -1, + max: 1, + step: 1, + options: { + 'Move Toward': 1, + Ignore: 0, + 'Move Away': -1, + }, +}); diff --git a/src/config/default-settings.ts b/src/config/default-settings.ts new file mode 100644 index 0000000..37ce510 --- /dev/null +++ b/src/config/default-settings.ts @@ -0,0 +1,74 @@ +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 computeDefaultInternalRenderAreaMegapixels = (): number => { + const rawDpr = + typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio) + ? window.devicePixelRatio + : 1; + const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP); + const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920; + const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080; + const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000; + return Math.min( + INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max, + Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels) + ); +}; + +export const defaultSettings: GardenAppConfig['defaultSettings'] = { + selectedColorIndex: 0, + + introNearDistanceMin: 28, + introNearDistanceInner: 4, + introNearSensorOffsetMultiplier: 0.75, + introTargetAngleBlend: 0.2, + introProgressCutoff: 0.999, + introTurnRateMultiplier: 3.4, + introRandomTurnMultiplier: 0.18, + introStepStopDistance: 0.5, + randomTimeScale: 0.34816, + + diffusionRateTrails: 0.22, + decayRateBrush: 18, + diffusionDecayRateDivisor: 1000, + diffusionNeighborDivisor: 8, + brushDecayAlphaOffset: 1.001, + brushEffectDuration: 8, + + brushCurveResolution: 12, + brushCurveMinBrushRadius: 1, + brushCurveMinSegmentSpacing: 4, + brushCurveMirrorResolutionExponent: 0.5, + brushCurveSegmentBrushRadiusRatio: 0.65, + brushSmoothingMinSampleDistance: 0.5, + + brushAlpha: 1, + brushDiscardThreshold: 0.02, + brushGrainNoiseScale: 22, + brushGrainNoiseOffsetX: 0.31, + brushGrainNoiseOffsetY: 0.67, + brushGrainMinStrength: 0.45, + brushGrainMaxStrength: 1, + + eraserClearAlpha: 0, + eraserClearBlue: 0, + eraserClearGreen: 0, + eraserClearRed: 0, + eraserLineDistanceEpsilon: 0.0001, + eraserMaskAlphaThreshold: 0.5, + + adaptiveCapInitial: 1_000_000, + adaptiveCapMin: 50_000, + internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(), + maxAgentCount: 1_500_000, + + renderTraceNormalizationFloor: 1, + renderBrushColorBase: 1.2, + renderBrushColorStrengthMultiplier: 1.6, +}; diff --git a/src/config/normalize-runtime-settings.test.ts b/src/config/normalize-runtime-settings.test.ts new file mode 100644 index 0000000..259ff17 --- /dev/null +++ b/src/config/normalize-runtime-settings.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeNumberControlValue, + normalizeRuntimeSettings, +} from './normalize-runtime-settings'; +import type { GardenRuntimeSettings } from './types'; + +describe('normalizeNumberControlValue', () => { + it('clamps and rounds numeric controls', () => { + expect( + normalizeNumberControlValue(12.6, { + folder: 'Test', + integer: true, + max: 10, + min: 0, + }) + ).toBe(10); + + expect( + normalizeNumberControlValue(Number.NaN, { + folder: 'Test', + min: 3, + }) + ).toBe(3); + }); + + it('keeps only declared option values', () => { + expect( + normalizeNumberControlValue(2, { + folder: 'Test', + options: { off: 0, on: 2 }, + }) + ).toBe(2); + + expect( + normalizeNumberControlValue(3, { + folder: 'Test', + options: { off: 0, on: 2 }, + }) + ).toBe(0); + }); +}); + +describe('normalizeRuntimeSettings', () => { + it('normalizes configured runtime keys and leaves hidden keys alone', () => { + const settings = { + brushSize: 99, + selectedColorIndex: 7, + } as GardenRuntimeSettings; + + expect( + normalizeRuntimeSettings(settings, { + brushSize: { + folder: 'Brush', + max: 12, + min: 1, + }, + }) + ).toMatchObject({ + brushSize: 12, + selectedColorIndex: 7, + }); + }); +}); diff --git a/src/config/normalize-runtime-settings.ts b/src/config/normalize-runtime-settings.ts new file mode 100644 index 0000000..ec4dcf5 --- /dev/null +++ b/src/config/normalize-runtime-settings.ts @@ -0,0 +1,46 @@ +import type { + GardenAppConfig, + GardenRuntimeSettings, + NumberControlConfig, +} from './types'; + +type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls']; + +export const normalizeNumberControlValue = ( + value: number, + config: NumberControlConfig +): number => { + if (config.options) { + const optionValues = Object.values(config.options); + if (optionValues.includes(value)) { + return value; + } + return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0); + } + + const min = config.min ?? Number.NEGATIVE_INFINITY; + const max = config.max ?? Number.POSITIVE_INFINITY; + const fallbackValue = config.min ?? 0; + const finiteValue = Number.isFinite(value) ? value : fallbackValue; + const clampedValue = Math.min(max, Math.max(min, finiteValue)); + return config.integer ? Math.round(clampedValue) : clampedValue; +}; + +export const normalizeRuntimeSettings = ( + settings: GardenRuntimeSettings, + controls: RuntimeSettingControls +): GardenRuntimeSettings => { + const normalized = { ...settings }; + + ( + Object.entries(controls) as Array< + [keyof GardenRuntimeSettings, NumberControlConfig | undefined] + > + ).forEach(([key, config]) => { + if (config) { + normalized[key] = normalizeNumberControlValue(normalized[key], config); + } + }); + + return normalized; +}; diff --git a/src/config/runtime-controls.ts b/src/config/runtime-controls.ts new file mode 100644 index 0000000..e6b8a70 --- /dev/null +++ b/src/config/runtime-controls.ts @@ -0,0 +1,148 @@ +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'), + color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'), + color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'), + color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'), + color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'), + color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'), + color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'), + color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'), + color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'), + + brushSize: { + folder: 'Brush', + label: 'Brush Size', + min: 1, + max: 36, + step: 0.25, + }, + spawnPerPixel: { + folder: 'Brush', + label: 'Density', + min: 0.01, + max: 0.38, + step: 0.001, + }, + strokeAngleJitterRadians: { + folder: 'Brush', + format: formatRadiansAsDegrees, + label: 'Spawn Spread', + min: 0, + max: Math.PI, + step: 0.01, + }, + sensorOffsetDistance: { + folder: 'Movement', + label: 'Sensor Reach', + min: 0, + max: 200, + step: 1, + }, + sensorOffsetAngle: { + folder: 'Movement', + label: 'Sensor Angle', + min: 0, + max: 180, + step: 1, + }, + moveSpeed: { + folder: 'Movement', + label: 'Travel Speed', + min: 10, + max: 250, + step: 1, + }, + turnSpeed: { + folder: 'Movement', + label: 'Turning Speed', + min: 1, + max: 200, + step: 1, + }, + forwardRotationScale: { + folder: 'Movement', + format: formatPercent, + label: 'Forward Focus', + min: 0, + max: 1, + step: 0.01, + }, + turnWhenLost: { + folder: 'Movement', + label: 'Wander Turn', + min: 0, + max: Math.PI * 2, + step: 0.01, + }, + individualTrailWeight: { + folder: 'Movement', + label: 'Trail Strength', + min: 0, + 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', + min: 800, + max: 1000, + step: 1, + }, + + clarity: { + folder: 'Look', + inverted: true, + label: 'Sharpness', + min: 0.00001, + max: 1, + step: 0.001, + }, + backgroundGrainStrength: { + folder: 'Look', + label: 'Background Grain', + min: 0, + max: 0.12, + step: 0.001, + }, + + maxAgentCount: { + folder: 'Performance', + format: formatCompactNumber, + integer: true, + label: 'Population Limit', + min: 0, + step: 10_000, + }, + internalRenderAreaMegapixels: { + folder: 'Performance', + label: 'Render Quality (MP)', + min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, + max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max, + step: 0.1, + }, +}; diff --git a/src/config/runtime-setting-bounds.ts b/src/config/runtime-setting-bounds.ts new file mode 100644 index 0000000..5400912 --- /dev/null +++ b/src/config/runtime-setting-bounds.ts @@ -0,0 +1,4 @@ +export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = { + min: 0.5, + max: 16.6, +} as const; diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..6f9aa29 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,269 @@ +import type { + GardenAudioConfig, + GardenAudioVibeSettings, +} from '../audio/garden-audio-config'; +import type { AgentSettings } from '../pipelines/agents/agent-pipeline'; +import type { BrushSettings } from '../pipelines/brush/brush-pipeline'; +import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline'; +import type { RenderSettings } from '../pipelines/render/render-pipeline'; +import type { RgbColor } from '../utils/rgb-color'; + +export interface NumberControlConfig { + format?: (value: number) => string; + folder: string; + integer?: boolean; + inverted?: boolean; + label?: string; + max?: number; + min?: number; + options?: Record; + step?: number; +} + +export type GardenRuntimeSettings = { + adaptiveCapInitial: number; + adaptiveCapMin: number; + backgroundGrainStrength: number; + brushCurveResolution: number; + brushCurveMinBrushRadius: number; + brushCurveMinSegmentSpacing: number; + brushCurveMirrorResolutionExponent: number; + brushCurveSegmentBrushRadiusRatio: number; + brushEffectDuration: number; + brushSmoothingMinSampleDistance: number; + eraserClearAlpha: number; + eraserClearBlue: number; + eraserClearGreen: number; + eraserClearRed: number; + eraserLineDistanceEpsilon: number; + eraserMaskAlphaThreshold: number; + eraserSize: number; + internalRenderAreaMegapixels: number; + mirrorSegmentCount: number; + maxAgentCount: number; + selectedColorIndex: number; + spawnPerPixel: number; + strokeAngleJitterRadians: number; +} & AgentSettings & + BrushSettings & + DiffusionSettings & + RenderSettings; + +type RuntimeSettingControlConfig = Partial< + Record +>; + +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< + GardenRuntimeSettings, + keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount' +>; + +export enum VibeId { + AuroraMycelium = 'aurora-mycelium', + VelvetObservatory = 'velvet-observatory', + LichenSignal = 'lichen-signal', + TidepoolLantern = 'tidepool-lantern', + PaperLanternFog = 'paper-lantern-fog', + ChromePollen = 'chrome-pollen', +} + +export interface VibePreset { + id: VibeId; + name: string; + colors: [RgbColor, RgbColor, RgbColor]; + backgroundColor: RgbColor; + settings: GardenVibeSettings; + audio: GardenAudioVibeSettings; +} + +export interface GardenAppConfig { + audio: GardenAudioConfig; + analytics: { + autoCapturePageviews: boolean; + domain: string; + endpoint: string; + logging: boolean; + }; + deltaTime: { + maxDeltaTimeSeconds: number; + minDeltaTimeSeconds: number; + }; + exportSnapshot: { + bytesPerPixel: number; + filenameExtension: string; + filenamePrefix: string; + filenameSuffix: string; + mimeType: string; + rowAlignmentBytes: number; + }; + menuHider: { + bottomRevealDistancePx: number; + desktopMediaQuery: string; + hideDelayMs: number; + }; + pipelines: { + common: { + noiseChannelSeeds: [number, number, number, number]; + noiseClearValue: GPUColor; + noiseDrawInstanceCount: number; + noiseDrawVertexCount: number; + noiseHashMultiplier: number; + noiseHashX: number; + noiseHashY: number; + noiseTextureFormat: GPUTextureFormat; + noiseTextureSize: number; + }; + brush: { + maxLineCount: number; + }; + diffusion: { + minDiffusionRate: number; + }; + eraser: { + maxTextureLineCount: number; + }; + }; + defaultSettings: GardenDefaultSettings; + runtimeSettings: { + controls: RuntimeSettingControlConfig; + }; + simulation: { + brushEffectFramesPerSecond: number; + clearColor: GPUColor; + initialAgentCount: number; + sourceActiveFramesAfterWrite: number; + intro: { + angleJitterRadians: number; + angleEaseEnd: number; + angleEaseStart: number; + circleMaxSideRatio: number; + circleMinSideRatio: number; + drawHintDelayMs: number; + durationSeconds: number; + entryJitterSideRatio: number; + fontScaleDown: number; + fontFamily: string; + initialFontHeightRatio: number; + initialFontWidthRatio: number; + letterSpacingEm: number; + maskAlphaThreshold: number; + maskGradientThreshold: number; + maskMaxPixels: number; + maskSampleDensity: number; + maxHeightRatio: number; + maxWidthRatio: number; + minEntryJitterPx: number; + minFontSizePx: number; + minTargetJitterPx: number; + pathEasing: 'easeOutQuad' | 'linear'; + pathProgressEpsilon: number; + radialJitterRatio: number; + radialStartEpsilon: number; + resizeMinimumRemainingSeconds: number; + resizeSettleMs: number; + targetDelayDistanceMultiplier: number; + targetDelayMax: number; + targetDelayRandomMultiplier: number; + targetJitterSideRatio: number; + title: string; + titleColorCutLetters: [number, number]; + titleRadiusMultiplier: number; + titleStrokeWidthMinPx: number; + titleStrokeWidthRatio: number; + verticalAnchor: number; + }; + introMoveSpeed: number; + stroke: { + densityMultiplier: number; + maxAgentCount: number; + }; + }; + storage: { + audioMutedKey: string; + audioVolumeKey: string; + vibeKey: string; + }; + toolbar: { + eraser: { + controlScaleMax: number; + controlScaleMin: number; + default: number; + max: number; + min: number; + step: number; + }; + mirror: { + default: number; + fallbackSegmentName: string; + max: number; + min: number; + names: Record; + offLabel: string; + step: number; + }; + contrast: { + backgroundOpacityMax: number; + brightLuminanceThreshold: number; + brightWeight: number; + bytesPerSample: number; + contrastOffset: number; + linearChannelBreakpoint: number; + linearChannelDivisor: number; + linearChannelGamma: number; + linearChannelOffset: number; + linearChannelScale: number; + lowContrastThreshold: number; + lowContrastWeight: number; + luminanceBase: number; + luminanceBlueWeight: number; + luminanceGreenWeight: number; + luminanceRange: number; + luminanceRedWeight: number; + sampleColumns: number; + sampleIntervalMs: number; + sampleRows: number; + whiteContrastNumerator: number; + }; + volume: { + default: number; + max: number; + min: number; + step: number; + }; + }; + tuningPane: { + showFpsOverlay: boolean; + startHidden: boolean; + title: string; + }; + vibes: { + defaultVibeId: VibeId; + presets: Array; + }; +} 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 new file mode 100644 index 0000000..7a30359 --- /dev/null +++ b/src/config/vibe-presets.ts @@ -0,0 +1,366 @@ +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 Copy', + colors: [ + [251, 210, 94], + [154, 99, 255], + [255, 31, 199], + ], + backgroundColor: [6, 13, 22], + settings: { + ...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.12000000000000002, + bpm: 60, + rampUpIntensity: 0.7, + rampUpTime: 0.14, + noteLength: 0.8599999999999999, + notePitchOffset: -2, + brightness: 0.84, + scale: musicScales.lydian, + progression: musicProgressions.aurora, + }, + }, + { + id: VibeId.VelvetObservatory, + name: 'Velvet Observatory Copy', + colors: [ + [178, 76, 62], + [2, 174, 255], + [213, 193, 9], + ], + backgroundColor: [7, 4, 22], + settings: { + ...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.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: [ + [183, 216, 92], + [65, 166, 128], + [238, 120, 76], + ], + backgroundColor: [0, 0, 0], + settings: { + ...colorReactions.lichenSignal, + backgroundGrainStrength: 0.02, + brushSize: 6.5, + clarity: 0.74, + decayRateTrails: 962, + forwardRotationScale: 0.3, + individualTrailWeight: 0.052, + moveSpeed: 72, + sensorOffsetAngle: 42, + sensorOffsetDistance: 54, + spawnPerPixel: 0.16, + strokeAngleJitterRadians: 3.14, + turnSpeed: 44, + turnWhenLost: 0.92, + }, + audio: { + ...defaultGardenAudioVibeSettings, + 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, + }, + }, + { + id: VibeId.TidepoolLantern, + name: 'Tidepool Lantern', + colors: [ + [30, 219, 194], + [61, 118, 255], + [255, 191, 91], + ], + backgroundColor: [4, 18, 29], + settings: { + ...colorReactions.tidepoolLantern, + backgroundGrainStrength: 0.018, + brushSize: 17, + clarity: 0.56, + decayRateTrails: 968, + forwardRotationScale: 0.38, + individualTrailWeight: 0.06, + moveSpeed: 88, + sensorOffsetAngle: 64, + sensorOffsetDistance: 46, + spawnPerPixel: 0.22, + strokeAngleJitterRadians: 1.8, + turnSpeed: 66, + turnWhenLost: 1.05, + }, + audio: { + ...defaultGardenAudioVibeSettings, + idleIntensity: 0.08, + bpm: 84, + rampUpIntensity: 0.95, + rampUpTime: 0.08, + noteLength: 0.46, + notePitchOffset: 0, + brightness: 0.98, + scale: musicScales.mixolydian, + progression: musicProgressions.tidepool, + }, + }, + { + id: VibeId.PaperLanternFog, + name: 'Paper Lantern Fog', + colors: [ + [255, 176, 108], + [239, 90, 108], + [128, 213, 184], + ], + backgroundColor: [30, 23, 20], + settings: { + ...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.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: [ + [178, 34, 34], + [255, 214, 48], + [77, 240, 157], + ], + backgroundColor: [7, 12, 11], + settings: { + ...colorReactions.chromePollen, + backgroundGrainStrength: 0.012, + 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.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/constants.ts b/src/constants.ts deleted file mode 100644 index 24bf445..0000000 --- a/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const isProduction: boolean = import.meta.env.PROD; diff --git a/src/game-loop/agent-population.test.ts b/src/game-loop/agent-population.test.ts new file mode 100644 index 0000000..2bc0e26 --- /dev/null +++ b/src/game-loop/agent-population.test.ts @@ -0,0 +1,178 @@ +import { vec2 } from 'gl-matrix'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { appConfig } from '../config'; +import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits'; +import { settings } from '../settings'; +import { AgentPopulation } from './agent-population'; +import { type FramePerformance } from './frame-performance'; + +const originalSettings = { + brushSize: settings.brushSize, + maxAgentCount: settings.maxAgentCount, + selectedColorIndex: settings.selectedColorIndex, + spawnPerPixel: settings.spawnPerPixel, + strokeAngleJitterRadians: settings.strokeAngleJitterRadians, +}; + +class RecordingAgentGenerationPipeline { + public readonly writtenAgentCounts: Array = []; + public readonly writtenAgentOffsets: Array = []; + public readonly writtenBatches: Array = []; + public readonly maxSupportedAgentCount = 1_000_000; + public maxAgentCount = 1_000_000; + private compactResolver: ((compactedAgentCount: number) => void) | null = null; + + public ensureMaxAgentCount(requestedMaxAgentCount: number): number { + this.maxAgentCount = Math.max(this.maxAgentCount, requestedMaxAgentCount); + return this.maxAgentCount; + } + + public writeAgents(agentOffset: number, data: Float32Array): void { + this.writtenAgentOffsets.push(agentOffset); + this.writtenAgentCounts.push(data.length / AGENT_FLOAT_COUNT); + this.writtenBatches.push(data.slice()); + } + + public compactAgents(): Promise { + return new Promise((resolve) => { + this.compactResolver = resolve; + }); + } + + public finishCompaction(compactedAgentCount: number): void { + this.compactResolver?.(compactedAgentCount); + this.compactResolver = null; + } +} + +const framePerformance = { + adaptiveCapDecreaseAgents: 0, + adaptiveCapInitial: 1_000_000, + adaptiveCapMin: 0, + hasAdaptiveCapHeadroom: true, +} as FramePerformance; + +const createPopulation = (): { + pipeline: RecordingAgentGenerationPipeline; + population: AgentPopulation; +} => { + const pipeline = new RecordingAgentGenerationPipeline(); + const population = new AgentPopulation( + pipeline as unknown as AgentGenerationPipeline, + 0, + () => 1, + framePerformance + ); + population.beginStroke(); + return { pipeline, population }; +}; + +const setSpawnRate = (agentsPerPixel: number): void => { + settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier; +}; + +describe('AgentPopulation stroke spawning', () => { + beforeEach(() => { + settings.brushSize = 0; + settings.maxAgentCount = 1_000_000; + settings.selectedColorIndex = 0; + settings.strokeAngleJitterRadians = 0; + setSpawnRate(1); + }); + + afterEach(() => { + Object.assign(settings, originalSettings); + }); + + it('spawns the same count for the same stroke length regardless of segmentation', () => { + const segmented = createPopulation(); + for (let x = 0; x < 10; x++) { + segmented.population.spawnStrokeAgents( + vec2.fromValues(x, 0), + vec2.fromValues(x + 1, 0) + ); + } + + const singleSegment = createPopulation(); + singleSegment.population.spawnStrokeAgents( + vec2.fromValues(0, 0), + vec2.fromValues(10, 0) + ); + + expect(segmented.population.activeAgentCount).toBe(10); + expect(singleSegment.population.activeAgentCount).toBe(10); + }); + + it('carries fractional spawn budget within a stroke', () => { + setSpawnRate(0.5); + const { population } = createPopulation(); + + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0)); + expect(population.activeAgentCount).toBe(0); + + population.spawnStrokeAgents(vec2.fromValues(1, 0), vec2.fromValues(2, 0)); + expect(population.activeAgentCount).toBe(1); + + population.spawnStrokeAgents(vec2.fromValues(2, 0), vec2.fromValues(3, 0)); + expect(population.activeAgentCount).toBe(1); + + population.spawnStrokeAgents(vec2.fromValues(3, 0), vec2.fromValues(4, 0)); + expect(population.activeAgentCount).toBe(2); + }); + + it('chunks long stroke writes without clipping length-linear spawn counts', () => { + const { pipeline, population } = createPopulation(); + const batchCapacity = appConfig.simulation.stroke.maxAgentCount; + const expectedAgentCount = batchCapacity + 10; + + population.spawnStrokeAgents( + vec2.fromValues(0, 0), + vec2.fromValues(expectedAgentCount, 0) + ); + + expect(population.activeAgentCount).toBe(expectedAgentCount); + expect(pipeline.writtenAgentCounts).toEqual([batchCapacity, 10]); + }); + + it('spawns agents in the movement direction', () => { + const { pipeline, population } = createPopulation(); + + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0)); + + expect(population.activeAgentCount).toBe(3); + expect(pipeline.writtenBatches[0][2]).toBe(0); + }); + + it('clears active agents when an intro replacement has no generated agents', () => { + const { population } = createPopulation(); + + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0)); + expect(population.activeAgentCount).toBe(3); + + settings.maxAgentCount = 0; + population.replaceIntroAgents(vec2.fromValues(100, 100), 0); + + expect(population.activeAgentCount).toBe(0); + }); + + it('queues stroke writes while async compaction is in flight', async () => { + const { pipeline, population } = createPopulation(); + + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(10, 0)); + population.requestCompactionAfterErase(); + population.compactAfterErase(false); + + population.spawnStrokeAgents(vec2.fromValues(10, 0), vec2.fromValues(15, 0)); + expect(population.activeAgentCount).toBe(10); + expect(pipeline.writtenAgentCounts).toEqual([10]); + + pipeline.finishCompaction(6); + await population.waitForCompaction(); + + expect(population.activeAgentCount).toBe(11); + expect(pipeline.writtenAgentOffsets).toEqual([0, 6]); + expect(pipeline.writtenAgentCounts).toEqual([10, 5]); + }); +}); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts new file mode 100644 index 0000000..1d0390f --- /dev/null +++ b/src/game-loop/agent-population.ts @@ -0,0 +1,339 @@ +import { vec2 } from 'gl-matrix'; + +import { appConfig } from '../config'; +import { getRenderQualityBrushSize } from '../config/brush-size'; +import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; +import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; +import { settings } from '../settings'; +import type { FramePerformance } from './frame-performance'; +import { createIntroTitleAgents } from './intro-title-agents'; + +export class AgentPopulation { + private activeCount = 0; + // Current performance-aware limit; new agents above it replace old agents. + private adaptiveCap: number; + // Next active agent slot to overwrite when new agents exceed the current cap. + private replacementCursor = 0; + private canExpandAdaptiveCap = true; + private shouldCompactAfterErase = false; + private isCompacting = false; + private pendingCompaction: Promise | null = null; + private readonly queuedAgentBatches: Array = []; + private pendingStrokeAgentCount = 0; + private readonly strokeAgentData = new Float32Array( + appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT + ); + + public constructor( + private readonly pipeline: AgentGenerationPipeline, + private readonly introSeed: number, + private readonly getCanvasPixelRatio: () => number, + private readonly framePerformance: FramePerformance + ) { + this.adaptiveCap = this.clampAndEnsureAdaptiveCap( + this.framePerformance.adaptiveCapInitial + ); + } + + public get activeAgentCount(): number { + return this.activeCount; + } + + public initializeIntroAgents(canvasSize: vec2): void { + this.replaceIntroAgents(canvasSize, 0); + } + + public replaceIntroAgents(canvasSize: vec2, progress: number): void { + this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); + const introAgentCount = Math.min( + this.adaptiveCap, + appConfig.simulation.initialAgentCount + ); + const data = createIntroTitleAgents({ + count: introAgentCount, + width: canvasSize[0], + height: canvasSize[1], + progress, + seed: this.introSeed, + }); + + if (data.length === 0) { + this.activeCount = 0; + this.replacementCursor = 0; + return; + } + + this.pipeline.writeAgents(0, data); + this.activeCount = data.length / AGENT_FLOAT_COUNT; + this.replacementCursor = 0; + } + + public onVibeChanged(): void { + this.pendingStrokeAgentCount = 0; + this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); + this.trimActiveCountToBudget(); + } + + public beginStroke(): void { + this.pendingStrokeAgentCount = 0; + } + + public resizeAgents(scale: vec2): void { + this.pipeline.resizeAgents(this.activeCount, scale); + } + + public requestCompactionAfterErase(): void { + this.shouldCompactAfterErase = true; + } + + public compactAfterErase(isSwipeActive: boolean): void { + if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) { + return; + } + + this.shouldCompactAfterErase = false; + if (this.activeCount === 0) { + return; + } + + this.isCompacting = true; + this.pendingCompaction = this.pipeline + .compactAgents(this.activeCount) + .then((compactedAgentCount) => { + const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount) + ? Math.max(0, Math.floor(compactedAgentCount)) + : 0; + this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount); + this.clampReplacementCursor(); + this.trimActiveCountToBudget(); + }) + .catch((error: unknown) => { + console.warn('Could not compact agents after erase.', error); + }) + .finally(() => { + this.isCompacting = false; + this.pendingCompaction = null; + this.flushQueuedAgentBatches(); + }); + } + + public async waitForCompaction(): Promise { + await this.pendingCompaction; + } + + public updateAdaptiveCap(): void { + const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); + this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom; + + if (this.canExpandAdaptiveCap) { + this.adaptiveCap = previousCap; + this.trimActiveCountToBudget(); + return; + } + + const decrease = this.framePerformance.adaptiveCapDecreaseAgents; + const responsiveCap = Math.min( + previousCap, + this.clampAndEnsureAdaptiveCap(this.activeCount) + ); + const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease); + this.adaptiveCap = nextCap; + this.trimActiveCountToBudget(decrease); + } + + public spawnStrokeAgents(from: vec2, to: vec2): void { + const deltaX = to[0] - from[0]; + const deltaY = to[1] - from[1]; + const length = Math.hypot(deltaX, deltaY); + const spawnRate = getStrokeSpawnRate(); + if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) { + return; + } + + const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount; + if (!Number.isFinite(expectedAgentCount)) { + this.pendingStrokeAgentCount = 0; + return; + } + + const count = Math.floor(expectedAgentCount); + this.pendingStrokeAgentCount = expectedAgentCount - count; + + if (count <= 0) { + return; + } + + const baseAngle = Math.atan2(deltaY, deltaX); + const spread = + getRenderQualityBrushSize( + settings.brushSize, + settings.internalRenderAreaMegapixels + ) * getSafePixelRatio(this.getCanvasPixelRatio()); + const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT; + if (batchCapacity <= 0) { + return; + } + + for (let written = 0; written < count; written += batchCapacity) { + const batchCount = Math.min(batchCapacity, count - written); + this.populateStrokeAgentBatch({ + baseAngle, + batchCount, + from, + spread, + to, + totalCount: count, + written, + }); + this.writeAgentBatch( + this.strokeAgentData.subarray(0, batchCount * AGENT_FLOAT_COUNT) + ); + } + } + + private populateStrokeAgentBatch({ + baseAngle, + batchCount, + from, + spread, + to, + totalCount, + written, + }: { + baseAngle: number; + batchCount: number; + from: vec2; + spread: number; + to: vec2; + totalCount: number; + written: number; + }): void { + for (let i = 0; i < batchCount; i++) { + const agentIndex = written + i; + const t = totalCount === 1 ? 0.5 : agentIndex / (totalCount - 1); + const x = from[0] + (to[0] - from[0]) * t; + const y = from[1] + (to[1] - from[1]) * t; + const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians; + const positionX = x + (Math.random() - 0.5) * spread; + const positionY = y + (Math.random() - 0.5) * spread; + + writeAgentValues(this.strokeAgentData, i, { + positionX, + positionY, + angle, + colorIndex: settings.selectedColorIndex, + targetPositionX: -1, + targetPositionY: -1, + targetAngle: angle, + introDelay: 0, + }); + } + } + + private writeAgentBatch(data: Float32Array): void { + if (data.length === 0) { + return; + } + + if (this.isCompacting) { + this.queuedAgentBatches.push(data.slice()); + return; + } + + const count = data.length / AGENT_FLOAT_COUNT; + this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); + this.expandAdaptiveCapForPendingAgents(count); + + const available = Math.max(0, this.adaptiveCap - this.activeCount); + const appendCount = Math.min(count, available); + + if (appendCount > 0) { + this.pipeline.writeAgents( + this.activeCount, + data.subarray(0, appendCount * AGENT_FLOAT_COUNT) + ); + this.activeCount += appendCount; + } + + let sourceAgentOffset = appendCount; + while (sourceAgentOffset < count && this.activeCount > 0) { + const targetAgentOffset = this.replacementCursor % this.activeCount; + const chunkAgentCount = Math.min( + count - sourceAgentOffset, + this.activeCount - targetAgentOffset + ); + + this.pipeline.writeAgents( + targetAgentOffset, + data.subarray( + sourceAgentOffset * AGENT_FLOAT_COUNT, + (sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT + ) + ); + + sourceAgentOffset += chunkAgentCount; + this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount; + } + } + + private flushQueuedAgentBatches(): void { + const batches = this.queuedAgentBatches.splice(0); + batches.forEach((batch) => this.writeAgentBatch(batch)); + } + + private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void { + const available = Math.max(0, this.adaptiveCap - this.activeCount); + if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) { + return; + } + + const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); + const pendingAgentCount = requestedAgentCount - available; + this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount); + } + + private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void { + if (this.activeCount <= this.adaptiveCap) { + return; + } + + this.activeCount = Math.max( + this.adaptiveCap, + this.activeCount - Math.max(1, Math.ceil(maxDecrease)) + ); + this.clampReplacementCursor(); + } + + private clampReplacementCursor(): void { + this.replacementCursor = + this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount; + } + + private clampAndEnsureAdaptiveCap(value: number): number { + const runtimeMaxCap = + settings.maxAgentCount === Number.POSITIVE_INFINITY + ? Number.POSITIVE_INFINITY + : Number.isFinite(settings.maxAgentCount) + ? Math.max(0, Math.floor(settings.maxAgentCount)) + : Math.max(0, Math.floor(this.pipeline.maxAgentCount)); + const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap); + const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap); + const finiteValue = Number.isFinite(value) ? value : minCap; + const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue))); + return Math.min( + nextCap, + this.pipeline.ensureMaxAgentCount(nextCap, this.activeCount) + ); + } +} + +const getStrokeSpawnRate = (): number => { + const spawnPerPixel = Number.isFinite(settings.spawnPerPixel) + ? settings.spawnPerPixel + : 0; + const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier) + ? appConfig.simulation.stroke.densityMultiplier + : 0; + return Math.max(0, spawnPerPixel * densityMultiplier); +}; diff --git a/src/game-loop/brush-stroke-smoother.ts b/src/game-loop/brush-stroke-smoother.ts new file mode 100644 index 0000000..3d45283 --- /dev/null +++ b/src/game-loop/brush-stroke-smoother.ts @@ -0,0 +1,155 @@ +import { vec2 } from 'gl-matrix'; + +import { appConfig } from '../config'; +import { getRenderQualityBrushSize } from '../config/brush-size'; +import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; +import { settings } from '../settings'; +import { type StrokeSegment } from './game-loop-types'; + +interface BrushStrokeSmootherOptions { + getCanvasPixelRatio: () => number; + getMirrorSegmentCount: () => number; +} + +export class BrushStrokeSmoother { + private readonly strokePoints: Array = []; + private lastBrushPosition: vec2 | null = null; + + public constructor(private readonly options: BrushStrokeSmootherOptions) {} + + public addSample(position: vec2): Array { + const previousSample = this.strokePoints[this.strokePoints.length - 1]; + if ( + previousSample !== undefined && + vec2.squaredDistance(previousSample, position) <= + getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio()) + ) { + return []; + } + + this.strokePoints.push(vec2.clone(position)); + + if (this.strokePoints.length > 3) { + this.strokePoints.shift(); + } + + if (this.strokePoints.length === 1) { + this.lastBrushPosition = vec2.clone(position); + return [{ from: position, to: position }]; + } + + if (this.strokePoints.length === 2) { + const [start, end] = this.strokePoints; + const midpoint = getMidpoint(start, end); + this.lastBrushPosition = midpoint; + return [{ from: start, to: midpoint }]; + } + + const [start, control, end] = this.strokePoints; + const curveStart = getMidpoint(start, control); + const curveEnd = getMidpoint(control, end); + this.lastBrushPosition = curveEnd; + return this.getQuadraticSegments(curveStart, control, curveEnd); + } + + public finish(): Array { + if (this.strokePoints.length === 0) { + return []; + } + + const finalSample = this.strokePoints[this.strokePoints.length - 1]; + if ( + this.lastBrushPosition !== null && + vec2.squaredDistance(this.lastBrushPosition, finalSample) > + getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio()) + ) { + return [{ from: this.lastBrushPosition, to: finalSample }]; + } + + return []; + } + + public clear(): void { + this.strokePoints.length = 0; + this.lastBrushPosition = null; + } + + public scale(scale: vec2): void { + this.strokePoints.forEach((point) => { + vec2.mul(point, point, scale); + }); + + if (this.lastBrushPosition !== null) { + vec2.mul(this.lastBrushPosition, this.lastBrushPosition, scale); + } + } + + private getQuadraticSegments( + start: vec2, + control: vec2, + end: vec2 + ): Array { + const curveLength = vec2.distance(start, control) + vec2.distance(control, end); + const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio()); + const brushSize = getRenderQualityBrushSize( + settings.brushSize, + settings.internalRenderAreaMegapixels + ); + const brushRadius = Math.max( + settings.brushCurveMinBrushRadius * canvasPixelRatio, + (brushSize * canvasPixelRatio) / 2 + ); + const segmentSpacing = Math.max( + settings.brushCurveMinSegmentSpacing * canvasPixelRatio, + brushRadius * settings.brushCurveSegmentBrushRadiusRatio + ); + const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount()); + const curveResolution = getBrushCurveResolution(); + const maxCurveSegments = Math.max( + 1, + Math.floor( + curveResolution / + Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent) + ) + ); + const segmentCount = Math.min( + maxCurveSegments, + Math.max(1, Math.ceil(curveLength / segmentSpacing)) + ); + + let previousPoint = start; + const segments: Array = []; + for (let i = 1; i <= segmentCount; i++) { + const point = getQuadraticPoint(start, control, end, i / segmentCount); + segments.push({ from: previousPoint, to: point }); + previousPoint = point; + } + + return segments; + } +} + +const getMidpoint = (from: vec2, to: vec2): vec2 => + vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2); + +const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => { + const inverseT = 1 - t; + return vec2.fromValues( + inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0], + inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1] + ); +}; + +const getBrushCurveResolution = (): number => { + const resolution = Number.isFinite(settings.brushCurveResolution) + ? settings.brushCurveResolution + : appConfig.defaultSettings.brushCurveResolution; + return Math.max(1, Math.floor(resolution)); +}; + +const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => { + const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance) + ? settings.brushSmoothingMinSampleDistance + : appConfig.defaultSettings.brushSmoothingMinSampleDistance; + return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2; +}; diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts new file mode 100644 index 0000000..fefd93c --- /dev/null +++ b/src/game-loop/eraser-preview.ts @@ -0,0 +1,122 @@ +import { settings } from '../settings'; + +export class EraserPreview { + private previewClientPosition: { x: number; y: number } | null = null; + private isErasing = false; + private isPointerHoveringCanvas = false; + private isSwipeActive = false; + private previousSize: number | null = null; + private previousLeft = ''; + private previousTop = ''; + private isVisible = false; + + public constructor( + private readonly canvas: HTMLCanvasElement, + private readonly element: HTMLElement, + private readonly getIsSwipeActive: () => boolean + ) {} + + public attach(): void { + this.canvas.addEventListener('pointerenter', this.onPointerEnter); + this.canvas.addEventListener('pointerleave', this.onPointerLeave); + this.canvas.addEventListener('pointerdown', this.onPointerDown); + this.canvas.addEventListener('pointermove', this.onPointerMove); + this.canvas.addEventListener('pointerup', this.onPointerUp); + this.canvas.addEventListener('pointercancel', this.onPointerUp); + } + + public detach(): void { + this.canvas.removeEventListener('pointerenter', this.onPointerEnter); + this.canvas.removeEventListener('pointerleave', this.onPointerLeave); + this.canvas.removeEventListener('pointerdown', this.onPointerDown); + this.canvas.removeEventListener('pointermove', this.onPointerMove); + this.canvas.removeEventListener('pointerup', this.onPointerUp); + this.canvas.removeEventListener('pointercancel', this.onPointerUp); + } + + public setEraseMode(isErasing: boolean): void { + this.isErasing = isErasing; + this.update(); + } + + public update(event?: PointerEvent): void { + this.isSwipeActive = this.getIsSwipeActive(); + + if (event) { + this.previewClientPosition = { + x: event.clientX, + y: event.clientY, + }; + } + + if (this.previousSize !== settings.eraserSize) { + this.element.style.setProperty('--eraser-preview-size', `${settings.eraserSize}px`); + this.previousSize = settings.eraserSize; + } + + if ( + !this.isErasing || + this.previewClientPosition === null || + (!this.isPointerHoveringCanvas && !this.isSwipeActive) + ) { + this.setVisible(false); + return; + } + + const rect = this.canvas.getBoundingClientRect(); + const left = `${this.previewClientPosition.x - rect.left}px`; + const top = `${this.previewClientPosition.y - rect.top}px`; + if (this.previousLeft !== left) { + this.element.style.left = left; + this.previousLeft = left; + } + if (this.previousTop !== top) { + this.element.style.top = top; + this.previousTop = top; + } + this.setVisible(true); + } + + private setVisible(isVisible: boolean): void { + if (this.isVisible === isVisible) { + return; + } + + this.isVisible = isVisible; + this.element.classList.toggle('visible', isVisible); + } + + private isPointerInsideCanvas(event: PointerEvent): boolean { + const rect = this.canvas.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); + } + + private readonly onPointerDown = (event: PointerEvent) => { + this.isPointerHoveringCanvas = true; + this.update(event); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + this.update(event); + }; + + private readonly onPointerUp = (event: PointerEvent) => { + this.isPointerHoveringCanvas = this.isPointerInsideCanvas(event); + this.update(event); + }; + + private readonly onPointerEnter = (event: PointerEvent) => { + this.isPointerHoveringCanvas = true; + this.update(event); + }; + + private readonly onPointerLeave = () => { + this.isPointerHoveringCanvas = false; + this.update(); + }; +} diff --git a/src/game-loop/export-snapshot-renderer.ts b/src/game-loop/export-snapshot-renderer.ts new file mode 100644 index 0000000..0be8911 --- /dev/null +++ b/src/game-loop/export-snapshot-renderer.ts @@ -0,0 +1,204 @@ +import { appConfig } from '../config'; +import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import type { VibeId } from '../vibes'; + +interface ExportSnapshotRendererOptions { + device: GPUDevice; + renderPipeline: RenderPipeline; + canvasFormat: GPUTextureFormat; + statusElement: HTMLElement; + seed: string; + getSourceSize: () => { width: number; height: number }; + getColorTextureView: () => GPUTextureView; + getSourceTextureView: () => GPUTextureView; + getSourceActive?: () => boolean; + getVibeId: () => VibeId; +} + +interface SnapshotLayout { + width: number; + height: number; + unpaddedBytesPerRow: number; + bytesPerRow: number; + readbackBufferBytes: number; +} + +export class ExportSnapshotRenderer { + private isExporting = false; + + public constructor(private readonly options: ExportSnapshotRendererOptions) {} + + public async export(): Promise { + if (this.isExporting) { + this.statusElement.textContent = 'Snapshot already saving...'; + return; + } + + this.isExporting = true; + this.statusElement.textContent = 'Saving snapshot...'; + + try { + const sourceSize = this.options.getSourceSize(); + await this.renderSnapshot(getSnapshotLayout(sourceSize.width, sourceSize.height)); + this.statusElement.textContent = ''; + } catch (error) { + this.statusElement.textContent = 'Snapshot failed'; + throw error; + } finally { + this.isExporting = false; + } + } + + private async renderSnapshot(layout: SnapshotLayout): Promise { + const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout; + let texture: GPUTexture | null = null; + let output: GPUBuffer | null = null; + let isOutputMapped = false; + + try { + texture = this.device.createTexture({ + size: { width, height }, + format: this.options.canvasFormat, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, + }); + output = this.device.createBuffer({ + size: layout.readbackBufferBytes, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + + const commandEncoder = this.device.createCommandEncoder(); + this.options.renderPipeline.executeToView( + commandEncoder, + this.options.getColorTextureView(), + this.options.getSourceTextureView(), + texture.createView(), + this.options.getSourceActive?.() ?? true + ); + commandEncoder.copyTextureToBuffer( + { texture }, + { buffer: output, bytesPerRow, rowsPerImage: height }, + { width, height } + ); + this.device.queue.submit([commandEncoder.finish()]); + + await output.mapAsync(GPUMapMode.READ); + isOutputMapped = true; + const pixels = readSnapshotPixels({ + mapped: new Uint8Array(output.getMappedRange()), + width, + height, + unpaddedBytesPerRow, + bytesPerRow, + isBgra: this.options.canvasFormat === 'bgra8unorm', + }); + output.unmap(); + isOutputMapped = false; + output.destroy(); + output = null; + texture.destroy(); + texture = null; + + await this.downloadPixels(pixels, width, height); + } finally { + if (output && isOutputMapped) { + output.unmap(); + } + output?.destroy(); + texture?.destroy(); + } + } + + private async downloadPixels( + pixels: Uint8ClampedArray, + width: number, + height: number + ): Promise { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not create export canvas'); + } + + context.putImageData(new ImageData(pixels, width, height), 0, 0); + const blob = await canvas.convertToBlob({ + type: appConfig.exportSnapshot.mimeType, + }); + const link = document.createElement('a'); + const objectUrl = URL.createObjectURL(blob); + try { + link.href = objectUrl; + link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${ + this.options.seed + }_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`; + link.click(); + } finally { + URL.revokeObjectURL(objectUrl); + } + } + + private get device(): GPUDevice { + return this.options.device; + } + + private get statusElement(): HTMLElement { + return this.options.statusElement; + } +} + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; + +const getSnapshotDimension = (value: number): number => + Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : 1; + +const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => { + const width = getSnapshotDimension(sourceWidth); + const height = getSnapshotDimension(sourceHeight); + const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel; + const bytesPerRow = alignTo( + unpaddedBytesPerRow, + appConfig.exportSnapshot.rowAlignmentBytes + ); + + return { + width, + height, + unpaddedBytesPerRow, + bytesPerRow, + readbackBufferBytes: bytesPerRow * height, + }; +}; + +const readSnapshotPixels = ({ + mapped, + width, + height, + unpaddedBytesPerRow, + bytesPerRow, + isBgra, +}: { + mapped: Uint8Array; + width: number; + height: number; + unpaddedBytesPerRow: number; + bytesPerRow: number; + isBgra: boolean; +}): Uint8ClampedArray => { + const pixels: Uint8ClampedArray = new Uint8ClampedArray( + unpaddedBytesPerRow * height + ); + for (let y = 0; y < height; y++) { + const sourceOffset = y * bytesPerRow; + const targetOffset = y * unpaddedBytesPerRow; + for (let x = 0; x < width; x++) { + const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel; + const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel; + pixels[target] = isBgra ? mapped[source + 2] : mapped[source]; + pixels[target + 1] = mapped[source + 1]; + pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2]; + pixels[target + 3] = mapped[source + 3]; + } + } + + return pixels; +}; diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts new file mode 100644 index 0000000..9fc249e --- /dev/null +++ b/src/game-loop/frame-performance.ts @@ -0,0 +1,61 @@ +import { settings } from '../settings'; + +const ADAPTIVE_REFRESH_TARGET_FPS = 60; +const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000; +const FRAME_GAP_RESET_SECONDS = 1; +const FPS_HEADROOM = 0.9; +const FPS_SMOOTHING_NEW = 0.06; +const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW; + +export class FramePerformance { + public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS; + public measuredFps = 0; + public frameDeltaSeconds = 0; + public measuredFrameTimeMs = 0; + + private previousFrameTime: DOMHighResTimeStamp | null = null; + + public get adaptiveCapInitial(): number { + return settings.adaptiveCapInitial; + } + + public get adaptiveCapMin(): number { + return settings.adaptiveCapMin; + } + + public get hasAdaptiveCapHeadroom(): boolean { + return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM; + } + + public get adaptiveCapDecreaseAgents(): number { + return Math.max( + 1, + Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds) + ); + } + + public update(time: DOMHighResTimeStamp): void { + const previous = this.previousFrameTime; + this.previousFrameTime = time; + if (previous === null) { + return; + } + + const deltaSeconds = (time - previous) / 1000; + if (deltaSeconds <= 0) { + return; + } + + this.measuredFrameTimeMs = deltaSeconds * 1000; + const fps = 1 / deltaSeconds; + this.measuredFps = fps; + if (deltaSeconds > FRAME_GAP_RESET_SECONDS) { + this.frameDeltaSeconds = 0; + this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS; + return; + } + + this.frameDeltaSeconds = deltaSeconds; + this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW; + } +} diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts new file mode 100644 index 0000000..64c9680 --- /dev/null +++ b/src/game-loop/game-loop-resources.ts @@ -0,0 +1,186 @@ +import { vec2 } from 'gl-matrix'; + +import { appConfig, type GardenRuntimeSettings } from '../config'; +import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; +import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; +import { CommonState } from '../pipelines/common-state/common-state'; +import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; +import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; +import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; +import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { initializeContext } from '../utils/graphics/initialize-context'; +import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; +import { GpuProfiler } from './gpu-profiler'; +import { SimulationFrameRenderer } from './simulation-frame'; +import { SimulationTextures } from './simulation-textures'; + +interface FrameParameters extends RenderInputs { + time: number; + deltaTime: number; + canvasSize: vec2; + activeAgentCount: number; + canvasPixelRatio: number; + introProgress: number; + selectedColorIndex: number; + eraserPixelSize: number; + runtimeSettings: GardenRuntimeSettings; +} + +export class GameLoopResources { + public readonly textures: SimulationTextures; + public readonly commonState: CommonState; + public readonly agentGenerationPipeline: AgentGenerationPipeline; + public readonly agentPipeline: AgentPipeline; + public readonly brushPipeline: BrushPipeline; + public readonly eraserAgentPipeline: EraserAgentPipeline; + public readonly eraserTexturePipeline: EraserTexturePipeline; + public readonly diffusionPipeline: DiffusionPipeline; + public readonly renderPipeline: RenderPipeline; + public readonly gpuProfiler: GpuProfiler | null; + + private readonly frameRenderer: SimulationFrameRenderer; + + public constructor( + canvas: HTMLCanvasElement, + private readonly device: GPUDevice, + private readonly canvasFormat: GPUTextureFormat, + canvasSize: vec2, + initialAgentCapacity: number, + initialMaxAgentCount: number + ) { + const context = initializeContext({ device, canvas, format: canvasFormat }); + + this.textures = new SimulationTextures(this.device, canvasSize); + + this.commonState = new CommonState(this.device); + this.commonState.setParameters({ + canvasSize, + }); + + this.agentGenerationPipeline = new AgentGenerationPipeline( + this.device, + Math.min(initialMaxAgentCount, initialAgentCapacity) + ); + + this.agentPipeline = new AgentPipeline( + this.device, + this.commonState, + () => this.agentGenerationPipeline.agentsBuffer + ); + this.brushPipeline = new BrushPipeline(this.device, this.commonState); + this.eraserAgentPipeline = new EraserAgentPipeline( + this.device, + () => this.agentGenerationPipeline.agentsBuffer + ); + this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); + this.diffusionPipeline = new DiffusionPipeline(this.device); + this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat); + this.gpuProfiler = GpuProfiler.create( + this.device, + () => appConfig.tuningPane.showFpsOverlay + ); + + this.frameRenderer = new SimulationFrameRenderer( + this.device, + this.textures, + { + agentPipeline: this.agentPipeline, + brushPipeline: this.brushPipeline, + eraserAgentPipeline: this.eraserAgentPipeline, + eraserTexturePipeline: this.eraserTexturePipeline, + diffusionPipeline: this.diffusionPipeline, + renderPipeline: this.renderPipeline, + }, + this.gpuProfiler + ); + } + + public resizeSimulationTo(nextSize: vec2): vec2 | null { + return this.textures.resizeTo(nextSize); + } + + public clearSimulation(): void { + this.textures.clear(); + this.frameRenderer.resetSourceMapActivity(); + } + + public get isSourceMapActive(): boolean { + return this.frameRenderer.isSourceMapActive; + } + + public get gpuPassTimeMs(): number | undefined { + return this.gpuProfiler?.latestTotalPassMs; + } + + public setFrameParameters({ + time, + deltaTime, + canvasSize, + activeAgentCount, + canvasPixelRatio, + introProgress, + selectedColorIndex, + channelColors, + backgroundColor, + eraserPixelSize, + runtimeSettings, + }: FrameParameters): void { + this.commonState.setParameters({ + canvasSize, + }); + this.agentPipeline.setParameters({ + ...runtimeSettings, + deltaTime, + time, + agentCount: activeAgentCount, + introMoveSpeed: appConfig.simulation.introMoveSpeed, + introProgress, + }); + this.brushPipeline.setParameters({ + ...runtimeSettings, + pixelRatio: canvasPixelRatio, + selectedColorIndex, + }); + this.diffusionPipeline.setParameters(runtimeSettings); + this.renderPipeline.setParameters({ + ...runtimeSettings, + channelColors, + backgroundColor, + }); + this.eraserAgentPipeline.setParameters({ + agentCount: activeAgentCount, + eraserSize: eraserPixelSize, + eraserMaskAlphaThreshold: runtimeSettings.eraserMaskAlphaThreshold, + maskSize: canvasSize, + }); + this.eraserTexturePipeline.setParameters({ + eraserSize: eraserPixelSize, + eraserLineDistanceEpsilon: runtimeSettings.eraserLineDistanceEpsilon, + eraserClearRed: runtimeSettings.eraserClearRed, + eraserClearGreen: runtimeSettings.eraserClearGreen, + eraserClearBlue: runtimeSettings.eraserClearBlue, + eraserClearAlpha: runtimeSettings.eraserClearAlpha, + }); + } + + public executeFrame( + isErasing: boolean, + canvasReadbackRequest?: CanvasReadbackRequest | null + ): void { + this.frameRenderer.execute(isErasing, canvasReadbackRequest); + } + + public destroy(): void { + this.agentGenerationPipeline.destroy(); + this.agentPipeline.destroy(); + this.brushPipeline.destroy(); + this.eraserAgentPipeline.destroy(); + this.eraserTexturePipeline.destroy(); + this.diffusionPipeline.destroy(); + this.renderPipeline.destroy(); + this.gpuProfiler?.destroy(); + this.commonState.destroy(); + this.textures.destroy(); + } +} diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts deleted file mode 100644 index e71ebc8..0000000 --- a/src/game-loop/game-loop-settings.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface GameLoopSettings { - maxAgentCountUpperLimit: number; - agentCount: number; - renderSpeed: number; - simulatedDelayMs: number; - - startColorHue: number; -} diff --git a/src/game-loop/game-loop-types.ts b/src/game-loop/game-loop-types.ts new file mode 100644 index 0000000..2dba0c9 --- /dev/null +++ b/src/game-loop/game-loop-types.ts @@ -0,0 +1,26 @@ +import { vec2 } from 'gl-matrix'; + +import type { RgbColor } from '../utils/rgb-color'; + +export interface GardenUi { + eraserPreview: HTMLElement; + exportStatus: HTMLElement; + grainOverlay: HTMLElement; + prompt: HTMLElement; + toolbar: HTMLElement; +} + +export interface RenderInputs { + channelColors: [RgbColor, RgbColor, RgbColor]; + backgroundColor: RgbColor; +} + +export interface StrokeSegment { + from: vec2; + to: vec2; +} + +export interface CanvasReadbackRequest { + encode(commandEncoder: GPUCommandEncoder, texture: GPUTexture): void; + afterSubmit(): void; +} diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index b10d843..aed5289 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -1,265 +1,364 @@ import { vec2 } from 'gl-matrix'; -import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; -import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; -import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; -import { CommonState } from '../pipelines/common-state/common-state'; -import { CopyPipeline } from '../pipelines/copy/copy-pipeline'; -import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; -import { RenderPipeline } from '../pipelines/render/render-pipeline'; -import { settings } from '../settings'; +import { GardenAudio } from '../audio/garden-audio'; +import { appConfig } from '../config'; +import { activeVibe, settings } from '../settings'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; -import { initializeContext } from '../utils/graphics/initialize-context'; -import { ResizableTexture } from '../utils/graphics/resizable-texture'; -import { sleep } from '../utils/sleep'; -import { GamePresentation } from './game-presentation'; -import { GameRules } from './game-rules'; +import { rgbColorToCss, type RgbColor } from '../utils/rgb-color'; +import { AgentPopulation } from './agent-population'; +import { EraserPreview } from './eraser-preview'; +import { ExportSnapshotRenderer } from './export-snapshot-renderer'; +import { FramePerformance } from './frame-performance'; +import { GameLoopResources } from './game-loop-resources'; +import { GardenUi } from './game-loop-types'; +import { getInternalRenderSize } from './internal-render-size'; +import { IntroPrompt } from './intro-prompt'; +import { PerfStatsOverlay } from './perf-stats-overlay'; +import { GardenPointerInput } from './pointer-input'; +import { PipelineStrokeOutput } from './stroke-output'; +import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; export default class GameLoop { - private readonly trailMapA: ResizableTexture; - private readonly trailMapB: ResizableTexture; - - private readonly commonState: CommonState; - private readonly copyPipeline: CopyPipeline; - private readonly agentGenerationPipeline: AgentGenerationPipeline; - private readonly agentPipeline: AgentPipeline; - private readonly renderPipeline: RenderPipeline; - private readonly brushPipeline: BrushPipeline; - private readonly diffusionPipeline: DiffusionPipeline; + private readonly resources: GameLoopResources; + private readonly audio = new GardenAudio(appConfig.audio); + private readonly introPrompt: IntroPrompt; + private readonly eraserPreview: EraserPreview; + private readonly pointerInput: GardenPointerInput; + private readonly agentPopulation: AgentPopulation; + private readonly exportSnapshotRenderer: ExportSnapshotRenderer; + private readonly framePerformance = new FramePerformance(); + private perfStatsOverlay: PerfStatsOverlay | null = null; + private readonly toolbarContrastMonitor: ToolbarContrastMonitor; + private readonly seedValue = Math.floor(Math.random() * 0xffffffff); + private readonly seed = this.seedValue.toString(16); + private readonly _canvasSize: vec2 = vec2.create(); + private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; + private previousAccentColor = ''; + private previousGrainStrength = Number.NaN; private hasFinished = false; + private animationFrameId: number | null = null; + private destroyPromise: Promise | null = null; private readonly finished = Promise.withResolvers(); - private activePointerId: number | null = null; - public constructor( private readonly canvas: HTMLCanvasElement, private readonly device: GPUDevice, + private readonly canvasFormat: GPUTextureFormat, private readonly deltaTimeCalculator: DeltaTimeCalculator, - private readonly gameRules: GameRules + private readonly ui: GardenUi ) { - const context = initializeContext({ device, canvas }); - - this.trailMapA = new ResizableTexture(this.device, this.canvasSize); - this.trailMapB = new ResizableTexture(this.device, this.canvasSize); this.resize(); - - this.copyPipeline = new CopyPipeline(this.device); - - this.commonState = new CommonState(this.device); - this.commonState.setParameters({ - canvasSize: this.canvasSize, - time: 0, - deltaTime: 0, + this.resources = new GameLoopResources( + canvas, + device, + this.canvasFormat, + this.canvasSize, + this.framePerformance.adaptiveCapInitial, + settings.maxAgentCount + ); + this.introPrompt = new IntroPrompt(ui.prompt); + this.toolbarContrastMonitor = new ToolbarContrastMonitor( + canvas, + ui.toolbar, + device, + this.canvasFormat + ); + this.agentPopulation = new AgentPopulation( + this.resources.agentGenerationPipeline, + this.seedValue, + () => this.canvasPixelRatio, + this.framePerformance + ); + this.agentPopulation.initializeIntroAgents(this.canvasSize); + this.pointerInput = new GardenPointerInput({ + canvas, + audio: this.audio, + strokeOutput: new PipelineStrokeOutput( + this.resources.brushPipeline, + this.resources.eraserAgentPipeline, + this.resources.eraserTexturePipeline + ), + getCanvasPixelRatio: () => this.canvasPixelRatio, + getMirrorSegmentCount: () => this.mirrorSegmentCount, + onStartDrawing: () => { + this.introPrompt.markStartedDrawing(); + this.agentPopulation.beginStroke(); + }, + onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(), + spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to), + }); + this.eraserPreview = new EraserPreview( + canvas, + ui.eraserPreview, + () => this.pointerInput.isSwipeActive + ); + this.exportSnapshotRenderer = new ExportSnapshotRenderer({ + device, + renderPipeline: this.resources.renderPipeline, + canvasFormat: this.canvasFormat, + statusElement: ui.exportStatus, + seed: this.seed, + getSourceSize: () => { + const size = this.resources.textures.trailMapA.getSize(); + return { + width: size[0], + height: size[1], + }; + }, + getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(), + getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), + getSourceActive: () => this.resources.isSourceMapActive, + getVibeId: () => activeVibe.id, }); - this.agentGenerationPipeline = new AgentGenerationPipeline( - this.device, - this.commonState, - settings.maxAgentCountUpperLimit - ); - this.agentGenerationPipeline.spawnFirstGeneration(); - - this.agentPipeline = new AgentPipeline( - this.device, - this.commonState, - this.agentGenerationPipeline.agentsBuffer - ); - this.brushPipeline = new BrushPipeline(this.device, this.commonState); - this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState); - this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); - - window.addEventListener('resize', this.resize.bind(this)); - canvas.addEventListener('pointerdown', this.onPointerDown.bind(this)); - canvas.addEventListener('pointermove', this.onPointerMove.bind(this)); - canvas.addEventListener('pointerup', this.onPointerUp.bind(this)); - canvas.addEventListener('pointercancel', this.onPointerUp.bind(this)); + this.syncPerfStatsOverlay(); } - private onPointerDown(event: PointerEvent) { - if (this.activePointerId !== null) { - return; - } - this.activePointerId = event.pointerId; - this.canvas.setPointerCapture(event.pointerId); - this.brushPipeline.clearSwipes(); - this.addSwipeAt(event); + public attachPointerInput(): void { + this.pointerInput.attach(); + this.eraserPreview.attach(); } - private onPointerMove(event: PointerEvent) { - if (event.pointerId !== this.activePointerId) { - return; - } - this.addSwipeAt(event); + public setEraseMode(isErasing: boolean): void { + this.pointerInput.setEraseMode(isErasing); + this.eraserPreview.setEraseMode(isErasing); } - private onPointerUp(event: PointerEvent) { - if (event.pointerId !== this.activePointerId) { - return; - } - this.addSwipeAt(event); - this.canvas.releasePointerCapture(event.pointerId); - this.activePointerId = null; + public updateEraserPreview(event?: PointerEvent): void { + this.eraserPreview.update(event); } - private addSwipeAt(event: PointerEvent) { - const position = vec2.fromValues( - event.clientX * this.devicePixelRatio, - this.canvas.height - event.clientY * this.devicePixelRatio - ); - this.brushPipeline.addSwipe(position); + public onVibeChanged(): void { + this.agentPopulation.onVibeChanged(); + this.syncPerfStatsOverlay(); } - private get isSwipeActive(): boolean { - return this.activePointerId !== null; + public setAudioMuted(isMuted: boolean): void { + this.audio.setMuted(isMuted); + } + + public setAudioVolume(volume: number): void { + this.audio.setMasterVolume(volume); + } + + public startAudio(userGesture = false): void { + this.audio.start(activeVibe, { userGesture }); + } + + public playVibeChangeAudio(userGesture = false): void { + this.audio.changeVibe(activeVibe, { userGesture }); } public async start(): Promise { - requestAnimationFrame(this.render.bind(this)); - requestAnimationFrame(this.updateCounts.bind(this)); + if (this.animationFrameId === null && !this.hasFinished) { + this.animationFrameId = requestAnimationFrame(this.render); + } return this.finished.promise; } - private async updateCounts(): Promise { - if (this.hasFinished) { - return; + public async exportSnapshot(): Promise { + return this.exportSnapshotRenderer.export(); + } + + public async destroy(): Promise { + this.destroyPromise ??= this.dispose(); + return this.destroyPromise; + } + + private async dispose(): Promise { + this.hasFinished = true; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; } - const generationCounts = await this.agentGenerationPipeline.countAgents( - settings.agentCount - ); - this.gameRules.updateGenerationCounts(generationCounts); - requestAnimationFrame(this.updateCounts.bind(this)); + this.pointerInput.detach(); + this.eraserPreview.detach(); + this.perfStatsOverlay?.destroy(); + this.perfStatsOverlay = null; + this.toolbarContrastMonitor.destroy(); + this.introPrompt.destroy(); + await this.agentPopulation.waitForCompaction(); + this.resources.destroy(); + await this.audio.destroy(); + this.finished.resolve(); } - public get aliveAgentCounts(): { - currentGenerationCount: number; - nextGenerationCount: number; - } { - return this.gameRules.generationCounts; - } - - public get maxAgentCount(): number { - return this.agentGenerationPipeline.maxAgentCount; - } - - private resize() { - this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio; - this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio; - } - - private async render(time: DOMHighResTimeStamp) { + private readonly render = (time: DOMHighResTimeStamp) => { + this.animationFrameId = null; if (this.hasFinished) { this.finished.resolve(); return; } - const accentColor = GamePresentation.getGenerationColor( - this.gameRules.nextGenerationId - 1 - ); - document.documentElement.style.setProperty( - '--accent-color', - `rgb(${accentColor[0] * 255},${accentColor[1] * 255},${accentColor[2] * 255})` - ); - const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); + this.framePerformance.update(time); + this.agentPopulation.updateAdaptiveCap(); + this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0); + this.resize(); + this.resizeSimulationToCanvas(time); + this.regenerateIntroAfterSettledResize(time); - time *= settings.renderSpeed; - const timeInSeconds = time / 1000; - const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize); + const channelColors = activeVibe.colors; + const backgroundColor = activeVibe.backgroundColor; + const runtimeSettings = { ...settings }; + const introProgress = this.introPrompt.progress; + const canvasPixelRatio = this.canvasPixelRatio; + const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio; + const isErasing = this.pointerInput.isEraseMode; + const accentColor = + channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0]; + this.updateAccentColor(accentColor); + this.updateGrainOverlay(runtimeSettings.backgroundGrainStrength); + this.audio.update({ + vibe: activeVibe, + isErasing, + }); - [ - this.commonState, - this.agentPipeline, - this.brushPipeline, - this.diffusionPipeline, - this.renderPipeline, - ].forEach((pipeline) => - pipeline.setParameters({ - time, - isNextGenerationOdd: this.gameRules.nextGenerationId % 2, - nextGenerationSensorOffsetDistance: this.gameRules.getSensorOffset(), - nextGenerationSpeed: this.gameRules.getNextGenerationMoveSpeed(), - infectionProbability: this.gameRules.getInfectionProbability(), - deltaTime, - canvasSize: this.canvasSize, - brushColor: GamePresentation.getGenerationColor( - this.gameRules.nextGenerationId - 1 - ), - evenGenerationColor: GamePresentation.getGenerationColor( - this.gameRules.nextGenerationId % 2 == 0 - ? this.gameRules.nextGenerationId - : this.gameRules.nextGenerationId - 1 - ), - oddGenerationColor: GamePresentation.getGenerationColor( - this.gameRules.nextGenerationId % 2 == 1 - ? this.gameRules.nextGenerationId - : this.gameRules.nextGenerationId - 1 - ), - ...settings, - center: spawnAction.position, - radius: spawnAction.radius, - }) + this.resources.setFrameParameters({ + time, + deltaTime, + canvasSize: this.canvasSize, + activeAgentCount: this.agentPopulation.activeAgentCount, + canvasPixelRatio, + introProgress, + selectedColorIndex: runtimeSettings.selectedColorIndex, + channelColors, + backgroundColor, + eraserPixelSize, + runtimeSettings, + }); + + this.resources.executeFrame( + isErasing, + this.toolbarContrastMonitor.takeReadbackRequest(time) ); - for (let i = 0; i < settings.renderSpeed; i++) { - const commandEncoder = this.device.createCommandEncoder(); + this.pointerInput.clearSwipesIfIdle(); + this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); + this.perfStatsOverlay?.update({ + time, + fps: this.framePerformance.measuredFps, + agentCount: this.agentPopulation.activeAgentCount, + frameTimeMs: this.framePerformance.measuredFrameTimeMs, + gpuPassTimeMs: this.resources.gpuPassTimeMs, + renderWidth: this.canvas.width, + renderHeight: this.canvas.height, + }); - this.copyPipeline.execute( - commandEncoder, - this.trailMapA.getTextureView(), - this.trailMapB.getTextureView() - ); - this.brushPipeline.execute(commandEncoder, this.trailMapB.getTextureView()); - this.agentPipeline.execute( - commandEncoder, - this.trailMapA.getTextureView(), - this.trailMapB.getTextureView() - ); - this.diffusionPipeline.execute( - commandEncoder, - this.trailMapB.getTextureView(), - this.trailMapA.getTextureView() - ); - this.renderPipeline.execute(commandEncoder, this.trailMapA.getTextureView()); + this.animationFrameId = requestAnimationFrame(this.render); + }; - this.device.queue.submit([commandEncoder.finish()]); + private syncPerfStatsOverlay(): void { + if (appConfig.tuningPane.showFpsOverlay) { + this.perfStatsOverlay ??= new PerfStatsOverlay( + this.canvas.parentElement ?? document.body + ); + return; } - if (!this.isSwipeActive) { - this.brushPipeline.clearSwipes(); - } - - if (settings.simulatedDelayMs > 0) { - await sleep(settings.simulatedDelayMs); - } - - // avoid resizing during rendering - this.trailMapA.resize(this.canvasSize); - this.trailMapB.resize(this.canvasSize); - - requestAnimationFrame(this.render.bind(this)); + this.perfStatsOverlay?.destroy(); + this.perfStatsOverlay = null; } - public async destroy() { - this.hasFinished = true; - await this.finished.promise; + private updateAccentColor(color: RgbColor): void { + const accentColor = rgbColorToCss(color); + if (this.previousAccentColor === accentColor) { + return; + } - this.copyPipeline?.destroy(); - this.agentGenerationPipeline?.destroy(); - this.agentPipeline?.destroy(); - this.brushPipeline?.destroy(); - this.diffusionPipeline?.destroy(); - this.renderPipeline?.destroy(); - this.commonState?.destroy(); - this.trailMapA?.destroy(); - this.trailMapB?.destroy(); + this.previousAccentColor = accentColor; + document.documentElement.style.setProperty('--accent-color', accentColor); + } + + private updateGrainOverlay(strength: number): void { + const safeStrength = Number.isFinite(strength) ? Math.max(0, strength) : 0; + if (Object.is(this.previousGrainStrength, safeStrength)) { + return; + } + + this.previousGrainStrength = safeStrength; + this.grainOverlay.hidden = safeStrength <= 0; + this.grainOverlay.style.setProperty('--garden-grain-strength', String(safeStrength)); + } + + private resize(): void { + const rect = this.canvas.getBoundingClientRect(); + const { width, height } = getInternalRenderSize({ + clientHeight: rect.height || this.canvas.clientHeight, + clientWidth: rect.width || this.canvas.clientWidth, + maxTextureDimension: this.device.limits.maxTextureDimension2D, + targetAreaMegapixels: settings.internalRenderAreaMegapixels, + }); + + if (this.canvas.width === width && this.canvas.height === height) { + return; + } + + this.canvas.width = width; + this.canvas.height = height; + } + + private resizeSimulationToCanvas(time: DOMHighResTimeStamp): void { + const scale = this.resources.resizeSimulationTo(this.canvasSize); + if (!scale) { + return; + } + + this.agentPopulation.resizeAgents(scale); + this.pointerInput.scaleLastPointerPosition(scale); + + if (this.introPrompt.shouldRegenerateTitleOnResize) { + this.pendingIntroResizeAt = time; + } + } + + private regenerateIntroAfterSettledResize(time: DOMHighResTimeStamp): void { + if (this.pendingIntroResizeAt === null) { + return; + } + + if (!this.introPrompt.shouldRegenerateTitleOnResize) { + this.pendingIntroResizeAt = null; + return; + } + + if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) { + return; + } + + this.introPrompt.rewindToLeaveRemainingTime( + appConfig.simulation.intro.resizeMinimumRemainingSeconds + ); + this.resources.clearSimulation(); + this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress); + this.pendingIntroResizeAt = null; } private get canvasSize(): vec2 { - return vec2.fromValues(this.canvas.width, this.canvas.height); + vec2.set(this._canvasSize, this.canvas.width, this.canvas.height); + return this._canvasSize; } - private get devicePixelRatio(): number { - return window.devicePixelRatio || 1; + private get canvasPixelRatio(): number { + const rect = this.canvas.getBoundingClientRect(); + const xScale = rect.width > 0 ? this.canvas.width / rect.width : 1; + const yScale = rect.height > 0 ? this.canvas.height / rect.height : xScale; + const ratio = (xScale + yScale) / 2; + return Number.isFinite(ratio) && ratio > 0 ? ratio : 1; + } + + private get mirrorSegmentCount(): number { + const count = Number.isFinite(settings.mirrorSegmentCount) + ? settings.mirrorSegmentCount + : appConfig.toolbar.mirror.min; + return Math.min( + appConfig.toolbar.mirror.max, + Math.max(appConfig.toolbar.mirror.min, Math.round(count)) + ); + } + + private get grainOverlay(): HTMLElement { + return this.ui.grainOverlay; } } diff --git a/src/game-loop/game-presentation.ts b/src/game-loop/game-presentation.ts deleted file mode 100644 index 7332da3..0000000 --- a/src/game-loop/game-presentation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { vec3 } from 'gl-matrix'; - -import { settings } from '../settings'; -import { hsl } from '../utils/hsl'; -import { Random } from '../utils/random'; - -const hues = [settings.startColorHue]; - -for (let i = 0; i < 100; i++) { - hues.push((hues[hues.length - 1] + Random.randomBetween(90, 240)) % 360); -} - -const colors = hues.map((hue) => - hsl(hue, Random.randomBetween(90, 100), Random.randomBetween(20, 30)) -); - -export class GamePresentation { - public static getGenerationColor(generation: number): vec3 { - return colors[generation % colors.length]; - } -} diff --git a/src/game-loop/game-rules.ts b/src/game-loop/game-rules.ts deleted file mode 100644 index 87bf9e4..0000000 --- a/src/game-loop/game-rules.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -import { GenerationCounts } from '../pipelines/agents/agent-generation/generation-counts'; -import { settings } from '../settings'; -import { clamp, clamp01 } from '../utils/clamp'; -import { mix } from '../utils/mix'; -import { Random } from '../utils/random'; - -export interface SpawnAction { - generation: number; - position: vec2; - radius: number; -} - -export class GameRules { - private static readonly DEFAULT_SPAWN_INTERVAL = 8; - private static readonly DEFAULT_SPAWN_TIME_LENGTH = 2; - private static readonly DEFAULT_SPAWN_RADIUS = 20; - - private lastSpawnTimeInSeconds = 0; - private currentSpawnInterval = 0; - private currentSpawnRadius = 0; - private lastGenerationChangeTimeInSeconds = 0; - - public nextGenerationId = 1; - public generationCounts: { - currentGenerationCount: number; - nextGenerationCount: number; - } = { - currentGenerationCount: 0, - nextGenerationCount: 1, - }; - - public constructor(startingTimeInSeconds: number) { - this.lastSpawnTimeInSeconds = startingTimeInSeconds; - this.lastGenerationChangeTimeInSeconds = startingTimeInSeconds; - } - - private lastSpawnAction: SpawnAction | undefined; - - public getSpawnAction(timeInSeconds: number, canvasSize: vec2): SpawnAction { - if ( - this.lastSpawnAction && - timeInSeconds - this.lastSpawnTimeInSeconds < GameRules.DEFAULT_SPAWN_TIME_LENGTH - ) { - return this.lastSpawnAction; - } - - this.currentSpawnInterval = mix( - GameRules.DEFAULT_SPAWN_INTERVAL, - GameRules.DEFAULT_SPAWN_INTERVAL / 5, - clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120) - ); - - this.currentSpawnRadius = mix( - GameRules.DEFAULT_SPAWN_RADIUS, - GameRules.DEFAULT_SPAWN_RADIUS * 3, - clamp01((timeInSeconds - this.lastGenerationChangeTimeInSeconds) / 120) - ); - - const q = this.generationCounts.nextGenerationCount / settings.agentCount; - - if ( - timeInSeconds - this.lastSpawnTimeInSeconds < this.currentSpawnInterval || - q > 0.05 - ) { - return { - generation: this.nextGenerationId, - position: vec2.create(), - radius: 0, - }; - } - - this.lastSpawnTimeInSeconds = timeInSeconds; - - this.lastSpawnAction = { - generation: this.nextGenerationId, - position: vec2.fromValues( - Random.randomBetween(0, canvasSize[0]), - Random.randomBetween(0, canvasSize[1]) - ), - radius: this.currentSpawnRadius, - }; - - return this.lastSpawnAction; - } - - public updateGenerationCounts({ - evenGenerationCount, - oddGenerationCount, - }: GenerationCounts): void { - const nextGenerationCount = - this.nextGenerationId % 2 === 1 ? oddGenerationCount : evenGenerationCount; - const currentGenerationCount = - this.nextGenerationId % 2 === 1 ? evenGenerationCount : oddGenerationCount; - - const q = currentGenerationCount / settings.agentCount; - - if (currentGenerationCount <= 100 && q < 0.05) { - this.nextGenerationId++; - this.lastGenerationChangeTimeInSeconds = performance.now() / 1000; - } - - this.generationCounts = { - currentGenerationCount, - nextGenerationCount, - }; - } - - public getNextGenerationMoveSpeed(): number { - const q = this.generationCounts.nextGenerationCount / settings.agentCount; - return mix(settings.moveSpeed / 8, settings.moveSpeed, q ** 2); - } - - public getInfectionProbability(): number { - const q = this.generationCounts.nextGenerationCount / settings.agentCount; - return clamp(mix(0.3, 1, q * 5), 0, 0.9); - } - - public getSensorOffset(): number { - const q = this.generationCounts.nextGenerationCount / settings.agentCount; - return mix(20, settings.sensorOffsetDistance, q); - } -} diff --git a/src/game-loop/gpu-profiler.ts b/src/game-loop/gpu-profiler.ts new file mode 100644 index 0000000..bc2318d --- /dev/null +++ b/src/game-loop/gpu-profiler.ts @@ -0,0 +1,173 @@ +const PASS_NAMES = [ + 'brush', + 'eraserTexture', + 'eraserAgent', + 'agent', + 'trailDiffusion', + 'render', + 'sourceDiffusion', +] as const; + +export type GpuPassName = (typeof PASS_NAMES)[number]; + +interface GpuProfilerSample { + frame: number; + passes: Partial>; + totalPassMs: number; +} + +interface ActivePass { + endQueryIndex: number; + name: GpuPassName; + startQueryIndex: number; +} + +interface ReadbackSlot { + buffer: GPUBuffer; + state: 'idle' | 'encoding' | 'mapping'; +} + +const MAX_QUERY_COUNT = PASS_NAMES.length * 2; +const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT; +const READBACK_SLOT_COUNT = 4; + +export class GpuProfiler { + private readonly querySet: GPUQuerySet; + private readonly resolveBuffer: GPUBuffer; + private readonly readbackSlots: Array; + private readonly isEnabled: () => boolean; + private activePasses: Array = []; + private nextQueryIndex = 0; + private frame = 0; + private latestSample: GpuProfilerSample | null = null; + + public static create(device: GPUDevice, isEnabled: () => boolean): GpuProfiler | null { + if (!device.features.has('timestamp-query')) { + return null; + } + return new GpuProfiler(device, isEnabled); + } + + private constructor(device: GPUDevice, isEnabled: () => boolean) { + this.isEnabled = isEnabled; + this.querySet = device.createQuerySet({ + type: 'timestamp', + count: MAX_QUERY_COUNT, + }); + this.resolveBuffer = device.createBuffer({ + size: MAX_QUERY_COUNT * QUERY_BYTES, + usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC, + }); + this.readbackSlots = Array.from({ length: READBACK_SLOT_COUNT }, () => ({ + buffer: device.createBuffer({ + size: MAX_QUERY_COUNT * QUERY_BYTES, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }), + state: 'idle' as const, + })); + } + + public beginFrame(): void { + this.frame += 1; + this.activePasses = []; + this.nextQueryIndex = 0; + } + + public timestampWrites( + name: GpuPassName + ): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined { + if (!this.isEnabled()) { + return undefined; + } + if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) { + return undefined; + } + + const startQueryIndex = this.nextQueryIndex; + const endQueryIndex = this.nextQueryIndex + 1; + this.nextQueryIndex += 2; + this.activePasses.push({ + endQueryIndex, + name, + startQueryIndex, + }); + + return { + querySet: this.querySet, + beginningOfPassWriteIndex: startQueryIndex, + endOfPassWriteIndex: endQueryIndex, + }; + } + + public resolve(commandEncoder: GPUCommandEncoder): (() => void) | null { + const queryCount = this.nextQueryIndex; + if (queryCount === 0 || this.activePasses.length === 0) { + return null; + } + + const slot = this.readbackSlots.find((candidate) => candidate.state === 'idle'); + if (!slot) { + return null; + } + + const byteLength = queryCount * QUERY_BYTES; + const passes = this.activePasses.slice(); + const frame = this.frame; + slot.state = 'encoding'; + commandEncoder.resolveQuerySet(this.querySet, 0, queryCount, this.resolveBuffer, 0); + commandEncoder.copyBufferToBuffer(this.resolveBuffer, 0, slot.buffer, 0, byteLength); + + return () => { + slot.state = 'mapping'; + void slot.buffer + .mapAsync(GPUMapMode.READ, 0, byteLength) + .then(() => { + this.publishSample(frame, passes, slot.buffer.getMappedRange(0, byteLength)); + slot.buffer.unmap(); + slot.state = 'idle'; + }) + .catch(() => { + slot.state = 'idle'; + }); + }; + } + + public destroy(): void { + this.querySet.destroy(); + this.resolveBuffer.destroy(); + this.readbackSlots.forEach((slot) => { + slot.buffer.destroy(); + }); + } + + public get latestTotalPassMs(): number | undefined { + return this.latestSample?.totalPassMs; + } + + private publishSample( + frame: number, + passes: Array, + mappedRange: ArrayBuffer + ): void { + const timestamps = new BigUint64Array(mappedRange); + const sample: GpuProfilerSample = { + frame, + passes: {}, + totalPassMs: 0, + }; + + passes.forEach(({ endQueryIndex, name, startQueryIndex }) => { + const start = timestamps[startQueryIndex]; + const end = timestamps[endQueryIndex]; + if (end < start) { + return; + } + + const elapsedMs = Number(end - start) / 1_000_000; + sample.passes[name] = elapsedMs; + sample.totalPassMs += elapsedMs; + }); + + this.latestSample = sample; + } +} diff --git a/src/game-loop/internal-render-size.ts b/src/game-loop/internal-render-size.ts new file mode 100644 index 0000000..5184618 --- /dev/null +++ b/src/game-loop/internal-render-size.ts @@ -0,0 +1,45 @@ +const MEGAPIXEL = 1_000_000; + +export interface InternalRenderSizeOptions { + clientHeight: number; + clientWidth: number; + maxTextureDimension: number; + targetAreaMegapixels: number; +} + +export interface InternalRenderSize { + height: number; + width: number; +} + +const getSafeInternalRenderAreaMegapixels = (targetAreaMegapixels: number): number => + Number.isFinite(targetAreaMegapixels) && targetAreaMegapixels > 0 + ? targetAreaMegapixels + : 1; + +export const getInternalRenderSize = ({ + clientHeight, + clientWidth, + maxTextureDimension, + targetAreaMegapixels, +}: InternalRenderSizeOptions): InternalRenderSize => { + const safeClientWidth = Math.max(1, clientWidth); + const safeClientHeight = Math.max(1, clientHeight); + const safeMaxTextureDimension = + Number.isFinite(maxTextureDimension) && maxTextureDimension > 0 + ? Math.floor(maxTextureDimension) + : Number.POSITIVE_INFINITY; + const targetArea = + getSafeInternalRenderAreaMegapixels(targetAreaMegapixels) * MEGAPIXEL; + const areaScale = Math.sqrt(targetArea / (safeClientWidth * safeClientHeight)); + const dimensionScale = Math.min( + areaScale, + safeMaxTextureDimension / safeClientWidth, + safeMaxTextureDimension / safeClientHeight + ); + + return { + height: Math.max(1, Math.round(safeClientHeight * dimensionScale)), + width: Math.max(1, Math.round(safeClientWidth * dimensionScale)), + }; +}; diff --git a/src/game-loop/intro-prompt.ts b/src/game-loop/intro-prompt.ts new file mode 100644 index 0000000..cd9468d --- /dev/null +++ b/src/game-loop/intro-prompt.ts @@ -0,0 +1,106 @@ +import { appConfig } from '../config'; + +const DRAW_HINT_CLASS = 'draw-hint'; + +export class IntroPrompt { + private introComplete = false; + private introElapsedSeconds = 0; + private introCompletedAt: number | null = null; + private hasStartedDrawing = false; + + public constructor(private readonly prompt: HTMLElement) {} + + public get progress(): number { + return this.introComplete + ? 1 + : Math.min( + 1, + this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds + ); + } + + public get shouldRegenerateTitleOnResize(): boolean { + return !this.introComplete && !this.hasStartedDrawing; + } + + public rewindToLeaveRemainingTime(remainingSeconds: number): void { + if (this.introComplete) { + return; + } + + const safeRemainingSeconds = Number.isFinite(remainingSeconds) + ? Math.max(0, remainingSeconds) + : 0; + this.introElapsedSeconds = Math.min( + this.introElapsedSeconds, + Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds) + ); + } + + public update(deltaTime: number): void { + const now = performance.now(); + + if (!this.introComplete) { + const safeDeltaTime = Number.isFinite(deltaTime) ? Math.max(0, deltaTime) : 0; + this.introElapsedSeconds += safeDeltaTime; + } + + if ( + !this.introComplete && + this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds + ) { + this.complete(now); + } + + if ( + !this.introComplete || + this.hasStartedDrawing || + this.introCompletedAt === null || + now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs + ) { + return; + } + + this.showDrawHint(); + } + + public complete(completedAt = performance.now()): void { + if (this.introComplete) { + return; + } + this.introComplete = true; + this.introCompletedAt = completedAt; + this.hideDrawHint(); + } + + public markStartedDrawing(): void { + this.hasStartedDrawing = true; + this.hideDrawHint(); + } + + public destroy(): void { + this.hideDrawHint(); + } + + private showDrawHint(): void { + if (this.prompt.classList.contains(DRAW_HINT_CLASS)) { + return; + } + + this.prompt.classList.add(DRAW_HINT_CLASS); + this.prompt.innerHTML = ` + + Draw on the screen + `; + } + + private hideDrawHint(): void { + this.prompt.classList.remove(DRAW_HINT_CLASS); + this.prompt.replaceChildren(); + } +} diff --git a/src/game-loop/intro-title-agents.ts b/src/game-loop/intro-title-agents.ts new file mode 100644 index 0000000..a34bbf4 --- /dev/null +++ b/src/game-loop/intro-title-agents.ts @@ -0,0 +1,422 @@ +import { appConfig, type GardenAppConfig } from '../config'; +import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; +import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math'; + +interface IntroTitlePoint { + x: number; + y: number; + tangent: number | null; + colorIndex: number; +} + +interface IntroTitleAgentOptions { + count: number; + width: number; + height: number; + progress?: number; + seed?: number; +} + +type RandomSource = () => number; +type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing']; + +const INTRO_TITLE = appConfig.simulation.intro.title; +const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean => + pathEasing === 'linear'; + +export const createIntroTitleAgents = ({ + count, + width, + height, + progress = 0, + seed, +}: IntroTitleAgentOptions): Float32Array => { + if (count <= 0) { + return new Float32Array(); + } + + const random = seed === undefined ? Math.random : createSeededRandom(seed); + const introProgress = clamp(progress, 0, 1); + const safeWidth = Math.max(1, width); + const safeHeight = Math.max(1, height); + const points = createIntroTitlePoints(safeWidth, safeHeight); + if (points.length === 0) { + return new Float32Array(); + } + + const data = new Float32Array(count * AGENT_FLOAT_COUNT); + const minSide = Math.min(safeWidth, safeHeight); + const targetJitter = Math.max( + appConfig.simulation.intro.minTargetJitterPx, + minSide * appConfig.simulation.intro.targetJitterSideRatio + ); + const entryJitter = Math.max( + appConfig.simulation.intro.minEntryJitterPx, + minSide * appConfig.simulation.intro.entryJitterSideRatio + ); + const titleRadius = points.reduce( + (radius, point) => + Math.max( + radius, + Math.hypot( + point.x - safeWidth / 2, + point.y - safeHeight * appConfig.simulation.intro.verticalAnchor + ) + ), + 0 + ); + const introCircleRadius = Math.min( + Math.max( + titleRadius * appConfig.simulation.intro.titleRadiusMultiplier, + minSide * appConfig.simulation.intro.circleMinSideRatio + ), + minSide * appConfig.simulation.intro.circleMaxSideRatio + ); + + for (let i = 0; i < count; i++) { + const point = points[Math.floor(random() * points.length)]; + const targetX = Math.max( + 0, + Math.min(safeWidth - 1, point.x + (random() - 0.5) * targetJitter) + ); + const targetY = Math.max( + 0, + Math.min(safeHeight - 1, point.y + (random() - 0.5) * targetJitter) + ); + const [startX, startY] = getIntroRadialStart( + targetX, + targetY, + safeWidth, + safeHeight, + introCircleRadius, + entryJitter, + random + ); + const approachAngle = Math.atan2(targetY - startY, targetX - startX); + let targetAngle = point.tangent ?? approachAngle; + if (Math.cos(targetAngle - approachAngle) < 0) { + targetAngle += Math.PI; + } + + const distanceFraction = + Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight); + const introDelay = Math.min( + appConfig.simulation.intro.targetDelayMax, + distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier + + random() * appConfig.simulation.intro.targetDelayRandomMultiplier + ); + const pathProgress = getIntroAgentPathProgress(introProgress, introDelay); + const initialAngle = + approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians; + const currentAngle = mixAngle( + initialAngle, + targetAngle, + smoothstep( + appConfig.simulation.intro.angleEaseStart, + appConfig.simulation.intro.angleEaseEnd, + pathProgress + ) + ); + writeAgentValues(data, i, { + positionX: mix(startX, targetX, pathProgress), + positionY: mix(startY, targetY, pathProgress), + angle: currentAngle, + colorIndex: point.colorIndex, + targetPositionX: targetX, + targetPositionY: targetY, + targetAngle, + introDelay, + }); + } + + return data; +}; + +const getIntroRadialStart = ( + targetX: number, + targetY: number, + width: number, + height: number, + radius: number, + jitter: number, + random: RandomSource +): [number, number] => { + const centerX = width / 2; + const centerY = height * appConfig.simulation.intro.verticalAnchor; + const offsetX = targetX - centerX; + const offsetY = targetY - centerY; + const length = Math.hypot(offsetX, offsetY); + const angle = + length > appConfig.simulation.intro.radialStartEpsilon + ? Math.atan2(offsetY, offsetX) + : random() * Math.PI * 2; + const directionX = Math.cos(angle); + const directionY = Math.sin(angle); + const tangentX = -directionY; + const tangentY = directionX; + const tangentJitter = (random() - 0.5) * jitter; + const radialJitter = + (random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio; + const startX = + centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter; + const startY = + centerY + directionY * (radius + radialJitter) + tangentY * tangentJitter; + + return [ + Math.max(0, Math.min(width - 1, startX)), + Math.max(0, Math.min(height - 1, startY)), + ]; +}; + +const createIntroTitlePoints = ( + width: number, + height: number +): Array => { + const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels); + const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height))); + const maskWidth = Math.max(1, Math.round(width * maskScale)); + const maskHeight = Math.max(1, Math.round(height * maskScale)); + const pointScaleX = width / maskWidth; + const pointScaleY = height / maskHeight; + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = maskWidth; + maskCanvas.height = maskHeight; + const context = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!context) { + return []; + } + + const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight); + context.clearRect(0, 0, maskWidth, maskHeight); + context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillStyle = '#fff'; + context.strokeStyle = '#fff'; + context.lineJoin = 'round'; + context.lineWidth = Math.max( + appConfig.simulation.intro.titleStrokeWidthMinPx, + fontSize * appConfig.simulation.intro.titleStrokeWidthRatio + ); + const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm; + drawIntroTitleText( + context, + maskWidth / 2, + maskHeight * appConfig.simulation.intro.verticalAnchor, + letterSpacing, + 'stroke' + ); + drawIntroTitleText( + context, + maskWidth / 2, + maskHeight * appConfig.simulation.intro.verticalAnchor, + letterSpacing, + 'fill' + ); + + const { data } = context.getImageData(0, 0, maskWidth, maskHeight); + const step = Math.max( + 1, + Math.floor( + Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity + ) + ); + const points: Array = []; + const characterColorBoundaries = getIntroTitleColorBoundaries( + context, + maskWidth, + letterSpacing + ); + + for (let y = 0; y < maskHeight; y += step) { + for (let x = 0; x < maskWidth; x += step) { + const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y); + if (alpha < appConfig.simulation.intro.maskAlphaThreshold) { + continue; + } + + points.push({ + x: x * pointScaleX, + y: y * pointScaleY, + tangent: estimateMaskTangent(data, maskWidth, maskHeight, x, y), + colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries), + }); + } + } + + return points; +}; + +const getIntroTitleColorBoundaries = ( + context: CanvasRenderingContext2D, + width: number, + letterSpacing: number +): [number, number] => { + const letters = Array.from(INTRO_TITLE); + const totalWidth = measureIntroTitleText(context, letters, letterSpacing); + let x = width / 2 - totalWidth / 2; + const cutLetters = appConfig.simulation.intro.titleColorCutLetters + .map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter)))) + .sort((a, b) => a - b); + const [firstCutLetter, secondCutLetter] = cutLetters; + const letterBoxes = letters.map((letter, index) => { + const letterWidth = context.measureText(letter).width; + const box = { + left: x, + right: x + letterWidth, + }; + x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing); + return box; + }); + + const getBoundaryBetweenLetters = (leftLetterIndex: number) => + (letterBoxes[leftLetterIndex].right + letterBoxes[leftLetterIndex + 1].left) / 2; + + return [ + getBoundaryBetweenLetters(firstCutLetter - 1), + getBoundaryBetweenLetters(secondCutLetter - 1), + ]; +}; + +const drawIntroTitleText = ( + context: CanvasRenderingContext2D, + centerX: number, + centerY: number, + letterSpacing: number, + mode: 'fill' | 'stroke' +): void => { + const letters = Array.from(INTRO_TITLE); + const totalWidth = measureIntroTitleText(context, letters, letterSpacing); + let x = centerX - totalWidth / 2; + + letters.forEach((letter, index) => { + const letterWidth = context.measureText(letter).width; + const drawX = x + letterWidth / 2; + if (mode === 'fill') { + context.fillText(letter, drawX, centerY); + } else { + context.strokeText(letter, drawX, centerY); + } + x += letterWidth + (index === letters.length - 1 ? 0 : letterSpacing); + }); +}; + +const measureIntroTitleText = ( + context: CanvasRenderingContext2D, + letters: Array, + letterSpacing: number +): number => { + const textWidth = letters.reduce( + (width, letter) => width + context.measureText(letter).width, + 0 + ); + return textWidth + Math.max(0, letters.length - 1) * letterSpacing; +}; + +const getIntroTitleColorIndex = (x: number, boundaries: [number, number]): number => { + if (x < boundaries[0]) { + return 0; + } + + if (x < boundaries[1]) { + return 1; + } + + return 2; +}; + +const getIntroTitleFontSize = ( + context: CanvasRenderingContext2D, + width: number, + height: number +): number => { + const maxWidth = width * appConfig.simulation.intro.maxWidthRatio; + const maxHeight = height * appConfig.simulation.intro.maxHeightRatio; + let fontSize = Math.floor( + Math.min( + height * appConfig.simulation.intro.initialFontHeightRatio, + width * appConfig.simulation.intro.initialFontWidthRatio + ) + ); + + while (fontSize > appConfig.simulation.intro.minFontSizePx) { + context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`; + const metrics = context.measureText(INTRO_TITLE); + const measuredHeight = + metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize; + + if (metrics.width <= maxWidth && measuredHeight <= maxHeight) { + return fontSize; + } + + fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown); + } + + return fontSize; +}; + +const estimateMaskTangent = ( + data: Uint8ClampedArray, + width: number, + height: number, + x: number, + y: number +): number | null => { + const gradientX = + getMaskAlpha(data, width, height, x + 1, y) - + getMaskAlpha(data, width, height, x - 1, y); + const gradientY = + getMaskAlpha(data, width, height, x, y + 1) - + getMaskAlpha(data, width, height, x, y - 1); + + if ( + Math.abs(gradientX) + Math.abs(gradientY) < + appConfig.simulation.intro.maskGradientThreshold + ) { + return null; + } + + return Math.atan2(gradientX, -gradientY); +}; + +const getMaskAlpha = ( + data: Uint8ClampedArray, + width: number, + height: number, + x: number, + y: number +): number => { + const clampedX = Math.max(0, Math.min(width - 1, Math.round(x))); + const clampedY = Math.max(0, Math.min(height - 1, Math.round(y))); + return data[(clampedY * width + clampedX) * 4 + 3]; +}; + +const getIntroAgentPathProgress = (introProgress: number, introDelay: number): number => { + if (introProgress <= introDelay) { + return 0; + } + + const activeProgress = + (introProgress - introDelay) / + Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay); + return easePathProgress(clamp(activeProgress, 0, 1)); +}; + +const createSeededRandom = (seed: number): RandomSource => { + let state = seed >>> 0; + + return () => { + let value = (state += 0x6d2b79f5); + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +}; + +const easePathProgress = (amount: number): number => { + if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) { + return amount; + } + + return easeOutQuad(amount); +}; diff --git a/src/game-loop/perf-stats-overlay.ts b/src/game-loop/perf-stats-overlay.ts new file mode 100644 index 0000000..9e6717f --- /dev/null +++ b/src/game-loop/perf-stats-overlay.ts @@ -0,0 +1,80 @@ +const PERF_STATS_REFRESH_MS = 200; +const UNAVAILABLE_STAT_TEXT = 'n/a'; +const ZERO_STAT_TEXT = '0'; +const ZERO_FRAME_TIME_TEXT = '0ms'; +const ZERO_RESOLUTION_TEXT = '0x0'; + +interface PerfStatsSnapshot { + time: DOMHighResTimeStamp; + fps: number; + agentCount: number; + frameTimeMs: number; + gpuPassTimeMs?: number; + renderWidth: number; + renderHeight: number; +} + +export class PerfStatsOverlay { + private readonly element: HTMLDivElement; + private previousUpdateTime = Number.NEGATIVE_INFINITY; + private previousText = ''; + + public constructor(parent: HTMLElement) { + this.element = document.createElement('div'); + this.element.className = 'perf-stats-overlay'; + this.element.setAttribute('aria-hidden', 'true'); + parent.append(this.element); + } + + public update({ + time, + fps, + agentCount, + frameTimeMs, + gpuPassTimeMs, + renderWidth, + renderHeight, + }: PerfStatsSnapshot): void { + if (time - this.previousUpdateTime < PERF_STATS_REFRESH_MS) { + return; + } + + this.previousUpdateTime = time; + const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatOptionalFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; + if (text !== this.previousText) { + this.element.textContent = text; + this.previousText = text; + } + } + + public destroy(): void { + this.element.remove(); + } +} + +const formatFps = (fps: number): string => + Number.isFinite(fps) ? Math.max(0, Math.round(fps)).toString() : ZERO_STAT_TEXT; + +const formatAgentCount = (agentCount: number): string => + Number.isFinite(agentCount) + ? Math.max(0, Math.round(agentCount)).toLocaleString('en-US') + : ZERO_STAT_TEXT; + +const formatFrameTime = (frameTimeMs: number | undefined): string => { + if (typeof frameTimeMs !== 'number' || !Number.isFinite(frameTimeMs)) { + return ZERO_FRAME_TIME_TEXT; + } + + const safeFrameTimeMs = Math.max(0, frameTimeMs); + return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`; +}; + +const formatOptionalFrameTime = (frameTimeMs: number | undefined): string => + typeof frameTimeMs === 'number' && Number.isFinite(frameTimeMs) + ? formatFrameTime(frameTimeMs) + : UNAVAILABLE_STAT_TEXT; + +const formatResolution = (width: number, height: number): string => + Number.isFinite(width) && Number.isFinite(height) + ? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}` + : ZERO_RESOLUTION_TEXT; diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts new file mode 100644 index 0000000..bee83ef --- /dev/null +++ b/src/game-loop/pointer-input.ts @@ -0,0 +1,249 @@ +import { vec2 } from 'gl-matrix'; + +import { GardenAudio } from '../audio/garden-audio'; +import { appConfig } from '../config'; +import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; +import { activeVibe } from '../settings'; +import { BrushStrokeSmoother } from './brush-stroke-smoother'; +import { type StrokeSegment } from './game-loop-types'; +import { getMirroredStrokeSegments } from './stroke-mirroring'; +import { type StrokeOutput } from './stroke-output'; + +interface GardenPointerInputOptions { + canvas: HTMLCanvasElement; + audio: GardenAudio; + strokeOutput: StrokeOutput; + getCanvasPixelRatio: () => number; + getMirrorSegmentCount: () => number; + onStartDrawing: () => void; + onEraseGestureEnded: () => void; + spawnStrokeAgents: (from: vec2, to: vec2) => void; +} + +interface PointerSample { + position: vec2; + previousPosition: vec2; + elapsedSeconds: number; + timeStamp: number; +} + +export class GardenPointerInput { + private readonly brushSmoother: BrushStrokeSmoother; + private activePointerId: number | null = null; + private lastPointerPosition: vec2 | null = null; + private lastPointerEventTimeMs: number | null = null; + private isErasing = false; + + public constructor(private readonly options: GardenPointerInputOptions) { + this.brushSmoother = new BrushStrokeSmoother({ + getCanvasPixelRatio: options.getCanvasPixelRatio, + getMirrorSegmentCount: options.getMirrorSegmentCount, + }); + } + + public attach(): void { + this.canvas.addEventListener('pointerdown', this.onPointerDown); + this.canvas.addEventListener('pointermove', this.onPointerMove); + this.canvas.addEventListener('pointerup', this.onPointerUp); + this.canvas.addEventListener('pointercancel', this.onPointerUp); + } + + public detach(): void { + this.canvas.removeEventListener('pointerdown', this.onPointerDown); + this.canvas.removeEventListener('pointermove', this.onPointerMove); + this.canvas.removeEventListener('pointerup', this.onPointerUp); + this.canvas.removeEventListener('pointercancel', this.onPointerUp); + } + + public setEraseMode(isErasing: boolean): void { + this.isErasing = isErasing; + } + + public clearSwipesIfIdle(): void { + if (this.isSwipeActive) { + return; + } + + this.options.strokeOutput.clearSwipes(); + } + + public scaleLastPointerPosition(scale: vec2): void { + if (this.lastPointerPosition !== null) { + vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale); + } + + this.brushSmoother.scale(scale); + } + + public get isSwipeActive(): boolean { + return this.activePointerId !== null; + } + + public get isEraseMode(): boolean { + return this.isErasing; + } + + private get canvas(): HTMLCanvasElement { + return this.options.canvas; + } + + private readonly onPointerDown = (event: PointerEvent) => { + if (this.activePointerId !== null) { + return; + } + + this.options.audio.beginGesture(); + this.options.onStartDrawing(); + this.activePointerId = event.pointerId; + this.canvas.setPointerCapture(event.pointerId); + this.lastPointerPosition = null; + this.lastPointerEventTimeMs = null; + this.brushSmoother.clear(); + this.addSwipeAt(event, { emitAudio: false }); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + if (event.pointerId !== this.activePointerId) { + return; + } + this.getCoalescedPointerEvents(event).forEach((coalescedEvent) => { + this.addSwipeAt(coalescedEvent); + }); + }; + + private readonly onPointerUp = (event: PointerEvent) => { + if (event.pointerId !== this.activePointerId) { + return; + } + this.addSwipeAt(event, { emitAudio: false }); + this.finishBrushStroke(); + this.options.audio.endGesture(); + if (this.isErasing) { + this.options.onEraseGestureEnded(); + } + try { + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + } finally { + this.activePointerId = null; + this.lastPointerPosition = null; + this.lastPointerEventTimeMs = null; + this.brushSmoother.clear(); + } + }; + + private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { + const sample = this.getPointerSample(event); + + if (this.isErasing) { + this.addEraseSample(sample); + } else { + this.addBrushSample(sample); + } + + if (options.emitAudio !== false) { + this.emitStrokeAudio(sample); + } + + this.lastPointerPosition = sample.position; + this.lastPointerEventTimeMs = sample.timeStamp; + } + + private getPointerSample(event: PointerEvent): PointerSample { + const position = this.getCanvasPointerPosition(event); + const previousPosition = this.lastPointerPosition ?? position; + const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; + const elapsedSeconds = Math.max( + appConfig.deltaTime.minDeltaTimeSeconds, + (event.timeStamp - previousTimeMs) / 1000 + ); + + return { + position, + previousPosition, + elapsedSeconds, + timeStamp: event.timeStamp, + }; + } + + private addBrushSample(sample: PointerSample): void { + this.emitBrushSegments(this.brushSmoother.addSample(sample.position)); + } + + private addEraseSample(sample: PointerSample): void { + this.options.strokeOutput.addEraseSegment(sample.previousPosition, sample.position); + } + + private emitStrokeAudio(sample: PointerSample): void { + this.options.audio.stroke({ + vibe: activeVibe, + from: sample.previousPosition, + to: sample.position, + canvasSize: [this.canvas.width, this.canvas.height], + isErasing: this.isErasing, + elapsedSeconds: sample.elapsedSeconds, + }); + } + + private getCanvasPointerPosition(event: PointerEvent): vec2 { + const rect = this.canvas.getBoundingClientRect(); + const xScale = getSafePixelRatio(this.canvas.width / rect.width); + const yScale = getSafePixelRatio(this.canvas.height / rect.height); + return vec2.fromValues( + (event.clientX - rect.left) * xScale, + (event.clientY - rect.top) * yScale + ); + } + + private emitBrushSegments(segments: Array): void { + segments.forEach((segment) => { + this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => { + this.options.strokeOutput.addBrushSegment( + mirroredSegment.from, + mirroredSegment.to + ); + this.options.spawnStrokeAgents(mirroredSegment.from, mirroredSegment.to); + }); + }); + } + + private finishBrushStroke(): void { + if (this.isErasing) { + return; + } + + this.emitBrushSegments(this.brushSmoother.finish()); + } + + private getCoalescedPointerEvents(event: PointerEvent): Array { + const getCoalescedEvents = ( + event as PointerEvent & { getCoalescedEvents?: () => Array } + ).getCoalescedEvents; + const coalescedEvents = + typeof getCoalescedEvents === 'function' ? getCoalescedEvents.call(event) : []; + + if (coalescedEvents.length === 0) { + return [event]; + } + + const lastEvent = coalescedEvents[coalescedEvents.length - 1]; + return isSamePointerSample(lastEvent, event) + ? coalescedEvents + : [...coalescedEvents, event]; + } + + private getMirroredSegments(from: vec2, to: vec2): Array { + return getMirroredStrokeSegments( + from, + to, + vec2.fromValues(this.canvas.width, this.canvas.height), + this.options.getMirrorSegmentCount() + ); + } +} + +const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean => + left.clientX === right.clientX && + left.clientY === right.clientY && + left.buttons === right.buttons; diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts new file mode 100644 index 0000000..36629e8 --- /dev/null +++ b/src/game-loop/simulation-frame.ts @@ -0,0 +1,144 @@ +import { appConfig } from '../config'; +import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; +import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; +import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; +import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; +import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; +import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { settings } from '../settings'; +import { CanvasReadbackRequest } from './game-loop-types'; +import { GpuProfiler } from './gpu-profiler'; +import { SimulationTextures } from './simulation-textures'; + +interface SimulationFramePipelines { + agentPipeline: AgentPipeline; + brushPipeline: BrushPipeline; + eraserAgentPipeline: EraserAgentPipeline; + eraserTexturePipeline: EraserTexturePipeline; + diffusionPipeline: DiffusionPipeline; + renderPipeline: RenderPipeline; +} + +export class SimulationFrameRenderer { + private sourceActiveFramesRemaining = 0; + private sourceMapsCleared = true; + + public constructor( + private readonly device: GPUDevice, + private readonly textures: SimulationTextures, + private readonly pipelines: SimulationFramePipelines, + private readonly gpuProfiler: GpuProfiler | null = null + ) {} + + public resetSourceMapActivity(): void { + this.sourceActiveFramesRemaining = 0; + this.sourceMapsCleared = true; + } + + public get isSourceMapActive(): boolean { + return this.sourceActiveFramesRemaining > 0; + } + + public execute( + isErasing: boolean, + canvasReadbackRequest?: CanvasReadbackRequest | null + ): void { + const commandEncoder = this.device.createCommandEncoder(); + this.gpuProfiler?.beginFrame(); + + // Clear the deposit map up-front so agents write fresh deposits each frame + // and diffuse sees only this frame's contributions added to trailMapA. + this.textures.clearDepositMap(commandEncoder); + let wroteSourceMap = false; + if (isErasing) { + if (this.pipelines.eraserAgentPipeline.hasActiveMask()) { + const eraserMask = this.textures.eraserMask.getTextureView(); + // Erase trailMapA directly — it's what agent and diffuse will read. + this.pipelines.eraserTexturePipeline.executeCombined( + commandEncoder, + eraserMask, + this.textures.sourceMapA.getTextureView(), + this.textures.trailMapA.getTextureView(), + this.gpuProfiler?.timestampWrites('eraserTexture') + ); + this.pipelines.eraserAgentPipeline.execute( + commandEncoder, + eraserMask, + this.gpuProfiler?.timestampWrites('eraserAgent') + ); + } + } else { + wroteSourceMap = this.pipelines.brushPipeline.executeSource( + commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.gpuProfiler?.timestampWrites('brush') + ); + } + + if (wroteSourceMap) { + this.sourceActiveFramesRemaining = getSourceActiveFrameCount(); + this.sourceMapsCleared = false; + } + + const useSourceMap = this.isSourceMapActive; + if (!useSourceMap && !this.sourceMapsCleared) { + this.textures.clearSourceMaps(commandEncoder); + this.sourceMapsCleared = true; + } + + this.pipelines.agentPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.depositMap.getTextureView(), + this.gpuProfiler?.timestampWrites('agent') + ); + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), + this.textures.trailMapA.getSize(), + this.textures.depositMap.getTextureView(), + this.gpuProfiler?.timestampWrites('trailDiffusion') + ); + const canvasTexture = this.pipelines.renderPipeline.execute( + commandEncoder, + this.textures.trailMapB.getTextureView(), + this.textures.sourceMapA.getTextureView(), + useSourceMap, + this.gpuProfiler?.timestampWrites('render') + ); + canvasReadbackRequest?.encode(commandEncoder, canvasTexture); + + if (useSourceMap) { + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.textures.sourceMapB.getTextureView(), + this.textures.sourceMapB.getSize(), + null, + this.gpuProfiler?.timestampWrites('sourceDiffusion') + ); + } + const afterGpuProfileSubmit = this.gpuProfiler?.resolve(commandEncoder); + this.device.queue.submit([commandEncoder.finish()]); + afterGpuProfileSubmit?.(); + canvasReadbackRequest?.afterSubmit(); + // After this frame's diffuse, trailMapB holds the fresh trail; swap so + // trailMapA is "current trail" again for the next frame and any external + // readers (e.g. export snapshot). + this.textures.swapTrailMaps(); + if (useSourceMap) { + this.textures.swapSourceMaps(); + this.sourceActiveFramesRemaining -= 1; + } + } +} + +const getSourceActiveFrameCount = (): number => { + const frameCount = + settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond; + if (Number.isFinite(frameCount) && frameCount > 0) { + return Math.ceil(frameCount); + } + return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite); +}; diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts new file mode 100644 index 0000000..7166595 --- /dev/null +++ b/src/game-loop/simulation-textures.ts @@ -0,0 +1,162 @@ +import { vec2 } from 'gl-matrix'; + +import { appConfig } from '../config'; +import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats'; +import { + ResizableTexture, + type PendingTextureResize, +} from '../utils/graphics/resizable-texture'; + +export class SimulationTextures { + // trailMapA holds the current trail (read by agent and diffuse). trailMapB + // receives the diffuse output; the two swap each frame so the freshly + // diffused texture becomes trailMapA for the next frame. + public trailMapA: ResizableTexture; + public trailMapB: ResizableTexture; + // Per-frame last-writer deposit map: cleared each frame, written sparsely by + // agents, then read by diffuse alongside trailMapA. + public readonly depositMap: ResizableTexture; + public readonly eraserMask: ResizableTexture; + public sourceMapA: ResizableTexture; + public sourceMapB: ResizableTexture; + + public constructor( + private readonly device: GPUDevice, + canvasSize: vec2 + ) { + this.trailMapA = this.createTexture(canvasSize); + this.trailMapB = this.createTexture(canvasSize); + this.depositMap = this.createTexture(canvasSize); + this.sourceMapA = this.createTexture(canvasSize); + this.sourceMapB = this.createTexture(canvasSize); + this.eraserMask = this.createEraserMask(canvasSize); + } + + public resizeTo(nextSize: vec2): vec2 | null { + const previousSize = this.trailMapA.getSize(); + if (vec2.equals(previousSize, nextSize)) { + return null; + } + + const scale = vec2.div(vec2.create(), nextSize, previousSize); + const resizes = [ + this.trailMapA, + this.trailMapB, + this.depositMap, + this.sourceMapA, + this.sourceMapB, + this.eraserMask, + ] + .map((texture): [ResizableTexture, PendingTextureResize] | null => { + const resize = texture.prepareResize(nextSize); + return resize ? [texture, resize] : null; + }) + .filter((resize): resize is [ResizableTexture, PendingTextureResize] => + Boolean(resize) + ); + + if (resizes.length > 0) { + const commandEncoder = this.device.createCommandEncoder(); + resizes.forEach(([texture, resize]) => { + texture.encodeResize(commandEncoder, resize); + }); + this.device.queue.submit([commandEncoder.finish()]); + resizes.forEach(([texture, resize]) => { + texture.commitResize(resize); + }); + } + + return scale; + } + + public clear(): void { + const commandEncoder = this.device.createCommandEncoder(); + [ + this.trailMapA, + this.trailMapB, + this.depositMap, + this.sourceMapA, + this.sourceMapB, + this.eraserMask, + ].forEach((texture) => { + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: texture.getTextureView(), + clearValue: appConfig.simulation.clearColor, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + passEncoder.end(); + }); + this.device.queue.submit([commandEncoder.finish()]); + } + + public clearDepositMap(commandEncoder: GPUCommandEncoder): void { + // Hardware fast-clear via a render pass with loadOp 'clear' and an empty + // body. Cheaper than copyTextureToTexture and writes no actual color data + // on tile-based GPUs. + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this.depositMap.getTextureView(), + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + passEncoder.end(); + } + + public swapTrailMaps(): void { + [this.trailMapA, this.trailMapB] = [this.trailMapB, this.trailMapA]; + } + + public clearSourceMaps(commandEncoder: GPUCommandEncoder): void { + // Only sourceMapA needs clearing — sourceMapB gets fully overwritten by + // the diffusion pass on the next active frame before it's ever sampled. + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: this.sourceMapA.getTextureView(), + clearValue: appConfig.simulation.clearColor, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + passEncoder.end(); + } + + public swapSourceMaps(): void { + [this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA]; + } + + public destroy(): void { + this.trailMapA.destroy(); + this.trailMapB.destroy(); + this.depositMap.destroy(); + this.sourceMapA.destroy(); + this.sourceMapB.destroy(); + this.eraserMask.destroy(); + } + + private createTexture(size: vec2): ResizableTexture { + return new ResizableTexture(this.device, size); + } + + private createEraserMask(size: vec2): ResizableTexture { + return new ResizableTexture(this.device, size, { + clearValue: { r: 1, g: 1, b: 1, a: 1 }, + format: ERASER_MASK_TEXTURE_FORMAT, + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.COPY_DST, + }); + } +} diff --git a/src/game-loop/stroke-mirroring.ts b/src/game-loop/stroke-mirroring.ts new file mode 100644 index 0000000..9bfb8d4 --- /dev/null +++ b/src/game-loop/stroke-mirroring.ts @@ -0,0 +1,42 @@ +import { vec2 } from 'gl-matrix'; + +import { type StrokeSegment } from './game-loop-types'; + +export const getMirroredStrokeSegments = ( + from: vec2, + to: vec2, + canvasSize: vec2, + segmentCount: number +): Array => { + if (segmentCount <= 1) { + return [{ from, to }]; + } + + const center = vec2.fromValues(canvasSize[0] / 2, canvasSize[1] / 2); + const angleStep = (Math.PI * 2) / segmentCount; + const segments: Array = []; + for (let i = 0; i < segmentCount; i++) { + const angle = angleStep * i; + segments.push({ + from: rotatePointAround(from, center, angle), + to: rotatePointAround(to, center, angle), + }); + } + + return segments; +}; + +const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => { + if (angle === 0) { + return point; + } + + const offsetX = point[0] - center[0]; + const offsetY = point[1] - center[1]; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return vec2.fromValues( + center[0] + offsetX * cos - offsetY * sin, + center[1] + offsetX * sin + offsetY * cos + ); +}; diff --git a/src/game-loop/stroke-output.ts b/src/game-loop/stroke-output.ts new file mode 100644 index 0000000..1e9650c --- /dev/null +++ b/src/game-loop/stroke-output.ts @@ -0,0 +1,34 @@ +import { vec2 } from 'gl-matrix'; + +import { type BrushPipeline } from '../pipelines/brush/brush-pipeline'; +import { type EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; +import { type EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; + +export interface StrokeOutput { + addBrushSegment(from: vec2, to: vec2): void; + addEraseSegment(from: vec2, to: vec2): void; + clearSwipes(): void; +} + +export class PipelineStrokeOutput implements StrokeOutput { + public constructor( + private readonly brushPipeline: BrushPipeline, + private readonly eraserAgentPipeline: EraserAgentPipeline, + private readonly eraserTexturePipeline: EraserTexturePipeline + ) {} + + public addBrushSegment(from: vec2, to: vec2): void { + this.brushPipeline.addSwipeSegment(from, to); + } + + public addEraseSegment(from: vec2, to: vec2): void { + this.eraserAgentPipeline.addSwipeSegment(from, to); + this.eraserTexturePipeline.addSwipeSegment(from, to); + } + + public clearSwipes(): void { + this.brushPipeline.clearSwipes(); + this.eraserAgentPipeline.clearSwipes(); + this.eraserTexturePipeline.clearSwipes(); + } +} diff --git a/src/game-loop/toolbar-contrast-monitor.ts b/src/game-loop/toolbar-contrast-monitor.ts new file mode 100644 index 0000000..8898123 --- /dev/null +++ b/src/game-loop/toolbar-contrast-monitor.ts @@ -0,0 +1,365 @@ +import { appConfig } from '../config'; +import { clamp01 } from '../utils/math'; +import type { CanvasReadbackRequest } from './game-loop-types'; + +interface CanvasSamplePoint { + x: number; + y: number; +} + +interface CanvasSampleRegion { + bytesPerRow: number; + height: number; + origin: CanvasSamplePoint; + sampleOffsets: Array; + width: number; +} + +interface ToolbarContrastMetrics { + averageLuminance: number; + backgroundOpacity: number; + brightRatio: number; + lowContrastRatio: number; +} + +const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity'; +const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength'; +const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256; + +const getLinearChannel = (channel: number): number => { + const normalized = channel / 255; + return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint + ? normalized / appConfig.toolbar.contrast.linearChannelDivisor + : ((normalized + appConfig.toolbar.contrast.linearChannelOffset) / + appConfig.toolbar.contrast.linearChannelScale) ** + appConfig.toolbar.contrast.linearChannelGamma; +}; + +const getRelativeLuminance = (red: number, green: number, blue: number): number => + appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) + + appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) + + appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue); + +const getToolbarContrastMetrics = ( + pixels: Uint8Array, + sampleOffsets: ReadonlyArray, + isBgra: boolean +): ToolbarContrastMetrics => { + const count = sampleOffsets.filter( + (offset) => + offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length + ).length; + if (count === 0) { + return { + averageLuminance: 0, + backgroundOpacity: 0, + brightRatio: 0, + lowContrastRatio: 0, + }; + } + + let luminanceTotal = 0; + let brightCount = 0; + let lowContrastCount = 0; + + sampleOffsets.forEach((offset) => { + if ( + offset < 0 || + offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length + ) { + return; + } + + const red = pixels[offset + (isBgra ? 2 : 0)]; + const green = pixels[offset + 1]; + const blue = pixels[offset + (isBgra ? 0 : 2)]; + const luminance = getRelativeLuminance(red, green, blue); + const contrastWithWhite = + appConfig.toolbar.contrast.whiteContrastNumerator / + (luminance + appConfig.toolbar.contrast.contrastOffset); + + luminanceTotal += luminance; + if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) { + brightCount++; + } + if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) { + lowContrastCount++; + } + }); + + const averageLuminance = luminanceTotal / count; + const brightRatio = brightCount / count; + const lowContrastRatio = lowContrastCount / count; + const backgroundStrength = clamp01( + Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) / + appConfig.toolbar.contrast.luminanceRange + + brightRatio * appConfig.toolbar.contrast.brightWeight + + lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight + ); + const backgroundOpacity = + backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax; + + return { + averageLuminance, + backgroundOpacity, + brightRatio, + lowContrastRatio, + }; +}; + +export class ToolbarContrastMonitor { + private readonly isBgra: boolean; + private isDestroyed = false; + private isReadbackPending = false; + private lastSampleAt = Number.NEGATIVE_INFINITY; + private readbackBuffer: GPUBuffer | null = null; + private readbackBufferSize = 0; + + public constructor( + private readonly canvas: HTMLCanvasElement, + private readonly toolbar: HTMLElement, + private readonly device: GPUDevice, + canvasFormat: GPUTextureFormat + ) { + this.isBgra = canvasFormat === 'bgra8unorm'; + } + + public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null { + if ( + this.isDestroyed || + this.isReadbackPending || + time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs + ) { + return null; + } + + const sampleRegion = this.getSampleRegion(); + if (sampleRegion.sampleOffsets.length === 0) { + return null; + } + + const bufferSize = sampleRegion.bytesPerRow * sampleRegion.height; + const buffer = this.getReadbackBuffer(bufferSize); + if (!buffer) { + return null; + } + + this.isReadbackPending = true; + this.lastSampleAt = time; + + let isCancelled = false; + let isEncoded = false; + const cancel = () => { + if (isCancelled) { + return; + } + + isCancelled = true; + this.isReadbackPending = false; + }; + + return { + encode: (commandEncoder, texture) => { + if (isCancelled) { + return; + } + + try { + commandEncoder.copyTextureToBuffer( + { + origin: sampleRegion.origin, + texture, + }, + { + buffer, + bytesPerRow: sampleRegion.bytesPerRow, + }, + { + depthOrArrayLayers: 1, + height: sampleRegion.height, + width: sampleRegion.width, + } + ); + isEncoded = true; + } catch { + cancel(); + } + }, + afterSubmit: () => { + if (isCancelled) { + return; + } + + if (!isEncoded) { + cancel(); + return; + } + + void this.readBuffer(buffer, sampleRegion.sampleOffsets); + }, + }; + } + + public destroy(): void { + this.isDestroyed = true; + this.readbackBuffer?.destroy(); + this.readbackBuffer = null; + this.readbackBufferSize = 0; + this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY); + this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY); + } + + private setToolbarBackgroundOpacity(backgroundOpacity: number): void { + const safeBackgroundOpacity = Math.min( + appConfig.toolbar.contrast.backgroundOpacityMax, + Math.max(0, backgroundOpacity) + ); + const backgroundStrength = + appConfig.toolbar.contrast.backgroundOpacityMax > 0 + ? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax) + : 0; + + this.toolbar.style.setProperty( + TOOLBAR_BACKGROUND_OPACITY_PROPERTY, + `${(safeBackgroundOpacity * 100).toFixed(1)}%` + ); + this.toolbar.style.setProperty( + TOOLBAR_BACKGROUND_STRENGTH_PROPERTY, + backgroundStrength.toFixed(3) + ); + } + + private getSampleRegion(): CanvasSampleRegion { + const emptyRegion = { + bytesPerRow: 0, + height: 0, + origin: { x: 0, y: 0 }, + sampleOffsets: [], + width: 0, + }; + const canvasRect = this.canvas.getBoundingClientRect(); + const toolbarRect = this.toolbar.getBoundingClientRect(); + if ( + canvasRect.width <= 0 || + canvasRect.height <= 0 || + toolbarRect.width <= 0 || + toolbarRect.height <= 0 + ) { + return emptyRegion; + } + + const left = Math.max(canvasRect.left, toolbarRect.left); + const right = Math.min(canvasRect.right, toolbarRect.right); + const top = Math.max(canvasRect.top, toolbarRect.top); + const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom); + if (left >= right || top >= bottom) { + return emptyRegion; + } + + const xScale = this.canvas.width / canvasRect.width; + const yScale = this.canvas.height / canvasRect.height; + const cssWidth = right - left; + const cssHeight = bottom - top; + const origin = { + x: Math.max(0, Math.floor((left - canvasRect.left) * xScale)), + y: Math.max(0, Math.floor((top - canvasRect.top) * yScale)), + }; + const regionRight = Math.min( + this.canvas.width, + Math.ceil((right - canvasRect.left) * xScale) + ); + const regionBottom = Math.min( + this.canvas.height, + Math.ceil((bottom - canvasRect.top) * yScale) + ); + const width = Math.max(0, regionRight - origin.x); + const height = Math.max(0, regionBottom - origin.y); + if (width === 0 || height === 0) { + return emptyRegion; + } + + const bytesPerRow = alignTo( + width * appConfig.toolbar.contrast.bytesPerSample, + GPU_COPY_BYTES_PER_ROW_ALIGNMENT + ); + const points = new Map(); + + for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) { + const cssY = + top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight; + const y = Math.min( + this.canvas.height - 1, + Math.max(0, Math.floor((cssY - canvasRect.top) * yScale)) + ); + + for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) { + const cssX = + left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth; + const x = Math.min( + this.canvas.width - 1, + Math.max(0, Math.floor((cssX - canvasRect.left) * xScale)) + ); + points.set(`${x}:${y}`, { x, y }); + } + } + + return { + bytesPerRow, + height, + origin, + sampleOffsets: [...points.values()].map( + (point) => + (point.y - origin.y) * bytesPerRow + + (point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample + ), + width, + }; + } + + private getReadbackBuffer(size: number): GPUBuffer | null { + if (this.readbackBuffer && this.readbackBufferSize >= size) { + return this.readbackBuffer; + } + + this.readbackBuffer?.destroy(); + try { + this.readbackBuffer = this.device.createBuffer({ + size, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + this.readbackBufferSize = size; + return this.readbackBuffer; + } catch { + this.readbackBuffer = null; + this.readbackBufferSize = 0; + return null; + } + } + + private async readBuffer( + buffer: GPUBuffer, + sampleOffsets: Array + ): Promise { + let isMapped = false; + try { + await buffer.mapAsync(GPUMapMode.READ); + isMapped = true; + + if (!this.isDestroyed) { + const pixels = new Uint8Array(buffer.getMappedRange()); + const metrics = getToolbarContrastMetrics(pixels, sampleOffsets, this.isBgra); + this.setToolbarBackgroundOpacity(metrics.backgroundOpacity); + } + } catch { + // Readback is an enhancement; leave rendering alone if the GPU rejects it. + } finally { + if (isMapped) { + buffer.unmap(); + } + this.isReadbackPending = false; + } + } +} + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; diff --git a/src/index.scss b/src/index.scss index 8c4500a..d6d8b85 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,275 +1,8 @@ -@use 'style/mixins' as *; @use 'style/common'; - -html > body { - width: 100%; - height: 100%; - display: flex; - position: relative; - - > .canvas-container { - height: 100%; - width: 100%; - display: flex; - - > canvas { - height: 100%; - width: 100%; - touch-action: none; - cursor: - url('../assets/icons/brush.svg') 0 24, - auto; - } - - > .errors-container { - position: absolute; - top: 0; - left: 0; - margin: var(--normal-margin); - - pre { - font-size: 20px; - color: red; - } - } - - .counters { - @include blurred-background(white); - position: absolute; - border-radius: var(--border-radius); - padding: var(--small-margin); - - @include on-large-screen { - top: var(--normal-margin); - right: var(--normal-margin); - } - - @include on-small-screen { - bottom: var(--normal-margin); - right: var(--normal-margin); - } - } - } - - > aside { - @include blurred-background(#fff); - box-shadow: var(--shadow); - display: flex; - position: absolute; - overflow: hidden; - - @include on-large-screen { - top: 50%; - left: 0; - transform: translateY(-50%); - max-height: 350px; - } - - @include on-small-screen { - top: 0; - left: 50%; - transform: translateX(-50%); - flex-direction: column; - } - - transition: opacity var(--transition-time-long); - border-radius: var(--border-radius); - margin: var(--small-margin); - - > nav.buttons { - @include center-children; - justify-content: space-evenly; - - @include on-large-screen { - flex-direction: column; - } - - > button { - position: relative; - border: none; - background-color: transparent; - cursor: pointer; - - @include square(var(--icon-size)); - margin: var(--small-margin); - - &::before, - &::after { - content: ''; - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - } - - &::before { - background-color: var(--accent-color); - - @include on-large-screen { - width: 0; - border-radius: 0 var(--border-radius) var(--border-radius) 0; - transition: - background-color var(--transition-time), - width var(--transition-time); - left: calc(-1 * var(--small-margin)); - height: 140%; - top: 50%; - transform: translateY(-50%); - } - - @include on-small-screen { - height: 0; - border-radius: 0 0 var(--border-radius) var(--border-radius); - transition: - background-color var(--transition-time), - height var(--transition-time); - top: calc(-1 * var(--small-margin)); - width: 140%; - left: 50%; - transform: translateX(-50%); - } - } - - &::after { - background-color: var(--accent-color); - - transition: - transform var(--transition-time), - background-color var(--transition-time); - - mask-repeat: no-repeat; - - @include square(var(--icon-size)); - } - - &.active { - &::before { - @include on-large-screen { - width: calc(100% + 2 * var(--small-margin)); - } - - @include on-small-screen { - height: calc(100% + 2 * var(--small-margin)); - } - } - - &::after { - background-color: white; - } - } - - &:hover::after { - transform: scale(1.15); - } - - &.info::after { - mask-image: url('../assets/icons/info.svg'); - } - &.maximize-full-screen::after { - mask-image: url('../assets/icons/maximize.svg'); - } - &.minimize-full-screen::after { - mask-image: url('../assets/icons/minimize.svg'); - } - &.settings::after { - mask-image: url('../assets/icons/settings.svg'); - } - &.restart::after { - mask-image: url('../assets/icons/restart.svg'); - } - } - } - - > main.pages { - overflow-x: hidden; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--main-color) transparent; - &::-webkit-scrollbar-track, - &::-webkit-scrollbar { - background-color: transparent; - width: 6px; - } - &::-webkit-scrollbar-thumb { - background-color: var(--main-color); - border-radius: var(--border-radius); - } - - &, - > * { - transition: - width var(--transition-time-long), - height var(--transition-time-long); - - @include on-large-screen { - width: max(500px, 10vw); - } - - @include on-small-screen { - height: max(500px, 70vh); - } - } - - &.hidden { - @include on-large-screen { - width: 0; - } - - @include on-small-screen { - height: 0; - } - } - - > section { - padding: var(--normal-margin); - display: flex; - flex-direction: column; - - .slider { - $track-height: 12px; - margin-bottom: var(--small-margin); - user-select: none; - - p { - display: flex; - justify-content: space-between; - } - - input[type='range'] { - width: 100%; - height: $track-height; - appearance: none; - background: transparent; - outline: none; - border-radius: 1000px; - - &::-webkit-slider-runnable-track { - appearance: none; - cursor: pointer; - border-radius: 1000px; - @include square(15px); - background: var(--accent-color); - } - - &::-webkit-slider-thumb { - appearance: none; - cursor: pointer; - border-radius: 1000px; - $size: 24px; - @include square($size); - background: white; - box-shadow: 0 0 5px 1px var(--accent-color); - - transform: translateY(-5px); - transition: transform var(--transition-time); - &:hover { - transform: translateY(-5px) scale(1.1); - } - } - } - } - } - } - } -} +@use 'style/app-shell'; +@use 'style/garden-prompt'; +@use 'style/control-dock'; +@use 'style/toolbar'; +@use 'style/config-pane'; +@use 'style/panels'; +@use 'style/loading'; diff --git a/src/index.ts b/src/index.ts index 1e2c2d9..74b98d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,107 +1,251 @@ -import { isProduction } from './constants'; import GameLoop from './game-loop/game-loop'; -import { GameRules } from './game-loop/game-rules'; import './index.scss'; +import { initAnalytics, trackExport, trackStart, trackVibeChange } from './analytics'; +import { preloadPianoSamples } from './audio/piano-samples'; +import { AudioControl } from './page/audio-control'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; +import { ConfigPane } from './page/config-pane'; +import { EraserSizeControl } from './page/eraser-size-control'; +import { ErrorPresenter } from './page/error-presenter'; import { FullScreenHandler } from './page/full-screen-handler'; import { MenuHider } from './page/menu-hider'; -import { setUpSettingsPage } from './page/set-up-settings-page'; -import { SettingsSlider } from './page/settings-slider'; -import { resetSettings } from './settings'; +import { MirrorSegmentControl } from './page/mirror-segment-control'; +import { PaletteControl } from './page/palette-control'; +import { SplashScreen } from './page/splash-screen'; +import { VibeNavigator } from './page/vibe-navigator'; +import { getMaxSupportedAgentCount } from './pipelines/agents/agent-limits'; +import { activeVibe } from './settings'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; +import { queryRequiredElement } from './utils/dom'; import { ErrorHandler, Severity } from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; -const elements = { - aside: document.querySelector('aside') as HTMLDivElement, - infoButton: document.querySelector('button.info') as HTMLButtonElement, - infoElement: document.querySelector('.info-page') as HTMLDivElement, - settingsPage: document.querySelector('.settings-page') as HTMLDivElement, - settingsContent: document.querySelector('.settings-content') as HTMLDivElement, - applyDefaults: document.querySelector('#apply-defaults') as HTMLButtonElement, - minimizeFullScreenButton: document.querySelector( - 'button.minimize-full-screen' - ) as HTMLButtonElement, - maximizeFullScreenButton: document.querySelector( - 'button.maximize-full-screen' - ) as HTMLButtonElement, - settingsButton: document.querySelector('button.settings') as HTMLButtonElement, - restartButton: document.querySelector('button.restart') as HTMLButtonElement, - canvas: document.querySelector('canvas') as HTMLCanvasElement, - errorContainer: document.querySelector('.errors-container') as HTMLDivElement, -}; - const main = async () => { + let hasRuntimeErrorListener = false; try { + initAnalytics(); + let shouldStop = false; + let hasStarted = false; let game: GameLoop | null = null; - - ErrorHandler.addOnErrorListener((error, _metadata) => { - elements.errorContainer.innerHTML += ` -
${error.message}
-      `;
-      game?.destroy();
-      shouldStop = true;
-    });
-
-    const infoPageHandler = new CollapsiblePanelAnimator(
-      elements.infoButton,
-      elements.infoElement,
-      elements.aside
-    );
-    const settingsPageHandler = new CollapsiblePanelAnimator(
-      elements.settingsButton,
-      elements.settingsPage,
-      elements.aside
-    );
-    settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
-    infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
-
-    if (isProduction) {
-      infoPageHandler.open();
-    }
-
-    new MenuHider(
-      elements.aside,
-      () =>
-        FullScreenHandler.isInFullScreenMode() &&
-        !settingsPageHandler.isOpen &&
-        !infoPageHandler.isOpen
-    );
-    new FullScreenHandler(
-      elements.minimizeFullScreenButton,
-      elements.maximizeFullScreenButton,
-      document.body
-    );
-
-    const gpu = await initializeGpu();
-
-    elements.restartButton.addEventListener('click', () => game?.destroy());
-
-    const deltaTimeCalculator = new DeltaTimeCalculator();
-    let sliders: Array> = [];
-
-    elements.applyDefaults.addEventListener('click', () => {
-      resetSettings();
-      sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
-    });
-
-    while (!shouldStop) {
-      const gameRules = new GameRules(performance.now() / 1000);
-      game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
-
-      if (sliders.length === 0) {
-        sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
+    let configPane: ConfigPane | null = null;
+    const getGame = () => game;
+    const destroyCurrentGame = async () => {
+      const currentGame = game;
+      if (!currentGame) {
+        return;
       }
 
-      await game.start();
+      game = null;
+      await currentGame.destroy();
+    };
+
+    const errorPresenter = new ErrorPresenter(
+      queryRequiredElement('.errors-container', HTMLElement)
+    );
+    ErrorHandler.addOnErrorListener((error) => {
+      errorPresenter.render(error);
+      if (error.severity === Severity.ERROR) {
+        document.body.classList.remove('is-loading');
+        void destroyCurrentGame();
+        shouldStop = true;
+      }
+    });
+    hasRuntimeErrorListener = true;
+
+    const aside = queryRequiredElement('aside', HTMLElement);
+    const canvas = queryRequiredElement('canvas', HTMLCanvasElement);
+    const toolbarRow = queryRequiredElement('.toolbar-row', HTMLElement);
+    const eraserPreview = queryRequiredElement('.eraser-preview', HTMLDivElement);
+    const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement);
+    const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement);
+    const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement);
+    const settingsButton = queryRequiredElement(
+      '[data-control="settings"]',
+      HTMLButtonElement
+    );
+    const restartButton = queryRequiredElement(
+      '[data-control="restart"]',
+      HTMLButtonElement
+    );
+    const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
+    const infoElement = queryRequiredElement('.info-page', HTMLElement);
+    const fullScreenButton = queryRequiredElement(
+      '[data-control="full-screen"]',
+      HTMLButtonElement
+    );
+    const export4kButton = queryRequiredElement(
+      '[data-control="export"]',
+      HTMLButtonElement
+    );
+
+    const splash = new SplashScreen();
+    let eraserSizeControl: EraserSizeControl | null = null;
+    const paletteControl = new PaletteControl({
+      getGame,
+      onChange: () => configPane?.refresh(),
+      onModeChange: (isEraserActive) => eraserSizeControl?.setActive(isEraserActive),
+    });
+    eraserSizeControl = new EraserSizeControl({
+      getGame,
+      onActivate: () => paletteControl.setEraserActive(true),
+      onChange: () => configPane?.refresh(),
+    });
+    const mirrorSegmentControl = new MirrorSegmentControl({
+      onChange: () => {
+        paletteControl.setEraserActive(false);
+        configPane?.refresh();
+      },
+    });
+    const audioControl = new AudioControl({
+      getGame,
+      hasStarted: () => hasStarted,
+      startButton: splash.startButton,
+    });
+
+    const syncRuntimeUi = () => {
+      eraserSizeControl?.render();
+      eraserSizeControl?.setActive(paletteControl.isEraserActive);
+      mirrorSegmentControl.render();
+      paletteControl.render();
+    };
+
+    const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
+    new MenuHider(
+      aside,
+      () =>
+        FullScreenHandler.isInFullScreenMode() &&
+        !configPane?.isOpen &&
+        !infoPageHandler.isOpen
+    );
+    new FullScreenHandler(fullScreenButton, document.documentElement);
+
+    new VibeNavigator({
+      onChange: ({ vibeId, vibeName, source, userGesture }) => {
+        trackVibeChange({ vibeId, vibeName, source });
+        game?.onVibeChanged();
+        syncRuntimeUi();
+        configPane?.refresh();
+        game?.playVibeChangeAudio(userGesture);
+      },
+    });
+
+    restartButton.addEventListener('click', () => void destroyCurrentGame());
+
+    export4kButton.addEventListener('click', async () => {
+      const currentGame = game;
+      if (!currentGame || export4kButton.disabled) {
+        return;
+      }
+
+      export4kButton.disabled = true;
+      try {
+        await currentGame.exportSnapshot();
+        trackExport({ vibeId: activeVibe.id });
+      } catch (error) {
+        ErrorHandler.addException(error, { severity: Severity.WARNING });
+      } finally {
+        export4kButton.disabled = false;
+      }
+    });
+
+    // Samples load before Start is enabled so the first audible piano note
+    // always uses the sampler. The Start tap still unlocks the AudioContext.
+    splash.showLoadingBar();
+    const fontsReady = document.fonts.ready.catch((error) => {
+      ErrorHandler.addException(error, {
+        fallbackMessage: 'Could not load fonts.',
+        severity: Severity.WARNING,
+      });
+    });
+    const gpuPromise = initializeGpu();
+
+    const preloadPromise = preloadPianoSamples(({ loadedCount, totalCount }) => {
+      const ratio = totalCount > 0 ? loadedCount / totalCount : 0;
+      splash.setLoadingStage(
+        `Loading piano samples ${loadedCount}/${totalCount}…`,
+        ratio
+      );
+    }).then(
+      () => {
+        splash.setLoadingStage('Ready', 1);
+      },
+      (error: unknown) => {
+        splash.setLoadingStage('Piano unavailable', 1);
+        ErrorHandler.addException(error, {
+          fallbackMessage: 'Could not preload piano samples.',
+          severity: Severity.WARNING,
+        });
+      }
+    );
+
+    const gpu = await gpuPromise;
+    const gpuNavigator = navigator.gpu;
+    if (!gpuNavigator) {
+      throw new Error('WebGPU is no longer available after initialization.');
+    }
+    const canvasFormat = gpuNavigator.getPreferredCanvasFormat();
+    configPane = new ConfigPane({
+      maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
+      settingsButton,
+      onOpen: () => infoPageHandler.close(),
+      onConfigChange: () => {
+        game?.onVibeChanged();
+        syncRuntimeUi();
+      },
+      onRuntimeChange: syncRuntimeUi,
+    });
+    infoPageHandler.onOpen = configPane.close.bind(configPane);
+    await fontsReady;
+    await preloadPromise;
+    splash.hideLoadingBar();
+
+    const deltaTimeCalculator = new DeltaTimeCalculator();
+
+    let isFirstStart = true;
+    while (!shouldStop) {
+      const loop = new GameLoop(canvas, gpu, canvasFormat, deltaTimeCalculator, {
+        toolbar: toolbarRow,
+        prompt: promptElement,
+        eraserPreview,
+        grainOverlay,
+        exportStatus,
+      });
+      game = loop;
+      syncRuntimeUi();
+      audioControl.render();
+
+      if (isFirstStart) {
+        isFirstStart = false;
+
+        // Splash is in the DOM by default; enable the button now that the
+        // audio system (GameLoop) is constructed and ready to be unlocked.
+        await splash.awaitStart(() => {
+          hasStarted = true;
+          game?.startAudio(true);
+          trackStart();
+        });
+
+        requestAnimationFrame(() =>
+          requestAnimationFrame(() => document.body.classList.remove('is-loading'))
+        );
+      }
+      loop.attachPointerInput();
+      await loop.start();
+      if (game === loop) {
+        game = null;
+      }
     }
   } catch (e) {
-    const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
-    ErrorHandler.addError(Severity.ERROR, message);
-    console.error(e);
+    document.body.classList.remove('is-loading');
+    if (hasRuntimeErrorListener) {
+      ErrorHandler.addException(e);
+    } else {
+      ErrorPresenter.renderStartup(e);
+      ErrorHandler.addException(e);
+    }
   }
 };
 
diff --git a/src/page/audio-control.ts b/src/page/audio-control.ts
new file mode 100644
index 0000000..7677bad
--- /dev/null
+++ b/src/page/audio-control.ts
@@ -0,0 +1,152 @@
+import { appConfig } from '../config';
+import type GameLoop from '../game-loop/game-loop';
+import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
+import { queryRequiredElement } from '../utils/dom';
+import { clamp01 } from '../utils/math';
+
+const clampAudioVolume = (value: number): number => {
+  const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
+  const safeValue = Number.isFinite(value) ? value : defaultVolume;
+  return Math.min(max, Math.max(min, clamp01(safeValue)));
+};
+
+const readInitialAudioVolume = (): number => {
+  const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
+  return storedVolume === null
+    ? appConfig.toolbar.volume.default
+    : clampAudioVolume(Number(storedVolume));
+};
+
+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;
+  startButton: HTMLElement;
+}
+
+export class AudioControl {
+  private readonly soundButton = queryRequiredElement(
+    '[data-control="sound"]',
+    HTMLButtonElement
+  );
+  private readonly volumeControl = queryRequiredElement(
+    '.volume-control',
+    HTMLLabelElement
+  );
+  private readonly volumeSlider = queryRequiredElement(
+    '.volume-slider',
+    HTMLInputElement
+  );
+
+  private audioVolume = readInitialAudioVolume();
+  private isMutedState =
+    readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
+    this.audioVolume <= 0;
+
+  public constructor(private readonly options: AudioControlOptions) {
+    this.soundButton.addEventListener('click', this.onToggleMute);
+    this.volumeSlider.addEventListener('input', this.onVolumeInput);
+
+    const passiveCaptureOptions = { capture: true, passive: true } as const;
+    const captureOptions = { capture: true } as const;
+    (
+      [
+        ['touchstart', passiveCaptureOptions],
+        ['pointerdown', passiveCaptureOptions],
+        ['touchend', passiveCaptureOptions],
+        ['pointerup', passiveCaptureOptions],
+        ['click', captureOptions],
+        ['keydown', captureOptions],
+      ] satisfies Array<[keyof WindowEventMap, AddEventListenerOptions]>
+    ).forEach(([event, opts]) => {
+      window.addEventListener(event, this.onUserGesture, opts);
+    });
+
+    this.render();
+  }
+
+  public get isMuted(): boolean {
+    return this.isMutedState || this.audioVolume <= 0;
+  }
+
+  public render(): void {
+    this.audioVolume = clampAudioVolume(this.audioVolume);
+    const isEffectivelyMuted = this.isMuted;
+    const volumePercent = Math.round(this.audioVolume * 100);
+
+    this.soundButton.classList.toggle('muted', isEffectivelyMuted);
+    this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
+    const muteLabel = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
+    this.soundButton.setAttribute('aria-label', muteLabel);
+    this.soundButton.title = muteLabel;
+
+    this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
+    this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
+    this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
+    this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
+    this.volumeSlider.setAttribute(
+      'aria-valuetext',
+      isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
+    );
+    this.volumeControl.classList.toggle('muted', isEffectivelyMuted);
+    this.volumeControl.title = isEffectivelyMuted
+      ? `Muted, ${volumePercent}% volume`
+      : `${volumePercent}% volume`;
+    this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
+
+    const game = this.options.getGame();
+    game?.setAudioVolume(this.audioVolume);
+    game?.setAudioMuted(isEffectivelyMuted);
+  }
+
+  private readonly onToggleMute = () => {
+    const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
+    if (shouldUnmute && this.audioVolume <= 0) {
+      this.audioVolume = appConfig.toolbar.volume.default;
+    }
+    this.isMutedState = !shouldUnmute;
+    this.persist();
+    this.render();
+    if (!this.isMutedState) {
+      this.options.getGame()?.startAudio(true);
+    }
+  };
+
+  private readonly onVolumeInput = () => {
+    this.audioVolume = clampAudioVolume(Number(this.volumeSlider.value));
+    this.isMutedState = this.audioVolume <= 0;
+    this.persist();
+    this.render();
+    if (!this.isMutedState) {
+      this.options.getGame()?.startAudio(true);
+    }
+  };
+
+  private readonly onUserGesture = (event: Event) => {
+    if (
+      !this.options.hasStarted() ||
+      this.isMutedState ||
+      (event.target instanceof Node && this.options.startButton.contains(event.target)) ||
+      (event.target instanceof Node && this.soundButton.contains(event.target))
+    ) {
+      return;
+    }
+    this.options.getGame()?.startAudio(true);
+  };
+
+  private persist(): void {
+    writeBrowserStorage(
+      appConfig.storage.audioMutedKey,
+      this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
+    );
+    writeBrowserStorage(
+      appConfig.storage.audioVolumeKey,
+      formatStoredAudioVolume(this.audioVolume)
+    );
+  }
+}
diff --git a/src/page/collapsible-panel-animator.ts b/src/page/collapsible-panel-animator.ts
index d4c91fa..a86a637 100644
--- a/src/page/collapsible-panel-animator.ts
+++ b/src/page/collapsible-panel-animator.ts
@@ -1,44 +1,90 @@
 export class CollapsiblePanelAnimator {
   private _isOpen = false;
-
-  public onOpen: () => unknown = () => {};
-  public onClose: () => unknown = () => {};
+  private focusBeforeOpen: HTMLElement | null = null;
+  private readonly abortController = new AbortController();
+  public onOpen?: () => void;
 
   public constructor(
     private readonly toggleButton: HTMLButtonElement,
     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 }
+    );
+    this.syncAccessibility();
   }
 
   public open() {
+    if (this._isOpen) {
+      return;
+    }
+
+    this.focusBeforeOpen =
+      document.activeElement instanceof HTMLElement ? document.activeElement : null;
     this._isOpen = true;
-    this.collapsibleContent.classList.remove('hidden');
-    this.toggleButton.classList.add('active');
-    this.onOpen();
+    this.onOpen?.();
+    this.syncAccessibility();
+    this.focusPanel();
   }
 
   public close() {
+    if (!this._isOpen) {
+      return;
+    }
+
+    const focusWasInside = this.collapsibleContent.contains(document.activeElement);
     this._isOpen = false;
-    this.collapsibleContent.classList.add('hidden');
-    this.toggleButton.classList.remove('active');
-    this.onClose();
+    this.syncAccessibility();
+
+    if (focusWasInside) {
+      (this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
+    }
   }
 
-  public toggle() {
+  public readonly toggle = () => {
     if (this._isOpen) {
       this.close();
     } else {
       this.open();
     }
+  };
+
+  public destroy(): void {
+    this.abortController.abort();
   }
 
   public get isOpen() {
     return this._isOpen;
   }
+
+  private syncAccessibility() {
+    this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
+    this.toggleButton.classList.toggle('active', this._isOpen);
+    this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
+    this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
+    this.collapsibleContent.inert = !this._isOpen;
+  }
+
+  private focusPanel() {
+    requestAnimationFrame(() => {
+      if (this._isOpen) {
+        this.collapsibleContent.focus({ preventScroll: true });
+      }
+    });
+  }
 }
diff --git a/src/page/color-reaction-matrix-control.ts b/src/page/color-reaction-matrix-control.ts
new file mode 100644
index 0000000..ca78cbb
--- /dev/null
+++ b/src/page/color-reaction-matrix-control.ts
@@ -0,0 +1,187 @@
+import type { FolderApi } from '@tweakpane/core';
+
+import { appConfig, normalizeNumberControlValue } from '../config';
+import { activeVibe, settings } from '../settings';
+import { rgbColorToCss } from '../utils/rgb-color';
+
+type PaneContainer = Pick;
+type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
+
+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 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 ColorReactionMatrixControl {
+  private readonly buttons = new Map<
+    ColorReactionKey,
+    {
+      element: HTMLButtonElement;
+      sourceColorIndex: number;
+      targetColorIndex: number;
+    }
+  >();
+  private readonly swatches: Array<{
+    colorIndex: number;
+    element: HTMLElement;
+  }> = [];
+
+  public constructor(private readonly onRuntimeChange: () => void) {}
+
+  public addTo(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.createCorner());
+    colorReactionRows.forEach((row) => {
+      matrix.appendChild(this.createHeader(row.colorIndex, row.label));
+    });
+
+    colorReactionRows.forEach((row) => {
+      matrix.appendChild(this.createHeader(row.colorIndex, row.label));
+      row.keys.forEach((key, columnIndex) => {
+        matrix.appendChild(this.createCell(key, row.colorIndex, columnIndex));
+      });
+    });
+
+    const matrixBlade = folder.addBlade({ view: 'separator' });
+    matrixBlade.element.classList.add('color-reaction-matrix-blade');
+    matrixBlade.element.replaceChildren(matrix);
+    this.sync();
+  }
+
+  public sync(): void {
+    this.buttons.forEach(({ element, sourceColorIndex, targetColorIndex }, key) => {
+      this.syncButton(element, key, sourceColorIndex, targetColorIndex);
+    });
+
+    this.swatches.forEach(({ colorIndex, element }) => {
+      element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
+    });
+  }
+
+  private createCorner(): HTMLDivElement {
+    const corner = document.createElement('div');
+    corner.className = 'color-reaction-matrix__corner';
+    return corner;
+  }
+
+  private createHeader(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.swatches.push({ colorIndex, element: swatch });
+    header.appendChild(swatch);
+
+    return header;
+  }
+
+  private createCell(
+    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.syncButton(button, key, sourceColorIndex, targetColorIndex);
+      this.onRuntimeChange();
+    });
+
+    this.buttons.set(key, {
+      element: button,
+      sourceColorIndex,
+      targetColorIndex,
+    });
+    cell.appendChild(button);
+
+    return cell;
+  }
+
+  private syncButton(
+    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`;
+  }
+}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
new file mode 100644
index 0000000..8bb7f28
--- /dev/null
+++ b/src/page/config-pane.ts
@@ -0,0 +1,395 @@
+import type { BindingParams, FolderApi } from '@tweakpane/core';
+import { Pane } from 'tweakpane';
+
+import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
+import {
+  appConfig,
+  normalizeNumberControlValue,
+  type GardenRuntimeSettings,
+  type NumberControlConfig,
+} from '../config';
+import { activeVibe, settings } from '../settings';
+import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
+import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
+
+type PaneContainer = Pick;
+type RuntimeControlKey = keyof GardenRuntimeSettings & string;
+type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
+type NumberPropertyKey = {
+  [Key in keyof T]-?: T[Key] extends number ? Key : never;
+}[keyof T] &
+  string;
+type VibeNumberKey = NumberPropertyKey;
+
+interface PaneState extends GardenAudioVibeSettings {
+  backgroundColor: string;
+  color1: string;
+  color2: string;
+  color3: string;
+}
+
+const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
+
+const MUSIC_CONTROLS: ReadonlyArray<{
+  key: VibeNumberKey;
+  label: string;
+  min: number;
+  max: number;
+  step: number;
+}> = [
+  { key: 'idleIntensity', label: 'Ambient Notes', min: 0, max: 1, step: 0.01 },
+  { key: 'bpm', label: 'Tempo', min: 48, max: 150, step: 1 },
+  { key: 'rampUpIntensity', label: 'Touch Energy', min: 0, max: 2, step: 0.01 },
+  { key: 'rampUpTime', label: 'Response Time', min: 0.01, max: 0.4, step: 0.01 },
+  { key: 'noteLength', label: 'Note Length', min: 0.1, max: 1.8, step: 0.01 },
+  { key: 'notePitchOffset', label: 'Pitch Shift', min: -12, max: 12, step: 1 },
+  { key: 'brightness', label: 'Tone Brightness', min: 0.5, max: 1.5, step: 0.01 },
+];
+
+interface ConfigPaneOptions {
+  maxSupportedAgentCount: number;
+  onConfigChange: () => void;
+  onOpen?: () => void;
+  onRuntimeChange: () => void;
+  settingsButton: HTMLButtonElement;
+}
+
+const getRuntimeControlKeys = (folder: string): Array =>
+  (
+    Object.entries(appConfig.runtimeSettings.controls) as Array<
+      [RuntimeControlKey, NumberControlConfig | undefined]
+    >
+  )
+    .filter(([, config]) => config?.folder === folder)
+    .map(([key]) => key);
+
+const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
+  const params: BindingParams = {
+    label: config.label,
+    options: config.options,
+    step: config.step,
+  };
+  if (config.format !== undefined) {
+    params.format = config.format;
+  }
+  if (config.min !== undefined) {
+    params.min = config.min;
+  }
+  if (config.max !== undefined) {
+    params.max = config.max;
+  }
+  return params;
+};
+
+const getInvertedNumberControlValue = (
+  value: number,
+  config: NumberControlConfig
+): number => {
+  if (!config.inverted || config.min === undefined || config.max === undefined) {
+    return value;
+  }
+  return config.min + config.max - value;
+};
+
+export class ConfigPane {
+  private readonly container: HTMLDivElement;
+  private readonly closeButton: HTMLButtonElement;
+  private readonly colorReactionMatrix: ColorReactionMatrixControl;
+  private readonly pane: Pane;
+  private readonly state: PaneState = {
+    backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
+    color1: rgbColorToHex(activeVibe.colors[0]),
+    color2: rgbColorToHex(activeVibe.colors[1]),
+    color3: rgbColorToHex(activeVibe.colors[2]),
+    ...activeVibe.audio,
+  };
+
+  public constructor(private readonly options: ConfigPaneOptions) {
+    this.colorReactionMatrix = new ColorReactionMatrixControl(
+      this.options.onRuntimeChange
+    );
+    this.container = document.createElement('div');
+    this.container.className = 'config-pane-container';
+
+    this.closeButton = document.createElement('button');
+    this.closeButton.className = 'config-pane-close';
+    this.closeButton.type = 'button';
+    this.closeButton.setAttribute('aria-label', 'Hide config overlay');
+    this.closeButton.title = 'Hide config overlay';
+    this.closeButton.addEventListener('click', () => this.setHidden(true));
+    this.container.appendChild(this.closeButton);
+
+    document.body.appendChild(this.container);
+
+    this.pane = new Pane({
+      container: this.container,
+      title: appConfig.tuningPane.title,
+      expanded: true,
+    });
+    this.pane.hidden = appConfig.tuningPane.startHidden;
+    this.pane.element.classList.add('config-pane');
+    this.pane.element.id = 'config-pane';
+
+    this.options.settingsButton.setAttribute('aria-controls', this.pane.element.id);
+    this.options.settingsButton.addEventListener('click', this.toggle);
+    document.addEventListener('pointerdown', this.dismissOnOutsidePointerDown, {
+      passive: true,
+    });
+    document.addEventListener('keydown', this.dismissOnEscape);
+
+    this.setUpTuningPane(this.pane);
+    this.syncOpenState();
+  }
+
+  public get isOpen(): boolean {
+    return !this.pane.hidden;
+  }
+
+  public refresh(): void {
+    this.syncVibeState();
+    this.pane.refresh();
+    this.colorReactionMatrix.sync();
+    this.syncOpenState();
+  }
+
+  public close(): void {
+    this.setHidden(true);
+  }
+
+  private readonly toggle = () => {
+    this.setHidden(!this.pane.hidden);
+  };
+
+  private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
+    if (!this.isOpen || !(event.target instanceof Node)) {
+      return;
+    }
+
+    if (
+      this.container.contains(event.target) ||
+      this.options.settingsButton.contains(event.target)
+    ) {
+      return;
+    }
+
+    this.setHidden(true);
+  };
+
+  private readonly dismissOnEscape = (event: KeyboardEvent) => {
+    if (event.key === 'Escape' && this.isOpen) {
+      this.setHidden(true);
+    }
+  };
+
+  private setHidden(isHidden: boolean): void {
+    const wasOpen = this.isOpen;
+    this.pane.hidden = isHidden;
+    this.syncOpenState();
+    if (!wasOpen && this.isOpen) {
+      this.options.onOpen?.();
+    }
+  }
+
+  private setUpTuningPane(container: PaneContainer): void {
+    this.setUpVibeSection(container);
+    this.addRuntimeSection(container, runtimeFolderOrder[0], true);
+    this.addRuntimeSection(container, runtimeFolderOrder[1], true);
+    this.colorReactionMatrix.addTo(container);
+    this.addRuntimeSection(container, runtimeFolderOrder[2], true);
+    const performanceFolder = this.addRuntimeSection(
+      container,
+      runtimeFolderOrder[3],
+      true
+    );
+    this.addFpsOverlayBinding(performanceFolder);
+    this.setUpMusicSection(container);
+    this.colorReactionMatrix.sync();
+  }
+
+  private setUpVibeSection(container: PaneContainer): void {
+    const folder = container.addFolder({
+      title: 'Colors',
+      expanded: true,
+    });
+
+    this.addColorBinding(folder, 'color1', '', (color) => {
+      activeVibe.colors[0] = color;
+    });
+    this.addColorBinding(folder, 'color2', '', (color) => {
+      activeVibe.colors[1] = color;
+    });
+    this.addColorBinding(folder, 'color3', '', (color) => {
+      activeVibe.colors[2] = color;
+    });
+    this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
+      activeVibe.backgroundColor = color;
+    });
+
+    if (import.meta.env.DEV) {
+      folder
+        .addButton({ title: 'Copy Vibe Preset' })
+        .on('click', () => void this.copyVibePresetToClipboard());
+    }
+  }
+
+  private addColorBinding(
+    container: PaneContainer,
+    key: VibeColorKey,
+    label: string,
+    updateColor: (color: RgbColor) => void
+  ): void {
+    container
+      .addBinding(this.state, key, {
+        label,
+        view: 'color',
+      } as BindingParams)
+      .on('change', ({ value }) => {
+        const color = hexColorToRgbColor(String(value));
+        if (!color) {
+          this.syncVibeState();
+          this.pane.refresh();
+          return;
+        }
+
+        updateColor(color);
+        this.colorReactionMatrix.sync();
+        this.options.onConfigChange();
+      });
+  }
+
+  private addRuntimeSection(
+    container: PaneContainer,
+    title: string,
+    expanded: boolean
+  ): PaneContainer {
+    const folder = container.addFolder({ title, expanded });
+    getRuntimeControlKeys(title).forEach((key) => this.addRuntimeBinding(folder, key));
+    return folder;
+  }
+
+  private addRuntimeBinding(container: PaneContainer, key: RuntimeControlKey): void {
+    const config = this.getRuntimeControlConfig(key);
+    if (!config) {
+      return;
+    }
+
+    settings[key] = normalizeNumberControlValue(settings[key], config);
+    const bindingTarget = this.getRuntimeBindingTarget(key, config);
+
+    container
+      .addBinding(bindingTarget, key, getNumberBindingParams(config))
+      .on('change', () => {
+        const nextValue = normalizeNumberControlValue(settings[key], config);
+        if (nextValue !== settings[key]) {
+          settings[key] = nextValue;
+          this.pane.refresh();
+        }
+        this.options.onRuntimeChange();
+      });
+  }
+
+  private getRuntimeBindingTarget(
+    key: RuntimeControlKey,
+    config: NumberControlConfig
+  ): typeof settings | Record {
+    if (!config.inverted) {
+      return settings;
+    }
+
+    const bindingTarget = {} as Record;
+    Object.defineProperty(bindingTarget, key, {
+      enumerable: true,
+      get: () => getInvertedNumberControlValue(settings[key], config),
+      set: (value: number) => {
+        settings[key] = getInvertedNumberControlValue(value, config);
+      },
+    });
+    return bindingTarget;
+  }
+
+  private getRuntimeControlConfig(
+    key: RuntimeControlKey
+  ): NumberControlConfig | undefined {
+    const config = appConfig.runtimeSettings.controls[key];
+    if (!config || key !== 'maxAgentCount') {
+      return config;
+    }
+
+    return {
+      ...config,
+      max: Math.max(config.min ?? 0, Math.floor(this.options.maxSupportedAgentCount)),
+    };
+  }
+
+  private addFpsOverlayBinding(container: PaneContainer): void {
+    container
+      .addBinding(appConfig.tuningPane, 'showFpsOverlay', {
+        label: 'Show FPS',
+      })
+      .on('change', () => this.options.onConfigChange());
+  }
+
+  private setUpMusicSection(container: PaneContainer): void {
+    const folder = container.addFolder({ title: 'Music', expanded: true });
+    MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
+      this.addVibeNumberBinding(folder, key, { folder: 'Music', label, min, max, step });
+    });
+  }
+
+  private addVibeNumberBinding(
+    container: PaneContainer,
+    key: VibeNumberKey,
+    config: NumberControlConfig
+  ): void {
+    this.state[key] = normalizeNumberControlValue(this.state[key], config);
+
+    container
+      .addBinding(this.state, key, getNumberBindingParams(config))
+      .on('change', () => {
+        const nextValue = normalizeNumberControlValue(this.state[key], config);
+        if (nextValue !== this.state[key]) {
+          this.state[key] = nextValue;
+          this.pane.refresh();
+        }
+        activeVibe.audio[key] = nextValue;
+        this.options.onConfigChange();
+      });
+  }
+
+  private async copyVibePresetToClipboard(): Promise {
+    const settingKeys = Object.keys(activeVibe.settings) as Array<
+      keyof typeof activeVibe.settings
+    >;
+    const preset = {
+      name: `${activeVibe.name} Copy`,
+      colors: activeVibe.colors,
+      backgroundColor: activeVibe.backgroundColor,
+      settings: Object.fromEntries(settingKeys.map((key) => [key, settings[key]])),
+      audio: activeVibe.audio,
+    };
+    try {
+      await navigator.clipboard.writeText(JSON.stringify(preset, null, 2));
+    } catch (error) {
+      console.warn('Could not copy vibe preset to clipboard.', error);
+    }
+  }
+
+  private syncVibeState(): void {
+    this.state.color1 = rgbColorToHex(activeVibe.colors[0]);
+    this.state.color2 = rgbColorToHex(activeVibe.colors[1]);
+    this.state.color3 = rgbColorToHex(activeVibe.colors[2]);
+    this.state.backgroundColor = rgbColorToHex(activeVibe.backgroundColor);
+    Object.assign(this.state, activeVibe.audio);
+  }
+
+  private syncOpenState(): void {
+    const { settingsButton } = this.options;
+    const label = this.isOpen ? 'Hide config overlay' : 'Show config overlay';
+    settingsButton.classList.toggle('active', this.isOpen);
+    settingsButton.setAttribute('aria-expanded', String(this.isOpen));
+    settingsButton.setAttribute('aria-label', label);
+    settingsButton.title = label;
+    this.container.classList.toggle('config-pane-container--open', this.isOpen);
+    this.closeButton.hidden = !this.isOpen;
+  }
+}
diff --git a/src/page/eraser-size-control.test.ts b/src/page/eraser-size-control.test.ts
new file mode 100644
index 0000000..6b2bbe8
--- /dev/null
+++ b/src/page/eraser-size-control.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { appConfig } from '../config';
+import {
+  getEraserSizeFromSliderRatio,
+  getEraserSliderRatioFromSize,
+} from './eraser-size-control';
+
+describe('eraser size slider mapping', () => {
+  it('maps slider position quadratically to eraser size', () => {
+    const { max, min } = appConfig.toolbar.eraser;
+
+    expect(getEraserSizeFromSliderRatio(0)).toBe(min);
+    expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
+    expect(getEraserSizeFromSliderRatio(1)).toBe(max);
+  });
+
+  it('maps eraser size back to the inverse slider position', () => {
+    const { max, min } = appConfig.toolbar.eraser;
+    const quarterRangeSize = min + (max - min) * 0.25;
+
+    expect(getEraserSliderRatioFromSize(min)).toBe(0);
+    expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
+    expect(getEraserSliderRatioFromSize(max)).toBe(1);
+  });
+});
diff --git a/src/page/eraser-size-control.ts b/src/page/eraser-size-control.ts
new file mode 100644
index 0000000..2c0e8a8
--- /dev/null
+++ b/src/page/eraser-size-control.ts
@@ -0,0 +1,102 @@
+import { appConfig } from '../config';
+import type GameLoop from '../game-loop/game-loop';
+import { settings } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+
+const clampEraserSize = (value: number): number => {
+  const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
+  const safeValue = Number.isFinite(value) ? value : defaultSize;
+  return Math.min(max, Math.max(min, Math.round(safeValue)));
+};
+
+const ERASER_SLIDER_MIN = 0;
+const ERASER_SLIDER_MAX = 1;
+const ERASER_SLIDER_STEP = 0.001;
+
+const clampSliderRatio = (value: number): number => {
+  const safeValue = Number.isFinite(value) ? value : ERASER_SLIDER_MIN;
+  return Math.min(ERASER_SLIDER_MAX, Math.max(ERASER_SLIDER_MIN, safeValue));
+};
+
+const getEraserSizeRatio = (size: number): number => {
+  const { max, min } = appConfig.toolbar.eraser;
+  return (clampEraserSize(size) - min) / (max - min);
+};
+
+export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
+  const { max, min } = appConfig.toolbar.eraser;
+  return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
+};
+
+export const getEraserSliderRatioFromSize = (size: number): number =>
+  Math.sqrt(getEraserSizeRatio(size));
+
+interface EraserSizeControlOptions {
+  getGame: () => GameLoop | null;
+  onActivate: () => void;
+  onChange: () => void;
+}
+
+export class EraserSizeControl {
+  private readonly control = queryRequiredElement(
+    '.eraser-size-control',
+    HTMLLabelElement
+  );
+  private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
+  private isActive = false;
+
+  public constructor(private readonly options: EraserSizeControlOptions) {
+    this.control.addEventListener('pointerdown', this.activate);
+    this.control.addEventListener('click', this.activate);
+    this.slider.addEventListener('focus', this.activate);
+    this.slider.addEventListener('input', () => {
+      settings.eraserSize = getEraserSizeFromSliderRatio(Number(this.slider.value));
+      this.activate();
+      this.render();
+      this.options.onChange();
+    });
+  }
+
+  public render(): void {
+    const size = clampEraserSize(settings.eraserSize);
+    if (settings.eraserSize !== size) {
+      settings.eraserSize = size;
+    }
+
+    const sliderRatio = getEraserSliderRatioFromSize(size);
+    this.slider.min = ERASER_SLIDER_MIN.toString();
+    this.slider.max = ERASER_SLIDER_MAX.toString();
+    this.slider.step = ERASER_SLIDER_STEP.toString();
+    this.slider.value = sliderRatio.toString();
+    this.slider.setAttribute('aria-valuetext', `${size}px`);
+
+    const sizeRatio = getEraserSizeRatio(size);
+    const scale =
+      appConfig.toolbar.eraser.controlScaleMin +
+      (appConfig.toolbar.eraser.controlScaleMax -
+        appConfig.toolbar.eraser.controlScaleMin) *
+        sizeRatio;
+    this.control.style.setProperty('--eraser-progress', `${sliderRatio * 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/error-presenter.ts b/src/page/error-presenter.ts
new file mode 100644
index 0000000..835c902
--- /dev/null
+++ b/src/page/error-presenter.ts
@@ -0,0 +1,62 @@
+import {
+  ErrorHandler,
+  getErrorMessage,
+  RuntimeError,
+  Severity,
+} from '../utils/error-handler';
+
+type RuntimeUiError = Parameters<
+  Parameters[0]
+>[0];
+
+const ERROR_CONTAINER_SELECTOR = '.errors-container';
+const ERROR_CONTAINER_CLASS = 'errors-container';
+
+const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError): void => {
+  const message = document.createElement('pre');
+  message.className = error.severity;
+  message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
+  message.setAttribute('role', error.severity === Severity.ERROR ? 'alert' : 'status');
+  message.setAttribute(
+    'aria-live',
+    error.severity === Severity.ERROR ? 'assertive' : 'polite'
+  );
+  container.append(message);
+
+  if (error.severity === Severity.ERROR) {
+    message.tabIndex = -1;
+    message.focus({ preventScroll: true });
+  }
+};
+
+const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
+  severity: Severity.ERROR,
+  message: getErrorMessage(exception),
+  ...(exception instanceof RuntimeError ? { code: exception.code } : {}),
+});
+
+export class ErrorPresenter {
+  public constructor(private readonly container: HTMLElement) {
+    container.setAttribute('aria-live', 'assertive');
+  }
+
+  public render(error: RuntimeUiError): void {
+    renderRuntimeMessage(this.container, error);
+  }
+
+  public static renderStartup(exception: unknown): void {
+    const existingContainer = document.querySelector(ERROR_CONTAINER_SELECTOR);
+    const container =
+      existingContainer instanceof HTMLElement
+        ? existingContainer
+        : document.createElement('div');
+
+    if (!(existingContainer instanceof HTMLElement)) {
+      container.className = ERROR_CONTAINER_CLASS;
+      document.body.append(container);
+    }
+
+    container.setAttribute('aria-live', 'assertive');
+    renderRuntimeMessage(container, getRuntimeUiError(exception));
+  }
+}
diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts
index 84c9150..498c08c 100644
--- a/src/page/full-screen-handler.ts
+++ b/src/page/full-screen-handler.ts
@@ -1,43 +1,46 @@
 export class FullScreenHandler {
+  private readonly abortController = new AbortController();
+
   public constructor(
-    private readonly minimizeButton: HTMLElement,
-    private readonly maximizeButton: HTMLElement,
+    private readonly toggleButton: HTMLElement,
     target: HTMLElement
   ) {
-    if (!document.fullscreenEnabled) {
-      minimizeButton.style.display = 'none';
-      maximizeButton.style.display = 'none';
+    if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
+      toggleButton.hidden = true;
       return;
     }
 
     this.updateButtons();
 
-    addEventListener('keydown', (e) => {
-      // on full screen request, only apply it to the target
-      if (e.key === 'F11') {
-        e.preventDefault();
+    const { signal } = this.abortController;
+    addEventListener('fullscreenchange', this.updateButtons, { signal });
+    toggleButton.addEventListener(
+      'click',
+      () => {
         if (FullScreenHandler.isInFullScreenMode()) {
-          document.exitFullscreen();
-        } else {
-          target.requestFullscreen();
+          void document.exitFullscreen();
+          return;
         }
-      }
-    });
-    addEventListener('fullscreenchange', this.updateButtons.bind(this));
-    maximizeButton.addEventListener('click', () => target.requestFullscreen());
-    minimizeButton.addEventListener('click', () => document.exitFullscreen());
+
+        void target.requestFullscreen().catch(() => undefined);
+      },
+      { signal }
+    );
   }
 
   public static isInFullScreenMode(): boolean {
     return document.fullscreenElement !== null;
   }
 
-  private updateButtons() {
-    this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
-      ? 'block'
-      : 'none';
-    this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
-      ? 'none'
-      : 'block';
+  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 173a5b6..ed8aa59 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,17 +1,139 @@
+import { appConfig } from '../config';
+
 export class MenuHider {
-  private static readonly DEFAULT_TIME_TO_LIVE = 3500;
-  private static readonly INTERVAL = 50;
-  private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+  private readonly desktopMediaQuery = window.matchMedia(
+    appConfig.menuHider.desktopMediaQuery
+  );
+  private hideTimeout: number | undefined;
+  private isHidden = false;
+  private pointerInside = false;
 
-  public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
-    setInterval(() => {
-      this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
-      element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1';
-    }, MenuHider.INTERVAL);
+  public constructor(
+    private readonly element: HTMLElement,
+    private readonly shouldBeHidden: () => boolean
+  ) {
+    element.addEventListener('pointerenter', this.onPointerEnter);
+    element.addEventListener('pointerleave', this.onPointerLeave);
+    element.addEventListener('focusin', this.onFocusIn);
+    element.addEventListener('focusout', this.onFocusOut);
+    window.addEventListener('pointermove', this.onPointerMove, { passive: true });
+    document.addEventListener('fullscreenchange', this.onVisibilityContextChange);
+    this.desktopMediaQuery.addEventListener('change', this.onVisibilityContextChange);
 
-    element.addEventListener(
-      'mouseover',
-      () => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
+    this.reveal();
+  }
+
+  private get canAutoHide(): boolean {
+    return (
+      this.desktopMediaQuery.matches &&
+      this.shouldBeHidden() &&
+      !this.pointerInside &&
+      !this.element.contains(document.activeElement)
     );
   }
+
+  private readonly onPointerEnter = () => {
+    this.pointerInside = true;
+    this.reveal();
+  };
+
+  private readonly onPointerLeave = () => {
+    this.pointerInside = false;
+    this.scheduleHide();
+  };
+
+  private readonly onFocusIn = () => {
+    this.reveal();
+  };
+
+  private readonly onFocusOut = () => {
+    window.setTimeout(() => this.scheduleHide(), 0);
+  };
+
+  private readonly onPointerMove = (event: PointerEvent) => {
+    if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
+      this.reveal();
+      return;
+    }
+
+    if (this.isPointerOverDock(event.clientX, event.clientY)) {
+      this.pointerInside = true;
+      this.reveal();
+      return;
+    }
+
+    this.pointerInside = false;
+
+    if (this.isHidden) {
+      if (this.isNearViewportBottom(event.clientY)) {
+        this.reveal();
+        this.scheduleHide();
+      }
+      return;
+    }
+
+    this.scheduleHide();
+  };
+
+  private readonly onVisibilityContextChange = () => {
+    this.scheduleHide();
+  };
+
+  private scheduleHide(): void {
+    if (!this.canAutoHide) {
+      this.clearHideTimeout();
+      this.reveal();
+      return;
+    }
+
+    if (this.hideTimeout !== undefined) {
+      return;
+    }
+
+    this.hideTimeout = window.setTimeout(() => {
+      this.hideTimeout = undefined;
+      if (this.canAutoHide) {
+        this.hide();
+      }
+    }, appConfig.menuHider.hideDelayMs);
+  }
+
+  private reveal(): void {
+    if (!this.isHidden && this.hideTimeout === undefined) {
+      return;
+    }
+
+    this.clearHideTimeout();
+    this.isHidden = false;
+    this.element.classList.remove('menu-hidden');
+  }
+
+  private hide(): void {
+    this.isHidden = true;
+    this.element.classList.add('menu-hidden');
+  }
+
+  private clearHideTimeout(): void {
+    if (this.hideTimeout === undefined) {
+      return;
+    }
+
+    window.clearTimeout(this.hideTimeout);
+    this.hideTimeout = undefined;
+  }
+
+  private isPointerOverDock(clientX: number, clientY: number): boolean {
+    const rect = this.element.getBoundingClientRect();
+    return (
+      clientX >= rect.left &&
+      clientX <= rect.right &&
+      clientY >= rect.top &&
+      clientY <= rect.bottom
+    );
+  }
+
+  private isNearViewportBottom(clientY: number): boolean {
+    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+    return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
+  }
 }
diff --git a/src/page/mirror-segment-control.ts b/src/page/mirror-segment-control.ts
new file mode 100644
index 0000000..1ea8ddc
--- /dev/null
+++ b/src/page/mirror-segment-control.ts
@@ -0,0 +1,66 @@
+import { appConfig } from '../config';
+import { settings } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+
+const clampMirrorSegmentCount = (value: number): number => {
+  const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
+  const safeValue = Number.isFinite(value) ? value : defaultCount;
+  return Math.min(max, Math.max(min, Math.round(safeValue)));
+};
+
+const getMirrorSegmentRatio = (count: number): number => {
+  const { max, min } = appConfig.toolbar.mirror;
+  return (count - min) / (max - min);
+};
+
+const formatMirrorSegmentCount = (count: number): string =>
+  count <= 1
+    ? appConfig.toolbar.mirror.offLabel
+    : `${count} ${
+        appConfig.toolbar.mirror.names[
+          count as keyof typeof appConfig.toolbar.mirror.names
+        ] ?? appConfig.toolbar.mirror.fallbackSegmentName
+      }`;
+
+interface MirrorSegmentControlOptions {
+  onChange: () => void;
+}
+
+export class MirrorSegmentControl {
+  private readonly control = queryRequiredElement(
+    '.mirror-segment-control',
+    HTMLLabelElement
+  );
+  private readonly slider = queryRequiredElement(
+    '.mirror-segment-slider',
+    HTMLInputElement
+  );
+
+  public constructor(private readonly options: MirrorSegmentControlOptions) {
+    this.slider.addEventListener('input', () => {
+      settings.mirrorSegmentCount = clampMirrorSegmentCount(Number(this.slider.value));
+      this.render();
+      this.options.onChange();
+    });
+  }
+
+  public render(): void {
+    const count = clampMirrorSegmentCount(settings.mirrorSegmentCount);
+    if (settings.mirrorSegmentCount !== count) {
+      settings.mirrorSegmentCount = count;
+    }
+
+    this.slider.min = appConfig.toolbar.mirror.min.toString();
+    this.slider.max = appConfig.toolbar.mirror.max.toString();
+    this.slider.step = appConfig.toolbar.mirror.step.toString();
+    this.slider.value = count.toString();
+
+    const label = formatMirrorSegmentCount(count);
+    const ratio = getMirrorSegmentRatio(count);
+    this.slider.setAttribute('aria-valuetext', label);
+    this.control.title = label;
+    this.control.classList.toggle('active', count > 1);
+    this.control.style.setProperty('--mirror-progress', `${ratio * 100}%`);
+    this.control.style.setProperty('--mirror-angle', `${(360 / count).toFixed(3)}deg`);
+  }
+}
diff --git a/src/page/palette-control.ts b/src/page/palette-control.ts
new file mode 100644
index 0000000..9007d8f
--- /dev/null
+++ b/src/page/palette-control.ts
@@ -0,0 +1,76 @@
+import type GameLoop from '../game-loop/game-loop';
+import { activeVibe, settings } from '../settings';
+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 isEraserActiveState = false;
+
+  public constructor(private readonly options: PaletteControlOptions) {
+    this.swatches.forEach((swatch, index) => {
+      swatch.addEventListener('click', () => {
+        settings.selectedColorIndex = index;
+        this.isEraserActiveState = false;
+        this.render();
+        this.options.onModeChange?.(false);
+        this.options.onChange();
+      });
+    });
+  }
+
+  public get isEraserActive(): boolean {
+    return this.isEraserActiveState;
+  }
+
+  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]);
+      const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState;
+      swatch.classList.toggle('active', isActive);
+      swatch.setAttribute('aria-pressed', String(isActive));
+    });
+    this.options.getGame()?.setEraseMode(this.isEraserActiveState);
+    document.documentElement.style.setProperty(
+      '--garden-background',
+      rgbColorToCss(activeVibe.backgroundColor)
+    );
+  }
+}
+
+const queryRequiredColorSwatches = (): Array => {
+  const selector = '.color-swatch';
+  const swatches = Array.from(document.querySelectorAll(selector));
+  const expectedCount = activeVibe.colors.length;
+  const hasExpectedSwatches =
+    swatches.length === expectedCount &&
+    swatches.every((swatch) => swatch instanceof HTMLButtonElement);
+
+  if (!hasExpectedSwatches) {
+    throw new RuntimeError(
+      ErrorCode.DOM_ELEMENT_MISSING,
+      `Expected ${expectedCount} color swatches.`,
+      {
+        details: {
+          actualCount: swatches.length,
+          expectedCount,
+          selector,
+        },
+      }
+    );
+  }
+
+  return swatches as Array;
+};
diff --git a/src/page/set-up-settings-page.ts b/src/page/set-up-settings-page.ts
deleted file mode 100644
index 238d704..0000000
--- a/src/page/set-up-settings-page.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { isProduction } from '../constants';
-import { settings } from '../settings';
-import { SettingsSlider, ValueScaling } from './settings-slider';
-
-export const setUpSettingsPage = (
-  settingsPage: HTMLDivElement,
-  maxAgentCount: number
-): Array> => {
-  const sliders: Array> = [
-    ...(isProduction
-      ? []
-      : [
-          new SettingsSlider(settings, 'renderSpeed', {
-            min: 1,
-            max: 10,
-            rounding: Math.round,
-          }),
-        ]),
-
-    new SettingsSlider(settings, 'agentCount', {
-      min: 1,
-      max: maxAgentCount,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'currentGenerationAggression', {
-      min: -5,
-      max: 5,
-    }),
-
-    new SettingsSlider(settings, 'nextGenerationAggression', {
-      min: -5,
-      max: 5,
-    }),
-
-    new SettingsSlider(settings, 'moveSpeed', {
-      min: 10,
-      max: 500,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'turnSpeed', {
-      min: 1,
-      max: 200,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'sensorOffsetAngle', {
-      min: 0,
-      max: 90,
-      step: 1,
-    }),
-
-    new SettingsSlider(settings, 'sensorOffsetDistance', {
-      min: 0,
-      max: 200,
-      scaling: ValueScaling.Quadratic,
-      rounding: Math.round,
-    }),
-
-    new SettingsSlider(settings, 'turnWhenLost', {
-      min: 0,
-      max: 1,
-    }),
-
-    new SettingsSlider(settings, 'individualTrailWeight', {
-      min: 0,
-      max: 1,
-    }),
-
-    new SettingsSlider(settings, 'diffusionRateTrails', {
-      min: 0,
-      max: 2,
-    }),
-
-    new SettingsSlider(settings, 'decayRateTrails', {
-      min: 0.1,
-      max: 5000,
-      scaling: ValueScaling.Quadratic,
-    }),
-
-    new SettingsSlider(settings, 'diffusionRateBrush', {
-      min: 0.001,
-      max: 1,
-    }),
-
-    new SettingsSlider(settings, 'decayRateBrush', {
-      min: 0.1,
-      max: 100,
-    }),
-
-    new SettingsSlider(settings, 'brushSize', {
-      min: 1,
-      max: 30,
-    }),
-
-    new SettingsSlider(settings, 'clarity', {
-      min: 0.00001,
-      max: 1,
-    }),
-  ];
-
-  const sliderContainerElement = document.createElement('div');
-
-  sliders.forEach((slider) => {
-    sliderContainerElement.appendChild(slider.element);
-  });
-
-  settingsPage.appendChild(sliderContainerElement);
-
-  return sliders;
-};
diff --git a/src/page/settings-slider.ts b/src/page/settings-slider.ts
deleted file mode 100644
index d7ad26b..0000000
--- a/src/page/settings-slider.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { formatNumber } from '../utils/format-number';
-
-export enum ValueScaling {
-  Linear,
-  Quadratic,
-  Logarithmic,
-}
-
-export interface SliderConfiguration {
-  min: number;
-  max: number;
-  unit?: string;
-  step?: number;
-  onChangeCallback?: (value: number) => unknown;
-  scaling: ValueScaling;
-  rounding: (value: number) => number;
-}
-
-export class SettingsSlider> {
-  private static readonly DEFAULT_STEP_COUNT = 20000;
-
-  private readonly slider: HTMLInputElement;
-  private readonly valueDisplay: HTMLSpanElement;
-  private readonly sliderWrapper: HTMLDivElement;
-  private readonly config: SliderConfiguration = {
-    min: 0,
-    max: 1,
-    scaling: ValueScaling.Linear,
-    rounding: (value) => value,
-  };
-
-  public constructor(
-    private readonly settings: T,
-    private readonly settingName: keyof T & string,
-    config: Partial = {}
-  ) {
-    this.slider = SettingsSlider.createSlider();
-    this.valueDisplay = SettingsSlider.createValueDisplay();
-    this.sliderWrapper = SettingsSlider.createSliderWrapper(
-      this.settingName,
-      this.slider,
-      this.valueDisplay
-    );
-
-    this.slider.addEventListener('input', this.onChange.bind(this));
-
-    this.updateConfig(config);
-  }
-
-  private static createSlider() {
-    const input = document.createElement('input');
-    input.type = 'range';
-    return input;
-  }
-
-  private static createValueDisplay() {
-    return document.createElement('span');
-  }
-
-  private static createSliderWrapper(
-    name: string,
-    slider: HTMLInputElement,
-    valueDisplay: HTMLSpanElement
-  ) {
-    const wrapper = document.createElement('div');
-    wrapper.classList.add('slider');
-    const label = document.createElement('label');
-
-    const title = document.createElement('p');
-    title.innerText = SettingsSlider.formatLabel(name);
-    title.appendChild(valueDisplay);
-
-    label.appendChild(title);
-    label.appendChild(slider);
-    wrapper.appendChild(label);
-
-    return wrapper;
-  }
-
-  private static formatLabel(value: string): string {
-    const formatted = value.replace(/([A-Z])/g, ' $1');
-
-    return (
-      formatted.charAt(0).toLocaleUpperCase() + formatted.slice(1).toLocaleLowerCase()
-    );
-  }
-
-  private onChange() {
-    this.settings[this.settingName] = this.config.rounding(
-      this.inverseScaling(Number(this.slider.value))
-    ) as any;
-
-    this.config.onChangeCallback?.(this.settings[this.settingName]);
-    this.valueDisplay.innerText = formatNumber(
-      this.settings[this.settingName],
-      this.config.unit
-    );
-  }
-
-  public updateSliderValueBasedOnSource() {
-    this.slider.value = this.scaling(this.settings[this.settingName]).toString();
-    this.onChange();
-  }
-
-  public updateConfig(config: Partial) {
-    Object.assign(this.config, config);
-
-    if (this.config.step === undefined) {
-      this.config.step =
-        (this.scaling(this.config.max) - this.scaling(this.config.min)) /
-        SettingsSlider.DEFAULT_STEP_COUNT;
-    }
-
-    this.slider.min = this.scaling(this.config.min).toString();
-    this.slider.max = this.scaling(this.config.max).toString();
-    this.slider.step = this.config.step.toString();
-    this.slider.value = this.scaling(this.settings[this.settingName]).toString();
-    this.onChange();
-  }
-
-  public get element(): HTMLElement {
-    return this.sliderWrapper;
-  }
-
-  private get scaling(): (value: number) => number {
-    switch (this.config.scaling) {
-      case ValueScaling.Linear:
-        return (value) => value;
-      case ValueScaling.Quadratic:
-        return (value) => Math.sqrt(value);
-      case ValueScaling.Logarithmic:
-        return (value) => Math.log10(value);
-    }
-  }
-
-  private get inverseScaling(): (value: number) => number {
-    switch (this.config.scaling) {
-      case ValueScaling.Linear:
-        return (value) => value;
-      case ValueScaling.Quadratic:
-        return (value) => Math.pow(value, 2);
-      case ValueScaling.Logarithmic:
-        return (value) => Math.pow(10, value);
-    }
-  }
-}
diff --git a/src/page/splash-screen.ts b/src/page/splash-screen.ts
new file mode 100644
index 0000000..00d44a4
--- /dev/null
+++ b/src/page/splash-screen.ts
@@ -0,0 +1,61 @@
+import { queryRequiredElement } from '../utils/dom';
+import { clamp01 } from '../utils/math';
+
+export class SplashScreen {
+  public readonly startButton = queryRequiredElement('.start-button', HTMLButtonElement);
+  private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
+  private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
+  private readonly loadingStatus = queryRequiredElement(
+    '.loading-status',
+    HTMLDivElement
+  );
+  private readonly loadingProgress = queryRequiredElement(
+    '.loading-progress',
+    HTMLDivElement
+  );
+
+  private setVisible(element: HTMLElement, isVisible: boolean): void {
+    element.dataset.visible = String(isVisible);
+    element.setAttribute('aria-hidden', String(!isVisible));
+    element.inert = !isVisible;
+  }
+
+  public setLoadingStage(label: string, ratio: number): void {
+    const percent = Math.round(clamp01(ratio) * 100);
+    this.loadingStatus.textContent = label;
+    this.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
+    this.loadingProgress.setAttribute('aria-valuenow', String(percent));
+  }
+
+  public awaitStart(onStart: () => void): Promise {
+    this.startButton.disabled = false;
+    return new Promise((resolve) => {
+      const 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);
+    });
+  }
+
+  public showLoadingBar(): void {
+    this.setVisible(this.loadingBar, true);
+  }
+
+  public hideLoadingBar(): void {
+    this.setVisible(this.loadingBar, false);
+  }
+}
diff --git a/src/page/vibe-navigator.ts b/src/page/vibe-navigator.ts
new file mode 100644
index 0000000..fc6ecc0
--- /dev/null
+++ b/src/page/vibe-navigator.ts
@@ -0,0 +1,85 @@
+import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings';
+import { queryRequiredElement } from '../utils/dom';
+import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri';
+import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes';
+
+interface VibeSelection {
+  source: string;
+  userGesture: boolean;
+  vibeId: VibeId;
+  vibeName: string;
+}
+
+interface VibeNavigatorOptions {
+  onChange: (selection: VibeSelection) => void;
+}
+
+export class VibeNavigator {
+  private readonly abortController = new AbortController();
+  private readonly previousButton = queryRequiredElement(
+    '.previous-vibe',
+    HTMLButtonElement
+  );
+  private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
+
+  public constructor(private readonly options: VibeNavigatorOptions) {
+    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'), {
+      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[(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
new file mode 100644
index 0000000..158d556
--- /dev/null
+++ b/src/pipelines/agents/agent-dispatch.ts
@@ -0,0 +1,49 @@
+const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const;
+
+export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number];
+
+const AGENT_WORKGROUP_SIZE_TARGETS = {
+  // Keep shader-specific targets conservative. Using the device maximum can
+  // hurt occupancy and makes compaction's workgroup scan more expensive.
+  simulation: 256,
+  eraser: 256,
+  resize: 256,
+  compaction: 256,
+} satisfies Record;
+
+export const getAgentWorkgroupSize = (
+  device: GPUDevice,
+  kind: AgentWorkgroupKind = 'simulation'
+): number => {
+  const deviceLimit = Math.max(
+    1,
+    Math.floor(
+      Math.min(
+        device.limits.maxComputeInvocationsPerWorkgroup,
+        device.limits.maxComputeWorkgroupSizeX
+      )
+    )
+  );
+  return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit);
+};
+
+export const getMinAgentWorkgroupSize = (device: GPUDevice): number =>
+  Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind)));
+
+export const substituteAgentWorkgroupSize = (
+  device: GPUDevice,
+  shaderCode: string,
+  kind: AgentWorkgroupKind = 'simulation'
+): string =>
+  shaderCode.replaceAll(
+    '__AGENT_WORKGROUP_SIZE__',
+    String(getAgentWorkgroupSize(device, kind))
+  );
+
+export const dispatchAgentWorkgroups = (
+  passEncoder: GPUComputePassEncoder,
+  workgroupSize: number,
+  agentCount: number
+): void => {
+  passEncoder.dispatchWorkgroups(Math.ceil(agentCount / workgroupSize), 1);
+};
diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
new file mode 100644
index 0000000..ae9336c
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
@@ -0,0 +1,97 @@
+struct Settings {
+  agentCount: u32,
+  padding0: u32,
+  padding1: u32,
+  padding2: u32,
+};
+
+struct Counters {
+  aliveAgentCount: atomic,
+};
+
+const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u;
+
+@group(1) @binding(0) var settings: Settings;
+@group(1) @binding(2) var counters: Counters;
+@group(1) @binding(3) var compactedAgents: array;
+
+var workgroupCompactedOffset: u32;
+var scanData: array;
+var clearAliveAgentCount: u32;
+
+@compute @workgroup_size(agentWorkgroupSize)
+fn main(
+  @builtin(global_invocation_id) global_id: vec3,
+  @builtin(local_invocation_id) local_id: vec3
+) {
+  let id = get_id(global_id);
+  let lid = local_id.x;
+
+  var isAlive = false;
+  var agent: Agent;
+  if id < settings.agentCount {
+    isAlive = agents[id].colorIndex >= 0.0 && agents[id].colorIndex < 2.5;
+    if isAlive {
+      agent = agents[id];
+    }
+  }
+
+  // Hillis-Steele inclusive prefix sum across the workgroup. Replaces a
+  // per-thread atomicAdd to a workgroup counter, eliminating serialization
+  // on dense workgroups.
+  scanData[lid] = select(0u, 1u, isAlive);
+  workgroupBarrier();
+
+  var offset: u32 = 1u;
+  while offset < agentWorkgroupSize {
+    let own = scanData[lid];
+    var contribution: u32 = 0u;
+    if lid >= offset {
+      contribution = scanData[lid - offset];
+    }
+    workgroupBarrier();
+    scanData[lid] = own + contribution;
+    workgroupBarrier();
+    offset = offset * 2u;
+  }
+
+  let inclusivePrefix = scanData[lid];
+  let workgroupAliveTotal = scanData[agentWorkgroupSize - 1u];
+  let exclusivePrefix = inclusivePrefix - select(0u, 1u, isAlive);
+
+  if lid == 0u {
+    if workgroupAliveTotal > 0u {
+      workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, workgroupAliveTotal);
+    } else {
+      workgroupCompactedOffset = 0u;
+    }
+  }
+
+  workgroupBarrier();
+
+  if isAlive {
+    compactedAgents[workgroupCompactedOffset + exclusivePrefix] = agent;
+  }
+}
+
+@compute @workgroup_size(agentWorkgroupSize)
+fn clearCompactedTail(
+  @builtin(global_invocation_id) global_id: vec3,
+  @builtin(local_invocation_id) local_id: vec3
+) {
+  let id = get_id(global_id);
+
+  if local_id.x == 0u {
+    clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
+  }
+
+  workgroupBarrier();
+
+  let firstClearId = clearAliveAgentCount + id * clearCompactedTailStride;
+  for (var offset = 0u; offset < clearCompactedTailStride; offset += 1u) {
+    let clearId = firstClearId + offset;
+    if clearId < settings.agentCount {
+      compactedAgents[clearId].colorIndex = -1.0;
+    }
+  }
+}
diff --git a/src/pipelines/agents/agent-generation/agent-counting.wgsl b/src/pipelines/agents/agent-generation/agent-counting.wgsl
deleted file mode 100644
index 9d4c1b6..0000000
--- a/src/pipelines/agents/agent-generation/agent-counting.wgsl
+++ /dev/null
@@ -1,31 +0,0 @@
-struct Settings {
-  agentCount: u32 // might be smaller than the length of the agents array
-};
-
-@group(1) @binding(0) var settings: Settings;
-
-struct Counters {
-  evenGenerationAlive: atomic,
-  oddGenerationAlive: atomic,
-};
-
-@group(1) @binding(2) var counters: Counters;
-
-
-@compute @workgroup_size(64)
-fn main(
-  @builtin(global_invocation_id) global_id: vec3,
-  @builtin(num_workgroups) workgroup_count: vec3
-) {
-  let id = global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
-
-  if id >= settings.agentCount {
-    return;
-  }
-
-  if agents[id].generation % 2 == 0 {
-    atomicAdd(&counters.evenGenerationAlive, 1);
-  } else {
-    atomicAdd(&counters.oddGenerationAlive, 1);
-  }
-}
diff --git a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
deleted file mode 100644
index 0f2bb34..0000000
--- a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
+++ /dev/null
@@ -1,34 +0,0 @@
-@compute @workgroup_size(64)
-fn main(
-  @builtin(global_invocation_id) global_id: vec3,
-  @builtin(num_workgroups) workgroup_count: vec3
-) {
-  let id = get_id(global_id, workgroup_count);
-
-  if id >= arrayLength(&agents) {
-    return;
-  }
-
-  let clusterId = f32(id % 1000);
-
-  let random = textureSampleLevel(
-    noise,
-    noiseSampler,
-    vec2(f32(id % 1999) / 2000, f32(id) / 1999 / 2000),
-    0
-  );
-  
-  let randomPosition = textureSampleLevel(
-    noise,
-    noiseSampler,
-    vec2(clusterId / 2000, clusterId / 2000),
-    0
-  );
-
-
-  agents[id] = Agent(
-    randomPosition.xz * state.size,
-    random.r * 3.14 * 2,
-    0,
-  );
-}
diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
index f347d59..524a5c2 100644
--- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
+++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
@@ -1,33 +1,65 @@
-import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts';
+import { vec2 } from 'gl-matrix';
+
+import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
 import { smartCompile } from '../../../utils/graphics/smart-compile';
-import { CommonState } from '../../common-state/common-state';
-import { AGENT_SIZE_IN_BYTES } from './agent';
-import countingShader from './agent-counting.wgsl?raw';
-import firstGenerationShader from './agent-first-generation.wgsl?raw';
+import {
+  dispatchAgentWorkgroups,
+  getAgentWorkgroupSize,
+  substituteAgentWorkgroupSize,
+} from '../agent-dispatch';
+import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits';
+import compactionShader from './agent-compaction.wgsl?raw';
+import resizeShader from './agent-resize.wgsl?raw';
 import agentSchema from './agent-schema.wgsl?raw';
-import { GenerationCounts } from './generation-counts';
 
 export class AgentGenerationPipeline {
-  private static readonly WORKGROUP_SIZE = 64;
-  private static readonly UNIFORM_COUNT = 1;
-  private static readonly COUNTER_COUNT = 3;
+  private static readonly UNIFORM_COUNT = 4;
+  private static readonly COUNTER_COUNT = 1;
+  private static readonly CLEAR_COMPACTED_TAIL_STRIDE = 4;
+  private static readonly ALLOCATION_GROWTH_FACTOR = 1.25;
 
   private readonly bindGroupLayout: GPUBindGroupLayout;
   private readonly uniforms: GPUBuffer;
-  private readonly bindGroup: GPUBindGroup;
+  private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUBuffer]>(
+    (active, inactive) =>
+      this.device.createBindGroup({
+        layout: this.bindGroupLayout,
+        entries: [
+          { binding: 0, resource: { buffer: this.uniforms } },
+          { binding: 1, resource: { buffer: active } },
+          { binding: 2, resource: { buffer: this.countersBuffer } },
+          { binding: 3, resource: { buffer: inactive } },
+        ],
+      })
+  );
 
-  private readonly firstGenerationPipeline: GPUComputePipeline;
-  private readonly countingPipeline: GPUComputePipeline;
+  private readonly resizePipeline: GPUComputePipeline;
+  private readonly compactionPipeline: GPUComputePipeline;
+  private readonly clearCompactedTailPipeline: GPUComputePipeline;
+  private readonly resizeWorkgroupSize: number;
+  private readonly compactionWorkgroupSize: number;
 
-  public readonly agentsBuffer: GPUBuffer;
-  public readonly countersBuffer: GPUBuffer;
-  public readonly countersStagingBuffer: GPUBuffer;
+  private activeAgentsBuffer: GPUBuffer;
+  private inactiveAgentsBuffer: GPUBuffer;
+  private allocatedMaxAgentCount: number;
+  private readonly countersBuffer: GPUBuffer;
+  private readonly countersStagingBuffer: GPUBuffer;
+  private readonly agentCountUniformValues = new Uint32Array(
+    AgentGenerationPipeline.UNIFORM_COUNT
+  );
+  private readonly resizeUniformBuffer = new ArrayBuffer(
+    AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
+  );
+  private readonly resizeUniformFloatValues = new Float32Array(this.resizeUniformBuffer);
+  private readonly resizeUniformUintValues = new Uint32Array(this.resizeUniformBuffer);
 
   public constructor(
     private readonly device: GPUDevice,
-    private readonly commonState: CommonState,
-    private readonly maxAgentCountUpperLimit: number
+    initialMaxAgentCount: number,
+    private readonly maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
   ) {
+    this.allocatedMaxAgentCount = this.clampMaxAgentCount(initialMaxAgentCount);
+    const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
     this.bindGroupLayout = device.createBindGroupLayout({
       entries: [
         {
@@ -51,13 +83,18 @@ export class AgentGenerationPipeline {
             type: 'storage',
           },
         },
+        {
+          binding: 3,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: {
+            type: 'storage',
+          },
+        },
       ],
     });
 
-    this.agentsBuffer = this.device.createBuffer({
-      size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
-      usage: GPUBufferUsage.STORAGE,
-    });
+    this.activeAgentsBuffer = this.createAgentsBuffer();
+    this.inactiveAgentsBuffer = this.createAgentsBuffer();
 
     this.countersBuffer = this.device.createBuffer({
       size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
@@ -74,99 +111,182 @@ export class AgentGenerationPipeline {
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
 
-    this.bindGroup = this.device.createBindGroup({
-      layout: this.bindGroupLayout,
-      entries: [
-        {
-          binding: 0,
-          resource: {
-            buffer: this.uniforms,
-          },
-        },
-        {
-          binding: 1,
-          resource: {
-            buffer: this.agentsBuffer,
-          },
-        },
-        {
-          binding: 2,
-          resource: {
-            buffer: this.countersBuffer,
-          },
-        },
-      ],
-    });
+    this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize');
+    this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction');
+    const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize');
+    const compactionSchema = substituteAgentWorkgroupSize(
+      device,
+      agentSchema,
+      'compaction'
+    );
 
-    this.firstGenerationPipeline = device.createComputePipeline({
+    this.resizePipeline = device.createComputePipeline({
       layout: device.createPipelineLayout({
-        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+        bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
       }),
       compute: {
-        module: smartCompile(
-          device,
-          CommonState.shaderCode,
-          agentSchema,
-          firstGenerationShader
-        ),
+        module: smartCompile(device, resizeSchema, resizeShader),
         entryPoint: 'main',
       },
     });
 
-    this.countingPipeline = device.createComputePipeline({
+    const compactionModule = smartCompile(
+      device,
+      compactionSchema,
+      compactionShader.replaceAll(
+        '__CLEAR_COMPACTED_TAIL_STRIDE__',
+        AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE.toString()
+      )
+    );
+
+    this.compactionPipeline = device.createComputePipeline({
       layout: device.createPipelineLayout({
-        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+        bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
       }),
       compute: {
-        module: smartCompile(device, CommonState.shaderCode, agentSchema, countingShader),
+        module: compactionModule,
         entryPoint: 'main',
       },
     });
+
+    this.clearCompactedTailPipeline = device.createComputePipeline({
+      layout: device.createPipelineLayout({
+        bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
+      }),
+      compute: {
+        module: compactionModule,
+        entryPoint: 'clearCompactedTail',
+      },
+    });
+  }
+
+  public get agentsBuffer(): GPUBuffer {
+    return this.activeAgentsBuffer;
+  }
+
+  private createAgentsBuffer(): GPUBuffer {
+    return this.device.createBuffer({
+      size: this.allocatedMaxAgentCount * AGENT_SIZE_IN_BYTES,
+      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
+    });
   }
 
   public get maxAgentCount(): number {
+    return this.allocatedMaxAgentCount;
+  }
+
+  public get maxSupportedAgentCount(): number {
+    return this.clampMaxAgentCount(Number.POSITIVE_INFINITY);
+  }
+
+  public ensureMaxAgentCount(
+    requestedMaxAgentCount: number,
+    activeAgentCount: number
+  ): number {
+    const requestedClampedMaxAgentCount = this.clampMaxAgentCount(requestedMaxAgentCount);
+    if (requestedClampedMaxAgentCount <= this.allocatedMaxAgentCount) {
+      return this.allocatedMaxAgentCount;
+    }
+
+    const nextMaxAgentCount = this.clampMaxAgentCount(
+      Math.max(
+        requestedClampedMaxAgentCount,
+        Math.ceil(
+          this.allocatedMaxAgentCount * AgentGenerationPipeline.ALLOCATION_GROWTH_FACTOR
+        )
+      )
+    );
+    const previousActiveAgentsBuffer = this.activeAgentsBuffer;
+    const previousInactiveAgentsBuffer = this.inactiveAgentsBuffer;
+    const previousMaxAgentCount = this.allocatedMaxAgentCount;
+    this.allocatedMaxAgentCount = nextMaxAgentCount;
+    this.activeAgentsBuffer = this.createAgentsBuffer();
+    this.inactiveAgentsBuffer = this.createAgentsBuffer();
+
+    const copyAgentCount = Math.min(
+      Math.max(0, Math.floor(activeAgentCount)),
+      previousMaxAgentCount,
+      nextMaxAgentCount
+    );
+    if (copyAgentCount > 0) {
+      const commandEncoder = this.device.createCommandEncoder();
+      commandEncoder.copyBufferToBuffer(
+        previousActiveAgentsBuffer,
+        0,
+        this.activeAgentsBuffer,
+        0,
+        copyAgentCount * AGENT_SIZE_IN_BYTES
+      );
+      this.device.queue.submit([commandEncoder.finish()]);
+    }
+
+    // GPUBuffer.destroy() defers actual freeing until pending submissions
+    // finish, so calling it synchronously after submit is safe.
+    previousActiveAgentsBuffer.destroy();
+    previousInactiveAgentsBuffer.destroy();
+    return this.allocatedMaxAgentCount;
+  }
+
+  private clampMaxAgentCount(value: number): number {
+    const requestedMaxAgentCount =
+      value === Number.POSITIVE_INFINITY
+        ? Number.POSITIVE_INFINITY
+        : Number.isFinite(value)
+          ? Math.floor(value)
+          : 0;
     return Math.min(
-      this.maxAgentCountUpperLimit,
-      Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
-      this.device.limits.maxComputeWorkgroupsPerDimension ** 3
+      getMaxSupportedAgentCount(this.device, this.maxAgentCountUpperLimit),
+      Math.max(0, requestedMaxAgentCount)
     );
   }
 
-  public spawnFirstGeneration(): void {
-    const commandEncoder = this.device.createCommandEncoder();
-
-    const passEncoder = commandEncoder.beginComputePass();
-    this.commonState.execute(passEncoder);
-    passEncoder.setPipeline(this.firstGenerationPipeline);
-    passEncoder.setBindGroup(1, this.bindGroup);
-    passEncoder.dispatchWorkgroups(
-      ...getWorkgroupCounts(
-        this.device,
-        this.maxAgentCount,
-        AgentGenerationPipeline.WORKGROUP_SIZE
-      )
+  public writeAgents(agentOffset: number, data: Float32Array): void {
+    this.device.queue.writeBuffer(
+      this.activeAgentsBuffer,
+      agentOffset * AGENT_SIZE_IN_BYTES,
+      data
     );
+  }
+
+  public resizeAgents(agentCount: number, scale: vec2): void {
+    if (agentCount <= 0 || vec2.equals(scale, vec2.fromValues(1, 1))) {
+      return;
+    }
+
+    this.resizeUniformFloatValues[0] = scale[0];
+    this.resizeUniformFloatValues[1] = scale[1];
+    this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
+    this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
+
+    const commandEncoder = this.device.createCommandEncoder();
+    const passEncoder = commandEncoder.beginComputePass();
+    passEncoder.setPipeline(this.resizePipeline);
+    passEncoder.setBindGroup(1, this.getBindGroup());
+    dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount);
     passEncoder.end();
 
     this.device.queue.submit([commandEncoder.finish()]);
   }
 
-  public async countAgents(agentCount: number): Promise {
-    this.device.queue.writeBuffer(this.countersBuffer, 0, new Uint32Array([0, 0]));
-    this.device.queue.writeBuffer(this.uniforms, 0, new Uint32Array([agentCount]));
+  public async compactAgents(agentCount: number): Promise {
+    if (agentCount <= 0) {
+      return 0;
+    }
+
+    this.agentCountUniformValues[0] = agentCount;
+    this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
 
     const commandEncoder = this.device.createCommandEncoder();
-
+    commandEncoder.clearBuffer(this.countersBuffer, 0, Uint32Array.BYTES_PER_ELEMENT);
     const passEncoder = commandEncoder.beginComputePass();
-    passEncoder.setPipeline(this.countingPipeline);
-    this.commonState.execute(passEncoder);
-    passEncoder.setBindGroup(1, this.bindGroup);
-    passEncoder.dispatchWorkgroups(
-      ...getWorkgroupCounts(
-        this.device,
-        agentCount,
-        AgentGenerationPipeline.WORKGROUP_SIZE
-      )
+    passEncoder.setPipeline(this.compactionPipeline);
+    passEncoder.setBindGroup(1, this.getBindGroup());
+    dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount);
+    passEncoder.setPipeline(this.clearCompactedTailPipeline);
+    dispatchAgentWorkgroups(
+      passEncoder,
+      this.compactionWorkgroupSize,
+      Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
     );
     passEncoder.end();
 
@@ -175,25 +295,39 @@ export class AgentGenerationPipeline {
       0,
       this.countersStagingBuffer,
       0,
-      AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT
+      Uint32Array.BYTES_PER_ELEMENT
     );
 
     this.device.queue.submit([commandEncoder.finish()]);
+    this.swapAgentBuffers();
 
     await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
-
-    const data = new Uint32Array(this.countersStagingBuffer.getMappedRange().slice(0));
+    const compactedCount = new Uint32Array(
+      this.countersStagingBuffer.getMappedRange(),
+      0,
+      1
+    )[0];
     this.countersStagingBuffer.unmap();
-    return {
-      evenGenerationCount: data[0],
-      oddGenerationCount: data[1],
-    };
+
+    return compactedCount;
+  }
+
+  private getBindGroup(): GPUBindGroup {
+    return this.bindGroupCache(this.activeAgentsBuffer, this.inactiveAgentsBuffer);
+  }
+
+  private swapAgentBuffers(): void {
+    [this.activeAgentsBuffer, this.inactiveAgentsBuffer] = [
+      this.inactiveAgentsBuffer,
+      this.activeAgentsBuffer,
+    ];
   }
 
   public destroy() {
     this.uniforms.destroy();
     this.countersBuffer.destroy();
     this.countersStagingBuffer.destroy();
-    this.agentsBuffer.destroy();
+    this.inactiveAgentsBuffer.destroy();
+    this.activeAgentsBuffer.destroy();
   }
 }
diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl
new file mode 100644
index 0000000..9af7973
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl
@@ -0,0 +1,21 @@
+struct ResizeSettings {
+  scale: vec2,
+  agentCount: u32,
+};
+
+@group(1) @binding(0) var resizeSettings: ResizeSettings;
+
+@compute @workgroup_size(agentWorkgroupSize)
+fn main(
+  @builtin(global_invocation_id) global_id: vec3
+) {
+  let id = get_id(global_id);
+
+  if id >= resizeSettings.agentCount {
+    return;
+  }
+
+  let scale = resizeSettings.scale;
+  agents[id].position = agents[id].position * scale;
+  agents[id].targetPosition = agents[id].targetPosition * scale;
+}
diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl
index 3b37725..a2744af 100644
--- a/src/pipelines/agents/agent-generation/agent-schema.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl
@@ -1,11 +1,16 @@
 struct Agent {
   position: vec2,
   angle: f32,
-  generation: f32,
+  colorIndex: f32,
+  targetPosition: vec2,
+  targetAngle: f32,
+  introDelay: f32,
 }
 
 @group(1) @binding(1) var agents: array;
 
-fn get_id(global_id: vec3,  workgroup_count: vec3) -> u32 {
-  return global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
-}    
+const agentWorkgroupSize = __AGENT_WORKGROUP_SIZE__u;
+
+fn get_id(global_id: vec3) -> u32 {
+  return global_id.x;
+}
diff --git a/src/pipelines/agents/agent-generation/agent.ts b/src/pipelines/agents/agent-generation/agent.ts
deleted file mode 100644
index b950f32..0000000
--- a/src/pipelines/agents/agent-generation/agent.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { vec2 } from 'gl-matrix';
-
-export interface Agent {
-  position: vec2;
-  angle: number;
-  generation: number;
-}
-
-export const AGENT_SIZE_IN_BYTES = 4 * Float32Array.BYTES_PER_ELEMENT;
diff --git a/src/pipelines/agents/agent-generation/generation-counts.ts b/src/pipelines/agents/agent-generation/generation-counts.ts
deleted file mode 100644
index 28a82a5..0000000
--- a/src/pipelines/agents/agent-generation/generation-counts.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface GenerationCounts {
-  evenGenerationCount: number;
-  oddGenerationCount: number;
-}
diff --git a/src/pipelines/agents/agent-limits.ts b/src/pipelines/agents/agent-limits.ts
new file mode 100644
index 0000000..405b37d
--- /dev/null
+++ b/src/pipelines/agents/agent-limits.ts
@@ -0,0 +1,64 @@
+import { getMinAgentWorkgroupSize } from './agent-dispatch';
+
+export const AGENT_FLOAT_COUNT = 8;
+export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
+
+const AGENT_LAYOUT = {
+  positionX: 0,
+  positionY: 1,
+  angle: 2,
+  colorIndex: 3,
+  targetPositionX: 4,
+  targetPositionY: 5,
+  targetAngle: 6,
+  introDelay: 7,
+} as const;
+
+export interface AgentLayoutValues {
+  angle: number;
+  colorIndex: number;
+  introDelay: number;
+  positionX: number;
+  positionY: number;
+  targetAngle: number;
+  targetPositionX: number;
+  targetPositionY: number;
+}
+
+export const writeAgentValues = (
+  target: Float32Array,
+  agentIndex: number,
+  values: AgentLayoutValues
+): void => {
+  const base = agentIndex * AGENT_FLOAT_COUNT;
+  target[base + AGENT_LAYOUT.positionX] = values.positionX;
+  target[base + AGENT_LAYOUT.positionY] = values.positionY;
+  target[base + AGENT_LAYOUT.angle] = values.angle;
+  target[base + AGENT_LAYOUT.colorIndex] = values.colorIndex;
+  target[base + AGENT_LAYOUT.targetPositionX] = values.targetPositionX;
+  target[base + AGENT_LAYOUT.targetPositionY] = values.targetPositionY;
+  target[base + AGENT_LAYOUT.targetAngle] = values.targetAngle;
+  target[base + AGENT_LAYOUT.introDelay] = values.introDelay;
+};
+
+export const getMaxSupportedAgentCount = (
+  device: GPUDevice,
+  maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
+): number => {
+  const storageBufferBindingSize =
+    device.limits.maxStorageBufferBindingSize ?? device.limits.maxBufferSize;
+  const upperLimit = Number.isFinite(maxAgentCountUpperLimit)
+    ? Math.floor(maxAgentCountUpperLimit)
+    : Number.POSITIVE_INFINITY;
+
+  return Math.max(
+    0,
+    Math.min(
+      upperLimit,
+      Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
+      Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
+      Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
+        getMinAgentWorkgroupSize(device)
+    )
+  );
+};
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index efcf9ed..6138935 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -1,197 +1,255 @@
-import { vec2 } from 'gl-matrix';
-
-import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
+import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
+import {
+  createCachedBufferWrite,
+  writeBufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { smartCompile } from '../../utils/graphics/smart-compile';
 import { CommonState } from '../common-state/common-state';
+import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
+import {
+  dispatchAgentWorkgroups,
+  getAgentWorkgroupSize,
+  substituteAgentWorkgroupSize,
+} from './agent-dispatch';
 import agentSchema from './agent-generation/agent-schema.wgsl?raw';
-import { AgentSettings } from './agent-settings';
 import shader from './agent.wgsl?raw';
 
-export class AgentPipeline {
-  private static readonly WORKGROUP_SIZE = 64;
-  private static readonly UNIFORM_COUNT = 19;
+export interface AgentSettings {
+  color1ToColor1: number;
+  color1ToColor2: number;
+  color1ToColor3: number;
+  color2ToColor1: number;
+  color2ToColor2: number;
+  color2ToColor3: number;
+  color3ToColor1: number;
+  color3ToColor2: number;
+  color3ToColor3: number;
+  moveSpeed: number;
+  turnSpeed: number;
+  sensorOffsetAngle: number;
+  sensorOffsetDistance: number;
+  turnWhenLost: number;
+  individualTrailWeight: number;
+  forwardRotationScale: number;
+  introNearDistanceMin: number;
+  introNearSensorOffsetMultiplier: number;
+  introTargetAngleBlend: number;
+  introProgressCutoff: number;
+  introNearDistanceInner: number;
+  introTurnRateMultiplier: number;
+  introRandomTurnMultiplier: number;
+  introStepStopDistance: number;
+  randomTimeScale: number;
+}
 
+// The Settings struct in WGSL starts with a mat3x3 reactionMatrix.
+// In uniform layout each of its 3 columns is stored as a vec3 padded to
+// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused
+// padding). Remaining scalars pack tightly from float 12 onward.
+const UNIFORM_COUNT = 32;
+const REACTION_MATRIX_COL0 = 0;
+const REACTION_MATRIX_COL1 = 4;
+const REACTION_MATRIX_COL2 = 8;
+const SCALAR_BASE = 12;
+
+export class AgentPipeline {
   private readonly bindGroupLayout: GPUBindGroupLayout;
-  private readonly pipeline: GPUComputePipeline;
+  private readonly pipelineFull: GPUComputePipeline;
+  private readonly pipelineSteady: GPUComputePipeline;
   private readonly uniforms: GPUBuffer;
-  private bindGroup?: GPUBindGroup;
-  private previousTrailMapIn?: GPUTextureView;
-  private previousTrailMapOut?: GPUTextureView;
+  private readonly workgroupSize: number;
+  private useSteadyPipeline = false;
+  private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
+  private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
+  private readonly uniformCache = createCachedBufferWrite(
+    UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
+  );
+  private readonly bindGroupCache = createBindGroupCache<
+    [GPUBuffer, GPUTextureView, GPUTextureView]
+  >((agentsBuffer, trailMapIn, trailMapOut) =>
+    this.device.createBindGroup({
+      layout: this.bindGroupLayout,
+      entries: [
+        { binding: 0, resource: { buffer: this.uniforms } },
+        { binding: 1, resource: { buffer: agentsBuffer } },
+        { binding: 2, resource: trailMapIn },
+        { binding: 3, resource: trailMapOut },
+      ],
+    })
+  );
 
   private agentCount = 0;
 
   public constructor(
     private readonly device: GPUDevice,
     private readonly commonState: CommonState,
-    private readonly agentsBuffer: GPUBuffer // doesn't get destroyed
+    private readonly getAgentsBuffer: () => GPUBuffer
   ) {
-    this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout);
+    this.bindGroupLayout = device.createBindGroupLayout({
+      entries: [
+        {
+          binding: 0,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: { type: 'uniform' },
+        },
+        {
+          binding: 1,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: { type: 'storage' },
+        },
+        {
+          binding: 2,
+          visibility: GPUShaderStage.COMPUTE,
+          texture: { sampleType: 'float' },
+        },
+        {
+          binding: 3,
+          visibility: GPUShaderStage.COMPUTE,
+          storageTexture: { format: TRAIL_SOURCE_TEXTURE_FORMAT },
+        },
+      ],
+    });
 
-    this.pipeline = device.createComputePipeline({
-      layout: device.createPipelineLayout({
-        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
-      }),
+    this.workgroupSize = getAgentWorkgroupSize(device, 'simulation');
+    const shaderModule = smartCompile(
+      device,
+      CommonState.shaderCode,
+      substituteAgentWorkgroupSize(device, agentSchema, 'simulation'),
+      shader
+    );
+    const pipelineLayout = device.createPipelineLayout({
+      bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+    });
+    this.pipelineFull = device.createComputePipeline({
+      layout: pipelineLayout,
       compute: {
-        module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
+        module: shaderModule,
         entryPoint: 'main',
       },
     });
+    this.pipelineSteady = device.createComputePipeline({
+      layout: pipelineLayout,
+      compute: {
+        module: shaderModule,
+        entryPoint: 'mainSteady',
+      },
+    });
 
-    this.uniforms = this.device.createBuffer({
-      size: AgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+    this.uniforms = device.createBuffer({
+      size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
   }
 
   public setParameters({
     deltaTime,
-    center,
-    radius,
-    brushTrailWeight,
     moveSpeed,
     turnSpeed,
     sensorOffsetAngle,
     sensorOffsetDistance,
-    nextGenerationSensorOffsetDistance,
-    currentGenerationAggression,
-    nextGenerationAggression,
-    nextGenerationSpeed,
-    isNextGenerationOdd,
     turnWhenLost,
     individualTrailWeight,
-    infectionProbability,
+    color1ToColor1,
+    color1ToColor2,
+    color1ToColor3,
+    color2ToColor1,
+    color2ToColor2,
+    color2ToColor3,
+    color3ToColor1,
+    color3ToColor2,
+    color3ToColor3,
+    forwardRotationScale,
+    introNearDistanceInner,
+    introNearDistanceMin,
+    introNearSensorOffsetMultiplier,
+    introTargetAngleBlend,
+    introProgressCutoff,
+    introTurnRateMultiplier,
+    introRandomTurnMultiplier,
+    introMoveSpeed,
+    introStepStopDistance,
+    randomTimeScale,
+    time,
     agentCount,
+    introProgress,
   }: AgentSettings & {
     deltaTime: number;
-    currentGenerationAggression: number;
-    nextGenerationAggression: number;
-    nextGenerationSensorOffsetDistance: number;
-    nextGenerationSpeed: number;
-    isNextGenerationOdd: number;
-    center: vec2;
-    radius: number;
-    infectionProbability: number;
+    time: number;
     agentCount: number;
+    introMoveSpeed: number;
+    introProgress?: number;
   }) {
     this.agentCount = agentCount;
-    this.device.queue.writeBuffer(
+    const resolvedIntroProgress = introProgress ?? 1;
+    // Once the intro target phase ends nothing reads intro fields again, so the
+    // steady-only pipeline can replace the full one for the rest of the session.
+    this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff;
+    // Reaction matrix: column N holds the weights for source colorIndex == N.
+    this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1;
+    this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2;
+    this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3;
+    this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1;
+    this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2;
+    this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3;
+    this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1;
+    this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2;
+    this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3;
+    this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime;
+    this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime;
+    const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
+    this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle);
+    this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle);
+    this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance;
+    this.uniformValues[SCALAR_BASE + 5] = turnWhenLost;
+    this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight;
+    this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount));
+    this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress;
+    this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale;
+    this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner;
+    this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin;
+    this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier;
+    this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend;
+    this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff;
+    this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier;
+    this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier;
+    this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime;
+    this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance;
+    this.uniformUintValues[SCALAR_BASE + 19] =
+      Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
+    writeBufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([
-        ...center,
-        radius,
-
-        brushTrailWeight,
-        moveSpeed * deltaTime,
-        turnSpeed * deltaTime,
-
-        (sensorOffsetAngle * Math.PI) / 180,
-        sensorOffsetDistance,
-
-        currentGenerationAggression,
-        nextGenerationAggression,
-        nextGenerationSensorOffsetDistance,
-        nextGenerationSpeed * deltaTime,
-        isNextGenerationOdd,
-
-        turnWhenLost,
-        individualTrailWeight,
-        infectionProbability,
-
-        agentCount,
-      ])
+      this.uniformValues,
+      this.uniformCache
     );
   }
 
   public execute(
     commandEncoder: GPUCommandEncoder,
     trailMapIn: GPUTextureView,
-    trailMapOut: GPUTextureView
+    trailMapOut: GPUTextureView,
+    timestampWrites?: GPUComputePassTimestampWrites
   ) {
-    this.ensureBindGroupExists(trailMapIn, trailMapOut);
-
-    const passEncoder = commandEncoder.beginComputePass();
-    passEncoder.setPipeline(this.pipeline);
-    this.commonState.execute(passEncoder);
-    passEncoder.setBindGroup(1, this.bindGroup);
-    passEncoder.dispatchWorkgroups(
-      ...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE)
-    );
-    passEncoder.end();
-  }
-
-  private ensureBindGroupExists(trailMapIn: GPUTextureView, trailMapOut: GPUTextureView) {
-    if (
-      this.previousTrailMapIn !== trailMapIn ||
-      this.previousTrailMapOut !== trailMapOut
-    ) {
-      this.bindGroup = this.device.createBindGroup({
-        layout: this.bindGroupLayout,
-        entries: [
-          {
-            binding: 0,
-            resource: {
-              buffer: this.uniforms,
-            },
-          },
-          {
-            binding: 1,
-            resource: {
-              buffer: this.agentsBuffer,
-            },
-          },
-          {
-            binding: 2,
-            resource: trailMapIn,
-          },
-          {
-            binding: 3,
-            resource: trailMapOut,
-          },
-        ],
-      });
-
-      this.previousTrailMapIn = trailMapIn;
-      this.previousTrailMapOut = trailMapOut;
+    if (this.agentCount <= 0) {
+      return;
     }
+
+    const passEncoder = commandEncoder.beginComputePass(
+      timestampWrites ? { timestampWrites } : undefined
+    );
+    passEncoder.setPipeline(
+      this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull
+    );
+    this.commonState.execute(passEncoder);
+    passEncoder.setBindGroup(
+      1,
+      this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
+    );
+    dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
+    passEncoder.end();
   }
 
   public destroy() {
     this.uniforms.destroy();
   }
-
-  private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
-    return {
-      entries: [
-        {
-          binding: 0,
-          visibility: GPUShaderStage.COMPUTE,
-          buffer: {
-            type: 'uniform',
-          },
-        },
-        {
-          binding: 1,
-          visibility: GPUShaderStage.COMPUTE,
-          buffer: {
-            type: 'storage',
-          },
-        },
-        {
-          binding: 2,
-          visibility: GPUShaderStage.COMPUTE,
-          texture: {
-            sampleType: 'float',
-          },
-        },
-        {
-          binding: 3,
-          visibility: GPUShaderStage.COMPUTE,
-          storageTexture: {
-            format: 'rgba16float',
-          },
-        },
-      ],
-    };
-  }
 }
diff --git a/src/pipelines/agents/agent-settings.ts b/src/pipelines/agents/agent-settings.ts
deleted file mode 100644
index 53b639c..0000000
--- a/src/pipelines/agents/agent-settings.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export interface AgentSettings {
-  brushTrailWeight: number;
-  moveSpeed: number;
-  turnSpeed: number;
-  sensorOffsetAngle: number;
-  sensorOffsetDistance: number;
-  turnWhenLost: number;
-  individualTrailWeight: number;
-  currentGenerationAggression: number;
-  nextGenerationAggression: number;
-}
diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl
index 576b3cc..912e2a0 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -1,134 +1,273 @@
+const PI: f32 = 3.14159265359;
+const TAU: f32 = 6.28318530718;
+const INV_TAU: f32 = 0.15915494309;
+
+const CHANNEL_MASKS = array, 3>(
+  vec3(1.0, 0.0, 0.0),
+  vec3(0.0, 1.0, 0.0),
+  vec3(0.0, 0.0, 1.0),
+);
+
 struct Settings {
-  center: vec2,
-  radius: f32,
-
-  brushTrailWeight: f32,
-  currentGenerationMoveRate: f32,
+  // Columns are indexed by source colorIndex; each column holds the per-target
+  // weights (colorXToColor1, colorXToColor2, colorXToColor3).
+  reactionMatrix: mat3x3,
+  moveRate: f32,
   turnRate: f32,
-
-  sensorAngle: f32,
+  sensorAngleSin: f32,
+  sensorAngleCos: f32,
   sensorOffset: f32,
-
-  currentGenerationAggression: f32,
-  nextGenerationAggression: f32,
-  nextGenerationSensorOffsetDistance: f32,
-  nextGenerationMoveRate: f32,
-  isNextGenerationOdd: f32,
-
   turnWhenLost: f32,
   individualTrailWeight: f32,
-  infectionProbability: f32,
-
-  agentCount: f32 // might be smaller than the length of the agents array
+  agentCount: u32,
+  introProgress: f32,
+  forwardRotationScale: f32,
+  introNearDistanceInner: f32,
+  introNearDistanceMin: f32,
+  introNearSensorOffsetMultiplier: f32,
+  introTargetAngleBlend: f32,
+  introProgressCutoff: f32,
+  introTurnRateMultiplier: f32,
+  introRandomTurnMultiplier: f32,
+  introMoveRate: f32,
+  introStepStopDistance: f32,
+  randomTimeSeed: u32,
 };
 
-
 @group(1) @binding(0) var settings: Settings;
-
-// even generation's trail -> red channel
-// odd generation's trail -> green channel
-// unused -> blue channel
-// brush -> alpha channel
 @group(1) @binding(2) var trailMapIn: texture_2d;
-@group(1) @binding(3) var trailMapOut: texture_storage_2d;
+@group(1) @binding(3) var trailMapOut: texture_storage_2d;
 
+struct AgentMovement {
+  rotation: f32,
+  step: vec2,
+}
 
-@compute @workgroup_size(64)
+@compute @workgroup_size(agentWorkgroupSize)
 fn main(
-  @builtin(global_invocation_id) global_id: vec3,
-  @builtin(num_workgroups) workgroup_count: vec3
+  @builtin(global_invocation_id) global_id: vec3
 ) {
-  let id = get_id(global_id, workgroup_count);
+  let id = get_id(global_id);
 
-  if id >= u32(settings.agentCount) {
+  if id >= settings.agentCount {
     return;
   }
 
-  var agent = agents[id];
-
-  let random = textureSampleLevel(
-    noise,
-    noiseSampler,
-    vec2(
-      f32(id) % 23647 / 2000,
-      state.time % 3243 / 2000
-    ),
-    0
-  );
-
-  let isFromCurrentGeneration = abs(agent.generation - settings.isNextGenerationOdd);
-  let isFromNextGeneration = 1.0 - isFromCurrentGeneration;
-  let isFromOddGeneration = agent.generation % 2;
-
-  let sensorOffset = mix(settings.sensorOffset, settings.nextGenerationSensorOffsetDistance, isFromNextGeneration); 
-  let moveRate = mix(settings.currentGenerationMoveRate, settings.nextGenerationMoveRate, isFromNextGeneration); 
-  let brushWeight = mix(settings.brushTrailWeight, 0, isFromNextGeneration);
-  
-  let trailForward = sense(agent.position, agent.angle, sensorOffset, 0);
-  let trailLeft = sense(agent.position, agent.angle, sensorOffset, settings.sensorAngle);
-  let trailRight = sense(agent.position, agent.angle, sensorOffset, -settings.sensorAngle);
-
-  var weightForward = brushWeight * trailForward.a;
-  var weightLeft = brushWeight * trailLeft.a;
-  var weightRight = brushWeight * trailRight.a;
-
-  let agression = mix(settings.currentGenerationAggression, settings.nextGenerationAggression, isFromNextGeneration) + weightForward; 
-
-  weightForward += mix(trailForward.r + agression * trailForward.g, trailForward.g + agression * trailForward.r, isFromOddGeneration);
-  weightLeft += mix(trailLeft.r + agression * trailLeft.g, trailLeft.g + agression * trailLeft.r, isFromOddGeneration);
-  weightRight += mix(trailRight.r + agression * trailRight.g, trailRight.g + agression * trailRight.r, isFromOddGeneration);
-  
-  var rotation: f32;
-  if weightForward >= weightLeft && weightForward >= weightRight {
-    rotation = 0;
-  } else {
-    rotation = sign(weightLeft - weightRight) * settings.turnRate;
+  let colorIndex = agents[id].colorIndex;
+  if colorIndex < 0.0 || colorIndex >= 2.5 {
+    return;
   }
 
-  let nextPosition = clamp(
-    agent.position + vec2(cos(agent.angle), sin(agent.angle)) * moveRate,
-    vec2(0, 0),
-    state.size
-  );
-  if nextPosition.x == 0 || nextPosition.x == state.size.x || nextPosition.y == 0 || nextPosition.y == state.size.y {
-    rotation = 3.14159265359 + random.a - 0.5;
-  }
-
-  var trail = vec4(settings.individualTrailWeight, 0, 0, 0);
-  if isFromOddGeneration == 1.0 {
-    trail = vec4(0, settings.individualTrailWeight, 0, 0);
-  }
-
-  var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0);
-
-  agent.angle += rotation;
-  trailBelow += trail;
-
-  if settings.radius > 0 && length(settings.center - agent.position) < settings.radius {
-    agent.generation = settings.isNextGenerationOdd;
-    
-    // clear trail map below so the agent won't die immediately
-    // trailBelow.r = (1 - settings.isNextGenerationOdd) * (trailBelow.r + trailBelow.g);
-    // trailBelow.g = settings.isNextGenerationOdd * (trailBelow.r + trailBelow.g);
-  } else {
-    let relativeWeight = mix(trailBelow.g - trailBelow.r, trailBelow.r - trailBelow.g, isFromOddGeneration);
-    if (relativeWeight > 0 && (
-        (isFromCurrentGeneration == 1.0 && trailBelow.a == 0 && random.b < settings.infectionProbability)
-     || (isFromCurrentGeneration == 0.0 && trailBelow.a > 0)
-    )) || (trailBelow.a > 0 && isFromCurrentGeneration == 0.0){
-      // trailBelow.r = isFromOddGeneration * (trailBelow.r + trailBelow.g);
-      // trailBelow.g = (1 - isFromOddGeneration) * (trailBelow.r + trailBelow.g);
-      agent.generation = (agent.generation + 1) % 2;
+  let position = agents[id].position;
+  let angle = agents[id].angle;
+  var targetPosition = vec2(-1.0, -1.0);
+  var hasIntroTarget = false;
+  if settings.introProgress < settings.introProgressCutoff {
+    targetPosition = agents[id].targetPosition;
+    hasIntroTarget = targetPosition.x >= 0.0 && targetPosition.y >= 0.0;
+    if hasIntroTarget && settings.introProgress < agents[id].introDelay {
+      return;
     }
   }
 
-  textureStore(trailMapOut, vec2(nextPosition), trailBelow);
-  agent.position = nextPosition;
-  agents[id] = agent;
+  let channelMask = get_channel_mask(colorIndex);
+  let reactionMask = get_reaction_mask(colorIndex);
+  let randomSeed = random_seed(id);
+  let maxPosition = state.size - vec2(1.0, 1.0);
+
+  var movement = AgentMovement(0.0, vec2(0.0, 0.0));
+  if hasIntroTarget {
+    movement = intro_decide(id, position, angle, targetPosition, randomSeed);
+  } else {
+    movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
+  }
+
+  agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
 }
 
-fn sense(agentPosition: vec2, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4 {
-  let sensorAngle = agentAngle + sensorOffsetAngle;
-  let sensorPosition = vec2(agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset);
-  return textureLoad(trailMapIn, sensorPosition, 0); 
+// Steady-state-only entry point used after introProgress >= introProgressCutoff.
+// Drops the intro target reads, atan2/smoothstep math, and introDelay check —
+// once intro completes those paths are dead for the rest of the session.
+@compute @workgroup_size(agentWorkgroupSize)
+fn mainSteady(
+  @builtin(global_invocation_id) global_id: vec3
+) {
+  let id = get_id(global_id);
+
+  if id >= settings.agentCount {
+    return;
+  }
+
+  let colorIndex = agents[id].colorIndex;
+  if colorIndex < 0.0 || colorIndex >= 2.5 {
+    return;
+  }
+
+  let position = agents[id].position;
+  let angle = agents[id].angle;
+  let channelMask = get_channel_mask(colorIndex);
+  let reactionMask = get_reaction_mask(colorIndex);
+  let randomSeed = random_seed(id);
+  let maxPosition = state.size - vec2(1.0, 1.0);
+
+  let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
+  agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
+}
+
+fn steady_decide(
+  position: vec2,
+  angle: f32,
+  reactionMask: vec3,
+  randomSeed: u32,
+  maxPosition: vec2
+) -> AgentMovement {
+  let randomTurn = random_float(randomSeed);
+  let direction = vec2(cos(angle), sin(angle));
+
+  let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
+  let leftSensor = sensor_position(
+    position,
+    rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
+    settings.sensorOffset,
+    maxPosition
+  );
+  let rightSensor = sensor_position(
+    position,
+    rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
+    settings.sensorOffset,
+    maxPosition
+  );
+
+  let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
+  let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
+  let trailRight = textureLoad(trailMapIn, rightSensor, 0);
+
+  let weightForward = dot(trailForward.rgb, reactionMask);
+  let weightLeft = dot(trailLeft.rgb, reactionMask);
+  let weightRight = dot(trailRight.rgb, reactionMask);
+
+  var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
+  if weightForward >= weightLeft && weightForward >= weightRight {
+    rotation = rotation * settings.forwardRotationScale;
+  } else {
+    rotation += sign(weightLeft - weightRight) * settings.turnRate;
+  }
+
+  return AgentMovement(rotation, direction * settings.moveRate);
+}
+
+fn intro_decide(
+  id: u32,
+  position: vec2,
+  angle: f32,
+  targetPosition: vec2,
+  randomSeed: u32
+) -> AgentMovement {
+  let introTargetOffset = targetPosition - position;
+  let introTargetDistance = length(introTargetOffset);
+  let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
+  let nearTitle = 1.0 - smoothstep(
+    settings.introNearDistanceInner,
+    max(
+      settings.introNearDistanceMin,
+      settings.sensorOffset * settings.introNearSensorOffsetMultiplier
+    ),
+    introTargetDistance
+  );
+  let desiredAngle = mix(
+    targetAngle,
+    agents[id].targetAngle,
+    nearTitle * settings.introTargetAngleBlend
+  );
+  let introTurn = angle_delta(angle, desiredAngle);
+
+  let rotation = clamp(
+      introTurn,
+      -settings.turnRate * settings.introTurnRateMultiplier,
+      settings.turnRate * settings.introTurnRateMultiplier
+    )
+    + (random_float(randomSeed + 1013904223u) - 0.5) *
+      settings.turnWhenLost *
+      settings.introRandomTurnMultiplier;
+  let moveRate = min(settings.introMoveRate, introTargetDistance);
+  var step = vec2(0.0, 0.0);
+  if introTargetDistance > settings.introStepStopDistance {
+    step = introTargetOffset / introTargetDistance * moveRate;
+  }
+  return AgentMovement(rotation, step);
+}
+
+fn agent_finalize(
+  id: u32,
+  position: vec2,
+  angle: f32,
+  channelMask: vec3,
+  randomSeed: u32,
+  maxPosition: vec2,
+  movement: AgentMovement
+) {
+  let nextPosition = clamp(position + movement.step, vec2(0, 0), maxPosition);
+  var rotation = movement.rotation;
+  if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
+    rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
+  }
+
+  // Writes only this agent's last-writer-wins deposit into a per-frame-cleared
+  // depositMap. Storage textures do not blend concurrent compute writes, so
+  // overlapping agents intentionally collapse to whichever write wins. The
+  // diffusion pass then sums trailMap + depositMap at tile-load time.
+  textureStore(
+    trailMapOut,
+    vec2(nextPosition),
+    vec4(channelMask * settings.individualTrailWeight, 0.0)
+  );
+  agents[id].angle = angle + rotation;
+  agents[id].position = nextPosition;
+}
+
+fn sensor_position(
+  agentPosition: vec2,
+  direction: vec2,
+  sensorOffset: f32,
+  maxPosition: vec2
+) -> vec2 {
+  return vec2(clamp(
+    agentPosition + direction * sensorOffset,
+    vec2(0, 0),
+    maxPosition
+  ));
+}
+
+fn rotate_direction(direction: vec2, angleSin: f32, angleCos: f32) -> vec2 {
+  return vec2(
+    direction.x * angleCos - direction.y * angleSin,
+    direction.x * angleSin + direction.y * angleCos
+  );
+}
+
+fn get_channel_mask(colorIndex: f32) -> vec3 {
+  return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))];
+}
+
+fn get_reaction_mask(colorIndex: f32) -> vec3 {
+  return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))];
+}
+
+fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
+  // Wraps to (-π, π] via fract(); replaces atan2(sin(d), cos(d)).
+  return (fract((targetAngle - sourceAngle) * INV_TAU + 0.5) - 0.5) * TAU;
+}
+
+fn random_seed(id: u32) -> u32 {
+  return id * 747796405u + settings.randomTimeSeed * 2891336453u;
+}
+
+fn random_float(seed: u32) -> f32 {
+  return f32(hash_u32(seed) >> 8u) * (1.0 / 16777216.0);
+}
+
+fn hash_u32(seed: u32) -> u32 {
+  let value = seed * 747796405u + 2891336453u;
+  let word = ((value >> ((value >> 28u) + 4u)) ^ value) * 277803737u;
+  return (word >> 22u) ^ word;
 }
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index 93a0cd3..b759c4f 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -1,261 +1,206 @@
 import { vec2 } from 'gl-matrix';
 
-import { clamp } from '../../utils/clamp';
+import { appConfig } from '../../config';
+import { getRenderQualityBrushSize } from '../../config/brush-size';
+import {
+  createCachedBufferWrite,
+  writeBufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { smartCompile } from '../../utils/graphics/smart-compile';
 import { CommonState } from '../common-state/common-state';
-import { BrushSettings } from './brush-settings';
+import {
+  LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
+  LINE_SEGMENT_VERTICES,
+  LineSegmentBuffer,
+} from '../common/line-segment-buffer';
+import lineSegmentShader from '../common/line-segment.wgsl?raw';
+import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
 import shader from './brush.wgsl?raw';
 
-export class BrushPipeline {
-  private static readonly UNIFORM_COUNT = 2;
-  private static readonly MAX_LINE_COUNT = 20;
-  private static readonly VERTICES_PER_LINE_SEGMENT = 6;
-  private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
+export interface BrushSettings {
+  brushSize: number;
+  brushAlpha: number;
+  brushDiscardThreshold: number;
+  brushGrainNoiseScale: number;
+  brushGrainNoiseOffsetX: number;
+  brushGrainNoiseOffsetY: number;
+  brushGrainMinStrength: number;
+  brushGrainMaxStrength: number;
+}
 
+interface BrushParameters extends BrushSettings {
+  internalRenderAreaMegapixels: number;
+  pixelRatio?: number;
+  selectedColorIndex: number;
+}
+
+export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
+  typeof pixelRatio === 'number' && Number.isFinite(pixelRatio) && pixelRatio > 0
+    ? pixelRatio
+    : 1;
+
+const UNIFORM_COUNT = 16;
+
+const setBrushUniformValues = (
+  target: Float32Array,
+  {
+    brushSize,
+    brushAlpha,
+    brushDiscardThreshold,
+    brushGrainNoiseScale,
+    brushGrainNoiseOffsetX,
+    brushGrainNoiseOffsetY,
+    brushGrainMinStrength,
+    brushGrainMaxStrength,
+    internalRenderAreaMegapixels,
+    selectedColorIndex,
+    pixelRatio,
+  }: BrushParameters
+): void => {
+  const safePixelRatio = getSafePixelRatio(pixelRatio);
+  const brushRadius =
+    (getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) *
+      safePixelRatio) /
+    2;
+
+  target[0] = brushRadius;
+  target[1] = brushRadius * brushRadius;
+  // target[2], target[3] are WGSL alignment padding for brushValue:vec4 — never read by the shader.
+  target[4] = selectedColorIndex === 0 ? 1 : 0;
+  target[5] = selectedColorIndex === 1 ? 1 : 0;
+  target[6] = selectedColorIndex === 2 ? 1 : 0;
+  target[7] = brushAlpha;
+  target[8] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
+  target[9] = brushGrainNoiseOffsetX;
+  target[10] = brushGrainNoiseOffsetY;
+  target[11] = brushDiscardThreshold;
+  target[12] = brushGrainMinStrength;
+  target[13] = brushGrainMaxStrength;
+};
+
+export class BrushPipeline {
   private readonly bindGroupLayout: GPUBindGroupLayout;
   private readonly bindGroup: GPUBindGroup;
-  private readonly pipeline: GPURenderPipeline;
+  private readonly renderPipeline: GPURenderPipeline;
   private readonly uniforms: GPUBuffer;
-  private readonly vertexBuffer: GPUBuffer;
-
-  private linePoints: Array = [];
-  private actualPoints: Array = [];
+  private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
+  private readonly uniformCache = createCachedBufferWrite(
+    UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
+  );
+  private readonly segments: LineSegmentBuffer;
 
   public constructor(
     private readonly device: GPUDevice,
     private readonly commonState: CommonState
   ) {
-    this.bindGroupLayout = device.createBindGroupLayout(BrushPipeline.bindGroupLayout);
+    this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
 
-    this.vertexBuffer = device.createBuffer({
-      size:
-        BrushPipeline.MAX_LINE_COUNT *
-        BrushPipeline.VERTICES_PER_LINE_SEGMENT *
-        BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT *
-        Float32Array.BYTES_PER_ELEMENT,
-      usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
+    this.bindGroupLayout = device.createBindGroupLayout({
+      entries: [
+        {
+          binding: 0,
+          visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
+          buffer: { type: 'uniform' },
+        },
+      ],
     });
 
-    this.pipeline = device.createRenderPipeline({
+    const shaderModule = smartCompile(
+      device,
+      CommonState.shaderCode,
+      lineSegmentShader,
+      shader
+    );
+    this.renderPipeline = device.createRenderPipeline({
       layout: device.createPipelineLayout({
-        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+        bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
       }),
       vertex: {
-        module: smartCompile(device, CommonState.shaderCode, shader),
+        module: shaderModule,
         entryPoint: 'vertex',
-        buffers: [
-          {
-            arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
-            attributes: [
-              {
-                shaderLocation: 0,
-                format: 'float32x2',
-                offset: 0,
-              },
-              {
-                shaderLocation: 1,
-                format: 'float32x2',
-                offset: Float32Array.BYTES_PER_ELEMENT * 2,
-              },
-              {
-                shaderLocation: 2,
-                format: 'float32x2',
-                offset: Float32Array.BYTES_PER_ELEMENT * 4,
-              },
-            ],
-          },
-        ],
+        buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
       },
       fragment: {
-        module: smartCompile(device, CommonState.shaderCode, shader),
+        module: shaderModule,
         entryPoint: 'fragment',
         targets: [
           {
-            format: 'rgba16float',
+            format: TRAIL_SOURCE_TEXTURE_FORMAT,
             blend: {
-              color: {
-                operation: 'add',
-                srcFactor: 'zero',
-                dstFactor: 'one',
-              },
-              alpha: {
-                operation: 'max',
-                srcFactor: 'one',
-                dstFactor: 'one',
-              },
+              color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
+              alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
             },
           },
         ],
       },
-      primitive: {
-        topology: 'triangle-list',
-      },
+      primitive: { topology: 'triangle-list' },
     });
 
-    this.uniforms = this.device.createBuffer({
-      size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+    this.uniforms = device.createBuffer({
+      size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
 
-    this.bindGroup = this.bindGroup = this.device.createBindGroup({
+    this.bindGroup = device.createBindGroup({
       layout: this.bindGroupLayout,
-      entries: [
-        {
-          binding: 0,
-          resource: {
-            buffer: this.uniforms,
-          },
-        },
-      ],
+      entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
     });
   }
 
-  public addSwipe(position: vec2) {
-    this.linePoints.push(position);
+  public addSwipeSegment(from: vec2, to: vec2): void {
+    this.segments.add(from, to);
   }
 
-  public clearSwipes() {
-    this.linePoints.length = 0;
+  public clearSwipes(): void {
+    this.segments.clear();
   }
 
-  public setParameters({ brushSize, brushSizeVariation }: BrushSettings) {
-    this.device.queue.writeBuffer(
+  public setParameters(parameters: BrushParameters): void {
+    setBrushUniformValues(this.uniformValues, parameters);
+    writeBufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([brushSize / 2, Math.floor((brushSize / 2) * brushSizeVariation)])
-    );
-
-    this.actualPoints = this.linePoints.slice();
-    this.linePoints.splice(0, this.linePoints.length - 1);
-
-    if (this.actualPoints.length === 0) {
-      return;
-    }
-
-    if (this.actualPoints.length === 1) {
-      this.actualPoints.push(this.actualPoints[0]); // allow single point swipes
-    }
-
-    if (this.actualPoints.length > BrushPipeline.MAX_LINE_COUNT + 1) {
-      this.actualPoints = BrushPipeline.subsampleLinePoints(this.actualPoints);
-    }
-
-    this.device.queue.writeBuffer(
-      this.vertexBuffer,
-      0,
-      new Float32Array(
-        new Array(this.lineCount).fill(0).flatMap((_, i) => {
-          const from = this.actualPoints[i];
-          const to = this.actualPoints[i + 1];
-          const [a, b, c, d] = this.getSegmentBoundingBox(from, to, brushSize / 2);
-          return [a, b, c, b, c, d].flatMap((v) => [...v, ...from, ...to]);
-        })
-      )
+      this.uniformValues,
+      this.uniformCache
     );
+    this.segments.flush();
   }
 
-  private static subsampleLinePoints(points: Array): Array {
-    const lines = [];
-    for (let i = 0; i < points.length - 2; i++) {
-      lines.push({
-        from: points[i],
-        to: points[i + 1],
-        length: vec2.dist(points[i], points[i + 1]),
-      });
+  public executeSource(
+    commandEncoder: GPUCommandEncoder,
+    sourceMapOut: GPUTextureView,
+    timestampWrites?: GPURenderPassTimestampWrites
+  ): boolean {
+    const lineCount = this.segments.activeCount;
+    if (lineCount === 0) {
+      return false;
     }
 
-    const sumLength = lines.reduce((sum, line) => sum + line.length, 0);
-
-    let currentLineIndex = 0;
-    let lineLengthSoFar = 0;
-    const result: Array = [points[0]];
-    for (let i = 1; i < BrushPipeline.MAX_LINE_COUNT; i++) {
-      const t = (i * sumLength) / (BrushPipeline.MAX_LINE_COUNT + 1);
-      while (lineLengthSoFar + lines[currentLineIndex].length < t) {
-        lineLengthSoFar += lines[currentLineIndex].length;
-        currentLineIndex++;
-      }
-
-      const line = lines[currentLineIndex];
-      const position = vec2.lerp(
-        vec2.create(),
-        line.from,
-        line.to,
-        (t - lineLengthSoFar) / line.length
-      );
-
-      result.push(position);
-    }
-
-    result.push(points[points.length - 1]);
-
-    return result;
-  }
-
-  private getSegmentBoundingBox(from: vec2, to: vec2, width: number): Array {
-    let dir = vec2.sub(vec2.create(), to, from);
-    vec2.normalize(dir, dir);
-
-    if (vec2.len(dir) === 0) {
-      dir = vec2.fromValues(1, 0); // allow single point swipes
-    }
-
-    const perp = vec2.fromValues(dir[1], -dir[0]);
-
-    vec2.scale(dir, dir, width);
-    vec2.scale(perp, perp, width);
-
-    const offsetStart = vec2.sub(vec2.create(), from, dir);
-    const offsetEnd = vec2.add(vec2.create(), to, dir);
-
-    return [
-      vec2.add(vec2.create(), offsetStart, perp),
-      vec2.sub(vec2.create(), offsetStart, perp),
-      vec2.add(vec2.create(), offsetEnd, perp),
-      vec2.sub(vec2.create(), offsetEnd, perp),
-    ];
-  }
-
-  public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
-    const renderPassDescriptor: GPURenderPassDescriptor = {
-      colorAttachments: [
-        {
-          view: trailMapOut,
-          loadOp: 'load',
-          storeOp: 'store',
-        },
-      ],
-    };
-
-    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
-    passEncoder.setPipeline(this.pipeline);
+    recordBrushPassForE2e();
+    const passEncoder = commandEncoder.beginRenderPass({
+      colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
+      timestampWrites,
+    });
+    passEncoder.setPipeline(this.renderPipeline);
     this.commonState.execute(passEncoder);
     passEncoder.setBindGroup(1, this.bindGroup);
-    passEncoder.setVertexBuffer(0, this.vertexBuffer);
-    passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
+    passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
+    passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
     passEncoder.end();
+    return true;
   }
 
-  public destroy() {
-    this.vertexBuffer.destroy();
+  public destroy(): void {
+    this.segments.destroy();
     this.uniforms.destroy();
   }
-
-  private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
-    return {
-      entries: [
-        {
-          binding: 0,
-          visibility: GPUShaderStage.FRAGMENT,
-          buffer: {
-            type: 'uniform',
-          },
-        },
-      ],
-    };
-  }
-
-  private get lineCount() {
-    return clamp(this.actualPoints.length - 1, 0, BrushPipeline.MAX_LINE_COUNT);
-  }
 }
+
+const recordBrushPassForE2e = (): void => {
+  if (typeof window === 'undefined') {
+    return;
+  }
+
+  const state = window as Window & { __fleetingGardenBrushPasses?: number };
+  state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1;
+};
diff --git a/src/pipelines/brush/brush-settings.ts b/src/pipelines/brush/brush-settings.ts
deleted file mode 100644
index cecb7a1..0000000
--- a/src/pipelines/brush/brush-settings.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface BrushSettings {
-  brushSize: number;
-  brushSizeVariation: number;
-}
diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl
index f705ead..8152986 100644
--- a/src/pipelines/brush/brush.wgsl
+++ b/src/pipelines/brush/brush.wgsl
@@ -1,6 +1,18 @@
+const SEGMENT_LENGTH_EPSILON: f32 = 0.0001;
+
 struct Settings {
-  brushSize: f32,
-  brushSizeVariation: f32
+  brushRadius: f32,
+  brushRadiusSquared: f32,
+  // padding to 16-byte alignment for the following vec4
+  _pad0: f32,
+  _pad1: f32,
+  brushValue: vec4,
+  brushGrainNoiseScale: f32,
+  brushGrainNoiseOffsetX: f32,
+  brushGrainNoiseOffsetY: f32,
+  brushDiscardThreshold: f32,
+  brushGrainMinStrength: f32,
+  brushGrainMaxStrength: f32,
 };
 
 @group(1) @binding(0) var settings: Settings;
@@ -8,41 +20,100 @@ struct Settings {
 struct VertexOutput {
   @builtin(position) position: vec4,
   @location(0) screenPosition: vec2,
-  @location(1) start: vec2,
-  @location(2) end: vec2
+  @location(1) @interpolate(flat) start: vec2,
+  @location(2) @interpolate(flat) direction: vec2,
+  @location(3) @interpolate(flat) inverseLengthSquared: f32,
+}
+
+struct BrushTargets {
+  @location(0) source: vec4,
 }
 
 @vertex
 fn vertex(
-  @location(0) screenPosition: vec2,
-  @location(1) @interpolate(flat) start: vec2,
-  @location(2) @interpolate(flat) end: vec2
+  @builtin(vertex_index) vertexIndex: u32,
+  @location(0) start: vec2,
+  @location(1) end: vec2
 ) -> VertexOutput {
+  let direction = end - start;
+  let denominator = dot(direction, direction);
+  var inverseLengthSquared = 0.0;
+  var normalizedDirection = vec2(1.0, 0.0);
+  if denominator > SEGMENT_LENGTH_EPSILON {
+    inverseLengthSquared = 1.0 / denominator;
+    normalizedDirection = direction * inverseSqrt(denominator);
+  }
+  let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.brushRadius);
   let uv = screenPosition / state.size;
-  let position = uv * 2.0 - 1.0;
-  return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
+  let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
+  return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
 }
 
 @fragment
 fn fragment(
   @location(0) screenPosition: vec2,
-  @location(1) start: vec2,
-  @location(2) end: vec2
-) -> @location(0) vec4 {
-    var distance = distanceFromLine(screenPosition, start, end);
-    let noise = textureSample(noise, noiseSampler, screenPosition / state.size / 50);
-    distance += noise.r * settings.brushSizeVariation;
+  @location(1) @interpolate(flat) start: vec2,
+  @location(2) @interpolate(flat) direction: vec2,
+  @location(3) @interpolate(flat) inverseLengthSquared: f32
+) -> BrushTargets {
+  let strength = brushStrength(screenPosition, start, direction, inverseLengthSquared);
 
-    if(distance > settings.brushSize) {
-      discard;
-    }
+  if(strength < settings.brushDiscardThreshold) {
+    discard;
+  }
 
-    return vec4(0, 0, 0, 1);
+  let color = brushOutput(strength);
+  return BrushTargets(color);
 }
 
-fn distanceFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
-  let pa = position - start;
-  let direction = end - start;
-  let q = clamp(dot(pa, direction) / dot(direction, direction), 0, 1);
-  return length(pa - direction * q);
+fn brushStrength(
+  screenPosition: vec2,
+  start: vec2,
+  direction: vec2,
+  inverseLengthSquared: f32
+) -> f32 {
+  let distanceSquared = distance_squared_from_segment(
+    screenPosition,
+    start,
+    direction,
+    inverseLengthSquared
+  );
+  if distanceSquared > settings.brushRadiusSquared {
+    return 0.0;
+  }
+
+  let maxGrainStrength = max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength);
+  if maxGrainStrength < settings.brushDiscardThreshold {
+    return 0.0;
+  }
+
+  // smoothstep(0.35, 1.0, sqrt(d²/r²)) reparameterized to squared distance:
+  // squaring the edges gives smoothstep(0.1225·r², r², d²), avoiding the sqrt.
+  let safeRadiusSquared = max(settings.brushRadiusSquared, 0.0001);
+  let feather = 1.0 - smoothstep(0.1225 * safeRadiusSquared, safeRadiusSquared, distanceSquared);
+  if feather <= 0.0 {
+    return 0.0;
+  }
+
+  if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
+    return settings.brushGrainMinStrength * feather;
+  }
+
+  let grainNoise = textureSampleLevel(
+    noise,
+    noiseSampler,
+    screenPosition * settings.brushGrainNoiseScale +
+      vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
+    0.0
+  ).r;
+  let grainStrength = mix(
+    settings.brushGrainMinStrength,
+    settings.brushGrainMaxStrength,
+    grainNoise
+  );
+  return grainStrength * feather;
+}
+
+fn brushOutput(strength: f32) -> vec4 {
+  return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
 }
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index 1dda653..5157459 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -1,12 +1,21 @@
 import { vec2 } from 'gl-matrix';
 
+import { appConfig } from '../../config';
+import {
+  createCachedBufferWrite,
+  writeBufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { generateNoise } from '../../utils/graphics/noise';
 
 export class CommonState {
   private static readonly UNIFORM_COUNT = 4;
 
   private readonly uniforms: GPUBuffer;
-  private readonly noise: GPUTextureView;
+  private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedBufferWrite(
+    CommonState.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
+  );
+  private readonly noise: GPUTexture;
   private readonly bindGroup: GPUBindGroup;
 
   public readonly bindGroupLayout: GPUBindGroupLayout;
@@ -14,10 +23,9 @@ export class CommonState {
   public static readonly shaderCode = /* wgsl */ `
     struct State {
       size: vec2,
-      deltaTime: f32, 
-      time: f32,
+      _padding: vec2,
     };
-    
+
     @group(0) @binding(0) var state: State;
     @group(0) @binding(1) var noiseSampler: sampler;
     @group(0) @binding(2) var noise: texture_2d;
@@ -29,11 +37,12 @@ export class CommonState {
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
 
-    this.noise = generateNoise({
+    const noise = generateNoise({
       device,
-      width: 2048,
-      height: 2048,
+      width: appConfig.pipelines.common.noiseTextureSize,
+      height: appConfig.pipelines.common.noiseTextureSize,
     });
+    this.noise = noise.texture;
 
     this.bindGroupLayout = device.createBindGroupLayout({
       entries: [
@@ -74,31 +83,28 @@ export class CommonState {
         {
           binding: 1,
           resource: this.device.createSampler({
+            addressModeU: 'repeat',
+            addressModeV: 'repeat',
             magFilter: 'linear',
             minFilter: 'linear',
           }),
         },
         {
           binding: 2,
-          resource: this.noise,
+          resource: noise.view,
         },
       ],
     });
   }
 
-  public setParameters({
-    canvasSize,
-    deltaTime,
-    time,
-  }: {
-    canvasSize: vec2;
-    deltaTime: number;
-    time: number;
-  }) {
-    this.device.queue.writeBuffer(
+  public setParameters({ canvasSize }: { canvasSize: vec2 }) {
+    this.uniformValues[0] = canvasSize[0];
+    this.uniformValues[1] = canvasSize[1];
+    writeBufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([...canvasSize, deltaTime, time])
+      this.uniformValues,
+      this.uniformCache
     );
   }
 
@@ -108,5 +114,6 @@ export class CommonState {
 
   public destroy() {
     this.uniforms.destroy();
+    this.noise.destroy();
   }
 }
diff --git a/src/pipelines/common/line-segment-buffer.ts b/src/pipelines/common/line-segment-buffer.ts
new file mode 100644
index 0000000..417e971
--- /dev/null
+++ b/src/pipelines/common/line-segment-buffer.ts
@@ -0,0 +1,92 @@
+import { vec2 } from 'gl-matrix';
+
+export interface LineSegment {
+  from: vec2;
+  to: vec2;
+}
+
+export const LINE_SEGMENT_VERTICES = 6;
+const LINE_SEGMENT_ATTRIBUTES = 4;
+
+export const LINE_SEGMENT_VERTEX_BUFFER_LAYOUT: GPUVertexBufferLayout = {
+  arrayStride: Float32Array.BYTES_PER_ELEMENT * LINE_SEGMENT_ATTRIBUTES,
+  stepMode: 'instance',
+  attributes: [
+    { shaderLocation: 0, format: 'float32x2', offset: 0 },
+    {
+      shaderLocation: 1,
+      format: 'float32x2',
+      offset: Float32Array.BYTES_PER_ELEMENT * 2,
+    },
+  ],
+};
+
+export class LineSegmentBuffer {
+  public readonly vertexBuffer: GPUBuffer;
+
+  private readonly device: GPUDevice;
+  private readonly maxSegments: number;
+  private readonly uploadData: Float32Array;
+
+  private pending: Array = [];
+  private active: Array = [];
+
+  public constructor(device: GPUDevice, maxSegments: number) {
+    this.device = device;
+    this.maxSegments = maxSegments;
+    this.uploadData = new Float32Array(maxSegments * LINE_SEGMENT_ATTRIBUTES);
+    this.vertexBuffer = device.createBuffer({
+      size: this.uploadData.byteLength,
+      usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
+    });
+  }
+
+  public add(from: vec2, to: vec2): void {
+    this.pending.push({ from: vec2.clone(from), to: vec2.clone(to) });
+  }
+
+  public clear(): void {
+    this.pending.length = 0;
+    this.active.length = 0;
+  }
+
+  public get activeCount(): number {
+    return this.active.length;
+  }
+
+  public flush(): void {
+    this.active = this.pending.slice();
+    this.pending.length = 0;
+
+    if (this.active.length === 0) {
+      return;
+    }
+
+    if (this.active.length > this.maxSegments) {
+      this.active = subsample(this.active, this.maxSegments);
+    }
+
+    let offset = 0;
+    for (const segment of this.active) {
+      this.uploadData[offset++] = segment.from[0];
+      this.uploadData[offset++] = segment.from[1];
+      this.uploadData[offset++] = segment.to[0];
+      this.uploadData[offset++] = segment.to[1];
+    }
+
+    this.device.queue.writeBuffer(this.vertexBuffer, 0, this.uploadData, 0, offset);
+  }
+
+  public destroy(): void {
+    this.vertexBuffer.destroy();
+  }
+}
+
+const subsample = (segments: Array, count: number): Array => {
+  const result: Array = [];
+  for (let i = 0; i < count; i++) {
+    const index = Math.round((i * (segments.length - 1)) / (count - 1));
+    result.push(segments[index]);
+  }
+  return result;
+};
diff --git a/src/pipelines/common/line-segment.wgsl b/src/pipelines/common/line-segment.wgsl
new file mode 100644
index 0000000..9a4fb20
--- /dev/null
+++ b/src/pipelines/common/line-segment.wgsl
@@ -0,0 +1,35 @@
+// Six corners forming two triangles for an instanced segment quad.
+// X spans [-1, 1] along the segment direction, Y spans [-1, 1] perpendicular.
+fn segment_vertex_corner(index: u32) -> vec2 {
+  let isRight = index == 2u || index >= 4u;
+  let isTop = index == 0u || index == 2u || index == 4u;
+  return vec2(
+    select(-1.0, 1.0, isRight),
+    select(-1.0, 1.0, isTop)
+  );
+}
+
+fn segment_vertex_position(
+  vertexIndex: u32,
+  start: vec2,
+  end: vec2,
+  direction: vec2,
+  radius: f32
+) -> vec2 {
+  let perpendicular = vec2(direction.y, -direction.x);
+  let corner = segment_vertex_corner(vertexIndex % 6u);
+  let center = mix(start, end, (corner.x + 1.0) * 0.5);
+  return center + direction * corner.x * radius + perpendicular * corner.y * radius;
+}
+
+fn distance_squared_from_segment(
+  position: vec2,
+  start: vec2,
+  direction: vec2,
+  inverseLengthSquared: f32
+) -> f32 {
+  let pa = position - start;
+  let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
+  let nearestOffset = pa - direction * q;
+  return dot(nearestOffset, nearestOffset);
+}
diff --git a/src/pipelines/copy/copy-pipeline.ts b/src/pipelines/copy/copy-pipeline.ts
deleted file mode 100644
index b64cedf..0000000
--- a/src/pipelines/copy/copy-pipeline.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { vec2 } from 'gl-matrix';
-
-import { smartCompile } from '../../utils/graphics/smart-compile';
-import shader from './copy.wgsl?raw';
-
-export class CopyPipeline {
-  private static readonly UNIFORM_COUNT = 2;
-
-  private readonly bindGroupLayout: GPUBindGroupLayout;
-  private readonly pipeline: GPURenderPipeline;
-  private readonly uniforms: GPUBuffer;
-
-  private readonly vertexBuffer: GPUBuffer;
-
-  private bindGroup?: GPUBindGroup;
-  private previousTrailMapIn?: GPUTextureView;
-
-  public constructor(private readonly device: GPUDevice) {
-    this.bindGroupLayout = device.createBindGroupLayout(CopyPipeline.bindGroupLayout);
-
-    this.uniforms = this.device.createBuffer({
-      size: CopyPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
-      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
-    });
-
-    this.vertexBuffer = device.createBuffer({
-      size: 2 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec2
-      usage: GPUBufferUsage.VERTEX,
-      mappedAtCreation: true,
-    });
-    // prettier-ignore
-    const vertexData = [
-    // U    V
-      0.0, 1.0,
-      1.0, 1.0,
-      0.0, 0.0,
-      1.0, 0.0,
-    ];
-    new Float32Array(this.vertexBuffer.getMappedRange()).set(vertexData);
-    this.vertexBuffer.unmap();
-
-    this.pipeline = device.createRenderPipeline({
-      layout: device.createPipelineLayout({
-        bindGroupLayouts: [this.bindGroupLayout],
-      }),
-      vertex: {
-        module: smartCompile(device, shader),
-        entryPoint: 'vertex',
-        buffers: [
-          {
-            arrayStride: 2 * Float32Array.BYTES_PER_ELEMENT,
-            stepMode: 'vertex',
-            attributes: [
-              {
-                shaderLocation: 0,
-                offset: 0,
-                format: 'float32x2',
-              },
-            ],
-          },
-        ],
-      },
-      fragment: {
-        module: smartCompile(device, shader),
-        entryPoint: 'fragment',
-        targets: [
-          {
-            format: 'rgba16float',
-          },
-        ],
-      },
-      primitive: {
-        topology: 'triangle-strip',
-      },
-    });
-  }
-
-  public execute(
-    commandEncoder: GPUCommandEncoder,
-    trailMapIn: GPUTextureView,
-    trailMapOut: GPUTextureView,
-    scale: vec2 = vec2.fromValues(1, 1)
-  ) {
-    this.device.queue.writeBuffer(this.uniforms, 0, new Float32Array(scale));
-
-    const renderPassDescriptor: GPURenderPassDescriptor = {
-      colorAttachments: [
-        {
-          view: trailMapOut,
-          loadOp: 'clear',
-          storeOp: 'store',
-        },
-      ],
-    };
-
-    this.ensureBindGroupExists(trailMapIn);
-    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
-    passEncoder.setPipeline(this.pipeline);
-    passEncoder.setBindGroup(0, this.bindGroup);
-    passEncoder.setVertexBuffer(0, this.vertexBuffer);
-    passEncoder.draw(4, 1);
-    passEncoder.end();
-  }
-
-  public destroy() {
-    this.vertexBuffer.destroy();
-  }
-
-  private ensureBindGroupExists(trailMapIn: GPUTextureView) {
-    if (this.previousTrailMapIn !== trailMapIn) {
-      this.bindGroup = this.device.createBindGroup({
-        layout: this.bindGroupLayout,
-        entries: [
-          {
-            binding: 0,
-            resource: {
-              buffer: this.uniforms,
-            },
-          },
-          {
-            binding: 1,
-            resource: this.device.createSampler({
-              magFilter: 'linear',
-              minFilter: 'linear',
-            }),
-          },
-          {
-            binding: 2,
-            resource: trailMapIn,
-          },
-        ],
-      });
-
-      this.previousTrailMapIn = trailMapIn;
-    }
-  }
-
-  private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
-    return {
-      entries: [
-        {
-          binding: 0,
-          visibility: GPUShaderStage.VERTEX,
-          buffer: {
-            type: 'uniform',
-          },
-        },
-        {
-          binding: 1,
-          visibility: GPUShaderStage.FRAGMENT,
-          sampler: {
-            type: 'filtering',
-          },
-        },
-        {
-          binding: 2,
-          visibility: GPUShaderStage.FRAGMENT,
-          texture: {
-            sampleType: 'float',
-          },
-        },
-      ],
-    };
-  }
-}
diff --git a/src/pipelines/copy/copy.wgsl b/src/pipelines/copy/copy.wgsl
deleted file mode 100644
index 94b017f..0000000
--- a/src/pipelines/copy/copy.wgsl
+++ /dev/null
@@ -1,19 +0,0 @@
-struct VertexOutput {
-  @builtin(position) position: vec4,
-  @location(0) uv: vec2,
-}
-
-@vertex
-fn vertex(@location(0) uv: vec2) -> VertexOutput {
-  let ndc = uv * sourceScaler * vec2(2) - vec2(1);
-  return VertexOutput(vec4(ndc.x, -ndc.y, 0, 1), uv);
-}
-
-@group(0) @binding(0) var sourceScaler: vec2;
-@group(0) @binding(1) var Sampler: sampler;
-@group(0) @binding(2) var original: texture_2d;
-
-@fragment
-fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
-  return textureSample(original, Sampler, uv);
-}
diff --git a/src/pipelines/diffusion/diffuse.wgsl b/src/pipelines/diffusion/diffuse.wgsl
index 8ae9dee..92ae448 100644
--- a/src/pipelines/diffusion/diffuse.wgsl
+++ b/src/pipelines/diffusion/diffuse.wgsl
@@ -1,48 +1,171 @@
 struct Settings {
   inverseDiffusionRateTrails: f32,
   decayRateTrails: f32,
-  inverseDiffusionRateBrush: f32,
-  decayRateBrush: f32,
+  diffusionNeighborScale: f32,
+  brushDecayAlphaMultiplier: f32,
+  brushDecayAlphaSubtract: f32,
+  padding0: f32,
+  padding1: f32,
+  padding2: f32,
 };
 
+const WORKGROUP_SIZE_X = __WORKGROUP_SIZE__u;
+const WORKGROUP_SIZE_Y = __WORKGROUP_SIZE__u;
+// Half a quantization step of rgba8unorm (1/255 ≈ 0.00392). Subtracted from
+// RGB each frame so multiplicative decay can fall through the unorm
+// quantization floor; without it, the smallest nonzero level (1/255) is a
+// fixed point and trails never reach pure black.
+const TRAIL_RGB_DECAY_SUBTRACT: f32 = 0.00196;
+// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass
+// can be served from workgroup memory without bounds checks for interior tiles.
+const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
+const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u;
+const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y;
+// 1.0 / 2^32, used to map a 32-bit hash to [0, 1).
+const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
 
-@group(1) @binding(0) var settings: Settings;
-@group(1) @binding(1) var Sampler: sampler;
-@group(1) @binding(2) var trailMap: texture_2d;
+@group(0) @binding(0) var settings: Settings;
+@group(0) @binding(1) var trailMap: texture_2d;
+@group(0) @binding(2) var trailMapOut: texture_storage_2d;
+// Per-frame deposit accumulator written sparsely by agents. Summed with
+// trailMap at tile-load so deposits propagate through the diffusion kernel
+// in the same frame.
+@group(0) @binding(3) var depositMap: texture_2d;
 
+var tile: array, TILE_TEXEL_COUNT>;
+var tileTrailStrength: array;
 
-@fragment
-fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
-  var current = textureSample(trailMap, Sampler, uv);
+@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
+fn main(
+  @builtin(global_invocation_id) global_id: vec3,
+  @builtin(local_invocation_id) local_id: vec3,
+  @builtin(workgroup_id) workgroup_id: vec3
+) {
+  let textureSize = vec2(textureDimensions(trailMap, 0));
+  let textureBound = textureSize - vec2(1, 1);
+  let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
+  let workgroupOrigin = workgroup_id.xy * vec2(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
 
-  current += (
-        propagate(uv, vec2(-1.0, -1.0), current)
-      + propagate(uv, vec2(-1.0, 1.0), current)
-      + propagate(uv, vec2(1.0, -1.0), current)
-      + propagate(uv, vec2(1.0, 1.0), current)
+  for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
+    let tilePosition = vec2(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
+    let sourcePixel = clamp(
+      vec2(workgroupOrigin + tilePosition) - vec2(1, 1),
+      vec2(0, 0),
+      textureBound
+    );
+    let texel = textureLoad(trailMap, sourcePixel, 0)
+      + textureLoad(depositMap, sourcePixel, 0);
+    tile[tileIndex] = texel;
+    tileTrailStrength[tileIndex] = length(texel.rgb);
+  }
 
-      + propagate(uv, vec2(-1.0, 0.0), current)
-      + propagate(uv, vec2(0.0, -1.0), current)
-      + propagate(uv, vec2(1.0, 0.0), current)
-      + propagate(uv, vec2(0.0, 1.0), current)
-  ) / 8;
+  workgroupBarrier();
 
+  let pixel = vec2(i32(global_id.x), i32(global_id.y));
+  if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
+    return;
+  }
+
+  let centerTilePosition = local_id.xy + vec2(1u, 1u);
+  let c = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
+  let rowNorth = c - TILE_SIZE_X;
+  let rowSouth = c + TILE_SIZE_X;
+
+  // Batch-load all 8 neighbour texels and strengths into registers up front
+  // so the compiler can schedule LDS reads in parallel.
+  let current = tile[c];
+  let nTL = tile[rowNorth - 1u];
+  let nT  = tile[rowNorth];
+  let nTR = tile[rowNorth + 1u];
+  let nL  = tile[c - 1u];
+  let nR  = tile[c + 1u];
+  let nBL = tile[rowSouth - 1u];
+  let nB  = tile[rowSouth];
+  let nBR = tile[rowSouth + 1u];
+
+  let sTL = tileTrailStrength[rowNorth - 1u];
+  let sT  = tileTrailStrength[rowNorth];
+  let sTR = tileTrailStrength[rowNorth + 1u];
+  let sL  = tileTrailStrength[c - 1u];
+  let sR  = tileTrailStrength[c + 1u];
+  let sBL = tileTrailStrength[rowSouth - 1u];
+  let sB  = tileTrailStrength[rowSouth];
+  let sBR = tileTrailStrength[rowSouth + 1u];
+
+  let random = random_from_pixel(pixel);
+  let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
+
+  let propagated =
+      propagate_value(nTL, sTL, current, trailWeight)
+    + propagate_value(nT,  sT,  current, trailWeight)
+    + propagate_value(nTR, sTR, current, trailWeight)
+    + propagate_value(nL,  sL,  current, trailWeight)
+    + propagate_value(nR,  sR,  current, trailWeight)
+    + propagate_value(nBL, sBL, current, trailWeight)
+    + propagate_value(nB,  sB,  current, trailWeight)
+    + propagate_value(nBR, sBR, current, trailWeight);
+
+  let updated = current + propagated * settings.diffusionNeighborScale;
   let decayed = clamp(vec4(
-    current.rgb * settings.decayRateTrails,
-    max(0, current.a + (current.a - 1.001) * settings.decayRateBrush)
+    updated.rgb * settings.decayRateTrails - vec3(TRAIL_RGB_DECAY_SUBTRACT),
+    updated.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract
   ), vec4(0), vec4(1));
- 
-  return decayed;
+
+  textureStore(trailMapOut, pixel, decayed);
 }
 
-
-fn propagate(uv: vec2, offset: vec2, currentColor: vec4) -> vec4 {
-  let neighbour = textureSample(trailMap, Sampler, uv + offset / state.size);
-  var random = textureSample(noise, noiseSampler, uv + offset / state.size * 0.5).r;
-  let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
-
+fn propagate_value(
+  neighbour: vec4,
+  neighbourStrength: f32,
+  current: vec4,
+  trailWeight: f32
+) -> vec4 {
+  let difference = clamp(neighbour - current, vec4(0), vec4(1));
   return vec4(
-    vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)),
-    length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush)
+    vec3(neighbourStrength * trailWeight),
+    neighbour.a * trailWeight
   ) * difference;
 }
+
+fn random_from_pixel(pixel: vec2) -> f32 {
+  let p = vec2(pixel);
+  var hash = p.x * 1664525u + p.y * 1013904223u + 374761393u;
+  hash = (hash ^ (hash >> 16u)) * 2246822519u;
+  hash = (hash ^ (hash >> 13u)) * 3266489917u;
+  hash = hash ^ (hash >> 16u);
+  return f32(hash) * HASH_TO_UNIT_FLOAT;
+}
+
+// Approximates pow(r, inverseRate) piecewise between powers (r, r^2, r^4, r^8, r^16)
+// so we can vary diffusion sharpness without paying for a real pow() per pixel.
+fn diffusion_weight(
+  r: f32,
+  inverseRate: f32
+) -> f32 {
+  if inverseRate < 1.0 {
+    let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
+    return mix(
+      rootApproximation,
+      r,
+      clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0)
+    );
+  }
+  let r2 = r * r;
+  if inverseRate < 2.0 {
+    return mix(r, r2, inverseRate - 1.0);
+  }
+  let r4 = r2 * r2;
+  if inverseRate < 4.0 {
+    // (inverseRate - 2.0) / (4.0 - 2.0)
+    return mix(r2, r4, (inverseRate - 2.0) * 0.5);
+  }
+  let r8 = r4 * r4;
+  if inverseRate < 8.0 {
+    // (inverseRate - 4.0) / (8.0 - 4.0)
+    return mix(r4, r8, (inverseRate - 4.0) * 0.25);
+  }
+  let r16 = r8 * r8;
+  // (inverseRate - 8.0) / (16.0 - 8.0); past 16, falls off as 16/inverseRate.
+  return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
+    * min(1.0, 16.0 / inverseRate);
+}
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index 3bb3422..b1c763d 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -1,47 +1,109 @@
-import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
+import { vec2 } from 'gl-matrix';
+
+import { appConfig } from '../../config';
+import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
+import {
+  createCachedBufferWrite,
+  writeBufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { smartCompile } from '../../utils/graphics/smart-compile';
-import { CommonState } from '../common-state/common-state';
+import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
 import shader from './diffuse.wgsl?raw';
-import { DiffusionSettings } from './diffusion-settings';
+
+export interface DiffusionSettings {
+  diffusionRateTrails: number;
+  decayRateTrails: number;
+  decayRateBrush: number;
+  diffusionDecayRateDivisor: number;
+  diffusionNeighborDivisor: number;
+  brushDecayAlphaOffset: number;
+}
+
+type DiffusionUniformSettings = Pick<
+  DiffusionSettings,
+  | 'diffusionRateTrails'
+  | 'decayRateTrails'
+  | 'decayRateBrush'
+  | 'diffusionDecayRateDivisor'
+  | 'diffusionNeighborDivisor'
+  | 'brushDecayAlphaOffset'
+>;
+
+const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
+  1 /
+  (Number.isFinite(diffusionRate) &&
+  diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
+    ? diffusionRate
+    : appConfig.pipelines.diffusion.minDiffusionRate);
+
+const setDiffusionUniformValues = (
+  target: Float32Array,
+  {
+    diffusionRateTrails,
+    decayRateTrails,
+    decayRateBrush,
+    diffusionDecayRateDivisor,
+    diffusionNeighborDivisor,
+    brushDecayAlphaOffset,
+  }: DiffusionUniformSettings
+): void => {
+  const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
+  const brushDecayRate = decayRateBrush / decayDivisor;
+  const neighborDivisor = Number.isFinite(diffusionNeighborDivisor)
+    ? Math.max(1, diffusionNeighborDivisor)
+    : 1;
+  target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
+  target[1] = decayRateTrails / decayDivisor;
+  target[2] = 1 / neighborDivisor;
+  target[3] = 1 + brushDecayRate;
+  target[4] = brushDecayAlphaOffset * brushDecayRate;
+  target[5] = 0;
+  target[6] = 0;
+  target[7] = 0;
+};
 
 export class DiffusionPipeline {
-  private static readonly UNIFORM_COUNT = 4;
+  private static readonly WORKGROUP_SIZE = 16;
+  private static readonly UNIFORM_COUNT = 8;
 
   private readonly bindGroupLayout: GPUBindGroupLayout;
-  private readonly pipeline: GPURenderPipeline;
+  private readonly pipeline: GPUComputePipeline;
   private readonly uniforms: GPUBuffer;
-  private readonly vertexBuffer: GPUBuffer;
+  // 1x1 zero texture used as the depositMap binding when callers don't supply
+  // one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for
+  // out-of-bounds coordinates, so the diffusion shader sums in zeros.
+  private readonly emptyDepositTexture: GPUTexture;
+  private readonly emptyDepositTextureView: GPUTextureView;
+  private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedBufferWrite(
+    DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
+  );
+  private readonly getBindGroup = createBindGroupCache<
+    [GPUTextureView, GPUTextureView, GPUTextureView]
+  >((trailMapIn, trailMapOut, depositMap) =>
+    this.device.createBindGroup({
+      layout: this.bindGroupLayout,
+      entries: [
+        { binding: 0, resource: { buffer: this.uniforms } },
+        { binding: 1, resource: trailMapIn },
+        { binding: 2, resource: trailMapOut },
+        { binding: 3, resource: depositMap },
+      ],
+    })
+  );
 
-  private bindGroup?: GPUBindGroup;
-  private previousTrailMapIn?: GPUTextureView;
-
-  public constructor(
-    private readonly device: GPUDevice,
-    private readonly commonState: CommonState
-  ) {
+  public constructor(private readonly device: GPUDevice) {
     this.bindGroupLayout = device.createBindGroupLayout(
       DiffusionPipeline.bindGroupLayout
     );
 
-    const { buffer, vertex } = setUpFullScreenQuad(device);
-    this.vertexBuffer = buffer;
-
-    this.pipeline = device.createRenderPipeline({
+    this.pipeline = device.createComputePipeline({
       layout: device.createPipelineLayout({
-        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+        bindGroupLayouts: [this.bindGroupLayout],
       }),
-      vertex,
-      fragment: {
-        module: smartCompile(device, CommonState.shaderCode, shader),
-        entryPoint: 'fragment',
-        targets: [
-          {
-            format: 'rgba16float',
-          },
-        ],
-      },
-      primitive: {
-        topology: 'triangle-strip',
+      compute: {
+        module: smartCompile(device, this.shaderCode),
+        entryPoint: 'main',
       },
     });
 
@@ -49,85 +111,81 @@ export class DiffusionPipeline {
       size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
+
+    this.emptyDepositTexture = device.createTexture({
+      format: TRAIL_SOURCE_TEXTURE_FORMAT,
+      size: { width: 1, height: 1 },
+      usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
+    });
+    this.emptyDepositTextureView = this.emptyDepositTexture.createView();
+    const clearEncoder = device.createCommandEncoder();
+    const clearPass = clearEncoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: this.emptyDepositTextureView,
+          clearValue: { r: 0, g: 0, b: 0, a: 0 },
+          loadOp: 'clear',
+          storeOp: 'store',
+        },
+      ],
+    });
+    clearPass.end();
+    device.queue.submit([clearEncoder.finish()]);
   }
 
   public setParameters({
     diffusionRateTrails,
     decayRateTrails,
-    diffusionRateBrush,
     decayRateBrush,
+    diffusionDecayRateDivisor,
+    diffusionNeighborDivisor,
+    brushDecayAlphaOffset,
   }: DiffusionSettings) {
-    this.device.queue.writeBuffer(
+    setDiffusionUniformValues(this.uniformValues, {
+      diffusionRateTrails,
+      decayRateTrails,
+      decayRateBrush,
+      diffusionDecayRateDivisor,
+      diffusionNeighborDivisor,
+      brushDecayAlphaOffset,
+    });
+    writeBufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([
-        1 / diffusionRateTrails,
-        decayRateTrails / 1000,
-        1 / diffusionRateBrush,
-        decayRateBrush / 1000,
-      ])
+      this.uniformValues,
+      this.uniformCache
     );
   }
 
   public execute(
     commandEncoder: GPUCommandEncoder,
     trailMapIn: GPUTextureView,
-    trailMapOut: GPUTextureView
+    trailMapOut: GPUTextureView,
+    size: vec2,
+    depositMap: GPUTextureView | null,
+    timestampWrites?: GPUComputePassTimestampWrites
   ) {
-    this.ensureBindGroupExists(trailMapIn);
+    const bindGroup = this.getBindGroup(
+      trailMapIn,
+      trailMapOut,
+      depositMap ?? this.emptyDepositTextureView
+    );
 
-    const renderPassDescriptor: GPURenderPassDescriptor = {
-      colorAttachments: [
-        {
-          view: trailMapOut,
-          clearValue: { r: 0, g: 0, b: 0, a: 0 },
-          loadOp: 'clear',
-          storeOp: 'store',
-        },
-      ],
-    };
-
-    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
+    const passEncoder = commandEncoder.beginComputePass(
+      timestampWrites ? { timestampWrites } : undefined
+    );
     passEncoder.setPipeline(this.pipeline);
-    passEncoder.setVertexBuffer(0, this.vertexBuffer);
-    this.commonState.execute(passEncoder);
-    passEncoder.setBindGroup(1, this.bindGroup);
-    passEncoder.draw(4, 1);
+    passEncoder.setBindGroup(0, bindGroup);
+    passEncoder.dispatchWorkgroups(
+      Math.ceil(size[0] / DiffusionPipeline.WORKGROUP_SIZE),
+      Math.ceil(size[1] / DiffusionPipeline.WORKGROUP_SIZE)
+    );
     passEncoder.end();
   }
 
-  private ensureBindGroupExists(trailMapIn: GPUTextureView) {
-    if (this.previousTrailMapIn !== trailMapIn) {
-      this.bindGroup = this.device.createBindGroup({
-        layout: this.bindGroupLayout,
-        entries: [
-          {
-            binding: 0,
-            resource: {
-              buffer: this.uniforms,
-            },
-          },
-          {
-            binding: 1,
-            resource: this.device.createSampler({
-              magFilter: 'linear',
-              minFilter: 'linear',
-            }),
-          },
-          {
-            binding: 2,
-            resource: trailMapIn,
-          },
-        ],
-      });
-
-      this.previousTrailMapIn = trailMapIn;
-    }
-  }
-
   public destroy() {
-    this.vertexBuffer.destroy();
     this.uniforms.destroy();
+    this.emptyDepositTexture.destroy();
   }
 
   private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
@@ -135,21 +193,29 @@ export class DiffusionPipeline {
       entries: [
         {
           binding: 0,
-          visibility: GPUShaderStage.FRAGMENT,
+          visibility: GPUShaderStage.COMPUTE,
           buffer: {
             type: 'uniform',
           },
         },
         {
           binding: 1,
-          visibility: GPUShaderStage.FRAGMENT,
-          sampler: {
-            type: 'filtering',
+          visibility: GPUShaderStage.COMPUTE,
+          texture: {
+            sampleType: 'float',
           },
         },
         {
           binding: 2,
-          visibility: GPUShaderStage.FRAGMENT,
+          visibility: GPUShaderStage.COMPUTE,
+          storageTexture: {
+            access: 'write-only',
+            format: TRAIL_SOURCE_TEXTURE_FORMAT,
+          },
+        },
+        {
+          binding: 3,
+          visibility: GPUShaderStage.COMPUTE,
           texture: {
             sampleType: 'float',
           },
@@ -157,4 +223,11 @@ export class DiffusionPipeline {
       ],
     };
   }
+
+  private get shaderCode(): string {
+    return shader.replaceAll(
+      '__WORKGROUP_SIZE__',
+      DiffusionPipeline.WORKGROUP_SIZE.toString()
+    );
+  }
 }
diff --git a/src/pipelines/diffusion/diffusion-settings.ts b/src/pipelines/diffusion/diffusion-settings.ts
deleted file mode 100644
index 909101b..0000000
--- a/src/pipelines/diffusion/diffusion-settings.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export interface DiffusionSettings {
-  diffusionRateTrails: number;
-  decayRateTrails: number;
-  diffusionRateBrush: number;
-  decayRateBrush: number;
-}
diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts
new file mode 100644
index 0000000..5cc9b32
--- /dev/null
+++ b/src/pipelines/eraser/eraser-agent-pipeline.ts
@@ -0,0 +1,208 @@
+import { vec2 } from 'gl-matrix';
+
+import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
+import {
+  createCachedBufferWrite,
+  writeBufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
+import { smartCompile } from '../../utils/graphics/smart-compile';
+import {
+  dispatchAgentWorkgroups,
+  getAgentWorkgroupSize,
+  substituteAgentWorkgroupSize,
+} from '../agents/agent-dispatch';
+import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
+import shader from './eraser-agent.wgsl?raw';
+
+interface Bounds {
+  maxX: number;
+  maxY: number;
+  minX: number;
+  minY: number;
+}
+
+export class EraserAgentPipeline {
+  private static readonly UNIFORM_COUNT = 8;
+
+  private readonly bindGroupLayout: GPUBindGroupLayout;
+  private readonly pipeline: GPUComputePipeline;
+  private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
+  private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
+  private readonly uniformCache = createCachedBufferWrite(
+    EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
+  );
+  private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>(
+    (agentsBuffer, eraserMask) =>
+      this.device.createBindGroup({
+        layout: this.bindGroupLayout,
+        entries: [
+          { binding: 0, resource: { buffer: this.uniforms } },
+          { binding: 1, resource: { buffer: agentsBuffer } },
+          { binding: 2, resource: eraserMask },
+        ],
+      })
+  );
+
+  private pendingSegmentCount = 0;
+  private activeSegmentCount = 0;
+  private pendingBounds: Bounds | null = null;
+  private agentCount = 0;
+  private readonly workgroupSize: number;
+
+  public constructor(
+    private readonly device: GPUDevice,
+    private readonly getAgentsBuffer: () => GPUBuffer
+  ) {
+    const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
+    this.bindGroupLayout = device.createBindGroupLayout({
+      entries: [
+        {
+          binding: 0,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: {
+            type: 'uniform',
+          },
+        },
+        {
+          binding: 1,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: {
+            type: 'storage',
+          },
+        },
+        {
+          binding: 2,
+          visibility: GPUShaderStage.COMPUTE,
+          texture: {
+            sampleType: 'float',
+          },
+        },
+      ],
+    });
+
+    this.uniforms = this.device.createBuffer({
+      size: EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+    });
+
+    this.workgroupSize = getAgentWorkgroupSize(device, 'eraser');
+    this.pipeline = device.createComputePipeline({
+      layout: device.createPipelineLayout({
+        bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
+      }),
+      compute: {
+        module: smartCompile(
+          device,
+          substituteAgentWorkgroupSize(device, agentSchema, 'eraser'),
+          shader
+        ),
+        entryPoint: 'main',
+      },
+    });
+  }
+
+  public addSwipeSegment(from: vec2, to: vec2): void {
+    this.pendingSegmentCount += 1;
+    this.pendingBounds = includeSegment(this.pendingBounds, from, to);
+  }
+
+  public clearSwipes(): void {
+    this.pendingSegmentCount = 0;
+    this.activeSegmentCount = 0;
+    this.pendingBounds = null;
+  }
+
+  public setParameters({
+    agentCount,
+    eraserMaskAlphaThreshold,
+    eraserSize,
+    maskSize,
+  }: {
+    agentCount: number;
+    eraserMaskAlphaThreshold: number;
+    eraserSize: number;
+    maskSize: vec2;
+  }): void {
+    this.agentCount = agentCount;
+    this.activeSegmentCount = this.pendingSegmentCount;
+    const activeBounds = expandBoundsToMask(this.pendingBounds, eraserSize / 2, maskSize);
+    this.pendingSegmentCount = 0;
+    this.pendingBounds = null;
+
+    this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
+    this.uniformValues[1] = eraserMaskAlphaThreshold;
+    this.uniformUintValues[2] = Math.max(0, Math.floor(maskSize[0]));
+    this.uniformUintValues[3] = Math.max(0, Math.floor(maskSize[1]));
+    this.uniformValues[4] = activeBounds.minX;
+    this.uniformValues[5] = activeBounds.minY;
+    this.uniformValues[6] = activeBounds.maxX;
+    this.uniformValues[7] = activeBounds.maxY;
+    writeBufferIfChanged(
+      this.device,
+      this.uniforms,
+      this.uniformValues,
+      this.uniformCache
+    );
+  }
+
+  public hasActiveMask(): boolean {
+    return this.activeSegmentCount > 0;
+  }
+
+  public execute(
+    commandEncoder: GPUCommandEncoder,
+    eraserMask: GPUTextureView,
+    timestampWrites?: GPUComputePassTimestampWrites
+  ): void {
+    if (!this.hasActiveMask() || this.agentCount === 0) {
+      return;
+    }
+
+    const passEncoder = commandEncoder.beginComputePass(
+      timestampWrites ? { timestampWrites } : undefined
+    );
+    passEncoder.setPipeline(this.pipeline);
+    passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
+    dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
+    passEncoder.end();
+  }
+
+  public destroy(): void {
+    this.uniforms.destroy();
+  }
+}
+
+const includeSegment = (bounds: Bounds | null, from: vec2, to: vec2): Bounds => {
+  const minX = Math.min(from[0], to[0]);
+  const minY = Math.min(from[1], to[1]);
+  const maxX = Math.max(from[0], to[0]);
+  const maxY = Math.max(from[1], to[1]);
+  if (!bounds) {
+    return { maxX, maxY, minX, minY };
+  }
+  return {
+    maxX: Math.max(bounds.maxX, maxX),
+    maxY: Math.max(bounds.maxY, maxY),
+    minX: Math.min(bounds.minX, minX),
+    minY: Math.min(bounds.minY, minY),
+  };
+};
+
+const expandBoundsToMask = (
+  bounds: Bounds | null,
+  radius: number,
+  maskSize: vec2
+): Bounds => {
+  const maxX = Math.max(0, maskSize[0] - 1);
+  const maxY = Math.max(0, maskSize[1] - 1);
+  if (!bounds) {
+    return { maxX, maxY, minX: 0, minY: 0 };
+  }
+  return {
+    maxX: Math.min(maxX, bounds.maxX + radius),
+    maxY: Math.min(maxY, bounds.maxY + radius),
+    minX: Math.max(0, bounds.minX - radius),
+    minY: Math.max(0, bounds.minY - radius),
+  };
+};
diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl
new file mode 100644
index 0000000..f98d551
--- /dev/null
+++ b/src/pipelines/eraser/eraser-agent.wgsl
@@ -0,0 +1,44 @@
+struct Settings {
+  agentCount: u32,
+  eraserMaskAlphaThreshold: f32,
+  maskWidth: u32,
+  maskHeight: u32,
+  boundsMin: vec2,
+  boundsMax: vec2,
+};
+
+@group(1) @binding(0) var settings: Settings;
+@group(1) @binding(2) var eraserMask: texture_2d;
+
+@compute @workgroup_size(agentWorkgroupSize)
+fn main(
+  @builtin(global_invocation_id) global_id: vec3
+) {
+  let id = get_id(global_id);
+
+  if id >= settings.agentCount {
+    return;
+  }
+
+  let colorIndex = agents[id].colorIndex;
+  if colorIndex < 0.0 || colorIndex >= 2.5 {
+    return;
+  }
+
+  let position = agents[id].position;
+  if any(position < settings.boundsMin) || any(position > settings.boundsMax) {
+    return;
+  }
+
+  let maskSize = vec2(i32(settings.maskWidth), i32(settings.maskHeight));
+  let maskPosition = clamp(
+    vec2(position),
+    vec2(0, 0),
+    maskSize - vec2(1, 1)
+  );
+  let maskSample = textureLoad(eraserMask, maskPosition, 0);
+
+  if maskSample.r < settings.eraserMaskAlphaThreshold {
+    agents[id].colorIndex = -1.0;
+  }
+}
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
new file mode 100644
index 0000000..694777f
--- /dev/null
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -0,0 +1,186 @@
+import { vec2 } from 'gl-matrix';
+
+import { appConfig } from '../../config';
+import {
+  createCachedBufferWrite,
+  writeBufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
+import { smartCompile } from '../../utils/graphics/smart-compile';
+import { CommonState } from '../common-state/common-state';
+import {
+  LINE_SEGMENT_VERTEX_BUFFER_LAYOUT,
+  LINE_SEGMENT_VERTICES,
+  LineSegmentBuffer,
+} from '../common/line-segment-buffer';
+import lineSegmentShader from '../common/line-segment.wgsl?raw';
+import {
+  ERASER_MASK_TEXTURE_FORMAT,
+  TRAIL_SOURCE_TEXTURE_FORMAT,
+} from '../texture-formats';
+import shader from './eraser-texture.wgsl?raw';
+
+interface EraserTextureParameters {
+  eraserSize: number;
+  eraserLineDistanceEpsilon: number;
+  eraserClearRed: number;
+  eraserClearGreen: number;
+  eraserClearBlue: number;
+  eraserClearAlpha: number;
+}
+
+const UNIFORM_COUNT = 8;
+const TARGET_FORMATS: Array = [
+  ERASER_MASK_TEXTURE_FORMAT,
+  TRAIL_SOURCE_TEXTURE_FORMAT,
+  TRAIL_SOURCE_TEXTURE_FORMAT,
+];
+
+export class EraserTexturePipeline {
+  private readonly bindGroupLayout: GPUBindGroupLayout;
+  private readonly bindGroup: GPUBindGroup;
+  private readonly combinedPipeline: GPURenderPipeline;
+  private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
+  private readonly uniformCache = createCachedBufferWrite(
+    UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
+  );
+  private readonly segments: LineSegmentBuffer;
+
+  public constructor(
+    private readonly device: GPUDevice,
+    private readonly commonState: CommonState
+  ) {
+    this.segments = new LineSegmentBuffer(
+      device,
+      appConfig.pipelines.eraser.maxTextureLineCount
+    );
+
+    this.bindGroupLayout = device.createBindGroupLayout({
+      entries: [
+        {
+          binding: 0,
+          visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
+          buffer: { type: 'uniform' },
+        },
+      ],
+    });
+
+    const shaderModule = smartCompile(
+      device,
+      CommonState.shaderCode,
+      lineSegmentShader,
+      shader
+    );
+    this.combinedPipeline = device.createRenderPipeline({
+      layout: device.createPipelineLayout({
+        bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
+      }),
+      vertex: {
+        module: shaderModule,
+        entryPoint: 'vertex',
+        buffers: [LINE_SEGMENT_VERTEX_BUFFER_LAYOUT],
+      },
+      fragment: {
+        module: shaderModule,
+        entryPoint: 'fragmentCombined',
+        targets: TARGET_FORMATS.map((format) => ({ format })),
+      },
+      primitive: { topology: 'triangle-list' },
+    });
+
+    this.uniforms = device.createBuffer({
+      size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+    });
+
+    this.bindGroup = device.createBindGroup({
+      layout: this.bindGroupLayout,
+      entries: [{ binding: 0, resource: { buffer: this.uniforms } }],
+    });
+  }
+
+  public addSwipeSegment(from: vec2, to: vec2): void {
+    this.segments.add(from, to);
+  }
+
+  public clearSwipes(): void {
+    this.segments.clear();
+  }
+
+  public setParameters({
+    eraserSize,
+    eraserLineDistanceEpsilon,
+    eraserClearRed,
+    eraserClearGreen,
+    eraserClearBlue,
+    eraserClearAlpha,
+  }: EraserTextureParameters): void {
+    const eraserRadius = eraserSize / 2;
+
+    this.uniformValues[0] = eraserRadius * eraserRadius;
+    this.uniformValues[1] = eraserLineDistanceEpsilon;
+    this.uniformValues[2] = eraserClearRed;
+    this.uniformValues[3] = eraserClearGreen;
+    this.uniformValues[4] = eraserClearBlue;
+    this.uniformValues[5] = eraserClearAlpha;
+    this.uniformValues[6] = eraserRadius;
+    writeBufferIfChanged(
+      this.device,
+      this.uniforms,
+      this.uniformValues,
+      this.uniformCache
+    );
+
+    this.segments.flush();
+  }
+
+  public executeCombined(
+    commandEncoder: GPUCommandEncoder,
+    eraserMaskOut: GPUTextureView,
+    sourceMapOut: GPUTextureView,
+    trailMapOut: GPUTextureView,
+    timestampWrites?: GPURenderPassTimestampWrites
+  ): void {
+    const lineCount = this.segments.activeCount;
+    if (lineCount === 0) {
+      const passEncoder = commandEncoder.beginRenderPass({
+        colorAttachments: [
+          {
+            view: eraserMaskOut,
+            clearValue: { r: 1, g: 1, b: 1, a: 1 },
+            loadOp: 'clear',
+            storeOp: 'store',
+          },
+        ],
+        timestampWrites,
+      });
+      passEncoder.end();
+      return;
+    }
+
+    const passEncoder = commandEncoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: eraserMaskOut,
+          clearValue: { r: 1, g: 1, b: 1, a: 1 },
+          loadOp: 'clear',
+          storeOp: 'store',
+        },
+        { view: sourceMapOut, loadOp: 'load', storeOp: 'store' },
+        { view: trailMapOut, loadOp: 'load', storeOp: 'store' },
+      ],
+      timestampWrites,
+    });
+    passEncoder.setPipeline(this.combinedPipeline);
+    this.commonState.execute(passEncoder);
+    passEncoder.setBindGroup(1, this.bindGroup);
+    passEncoder.setVertexBuffer(0, this.segments.vertexBuffer);
+    passEncoder.draw(LINE_SEGMENT_VERTICES, lineCount);
+    passEncoder.end();
+  }
+
+  public destroy(): void {
+    this.segments.destroy();
+    this.uniforms.destroy();
+  }
+}
diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl
new file mode 100644
index 0000000..635258e
--- /dev/null
+++ b/src/pipelines/eraser/eraser-texture.wgsl
@@ -0,0 +1,79 @@
+struct Settings {
+  eraserRadiusSquared: f32,
+  lineDistanceEpsilon: f32,
+  clearRed: f32,
+  clearGreen: f32,
+  clearBlue: f32,
+  clearAlpha: f32,
+  eraserRadius: f32,
+};
+
+@group(1) @binding(0) var settings: Settings;
+
+struct VertexOutput {
+  @builtin(position) position: vec4,
+  @location(0) screenPosition: vec2,
+  @location(1) @interpolate(flat) start: vec2,
+  @location(2) @interpolate(flat) direction: vec2,
+  @location(3) @interpolate(flat) inverseLengthSquared: f32,
+}
+
+struct EraserCombinedTargets {
+  @location(0) mask: vec4,
+  @location(1) source: vec4,
+  @location(2) trail: vec4,
+}
+
+@vertex
+fn vertex(
+  @builtin(vertex_index) vertexIndex: u32,
+  @location(0) start: vec2,
+  @location(1) end: vec2
+) -> VertexOutput {
+  let direction = end - start;
+  let denominator = dot(direction, direction);
+  var inverseLengthSquared = 0.0;
+  var normalizedDirection = vec2(1.0, 0.0);
+  if denominator > settings.lineDistanceEpsilon {
+    inverseLengthSquared = 1.0 / denominator;
+    normalizedDirection = direction * inverseSqrt(denominator);
+  }
+  let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.eraserRadius);
+  let uv = screenPosition / state.size;
+  let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
+  return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
+}
+
+@fragment
+fn fragmentCombined(
+  @location(0) screenPosition: vec2,
+  @location(1) @interpolate(flat) start: vec2,
+  @location(2) @interpolate(flat) direction: vec2,
+  @location(3) @interpolate(flat) inverseLengthSquared: f32
+) -> EraserCombinedTargets {
+  let distanceSquared = distance_squared_from_segment(
+    screenPosition,
+    start,
+    direction,
+    inverseLengthSquared
+  );
+  if distanceSquared > settings.eraserRadiusSquared {
+    discard;
+  }
+
+  let cleared = getEraserClearValue();
+  return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared);
+}
+
+fn getEraserMaskValue() -> vec4 {
+  return vec4(settings.clearAlpha, 0.0, 0.0, 1.0);
+}
+
+fn getEraserClearValue() -> vec4 {
+  return vec4(
+    settings.clearRed,
+    settings.clearGreen,
+    settings.clearBlue,
+    settings.clearAlpha
+  );
+}
diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts
index 73ac288..12d7ada 100644
--- a/src/pipelines/render/render-pipeline.ts
+++ b/src/pipelines/render/render-pipeline.ts
@@ -1,162 +1,220 @@
-import { vec3 } from 'gl-matrix';
-
+import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
+import {
+  createCachedBufferWrite,
+  writeBufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
 import { smartCompile } from '../../utils/graphics/smart-compile';
-import { CommonState } from '../common-state/common-state';
-import { RenderSettings } from './render-settings';
+import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
 import shader from './render.wgsl?raw';
 
-export class RenderPipeline {
-  private static readonly UNIFORM_COUNT = 13;
+export interface RenderSettings {
+  clarity: number;
+  renderTraceNormalizationFloor: number;
+  renderBrushColorBase: number;
+  renderBrushColorStrengthMultiplier: number;
+}
 
+// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 4 scalars,
+// rounded up to 20 floats for 16-byte uniform alignment.
+const UNIFORM_COUNT = 20;
+
+export class RenderPipeline {
   private readonly bindGroupLayout: GPUBindGroupLayout;
   private readonly pipeline: GPURenderPipeline;
+  private readonly noSourcePipeline: GPURenderPipeline;
   private readonly uniforms: GPUBuffer;
-  private readonly vertexBuffer: GPUBuffer;
+  private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
+  private readonly uniformCache = createCachedBufferWrite(
+    UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
+  );
 
-  private bindGroup?: GPUBindGroup;
-  private previousColorTexture?: GPUTextureView;
+  private readonly getBindGroup = createBindGroupCache<[GPUTextureView, GPUTextureView]>(
+    (colorTexture, sourceTexture) =>
+      this.device.createBindGroup({
+        layout: this.bindGroupLayout,
+        entries: [
+          { binding: 0, resource: { buffer: this.uniforms } },
+          { binding: 2, resource: colorTexture },
+          { binding: 3, resource: sourceTexture },
+        ],
+      })
+  );
 
   public constructor(
     private readonly context: GPUCanvasContext,
     private readonly device: GPUDevice,
-    private readonly commonState: CommonState
+    private readonly canvasFormat: GPUTextureFormat
   ) {
-    this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout);
-
-    const { buffer, vertex } = setUpFullScreenQuad(device);
-    this.vertexBuffer = buffer;
-
-    this.pipeline = device.createRenderPipeline({
-      layout: device.createPipelineLayout({
-        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
-      }),
-      vertex,
-      fragment: {
-        module: smartCompile(device, CommonState.shaderCode, shader),
-        entryPoint: 'fragment',
-        targets: [
-          {
-            format: navigator.gpu.getPreferredCanvasFormat(),
-          },
-        ],
-      },
-      primitive: {
-        topology: 'triangle-strip',
-      },
-    });
-
-    this.uniforms = this.device.createBuffer({
-      size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
-      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
-    });
-  }
-
-  public setParameters({
-    brushColor,
-    evenGenerationColor,
-    oddGenerationColor,
-    clarity,
-  }: RenderSettings & {
-    brushColor: vec3;
-    evenGenerationColor: vec3;
-    oddGenerationColor: vec3;
-  }) {
-    this.device.queue.writeBuffer(
-      this.uniforms,
-      0,
-      new Float32Array([
-        ...brushColor,
-        0, //padding
-        ...evenGenerationColor,
-        0, //padding
-        ...oddGenerationColor,
-        clarity,
-      ])
-    );
-  }
-
-  public execute(commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView) {
-    this.ensureBindGroupExists(colorTexture);
-
-    const renderPassDescriptor: GPURenderPassDescriptor = {
-      colorAttachments: [
-        {
-          view: this.context.getCurrentTexture().createView(),
-          clearValue: { r: 0, g: 1, b: 1, a: 1 },
-          loadOp: 'clear',
-          storeOp: 'store',
-        },
-      ],
-    };
-    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
-    passEncoder.setPipeline(this.pipeline);
-    this.commonState.execute(passEncoder);
-    passEncoder.setVertexBuffer(0, this.vertexBuffer);
-    passEncoder.setBindGroup(1, this.bindGroup);
-    passEncoder.draw(4, 1);
-    passEncoder.end();
-  }
-
-  private ensureBindGroupExists(colorTexture: GPUTextureView) {
-    if (this.previousColorTexture !== colorTexture) {
-      this.bindGroup = this.device.createBindGroup({
-        layout: this.bindGroupLayout,
-        entries: [
-          {
-            binding: 0,
-            resource: {
-              buffer: this.uniforms,
-            },
-          },
-          {
-            binding: 1,
-            resource: this.device.createSampler({
-              magFilter: 'linear',
-              minFilter: 'linear',
-            }),
-          },
-          {
-            binding: 2,
-            resource: colorTexture,
-          },
-        ],
-      });
-
-      this.previousColorTexture = colorTexture;
-    }
-  }
-
-  public destroy() {
-    this.vertexBuffer.destroy();
-    this.uniforms.destroy();
-  }
-
-  private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
-    return {
+    this.bindGroupLayout = device.createBindGroupLayout({
       entries: [
         {
           binding: 0,
           visibility: GPUShaderStage.FRAGMENT,
-          buffer: {
-            type: 'uniform',
-          },
-        },
-        {
-          binding: 1,
-          visibility: GPUShaderStage.FRAGMENT,
-          sampler: {
-            type: 'filtering',
-          },
+          buffer: { type: 'uniform' },
         },
         {
           binding: 2,
           visibility: GPUShaderStage.FRAGMENT,
-          texture: {
-            sampleType: 'float',
-          },
+          texture: { sampleType: 'float' },
+        },
+        {
+          binding: 3,
+          visibility: GPUShaderStage.FRAGMENT,
+          texture: { sampleType: 'float' },
         },
       ],
-    };
+    });
+
+    const shaderModule = smartCompile(device, shader);
+    const vertex = setUpFullScreenQuad(device);
+    const pipelineLayout = device.createPipelineLayout({
+      bindGroupLayouts: [this.bindGroupLayout],
+    });
+    this.pipeline = this.createPipeline(
+      pipelineLayout,
+      vertex,
+      shaderModule,
+      this.canvasFormat,
+      'fragment'
+    );
+    this.noSourcePipeline = this.createPipeline(
+      pipelineLayout,
+      vertex,
+      shaderModule,
+      this.canvasFormat,
+      'fragmentNoSource'
+    );
+
+    this.uniforms = device.createBuffer({
+      size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+    });
+  }
+
+  private createPipeline(
+    layout: GPUPipelineLayout,
+    vertex: GPUVertexState,
+    shaderModule: GPUShaderModule,
+    format: GPUTextureFormat,
+    fragmentEntryPoint: string
+  ): GPURenderPipeline {
+    return this.device.createRenderPipeline({
+      layout,
+      vertex,
+      fragment: {
+        module: shaderModule,
+        entryPoint: fragmentEntryPoint,
+        targets: [{ format }],
+      },
+      primitive: { topology: 'triangle-list' },
+    });
+  }
+
+  public setParameters({
+    channelColors,
+    backgroundColor,
+    clarity,
+    renderTraceNormalizationFloor,
+    renderBrushColorBase,
+    renderBrushColorStrengthMultiplier,
+  }: RenderSettings & {
+    channelColors: [RgbColor, RgbColor, RgbColor];
+    backgroundColor: RgbColor;
+  }) {
+    const [a, b, c] = channelColors;
+    this.uniformValues[0] = rgbChannelToUnit(a[0]);
+    this.uniformValues[1] = rgbChannelToUnit(a[1]);
+    this.uniformValues[2] = rgbChannelToUnit(a[2]);
+    // uniformValues[3], [7], [11] are WGSL vec3→vec4 alignment padding.
+    this.uniformValues[4] = rgbChannelToUnit(b[0]);
+    this.uniformValues[5] = rgbChannelToUnit(b[1]);
+    this.uniformValues[6] = rgbChannelToUnit(b[2]);
+    this.uniformValues[8] = rgbChannelToUnit(c[0]);
+    this.uniformValues[9] = rgbChannelToUnit(c[1]);
+    this.uniformValues[10] = rgbChannelToUnit(c[2]);
+    this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
+    this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
+    this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
+    this.uniformValues[15] = clarity;
+    this.uniformValues[16] = renderTraceNormalizationFloor;
+    this.uniformValues[17] = renderBrushColorBase;
+    this.uniformValues[18] = renderBrushColorStrengthMultiplier;
+    writeBufferIfChanged(
+      this.device,
+      this.uniforms,
+      this.uniformValues,
+      this.uniformCache
+    );
+  }
+
+  public execute(
+    commandEncoder: GPUCommandEncoder,
+    colorTexture: GPUTextureView,
+    sourceTexture: GPUTextureView,
+    useSourceTexture = true,
+    timestampWrites?: GPURenderPassTimestampWrites
+  ): GPUTexture {
+    const canvasTexture = this.context.getCurrentTexture();
+    this.encodePass(
+      commandEncoder,
+      colorTexture,
+      sourceTexture,
+      canvasTexture.createView(),
+      useSourceTexture,
+      timestampWrites
+    );
+    return canvasTexture;
+  }
+
+  public executeToView(
+    commandEncoder: GPUCommandEncoder,
+    colorTexture: GPUTextureView,
+    sourceTexture: GPUTextureView,
+    outputTexture: GPUTextureView,
+    useSourceTexture = true,
+    timestampWrites?: GPURenderPassTimestampWrites
+  ) {
+    this.encodePass(
+      commandEncoder,
+      colorTexture,
+      sourceTexture,
+      outputTexture,
+      useSourceTexture,
+      timestampWrites
+    );
+  }
+
+  private encodePass(
+    commandEncoder: GPUCommandEncoder,
+    colorTexture: GPUTextureView,
+    sourceTexture: GPUTextureView,
+    output: GPUTextureView,
+    useSourceTexture: boolean,
+    timestampWrites?: GPURenderPassTimestampWrites
+  ) {
+    const passEncoder = commandEncoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: output,
+          clearValue: { r: 0, g: 0, b: 0, a: 1 },
+          loadOp: 'clear',
+          storeOp: 'store',
+        },
+      ],
+      timestampWrites,
+    });
+    passEncoder.setPipeline(this.getPipeline(useSourceTexture));
+    passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture));
+    passEncoder.draw(3, 1);
+    passEncoder.end();
+  }
+
+  private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
+    return useSourceTexture ? this.pipeline : this.noSourcePipeline;
+  }
+
+  public destroy() {
+    this.uniforms.destroy();
   }
 }
diff --git a/src/pipelines/render/render-settings.ts b/src/pipelines/render/render-settings.ts
deleted file mode 100644
index c329309..0000000
--- a/src/pipelines/render/render-settings.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface RenderSettings {
-  clarity: number;
-}
diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl
index 8607d7c..7e1ab26 100644
--- a/src/pipelines/render/render.wgsl
+++ b/src/pipelines/render/render.wgsl
@@ -1,39 +1,158 @@
 struct Settings {
-  brushColor: vec3,
-  evenGenerationColor: vec3,
-  oddGenerationColor: vec3,
+  colorA: vec3,
+  _colorAPadding: f32,
+  colorB: vec3,
+  _colorBPadding: f32,
+  colorC: vec3,
+  _colorCPadding: f32,
+  backgroundColor: vec3,
   clarity: f32,
+  traceNormalizationFloor: f32,
+  brushColorBase: f32,
+  brushColorStrengthMultiplier: f32,
 };
 
-@group(1) @binding(0) var settings: Settings;
-@group(1) @binding(1) var Sampler: sampler;
-@group(1) @binding(2) var trailMap: texture_2d;
+const COMMON_CHANNEL_REDUCTION: f32 = 0.75;
+const OVERLAP_SATURATION_BOOST: f32 = 1.35;
+const LOW_SATURATION_RESCUE_AMOUNT: f32 = 0.65;
+const LOW_SATURATION_RESCUE_MIN: f32 = 0.08;
+const LOW_SATURATION_RESCUE_MAX: f32 = 0.22;
+const COLOR_WEIGHT_EPSILON: f32 = 0.0001;
+const LUMA_WEIGHTS: vec3 = vec3(0.2126, 0.7152, 0.0722);
+
+@group(0) @binding(0) var settings: Settings;
+@group(0) @binding(2) var trailMap: texture_2d;
+@group(0) @binding(3) var sourceMap: texture_2d;
 
 @fragment
-fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
-  let traces = textureSample(trailMap, Sampler, uv);
-  let random = textureSample(noise, noiseSampler, uv);
+fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 {
+  let pixel = vec2(position.xy);
+  let traces = textureLoad(trailMap, pixel, 0);
+  let sources = textureLoad(sourceMap, pixel, 0);
+  return renderColor(traces, sources, getFlatBackground());
+}
 
-  let backgroundColor = vec3(0.9) + 0.075 * random.r;
+@fragment
+fn fragmentNoSource(@builtin(position) position: vec4) -> @location(0) vec4 {
+  let pixel = vec2(position.xy);
+  let traces = textureLoad(trailMap, pixel, 0);
+  return renderColor(traces, vec4(0.0), getFlatBackground());
+}
 
-  let evenGenerationStrength = clarity(traces.r);
-  let oddGenerationStrength = clarity(traces.g);
-  let brushStrength = traces.a;
+fn renderColor(traces: vec4, sources: vec4, background: vec3) -> vec4 {
+  let traceStrengths = clarity(traces.rgb);
+  let sourceStrengths = clarity(sources.rgb);
+  let traceStrength = maxComponent(traceStrengths);
+  let brushStrength = maxComponent(sourceStrengths);
+  if max(traceStrength, brushStrength) <= 0.0 {
+    return vec4(background, 1);
+  }
 
-  let color = max(
-    mix(
-      evenGenerationStrength * settings.evenGenerationColor,
-      oddGenerationStrength * settings.oddGenerationColor,
-      oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
+  if brushStrength <= 0.0 {
+    let traceColor = colorFromChannelStrengths(traceStrengths);
+    return vec4(mix(background, clamp(traceColor, vec3(0), vec3(1)), traceStrength), 1);
+  }
+
+  let strengths = max(traceStrengths, sourceStrengths);
+  let traceColor = colorFromChannelStrengths(strengths);
+  let brushColor = colorFromChannelStrengths(sourceStrengths);
+  let brushVisibility = clamp(
+    brushStrength * (
+      settings.brushColorBase +
+      brushStrength * settings.brushColorStrengthMultiplier
     ),
-    brushStrength * settings.brushColor
+    0,
+    1
+  );
+  let color = mix(traceColor, brushColor, brushVisibility);
+
+  let strength = max(maxComponent(strengths), brushVisibility);
+  return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
+}
+
+fn maxComponent(v: vec3) -> f32 {
+  return max(max(v.r, v.g), v.b);
+}
+
+fn minComponent(v: vec3) -> f32 {
+  return min(min(v.r, v.g), v.b);
+}
+
+fn componentSum(v: vec3) -> f32 {
+  return v.r + v.g + v.b;
+}
+
+fn clarity(strength: vec3) -> vec3 {
+  return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
+}
+
+fn colorFromChannelStrengths(strengths: vec3) -> vec3 {
+  if maxComponent(strengths) <= 0.0 {
+    return vec3(0.0);
+  }
+
+  let weights = colorWeights(strengths);
+  let color =
+      weights.r * settings.colorA
+    + weights.g * settings.colorB
+    + weights.b * settings.colorC;
+  return preserveOverlapVibrancy(normalizeColorIntensity(color), strengths);
+}
+
+fn colorWeights(strengths: vec3) -> vec3 {
+  let commonStrength = minComponent(strengths);
+  var weightBase = max(
+    strengths - vec3(commonStrength * COMMON_CHANNEL_REDUCTION),
+    vec3(0.0)
+  );
+  if componentSum(weightBase) <= COLOR_WEIGHT_EPSILON {
+    weightBase = strengths;
+  }
+
+  let sharpenedWeights = weightBase * weightBase;
+  return sharpenedWeights / max(COLOR_WEIGHT_EPSILON, componentSum(sharpenedWeights));
+}
+
+fn preserveOverlapVibrancy(color: vec3, strengths: vec3) -> vec3 {
+  let strongest = maxComponent(strengths);
+  let overlapAmount = clamp(
+    (componentSum(strengths) - strongest) / max(COLOR_WEIGHT_EPSILON, strongest),
+    0.0,
+    1.0
   );
 
-  let strength = max(evenGenerationStrength, max(oddGenerationStrength, brushStrength));
+  let luminance = dot(color, LUMA_WEIGHTS);
+  var vibrantColor = clamp(
+    vec3(luminance) +
+      (color - vec3(luminance)) *
+      mix(1.0, OVERLAP_SATURATION_BOOST, overlapAmount),
+    vec3(0.0),
+    vec3(1.0)
+  );
 
-  return vec4(mix(backgroundColor, color, strength), 1);
+  let saturation = maxComponent(vibrantColor) - minComponent(vibrantColor);
+  let rescueAmount =
+    overlapAmount *
+    (1.0 - smoothstep(LOW_SATURATION_RESCUE_MIN, LOW_SATURATION_RESCUE_MAX, saturation)) *
+    LOW_SATURATION_RESCUE_AMOUNT;
+  return mix(vibrantColor, dominantColor(strengths), rescueAmount);
 }
 
-fn clarity(strength: f32) -> f32 {
-  return pow(strength, settings.clarity);
+fn dominantColor(strengths: vec3) -> vec3 {
+  if strengths.r >= strengths.g && strengths.r >= strengths.b {
+    return normalizeColorIntensity(settings.colorA);
+  }
+  if strengths.g >= strengths.b {
+    return normalizeColorIntensity(settings.colorB);
+  }
+  return normalizeColorIntensity(settings.colorC);
+}
+
+fn normalizeColorIntensity(color: vec3) -> vec3 {
+  let brightestChannel = maxComponent(color);
+  return color / max(settings.traceNormalizationFloor, brightestChannel);
+}
+
+fn getFlatBackground() -> vec3 {
+  return clamp(settings.backgroundColor, vec3(0), vec3(1));
 }
diff --git a/src/pipelines/texture-formats.ts b/src/pipelines/texture-formats.ts
new file mode 100644
index 0000000..568e03d
--- /dev/null
+++ b/src/pipelines/texture-formats.ts
@@ -0,0 +1,2 @@
+export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat;
+export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat;
diff --git a/src/settings.ts b/src/settings.ts
index 4715928..c91ac83 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,54 +1,77 @@
-import { GameLoopSettings } from './game-loop/game-loop-settings';
-import { AgentSettings } from './pipelines/agents/agent-settings';
-import { BrushSettings } from './pipelines/brush/brush-settings';
-import { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
-import { RenderSettings } from './pipelines/render/render-settings';
-import { persist } from './utils/persist';
+import {
+  appConfig,
+  normalizeRuntimeSettings,
+  type GardenRuntimeSettings,
+} from './config';
+import { writeBrowserStorage } from './utils/browser-storage';
+import { getInitialVibe, type VibePreset } from './vibes';
 
-const initialValues: GameLoopSettings &
-  AgentSettings &
-  BrushSettings &
-  DiffusionSettings &
-  RenderSettings = {
-  agentCount: 1_001_500,
+const preservedRuntimeSettingKeys = [
+  'eraserSize',
+  'adaptiveCapInitial',
+  'adaptiveCapMin',
+  'internalRenderAreaMegapixels',
+  'maxAgentCount',
+  'mirrorSegmentCount',
+] satisfies ReadonlyArray;
 
-  currentGenerationAggression: -5,
-  nextGenerationAggression: 0.2,
+const cloneRgbColor = (color: T): T =>
+  [...color] as T;
 
-  moveSpeed: 74,
-  turnSpeed: 45,
-  sensorOffsetAngle: 31,
-  sensorOffsetDistance: 43,
-  turnWhenLost: 0.01,
+const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({
+  ...audio,
+  ...(audio.scale ? { scale: [...audio.scale] } : {}),
+  ...(audio.progression
+    ? { progression: audio.progression.map((chord) => ({ ...chord })) }
+    : {}),
+});
 
-  brushTrailWeight: 500,
-  individualTrailWeight: 0.05,
+const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
+  ...vibe,
+  colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
+  backgroundColor: cloneRgbColor(vibe.backgroundColor),
+  settings: { ...vibe.settings },
+  audio: cloneVibeAudio(vibe.audio),
+});
 
-  diffusionRateTrails: 0,
-  decayRateTrails: 944,
-  diffusionRateBrush: 0.35,
-  decayRateBrush: 18,
+const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
+  normalizeRuntimeSettings(
+    {
+      ...appConfig.defaultSettings,
+      eraserSize: appConfig.toolbar.eraser.default,
+      mirrorSegmentCount: appConfig.toolbar.mirror.default,
+      ...vibe.settings,
+    },
+    appConfig.runtimeSettings.controls
+  );
 
-  clarity: 0.7,
-  brushSize: 12,
+export let activeVibe = cloneVibePreset(getInitialVibe());
 
-  brushSizeVariation: 0.5, // hidden on the UI
-
-  startColorHue: 200,
-
-  maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart
-
-  // debug options
-  renderSpeed: 1,
-  simulatedDelayMs: 0,
+export const settings: GardenRuntimeSettings = {
+  ...buildSettings(activeVibe),
 };
 
-export const settings: { [key: string]: number } & GameLoopSettings &
-  AgentSettings &
-  BrushSettings &
-  DiffusionSettings &
-  RenderSettings = persist({ ...initialValues });
-
-export const resetSettings = () => {
-  Object.assign(settings, initialValues);
+export const rememberActiveVibeSelection = (): void => {
+  writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
+};
+
+export const applyVibeSettings = (vibe: VibePreset) => {
+  activeVibe = cloneVibePreset(vibe);
+  const nextSettings = buildSettings(activeVibe);
+  preservedRuntimeSettingKeys.forEach((key) => {
+    nextSettings[key] = settings[key];
+  });
+  nextSettings.selectedColorIndex = Math.min(
+    settings.selectedColorIndex,
+    activeVibe.colors.length - 1
+  );
+
+  Object.assign(
+    settings,
+    normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
+  );
+
+  rememberActiveVibeSelection();
+
+  return activeVibe;
 };
diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss
new file mode 100644
index 0000000..f32be5d
--- /dev/null
+++ b/src/style/_app-shell.scss
@@ -0,0 +1,136 @@
+html > body.is-loading .perf-stats-overlay {
+  display: none;
+}
+
+$grain-noise-a: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='257' height='257' viewBox='0 0 257 257'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' seed='17' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='257' height='257' filter='url(%23n)'/%3E%3C/svg%3E");
+$grain-noise-b: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='389' height='389' viewBox='0 0 389 389'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.53' numOctaves='5' seed='41' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='389' height='389' filter='url(%23n)'/%3E%3C/svg%3E");
+
+html > body {
+  width: 100%;
+  height: 100vh;
+  height: 100dvh;
+  overflow: hidden;
+  display: flex;
+  position: relative;
+  background: var(--garden-background, #10151f);
+
+  > .canvas-container {
+    height: 100%;
+    width: 100%;
+    display: flex;
+    position: relative;
+    overflow: hidden;
+
+    > canvas {
+      position: relative;
+      z-index: 0;
+      height: 100%;
+      width: 100%;
+      touch-action: none;
+    }
+
+    > .garden-grain {
+      --garden-grain-strength: 0;
+
+      position: absolute;
+      inset: 0;
+      z-index: 1;
+      pointer-events: none;
+      contain: strict;
+
+      &::before,
+      &::after {
+        content: '';
+        position: absolute;
+        inset: 0;
+      }
+
+      &::before {
+        opacity: clamp(0, calc(var(--garden-grain-strength) * 4.25), 0.24);
+        background-image: $grain-noise-a;
+        background-size: 257px 257px;
+        filter: contrast(145%) brightness(0.82);
+        mix-blend-mode: multiply;
+      }
+
+      &::after {
+        opacity: clamp(0, calc(var(--garden-grain-strength) * 2.5), 0.12);
+        background-image: $grain-noise-b;
+        background-position: 73px 41px;
+        background-size: 389px 389px;
+        filter: contrast(135%) brightness(1);
+        mix-blend-mode: screen;
+        transform: rotate(0.01deg);
+      }
+
+      &[hidden] {
+        display: none;
+      }
+    }
+
+    > .eraser-preview {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 3;
+      width: var(--eraser-preview-size, 96px);
+      height: var(--eraser-preview-size, 96px);
+      border: 2px solid rgb(255 234 228 / 88%);
+      border-radius: 50%;
+      background: rgb(255 140 117 / 13%);
+      box-shadow:
+        0 0 0 1px rgb(255 88 70 / 34%),
+        0 0 26px rgb(255 118 92 / 24%);
+      opacity: 0;
+      pointer-events: none;
+      transform: translate(-50%, -50%);
+      transition:
+        opacity var(--transition-time),
+        width var(--transition-time),
+        height var(--transition-time);
+      mix-blend-mode: screen;
+
+      &.visible {
+        opacity: 1;
+      }
+    }
+
+    > .perf-stats-overlay {
+      position: absolute;
+      top: max(8px, env(safe-area-inset-top));
+      left: max(8px, env(safe-area-inset-left));
+      z-index: 6;
+      padding: 6px 8px;
+      border: 1px solid rgb(255 255 255 / 18%);
+      border-radius: 6px;
+      background: rgb(0 0 0 / 62%);
+      color: rgb(255 255 255 / 92%);
+      font:
+        600 12px/1.35 ui-monospace,
+        SFMono-Regular,
+        Menlo,
+        Consolas,
+        monospace;
+      white-space: pre;
+      pointer-events: none;
+      user-select: none;
+      box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
+    }
+
+    > .errors-container {
+      position: absolute;
+      top: 0;
+      left: 0;
+      max-width: calc(100% - var(--normal-margin) * 2);
+      margin: var(--normal-margin);
+      z-index: 5;
+
+      pre {
+        font-size: 20px;
+        color: red;
+        white-space: pre-wrap;
+        overflow-wrap: anywhere;
+      }
+    }
+  }
+}
diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss
new file mode 100644
index 0000000..ad7ae31
--- /dev/null
+++ b/src/style/_config-pane.scss
@@ -0,0 +1,309 @@
+@use 'mixins' as *;
+
+.config-pane-container {
+  --config-pane-available-height: calc(
+    100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+  );
+
+  position: fixed;
+  top: max(12px, env(safe-area-inset-top, 0px));
+  right: max(12px, env(safe-area-inset-right, 0px));
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr);
+  gap: 4px;
+  z-index: 20;
+  width: min(
+    420px,
+    calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px))
+  );
+  max-height: var(--config-pane-available-height);
+  pointer-events: none;
+
+  @supports (height: 100dvh) {
+    --config-pane-available-height: calc(
+      100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+    );
+  }
+}
+
+.config-pane-container--open {
+  pointer-events: auto;
+}
+
+.config-pane {
+  --tp-blade-value-width: min(260px, 64%);
+
+  width: 100%;
+  max-height: calc(var(--config-pane-available-height) - 36px);
+  overflow-x: hidden;
+  overflow-y: auto;
+  overscroll-behavior: contain;
+  pointer-events: auto;
+  scrollbar-width: thin;
+  touch-action: pan-y;
+  -webkit-overflow-scrolling: touch;
+
+  // 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;
+  }
+
+  .tp-sldtxtv_s {
+    flex: 1 1 auto;
+    min-width: 0;
+  }
+
+  .tp-sldtxtv_t {
+    flex: 0 0 54px;
+  }
+}
+
+.config-pane-close {
+  position: relative;
+  justify-self: end;
+  display: grid;
+  width: 28px;
+  height: 28px;
+  place-items: center;
+  border: 0;
+  border-radius: 4px;
+  background: transparent;
+  color: rgb(235 238 245 / 82%);
+  cursor: pointer;
+  font: inherit;
+  font-size: 0;
+  pointer-events: auto;
+  transition:
+    background-color var(--transition-time),
+    color var(--transition-time);
+
+  &::before,
+  &::after {
+    content: '';
+    position: absolute;
+    width: 14px;
+    height: 2px;
+    border-radius: 999px;
+    background: currentColor;
+  }
+
+  &::before {
+    transform: rotate(45deg);
+  }
+
+  &::after {
+    transform: rotate(-45deg);
+  }
+
+  &:hover {
+    background: rgb(255 255 255 / 10%);
+    color: white;
+  }
+
+  &:focus-visible {
+    outline: 2px solid white;
+    outline-offset: -2px;
+  }
+
+  &[hidden] {
+    display: none;
+  }
+}
+
+@mixin mobile-config-pane() {
+  .config-pane-container {
+    --config-pane-available-height: min(
+      64vh,
+      calc(
+        100vh - 112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+      )
+    );
+
+    top: max(8px, env(safe-area-inset-top, 0px));
+    right: auto;
+    left: 50%;
+    width: min(80vw, 420px);
+    transform: translateX(-50%);
+
+    @supports (height: 100dvh) {
+      --config-pane-available-height: min(
+        64dvh,
+        calc(
+          100dvh -
+            112px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
+        )
+      );
+    }
+  }
+
+  .config-pane {
+    --tp-blade-value-width: min(210px, 62%);
+    --tp-container-unit-size: 18px;
+
+    padding-bottom: 10px;
+    font-size: 11px;
+
+    // Tweakpane v4 internal class — re-verify on upgrade.
+    .tp-sldtxtv_t {
+      flex-basis: 48px;
+    }
+  }
+
+  .config-pane-close {
+    width: 32px;
+    height: 32px;
+  }
+}
+
+@include on-mobile-input {
+  @include mobile-config-pane;
+}
+
+.color-reaction-matrix-blade {
+  padding: 6px 8px 8px;
+}
+
+.color-reaction-matrix {
+  display: grid;
+  grid-template-columns: 28px repeat(3, minmax(0, 1fr));
+  gap: 4px;
+  align-items: stretch;
+}
+
+.color-reaction-matrix__corner,
+.color-reaction-matrix__header {
+  display: flex;
+  min-width: 0;
+  min-height: 28px;
+  align-items: center;
+  justify-content: center;
+  color: rgb(255 255 255 / 76%);
+  font-size: 11px;
+  line-height: 1;
+}
+
+.color-reaction-matrix__header {
+  gap: 0;
+}
+
+.color-reaction-matrix__corner {
+  justify-content: flex-start;
+  padding-left: 2px;
+  color: rgb(255 255 255 / 62%);
+}
+
+.color-reaction-matrix__swatch {
+  flex: 0 0 auto;
+  width: 12px;
+  height: 12px;
+  border: 1px solid rgb(255 255 255 / 55%);
+  border-radius: 999px;
+  box-shadow: 0 0 0 1px rgb(0 0 0 / 18%);
+}
+
+.color-reaction-matrix__cell {
+  min-width: 0;
+  display: grid;
+}
+
+.color-reaction-matrix__button {
+  position: relative;
+  display: grid;
+  width: 100%;
+  min-width: 0;
+  height: 28px;
+  place-items: center;
+  border: 1px solid rgb(255 255 255 / 16%);
+  border-radius: 4px;
+  padding: 0 4px;
+  background: rgb(255 255 255 / 8%);
+  color: white;
+  font: inherit;
+  cursor: pointer;
+  transition:
+    background-color var(--transition-time),
+    border-color var(--transition-time),
+    color var(--transition-time),
+    transform var(--transition-time);
+}
+
+.color-reaction-matrix__button:hover {
+  transform: translateY(-1px);
+}
+
+.color-reaction-matrix__button:focus-visible {
+  outline: 2px solid rgb(255 255 255 / 72%);
+  outline-offset: 1px;
+}
+
+.color-reaction-matrix__button[data-reaction='follow'] {
+  border-color: rgb(115 235 160 / 44%);
+  background: rgb(53 165 96 / 20%);
+  color: rgb(157 255 195 / 94%);
+}
+
+.color-reaction-matrix__button[data-reaction='ignore'] {
+  border-color: rgb(255 255 255 / 18%);
+  background: rgb(255 255 255 / 7%);
+  color: rgb(235 238 245 / 72%);
+}
+
+.color-reaction-matrix__button[data-reaction='avoid'] {
+  border-color: rgb(255 145 120 / 46%);
+  background: rgb(215 74 54 / 19%);
+  color: rgb(255 171 148 / 94%);
+}
+
+.color-reaction-matrix__icon {
+  position: relative;
+  display: block;
+  width: 16px;
+  height: 16px;
+}
+
+.color-reaction-matrix__icon::before,
+.color-reaction-matrix__icon::after {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  border-radius: 999px;
+  background: currentColor;
+  transform: translate(-50%, -50%);
+}
+
+.color-reaction-matrix__button[data-reaction='follow']
+  > .color-reaction-matrix__icon::before,
+.color-reaction-matrix__button[data-reaction='avoid']
+  > .color-reaction-matrix__icon::before {
+  width: 14px;
+  height: 2px;
+}
+
+.color-reaction-matrix__button[data-reaction='follow']
+  > .color-reaction-matrix__icon::after {
+  width: 2px;
+  height: 14px;
+}
+
+.color-reaction-matrix__button[data-reaction='avoid']
+  > .color-reaction-matrix__icon::after {
+  display: none;
+}
+
+.color-reaction-matrix__button[data-reaction='ignore'] > .color-reaction-matrix__icon {
+  width: 12px;
+  height: 12px;
+  border: 2px solid currentColor;
+  border-radius: 999px;
+  opacity: 0.82;
+}
+
+.color-reaction-matrix__button[data-reaction='ignore']
+  > .color-reaction-matrix__icon::before,
+.color-reaction-matrix__button[data-reaction='ignore']
+  > .color-reaction-matrix__icon::after {
+  display: none;
+}
diff --git a/src/style/_control-dock.scss b/src/style/_control-dock.scss
new file mode 100644
index 0000000..aec5d2f
--- /dev/null
+++ b/src/style/_control-dock.scss
@@ -0,0 +1,39 @@
+html > body > aside.control-dock {
+  --dock-hidden-translate-y: calc(100% + env(safe-area-inset-bottom, 0px) + 16px);
+
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: env(safe-area-inset-bottom, 0px);
+  z-index: 4;
+  width: min(calc(100vw - 1rem), 980px);
+  margin: 0 auto;
+  transform: translateY(0);
+  visibility: visible;
+  pointer-events: none;
+  transition:
+    opacity var(--transition-time-long),
+    transform var(--transition-time-long),
+    visibility 0s;
+
+  > .toolbar-row,
+  > .info-page {
+    pointer-events: auto;
+  }
+
+  &.menu-hidden {
+    opacity: 0;
+    visibility: hidden;
+    transform: translateY(var(--dock-hidden-translate-y));
+    pointer-events: none;
+    transition:
+      opacity var(--transition-time-long),
+      transform var(--transition-time-long),
+      visibility 0s var(--transition-time-long);
+
+    > .toolbar-row,
+    > .info-page {
+      pointer-events: none;
+    }
+  }
+}
diff --git a/src/style/_garden-prompt.scss b/src/style/_garden-prompt.scss
new file mode 100644
index 0000000..6f7cf74
--- /dev/null
+++ b/src/style/_garden-prompt.scss
@@ -0,0 +1,126 @@
+@use 'mixins' as *;
+
+html > body > .canvas-container > .garden-prompt {
+  position: absolute;
+  left: 50%;
+  transform: translateX(-50%);
+  text-align: center;
+  pointer-events: none;
+  z-index: 2;
+
+  &:empty {
+    display: none;
+  }
+
+  &.draw-hint {
+    display: flex;
+    align-items: center;
+    top: calc(1.25rem + env(safe-area-inset-top));
+    gap: 16px;
+    width: max-content;
+    min-height: 78px;
+    max-width: min(88vw, 460px);
+    padding: 12px 18px 12px 14px;
+    border: 1px solid rgb(255 255 255 / 16%);
+    border-radius: 8px;
+    background: rgb(10 12 16 / 50%);
+    box-shadow:
+      0 14px 42px rgb(0 0 0 / 28%),
+      inset 0 0 0 1px rgb(255 255 255 / 5%);
+    backdrop-filter: blur(12px);
+    color: rgb(255 255 255 / 94%);
+    font-size: 20px;
+    font-weight: 400;
+    line-height: 1.2;
+    text-shadow: 0 1px 12px rgb(0 0 0 / 58%);
+  }
+
+  .draw-hint-mark {
+    width: 128px;
+    height: 72px;
+    flex: 0 0 128px;
+    overflow: visible;
+  }
+
+  .draw-hint-shadow,
+  .draw-hint-stroke {
+    fill: none;
+    stroke-linecap: round;
+    stroke-linejoin: round;
+  }
+
+  .draw-hint-shadow {
+    stroke: rgb(0 0 0 / 42%);
+    stroke-width: 10px;
+  }
+
+  .draw-hint-stroke {
+    stroke: color-mix(in srgb, var(--accent-color) 74%, white);
+    stroke-width: 5px;
+    stroke-dasharray: 154;
+    animation: draw-hint-stroke 2.4s ease-in-out infinite;
+    filter: drop-shadow(
+      0 0 12px color-mix(in srgb, var(--accent-color) 60%, transparent)
+    );
+  }
+
+  .draw-hint-start {
+    fill: rgb(255 255 255 / 64%);
+  }
+
+  .draw-hint-end {
+    fill: white;
+    stroke: color-mix(in srgb, var(--accent-color) 72%, transparent);
+    stroke-width: 5px;
+    transform-origin: 116px 42px;
+    animation: draw-hint-tap 2.4s ease-in-out infinite;
+  }
+
+  @include on-small-screen {
+    &.draw-hint {
+      top: calc(0.75rem + env(safe-area-inset-top));
+      gap: 10px;
+      min-height: 58px;
+      max-width: min(92vw, 340px);
+      padding: 9px 12px 9px 10px;
+      font-size: 16px;
+    }
+
+    .draw-hint-mark {
+      width: 96px;
+      height: 54px;
+      flex-basis: 96px;
+    }
+  }
+}
+
+@keyframes draw-hint-stroke {
+  0%,
+  18% {
+    stroke-dashoffset: 154;
+  }
+
+  58%,
+  100% {
+    stroke-dashoffset: 0;
+  }
+}
+
+@keyframes draw-hint-tap {
+  0%,
+  16% {
+    opacity: 0;
+    transform: scale(0.72);
+  }
+
+  36%,
+  74% {
+    opacity: 1;
+    transform: scale(1);
+  }
+
+  100% {
+    opacity: 0.76;
+    transform: scale(0.88);
+  }
+}
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
new file mode 100644
index 0000000..925f915
--- /dev/null
+++ b/src/style/_loading.scss
@@ -0,0 +1,183 @@
+.loading-indicator {
+  --loading-gap: 22px;
+
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  z-index: 3;
+  width: min(86vw, 380px);
+  transform: translate(-50%, -50%);
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity var(--transition-time-long);
+  contain: layout;
+
+  > .splash {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    align-items: center;
+    pointer-events: auto;
+    opacity: 1;
+    visibility: visible;
+    transition:
+      opacity var(--transition-time),
+      visibility 0s linear 0s;
+
+    &[data-visible='false'] {
+      opacity: 0;
+      visibility: hidden;
+      pointer-events: none;
+      transition:
+        opacity var(--transition-time),
+        visibility 0s linear var(--transition-time);
+    }
+
+    > .splash-title {
+      margin: 0;
+      color: rgb(255 255 255 / 96%);
+      font-size: clamp(28px, 6vw, 42px);
+      font-weight: 700;
+      line-height: 1.1;
+      text-align: center;
+      letter-spacing: 0.01em;
+      text-shadow:
+        0 2px 18px rgb(0 0 0 / 60%),
+        0 0 32px rgb(255 255 255 / 10%);
+    }
+
+    > .splash-description {
+      margin: 0;
+      max-width: 28ch;
+      color: rgb(255 255 255 / 80%);
+      font-size: 15px;
+      font-weight: 400;
+      line-height: 1.45;
+      text-align: center;
+      text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
+    }
+
+    > .start-button {
+      margin-top: 8px;
+      padding: 14px 40px;
+      border: 1px solid rgb(255 255 255 / 38%);
+      border-radius: 999px;
+      background: rgb(255 255 255 / 8%);
+      color: rgb(255 255 255 / 96%);
+      font-size: 16px;
+      font-weight: 600;
+      letter-spacing: 0.04em;
+      text-transform: uppercase;
+      cursor: pointer;
+      backdrop-filter: blur(6px);
+      box-shadow:
+        0 0 24px rgb(255 255 255 / 14%),
+        0 1px 6px rgb(0 0 0 / 28%);
+      transition:
+        opacity var(--transition-time),
+        transform var(--transition-time),
+        background var(--transition-time);
+
+      &[disabled] {
+        opacity: 0.5;
+        cursor: progress;
+      }
+
+      &:not([disabled]):hover,
+      &:not([disabled]):focus-visible {
+        background: rgb(255 255 255 / 16%);
+        transform: scale(1.04);
+        outline: none;
+      }
+
+      &:not([disabled]):active {
+        transform: scale(0.98);
+      }
+    }
+  }
+
+  > .loading-bar {
+    position: absolute;
+    top: calc(100% + var(--loading-gap));
+    left: 0;
+    right: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 18px;
+    align-items: center;
+    width: 100%;
+    opacity: 0;
+    visibility: hidden;
+    pointer-events: none;
+    transition:
+      opacity var(--transition-time),
+      visibility 0s linear var(--transition-time);
+
+    &[data-visible='true'] {
+      opacity: 1;
+      visibility: visible;
+      transition:
+        opacity var(--transition-time),
+        visibility 0s linear 0s;
+    }
+
+    > .loading-status {
+      color: rgb(255 255 255 / 88%);
+      font-size: 16px;
+      font-weight: 400;
+      line-height: 1.25;
+      text-align: center;
+      text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
+      letter-spacing: 0.01em;
+      min-height: 1.25em;
+    }
+
+    > .loading-progress {
+      --loading-progress: 0%;
+
+      position: relative;
+      width: 100%;
+      height: 3px;
+      overflow: hidden;
+      border-radius: 999px;
+      background: rgb(255 255 255 / 14%);
+      box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
+
+      &::before {
+        content: '';
+        position: absolute;
+        top: 0;
+        left: 0;
+        bottom: 0;
+        width: var(--loading-progress);
+        border-radius: inherit;
+        background: linear-gradient(
+          90deg,
+          rgb(255 255 255 / 72%),
+          rgb(255 255 255 / 96%)
+        );
+        box-shadow: 0 0 12px rgb(255 255 255 / 38%);
+        transition: width var(--transition-time-long) ease-out;
+      }
+    }
+  }
+}
+
+html > body.is-loading {
+  .loading-indicator {
+    opacity: 1;
+  }
+
+  .eraser-preview {
+    visibility: hidden;
+  }
+
+  aside.control-dock {
+    opacity: 0;
+    visibility: hidden;
+  }
+}
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
new file mode 100644
index 0000000..28717bb
--- /dev/null
+++ b/src/style/_panels.scss
@@ -0,0 +1,96 @@
+@use 'mixins' as *;
+
+html > body > aside.control-dock > .info-page {
+  width: min(calc(100vw - 1rem), 560px);
+  max-height: min(58vh, 520px);
+  max-height: min(58dvh, 520px);
+  margin: 0 auto 10px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  border: 1px solid rgb(255 255 255 / 78%);
+  border-radius: 8px;
+  background:
+    linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
+    rgb(255 255 255);
+  color: rgb(24 30 27);
+  box-shadow:
+    0 20px 54px rgb(0 0 0 / 38%),
+    0 2px 12px rgb(0 0 0 / 22%);
+  backdrop-filter: blur(12px);
+  scrollbar-width: thin;
+  scrollbar-color: var(--main-color) transparent;
+  transition:
+    max-height var(--transition-time-long),
+    opacity var(--transition-time-long),
+    transform var(--transition-time-long),
+    margin-bottom var(--transition-time-long);
+
+  &::-webkit-scrollbar-track,
+  &::-webkit-scrollbar {
+    background-color: transparent;
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background-color: var(--main-color);
+    border-radius: 8px;
+  }
+
+  &:focus-visible {
+    outline: 2px solid white;
+    outline-offset: 3px;
+  }
+
+  > section {
+    display: flex;
+    flex-direction: column;
+    gap: 0.85rem;
+    padding: var(--normal-margin);
+
+    h1 {
+      margin-bottom: 0;
+      color: rgb(16 24 20);
+      font-size: 2rem;
+      line-height: 1.1;
+    }
+
+    p {
+      max-width: 54ch;
+      margin-bottom: 0;
+      color: rgb(42 48 45);
+      font-size: 1.1rem;
+      line-height: 1.65;
+      hyphens: auto;
+    }
+
+    a {
+      color: rgb(0 84 120);
+      font-weight: 400;
+
+      &:focus-visible {
+        outline: 2px solid currentColor;
+        outline-offset: 3px;
+      }
+    }
+  }
+
+  &.hidden {
+    max-height: 0;
+    margin-bottom: 0;
+    border-color: transparent;
+    opacity: 0;
+    pointer-events: none;
+    box-shadow: none;
+    transform: translateY(8px);
+    visibility: hidden;
+  }
+
+  @include on-small-screen {
+    max-height: min(54vh, 500px);
+    max-height: min(54dvh, 500px);
+
+    > section {
+      padding: var(--small-margin);
+    }
+  }
+}
diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss
new file mode 100644
index 0000000..ef7ca8c
--- /dev/null
+++ b/src/style/_toolbar.scss
@@ -0,0 +1,4 @@
+@use 'toolbar/layout';
+@use 'toolbar/buttons';
+@use 'toolbar/garden-controls';
+@use 'toolbar/responsive';
diff --git a/src/style/common.scss b/src/style/common.scss
index 8954439..23c82aa 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -1,6 +1,5 @@
 @use 'vars';
 @use 'fonts';
-@use 'mixins' as *;
 
 *,
 *::before,
@@ -9,41 +8,34 @@
   padding: 0;
   box-sizing: border-box;
 
-  @media (prefers-reduced-motion) {
+  @media (prefers-reduced-motion: reduce) {
     transition: none !important;
     animation: none !important;
   }
 }
 
-h1,
-h2,
-h3,
-h4,
-h5,
-h6 {
-  font-family: 'Comfortaa', sans-serif;
-  margin-bottom: var(--small-margin);
-}
-
-p {
-  font-family: 'Open Sans', sans-serif;
-}
-
 html {
   height: 100%;
+  touch-action: manipulation;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
   text-rendering: optimizeLegibility;
 }
 
-.large-button {
-  border: none;
-  background-color: var(--accent-color);
-  cursor: pointer;
-  border-radius: var(--border-radius);
-  padding: calc(var(--small-margin) / 2) var(--small-margin);
-  margin: var(--small-margin) auto;
-  align-self: flex-end;
-  @include main-font();
-  color: white;
+body {
+  font-family: 'Open Sans', sans-serif;
+  touch-action: manipulation;
+}
+
+.visually-hidden {
+  position: absolute !important;
+  width: 1px !important;
+  height: 1px !important;
+  margin: -1px !important;
+  padding: 0 !important;
+  overflow: hidden !important;
+  clip: rect(0 0 0 0) !important;
+  clip-path: inset(50%) !important;
+  white-space: nowrap !important;
+  border: 0 !important;
 }
diff --git a/src/style/fonts.scss b/src/style/fonts.scss
index a00d604..a4f4b3c 100644
--- a/src/style/fonts.scss
+++ b/src/style/fonts.scss
@@ -1,25 +1,8 @@
-/* comfortaa-regular - latin */
-@font-face {
-  font-family: 'Comfortaa';
-  font-style: normal;
-  font-weight: 400;
-  font-display: swap;
-  src:
-    local(''),
-    url('../../assets/fonts/comfortaa-v40-latin-regular.woff2') format('woff2'),
-    /* Chrome 26+, Opera 23+, Firefox 39+ */
-      url('../../assets/fonts/comfortaa-v40-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-
 /* open-sans-regular - latin */
 @font-face {
   font-family: 'Open Sans';
   font-style: normal;
   font-weight: 400;
   font-display: swap;
-  src:
-    local(''),
-    url('../../assets/fonts/open-sans-v34-latin-regular.woff2') format('woff2'),
-    /* Chrome 26+, Opera 23+, Firefox 39+ */
-      url('../../assets/fonts/open-sans-v34-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+  src: url('../../assets/fonts/open-sans-v34-latin-regular.woff2') format('woff2');
 }
diff --git a/src/style/mixins.scss b/src/style/mixins.scss
index 4ed95f5..9197605 100644
--- a/src/style/mixins.scss
+++ b/src/style/mixins.scss
@@ -1,6 +1,3 @@
-@use 'sass:math';
-@use 'sass:color';
-
 $breakpoint-width: 600px !default;
 
 @mixin on-small-screen() {
@@ -9,131 +6,8 @@ $breakpoint-width: 600px !default;
   }
 }
 
-@mixin on-large-screen() {
-  @media (min-width: $breakpoint-width) {
+@mixin on-mobile-input() {
+  @media (max-width: ($breakpoint-width - 1px)), (hover: none) and (pointer: coarse) {
     @content;
   }
 }
-
-@mixin title-fragment-link() {
-  position: relative;
-
-  &:before {
-    content: '#';
-    position: absolute;
-    left: -0.5ch;
-    top: 50%;
-    opacity: 0;
-    transform: translateX(-100%) translateY(-50%);
-    transition: opacity var(--transition-time);
-  }
-
-  &:hover:before {
-    opacity: 0.5;
-  }
-}
-
-@mixin center-children() {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-@mixin absolute-center() {
-  position: absolute;
-  left: 50%;
-  top: 50%;
-  transform: translateX(-50%) translateY(-50%);
-}
-
-@mixin blurred-background($color: transparent) {
-  background-color: color.adjust($color, $alpha: -0.66);
-  backdrop-filter: blur(var(--blur-radius));
-}
-
-@mixin square($size) {
-  width: $size;
-  height: $size;
-}
-
-@mixin title-font() {
-  font:
-    400 3rem 'Comfortaa',
-    sans-serif;
-  color: var(--normal-text-color);
-  line-height: 1;
-
-  @include on-small-screen {
-    font-size: 3rem;
-    line-height: 1.1;
-  }
-}
-
-@mixin sub-title-font() {
-  font:
-    400 1.75rem 'Comfortaa',
-    sans-serif;
-  color: var(--normal-text-color);
-  hyphens: auto;
-}
-
-@mixin main-font() {
-  font:
-    400 1.1rem 'Open Sans',
-    sans-serif;
-  color: var(--normal-text-color);
-  line-height: 1.8;
-  hyphens: auto;
-}
-
-@mixin special-text-font() {
-  font:
-    400 1rem 'Open Sans',
-    sans-serif;
-  color: var(--special-text-color);
-  hyphens: auto;
-  font-style: italic;
-}
-
-@mixin link {
-  $border-shift: 10px;
-  $line-width: 2px;
-
-  @include special-text-font();
-  cursor: pointer;
-  position: relative;
-  display: inline-block;
-  overflow: hidden;
-
-  padding: 0 3px $line-width 0;
-
-  &:before,
-  &:after {
-    content: '';
-    display: block;
-    position: absolute;
-    bottom: 0;
-  }
-
-  &:before {
-    width: calc(100% + #{$border-shift});
-    border-bottom: $line-width dashed var(--accent-color);
-    transition: transform var(--transition-time);
-  }
-
-  &:after {
-    width: 100%;
-    height: $line-width;
-    background: linear-gradient(
-      90deg,
-      var(--card-color) 0,
-      transparent 4px,
-      transparent calc(100% - 4px),
-      var(--card-color) 100%
-    );
-  }
-
-  &:hover:before {
-    transform: translateX(-$border-shift);
-  }
-}
diff --git a/src/style/toolbar/_buttons.scss b/src/style/toolbar/_buttons.scss
new file mode 100644
index 0000000..198ca47
--- /dev/null
+++ b/src/style/toolbar/_buttons.scss
@@ -0,0 +1,161 @@
+@use 'shared' as *;
+
+.buttons {
+  grid-area: buttons;
+  display: flex;
+  flex-wrap: nowrap;
+  align-items: center;
+  justify-content: center;
+  justify-self: center;
+  gap: 4px;
+  width: fit-content;
+  max-width: 100%;
+  min-width: 0;
+
+  > button,
+  > .audio-control > button {
+    position: relative;
+    width: 44px;
+    height: 44px;
+    flex: 1 1 44px;
+    max-width: 54px;
+    min-width: 0;
+    @include toolbar-control-surface(transparent, rgb(255 255 255 / 9%));
+
+    &::after {
+      content: '';
+      position: absolute;
+      inset: 0;
+      z-index: 1;
+      width: 20px;
+      height: 20px;
+      margin: auto;
+      background-color: rgb(245 250 244 / 76%);
+      mask-position: center;
+      mask-repeat: no-repeat;
+      mask-size: contain;
+      transition:
+        background-color var(--transition-time),
+        transform var(--transition-time);
+    }
+
+    &:hover::after {
+      transform: scale(1.08);
+    }
+
+    &.active {
+      border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%);
+      background: color-mix(in srgb, var(--accent-color) 30%, transparent);
+    }
+
+    &.active::after {
+      background-color: white;
+    }
+
+    @each $class, $icon in $toolbar-icons {
+      &.#{$class}::after {
+        mask-image: url('../../../assets/icons/#{$icon}.svg');
+      }
+    }
+
+    &.full-screen-toggle.active::after {
+      mask-image: url('../../../assets/icons/minimize.svg');
+    }
+
+    &.sound.muted::before {
+      content: '';
+      position: absolute;
+      inset: 0;
+      z-index: 2;
+      width: 2px;
+      height: 28px;
+      margin: auto;
+      border-radius: 999px;
+      background: white;
+      transform: rotate(-45deg);
+      transform-origin: center;
+    }
+
+    &.sound.muted::after {
+      background-color: rgb(255 255 255 / 46%);
+    }
+  }
+
+  > .audio-control {
+    display: flex;
+    align-items: center;
+    width: 132px;
+    height: 44px;
+    flex: 2 1 132px;
+    max-width: 150px;
+    min-width: 0;
+    padding-right: 10px;
+    @include toolbar-control-surface(rgb(255 255 255 / 4%), rgb(255 255 255 / 7%));
+
+    > button {
+      flex: 0 0 42px;
+      min-width: 42px;
+      border-color: transparent;
+
+      &:focus-visible {
+        outline-offset: -4px;
+      }
+    }
+
+    > .volume-control {
+      --range-progress: var(--volume-progress, 42%);
+      --range-track-height: 4px;
+      --range-fill: color-mix(in srgb, var(--accent-color) 62%, white 8%);
+      --range-empty: rgb(255 255 255 / 18%);
+      --range-track-shadow:
+        inset 0 1px 1px rgb(0 0 0 / 24%), 0 1px 0 rgb(255 255 255 / 8%);
+      --range-thumb-width: 12px;
+      --range-thumb-height: 12px;
+      --range-thumb-border: 2px solid rgb(13 18 24);
+      --range-thumb-radius: 50%;
+      --range-thumb-background: rgb(245 250 244);
+      --range-thumb-shadow: 0 0 0 1px rgb(255 255 255 / 46%), 0 3px 8px rgb(0 0 0 / 28%);
+      --range-thumb-hover-shadow:
+        0 0 0 1px rgb(255 255 255 / 56%),
+        0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent),
+        0 4px 10px rgb(0 0 0 / 34%);
+      --range-thumb-hover-transform: scale(1.08);
+      --range-focus-outline-offset: -4px;
+
+      position: relative;
+      display: grid;
+      align-items: center;
+      height: 44px;
+      flex: 1 1 auto;
+      min-width: 0;
+      padding-left: 3px;
+      cursor: ew-resize;
+      opacity: 0.96;
+      transition: opacity var(--transition-time);
+
+      &.muted {
+        opacity: 0.56;
+      }
+    }
+
+    > .volume-control input[type='range'] {
+      @include toolbar-range-input();
+    }
+  }
+
+  > .export-status {
+    flex: 0 1 140px;
+    min-height: 20px;
+    max-width: 140px;
+    overflow: hidden;
+    color: rgb(255 255 255 / 82%);
+    font-size: 13px;
+    line-height: 1.2;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:empty {
+      display: none;
+    }
+  }
+}
diff --git a/src/style/toolbar/_garden-controls.scss b/src/style/toolbar/_garden-controls.scss
new file mode 100644
index 0000000..4dca516
--- /dev/null
+++ b/src/style/toolbar/_garden-controls.scss
@@ -0,0 +1,148 @@
+@use 'shared' as *;
+
+.garden-controls {
+  grid-area: swatches;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: center;
+  min-width: 0;
+  padding: 0 4px;
+
+  > .swatches {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 12px;
+    min-height: 58px;
+    padding: 6px 10px;
+
+    > .color-swatch {
+      position: relative;
+      width: 44px;
+      height: 44px;
+      border: 2px solid rgb(255 255 255 / 54%);
+      border-radius: 50%;
+      box-shadow:
+        inset 0 0 0 1px rgb(0 0 0 / 16%),
+        0 3px 10px rgb(0 0 0 / 22%);
+
+      &:hover {
+        transform: translateY(-2px);
+      }
+
+      &.active {
+        outline: 2px solid rgb(255 255 255 / 96%);
+        outline-offset: 3px;
+        box-shadow:
+          inset 0 0 0 1px rgb(0 0 0 / 14%),
+          0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent),
+          0 7px 18px rgb(0 0 0 / 26%);
+      }
+    }
+
+    > .eraser-size-control,
+    > .mirror-segment-control {
+      --control-thumb-hover-transform: scale(1.03);
+      --control-thumb-radius: 50%;
+      --control-thumb-transform: none;
+      --range-progress: var(--control-progress);
+      --range-track-height: 7px;
+      --range-fill: rgb(var(--control-rgb) / 72%);
+      --range-empty: rgb(255 255 255 / 24%);
+      --range-track-shadow: inset 0 1px 2px rgb(0 0 0 / 24%);
+      --range-thumb-width: var(--control-thumb-width);
+      --range-thumb-height: var(--control-thumb-height);
+      --range-thumb-border: 2px solid rgb(255 255 255 / 92%);
+      --range-thumb-radius: var(--control-thumb-radius);
+      --range-thumb-background: var(--control-thumb-background);
+      --range-thumb-shadow:
+        inset 0 1px 2px rgb(255 255 255 / 22%), 0 4px 12px rgb(0 0 0 / 30%);
+      --range-thumb-hover-shadow:
+        inset 0 1px 2px rgb(255 255 255 / 22%), 0 0 0 4px rgb(var(--control-rgb) / 22%),
+        0 5px 14px rgb(0 0 0 / 34%);
+      --range-thumb-hover-transform: var(--control-thumb-hover-transform);
+      --range-thumb-transform: var(--control-thumb-transform);
+      --range-thumb-transition:
+        box-shadow var(--transition-time), height var(--transition-time),
+        margin-top var(--transition-time), transform var(--transition-time),
+        width var(--transition-time);
+
+      position: relative;
+      display: grid;
+      align-items: center;
+      width: 184px;
+      height: 46px;
+      flex: 0 0 184px;
+      padding: 0 12px;
+      overflow: hidden;
+      border: 1px solid rgb(255 255 255 / 14%);
+      border-radius: 8px;
+      background: linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%));
+      box-shadow:
+        inset 0 0 0 1px rgb(255 255 255 / 6%),
+        0 3px 10px rgb(0 0 0 / 18%);
+      cursor: ew-resize;
+      transition:
+        border-color var(--transition-time),
+        background-color var(--transition-time),
+        box-shadow var(--transition-time),
+        transform var(--transition-time);
+
+      &:hover {
+        border-color: rgb(255 255 255 / 24%);
+        transform: translateY(-2px);
+      }
+
+      &.active {
+        border-color: rgb(var(--control-rgb) / 72%);
+        background-color: rgb(var(--control-rgb) / 11%);
+        box-shadow:
+          inset 0 0 0 1px rgb(255 255 255 / 10%),
+          0 0 0 5px rgb(var(--control-rgb) / 28%),
+          0 6px 15px rgb(0 0 0 / 22%);
+      }
+
+      input[type='range'] {
+        @include toolbar-range-input();
+      }
+    }
+
+    > .eraser-size-control {
+      --control-progress: var(--eraser-progress, 33%);
+      --control-rgb: 255 140 117;
+      --control-thumb-background:
+        linear-gradient(
+          110deg,
+          transparent 0 12%,
+          rgb(255 255 255 / 44%) 13% 20%,
+          transparent 21% 100%
+        ),
+        linear-gradient(
+          90deg,
+          #ff8fa3 0 52%,
+          rgb(54 46 51 / 78%) 53% 56%,
+          #f5eee5 57% 100%
+        );
+      --control-thumb-height: calc(21px * var(--eraser-control-scale, 1));
+      --control-thumb-hover-transform: rotate(-10deg) scale(1.03);
+      --control-thumb-radius: calc(6px * var(--eraser-control-scale, 1));
+      --control-thumb-transform: rotate(-10deg);
+      --control-thumb-width: calc(34px * var(--eraser-control-scale, 1));
+    }
+
+    > .mirror-segment-control {
+      --control-progress: var(--mirror-progress, 0%);
+      --control-rgb: 148 233 203;
+      --control-thumb-background:
+        radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px),
+        repeating-conic-gradient(
+          from -90deg,
+          rgb(218 255 241) 0 8deg,
+          rgb(8 22 19 / 94%) 8deg var(--mirror-angle, 360deg)
+        );
+      --control-thumb-height: 44px;
+      --control-thumb-width: 44px;
+    }
+  }
+}
diff --git a/src/style/toolbar/_layout.scss b/src/style/toolbar/_layout.scss
new file mode 100644
index 0000000..f2c8930
--- /dev/null
+++ b/src/style/toolbar/_layout.scss
@@ -0,0 +1,173 @@
+@use 'shared' as *;
+
+.toolbar-row {
+  --toolbar-background-opacity: 0%;
+  --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:
+    var(--vibe-button-hit-size)
+    minmax(0, var(--toolbar-top-max-width))
+    var(--vibe-button-hit-size);
+  align-items: stretch;
+  justify-content: center;
+  width: fit-content;
+  max-width: 100%;
+  margin: 0 auto;
+  padding-inline: clamp(8px, 1.4vw, 14px);
+  column-gap: 0;
+  row-gap: 0;
+  border-radius: 12px;
+  color: rgb(245 250 244 / 92%);
+  background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
+  box-shadow:
+    inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)),
+    inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)),
+    0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%));
+  backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px))
+    brightness(calc(1 - var(--toolbar-background-strength) * 0.38))
+    saturate(calc(1 - var(--toolbar-background-strength) * 0.18));
+  font-size: 13px;
+  font-weight: 400;
+  line-height: 1;
+  transition:
+    backdrop-filter var(--transition-time-long),
+    background-color var(--transition-time-long),
+    box-shadow var(--transition-time-long);
+
+  &::after {
+    content: '';
+    grid-area: divider;
+    align-self: center;
+    justify-self: center;
+    width: min(100%, var(--toolbar-top-max-width));
+    height: 1px;
+    margin-block: var(--toolbar-divider-space);
+    background: rgb(255 255 255 / 12%);
+  }
+
+  button {
+    min-width: 44px;
+    min-height: 44px;
+    border: 0;
+    font: inherit;
+    cursor: pointer;
+    @include toolbar-button-transition();
+
+    &:disabled {
+      cursor: progress;
+      opacity: 0.58;
+    }
+
+    &:focus-visible {
+      outline: 2px solid white;
+      outline-offset: 2px;
+    }
+  }
+
+  > .toolbar-shell {
+    grid-area: controls;
+    display: grid;
+    grid-template-areas: 'swatches';
+    grid-template-columns: minmax(0, 1fr);
+    align-items: center;
+    justify-content: center;
+    justify-self: center;
+    width: min(100%, var(--toolbar-top-max-width));
+    min-width: 0;
+    padding: 8px 9px;
+  }
+
+  > .vibe-button {
+    --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: var(--vibe-button-hit-size);
+    height: auto;
+    min-height: 72px;
+    flex: 0 0 auto;
+    padding: 0;
+    border-radius: 0;
+    background: transparent;
+    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: var(--vibe-chevron-size);
+      height: var(--vibe-chevron-size);
+      border-color: currentColor;
+      border-style: solid;
+      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: var(--vibe-chevron-stroke) var(--vibe-chevron-stroke) 0 0;
+      transform: translate(-65%, -50%) rotate(45deg);
+    }
+
+    &:hover {
+      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 {
+      transform: translateX(-2px);
+    }
+
+    &.next-vibe:hover {
+      transform: translateX(2px);
+    }
+  }
+
+  > .previous-vibe {
+    grid-area: previous;
+  }
+
+  > .next-vibe {
+    grid-area: next;
+  }
+}
diff --git a/src/style/toolbar/_responsive.scss b/src/style/toolbar/_responsive.scss
new file mode 100644
index 0000000..2aeba6c
--- /dev/null
+++ b/src/style/toolbar/_responsive.scss
@@ -0,0 +1,157 @@
+@use '../mixins' as *;
+
+.toolbar-row {
+  @include on-small-screen {
+    --toolbar-divider-space: 4px;
+    --toolbar-top-max-width: 329px;
+    --vibe-button-hit-size: 44px;
+
+    grid-template-areas:
+      'previous controls next'
+      '. divider .'
+      'buttons buttons buttons';
+    width: 100%;
+    padding-inline: 4px;
+    column-gap: 0;
+    row-gap: 0;
+
+    > .vibe-button {
+      --vibe-button-surface-inset-block: 5px;
+      --vibe-button-surface-inset-inline: 3px;
+      --vibe-chevron-size: 17px;
+      --vibe-chevron-stroke: 3px;
+
+      width: var(--vibe-button-hit-size);
+      min-height: 44px;
+    }
+
+    > .toolbar-shell {
+      padding: 4px;
+    }
+
+    > nav.buttons {
+      justify-self: stretch;
+      justify-content: space-between;
+      gap: clamp(1px, 0.55vw, 2px);
+      width: auto;
+      max-width: none;
+      margin-inline: -4px;
+
+      > button {
+        width: auto;
+        height: 38px;
+        flex: 1 1 clamp(28px, 8vw, 38px);
+        max-width: 38px;
+        min-height: 38px;
+
+        &::after {
+          width: 17px;
+          height: 17px;
+        }
+      }
+
+      > .audio-control {
+        width: auto;
+        height: 38px;
+        flex: 2 1 clamp(58px, 18vw, 118px);
+        max-width: 118px;
+        padding-right: clamp(4px, 1.8vw, 9px);
+
+        > button {
+          width: auto;
+          flex: 1 1 clamp(28px, 8vw, 38px);
+          min-width: 0;
+        }
+
+        > .volume-control {
+          height: 38px;
+        }
+      }
+
+      > .export-status {
+        flex-basis: 0;
+        max-width: 0;
+        text-align: center;
+      }
+    }
+
+    > .toolbar-shell > .garden-controls {
+      padding: 2px 4px;
+
+      > .swatches {
+        display: grid;
+        grid-template-columns: repeat(6, minmax(0, 1fr));
+        justify-items: center;
+        justify-content: stretch;
+        width: 100%;
+        min-width: 0;
+        min-height: 48px;
+        flex: 1 1 100%;
+        padding: 3px 5px;
+        column-gap: 6px;
+        row-gap: 6px;
+
+        > .color-swatch {
+          width: 38px;
+          height: 38px;
+          min-width: 38px;
+          min-height: 38px;
+
+          grid-column: span 2;
+        }
+
+        > .eraser-size-control,
+        > .mirror-segment-control {
+          justify-self: stretch;
+          width: 100%;
+          min-width: 0;
+          height: 38px;
+          padding: 0 7px;
+        }
+
+        > .eraser-size-control {
+          grid-column: 1 / span 3;
+        }
+
+        > .mirror-segment-control {
+          --control-thumb-height: 34px;
+          --control-thumb-width: 34px;
+
+          grid-column: 4 / span 3;
+        }
+      }
+    }
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .toolbar-row {
+    > .vibe-button.previous-vibe:hover,
+    > .vibe-button.next-vibe:hover,
+    > .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover,
+    > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover,
+    > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover {
+      transform: none;
+    }
+
+    > nav.buttons > button:hover::after,
+    > nav.buttons > .audio-control > button:hover::after {
+      transform: none;
+    }
+
+    > nav.buttons > .audio-control > .volume-control input[type='range'] {
+      &::-webkit-slider-thumb:hover {
+        transform: none;
+      }
+    }
+
+    > .toolbar-shell > .garden-controls > .swatches {
+      > .eraser-size-control input[type='range'],
+      > .mirror-segment-control input[type='range'] {
+        &::-webkit-slider-thumb:hover {
+          transform: var(--range-thumb-transform, none);
+        }
+      }
+    }
+  }
+}
diff --git a/src/style/toolbar/_shared.scss b/src/style/toolbar/_shared.scss
new file mode 100644
index 0000000..ed477f2
--- /dev/null
+++ b/src/style/toolbar/_shared.scss
@@ -0,0 +1,104 @@
+$toolbar-icons: (
+  info: 'info',
+  full-screen-toggle: 'maximize',
+  settings: 'settings',
+  sound: 'sound',
+  export-4k: 'download',
+  restart: 'restart',
+);
+
+@mixin toolbar-button-transition() {
+  transition:
+    background-color var(--transition-time),
+    border-color var(--transition-time),
+    color var(--transition-time),
+    box-shadow var(--transition-time),
+    opacity var(--transition-time),
+    transform var(--transition-time);
+}
+
+@mixin toolbar-control-surface($background, $hover-background) {
+  border: 1px solid transparent;
+  border-radius: 8px;
+  background: $background;
+  transition:
+    border-color var(--transition-time),
+    background-color var(--transition-time),
+    box-shadow var(--transition-time),
+    opacity var(--transition-time);
+
+  &:hover {
+    border-color: rgb(255 255 255 / 10%);
+    background: $hover-background;
+  }
+}
+
+@mixin toolbar-range-track() {
+  height: var(--range-track-height);
+  border: var(--range-track-border, 0);
+  border-radius: 999px;
+  background: linear-gradient(
+    90deg,
+    var(--range-fill) 0 var(--range-progress),
+    var(--range-empty) var(--range-progress) 100%
+  );
+  box-shadow: var(--range-track-shadow);
+  cursor: ew-resize;
+}
+
+@mixin toolbar-range-thumb() {
+  width: var(--range-thumb-width);
+  height: var(--range-thumb-height);
+  border: var(--range-thumb-border);
+  border-radius: var(--range-thumb-radius);
+  background: var(--range-thumb-background);
+  box-shadow: var(--range-thumb-shadow);
+  cursor: ew-resize;
+  transform: var(--range-thumb-transform, none);
+}
+
+@mixin toolbar-range-input() {
+  position: relative;
+  z-index: 1;
+  width: 100%;
+  height: 100%;
+  appearance: none;
+  background: transparent;
+  cursor: ew-resize;
+  outline: none;
+  touch-action: pan-y;
+
+  &:focus-visible {
+    border-radius: 8px;
+    outline: 2px solid white;
+    outline-offset: var(--range-focus-outline-offset, 2px);
+  }
+
+  &::-webkit-slider-runnable-track {
+    @include toolbar-range-track();
+  }
+
+  &::-webkit-slider-thumb {
+    @include toolbar-range-thumb();
+    margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) / 2);
+    appearance: none;
+    transition: var(
+      --range-thumb-transition,
+      box-shadow var(--transition-time),
+      transform var(--transition-time)
+    );
+  }
+
+  &::-webkit-slider-thumb:hover {
+    box-shadow: var(--range-thumb-hover-shadow, var(--range-thumb-shadow));
+    transform: var(--range-thumb-hover-transform, var(--range-thumb-transform, none));
+  }
+
+  &::-moz-range-track {
+    @include toolbar-range-track();
+  }
+
+  &::-moz-range-thumb {
+    @include toolbar-range-thumb();
+  }
+}
diff --git a/src/style/vars.scss b/src/style/vars.scss
index aa832ca..319d82e 100644
--- a/src/style/vars.scss
+++ b/src/style/vars.scss
@@ -1,33 +1,8 @@
-@use 'mixins' as *;
-
 :root {
   --transition-time: 200ms;
   --transition-time-long: 350ms;
-  --line-width: 4px;
-  --line-height: 1.125rem;
-  --accent-color: rgb(6.39851188659668, 70.28645324707031, 102.23043060302734);
-  --very-light-text-color: #ffffff;
+  --accent-color: rgb(255 93 162);
   --main-color: #aaa;
-  --normal-text-color: #31343f;
-  --blurred-card-color: transparent;
-  --blur-radius: 12px;
-  --special-text-color: var(--accent-color);
-  --border-radius: 0.6rem;
-
-  --large-margin: 4.6rem;
   --normal-margin: 2rem;
   --small-margin: 1rem;
-  --shadow: 0 0 5px 2px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2);
-  --icon-size: 2.5rem;
-  --large-icon-size: 3.75rem;
-  --body-width: min(80%, 60rem);
-}
-
-@include on-small-screen {
-  :root {
-    --body-width: 90%;
-    --large-margin: 2.8rem;
-    --normal-margin: 2rem;
-    --icon-size: 2rem;
-  }
 }
diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts
new file mode 100644
index 0000000..f91a3e0
--- /dev/null
+++ b/src/utils/browser-storage.ts
@@ -0,0 +1,18 @@
+export const readBrowserStorage = (key: string): string | null => {
+  try {
+    return localStorage.getItem(key);
+  } catch {
+    return null;
+  }
+};
+
+export const writeBrowserStorage = (key: string, value: string): void => {
+  try {
+    localStorage.setItem(key, value);
+  } catch (error) {
+    console.warn(
+      'Storage can be unavailable in private browsing or embedded contexts.',
+      error
+    );
+  }
+};
diff --git a/src/utils/clamp.ts b/src/utils/clamp.ts
deleted file mode 100644
index 45da555..0000000
--- a/src/utils/clamp.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const clamp = (value: number, min: number, max: number): number =>
-  Math.min(max, Math.max(min, value));
-
-export const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index fb1ef5a..78489c2 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -1,37 +1,26 @@
-import { clamp } from './clamp';
-import { exponentialDecay } from './exponential-decay';
+import { appConfig } from '../config';
+import { clamp } from './math';
 
 export class DeltaTimeCalculator {
-  private static FPS_EXPONENTIAL_DECAY_STRENGTH = 0.01;
-
   private previousTime: DOMHighResTimeStamp | null = null;
-  private deltaTimeAccumulator: number | null = null;
+  private readonly visibilityChangeListener = () => this.handleVisibilityChange();
 
-  constructor(
-    private readonly maxDeltaTimeInSeconds: number = 1 / 30,
-    private readonly minDeltaTimeInSeconds: number = 1 / 240
-  ) {
-    document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
+  constructor() {
+    document.addEventListener('visibilitychange', this.visibilityChangeListener);
   }
 
-  public calculateDeltaTimeInSeconds(
-    currentTime: DOMHighResTimeStamp
-  ): DOMHighResTimeStamp {
+  public calculateDeltaTimeInSeconds(currentTime: DOMHighResTimeStamp): number {
     if (this.previousTime === null) {
       this.previousTime = currentTime;
     }
 
     const delta = currentTime - this.previousTime;
     this.previousTime = currentTime;
-    const deltaInSeconds = delta / 1000;
-
-    this.deltaTimeAccumulator = exponentialDecay({
-      accumulator: this.deltaTimeAccumulator ?? deltaInSeconds,
-      nextValue: deltaInSeconds,
-      biasOfNextValue: DeltaTimeCalculator.FPS_EXPONENTIAL_DECAY_STRENGTH,
-    });
-
-    return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds);
+    return clamp(
+      delta / 1000,
+      appConfig.deltaTime.minDeltaTimeSeconds,
+      appConfig.deltaTime.maxDeltaTimeSeconds
+    );
   }
 
   private handleVisibilityChange() {
@@ -40,7 +29,7 @@ export class DeltaTimeCalculator {
     }
   }
 
-  public get fps() {
-    return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
+  public destroy(): void {
+    document.removeEventListener('visibilitychange', this.visibilityChangeListener);
   }
 }
diff --git a/src/utils/dom.ts b/src/utils/dom.ts
new file mode 100644
index 0000000..b2cbcd2
--- /dev/null
+++ b/src/utils/dom.ts
@@ -0,0 +1,24 @@
+import { ErrorCode, RuntimeError } from './error-handler';
+
+type ElementConstructor = abstract new () => T;
+
+export const queryRequiredElement = (
+  selector: string,
+  constructor: ElementConstructor
+): T => {
+  const element = document.querySelector(selector);
+  if (!(element instanceof constructor)) {
+    throw new RuntimeError(
+      ErrorCode.DOM_ELEMENT_MISSING,
+      `Missing required DOM element: ${selector}`,
+      {
+        details: {
+          expectedType: constructor.name,
+          selector,
+        },
+      }
+    );
+  }
+
+  return element;
+};
diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts
index ca91a8a..cffd60c 100644
--- a/src/utils/error-handler.ts
+++ b/src/utils/error-handler.ts
@@ -4,42 +4,231 @@ export enum Severity {
   ERROR = 'error',
 }
 
-export interface ErrorHandlerError {
-  severity: Severity;
-  message: string;
+export enum ErrorCode {
+  WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
+  WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
+  WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',
+  WEBGPU_DEVICE_UNAVAILABLE = 'webgpu-device-unavailable',
+  WEBGPU_CONTEXT_UNAVAILABLE = 'webgpu-context-unavailable',
+  WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
+  WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
+  WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
+  DOM_ELEMENT_MISSING = 'dom-element-missing',
 }
 
-export type ErrorMetadata = { [key: string]: any };
+type ErrorMetadataPrimitive = string | number | boolean | null;
+type ErrorMetadataValue =
+  | ErrorMetadataPrimitive
+  | Array
+  | { [key: string]: ErrorMetadataValue };
+type ErrorMetadata = { [key: string]: ErrorMetadataValue };
+
+interface RuntimeErrorOptions {
+  cause?: unknown;
+  details?: Record;
+}
+
+export class RuntimeError extends Error {
+  public readonly code: ErrorCode | string;
+  public readonly details: ErrorMetadata;
+
+  public constructor(
+    code: ErrorCode | string,
+    message: string,
+    { cause, details = {} }: RuntimeErrorOptions = {}
+  ) {
+    super(message);
+    this.name = 'RuntimeError';
+    this.code = code;
+    this.details = serializeMetadataValue(details) as ErrorMetadata;
+
+    if (cause !== undefined) {
+      this.cause = cause;
+    }
+  }
+}
+
+interface ErrorHandlerError {
+  severity: Severity;
+  message: string;
+  code?: ErrorCode | string;
+  details?: ErrorMetadata;
+}
+
+interface ErrorHandlerErrorOptions {
+  code?: ErrorCode | string;
+  details?: Record;
+}
+
+interface ErrorHandlerExceptionOptions extends ErrorHandlerErrorOptions {
+  fallbackMessage?: string;
+  severity?: Severity;
+}
+
+const MAX_METADATA_DEPTH = 4;
+const UNREADABLE_VALUE = '[Unreadable]';
+
+const isRecord = (value: unknown): value is Record =>
+  typeof value === 'object' && value !== null;
+
+const safelyRead = (value: Record, key: string): unknown => {
+  try {
+    return value[key];
+  } catch {
+    return undefined;
+  }
+};
+
+const isIterable = (value: unknown): value is Iterable =>
+  isRecord(value) && Symbol.iterator in value;
+
+const serializeMetadataValue = (value: unknown, depth = 0): ErrorMetadataValue => {
+  if (value === null) {
+    return null;
+  }
+
+  switch (typeof value) {
+    case 'string':
+    case 'boolean':
+      return value;
+    case 'number':
+      return Number.isFinite(value) ? value : value.toString();
+    case 'bigint':
+      return value.toString();
+    case 'undefined':
+      return null;
+    case 'symbol':
+      return value.toString();
+    case 'function':
+      return `[Function ${value.name || 'anonymous'}]`;
+  }
+
+  if (depth >= MAX_METADATA_DEPTH) {
+    return '[Object]';
+  }
+
+  if (Array.isArray(value)) {
+    return value.map((item) => serializeMetadataValue(item, depth + 1));
+  }
+
+  if (isIterable(value)) {
+    try {
+      return Array.from(value, (item) => serializeMetadataValue(item, depth + 1));
+    } catch {
+      return UNREADABLE_VALUE;
+    }
+  }
+
+  const serialized: ErrorMetadata = {};
+  const record = value as Record;
+  for (const key of Object.keys(record)) {
+    try {
+      serialized[key] = serializeMetadataValue(record[key], depth + 1);
+    } catch {
+      serialized[key] = UNREADABLE_VALUE;
+    }
+  }
+
+  return serialized;
+};
+
+export const getErrorMessage = (
+  exception: unknown,
+  fallbackMessage = 'Unknown error'
+): string => {
+  if (typeof exception === 'string') {
+    return exception || fallbackMessage;
+  }
+
+  if (exception instanceof Error) {
+    const record = exception as unknown as Record;
+    const message = safelyRead(record, 'message');
+    if (typeof message === 'string' && message.length > 0) {
+      return message;
+    }
+
+    const name = safelyRead(record, 'name');
+    if (typeof name === 'string' && name.length > 0) {
+      return name;
+    }
+
+    return fallbackMessage;
+  }
+
+  if (isRecord(exception)) {
+    const message = safelyRead(exception, 'message');
+    if (typeof message === 'string' && message.length > 0) {
+      return message;
+    }
+  }
+
+  if (
+    typeof exception === 'number' ||
+    typeof exception === 'boolean' ||
+    typeof exception === 'bigint' ||
+    typeof exception === 'symbol'
+  ) {
+    return exception.toString();
+  }
+
+  return fallbackMessage;
+};
 
 export class ErrorHandler {
-  private static readonly errors: Array = [];
   private static metadata: ErrorMetadata = {};
   private static onErrorListeners: Array<
     (error: ErrorHandlerError, metadata: ErrorMetadata) => void
   > = [];
 
-  public static addException(exception: Error) {
-    ErrorHandler.addError(Severity.ERROR, exception.message);
+  public static addException(
+    exception: unknown,
+    {
+      severity = Severity.ERROR,
+      fallbackMessage,
+      code,
+      details,
+    }: ErrorHandlerExceptionOptions = {}
+  ) {
+    const runtimeError = exception instanceof RuntimeError ? exception : undefined;
+    ErrorHandler.addError(severity, getErrorMessage(exception, fallbackMessage), {
+      code: code ?? runtimeError?.code,
+      details: {
+        ...(runtimeError?.details ?? {}),
+        ...(details ?? {}),
+      },
+    });
   }
 
-  public static addError(severity: Severity, message: string) {
-    ErrorHandler.errors.push({ severity, message });
+  public static addError(
+    severity: Severity,
+    message: string,
+    { code, details }: ErrorHandlerErrorOptions = {}
+  ) {
+    const error: ErrorHandlerError = {
+      severity,
+      message,
+      ...(code === undefined ? {} : { code }),
+      ...(details === undefined
+        ? {}
+        : { details: serializeMetadataValue(details) as ErrorMetadata }),
+    };
     ErrorHandler.onErrorListeners.forEach((listener) =>
-      listener({ severity, message }, ErrorHandler.metadata)
+      listener(error, ErrorHandler.metadata)
     );
   }
 
-  public static addMetadata(key: string, value: any) {
-    const serialized: Record = {};
-    for (const k in value) {
-      serialized[k] = value[k];
-    }
-    ErrorHandler.metadata[key] = serialized;
+  public static addMetadata(key: string, value: unknown) {
+    ErrorHandler.metadata[key] = serializeMetadataValue(value);
   }
 
   public static addOnErrorListener(
     listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
-  ) {
+  ): () => void {
     ErrorHandler.onErrorListeners.push(listener);
+    return () => {
+      ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter(
+        (registeredListener) => registeredListener !== listener
+      );
+    };
   }
 }
diff --git a/src/utils/exponential-decay.ts b/src/utils/exponential-decay.ts
deleted file mode 100644
index 3f13b6a..0000000
--- a/src/utils/exponential-decay.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export const exponentialDecay = ({
-  accumulator,
-  nextValue,
-  biasOfNextValue,
-}: {
-  accumulator: number;
-  nextValue: number;
-  biasOfNextValue: number;
-}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue;
diff --git a/src/utils/format-number.test.ts b/src/utils/format-number.test.ts
deleted file mode 100644
index c434967..0000000
--- a/src/utils/format-number.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { formatNumber } from './format-number';
-
-describe('formatNumber', () => {
-  it('renders integers without decimals', () => {
-    expect(formatNumber(42)).toBe('42 ');
-  });
-  it('renders fractional values with two decimals', () => {
-    expect(formatNumber(3.14159)).toBe('3.14 ');
-  });
-  it('renders thousands compactly', () => {
-    expect(formatNumber(2500)).toBe('2.5 thousand ');
-  });
-  it('renders millions compactly', () => {
-    expect(formatNumber(1_500_000)).toBe('1.5 million ');
-  });
-  it('appends the unit when provided', () => {
-    expect(formatNumber(5, 'agents')).toBe('5 agents');
-    expect(formatNumber(2_000_000, 'agents')).toBe('2.0 million agents');
-  });
-});
diff --git a/src/utils/format-number.ts b/src/utils/format-number.ts
deleted file mode 100644
index a57812e..0000000
--- a/src/utils/format-number.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export const formatNumber = (value: number, unit = ''): string => {
-  if (value >= 1e6) {
-    return `${(value / 1e6).toFixed(1)} million ${unit}`;
-  }
-
-  if (value >= 1e3) {
-    return `${(value / 1e3).toFixed(1)} thousand ${unit}`;
-  }
-
-  return `${value === Math.floor(value) ? value : value.toFixed(2)} ${unit}`;
-};
diff --git a/src/utils/graphics/bind-group-cache.ts b/src/utils/graphics/bind-group-cache.ts
new file mode 100644
index 0000000..40ebce0
--- /dev/null
+++ b/src/utils/graphics/bind-group-cache.ts
@@ -0,0 +1,38 @@
+type BindGroupCacheKeys = readonly [object, ...object[]];
+
+interface BindGroupCacheNode {
+  bindGroup?: GPUBindGroup;
+  children: WeakMap;
+}
+
+const createNode = (): BindGroupCacheNode => ({
+  children: new WeakMap(),
+});
+
+const getOrCreateNode = (
+  children: WeakMap,
+  key: object
+): BindGroupCacheNode => {
+  let node = children.get(key);
+  if (!node) {
+    node = createNode();
+    children.set(key, node);
+  }
+  return node;
+};
+
+export const createBindGroupCache = (
+  factory: (...keys: Keys) => GPUBindGroup
+): ((...keys: Keys) => GPUBindGroup) => {
+  const root = new WeakMap();
+
+  return (...keys) => {
+    let node = getOrCreateNode(root, keys[0]);
+    for (const key of keys.slice(1)) {
+      node = getOrCreateNode(node.children, key);
+    }
+
+    node.bindGroup ??= factory(...keys);
+    return node.bindGroup;
+  };
+};
diff --git a/src/utils/graphics/cached-buffer-write.test.ts b/src/utils/graphics/cached-buffer-write.test.ts
new file mode 100644
index 0000000..a816f61
--- /dev/null
+++ b/src/utils/graphics/cached-buffer-write.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+  createCachedBufferWrite,
+  updateCachedBufferWrite,
+  writeBufferIfChanged,
+} from './cached-buffer-write';
+
+describe('cached buffer writes', () => {
+  it('compares raw bytes so aliased uint changes are detected', () => {
+    const values = new Float32Array(1);
+    const uintValues = new Uint32Array(values.buffer);
+    const cache = createCachedBufferWrite(values.byteLength);
+
+    uintValues[0] = 0x7fc00001;
+    expect(updateCachedBufferWrite(values, cache)).toBe(true);
+    expect(updateCachedBufferWrite(values, cache)).toBe(false);
+
+    uintValues[0] = 0x7fc00002;
+    expect(Number.isNaN(values[0])).toBe(true);
+    expect(updateCachedBufferWrite(values, cache)).toBe(true);
+  });
+
+  it('writes to the GPU queue only when the raw buffer changed', () => {
+    const values = new Uint32Array([1, 2, 3, 4]);
+    const writeBuffer = vi.fn();
+    const device = { queue: { writeBuffer } } as unknown as GPUDevice;
+    const buffer = {} as GPUBuffer;
+    const cache = createCachedBufferWrite(values.byteLength);
+
+    expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
+    expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(false);
+
+    values[2] = 5;
+    expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
+    expect(writeBuffer).toHaveBeenCalledTimes(2);
+  });
+});
diff --git a/src/utils/graphics/cached-buffer-write.ts b/src/utils/graphics/cached-buffer-write.ts
new file mode 100644
index 0000000..774da8f
--- /dev/null
+++ b/src/utils/graphics/cached-buffer-write.ts
@@ -0,0 +1,46 @@
+interface CachedBufferWrite {
+  hasValue: boolean;
+  previous: Uint8Array;
+}
+
+export const createCachedBufferWrite = (byteLength: number): CachedBufferWrite => ({
+  hasValue: false,
+  previous: new Uint8Array(byteLength),
+});
+
+export const updateCachedBufferWrite = (
+  values: ArrayBufferView,
+  cache: CachedBufferWrite
+): boolean => {
+  const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
+  if (bytes.length !== cache.previous.length) {
+    throw new Error('Cached buffer write length mismatch');
+  }
+
+  let hasChanged = !cache.hasValue;
+  for (let i = 0; i < bytes.length && !hasChanged; i++) {
+    hasChanged = bytes[i] !== cache.previous[i];
+  }
+
+  if (!hasChanged) {
+    return false;
+  }
+
+  cache.previous.set(bytes);
+  cache.hasValue = true;
+  return true;
+};
+
+export const writeBufferIfChanged = (
+  device: GPUDevice,
+  buffer: GPUBuffer,
+  values: ArrayBufferView,
+  cache: CachedBufferWrite
+): boolean => {
+  if (!updateCachedBufferWrite(values, cache)) {
+    return false;
+  }
+
+  device.queue.writeBuffer(buffer, 0, values);
+  return true;
+};
diff --git a/src/utils/graphics/full-screen-quad.ts b/src/utils/graphics/full-screen-quad.ts
index 3e9dbec..6a38f2a 100644
--- a/src/utils/graphics/full-screen-quad.ts
+++ b/src/utils/graphics/full-screen-quad.ts
@@ -1,65 +1,25 @@
 import { smartCompile } from './smart-compile';
 
-export const setUpFullScreenQuad = (
-  device: GPUDevice
-): {
-  buffer: GPUBuffer;
-  vertex: GPUVertexState;
-} => {
-  const buffer = device.createBuffer({
-    size: 4 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec4
-    usage: GPUBufferUsage.VERTEX,
-    mappedAtCreation: true,
-  });
-  // prettier-ignore
-  const vertexData = [
-    // posX  posY U    V
-      -1.0, -1.0, 0.0, 1.0,
-      +1.0, -1.0, 1.0, 1.0,
-      -1.0, +1.0, 0.0, 0.0,
-      +1.0, +1.0, 1.0, 0.0,
-  ];
-  new Float32Array(buffer.getMappedRange()).set(vertexData);
-  buffer.unmap();
+export const setUpFullScreenQuad = (device: GPUDevice): GPUVertexState => ({
+  module: smartCompile(
+    device,
+    /* wgsl */ `
+    struct VertexOutput {
+      @builtin(position) position: vec4,
+      @location(0) uv: vec2,
+    }
 
-  return {
-    buffer,
-    vertex: {
-      module: smartCompile(
-        device,
-        /* wgsl */ `
-        struct VertexOutput {
-          @builtin(position) position: vec4,
-          @location(0) uv: vec2,
-        }
-        
-        @vertex
-        fn vertex(
-          @location(0) position: vec2,
-          @location(1) uv: vec2
-        ) -> VertexOutput {
-          return VertexOutput(vec4(position, 0.0, 1.0), uv);
-        }`
-      ),
-      entryPoint: 'vertex',
-      buffers: [
-        {
-          arrayStride: 4 * Float32Array.BYTES_PER_ELEMENT,
-          stepMode: 'vertex',
-          attributes: [
-            {
-              shaderLocation: 0,
-              offset: 0,
-              format: 'float32x2',
-            },
-            {
-              shaderLocation: 1,
-              offset: 8,
-              format: 'float32x2',
-            },
-          ],
-        },
-      ],
-    },
-  };
-};
+    @vertex
+    fn vertex(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
+      let positions = array, 3>(
+        vec2(-1.0, -1.0),
+        vec2(3.0, -1.0),
+        vec2(-1.0, 3.0)
+      );
+      let position = positions[vertexIndex];
+      let uv = vec2(position.x * 0.5 + 0.5, 0.5 - position.y * 0.5);
+      return VertexOutput(vec4(position, 0.0, 1.0), uv);
+    }`
+  ),
+  entryPoint: 'vertex',
+});
diff --git a/src/utils/graphics/get-workgroup-counts.ts b/src/utils/graphics/get-workgroup-counts.ts
deleted file mode 100644
index fe016e7..0000000
--- a/src/utils/graphics/get-workgroup-counts.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-export const getWorkgroupCounts = (
-  device: GPUDevice,
-  invocationCount: number,
-  workgroupSize: number
-): [number, number, number] => {
-  const workgroupCount = Math.ceil(invocationCount / workgroupSize);
-
-  const workgroupCountX = Math.min(
-    device.limits.maxComputeWorkgroupsPerDimension,
-    workgroupCount
-  );
-
-  const workgroupCountY = Math.min(
-    device.limits.maxComputeWorkgroupsPerDimension,
-    Math.ceil(workgroupCount / workgroupCountX)
-  );
-
-  const workgroupCountZ = Math.min(
-    device.limits.maxComputeWorkgroupsPerDimension,
-    Math.ceil(workgroupCount / workgroupCountX / workgroupCountY)
-  );
-
-  if (workgroupCountX * workgroupCountY * workgroupCountZ < workgroupCount) {
-    throw new Error('Cannot have this many invocations');
-  }
-
-  return [workgroupCountX, workgroupCountY, workgroupCountZ];
-};
diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts
index 5e21820..da8b994 100644
--- a/src/utils/graphics/initialize-context.ts
+++ b/src/utils/graphics/initialize-context.ts
@@ -1,17 +1,50 @@
+import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
+
 export const initializeContext = ({
   device,
   canvas,
+  format,
 }: {
   device: GPUDevice;
   canvas: HTMLCanvasElement;
+  format: GPUTextureFormat;
 }): GPUCanvasContext => {
-  const context = canvas.getContext('webgpu') as any as GPUCanvasContext;
+  const context = canvas.getContext('webgpu');
 
-  context.configure({
-    device: device,
-    format: navigator.gpu.getPreferredCanvasFormat(),
-    alphaMode: 'premultiplied',
-  });
+  if (!context) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_CONTEXT_UNAVAILABLE,
+      'Could not create a WebGPU canvas context.',
+      {
+        details: {
+          canvasHeight: canvas.height,
+          canvasWidth: canvas.width,
+        },
+      }
+    );
+  }
+
+  try {
+    context.configure({
+      device: device,
+      format,
+      usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
+      alphaMode: 'opaque',
+    });
+  } catch (error) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_CONTEXT_CONFIGURATION_FAILED,
+      'Could not configure the WebGPU canvas context.',
+      {
+        cause: error,
+        details: {
+          causeMessage: getErrorMessage(error),
+          canvasHeight: canvas.height,
+          canvasWidth: canvas.width,
+        },
+      }
+    );
+  }
 
   return context;
 };
diff --git a/src/utils/graphics/initialize-gpu.test.ts b/src/utils/graphics/initialize-gpu.test.ts
new file mode 100644
index 0000000..44bbd91
--- /dev/null
+++ b/src/utils/graphics/initialize-gpu.test.ts
@@ -0,0 +1,100 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { initializeGpu } from './initialize-gpu';
+
+const limits = {
+  maxBufferSize: 1024,
+  maxComputeWorkgroupsPerDimension: 16,
+  maxStorageBufferBindingSize: 1024,
+} as unknown as GPUSupportedLimits;
+
+const createDevice = (): GPUDevice =>
+  ({
+    addEventListener: vi.fn(),
+    lost: new Promise(() => undefined),
+  }) as unknown as GPUDevice;
+
+const createAdapter = (features: Array = []): GPUAdapter => {
+  const device = createDevice();
+  return {
+    features: new Set(features),
+    info: {},
+    limits,
+    requestDevice: vi.fn().mockResolvedValue(device),
+  } as unknown as GPUAdapter;
+};
+
+const stubSecureWebGpu = (requestAdapter: GPU['requestAdapter']): void => {
+  vi.stubGlobal('window', { isSecureContext: true });
+  vi.stubGlobal('navigator', {
+    gpu: {
+      requestAdapter,
+    },
+  });
+};
+
+describe('initializeGpu', () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('starts with the least demanding compatibility adapter request', async () => {
+    const adapter = createAdapter();
+    const requestAdapter = vi.fn().mockResolvedValue(adapter);
+    stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
+
+    await initializeGpu();
+
+    expect(requestAdapter).toHaveBeenNthCalledWith(1, {
+      featureLevel: 'compatibility',
+    });
+    expect(requestAdapter).toHaveBeenCalledTimes(1);
+    expect(adapter.requestDevice).toHaveBeenCalled();
+  });
+
+  it('continues trying adapters if one request throws', async () => {
+    const adapter = createAdapter();
+    const requestAdapter = vi
+      .fn()
+      .mockRejectedValueOnce(new Error('adapter request failed'))
+      .mockResolvedValueOnce(adapter);
+    stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
+
+    await expect(initializeGpu()).resolves.toBeDefined();
+    expect(requestAdapter).toHaveBeenCalledTimes(2);
+  });
+
+  it('falls back through core and high-performance adapter requests', async () => {
+    const adapter = createAdapter();
+    const requestAdapter = vi
+      .fn()
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce(adapter);
+    stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
+
+    await initializeGpu();
+
+    expect(requestAdapter).toHaveBeenNthCalledWith(1, {
+      featureLevel: 'compatibility',
+    });
+    expect(requestAdapter).toHaveBeenNthCalledWith(2, undefined);
+    expect(requestAdapter).toHaveBeenNthCalledWith(3, {
+      featureLevel: 'compatibility',
+      powerPreference: 'high-performance',
+    });
+    expect(adapter.requestDevice).toHaveBeenCalled();
+  });
+
+  it('requests only the core feature when the adapter exposes optional features', async () => {
+    const adapter = createAdapter(['core-features-and-limits', 'timestamp-query']);
+    const requestAdapter = vi.fn().mockResolvedValue(adapter);
+    stubSecureWebGpu(requestAdapter as GPU['requestAdapter']);
+
+    await initializeGpu();
+
+    expect(adapter.requestDevice).toHaveBeenCalledWith({
+      requiredFeatures: ['core-features-and-limits'],
+    });
+  });
+});
diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts
index 18ba035..1fdc9bf 100644
--- a/src/utils/graphics/initialize-gpu.ts
+++ b/src/utils/graphics/initialize-gpu.ts
@@ -1,33 +1,219 @@
-import { ErrorHandler, Severity } from '../error-handler';
+import {
+  ErrorCode,
+  ErrorHandler,
+  getErrorMessage,
+  RuntimeError,
+  Severity,
+} from '../error-handler';
 
-export const initializeGpu = async (): Promise => {
-  const gpu = navigator.gpu;
-  if (!gpu) {
-    throw new Error('WebGPU is not supported in your browser');
+const WEBGPU_BROWSER_SUPPORT_MESSAGE =
+  'Fleeting Garden needs WebGPU. Try the latest Chrome, Edge, or another browser with WebGPU enabled.';
+
+const REQUESTED_LIMIT_NAMES = [
+  'maxBufferSize',
+  'maxStorageBufferBindingSize',
+  'maxComputeWorkgroupsPerDimension',
+] as const satisfies ReadonlyArray;
+
+interface AdapterRequestAttempt {
+  label: string;
+  options?: GPURequestAdapterOptions;
+}
+
+const ADAPTER_REQUEST_ATTEMPTS: ReadonlyArray = [
+  {
+    label: 'compatibility-default',
+    options: { featureLevel: 'compatibility' },
+  },
+  {
+    label: 'core-default',
+  },
+  {
+    label: 'compatibility-high-performance',
+    options: { featureLevel: 'compatibility', powerPreference: 'high-performance' },
+  },
+  {
+    label: 'core-high-performance',
+    options: { powerPreference: 'high-performance' },
+  },
+] as const;
+
+const getRelevantLimits = (
+  limits: GPUSupportedLimits
+): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
+  Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
+    (typeof REQUESTED_LIMIT_NAMES)[number],
+    number
+  >;
+
+const getAdapterInfo = (adapter: GPUAdapter): Record => {
+  try {
+    const info = adapter.info;
+    return {
+      architecture: info.architecture,
+      description: info.description,
+      device: info.device,
+      isFallbackAdapter: info.isFallbackAdapter,
+      subgroupMaxSize: info.subgroupMaxSize,
+      subgroupMinSize: info.subgroupMinSize,
+      vendor: info.vendor,
+    };
+  } catch (error) {
+    return {
+      unavailableReason: getErrorMessage(error),
+    };
+  }
+};
+
+const getRequiredFeatures = (adapter: GPUAdapter): Array => {
+  const requiredFeatures: Array = [];
+
+  if (adapter.features.has('core-features-and-limits')) {
+    requiredFeatures.push('core-features-and-limits');
   }
 
-  const adapter = await gpu.requestAdapter({
-    powerPreference: 'high-performance',
+  return requiredFeatures;
+};
+
+type AdapterRequestOutcome = 'adapter' | 'unavailable' | 'error';
+
+const describeAdapterRequest = (
+  attempt: AdapterRequestAttempt,
+  outcome: AdapterRequestOutcome,
+  causeMessage?: string
+): Record => ({
+  label: attempt.label,
+  featureLevel: attempt.options?.featureLevel ?? 'core',
+  powerPreference: attempt.options?.powerPreference ?? 'default',
+  outcome,
+  ...(causeMessage === undefined ? {} : { causeMessage }),
+});
+
+const requestAdapter = async (
+  gpu: GPU
+): Promise<{
+  adapter: GPUAdapter | null;
+  attempts: Array>;
+}> => {
+  const attempts: Array> = [];
+
+  for (const attempt of ADAPTER_REQUEST_ATTEMPTS) {
+    try {
+      const adapter = await gpu.requestAdapter(attempt.options);
+      attempts.push(describeAdapterRequest(attempt, adapter ? 'adapter' : 'unavailable'));
+
+      if (adapter) {
+        return { adapter, attempts };
+      }
+    } catch (error) {
+      attempts.push(describeAdapterRequest(attempt, 'error', getErrorMessage(error)));
+    }
+  }
+
+  return { adapter: null, attempts };
+};
+
+const formatAdapterAttemptSummary = (
+  attempts: Array>
+): string => {
+  if (attempts.length === 0) {
+    return 'No adapter requests were attempted.';
+  }
+
+  return `Adapter attempts: ${attempts
+    .map((attempt) => `${attempt.label}: ${attempt.outcome}`)
+    .join('; ')}`;
+};
+
+export const initializeGpu = async (): Promise => {
+  if (window.isSecureContext === false) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_INSECURE_CONTEXT,
+      'WebGPU requires a secure context. Open Fleeting Garden over HTTPS or from localhost.'
+    );
+  }
+
+  const gpu = navigator.gpu;
+  if (!gpu) {
+    throw new RuntimeError(ErrorCode.WEBGPU_UNSUPPORTED, WEBGPU_BROWSER_SUPPORT_MESSAGE, {
+      details: {
+        hasNavigatorGpu: false,
+        isSecureContext: window.isSecureContext,
+      },
+    });
+  }
+
+  const { adapter, attempts } = await requestAdapter(gpu);
+  ErrorHandler.addMetadata('webgpuAdapterRequest', {
+    attempts,
+    selectedAttempt: attempts[attempts.length - 1]?.label ?? null,
   });
 
   if (!adapter) {
-    throw new Error('Could not request adatper');
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
+      [
+        'WebGPU is available, but this browser could not provide a compatible GPU adapter.',
+        formatAdapterAttemptSummary(attempts),
+      ].join('\n'),
+      {
+        details: {
+          attempts,
+          hasNavigatorGpu: true,
+          isSecureContext: window.isSecureContext,
+          platform: navigator.platform,
+          userAgent: navigator.userAgent,
+        },
+      }
+    );
   }
 
-  ErrorHandler.addMetadata('features', adapter.features);
-  ErrorHandler.addMetadata('limits', adapter.limits);
-
-  const gpuDevice = await adapter.requestDevice({
-    requiredLimits: {
-      maxBufferSize: adapter.limits.maxBufferSize,
-      maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
-      maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
-    },
+  const requiredFeatures = getRequiredFeatures(adapter);
+  ErrorHandler.addMetadata('webgpuAdapter', {
+    features: Array.from(adapter.features).sort(),
+    info: getAdapterInfo(adapter),
+    requiredFeatures,
+    relevantLimits: getRelevantLimits(adapter.limits),
   });
 
+  let gpuDevice: GPUDevice;
+  try {
+    gpuDevice = await adapter.requestDevice({
+      requiredFeatures,
+    });
+  } catch (error) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
+      'Could not create a WebGPU device for this adapter.',
+      {
+        cause: error,
+        details: {
+          causeMessage: getErrorMessage(error),
+          requiredFeatures,
+        },
+      }
+    );
+  }
+
   gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
-    ErrorHandler.addError(Severity.ERROR, event.error.message)
+    ErrorHandler.addException(event.error, {
+      code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,
+      severity: Severity.ERROR,
+    })
   );
 
+  gpuDevice.lost.then((info) => {
+    if (info.reason === 'destroyed') {
+      return;
+    }
+
+    ErrorHandler.addError(Severity.ERROR, info.message || 'The WebGPU device was lost.', {
+      code: ErrorCode.WEBGPU_DEVICE_LOST,
+      details: {
+        reason: info.reason,
+      },
+    });
+  });
+
   return gpuDevice;
 };
diff --git a/src/utils/graphics/noise.ts b/src/utils/graphics/noise.ts
index 720af9c..c7cdc96 100644
--- a/src/utils/graphics/noise.ts
+++ b/src/utils/graphics/noise.ts
@@ -1,7 +1,11 @@
+import { appConfig } from '../../config';
 import { setUpFullScreenQuad } from './full-screen-quad';
 import { smartCompile } from './smart-compile';
 
-const textureCache = new Map();
+export interface GeneratedNoiseTexture {
+  texture: GPUTexture;
+  view: GPUTextureView;
+}
 
 export const generateNoise = ({
   device,
@@ -11,15 +15,8 @@ export const generateNoise = ({
   device: GPUDevice;
   width: number;
   height: number;
-}): GPUTextureView => {
-  const cacheKey = `${width}x${height}`;
-  const cached = textureCache.get(cacheKey);
-  if (cached) {
-    return cached.createView();
-  }
-
-  const { buffer, vertex } = setUpFullScreenQuad(device);
-  const vertexBuffer = buffer;
+}): GeneratedNoiseTexture => {
+  const vertex = setUpFullScreenQuad(device);
 
   const pipeline = device.createRenderPipeline({
     layout: 'auto',
@@ -29,28 +26,34 @@ export const generateNoise = ({
         device,
         /* wgsl */ `
         fn random_with_seed(uv: vec2, seed: f32) -> f32 {
-          return fract(sin(dot(uv, vec2(12.9898 + seed, 78.233 + seed)))* 43758.5453123 + seed);
+          return fract(sin(dot(
+            uv,
+            vec2(
+              ${appConfig.pipelines.common.noiseHashX} + seed,
+              ${appConfig.pipelines.common.noiseHashY} + seed
+            )
+          )) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
         }
 
         @fragment
         fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
           return vec4(
-            random_with_seed(uv, 0),
-            random_with_seed(uv, 1),
-            random_with_seed(uv, 2),
-            random_with_seed(uv, 3),
+            random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
+            0.0,
+            0.0,
+            1.0,
           );
         }`
       ),
       entryPoint: 'fragment',
       targets: [
         {
-          format: 'rgba16float',
+          format: appConfig.pipelines.common.noiseTextureFormat,
         },
       ],
     },
     primitive: {
-      topology: 'triangle-strip',
+      topology: 'triangle-list',
     },
   });
 
@@ -60,7 +63,7 @@ export const generateNoise = ({
       height,
       depthOrArrayLayers: 1,
     },
-    format: 'rgba16float',
+    format: appConfig.pipelines.common.noiseTextureFormat,
     usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
   });
 
@@ -68,7 +71,7 @@ export const generateNoise = ({
     colorAttachments: [
       {
         view: colorTexture.createView(),
-        clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
+        clearValue: appConfig.pipelines.common.noiseClearValue,
         loadOp: 'clear',
         storeOp: 'store',
       },
@@ -79,11 +82,15 @@ export const generateNoise = ({
 
   const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
   passEncoder.setPipeline(pipeline);
-  passEncoder.setVertexBuffer(0, vertexBuffer);
-  passEncoder.draw(4, 1);
+  passEncoder.draw(
+    appConfig.pipelines.common.noiseDrawVertexCount,
+    appConfig.pipelines.common.noiseDrawInstanceCount
+  );
   passEncoder.end();
 
   device.queue.submit([commandEncoder.finish()]);
-  textureCache.set(cacheKey, colorTexture);
-  return colorTexture.createView();
+  return {
+    texture: colorTexture,
+    view: colorTexture.createView(),
+  };
 };
diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts
index 54b21ed..5624edc 100644
--- a/src/utils/graphics/resizable-texture.ts
+++ b/src/utils/graphics/resizable-texture.ts
@@ -1,63 +1,124 @@
 import { vec2 } from 'gl-matrix';
 
-import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
+import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../../pipelines/texture-formats';
+
+interface ResizableTextureOptions {
+  clearValue?: GPUColor;
+  format?: GPUTextureFormat;
+  usage?: GPUTextureUsageFlags;
+}
+
+export interface PendingTextureResize {
+  copySize: GPUExtent3DStrict;
+  newSize: vec2;
+  newTexture: GPUTexture;
+  newTextureView: GPUTextureView;
+  oldTexture: GPUTexture;
+}
 
 export class ResizableTexture {
   private texture: GPUTexture;
   private textureView: GPUTextureView;
   private size: vec2;
-  private readonly copyPipeline: CopyPipeline;
+  private readonly clearValue: GPUColor;
+  private readonly format: GPUTextureFormat;
+  private readonly usage: GPUTextureUsageFlags;
 
   public constructor(
     private readonly device: GPUDevice,
-    size: vec2
+    size: vec2,
+    {
+      clearValue = { r: 0, g: 0, b: 0, a: 0 },
+      format = TRAIL_SOURCE_TEXTURE_FORMAT,
+      usage = defaultTextureUsage,
+    }: ResizableTextureOptions = {}
   ) {
-    this.copyPipeline = new CopyPipeline(this.device);
-    this.size = size;
+    this.size = vec2.clone(size);
+    this.clearValue = clearValue;
+    this.format = format;
+    this.usage = usage;
     this.texture = this.createTexture(size);
     this.textureView = this.texture.createView();
   }
 
-  public resize(size: vec2): void {
+  public prepareResize(size: vec2): PendingTextureResize | null {
     if (vec2.equals(this.size, size)) {
-      return;
+      return null;
     }
 
     const newTexture = this.createTexture(size);
     const newTextureView = newTexture.createView();
+    const copySize = {
+      width: Math.min(this.size[0], size[0]),
+      height: Math.min(this.size[1], size[1]),
+    };
 
-    const commandEncoder = this.device.createCommandEncoder();
-    this.copyPipeline.execute(
-      commandEncoder,
-      this.textureView,
+    return {
+      copySize,
+      newSize: vec2.clone(size),
+      newTexture,
       newTextureView,
-      vec2.div(vec2.create(), this.size, size)
-    );
-    this.device.queue.submit([commandEncoder.finish()]);
-    this.texture.destroy();
+      oldTexture: this.texture,
+    };
+  }
 
-    this.size = size;
-    this.texture = newTexture;
-    this.textureView = newTextureView;
+  public encodeResize(
+    commandEncoder: GPUCommandEncoder,
+    resize: PendingTextureResize
+  ): void {
+    const clearPass = commandEncoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: resize.newTextureView,
+          clearValue: this.clearValue,
+          loadOp: 'clear',
+          storeOp: 'store',
+        },
+      ],
+    });
+    clearPass.end();
+    commandEncoder.copyTextureToTexture(
+      { texture: resize.oldTexture },
+      { texture: resize.newTexture },
+      resize.copySize
+    );
+  }
+
+  public commitResize(resize: PendingTextureResize): void {
+    resize.oldTexture.destroy();
+    this.size = resize.newSize;
+    this.texture = resize.newTexture;
+    this.textureView = resize.newTextureView;
+  }
+
+  public getSize(): vec2 {
+    return vec2.clone(this.size);
   }
 
   public getTextureView(): GPUTextureView {
     return this.textureView;
   }
 
+  public getTexture(): GPUTexture {
+    return this.texture;
+  }
+
   public destroy(): void {
     this.texture.destroy();
-    this.copyPipeline.destroy();
   }
 
   private createTexture(size: vec2): GPUTexture {
     return this.device.createTexture({
-      format: 'rgba16float',
+      format: this.format,
       size: { width: size[0], height: size[1] },
-      usage:
-        GPUTextureUsage.STORAGE_BINDING |
-        GPUTextureUsage.TEXTURE_BINDING |
-        GPUTextureUsage.RENDER_ATTACHMENT,
+      usage: this.usage,
     });
   }
 }
+
+const defaultTextureUsage =
+  GPUTextureUsage.STORAGE_BINDING |
+  GPUTextureUsage.TEXTURE_BINDING |
+  GPUTextureUsage.RENDER_ATTACHMENT |
+  GPUTextureUsage.COPY_SRC |
+  GPUTextureUsage.COPY_DST;
diff --git a/src/utils/graphics/smart-compile.ts b/src/utils/graphics/smart-compile.ts
index 044ec24..191380e 100644
--- a/src/utils/graphics/smart-compile.ts
+++ b/src/utils/graphics/smart-compile.ts
@@ -10,20 +10,25 @@ export const smartCompile = (
     code: concatenated,
   });
 
-  module.getCompilationInfo().then((info) =>
-    info.messages.forEach((message) =>
+  module.getCompilationInfo().then((info) => {
+    if (info.messages.length === 0) {
+      return;
+    }
+
+    const lines = concatenated.split('\n');
+    info.messages.forEach((message) => {
+      const sourceLine = lines[message.lineNum - 1] ?? '';
+      const fullSource = import.meta.env.DEV ? `\n\nCode:\n${concatenated}\n` : '';
       ErrorHandler.addError(
         {
           info: Severity.INFO,
           warning: Severity.WARNING,
           error: Severity.ERROR,
         }[message.type],
-        `${message.message}\n${
-          concatenated.split('\n')[message.lineNum - 1]
-        }\n\nCode:\n${concatenated}\n`
-      )
-    )
-  );
+        `${message.message}\n${sourceLine}${fullSource}`
+      );
+    });
+  });
 
   return module;
 };
diff --git a/src/utils/hsl.test.ts b/src/utils/hsl.test.ts
deleted file mode 100644
index 458f758..0000000
--- a/src/utils/hsl.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { hsl } from './hsl';
-
-describe('hsl', () => {
-  it('produces pure red at hue 0', () => {
-    const [r, g, b] = hsl(0, 100, 50);
-    expect(r).toBeCloseTo(1);
-    expect(g).toBeCloseTo(0);
-    expect(b).toBeCloseTo(0);
-  });
-  it('produces pure green at hue 120', () => {
-    const [r, g, b] = hsl(120, 100, 50);
-    expect(r).toBeCloseTo(0);
-    expect(g).toBeCloseTo(1);
-    expect(b).toBeCloseTo(0);
-  });
-  it('produces pure blue at hue 240', () => {
-    const [r, g, b] = hsl(240, 100, 50);
-    expect(r).toBeCloseTo(0);
-    expect(g).toBeCloseTo(0);
-    expect(b).toBeCloseTo(1);
-  });
-  it('produces gray at saturation 0', () => {
-    const [r, g, b] = hsl(180, 0, 50);
-    expect(r).toBeCloseTo(0.5);
-    expect(g).toBeCloseTo(0.5);
-    expect(b).toBeCloseTo(0.5);
-  });
-  it('produces black at lightness 0', () => {
-    const [r, g, b] = hsl(0, 100, 0);
-    expect(r).toBe(0);
-    expect(g).toBe(0);
-    expect(b).toBe(0);
-  });
-  it('produces white at lightness 100', () => {
-    const [r, g, b] = hsl(0, 100, 100);
-    expect(r).toBeCloseTo(1);
-    expect(g).toBeCloseTo(1);
-    expect(b).toBeCloseTo(1);
-  });
-});
diff --git a/src/utils/hsl.ts b/src/utils/hsl.ts
deleted file mode 100644
index 89c2d7c..0000000
--- a/src/utils/hsl.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { vec3 } from 'gl-matrix';
-
-import { rgb } from './rgb';
-
-export const hsl = (hue: number, saturation: number, lightness: number): vec3 => {
-  hue /= 360;
-  saturation /= 100;
-  lightness /= 100;
-  let r: number, g: number, b: number;
-
-  if (saturation == 0) {
-    r = g = b = lightness;
-  } else {
-    const hue2rgb = (p: number, q: number, t: number) => {
-      if (t < 0) t += 1;
-      if (t > 1) t -= 1;
-      if (t < 1 / 6) return p + (q - p) * 6 * t;
-      if (t < 1 / 2) return q;
-      if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
-      return p;
-    };
-
-    const q =
-      lightness < 0.5
-        ? lightness * (1 + saturation)
-        : lightness + saturation - lightness * saturation;
-    const p = 2 * lightness - q;
-
-    r = hue2rgb(p, q, hue + 1 / 3);
-    g = hue2rgb(p, q, hue);
-    b = hue2rgb(p, q, hue - 1 / 3);
-  }
-
-  return rgb(r, g, b);
-};
diff --git a/src/utils/math.test.ts b/src/utils/math.test.ts
deleted file mode 100644
index 9b5eeab..0000000
--- a/src/utils/math.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { clamp, clamp01 } from './clamp';
-import { exponentialDecay } from './exponential-decay';
-import { mix } from './mix';
-
-describe('clamp', () => {
-  it('returns value when within bounds', () => {
-    expect(clamp(5, 0, 10)).toBe(5);
-  });
-  it('clamps below to lower bound', () => {
-    expect(clamp(-3, 0, 10)).toBe(0);
-  });
-  it('clamps above to upper bound', () => {
-    expect(clamp(42, 0, 10)).toBe(10);
-  });
-});
-
-describe('clamp01', () => {
-  it('passes through values in [0, 1]', () => {
-    expect(clamp01(0.25)).toBe(0.25);
-  });
-  it('clamps negatives to 0', () => {
-    expect(clamp01(-1)).toBe(0);
-  });
-  it('clamps above 1 to 1', () => {
-    expect(clamp01(2)).toBe(1);
-  });
-});
-
-describe('mix', () => {
-  it('returns from at q=0', () => {
-    expect(mix(10, 20, 0)).toBe(10);
-  });
-  it('returns to at q=1', () => {
-    expect(mix(10, 20, 1)).toBe(20);
-  });
-  it('interpolates at q=0.5', () => {
-    expect(mix(10, 20, 0.5)).toBe(15);
-  });
-  it('extrapolates outside [0, 1]', () => {
-    expect(mix(0, 10, 2)).toBe(20);
-    expect(mix(0, 10, -1)).toBe(-10);
-  });
-});
-
-describe('exponentialDecay', () => {
-  it('returns nextValue when bias is 1', () => {
-    expect(exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 1 })).toBe(
-      10
-    );
-  });
-  it('returns accumulator when bias is 0', () => {
-    expect(exponentialDecay({ accumulator: 5, nextValue: 10, biasOfNextValue: 0 })).toBe(
-      5
-    );
-  });
-  it('blends with given bias', () => {
-    expect(
-      exponentialDecay({ accumulator: 0, nextValue: 10, biasOfNextValue: 0.25 })
-    ).toBe(2.5);
-  });
-});
diff --git a/src/utils/math.ts b/src/utils/math.ts
new file mode 100644
index 0000000..5937e8a
--- /dev/null
+++ b/src/utils/math.ts
@@ -0,0 +1,29 @@
+export const clamp = (value: number, min: number, max: number): number =>
+  Math.min(max, Math.max(min, value));
+
+export const clamp01 = (value: number): number => clamp(value, 0, 1);
+
+export const mix = (from: number, to: number, amount: number): number =>
+  from + (to - from) * amount;
+
+export const mixAngle = (from: number, to: number, amount: number): number => {
+  const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
+  return from + delta * amount;
+};
+
+export const approach = (
+  current: number,
+  target: number,
+  elapsedSeconds: number,
+  timeConstantSeconds: number
+): number => {
+  const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
+  return mix(current, target, amount);
+};
+
+export const smoothstep = (edge0: number, edge1: number, value: number): number => {
+  const amount = clamp01((value - edge0) / (edge1 - edge0));
+  return amount * amount * (3 - 2 * amount);
+};
+
+export const easeOutQuad = (value: number): number => value * (2 - value);
diff --git a/src/utils/mix.ts b/src/utils/mix.ts
deleted file mode 100644
index 16a76ed..0000000
--- a/src/utils/mix.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const mix = (from: number, to: number, q: number) => from + (to - from) * q;
diff --git a/src/utils/persist.ts b/src/utils/persist.ts
deleted file mode 100644
index 4489458..0000000
--- a/src/utils/persist.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-export const persist = >(wrapee: T): T => {
-  const keys = Object.keys(wrapee);
-  keys.sort();
-
-  const keysToShortKeys = Object.fromEntries(keys.map((key) => [key, key]));
-
-  const params = new URLSearchParams(window.location.search);
-  const newParams = new URLSearchParams();
-  keys.forEach((key) => {
-    if (params.has(keysToShortKeys[key])) {
-      (wrapee as any)[key] = Number(params.get(keysToShortKeys[key]));
-      newParams.set(keysToShortKeys[key], params.get(keysToShortKeys[key])!);
-    }
-  });
-
-  window.history.replaceState(
-    {},
-    '',
-    `${window.location.pathname}?${newParams.toString()}`
-  );
-
-  return new Proxy(wrapee, {
-    set: (target, key: string, value: number) => {
-      const params = new URLSearchParams(window.location.search);
-
-      params.set(keysToShortKeys[key], value.toString());
-
-      (target as any)[key] = value;
-
-      window.history.replaceState(
-        {},
-        '',
-        `${window.location.pathname}?${params.toString()}`
-      );
-
-      return true;
-    },
-  });
-};
diff --git a/src/utils/random.test.ts b/src/utils/random.test.ts
deleted file mode 100644
index 3d28634..0000000
--- a/src/utils/random.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { beforeEach, describe, expect, it } from 'vitest';
-
-import { Random } from './random';
-
-describe('Random', () => {
-  beforeEach(() => {
-    Random.seed = 42;
-  });
-
-  it('produces values in [0, 1)', () => {
-    for (let i = 0; i < 1000; i++) {
-      const v = Random.getRandom();
-      expect(v).toBeGreaterThanOrEqual(0);
-      expect(v).toBeLessThan(1);
-    }
-  });
-
-  it('is deterministic for the same seed', () => {
-    Random.seed = 42;
-    const a = Array.from({ length: 8 }, () => Random.getRandom());
-    Random.seed = 42;
-    const b = Array.from({ length: 8 }, () => Random.getRandom());
-    expect(a).toEqual(b);
-  });
-
-  it('produces different sequences for different seeds', () => {
-    Random.seed = 1;
-    const a = Array.from({ length: 4 }, () => Random.getRandom());
-    Random.seed = 2;
-    const b = Array.from({ length: 4 }, () => Random.getRandom());
-    expect(a).not.toEqual(b);
-  });
-
-  it('randomBetween stays within [from, to)', () => {
-    for (let i = 0; i < 1000; i++) {
-      const v = Random.randomBetween(-10, 10);
-      expect(v).toBeGreaterThanOrEqual(-10);
-      expect(v).toBeLessThan(10);
-    }
-  });
-});
diff --git a/src/utils/random.ts b/src/utils/random.ts
deleted file mode 100644
index 2d7e258..0000000
--- a/src/utils/random.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-// https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript, Mulberry32
-export abstract class Random {
-  private static _seed = 42;
-
-  public static set seed(value: number) {
-    Random._seed = value;
-  }
-
-  public static getRandomInt(): number {
-    let t = (Random._seed += 0x6d2b79f5);
-    t = Math.imul(t ^ (t >>> 15), t | 1);
-    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
-    return (t ^ (t >>> 14)) >>> 0;
-  }
-
-  public static getRandom(): number {
-    return Random.getRandomInt() / 4294967296;
-  }
-
-  public static randomBetween(from: number, to: number): number {
-    return from + Random.getRandom() * (to - from);
-  }
-}
diff --git a/src/utils/rgb-color.ts b/src/utils/rgb-color.ts
new file mode 100644
index 0000000..aa9be3f
--- /dev/null
+++ b/src/utils/rgb-color.ts
@@ -0,0 +1,42 @@
+export type RgbColor = [red: number, green: number, blue: number];
+
+const RGB_CHANNEL_MAX = 255;
+
+const toFiniteRgbChannel = (value: number): number =>
+  Number.isFinite(value) ? value : 0;
+
+const clampRgbChannel = (value: number): number =>
+  Math.min(RGB_CHANNEL_MAX, Math.max(0, Math.round(toFiniteRgbChannel(value))));
+
+export const rgbColorToCss = ([red, green, blue]: RgbColor): string =>
+  `rgb(${clampRgbChannel(red)}, ${clampRgbChannel(green)}, ${clampRgbChannel(blue)})`;
+
+export const rgbColorToHex = ([red, green, blue]: RgbColor): string =>
+  `#${[red, green, blue]
+    .map((channel) => clampRgbChannel(channel).toString(16).padStart(2, '0'))
+    .join('')}`;
+
+export const hexColorToRgbColor = (value: string): RgbColor | null => {
+  const match = value.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
+  if (!match) {
+    return null;
+  }
+
+  const shorthandOrHex = match[1];
+  const hex =
+    shorthandOrHex.length === 3
+      ? shorthandOrHex
+          .split('')
+          .map((channel) => `${channel}${channel}`)
+          .join('')
+      : shorthandOrHex;
+
+  return [
+    Number.parseInt(hex.slice(0, 2), 16),
+    Number.parseInt(hex.slice(2, 4), 16),
+    Number.parseInt(hex.slice(4, 6), 16),
+  ];
+};
+
+export const rgbChannelToUnit = (value: number): number =>
+  Math.min(1, Math.max(0, toFiniteRgbChannel(value) / RGB_CHANNEL_MAX));
diff --git a/src/utils/rgb.ts b/src/utils/rgb.ts
deleted file mode 100644
index a6ef20a..0000000
--- a/src/utils/rgb.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { vec3 } from 'gl-matrix';
-
-export const rgb = (r: number, g: number, b: number): vec3 => vec3.fromValues(r, g, b);
diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts
deleted file mode 100644
index b358bfa..0000000
--- a/src/utils/sleep.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const sleep = (ms: number): Promise => {
-  return new Promise((resolve, _) => setTimeout(resolve, ms));
-};
diff --git a/src/vibe-registry.ts b/src/vibe-registry.ts
new file mode 100644
index 0000000..0b0ae54
--- /dev/null
+++ b/src/vibe-registry.ts
@@ -0,0 +1,7 @@
+import { appConfig } from './config';
+import type { VibeId, VibePreset } from './config/types';
+
+export const VIBE_PRESETS: Array = appConfig.vibes.presets;
+
+export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
+  VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
diff --git a/src/vibe-uri.test.ts b/src/vibe-uri.test.ts
new file mode 100644
index 0000000..72c04f2
--- /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
new file mode 100644
index 0000000..95d1f7c
--- /dev/null
+++ b/src/vibes.ts
@@ -0,0 +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 };
+
+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 storedOrLegacyVibeId = isVibeId(storedVibeId)
+    ? storedVibeId
+    : getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
+  const initialVibeId =
+    uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
+
+  return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
+};
diff --git a/tsconfig.json b/tsconfig.json
index d2eb3bf..fbd104c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,14 +9,13 @@
     "isolatedModules": true,
     "noEmit": true,
     "skipLibCheck": true,
-    "resolveJsonModule": true,
     "esModuleInterop": true,
     "allowSyntheticDefaultImports": true,
     "forceConsistentCasingInFileNames": true,
 
     "strict": true,
-    "noUnusedLocals": false,
-    "noUnusedParameters": false
+    "noUnusedLocals": true,
+    "noUnusedParameters": true
   },
-  "include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
+  "include": ["src/**/*", "pwa-assets.config.ts", "vite.config.ts"]
 }
diff --git a/tsconfig.playwright.json b/tsconfig.playwright.json
new file mode 100644
index 0000000..fca3dfe
--- /dev/null
+++ b/tsconfig.playwright.json
@@ -0,0 +1,7 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "types": ["node"]
+  },
+  "include": ["playwright.config.ts", "e2e/**/*.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
index 7215fa6..b063e3c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,12 +1,15 @@
+import basicSsl from '@vitejs/plugin-basic-ssl';
 import browserslist from 'browserslist';
+import browserslistToEsbuild from 'browserslist-to-esbuild';
 import { browserslistToTargets } from 'lightningcss';
-import { defineConfig } from 'vitest/config';
 import { viteSingleFile } from 'vite-plugin-singlefile';
+import { defineConfig } from 'vitest/config';
 
 const cssTargets = browserslistToTargets(browserslist());
+const esbuildTargets = browserslistToEsbuild();
 
-export default defineConfig({
-  plugins: [viteSingleFile()],
+export default defineConfig(({ command }) => ({
+  plugins: [viteSingleFile(), ...(command === 'serve' ? [basicSsl()] : [])],
   css: {
     transformer: 'lightningcss',
     lightningcss: {
@@ -14,16 +17,14 @@ export default defineConfig({
     },
   },
   build: {
-    target: 'es2022',
-    cssCodeSplit: false,
+    target: esbuildTargets,
     cssMinify: 'lightningcss',
-    assetsInlineLimit: Number.MAX_SAFE_INTEGER,
   },
   server: {
-    open: true,
+    host: true,
   },
   test: {
     environment: 'node',
     include: ['src/**/*.test.ts'],
   },
-});
+}));