diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 232fbf9..93748c6 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -27,18 +27,36 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + - name: Lint - run: npm run lint -- --check || true + run: npm run lint - name: Typecheck run: npm run typecheck - - name: Build - run: npm run build + - name: Typecheck browser tests + run: npm run typecheck:e2e + + - name: Test + run: npm test + + - name: Browser tests + run: npm run test:e2e + + - 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 diff --git a/.gitignore b/.gitignore index 14d1e17..916a63e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ ts-node--*/ rss.xml dist +playwright-report +test-results # Logs logs diff --git a/README.md b/README.md index a55e624..1a7466c 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 a 4K wallpaper. -## 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` builds the production bundle and runs the Playwright Chromium + smoke test. +- `npx playwright install chromium` installs the local browser binary when needed. diff --git a/assets/icons/download.svg b/assets/icons/download.svg new file mode 100644 index 0000000..f880e05 --- /dev/null +++ b/assets/icons/download.svg @@ -0,0 +1,10 @@ + + + diff --git a/assets/icons/sound.svg b/assets/icons/sound.svg new file mode 100644 index 0000000..78dbb2b --- /dev/null +++ b/assets/icons/sound.svg @@ -0,0 +1,3 @@ + + + diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..e42de2f --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'gpu', { + configurable: true, + value: undefined, + }); + }); + + await page.goto('/'); + + await expect(page).toHaveTitle('Fleeting Garden'); + await expect( + page.getByRole('img', { name: 'Interactive generative garden canvas' }) + ).toBeVisible(); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); + + await page.getByRole('button', { name: 'About' }).click(); + await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); +}); diff --git a/index.html b/index.html index 17b5847..1957196 100644 --- a/index.html +++ b/index.html @@ -6,16 +6,16 @@ name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" /> - + - + @@ -27,55 +27,175 @@ - 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, adjust the + config overlay, export, restart, or open more information. +

+ +
+ +
+ +
Starting up…
+
+
+
+
- diff --git a/package-lock.json b/package-lock.json index fc94ae1..7c9269f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,31 @@ { - "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" + "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", "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", "lightningcss": "^1.32.0", "npm-check-updates": "^22.1.0", @@ -1258,6 +1262,22 @@ "url": "https://opencollective.com/pkgr" } }, + "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": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@quansync/fs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", @@ -1560,6 +1580,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 +1901,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", @@ -2740,6 +2780,7 @@ "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": { @@ -3437,6 +3478,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", @@ -3831,6 +3919,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", diff --git a/package.json b/package.json index ce5a93e..f0b074d 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,25 @@ { - "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": "A WebGPU drawing garden where coloured paths grow into organic agent trails.", "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": "npm run lint:check", + "lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check", + "lint:fix": "eslint --fix \"src/**/*.ts\"", + "format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", + "format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"scripts/**/*.mjs\" \"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": "npm run build && playwright test", + "test:e2e:ui": "npm run build && playwright test --ui", "test:watch": "vitest", + "unused:check": "node scripts/check-unused-exports.mjs", "generate-icons": "pwa-assets-generator", "update": "ncu" }, @@ -33,20 +41,21 @@ "browserslist": [ "supports webgpu and last 2 years" ], - "dependencies": { - "gl-matrix": "^3.4.4" - }, "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", "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", "lightningcss": "^1.32.0", "npm-check-updates": "^22.1.0", @@ -57,5 +66,8 @@ "vite": "^8.0.10", "vite-plugin-singlefile": "^2.3.3", "vitest": "^4.1.5" + }, + "dependencies": { + "tweakpane": "^4.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e252a37 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +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: isCi ? 1 : undefined, + reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + ignoreHTTPSErrors: true, + trace: 'on-first-retry', + }, + webServer: { + command: `npm run preview -- --host 127.0.0.1 --port ${port}`, + ignoreHTTPSErrors: true, + reuseExistingServer: !isCi, + timeout: 120_000, + url: baseURL, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png index b8bfae3..78ea11e 100644 Binary files a/public/apple-touch-icon-180x180.png and b/public/apple-touch-icon-180x180.png differ diff --git a/public/audio/piano/A0v12.m4a b/public/audio/piano/A0v12.m4a new file mode 100644 index 0000000..db06fc3 Binary files /dev/null and b/public/audio/piano/A0v12.m4a differ diff --git a/public/audio/piano/A1v12.m4a b/public/audio/piano/A1v12.m4a new file mode 100644 index 0000000..f1ed488 Binary files /dev/null and b/public/audio/piano/A1v12.m4a differ diff --git a/public/audio/piano/A2v12.m4a b/public/audio/piano/A2v12.m4a new file mode 100644 index 0000000..52df725 Binary files /dev/null and b/public/audio/piano/A2v12.m4a differ diff --git a/public/audio/piano/A3v12.m4a b/public/audio/piano/A3v12.m4a new file mode 100644 index 0000000..707a766 Binary files /dev/null and b/public/audio/piano/A3v12.m4a differ diff --git a/public/audio/piano/A4v12.m4a b/public/audio/piano/A4v12.m4a new file mode 100644 index 0000000..679bcff Binary files /dev/null and b/public/audio/piano/A4v12.m4a differ diff --git a/public/audio/piano/A5v12.m4a b/public/audio/piano/A5v12.m4a new file mode 100644 index 0000000..4a2c896 Binary files /dev/null and b/public/audio/piano/A5v12.m4a differ diff --git a/public/audio/piano/A6v12.m4a b/public/audio/piano/A6v12.m4a new file mode 100644 index 0000000..abbd605 Binary files /dev/null and b/public/audio/piano/A6v12.m4a differ diff --git a/public/audio/piano/A7v12.m4a b/public/audio/piano/A7v12.m4a new file mode 100644 index 0000000..3fd6829 Binary files /dev/null and b/public/audio/piano/A7v12.m4a differ diff --git a/public/audio/piano/C1v12.m4a b/public/audio/piano/C1v12.m4a new file mode 100644 index 0000000..59d5f61 Binary files /dev/null and b/public/audio/piano/C1v12.m4a differ diff --git a/public/audio/piano/C2v12.m4a b/public/audio/piano/C2v12.m4a new file mode 100644 index 0000000..9b636f9 Binary files /dev/null and b/public/audio/piano/C2v12.m4a differ diff --git a/public/audio/piano/C3v12.m4a b/public/audio/piano/C3v12.m4a new file mode 100644 index 0000000..e891e16 Binary files /dev/null and b/public/audio/piano/C3v12.m4a differ diff --git a/public/audio/piano/C4v12.m4a b/public/audio/piano/C4v12.m4a new file mode 100644 index 0000000..6061dc5 Binary files /dev/null and b/public/audio/piano/C4v12.m4a differ diff --git a/public/audio/piano/C5v12.m4a b/public/audio/piano/C5v12.m4a new file mode 100644 index 0000000..a6d8898 Binary files /dev/null and b/public/audio/piano/C5v12.m4a differ diff --git a/public/audio/piano/C6v12.m4a b/public/audio/piano/C6v12.m4a new file mode 100644 index 0000000..745a4d6 Binary files /dev/null and b/public/audio/piano/C6v12.m4a differ diff --git a/public/audio/piano/C7v12.m4a b/public/audio/piano/C7v12.m4a new file mode 100644 index 0000000..6470854 Binary files /dev/null and b/public/audio/piano/C7v12.m4a differ diff --git a/public/audio/piano/C8v12.m4a b/public/audio/piano/C8v12.m4a new file mode 100644 index 0000000..dfbbfd1 Binary files /dev/null and b/public/audio/piano/C8v12.m4a differ diff --git a/public/audio/piano/Dsharp1v12.m4a b/public/audio/piano/Dsharp1v12.m4a new file mode 100644 index 0000000..22d0924 Binary files /dev/null and b/public/audio/piano/Dsharp1v12.m4a differ diff --git a/public/audio/piano/Dsharp2v12.m4a b/public/audio/piano/Dsharp2v12.m4a new file mode 100644 index 0000000..f25db22 Binary files /dev/null and b/public/audio/piano/Dsharp2v12.m4a differ diff --git a/public/audio/piano/Dsharp3v12.m4a b/public/audio/piano/Dsharp3v12.m4a new file mode 100644 index 0000000..7e09558 Binary files /dev/null and b/public/audio/piano/Dsharp3v12.m4a differ diff --git a/public/audio/piano/Dsharp4v12.m4a b/public/audio/piano/Dsharp4v12.m4a new file mode 100644 index 0000000..d670fbb Binary files /dev/null and b/public/audio/piano/Dsharp4v12.m4a differ diff --git a/public/audio/piano/Dsharp5v12.m4a b/public/audio/piano/Dsharp5v12.m4a new file mode 100644 index 0000000..cdbd7b8 Binary files /dev/null and b/public/audio/piano/Dsharp5v12.m4a differ diff --git a/public/audio/piano/Dsharp6v12.m4a b/public/audio/piano/Dsharp6v12.m4a new file mode 100644 index 0000000..b5ff787 Binary files /dev/null and b/public/audio/piano/Dsharp6v12.m4a differ diff --git a/public/audio/piano/Dsharp7v12.m4a b/public/audio/piano/Dsharp7v12.m4a new file mode 100644 index 0000000..a9b6cda Binary files /dev/null and b/public/audio/piano/Dsharp7v12.m4a differ diff --git a/public/audio/piano/Fsharp1v12.m4a b/public/audio/piano/Fsharp1v12.m4a new file mode 100644 index 0000000..752590f Binary files /dev/null and b/public/audio/piano/Fsharp1v12.m4a differ diff --git a/public/audio/piano/Fsharp2v12.m4a b/public/audio/piano/Fsharp2v12.m4a new file mode 100644 index 0000000..3477cb8 Binary files /dev/null and b/public/audio/piano/Fsharp2v12.m4a differ diff --git a/public/audio/piano/Fsharp3v12.m4a b/public/audio/piano/Fsharp3v12.m4a new file mode 100644 index 0000000..d36f8dd Binary files /dev/null and b/public/audio/piano/Fsharp3v12.m4a differ diff --git a/public/audio/piano/Fsharp4v12.m4a b/public/audio/piano/Fsharp4v12.m4a new file mode 100644 index 0000000..21df1e2 Binary files /dev/null and b/public/audio/piano/Fsharp4v12.m4a differ diff --git a/public/audio/piano/Fsharp5v12.m4a b/public/audio/piano/Fsharp5v12.m4a new file mode 100644 index 0000000..1105dfb Binary files /dev/null and b/public/audio/piano/Fsharp5v12.m4a differ diff --git a/public/audio/piano/Fsharp6v12.m4a b/public/audio/piano/Fsharp6v12.m4a new file mode 100644 index 0000000..d141d41 Binary files /dev/null and b/public/audio/piano/Fsharp6v12.m4a differ diff --git a/public/audio/piano/Fsharp7v12.m4a b/public/audio/piano/Fsharp7v12.m4a new file mode 100644 index 0000000..d69ac59 Binary files /dev/null and b/public/audio/piano/Fsharp7v12.m4a differ diff --git a/public/audio/piano/README.md b/public/audio/piano/README.md new file mode 100644 index 0000000..ab6ee8c --- /dev/null +++ b/public/audio/piano/README.md @@ -0,0 +1,7 @@ +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/ diff --git a/public/favicon.ico b/public/favicon.ico index e545180..5307896 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..1c0ddbe 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..d2d918b 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,38 +1,38 @@ { - "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": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.", + "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" }, { - "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..a7224f9 100644 Binary files a/public/maskable-icon-512x512.png and b/public/maskable-icon-512x512.png differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png index 667a104..22bb33e 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..0809d42 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..7a20069 100644 Binary files a/public/pwa-64x64.png and b/public/pwa-64x64.png differ diff --git a/scripts/check-unused-exports.mjs b/scripts/check-unused-exports.mjs new file mode 100644 index 0000000..6114bba --- /dev/null +++ b/scripts/check-unused-exports.mjs @@ -0,0 +1,185 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const projectRoot = process.cwd(); +const sourceRoot = path.join(projectRoot, 'src'); + +const toPosix = (value) => value.split(path.sep).join('/'); + +const listTypeScriptFiles = (directory) => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return listTypeScriptFiles(entryPath); + } + return entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts') + ? [entryPath] + : []; + }); + +const files = listTypeScriptFiles(sourceRoot); +const fileSet = new Set(files.map((file) => path.resolve(file))); + +const resolveModule = (fromFile, specifier) => { + if (!specifier.startsWith('.')) { + return null; + } + + const base = path.resolve(path.dirname(fromFile), specifier); + const candidates = [ + `${base}.ts`, + path.join(base, 'index.ts'), + base.endsWith('.ts') ? base : null, + ].filter(Boolean); + + return candidates.find((candidate) => existsSync(candidate) && fileSet.has(candidate)) ?? null; +}; + +const exportKey = (file, name) => `${path.resolve(file)}:${name}`; +const isExported = (node) => + ts.canHaveModifiers(node) && + (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); +const isDefaultExported = (node) => + ts.canHaveModifiers(node) && + (ts.getModifiers(node) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword); + +const exportedDeclarations = new Map(); +const usedExports = new Set(); +const wildcardUsedFiles = new Set(); + +const markUsed = (fromFile, name) => { + usedExports.add(exportKey(fromFile, name)); +}; + +const collectImportUsage = (file, sourceFile) => { + sourceFile.forEachChild((node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const resolved = resolveModule(file, node.moduleSpecifier.text); + if (!resolved || !node.importClause) { + return; + } + + if (node.importClause.name) { + markUsed(resolved, 'default'); + } + + const namedBindings = node.importClause.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + namedBindings.elements.forEach((element) => { + markUsed(resolved, (element.propertyName ?? element.name).text); + }); + } else if (namedBindings && ts.isNamespaceImport(namedBindings)) { + wildcardUsedFiles.add(path.resolve(resolved)); + } + return; + } + + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const resolved = resolveModule(file, node.moduleSpecifier.text); + if (!resolved) { + return; + } + + if (!node.exportClause) { + wildcardUsedFiles.add(path.resolve(resolved)); + return; + } + + if (ts.isNamedExports(node.exportClause)) { + node.exportClause.elements.forEach((element) => { + markUsed(resolved, (element.propertyName ?? element.name).text); + }); + } + } + }); +}; + +const collectExportedDeclarations = (file, sourceFile) => { + if (file.endsWith('.test.ts')) { + return; + } + + sourceFile.forEachChild((node) => { + if (ts.isVariableStatement(node) && isExported(node)) { + node.declarationList.declarations.forEach((declaration) => { + if (ts.isIdentifier(declaration.name)) { + exportedDeclarations.set(exportKey(file, declaration.name.text), { + file, + name: declaration.name.text, + }); + } + }); + return; + } + + if ( + (ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node)) && + isExported(node) + ) { + if (isDefaultExported(node)) { + exportedDeclarations.set(exportKey(file, 'default'), { file, name: 'default' }); + return; + } + + if (node.name) { + exportedDeclarations.set(exportKey(file, node.name.text), { + file, + name: node.name.text, + }); + } + return; + } + + if ( + ts.isExportDeclaration(node) && + !node.moduleSpecifier && + node.exportClause && + ts.isNamedExports(node.exportClause) + ) { + node.exportClause.elements.forEach((element) => { + exportedDeclarations.set(exportKey(file, element.name.text), { + file, + name: element.name.text, + }); + }); + } + }); +}; + +const parsedFiles = files.map((file) => ({ + file, + sourceFile: ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ), +})); + +parsedFiles.forEach(({ file, sourceFile }) => collectImportUsage(file, sourceFile)); +parsedFiles.forEach(({ file, sourceFile }) => collectExportedDeclarations(file, sourceFile)); + +const unusedExports = Array.from(exportedDeclarations.entries()) + .filter(([key, declaration]) => !usedExports.has(key) && !wildcardUsedFiles.has(declaration.file)) + .map(([, declaration]) => declaration) + .sort((left, right) => + `${left.file}:${left.name}`.localeCompare(`${right.file}:${right.name}`) + ); + +if (unusedExports.length > 0) { + console.error('Unused exported declarations found:'); + unusedExports.forEach(({ file, name }) => { + console.error(`- ${toPosix(path.relative(projectRoot, file))}: ${name}`); + }); + process.exitCode = 1; +} diff --git a/src/audio/garden-audio-config.ts b/src/audio/garden-audio-config.ts new file mode 100644 index 0000000..c7a2c99 --- /dev/null +++ b/src/audio/garden-audio-config.ts @@ -0,0 +1,71 @@ +import { appConfig } from '../config'; + +type GardenAudioChordQuality = 'major' | 'minor'; + +export interface GardenAudioChord { + rootOffset: number; + quality: GardenAudioChordQuality; +} + +interface GardenAudioColorVoice { + scaleDegreeOffset: number; + velocityMultiplier: number; + panOffset: number; +} + +export interface GardenAudioVibeProfile { + rootMidi: number; + scale: Array; + brightness: number; + delayTimeMultiplier: number; + progression: Array; +} + +export interface GardenAudioConfig { + masterVolume: number; + fadeInSeconds: number; + updateRampSeconds: number; + highPassFrequencyHz: number; + fallbackVibeId: string; + compressor: { + thresholdDb: number; + kneeDb: number; + ratio: number; + attackSeconds: number; + releaseSeconds: number; + }; + delay: { + timeSeconds: number; + feedback: number; + wetGain: number; + }; + piano: { + maxVoices: number; + gain: number; + sustainSeconds: number; + sustainLevel: number; + releaseSeconds: number; + lowpassHz: number; + }; + input: { + pressureFallback: number; + }; + rhythm: { + bpm: number; + stepsPerBeat: number; + stepsPerBar: number; + lookaheadSeconds: number; + speedForFullEnergyPixelsPerSecond: number; + sparseActivity: number; + }; + eraser: { + minIntervalSeconds: number; + noiseGain: number; + filterMinHz: number; + filterMaxHz: number; + }; + colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice]; + vibes: Record; +} + +export const gardenAudioConfig: GardenAudioConfig = appConfig.audio; diff --git a/src/audio/garden-audio-energy.test.ts b/src/audio/garden-audio-energy.test.ts new file mode 100644 index 0000000..b82c074 --- /dev/null +++ b/src/audio/garden-audio-energy.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { GardenAudioEnergy } from './garden-audio-energy'; + +describe('GardenAudioEnergy', () => { + it('suspends activity but keeps a fading level when the gesture ends', () => { + const energy = new GardenAudioEnergy(); + + energy.beginGesture(0); + energy.recordStroke(0.8, 0.1); + energy.update(0.1); + energy.update(0.2); + + const levelBeforeLift = energy.getLevel(); + expect(energy.getActivity()).toBeGreaterThan(0); + + energy.endGesture(); + + expect(energy.getActivity()).toBe(0); + expect(energy.getLevel()).toBe(levelBeforeLift); + energy.update(0.3); + expect(energy.getLevel()).toBeLessThan(levelBeforeLift); + expect(energy.getLevel()).toBeGreaterThan(0); + }); + + it('uses recent stroke intensity rather than gesture duration alone', () => { + const energy = new GardenAudioEnergy(); + + energy.beginGesture(0); + energy.recordStroke(1, 0.1); + energy.update(0.1); + energy.update(0.2); + const activeLevel = energy.getActivity(); + + energy.update(1.2); + + expect(energy.getActivity()).toBeLessThan(activeLevel); + }); + + it('raises activity immediately when a stroke is recorded', () => { + const energy = new GardenAudioEnergy(); + + energy.beginGesture(0); + energy.recordStroke(0.12, 0.05); + + expect(energy.getActivity()).toBeGreaterThan(0.09); + }); +}); diff --git a/src/audio/garden-audio-energy.ts b/src/audio/garden-audio-energy.ts new file mode 100644 index 0000000..99bbc0d --- /dev/null +++ b/src/audio/garden-audio-energy.ts @@ -0,0 +1,83 @@ +import type { GardenAudioEngineConfig } from '../config'; +import { clamp01 } from '../utils/clamp'; + +const STROKE_IMMEDIATE_ACTIVITY_SCALE = 0.85; + +export class GardenAudioEnergy { + private isGestureActive = false; + private energy = 0; + private targetEnergy = 0; + private lastEnergyUpdateAt = 0; + + public constructor(private readonly engineConfig: GardenAudioEngineConfig) {} + + public beginGesture(now: number): void { + this.isGestureActive = true; + this.lastEnergyUpdateAt = now; + } + + public endGesture(): void { + this.isGestureActive = false; + this.targetEnergy = 0; + } + + public recordStroke(strokeEnergy: number, now: number): void { + const energy = clamp01(strokeEnergy); + this.targetEnergy = Math.max(this.targetEnergy, energy); + if (this.isGestureActive) { + this.energy = Math.max(this.energy, energy * STROKE_IMMEDIATE_ACTIVITY_SCALE); + } + this.lastEnergyUpdateAt ||= now; + } + + public recordEraserStroke(): void { + this.targetEnergy = 0; + } + + public silence(): void { + this.targetEnergy = 0; + this.energy = 0; + } + + public update(now: number): void { + if (this.lastEnergyUpdateAt <= 0) { + this.lastEnergyUpdateAt = now; + return; + } + + const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt); + this.lastEnergyUpdateAt = now; + this.targetEnergy *= Math.exp( + -elapsedSeconds / this.engineConfig.energy.strokeDecaySeconds + ); + + const target = this.isGestureActive ? this.targetEnergy : 0; + let timeConstant = this.engineConfig.energy.decaySeconds; + if (!this.isGestureActive) { + timeConstant = this.engineConfig.energy.releaseSeconds; + } else if (target > this.energy) { + timeConstant = this.engineConfig.energy.attackSeconds; + } + const amount = 1 - Math.exp(-elapsedSeconds / timeConstant); + this.energy += (target - this.energy) * amount; + } + + public getActivity(): number { + if (!this.isGestureActive) { + return 0; + } + + return this.getLevel(); + } + + 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-graph.ts b/src/audio/garden-audio-graph.ts new file mode 100644 index 0000000..d4bb9ae --- /dev/null +++ b/src/audio/garden-audio-graph.ts @@ -0,0 +1,206 @@ +import type { GardenAudioEngineConfig } from '../config'; +import { clamp } from '../utils/clamp'; +import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config'; + +export class GardenAudioGraph { + public context: AudioContext | null = null; + public eventBus: GainNode | null = null; + public delayInput: 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 hasUnlocked = false; + + public constructor( + private readonly config: GardenAudioConfig, + private readonly engineConfig: GardenAudioEngineConfig + ) {} + + public ensureContext(canCreate: boolean): AudioContext | null { + if (this.context) { + return this.context; + } + + if (!canCreate) { + return null; + } + + const context = new AudioContext({ latencyHint: 'interactive' }); + const masterGain = context.createGain(); + const highPass = context.createBiquadFilter(); + const compressor = context.createDynamicsCompressor(); + + masterGain.gain.value = 0; + highPass.type = 'highpass'; + highPass.frequency.value = this.config.highPassFrequencyHz; + compressor.threshold.value = this.config.compressor.thresholdDb; + compressor.knee.value = this.config.compressor.kneeDb; + compressor.ratio.value = this.config.compressor.ratio; + compressor.attack.value = this.config.compressor.attackSeconds; + compressor.release.value = this.config.compressor.releaseSeconds; + + masterGain.connect(highPass); + highPass.connect(compressor); + compressor.connect(context.destination); + + this.context = context; + this.masterGain = masterGain; + this.noiseBuffer = this.createNoiseBuffer(context); + this.createDelay(context, masterGain); + this.createBuses(context, masterGain); + + return context; + } + + // iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once + // a buffer source has been started inside a user-gesture handler. Calling + // resume() alone leaves the context "running" but silent. + public unlock(): void { + if (!this.context || this.hasUnlocked) { + return; + } + + const buffer = this.context.createBuffer( + 1, + this.engineConfig.graph.unlockBufferLength, + this.engineConfig.graph.unlockSampleRate + ); + const source = this.context.createBufferSource(); + source.buffer = buffer; + source.connect(this.context.destination); + source.start(0); + this.hasUnlocked = true; + } + + public setMasterGain(targetGain: number, timeConstantSeconds: number): void { + if (!this.context || !this.masterGain) { + return; + } + + this.masterGain.gain.setTargetAtTime( + targetGain, + this.context.currentTime, + timeConstantSeconds + ); + } + + public applyDelayProfile(profile: GardenAudioVibeProfile): void { + if (!this.context || !this.delayNode) { + return; + } + + this.delayNode.delayTime.setTargetAtTime( + this.config.delay.timeSeconds * profile.delayTimeMultiplier, + this.context.currentTime, + this.engineConfig.graph.delayTimeRampSeconds + ); + } + + public updateDelay(profile: GardenAudioVibeProfile, activity: number): void { + if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { + return; + } + + const now = this.context.currentTime; + this.delayNode.delayTime.setTargetAtTime( + this.config.delay.timeSeconds * profile.delayTimeMultiplier, + now, + this.engineConfig.graph.delayTimeRampSeconds + ); + this.delayFeedback.gain.setTargetAtTime( + clamp( + this.config.delay.feedback + + activity * this.engineConfig.graph.delayActivityFeedbackWeight, + this.engineConfig.graph.delayFeedbackMin, + this.engineConfig.graph.delayFeedbackMax + ), + now, + this.config.updateRampSeconds + ); + this.delayOutput.gain.setTargetAtTime( + this.config.delay.wetGain * + (this.engineConfig.graph.delayOutputBase + + activity * this.engineConfig.graph.delayOutputActivityWeight), + now, + this.config.updateRampSeconds + ); + } + + public async close(): Promise { + const context = this.context; + if (!context) { + return; + } + + if (this.masterGain && context.state !== 'closed') { + this.masterGain.gain.setTargetAtTime( + this.engineConfig.graph.closeGain, + context.currentTime, + this.engineConfig.graph.closeRampSeconds + ); + } + + this.clearNodes(); + + if (context.state !== 'closed') { + await context.close().catch(() => undefined); + } + } + + private createDelay(context: AudioContext, masterGain: GainNode): void { + const delayInput = context.createGain(); + const delayNode = context.createDelay(2); + const delayFeedback = context.createGain(); + const delayOutput = context.createGain(); + + delayNode.delayTime.value = this.config.delay.timeSeconds; + delayFeedback.gain.value = this.config.delay.feedback; + delayOutput.gain.value = this.config.delay.wetGain; + + delayInput.connect(delayNode); + delayNode.connect(delayFeedback); + delayFeedback.connect(delayNode); + delayNode.connect(delayOutput); + delayOutput.connect(masterGain); + + this.delayInput = delayInput; + this.delayNode = delayNode; + this.delayFeedback = delayFeedback; + this.delayOutput = delayOutput; + } + + private createBuses(context: AudioContext, masterGain: GainNode): void { + this.eventBus = context.createGain(); + this.eventBus.gain.value = this.engineConfig.graph.eventBusGain; + this.eventBus.connect(masterGain); + } + + private createNoiseBuffer(context: AudioContext): AudioBuffer { + const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate); + const data = buffer.getChannelData(0); + + for (let index = 0; index < data.length; index++) { + data[index] = + this.engineConfig.graph.noiseMin + + Math.random() * + (this.engineConfig.graph.noiseMax - this.engineConfig.graph.noiseMin); + } + + return buffer; + } + + private clearNodes(): void { + this.context = null; + this.eventBus = null; + this.delayInput = null; + this.noiseBuffer = null; + this.masterGain = null; + this.delayNode = null; + this.delayFeedback = null; + this.delayOutput = null; + this.hasUnlocked = false; + } +} diff --git a/src/audio/garden-audio-input.ts b/src/audio/garden-audio-input.ts new file mode 100644 index 0000000..015e881 --- /dev/null +++ b/src/audio/garden-audio-input.ts @@ -0,0 +1,75 @@ +import type { GardenAudioEngineConfig } from '../config'; +import { clamp01 } from '../utils/clamp'; +import { GardenAudioStroke } from './garden-audio-types'; + +export interface GardenAudioStrokeMetrics { + distancePixels: number; + pressure: number; + speedAmount: number; + effectiveEnergy: number; +} + +export const getStrokeMetrics = ( + stroke: GardenAudioStroke, + speedForFullEnergyPixelsPerSecond: number, + fallbackPressure: number, + inputConfig: GardenAudioEngineConfig['input'] +): GardenAudioStrokeMetrics => { + const dx = stroke.to[0] - stroke.from[0]; + const dy = stroke.to[1] - stroke.from[1]; + const distancePixels = Math.hypot(dx, dy); + const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig); + const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig); + const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond); + const strokeEnergy = clamp01( + inputConfig.strokeEnergyBase + + speedAmount * inputConfig.strokeEnergySpeedWeight + + pressure * inputConfig.strokeEnergyPressureWeight + ); + const effectiveEnergy = + strokeEnergy * + (inputConfig.distanceEnergyBase + + clamp01(distancePixels / inputConfig.distanceForFullEnergyPixels) * + inputConfig.distanceEnergyScale); + + return { + distancePixels, + pressure, + speedAmount, + effectiveEnergy, + }; +}; + +const getStrokeVelocity = ( + stroke: GardenAudioStroke, + distancePixels: number, + inputConfig: GardenAudioEngineConfig['input'] +): number => { + if ( + stroke.velocityPixelsPerSecond !== undefined && + Number.isFinite(stroke.velocityPixelsPerSecond) && + stroke.velocityPixelsPerSecond >= 0 + ) { + return stroke.velocityPixelsPerSecond; + } + + return distancePixels / inputConfig.fallbackFrameSeconds; +}; + +const getPressureAmount = ( + stroke: GardenAudioStroke, + fallbackPressure: number, + inputConfig: GardenAudioEngineConfig['input'] +): number => { + if ( + stroke.pressure !== undefined && + Number.isFinite(stroke.pressure) && + stroke.pressure > 0 + ) { + return clamp01(stroke.pressure); + } + + return stroke.pointerType === 'pen' + ? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure)) + : clamp01(fallbackPressure); +}; diff --git a/src/audio/garden-audio-music.ts b/src/audio/garden-audio-music.ts new file mode 100644 index 0000000..11ee580 --- /dev/null +++ b/src/audio/garden-audio-music.ts @@ -0,0 +1,39 @@ +import { VibePreset } from '../vibes'; +import { + GardenAudioChord, + GardenAudioConfig, + GardenAudioVibeProfile, +} from './garden-audio-config'; +import { GardenAudioColorIndex } from './garden-audio-types'; + +export const normalizeColorIndex = (index: number): GardenAudioColorIndex => + Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex; + +export const getVibeProfile = ( + config: GardenAudioConfig, + vibe: VibePreset +): GardenAudioVibeProfile => + config.vibes[vibe.id] ?? + config.vibes[config.fallbackVibeId] ?? + Object.values(config.vibes)[0]; + +export const getChordIntervals = ( + chord: GardenAudioChord, + openVoicing: boolean +): Array => { + if (openVoicing) { + return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15]; + } + + return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15]; +}; + +export 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 * 12; +}; diff --git a/src/audio/garden-audio-types.ts b/src/audio/garden-audio-types.ts new file mode 100644 index 0000000..b6edc29 --- /dev/null +++ b/src/audio/garden-audio-types.ts @@ -0,0 +1,66 @@ +import { VibePreset } from '../vibes'; + +export type GardenAudioColorIndex = 0 | 1 | 2; + +export interface GardenAudioSnapshot { + vibe: VibePreset; + selectedColorIndex: number; + isErasing: boolean; + mirrorSegmentCount?: number; +} + +export interface GardenAudioStroke { + vibe: VibePreset; + from: ArrayLike; + to: ArrayLike; + canvasSize: ArrayLike; + colorIndex: number; + isErasing: boolean; + pressure?: number; + velocityPixelsPerSecond?: number; + eraserSizePixels?: number; + mirrorSegmentCount?: number; + pointerType?: string; +} + +export interface GardenAudioTouchDown { + vibe: VibePreset; + colorIndex: number; + mirrorSegmentCount?: number; + pressure?: number; + pointerType?: string; +} + +export interface GardenAudioStartOptions { + userGesture?: boolean; +} + +export interface LoadedPianoSample { + midi: number; + buffer: AudioBuffer; +} + +export interface ActivePianoVoice { + gain: GainNode; + source: AudioBufferSourceNode; + startAt: number; + stopAt: number; +} + +export interface PianoNote { + midi: number; + velocity: number; + startTime: number; + durationSeconds: number; + pan: number; + delaySend?: number; + lowpassHz?: number; +} + +export interface NoiseBurst { + startTime: number; + durationSeconds: number; + gain: number; + filterHz: number; + pan: number; +} diff --git a/src/audio/garden-audio.test.ts b/src/audio/garden-audio.test.ts new file mode 100644 index 0000000..c026afe --- /dev/null +++ b/src/audio/garden-audio.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { VIBE_PRESETS } from '../vibes'; +import { GardenAudio } from './garden-audio'; +import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; + +const calls = { + constructed: 0, + resumed: 0, +}; + +let contextState: AudioContextState = 'suspended'; + +class FakeAudioParam { + public value = 0; + public setTargetAtTime = vi.fn(); + public setValueAtTime = vi.fn(); + public exponentialRampToValueAtTime = vi.fn(); + public cancelScheduledValues = vi.fn(); +} + +class FakeAudioNode { + public readonly gain = new FakeAudioParam(); + public readonly frequency = new FakeAudioParam(); + public readonly threshold = new FakeAudioParam(); + public readonly knee = new FakeAudioParam(); + public readonly ratio = new FakeAudioParam(); + public readonly attack = new FakeAudioParam(); + public readonly release = new FakeAudioParam(); + public readonly delayTime = new FakeAudioParam(); + public type = ''; + public connect = vi.fn(); + public disconnect = vi.fn(); +} + +class FakeAudioBuffer { + private readonly data: Float32Array; + + public constructor(length: number) { + this.data = new Float32Array(length); + } + + public getChannelData(): Float32Array { + return this.data; + } +} + +class FakeAudioContext { + public readonly currentTime = 1; + public readonly sampleRate = 16; + public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode; + + public constructor() { + calls.constructed += 1; + } + + public get state(): AudioContextState { + return contextState; + } + + public set state(state: AudioContextState) { + contextState = state; + } + + public createGain(): GainNode { + return new FakeAudioNode() as unknown as GainNode; + } + + public createBiquadFilter(): BiquadFilterNode { + return new FakeAudioNode() as unknown as BiquadFilterNode; + } + + public createDynamicsCompressor(): DynamicsCompressorNode { + return new FakeAudioNode() as unknown as DynamicsCompressorNode; + } + + public createDelay(): DelayNode { + return new FakeAudioNode() as unknown as DelayNode; + } + + public createBuffer(_channels: number, length: number): AudioBuffer { + return new FakeAudioBuffer(length) as unknown as AudioBuffer; + } + + public createBufferSource(): AudioBufferSourceNode { + const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & { + buffer: AudioBuffer | null; + start: () => void; + stop: () => void; + }; + node.buffer = null; + node.start = vi.fn(); + node.stop = vi.fn(); + return node; + } + + public async resume(): Promise { + calls.resumed += 1; + contextState = 'running'; + } +} + +const makeConfig = (): GardenAudioConfig => ({ + ...gardenAudioConfig, +}); + +describe('GardenAudio startup policy', () => { + beforeEach(() => { + calls.constructed = 0; + calls.resumed = 0; + contextState = 'suspended'; + vi.stubGlobal('AudioContext', FakeAudioContext); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests'))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does not create an AudioContext from passive audio paths', () => { + const audio = new GardenAudio(makeConfig()); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe); + audio.stroke({ + vibe, + from: [0, 0], + to: [12, 0], + canvasSize: [100, 100], + colorIndex: 0, + isErasing: false, + }); + + expect(calls.constructed).toBe(0); + }); + + it('only resumes a suspended context from a user gesture start', () => { + const audio = new GardenAudio(makeConfig()); + const vibe = VIBE_PRESETS[0]; + + audio.start(vibe, { userGesture: true }); + + expect(calls.constructed).toBe(1); + expect(calls.resumed).toBe(1); + expect(contextState).toBe('running'); + + contextState = 'suspended'; + audio.start(vibe); + audio.setMuted(false); + + expect(calls.resumed).toBe(1); + }); +}); diff --git a/src/audio/garden-audio.ts b/src/audio/garden-audio.ts new file mode 100644 index 0000000..01d5a5d --- /dev/null +++ b/src/audio/garden-audio.ts @@ -0,0 +1,380 @@ +import { appConfig } from '../config'; +import { clamp, clamp01 } from '../utils/clamp'; +import { VibePreset } from '../vibes'; +import { GardenAudioConfig } from './garden-audio-config'; +import { GardenAudioEnergy } from './garden-audio-energy'; +import { GardenAudioGraph } from './garden-audio-graph'; +import { GardenAudioStrokeMetrics, getStrokeMetrics } from './garden-audio-input'; +import { getVibeProfile, normalizeColorIndex } from './garden-audio-music'; +import type { + GardenAudioColorIndex, + GardenAudioSnapshot, + GardenAudioStartOptions, + GardenAudioStroke, + GardenAudioTouchDown, +} from './garden-audio-types'; +import { GenerativePianoEngine } from './generative-piano'; +import { NoiseBurstPlayer } from './noise-burst-player'; +import { PianoSampler } from './piano-sampler'; + +export type { + GardenAudioSnapshot, + GardenAudioStartOptions, + GardenAudioStroke, + GardenAudioTouchDown, +} from './garden-audio-types'; + +export class GardenAudio { + private readonly graph: GardenAudioGraph; + private readonly piano: PianoSampler; + private readonly noise: NoiseBurstPlayer; + private readonly energy: GardenAudioEnergy; + private readonly pianoEngine: GenerativePianoEngine; + + private currentVibeId: string | null = null; + private hasStarted = false; + private isDestroyed = false; + private isMuted = false; + private isGestureActive = false; + private selectedColorIndex: GardenAudioColorIndex = 0; + private hasQueuedPianoLoad = false; + private lastEraserAt = Number.NEGATIVE_INFINITY; + private lastVibeStingerAt = Number.NEGATIVE_INFINITY; + + public constructor(private readonly config: GardenAudioConfig) { + this.graph = new GardenAudioGraph(config); + this.piano = new PianoSampler(config, this.graph); + this.noise = new NoiseBurstPlayer(this.graph); + this.energy = new GardenAudioEnergy(); + this.pianoEngine = new GenerativePianoEngine(config, (note) => this.piano.play(note)); + } + + public start(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { + if (this.isDestroyed || this.isMuted) { + return; + } + + const context = this.graph.ensureContext(options.userGesture === true); + if (!context) { + return; + } + + if (options.userGesture === true) { + this.graph.unlock(); + } + + if (context.state === 'suspended') { + if (options.userGesture !== true) { + return; + } + void context.resume().catch(() => undefined); + } + + this.hasStarted = true; + this.applyVibe(vibe); + this.pianoEngine.prime(context.currentTime); + this.graph.setMasterGain( + this.config.masterVolume, + options.userGesture === true + ? appConfig.audioEngine.muteRampSeconds + : this.config.fadeInSeconds + ); + + if (!this.hasQueuedPianoLoad) { + this.hasQueuedPianoLoad = true; + void this.piano.load(context).then(() => { + if (this.graph.context === context && !this.isDestroyed) { + this.pianoEngine.cue(context.currentTime); + } + }); + } + } + + public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void { + const previousVibeId = this.currentVibeId; + this.start(vibe, options); + + const context = this.graph.context; + if ( + context && + (context.state === 'running' || options.userGesture === true) && + !this.isMuted && + !this.isDestroyed && + previousVibeId !== null && + previousVibeId !== vibe.id + ) { + this.playVibeChangeStinger(vibe); + } + } + + public setMuted(isMuted: boolean): void { + this.isMuted = isMuted; + this.graph.setMasterGain( + isMuted ? appConfig.audioEngine.muteGain : this.config.masterVolume, + isMuted ? appConfig.audioEngine.muteRampSeconds : this.config.fadeInSeconds + ); + } + + public beginGesture(): void { + const context = this.graph.context; + if (!context) { + return; + } + + this.isGestureActive = true; + this.energy.beginGesture(context.currentTime); + this.pianoEngine.beginGesture(); + } + + public endGesture(): void { + this.isGestureActive = false; + this.energy.endGesture(); + this.pianoEngine.endGesture(); + } + + public touchDown(touch: GardenAudioTouchDown): void { + if (this.isDestroyed || this.isMuted) { + return; + } + + const context = this.graph.context; + if (!context || !this.isGestureActive) { + return; + } + + this.selectedColorIndex = normalizeColorIndex(touch.colorIndex); + const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1); + const pressure = this.getTouchPressure(touch.pressure, touch.pointerType); + const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22); + + this.energy.recordStroke(strength, context.currentTime); + this.pianoEngine.recordTouchDown({ + vibe: touch.vibe, + now: context.currentTime, + strength, + selectedColorIndex: this.selectedColorIndex, + mirrorAmount, + }); + } + + public update(snapshot: GardenAudioSnapshot): void { + const context = this.graph.context; + if (!this.hasStarted || !context || this.isMuted) { + return; + } + + this.applyVibe(snapshot.vibe); + this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex); + this.energy.update(context.currentTime); + + if (snapshot.isErasing) { + this.energy.silence(); + } + + this.pianoEngine.renderLookahead({ + vibe: snapshot.vibe, + now: context.currentTime, + activity: snapshot.isErasing ? 0 : this.energy.getLevel(), + selectedColorIndex: this.selectedColorIndex, + }); + this.updateDelay(snapshot); + } + + public stroke(stroke: GardenAudioStroke): void { + if (this.isDestroyed || this.isMuted) { + return; + } + + this.start(stroke.vibe); + const context = this.graph.context; + if (!context) { + return; + } + if (!this.isGestureActive) { + return; + } + + const metrics = getStrokeMetrics( + stroke, + this.config.rhythm.speedForFullEnergyPixelsPerSecond, + this.config.input.pressureFallback + ); + const now = context.currentTime; + + this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex); + + if (stroke.isErasing) { + this.energy.recordEraserStroke(); + this.playEraser(stroke, metrics.speedAmount, metrics.pressure, now); + return; + } + + const mirrorAmount = this.getMirrorAmount(stroke.mirrorSegmentCount ?? 1); + const strokeEnergy = this.getStrokeMusicActivity(stroke, metrics, mirrorAmount); + this.energy.recordStroke(strokeEnergy, now); + this.pianoEngine.recordStroke({ + vibe: stroke.vibe, + now, + activity: strokeEnergy, + selectedColorIndex: this.selectedColorIndex, + mirrorAmount, + }); + } + + public async destroy(): Promise { + this.isDestroyed = true; + await this.graph.close(); + + this.piano.reset(); + this.energy.reset(); + this.pianoEngine.reset(); + this.currentVibeId = null; + this.hasStarted = false; + this.isGestureActive = false; + this.selectedColorIndex = 0; + this.hasQueuedPianoLoad = false; + 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 < + appConfig.audioEngine.vibeChangeStingerMinIntervalSeconds + ) { + return; + } + + this.lastVibeStingerAt = now; + this.pianoEngine.playVibeChangeStinger(vibe, now); + } + + private playEraser( + stroke: GardenAudioStroke, + speedAmount: number, + pressure: number, + now: number + ): void { + if (!this.graph.context) { + return; + } + + const sizeAmount = clamp01( + (stroke.eraserSizePixels ?? appConfig.audioEngine.eraser.defaultSizePixels) / + Math.max( + 1, + stroke.canvasSize[0] * appConfig.audioEngine.eraser.canvasWidthRatioForFullSize + ) + ); + const x = clamp01(stroke.to[0] / Math.max(1, stroke.canvasSize[0])); + const filterHz = + this.config.eraser.filterMinHz + + (this.config.eraser.filterMaxHz - this.config.eraser.filterMinHz) * + clamp01( + speedAmount * appConfig.audioEngine.eraser.filterSpeedWeight + + pressure * appConfig.audioEngine.eraser.filterPressureWeight + + sizeAmount * appConfig.audioEngine.eraser.filterSizeWeight + ); + + if (now - this.lastEraserAt >= this.config.eraser.minIntervalSeconds) { + this.lastEraserAt = now; + this.noise.play({ + startTime: now, + durationSeconds: appConfig.audioEngine.eraser.durationSeconds, + gain: + this.config.eraser.noiseGain * + (appConfig.audioEngine.eraser.gainBase + + speedAmount * appConfig.audioEngine.eraser.gainSpeedWeight + + pressure * appConfig.audioEngine.eraser.gainPressureWeight + + sizeAmount * appConfig.audioEngine.eraser.gainSizeWeight), + filterHz, + pan: clamp(x * 2 - 1, -1, 1), + }); + } + } + + private updateDelay(snapshot: GardenAudioSnapshot): void { + const context = this.graph.context; + if (!context) { + return; + } + + const profile = getVibeProfile(this.config, snapshot.vibe); + const activity = snapshot.isErasing + ? appConfig.audioEngine.delay.erasingActivity + : this.energy.getLevel(); + this.graph.updateDelay(profile, activity); + } + + private applyVibe(vibe: VibePreset): void { + if (!this.graph.context || this.currentVibeId === vibe.id) { + return; + } + + this.currentVibeId = vibe.id; + this.graph.applyDelayProfile(getVibeProfile(this.config, vibe)); + this.pianoEngine.cue(this.graph.context.currentTime); + } + + private getMirrorAmount(mirrorSegmentCount: number): number { + const maxMirrorSegmentCount = Math.max(1, appConfig.simulation.maxMirrorSegmentCount); + const segmentCount = clamp( + Number.isFinite(mirrorSegmentCount) ? mirrorSegmentCount : 1, + 1, + maxMirrorSegmentCount + ); + + if (maxMirrorSegmentCount <= 1) { + return 0; + } + + return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1)); + } + + private getStrokeMusicActivity( + stroke: GardenAudioStroke, + metrics: GardenAudioStrokeMetrics, + mirrorAmount: number + ): number { + const speedRatio = + (stroke.velocityPixelsPerSecond ?? 0) / + Math.max(1, this.config.rhythm.speedForFullEnergyPixelsPerSecond); + const speedDrive = smoothstep(0.35, 1.1, speedRatio); + const speedOverdrive = smoothstep(1.15, 1.8, speedRatio); + const distanceDrive = smoothstep(10, 90, metrics.distancePixels); + const baseStroke = clamp01( + 0.08 + speedDrive * 0.5 + metrics.pressure * 0.2 + distanceDrive * 0.22 + ); + const mirrorWild = smoothstep(0.45, 0.9, mirrorAmount); + const maniaDrive = speedOverdrive * smoothstep(0.62, 0.82, baseStroke); + const maniaBoost = maniaDrive * (0.18 + mirrorWild * 0.62); + + return clamp01( + baseStroke * (0.68 + mirrorAmount * 0.3) + + 0.025 + + mirrorAmount * 0.045 + + maniaBoost + ); + } + + private getTouchPressure(pressure: number | undefined, pointerType?: string): number { + if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) { + return clamp01(pressure); + } + + return pointerType === 'pen' + ? Math.max(appConfig.audioEngine.input.penMinPressure, this.config.input.pressureFallback) + : this.config.input.pressureFallback; + } +} + +const smoothstep = (edge0: number, edge1: number, value: number): number => { + const amount = clamp01((value - edge0) / (edge1 - edge0)); + return amount * amount * (3 - 2 * amount); +}; diff --git a/src/audio/generative-piano.test.ts b/src/audio/generative-piano.test.ts new file mode 100644 index 0000000..f75bf8c --- /dev/null +++ b/src/audio/generative-piano.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; + +import { VIBE_PRESETS } from '../vibes'; +import { gardenAudioConfig } from './garden-audio-config'; +import { PianoNote } from './garden-audio-types'; +import { GenerativePianoEngine } from './generative-piano'; + +const makeEngine = () => { + const notes: Array = []; + const engine = new GenerativePianoEngine(gardenAudioConfig, (note) => { + notes.push(note); + }); + + return { engine, notes }; +}; + +const getBeatSeconds = (): number => 60 / gardenAudioConfig.rhythm.bpm; + +const getBeatsPerBar = (): number => + Math.round(gardenAudioConfig.rhythm.stepsPerBar / gardenAudioConfig.rhythm.stepsPerBeat); + +const renderBars = ( + engine: GenerativePianoEngine, + activity: number, + selectedColorIndex = 0, + bars = 8 +) => { + engine.renderLookahead({ + vibe: VIBE_PRESETS[0], + now: 0, + activity, + selectedColorIndex: selectedColorIndex as 0 | 1 | 2, + lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars, + }); +}; + +const average = (values: Array): number => + values.reduce((sum, value) => sum + value, 0) / values.length; + +const uniqueStartTimes = (notes: Array): Array => + Array.from(new Set(notes.map((note) => note.startTime.toFixed(3)))); + +const countNotesBetween = ( + notes: Array, + startSeconds: number, + endSeconds: number +): number => + notes.filter( + (note) => note.startTime >= startSeconds && note.startTime < endSeconds + ).length; + +describe('GenerativePianoEngine', () => { + it('plays quiet background music even when the garden is idle', () => { + const { engine, notes } = makeEngine(); + + renderBars(engine, 0); + + expect(notes.length).toBeGreaterThan(0); + expect(notes.some((note) => note.durationSeconds > getBeatSeconds() * 12)).toBe( + true + ); + expect(Math.max(...notes.map((note) => note.velocity))).toBeLessThan(0.16); + }); + + it('keeps the background sparse instead of filling every beat', () => { + const { engine, notes } = makeEngine(); + + renderBars(engine, 0, 1, 4); + + expect(uniqueStartTimes(notes).length).toBeLessThan(8); + }); + + it('lets activity add density without changing the beat grid', () => { + const idle = makeEngine(); + const active = makeEngine(); + const startDelaySeconds = 0.02; + + renderBars(idle.engine, 0, 1, 8); + renderBars(active.engine, 1, 1, 8); + + expect(active.notes.length).toBeGreaterThan(idle.notes.length); + active.notes.forEach((note) => { + const beatsFromStart = (note.startTime - startDelaySeconds) / getBeatSeconds(); + expect(Math.abs(beatsFromStart - Math.round(beatsFromStart))).toBeLessThan(0.001); + }); + }); + + it('uses color pools with multiple notes instead of one key per color', () => { + ([0, 1, 2] as const).forEach((selectedColorIndex) => { + const { engine, notes } = makeEngine(); + + renderBars(engine, 1, selectedColorIndex, 16); + + expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3); + }); + }); + + it('keeps the upper color higher and wider than the lower color', () => { + const lower = makeEngine(); + const upper = makeEngine(); + + renderBars(lower.engine, 1, 0, 16); + renderBars(upper.engine, 1, 2, 16); + + expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan( + average(lower.notes.map((note) => note.midi)) + ); + expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan( + average(lower.notes.map((note) => note.pan)) + ); + }); + + it('starts a fading brush phrase layer with each new brush gesture', () => { + const baseline = makeEngine(); + const layered = makeEngine(); + const now = 4; + + baseline.engine.renderLookahead({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.35, + selectedColorIndex: 1, + lookaheadSeconds: 12, + }); + + layered.engine.beginGesture(); + layered.engine.recordStroke({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.85, + selectedColorIndex: 1, + mirrorAmount: 0.45, + }); + layered.engine.renderLookahead({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.35, + selectedColorIndex: 1, + lookaheadSeconds: 12, + }); + + const earlyExtra = + countNotesBetween(layered.notes, now + 1, now + 5) - + countNotesBetween(baseline.notes, now + 1, now + 5); + const lateExtra = + countNotesBetween(layered.notes, now + 10.5, now + 12) - + countNotesBetween(baseline.notes, now + 10.5, now + 12); + + expect(earlyExtra).toBeGreaterThan(2); + expect(lateExtra).toBe(0); + }); + + it('makes brush phrase layers denser at higher mirror amounts', () => { + const lowMirror = makeEngine(); + const highMirror = makeEngine(); + const now = 4; + + lowMirror.engine.beginGesture(); + lowMirror.engine.recordStroke({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.85, + selectedColorIndex: 2, + mirrorAmount: 0, + }); + lowMirror.engine.renderLookahead({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.35, + selectedColorIndex: 2, + lookaheadSeconds: 9, + }); + + highMirror.engine.beginGesture(); + highMirror.engine.recordStroke({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.85, + selectedColorIndex: 2, + mirrorAmount: 1, + }); + highMirror.engine.renderLookahead({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.35, + selectedColorIndex: 2, + lookaheadSeconds: 9, + }); + + expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length); + }); + + it('plays one immediate touch note and throttles later stroke accents', () => { + const { engine, notes } = makeEngine(); + const now = 4; + + engine.beginGesture(); + engine.recordStroke({ + vibe: VIBE_PRESETS[0], + now, + activity: 0.9, + selectedColorIndex: 1, + }); + engine.recordStroke({ + vibe: VIBE_PRESETS[0], + now: now + 1, + activity: 0.95, + selectedColorIndex: 1, + }); + + expect(notes).toHaveLength(1); + expect(notes[0].startTime).toBe(now); + + engine.recordStroke({ + vibe: VIBE_PRESETS[0], + now: now + 6, + activity: 0.95, + selectedColorIndex: 1, + }); + + expect(notes).toHaveLength(2); + expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(1); + }); + + it('is deterministic for the same musical inputs', () => { + const first = makeEngine(); + const second = makeEngine(); + + renderBars(first.engine, 0.78, 2, 16); + renderBars(second.engine, 0.78, 2, 16); + + expect(second.notes).toEqual(first.notes); + }); +}); diff --git a/src/audio/generative-piano.ts b/src/audio/generative-piano.ts new file mode 100644 index 0000000..503f030 --- /dev/null +++ b/src/audio/generative-piano.ts @@ -0,0 +1,900 @@ +import { appConfig } from '../config'; +import { clamp, clamp01 } from '../utils/clamp'; +import { VibePreset } from '../vibes'; +import { + GardenAudioChord, + GardenAudioConfig, + GardenAudioVibeProfile, +} from './garden-audio-config'; +import { degreeToSemitone, getChordIntervals, getVibeProfile } from './garden-audio-music'; +import { GardenAudioColorIndex, PianoNote } from './garden-audio-types'; + +interface RenderLookaheadRequest { + vibe: VibePreset; + now: number; + activity: number; + selectedColorIndex: GardenAudioColorIndex; + lookaheadSeconds?: number; +} + +interface StrokeAccentRequest { + vibe: VibePreset; + now: number; + activity: number; + selectedColorIndex: GardenAudioColorIndex; + mirrorAmount?: number; +} + +interface TouchDownRequest { + vibe: VibePreset; + now: number; + strength: number; + selectedColorIndex: GardenAudioColorIndex; + mirrorAmount?: number; +} + +interface Register { + midiMin: number; + midiMax: number; + preferredMidi: number; + pan: number; +} + +interface ColorPool extends Register { + scaleDegrees: ReadonlyArray; +} + +interface PitchCandidate { + midi: number; + preference: number; +} + +interface PitchSource { + baseMidi: number; + offsets: ReadonlyArray; +} + +interface BrushPhraseLayer { + vibe: VibePreset; + startedAt: number; + expiresAt: number; + selectedColorIndex: GardenAudioColorIndex; + energy: number; + mirrorAmount: number; +} + +const COLOR_POOLS: [ColorPool, ColorPool, ColorPool] = [ + { + 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: 81, + preferredMidi: 72, + pan: 0.18, + scaleDegrees: [2, 3, 4, 6], + }, +]; + +const PAD_REGISTERS: [Register, Register, Register] = [ + { + 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, + }, +]; + +const CHORD_BARS = 4; +const SUPPORT_BAR_SPACING = 2; +const SUPPORT_BAR_OFFSET = 1; +const IDLE_TEXTURE_BAR_SPACING = 2; +const MEDIUM_TEXTURE_BAR_SPACING = 1; +const TEXTURE_BEAT = 2; +const HIGH_ACTIVITY_EXTRA_BEAT = 3; +const HIGH_ACTIVITY_EXTRA_THRESHOLD = 0.45; +const NOTE_SCORE_PREFERENCE_WEIGHT = 1.8; +const NOTE_SCORE_REGISTER_WEIGHT = 0.28; +const NOTE_SCORE_REPEAT_PENALTY = 3.2; +const GESTURE_ACCENT_SPACING_SECONDS = 0.26; +const GESTURE_ACCENT_MIN_INTERVAL_SECONDS = 2.5; +const STROKE_ACCENT_MIN_INTERVAL_SECONDS = 3.2; +const STROKE_ACCENT_THRESHOLD = 0.58; +const STINGER_SPACING_SECONDS = 0.08; +const STINGER_DURATION_SECONDS = 1.1; +const MAX_BRUSH_PHRASE_LAYERS = 5; +const BRUSH_LAYER_BASE_SECONDS = 5.5; +const BRUSH_LAYER_ENERGY_SECONDS = 2.5; +const BRUSH_LAYER_MIRROR_SECONDS = 3; +const BRUSH_LAYER_MIN_INTENSITY = 0.08; +const BRUSH_STREAM_IDLE_INTERVAL_BEATS = 2; +const BRUSH_STREAM_ACTIVE_INTERVAL_BEATS = 1; +const BRUSH_STREAM_INTENSE_INTERVAL_BEATS = 0.5; +const BRUSH_STREAM_MANIC_INTERVAL_BEATS = 0.25; + +export class GenerativePianoEngine { + private nextBeatAt: number | null = null; + private timelineStartedAt: number | null = null; + private beatIndex = 0; + private isWaitingForGestureAccent = false; + private lastGestureAccentAt = Number.NEGATIVE_INFINITY; + private lastStrokeAccentAt = Number.NEGATIVE_INFINITY; + private readonly lastMidiByColor: [number | null, number | null, number | null] = [ + null, + null, + null, + ]; + private brushPhraseLayers: Array = []; + private nextBrushStreamAt: number | null = null; + private brushStreamNoteIndex = 0; + private lastBrushStreamMidi: number | null = null; + + public constructor( + private readonly config: GardenAudioConfig, + private readonly playNote: (note: PianoNote) => void + ) {} + + public prime(now: number): void { + if (this.nextBeatAt === null) { + this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds; + } + this.timelineStartedAt ??= now; + this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds; + } + + public cue(now: number): void { + this.nextBeatAt = now + appConfig.audioEngine.startDelaySeconds; + this.timelineStartedAt = now; + this.beatIndex = 0; + this.nextBrushStreamAt = now + appConfig.audioEngine.startDelaySeconds; + this.brushStreamNoteIndex = 0; + this.lastBrushStreamMidi = null; + } + + public beginGesture(): void { + this.isWaitingForGestureAccent = true; + } + + public endGesture(): void { + this.isWaitingForGestureAccent = false; + } + + public recordTouchDown({ + vibe, + now, + strength, + selectedColorIndex, + mirrorAmount = 0, + }: TouchDownRequest): void { + const normalizedStrength = clamp01(strength); + const normalizedMirrorAmount = clamp01(mirrorAmount); + + this.isWaitingForGestureAccent = false; + this.lastGestureAccentAt = now; + this.lastStrokeAccentAt = now; + this.startBrushPhraseLayer({ + vibe, + now, + strength: normalizedStrength, + selectedColorIndex, + mirrorAmount: normalizedMirrorAmount, + }); + this.playTouchNote(vibe, now, selectedColorIndex, normalizedStrength); + } + + public recordStroke({ + vibe, + now, + activity, + selectedColorIndex, + mirrorAmount = 0, + }: StrokeAccentRequest): void { + const strength = clamp01(activity); + const normalizedMirrorAmount = clamp01(mirrorAmount); + + if ( + this.isWaitingForGestureAccent && + now - this.lastGestureAccentAt >= GESTURE_ACCENT_MIN_INTERVAL_SECONDS + ) { + this.recordTouchDown({ + vibe, + now, + strength, + selectedColorIndex, + mirrorAmount: normalizedMirrorAmount, + }); + return; + } + + this.isWaitingForGestureAccent = false; + if ( + strength >= STROKE_ACCENT_THRESHOLD && + now - this.lastStrokeAccentAt >= STROKE_ACCENT_MIN_INTERVAL_SECONDS + ) { + this.lastStrokeAccentAt = now; + this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1); + } + } + + public renderLookahead({ + vibe, + now, + activity, + selectedColorIndex, + lookaheadSeconds = this.config.rhythm.lookaheadSeconds, + }: RenderLookaheadRequest): void { + this.prime(now); + this.skipLateBeats(now, this.getBeatDurationSeconds()); + + if (this.nextBeatAt === null) { + return; + } + + const profile = getVibeProfile(this.config, vibe); + const lookaheadEnd = now + lookaheadSeconds; + while (this.nextBeatAt <= lookaheadEnd) { + this.renderBeat({ + profile, + beatIndex: this.beatIndex, + startTime: this.nextBeatAt, + expression: this.getExpression(activity), + selectedColorIndex, + }); + this.nextBeatAt += this.getBeatDurationSeconds(); + this.beatIndex += 1; + } + this.renderBrushPhraseLayers({ + vibe, + now, + lookaheadEnd, + activity, + selectedColorIndex, + }); + } + + public playVibeChangeStinger(vibe: VibePreset, now: number): void { + const profile = getVibeProfile(this.config, vibe); + const chord = this.getChord(profile, 0); + const intervals = getChordIntervals(chord, true); + const rootMidi = profile.rootMidi + chord.rootOffset; + const notes = [ + { + midi: this.chooseMidi({ baseMidi: rootMidi, offsets: [0] }, PAD_REGISTERS[0]), + velocity: 0.1, + pan: -0.16, + delaySend: 0.012, + }, + { + midi: this.chooseMidi( + { baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] }, + PAD_REGISTERS[1] + ), + velocity: 0.085, + pan: 0, + delaySend: 0.014, + }, + { + midi: this.chooseMidi( + { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, + PAD_REGISTERS[2] + ), + velocity: 0.07, + pan: 0.16, + delaySend: 0.016, + }, + ]; + + notes.forEach((note, index) => { + this.playNote({ + ...note, + durationSeconds: STINGER_DURATION_SECONDS, + lowpassHz: this.getLowpassHz(profile, note.midi, 0.35), + startTime: now + index * STINGER_SPACING_SECONDS, + }); + }); + } + + public reset(): void { + this.nextBeatAt = null; + this.timelineStartedAt = null; + this.beatIndex = 0; + this.isWaitingForGestureAccent = false; + this.lastGestureAccentAt = Number.NEGATIVE_INFINITY; + this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY; + this.lastMidiByColor[0] = null; + this.lastMidiByColor[1] = null; + this.lastMidiByColor[2] = null; + this.brushPhraseLayers = []; + this.nextBrushStreamAt = null; + this.brushStreamNoteIndex = 0; + this.lastBrushStreamMidi = null; + } + + private renderBeat({ + profile, + beatIndex, + startTime, + expression, + selectedColorIndex, + }: { + profile: GardenAudioVibeProfile; + beatIndex: number; + startTime: number; + expression: number; + selectedColorIndex: GardenAudioColorIndex; + }): void { + const beatsPerBar = this.getBeatsPerBar(); + const beatInBar = beatIndex % beatsPerBar; + const barIndex = Math.floor(beatIndex / beatsPerBar); + + if (beatInBar === 0 && barIndex % CHORD_BARS === 0) { + this.playPadChord(profile, barIndex, startTime, expression); + } + + if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) { + this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex); + } + + if (beatInBar === TEXTURE_BEAT && this.shouldPlayTexture(expression, barIndex)) { + this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex); + } + + if ( + beatInBar === HIGH_ACTIVITY_EXTRA_BEAT && + expression >= HIGH_ACTIVITY_EXTRA_THRESHOLD + ) { + this.playTextureNote( + profile, + barIndex + 1, + startTime, + expression * 0.9, + selectedColorIndex + ); + } + } + + 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() * CHORD_BARS * 0.88; + const notes = [ + { + source: { baseMidi: rootMidi, offsets: [0] }, + register: PAD_REGISTERS[0], + velocity: 0.082, + }, + { + source: { baseMidi: rootMidi, offsets: [intervals[1]] }, + register: PAD_REGISTERS[1], + velocity: 0.064, + }, + { + source: { baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] }, + register: PAD_REGISTERS[2], + velocity: 0.052, + }, + ]; + + notes.forEach(({ source, register, velocity }) => { + const midi = this.chooseMidi(source, register); + this.playNote({ + midi, + velocity: velocity + expression * 0.02, + startTime, + durationSeconds, + pan: register.pan, + delaySend: 0.018, + lowpassHz: this.getLowpassHz(profile, midi, expression * 0.45), + }); + }); + } + + private playSupportNote( + profile: GardenAudioVibeProfile, + barIndex: number, + startTime: number, + expression: number, + selectedColorIndex: GardenAudioColorIndex + ): void { + const pool = COLOR_POOLS[selectedColorIndex]; + 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, selectedColorIndex), + }, + pool, + this.lastMidiByColor[selectedColorIndex], + true + ); + + this.lastMidiByColor[selectedColorIndex] = midi; + this.playNote({ + midi, + velocity: + (0.105 + expression * 0.07) * + this.config.colorVoices[selectedColorIndex].velocityMultiplier, + startTime, + durationSeconds: 1.35 + expression * 0.4, + pan: this.getColorPan(selectedColorIndex), + delaySend: 0.016 + expression * 0.006, + lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7), + }); + } + + private playTextureNote( + profile: GardenAudioVibeProfile, + barIndex: number, + startTime: number, + expression: number, + selectedColorIndex: GardenAudioColorIndex + ): void { + const pool = COLOR_POOLS[selectedColorIndex]; + const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex); + const midi = this.chooseMidi( + { + baseMidi: profile.rootMidi, + offsets: degrees.map((degree) => degreeToSemitone(profile, degree)), + }, + pool, + this.lastMidiByColor[selectedColorIndex], + true + ); + + this.lastMidiByColor[selectedColorIndex] = midi; + this.playNote({ + midi, + velocity: + (0.09 + expression * 0.08) * + this.config.colorVoices[selectedColorIndex].velocityMultiplier, + startTime, + durationSeconds: 0.62 + expression * 0.24, + pan: this.getColorPan(selectedColorIndex), + delaySend: 0.016 + expression * 0.006, + lowpassHz: this.getLowpassHz(profile, midi, expression), + }); + } + + private playGestureAccent( + vibe: VibePreset, + now: number, + selectedColorIndex: GardenAudioColorIndex, + strength: number, + noteCount: number + ): void { + const profile = getVibeProfile(this.config, vibe); + const pool = COLOR_POOLS[selectedColorIndex]; + const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3)); + + for (let index = 0; index < noteCount; index += 1) { + const midi = this.chooseMidi( + { + baseMidi: profile.rootMidi, + offsets: degrees + .slice(index) + .concat(degrees.slice(0, index)) + .map((degree) => degreeToSemitone(profile, degree)), + }, + pool, + this.lastMidiByColor[selectedColorIndex], + true + ); + + this.lastMidiByColor[selectedColorIndex] = midi; + this.playNote({ + midi, + velocity: + (0.12 + strength * 0.09) * + this.config.colorVoices[selectedColorIndex].velocityMultiplier, + startTime: + now + + appConfig.audioEngine.startDelaySeconds + + index * GESTURE_ACCENT_SPACING_SECONDS, + durationSeconds: 0.48 + strength * 0.22, + pan: this.getColorPan(selectedColorIndex), + delaySend: 0.012, + lowpassHz: this.getLowpassHz(profile, midi, strength), + }); + } + } + + private playTouchNote( + vibe: VibePreset, + now: number, + selectedColorIndex: GardenAudioColorIndex, + strength: number + ): void { + const profile = getVibeProfile(this.config, vibe); + const pool = COLOR_POOLS[selectedColorIndex]; + 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, selectedColorIndex), + }, + pool, + this.lastMidiByColor[selectedColorIndex], + true + ); + + this.lastMidiByColor[selectedColorIndex] = midi; + this.lastBrushStreamMidi = midi; + this.playNote({ + midi, + velocity: + (0.14 + strength * 0.11) * + this.config.colorVoices[selectedColorIndex].velocityMultiplier, + startTime: now, + durationSeconds: 0.55 + strength * 0.18, + pan: this.getColorPan(selectedColorIndex), + delaySend: 0.006, + lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.45 + strength * 0.45)), + }); + } + + private startBrushPhraseLayer({ + vibe, + now, + strength, + selectedColorIndex, + mirrorAmount, + }: { + vibe: VibePreset; + now: number; + strength: number; + selectedColorIndex: GardenAudioColorIndex; + mirrorAmount: number; + }): void { + const lifetimeSeconds = + BRUSH_LAYER_BASE_SECONDS + + strength * BRUSH_LAYER_ENERGY_SECONDS + + mirrorAmount * BRUSH_LAYER_MIRROR_SECONDS; + + this.brushPhraseLayers.push({ + vibe, + startedAt: now, + expiresAt: now + lifetimeSeconds, + selectedColorIndex, + energy: strength, + mirrorAmount, + }); + + if (this.brushPhraseLayers.length > MAX_BRUSH_PHRASE_LAYERS) { + this.brushPhraseLayers = this.brushPhraseLayers.slice(-MAX_BRUSH_PHRASE_LAYERS); + } + } + + private renderBrushPhraseLayers({ + vibe, + now, + lookaheadEnd, + activity, + selectedColorIndex, + }: { + vibe: VibePreset; + now: number; + lookaheadEnd: number; + activity: number; + selectedColorIndex: GardenAudioColorIndex; + }): void { + const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds; + this.nextBrushStreamAt ??= now + appConfig.audioEngine.startDelaySeconds; + + this.brushPhraseLayers = this.brushPhraseLayers.filter( + (layer) => layer.expiresAt > earliestStart + ); + + while (this.nextBrushStreamAt < earliestStart) { + const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity); + this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity); + this.brushStreamNoteIndex += 1; + } + + while (this.nextBrushStreamAt <= lookaheadEnd) { + const frame = this.getBrushStreamFrame(this.nextBrushStreamAt, activity); + if (frame.intensity >= BRUSH_LAYER_MIN_INTENSITY) { + this.playBrushStreamNote({ + vibe, + startTime: this.nextBrushStreamAt, + intensity: frame.intensity, + selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex, + }); + } + this.nextBrushStreamAt += this.getBrushStreamIntervalSeconds(frame.intensity); + this.brushStreamNoteIndex += 1; + } + } + + private playBrushStreamNote({ + vibe, + startTime, + intensity, + selectedColorIndex, + }: { + vibe: VibePreset; + startTime: number; + intensity: number; + selectedColorIndex: GardenAudioColorIndex; + }): void { + const profile = getVibeProfile(this.config, vibe); + const pool = COLOR_POOLS[selectedColorIndex]; + const chord = this.getChord(profile, this.getGlobalBarIndex(startTime)); + const chordIntervals = getChordIntervals(chord, false); + const rootMidi = profile.rootMidi + chord.rootOffset; + const useChordTone = this.brushStreamNoteIndex % 4 === 0; + const source = useChordTone + ? { + baseMidi: rootMidi, + offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex), + } + : { + baseMidi: profile.rootMidi, + offsets: this.rotate( + pool.scaleDegrees, + this.brushStreamNoteIndex + selectedColorIndex + ).map((degree) => degreeToSemitone(profile, degree)), + }; + const midi = this.chooseMidi(source, pool, this.lastBrushStreamMidi, true); + + this.lastBrushStreamMidi = midi; + this.lastMidiByColor[selectedColorIndex] = midi; + this.playNote({ + midi, + velocity: + (0.1 + intensity * 0.13) * + this.config.colorVoices[selectedColorIndex].velocityMultiplier, + startTime, + durationSeconds: 0.42 + intensity * 0.22, + pan: this.getColorPan(selectedColorIndex), + delaySend: 0.012 + intensity * 0.01, + lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.35 + intensity * 0.65)), + }); + } + + private getBrushStreamFrame( + startTime: number, + activity: number + ): { + intensity: number; + selectedColorIndex: GardenAudioColorIndex | null; + } { + const layerStates = this.brushPhraseLayers.map((layer) => ({ + layer, + intensity: + layer.energy * + this.getBrushPhraseFade(layer, startTime) * + (0.8 + layer.mirrorAmount * 0.45), + })); + 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 * 0.45 + layeredIntensity), + selectedColorIndex: dominant?.layer.selectedColorIndex ?? null, + }; + } + + private getBrushStreamIntervalSeconds(intensity: number): number { + const intervalBeats = + intensity >= 0.85 + ? BRUSH_STREAM_MANIC_INTERVAL_BEATS + : intensity >= 0.62 + ? BRUSH_STREAM_INTENSE_INTERVAL_BEATS + : intensity >= 0.34 + ? BRUSH_STREAM_ACTIVE_INTERVAL_BEATS + : BRUSH_STREAM_IDLE_INTERVAL_BEATS; + return this.getBeatDurationSeconds() * intervalBeats; + } + + private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number { + const lifetimeSeconds = layer.expiresAt - layer.startedAt; + const ageSeconds = startTime - layer.startedAt; + return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds)); + } + + private chooseMidi( + pitchSource: PitchSource, + register: Register, + 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: Register + ): Array { + const candidates: Array = []; + + pitchSource.offsets.forEach((offset, preference) => { + for (let octave = -3; octave <= 3; octave += 1) { + const midi = pitchSource.baseMidi + offset + octave * 12; + if (midi >= register.midiMin && midi <= register.midiMax) { + candidates.push({ midi: Math.round(midi), preference }); + } + } + }); + + return candidates; + } + + private scoreCandidate( + candidate: PitchCandidate, + register: Register, + previousMidi: number, + avoidRepeat: boolean + ): number { + return ( + Math.abs(candidate.midi - previousMidi) + + Math.abs(candidate.midi - register.preferredMidi) * NOTE_SCORE_REGISTER_WEIGHT + + candidate.preference * NOTE_SCORE_PREFERENCE_WEIGHT + + (avoidRepeat && candidate.midi === previousMidi ? NOTE_SCORE_REPEAT_PENALTY : 0) + ); + } + + private shouldPlaySupport(expression: number, barIndex: number): boolean { + if (expression >= 0.55) { + return true; + } + + return barIndex % SUPPORT_BAR_SPACING === SUPPORT_BAR_OFFSET; + } + + private shouldPlayTexture(expression: number, barIndex: number): boolean { + const spacing = + expression < 0.35 + ? IDLE_TEXTURE_BAR_SPACING + : expression < 0.7 + ? MEDIUM_TEXTURE_BAR_SPACING + : 1; + + return barIndex % spacing === (spacing === 1 ? 0 : 1); + } + + private getSupportOffsets( + chordIntervals: ReadonlyArray, + selectedColorIndex: GardenAudioColorIndex + ): Array { + if (selectedColorIndex === 0) { + return [0, chordIntervals[2], 12]; + } + + if (selectedColorIndex === 1) { + return [chordIntervals[1], chordIntervals[2], 0, 12]; + } + + return [chordIntervals[2], 12, chordIntervals[3], chordIntervals[1] + 12]; + } + + private getChord( + profile: GardenAudioVibeProfile, + barIndex: number + ): GardenAudioChord { + const progressionIndex = + Math.floor(barIndex / CHORD_BARS) % profile.progression.length; + return profile.progression[progressionIndex]; + } + + private getGlobalBarIndex(startTime: number): number { + const timelineStartedAt = this.timelineStartedAt ?? startTime; + const elapsedSeconds = Math.max(0, startTime - timelineStartedAt); + return Math.floor(elapsedSeconds / this.getBarDurationSeconds()); + } + + private getColorPan(selectedColorIndex: GardenAudioColorIndex): number { + const pool = COLOR_POOLS[selectedColorIndex]; + const colorVoice = this.config.colorVoices[selectedColorIndex]; + return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1); + } + + private getLowpassHz( + profile: GardenAudioVibeProfile, + midi: number, + expression: number + ): number { + const midiLift = clamp01((midi - 48) / 33) * 720; + return clamp( + this.config.piano.lowpassHz * profile.brightness * (0.58 + expression * 0.32) + + midiLift, + appConfig.audioEngine.piano.lowpassMinHz, + appConfig.audioEngine.piano.lowpassMaxHz + ); + } + + private skipLateBeats(now: number, beatSeconds: number): void { + if (this.nextBeatAt === null) { + return; + } + + const earliestStart = now + appConfig.audioEngine.piano.scheduleAheadSeconds; + if (this.nextBeatAt >= earliestStart) { + return; + } + + const skippedBeats = Math.floor((earliestStart - this.nextBeatAt) / beatSeconds) + 1; + this.nextBeatAt += skippedBeats * beatSeconds; + this.beatIndex += skippedBeats; + } + + private getExpression(activity: number): number { + return clamp01( + (activity - this.config.rhythm.sparseActivity) / + (1 - this.config.rhythm.sparseActivity) + ); + } + + private getBeatDurationSeconds(): number { + return 60 / this.config.rhythm.bpm; + } + + 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 rotate(values: ReadonlyArray, offset: number): Array { + return values.map((_, index) => values[(index + offset) % values.length]); + } +} diff --git a/src/audio/noise-burst-player.ts b/src/audio/noise-burst-player.ts new file mode 100644 index 0000000..3280978 --- /dev/null +++ b/src/audio/noise-burst-player.ts @@ -0,0 +1,65 @@ +import type { GardenAudioEngineConfig } from '../config'; +import { GardenAudioGraph } from './garden-audio-graph'; +import { NoiseBurst } from './garden-audio-types'; + +export class NoiseBurstPlayer { + public constructor( + private readonly engineConfig: GardenAudioEngineConfig, + private readonly graph: GardenAudioGraph + ) {} + + public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void { + const { context, eventBus, noiseBuffer } = this.graph; + if (!context || !eventBus || !noiseBuffer) { + return; + } + + const scheduledStart = Math.max( + context.currentTime + this.engineConfig.noiseBurst.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 = 'bandpass'; + filter.frequency.setValueAtTime(filterHz, scheduledStart); + filter.Q.value = this.engineConfig.noiseBurst.filterQ; + envelope.gain.setValueAtTime( + this.engineConfig.noiseBurst.silentGain, + scheduledStart + ); + envelope.gain.exponentialRampToValueAtTime( + Math.max(this.engineConfig.noiseBurst.silentGain, gain), + scheduledStart + this.engineConfig.noiseBurst.attackSeconds + ); + envelope.gain.exponentialRampToValueAtTime( + this.engineConfig.noiseBurst.silentGain, + stopAt + ); + panner.pan.setValueAtTime(pan, scheduledStart); + + source.connect(filter); + filter.connect(envelope); + envelope.connect(panner); + panner.connect(eventBus); + source.start( + scheduledStart, + Math.random() * this.engineConfig.noiseBurst.offsetRandomSeconds + ); + 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..d0a678e --- /dev/null +++ b/src/audio/piano-sampler.ts @@ -0,0 +1,285 @@ +import type { GardenAudioEngineConfig } from '../config'; +import { clamp, clamp01 } from '../utils/clamp'; +import { GardenAudioConfig } from './garden-audio-config'; +import { GardenAudioGraph } from './garden-audio-graph'; +import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types'; +import { pianoSampleDefinitions } from './piano-samples'; + +export class PianoSampler { + private sampleLoadPromise: Promise | null = null; + private samples: Array = []; + private activeVoices: Array = []; + + public constructor( + private readonly config: GardenAudioConfig, + private readonly engineConfig: GardenAudioEngineConfig, + private readonly graph: GardenAudioGraph + ) {} + + public async load(context: AudioContext): Promise { + if (this.sampleLoadPromise) { + return this.sampleLoadPromise; + } + + this.sampleLoadPromise = Promise.all( + pianoSampleDefinitions.map(async (sample) => { + const response = await fetch(sample.url); + if (!response.ok) { + throw new Error(`Unable to load piano sample ${sample.url}`); + } + const audioData = await response.arrayBuffer(); + const buffer = await context.decodeAudioData(audioData); + return { midi: sample.midi, buffer }; + }) + ) + .then((samples) => { + this.samples = samples.sort((a, b) => a.midi - b.midi); + }) + .catch(() => { + this.samples = []; + }); + + return this.sampleLoadPromise; + } + + public play({ + midi, + velocity, + startTime, + durationSeconds, + pan, + delaySend = 0, + lowpassHz = this.config.piano.lowpassHz, + }: PianoNote): void { + const { context, eventBus, delayInput } = this.graph; + if (!context || !eventBus) { + return; + } + + const sample = this.findNearestSample(midi); + if (!sample) { + this.playFallbackPluck({ + midi, + velocity, + startTime, + durationSeconds, + pan, + delaySend, + lowpassHz, + }); + return; + } + + const scheduledStart = Math.max( + context.currentTime + this.engineConfig.piano.scheduleAheadSeconds, + startTime + ); + const noteVelocity = clamp01(velocity); + const noteGainValue = Math.max( + this.engineConfig.piano.minGain, + this.config.piano.gain * noteVelocity + ); + const sustainSeconds = + this.config.piano.sustainSeconds * + (this.engineConfig.piano.sustainBase + + noteVelocity * this.engineConfig.piano.sustainVelocityRange); + const sustainAt = + scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); + const releaseAt = sustainAt + sustainSeconds; + const releaseSeconds = this.config.piano.releaseSeconds; + const stopAt = releaseAt + releaseSeconds; + const source = context.createBufferSource(); + 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(); + oldest?.gain.gain.cancelScheduledValues(scheduledStart); + oldest?.gain.gain.setTargetAtTime( + this.engineConfig.piano.minGain, + scheduledStart, + this.engineConfig.piano.voiceStealFadeSeconds + ); + oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds); + } + + source.buffer = sample.buffer; + source.playbackRate.setValueAtTime( + Math.pow( + 2, + (midi - sample.midi) / this.engineConfig.piano.pitchSemitonesPerOctave + ), + scheduledStart + ); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime( + clamp( + lowpassHz, + this.engineConfig.piano.lowpassMinHz, + this.engineConfig.piano.lowpassMaxHz + ), + scheduledStart + ); + filter.Q.value = this.engineConfig.piano.filterQ; + gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart); + gain.gain.exponentialRampToValueAtTime( + noteGainValue, + scheduledStart + this.engineConfig.piano.gainAttackSeconds + ); + gain.gain.setTargetAtTime( + Math.max( + this.engineConfig.piano.minGain, + noteGainValue * this.config.piano.sustainLevel + ), + sustainAt, + Math.max( + this.engineConfig.piano.minFadeSeconds, + sustainSeconds * this.engineConfig.piano.sustainBase + ) + ); + gain.gain.setTargetAtTime( + this.engineConfig.piano.minGain, + releaseAt, + releaseSeconds + ); + panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); + + 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 + this.engineConfig.piano.tailStopExtraSeconds); + this.activeVoices.push({ gain, source, startAt: scheduledStart, 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 } + ); + } + + public reset(): void { + this.sampleLoadPromise = null; + this.samples = []; + this.activeVoices = []; + } + + 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 playFallbackPluck({ + midi, + velocity, + startTime, + durationSeconds, + pan, + delaySend = 0, + lowpassHz = this.config.piano.lowpassHz, + }: PianoNote): void { + const { context, eventBus, delayInput } = this.graph; + if (!context || !eventBus) { + return; + } + + const scheduledStart = Math.max( + context.currentTime + this.engineConfig.piano.scheduleAheadSeconds, + startTime + ); + const oscillator = context.createOscillator(); + const filter = context.createBiquadFilter(); + const gain = context.createGain(); + const panner = context.createStereoPanner(); + let sendGain: GainNode | null = null; + const noteVelocity = clamp01(velocity); + const noteGainValue = Math.max( + this.engineConfig.piano.minGain, + this.config.piano.gain * noteVelocity * 0.42 + ); + const releaseAt = + scheduledStart + Math.max(this.engineConfig.piano.minDurationSeconds, durationSeconds); + const stopAt = releaseAt + this.config.piano.releaseSeconds; + + oscillator.type = 'triangle'; + oscillator.frequency.setValueAtTime( + 440 * Math.pow(2, (midi - 69) / appConfig.audioEngine.piano.pitchSemitonesPerOctave), + scheduledStart + ); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime( + clamp( + lowpassHz * 0.72, + this.engineConfig.piano.lowpassMinHz, + this.engineConfig.piano.lowpassMaxHz + ), + scheduledStart + ); + filter.Q.value = this.engineConfig.piano.filterQ; + gain.gain.setValueAtTime(this.engineConfig.piano.minGain, scheduledStart); + gain.gain.exponentialRampToValueAtTime( + noteGainValue, + scheduledStart + this.engineConfig.piano.gainAttackSeconds + ); + gain.gain.setTargetAtTime( + this.engineConfig.piano.minGain, + releaseAt, + this.config.piano.releaseSeconds + ); + panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart); + + oscillator.connect(filter); + filter.connect(gain); + gain.connect(panner); + panner.connect(eventBus); + + if (delayInput && delaySend > 0) { + sendGain = context.createGain(); + sendGain.gain.value = delaySend * 0.5; + panner.connect(sendGain); + sendGain.connect(delayInput); + } + + oscillator.start(scheduledStart); + oscillator.stop(stopAt + this.engineConfig.piano.tailStopExtraSeconds); + oscillator.addEventListener( + 'ended', + () => { + oscillator.disconnect(); + filter.disconnect(); + gain.disconnect(); + panner.disconnect(); + sendGain?.disconnect(); + }, + { once: true } + ); + } +} diff --git a/src/audio/piano-samples.ts b/src/audio/piano-samples.ts new file mode 100644 index 0000000..097feab --- /dev/null +++ b/src/audio/piano-samples.ts @@ -0,0 +1,46 @@ +interface PianoSampleDefinition { + midi: number; + url: string; +} + +const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`; + +const sampleFiles: Array<[fileName: string, midi: number]> = [ + ['A0v12.m4a', 21], + ['C1v12.m4a', 24], + ['Dsharp1v12.m4a', 27], + ['Fsharp1v12.m4a', 30], + ['A1v12.m4a', 33], + ['C2v12.m4a', 36], + ['Dsharp2v12.m4a', 39], + ['Fsharp2v12.m4a', 42], + ['A2v12.m4a', 45], + ['C3v12.m4a', 48], + ['Dsharp3v12.m4a', 51], + ['Fsharp3v12.m4a', 54], + ['A3v12.m4a', 57], + ['C4v12.m4a', 60], + ['Dsharp4v12.m4a', 63], + ['Fsharp4v12.m4a', 66], + ['A4v12.m4a', 69], + ['C5v12.m4a', 72], + ['Dsharp5v12.m4a', 75], + ['Fsharp5v12.m4a', 78], + ['A5v12.m4a', 81], + ['C6v12.m4a', 84], + ['Dsharp6v12.m4a', 87], + ['Fsharp6v12.m4a', 90], + ['A6v12.m4a', 93], + ['C7v12.m4a', 96], + ['Dsharp7v12.m4a', 99], + ['Fsharp7v12.m4a', 102], + ['A7v12.m4a', 105], + ['C8v12.m4a', 108], +]; + +export const pianoSampleDefinitions: Array = sampleFiles + .map(([fileName, midi]) => ({ + midi, + url: `${sampleBaseUrl}${fileName}`, + })) + .sort((a, b) => a.midi - b.midi); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d45cb61 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,292 @@ +import { runtimeSettings } from './config/runtime-settings'; +import type { GardenAppConfig } from './config/types'; +import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets'; + +export type { + AgentColorInteractionSettings, + GardenAppConfig, + GardenAudioEngineConfig, + GardenRuntimeSettings, + GardenSimulationConfig, + GardenStorageConfig, + GardenVibeSettings, + NumberControlConfig, + RuntimeSettingControlConfig, + VibePreset, +} from './config/types'; + +export const appConfig = { + audio: { + masterVolume: 0.42, + fadeInSeconds: 0.45, + updateRampSeconds: 0.08, + highPassFrequencyHz: 45, + fallbackVibeId: defaultVibeId, + compressor: { + thresholdDb: -18, + kneeDb: 18, + ratio: 2.4, + attackSeconds: 0.006, + releaseSeconds: 0.18, + }, + delay: { + timeSeconds: 0.46, + feedback: 0.12, + wetGain: 0.044, + }, + piano: { + maxVoices: 24, + gain: 0.48, + sustainSeconds: 0.42, + sustainLevel: 0.32, + releaseSeconds: 0.24, + lowpassHz: 7600, + }, + input: { + pressureFallback: 0.48, + }, + rhythm: { + bpm: 74, + stepsPerBeat: 4, + stepsPerBar: 16, + lookaheadSeconds: 0.3, + speedForFullEnergyPixelsPerSecond: 1800, + sparseActivity: 0.055, + }, + eraser: { + minIntervalSeconds: 0.12, + noiseGain: 0.028, + filterMinHz: 650, + filterMaxHz: 3600, + }, + colorVoices: [ + { + scaleDegreeOffset: 0, + velocityMultiplier: 0.92, + panOffset: -0.14, + }, + { + scaleDegreeOffset: 1, + velocityMultiplier: 1, + panOffset: 0, + }, + { + scaleDegreeOffset: 2, + velocityMultiplier: 0.86, + panOffset: 0.14, + }, + ], + vibes: audioVibes, + }, + audioEngine: { + energy: { + attackSeconds: 0.08, + decaySeconds: 0.9, + releaseSeconds: 1.15, + strokeDecaySeconds: 0.32, + }, + eraser: { + canvasWidthRatioForFullSize: 0.18, + defaultSizePixels: 96, + durationSeconds: 0.08, + filterPressureWeight: 0.26, + filterSizeWeight: 0.16, + filterSpeedWeight: 0.58, + gainBase: 0.45, + gainPressureWeight: 0.24, + gainSizeWeight: 0.18, + gainSpeedWeight: 0.38, + }, + delay: { + erasingActivity: 0.12, + }, + graph: { + closeGain: 0.0001, + closeRampSeconds: 0.015, + delayActivityFeedbackWeight: 0.08, + delayFeedbackMax: 0.32, + delayFeedbackMin: 0.04, + delayOutputActivityWeight: 0.5, + delayOutputBase: 0.65, + delayTimeRampSeconds: 0.12, + eventBusGain: 1, + noiseMax: 1, + noiseMin: -1, + unlockBufferLength: 1, + unlockSampleRate: 22050, + }, + input: { + distanceEnergyBase: 0.34, + distanceEnergyScale: 0.66, + distanceForFullEnergyPixels: 140, + fallbackFrameSeconds: 1 / 60, + penMinPressure: 0.56, + strokeEnergyBase: 0.18, + strokeEnergyPressureWeight: 0.22, + strokeEnergySpeedWeight: 0.62, + }, + muteGain: 0.0001, + muteRampSeconds: 0.02, + noiseBurst: { + attackSeconds: 0.004, + filterQ: 1.4, + offsetRandomSeconds: 0.4, + scheduleAheadSeconds: 0.002, + silentGain: 0.0001, + }, + piano: { + filterQ: 0.7, + gainAttackSeconds: 0.006, + lowpassMaxHz: 12000, + lowpassMinHz: 1400, + minDurationSeconds: 0.08, + minFadeSeconds: 0.08, + minGain: 0.0001, + pitchSemitonesPerOctave: 12, + scheduleAheadSeconds: 0.002, + sustainBase: 0.45, + sustainVelocityRange: 0.55, + tailStopExtraSeconds: 0.05, + voiceStealFadeSeconds: 0.025, + voiceStealStopSeconds: 0.05, + }, + startDelaySeconds: 0.02, + vibeChangeStingerMinIntervalSeconds: 0.45, + }, + deltaTime: { + fpsExponentialDecayStrength: 0.01, + maxDeltaTimeSeconds: 1 / 30, + minDeltaTimeSeconds: 1 / 240, + }, + export4k: { + bytesPerPixel: 4, + height: 2160, + jsHeapSafetyMultiplier: 1.5, + lowMemoryDeviceGiB: 2, + lowMemoryExportFraction: 0.08, + rowAlignmentBytes: 256, + width: 3840, + }, + menuHider: { + bottomRevealDistancePx: 96, + intervalMs: 50, + timeToLiveMs: 3500, + }, + pipelines: { + brush: { + maxLineCount: 240, + }, + diffusion: { + minDiffusionRate: 0.000001, + }, + eraser: { + maxSegmentCount: 384, + maxTextureLineCount: 384, + segmentFloatCount: 4, + workgroupSize: 64, + }, + }, + runtimeSettings, + simulation: { + budget: { + adaptiveCapDecreaseAgentsPerSecond: 50_000, + adaptiveCapMin: 500_000, + fpsHeadroom: 0.95, + fpsSmoothingNew: 0.06, + fpsSmoothingRetain: 0.94, + initialTargetAgentBudget: 20_000, + rampAgentsPerSecond: 20_000, + refreshTargetDecay: 0.995, + }, + brushEffectFramesPerSecond: 60, + globalAgentCap: 10_000_000, + initialAgentCount: 180_000, + intro: { + angleJitterRadians: Math.PI * 0.08, + circleMaxSideRatio: 0.46, + circleMinSideRatio: 0.32, + drawHintClass: 'draw-hint', + drawHintDelayMs: 3000, + durationSeconds: 4, + entryJitterSideRatio: 0.035, + fontScaleDown: 0.94, + initialFontHeightRatio: 0.28, + initialFontWidthRatio: 0.19, + letterSpacingEm: 0.07, + maskAlphaThreshold: 32, + maskGradientThreshold: 8, + maskSampleDensity: 540, + maxHeightRatio: 0.25, + maxWidthRatio: 0.76, + minEntryJitterPx: 6, + minFontSizePx: 18, + minTargetJitterPx: 1, + radialJitterRatio: 0.35, + 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, + }, + introMoveSpeedBaseMultiplier: 1.8, + introMoveSpeedProgressMultiplier: 0.35, + maxMirrorSegmentCount: 12, + stroke: { + angleJitterRadians: Math.PI * 0.7, + densityMultiplier: 110, + maxAgentCount: 2_400, + minAgentCount: 140, + }, + }, + storage: { + audioMutedKey: 'fleeting-garden:audio-muted', + vibeKey: 'fleeting-garden:vibe', + }, + telemetry: { + enabled: false, + intervalMs: 1000, + }, + toolbar: { + eraser: { + controlScaleMax: 1.34, + controlScaleMin: 0.74, + default: 96, + max: 240, + min: 24, + step: 1, + }, + mirror: { + default: 1, + 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', + }, + step: 1, + }, + }, + tuningPane: { + expandedDepth: 1, + startHidden: true, + title: 'Garden Config', + }, + vibes: { + defaultVibeId, + presets: vibePresets, + }, +} satisfies GardenAppConfig; diff --git a/src/config/color-interactions.ts b/src/config/color-interactions.ts new file mode 100644 index 0000000..b6c870e --- /dev/null +++ b/src/config/color-interactions.ts @@ -0,0 +1,71 @@ +import type { + AgentColorInteractionSettings, + NumberControlConfig, +} from './types'; + +const agentInteractionOptions: Record = { + Follow: 1, + Avoid: -1, + Ignore: 0, +}; + +export const defaultColorInteractionSettings: AgentColorInteractionSettings = { + color1ToColor1: 1, + color1ToColor2: 0, + color1ToColor3: 0, + color2ToColor1: 0, + color2ToColor2: 1, + color2ToColor3: 0, + color3ToColor1: 0, + color3ToColor2: 0, + color3ToColor3: 1, +}; + +const hashString = (value: string): number => { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +}; + +const createSeededRandom = (seed: number): (() => number) => { + let state = seed; + return () => { + let value = (state += 0x6d2b79f5); + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; +}; + +export const createColorInteractionSettings = ( + seedSource: string +): AgentColorInteractionSettings => { + const random = createSeededRandom(hashString(seedSource)); + const values = Object.values(agentInteractionOptions); + const randomInteraction = () => + values[Math.floor(random() * values.length)] ?? defaultColorInteractionSettings.color1ToColor2; + + return { + color1ToColor1: 1, + color1ToColor2: randomInteraction(), + color1ToColor3: randomInteraction(), + color2ToColor1: randomInteraction(), + color2ToColor2: 1, + color2ToColor3: randomInteraction(), + color3ToColor1: randomInteraction(), + color3ToColor2: randomInteraction(), + color3ToColor3: 1, + }; +}; + +export const colorInteractionControl = (label: string): NumberControlConfig => ({ + folder: 'Color Reactions', + label, + min: -1, + max: 1, + step: 1, + options: agentInteractionOptions, +}); diff --git a/src/config/runtime-settings.ts b/src/config/runtime-settings.ts new file mode 100644 index 0000000..2de328f --- /dev/null +++ b/src/config/runtime-settings.ts @@ -0,0 +1,206 @@ +import { + colorInteractionControl, + defaultColorInteractionSettings, +} from './color-interactions'; +import type { GardenAppConfig } from './types'; + +export const runtimeSettings: GardenAppConfig['runtimeSettings'] = { + defaults: { + agentBudgetMax: 1_000_000, + agentCount: 0, + selectedColorIndex: 0, + spawnPerPixel: 0.22, + + moveSpeed: 82, + turnSpeed: 58, + sensorOffsetAngle: 34, + sensorOffsetDistance: 38, + turnWhenLost: 0.8, + + individualTrailWeight: 0.07, + ...defaultColorInteractionSettings, + + diffusionRateTrails: 0.22, + decayRateTrails: 965, + diffusionRateBrush: 0.35, + decayRateBrush: 18, + brushEffectDuration: 8, + + clarity: 0.62, + brushSize: 14, + brushCurveResolution: 12, + eraserSize: 96, + mirrorSegmentCount: 1, + + brushSizeVariation: 0.5, + + startColorHue: 200, + + renderSpeed: 1, + simulatedDelayMs: 0, + }, + controls: { + agentBudgetMax: { + folder: 'Runtime', + integer: true, + min: 500_000, + max: 10_000_000, + step: 50_000, + }, + agentCount: { + folder: 'Runtime', + integer: true, + min: 0, + max: 1_000_000, + step: 1_000, + }, + color1ToColor1: colorInteractionControl('1 -> 1'), + color1ToColor2: colorInteractionControl('1 -> 2'), + color1ToColor3: colorInteractionControl('1 -> 3'), + color2ToColor1: colorInteractionControl('2 -> 1'), + color2ToColor2: colorInteractionControl('2 -> 2'), + color2ToColor3: colorInteractionControl('2 -> 3'), + color3ToColor1: colorInteractionControl('3 -> 1'), + color3ToColor2: colorInteractionControl('3 -> 2'), + color3ToColor3: colorInteractionControl('3 -> 3'), + brushEffectDuration: { + folder: 'Diffusion', + min: 0.5, + max: 20, + step: 0.05, + }, + brushSize: { + folder: 'Brush', + min: 1, + max: 60, + step: 0.25, + }, + brushSizeVariation: { + folder: 'Brush', + min: 0, + max: 1, + step: 0.01, + }, + brushCurveResolution: { + folder: 'Brush', + integer: true, + label: 'curve resolution', + min: 1, + max: 32, + step: 1, + }, + clarity: { + folder: 'Render', + min: 0.00001, + max: 1, + step: 0.001, + }, + decayRateBrush: { + folder: 'Diffusion', + min: 0.1, + max: 100, + step: 0.1, + }, + decayRateTrails: { + folder: 'Diffusion', + min: 0.1, + max: 5000, + step: 1, + }, + diffusionRateBrush: { + folder: 'Diffusion', + min: 0.001, + max: 1, + step: 0.001, + }, + diffusionRateTrails: { + folder: 'Diffusion', + min: 0, + max: 2, + step: 0.001, + }, + eraserSize: { + folder: 'Brush', + integer: true, + min: 24, + max: 240, + step: 1, + }, + individualTrailWeight: { + folder: 'Agent', + min: 0, + max: 1, + step: 0.001, + }, + mirrorSegmentCount: { + folder: 'Brush', + integer: true, + min: 1, + max: 12, + step: 1, + }, + moveSpeed: { + folder: 'Agent', + min: 10, + max: 500, + step: 1, + }, + renderSpeed: { + folder: 'Runtime', + integer: true, + min: 1, + max: 10, + step: 1, + }, + selectedColorIndex: { + folder: 'Brush', + integer: true, + min: 0, + max: 2, + step: 1, + }, + sensorOffsetAngle: { + folder: 'Agent', + min: 0, + max: 90, + step: 1, + }, + sensorOffsetDistance: { + folder: 'Agent', + min: 0, + max: 200, + step: 1, + }, + simulatedDelayMs: { + folder: 'Runtime', + integer: true, + min: 0, + max: 2000, + step: 1, + }, + spawnPerPixel: { + folder: 'Agent', + min: 0.01, + max: 1, + step: 0.001, + }, + startColorHue: { + folder: 'Render', + min: 0, + max: 360, + step: 1, + }, + turnSpeed: { + folder: 'Agent', + min: 1, + max: 200, + step: 1, + }, + turnWhenLost: { + folder: 'Agent', + min: 0, + max: 1, + step: 0.001, + }, + }, +}; diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..6557e4f --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,287 @@ +import type { + GardenAudioConfig, + GardenAudioVibeProfile, +} from '../audio/garden-audio-config'; +import type { GameLoopSettings } from '../game-loop/game-loop-settings'; +import type { AgentSettings } from '../pipelines/agents/agent-settings'; +import type { BrushSettings } from '../pipelines/brush/brush-settings'; +import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings'; +import type { RenderSettings } from '../pipelines/render/render-settings'; + +export type GardenRuntimeSettings = GameLoopSettings & + AgentSettings & + BrushSettings & + DiffusionSettings & + RenderSettings; + +export type AgentColorInteractionSettings = Pick< + AgentSettings, + | 'color1ToColor1' + | 'color1ToColor2' + | 'color1ToColor3' + | 'color2ToColor1' + | 'color2ToColor2' + | 'color2ToColor3' + | 'color3ToColor1' + | 'color3ToColor2' + | 'color3ToColor3' +>; + +export type GardenVibeSettings = Partial< + Pick< + GardenRuntimeSettings, + | 'agentBudgetMax' + | 'brushSize' + | 'color1ToColor1' + | 'color1ToColor2' + | 'color1ToColor3' + | 'color2ToColor1' + | 'color2ToColor2' + | 'color2ToColor3' + | 'color3ToColor1' + | 'color3ToColor2' + | 'color3ToColor3' + | 'clarity' + | 'decayRateTrails' + | 'diffusionRateTrails' + | 'individualTrailWeight' + | 'moveSpeed' + | 'sensorOffsetAngle' + | 'sensorOffsetDistance' + | 'spawnPerPixel' + | 'turnSpeed' + > +>; + +export interface VibePreset { + id: string; + name: string; + colors: [string, string, string]; + backgroundColor: string; + settings: GardenVibeSettings; + audio: GardenAudioVibeProfile; +} + +export interface NumberControlConfig { + folder: string; + integer?: boolean; + label?: string; + max: number; + min: number; + options?: Record; + step?: number; +} + +export type RuntimeSettingControlConfig = { + [Key in keyof GardenRuntimeSettings]: NumberControlConfig; +}; + +export interface GardenAppConfig { + audio: GardenAudioConfig; + audioEngine: { + energy: { + attackSeconds: number; + decaySeconds: number; + releaseSeconds: number; + strokeDecaySeconds: number; + }; + eraser: { + canvasWidthRatioForFullSize: number; + defaultSizePixels: number; + durationSeconds: number; + filterPressureWeight: number; + filterSizeWeight: number; + filterSpeedWeight: number; + gainBase: number; + gainPressureWeight: number; + gainSizeWeight: number; + gainSpeedWeight: number; + }; + delay: { + erasingActivity: number; + }; + graph: { + closeGain: number; + closeRampSeconds: number; + delayActivityFeedbackWeight: number; + delayFeedbackMax: number; + delayFeedbackMin: number; + delayOutputActivityWeight: number; + delayOutputBase: number; + delayTimeRampSeconds: number; + eventBusGain: number; + noiseMax: number; + noiseMin: number; + unlockBufferLength: number; + unlockSampleRate: number; + }; + input: { + distanceEnergyBase: number; + distanceEnergyScale: number; + distanceForFullEnergyPixels: number; + fallbackFrameSeconds: number; + penMinPressure: number; + strokeEnergyBase: number; + strokeEnergyPressureWeight: number; + strokeEnergySpeedWeight: number; + }; + muteGain: number; + muteRampSeconds: number; + noiseBurst: { + attackSeconds: number; + filterQ: number; + offsetRandomSeconds: number; + scheduleAheadSeconds: number; + silentGain: number; + }; + piano: { + filterQ: number; + gainAttackSeconds: number; + lowpassMaxHz: number; + lowpassMinHz: number; + minDurationSeconds: number; + minFadeSeconds: number; + minGain: number; + pitchSemitonesPerOctave: number; + scheduleAheadSeconds: number; + sustainBase: number; + sustainVelocityRange: number; + tailStopExtraSeconds: number; + voiceStealFadeSeconds: number; + voiceStealStopSeconds: number; + }; + startDelaySeconds: number; + vibeChangeStingerMinIntervalSeconds: number; + }; + deltaTime: { + fpsExponentialDecayStrength: number; + maxDeltaTimeSeconds: number; + minDeltaTimeSeconds: number; + }; + export4k: { + bytesPerPixel: number; + height: number; + jsHeapSafetyMultiplier: number; + lowMemoryDeviceGiB: number; + lowMemoryExportFraction: number; + rowAlignmentBytes: number; + width: number; + }; + menuHider: { + bottomRevealDistancePx: number; + intervalMs: number; + timeToLiveMs: number; + }; + pipelines: { + brush: { + maxLineCount: number; + }; + diffusion: { + minDiffusionRate: number; + }; + eraser: { + maxSegmentCount: number; + maxTextureLineCount: number; + segmentFloatCount: number; + workgroupSize: number; + }; + }; + runtimeSettings: { + controls: RuntimeSettingControlConfig; + defaults: GardenRuntimeSettings; + }; + simulation: { + budget: { + adaptiveCapDecreaseAgentsPerSecond: number; + adaptiveCapMin: number; + fpsHeadroom: number; + fpsSmoothingNew: number; + fpsSmoothingRetain: number; + initialTargetAgentBudget: number; + rampAgentsPerSecond: number; + refreshTargetDecay: number; + }; + brushEffectFramesPerSecond: number; + globalAgentCap: number; + initialAgentCount: number; + intro: { + angleJitterRadians: number; + circleMaxSideRatio: number; + circleMinSideRatio: number; + drawHintClass: string; + drawHintDelayMs: number; + durationSeconds: number; + entryJitterSideRatio: number; + fontScaleDown: number; + initialFontHeightRatio: number; + initialFontWidthRatio: number; + letterSpacingEm: number; + maskAlphaThreshold: number; + maskGradientThreshold: number; + maskSampleDensity: number; + maxHeightRatio: number; + maxWidthRatio: number; + minEntryJitterPx: number; + minFontSizePx: number; + minTargetJitterPx: number; + radialJitterRatio: number; + targetDelayDistanceMultiplier: number; + targetDelayMax: number; + targetDelayRandomMultiplier: number; + targetJitterSideRatio: number; + title: string; + titleColorCutLetters: [number, number]; + titleRadiusMultiplier: number; + titleStrokeWidthMinPx: number; + titleStrokeWidthRatio: number; + verticalAnchor: number; + }; + introMoveSpeedBaseMultiplier: number; + introMoveSpeedProgressMultiplier: number; + maxMirrorSegmentCount: number; + stroke: { + angleJitterRadians: number; + densityMultiplier: number; + maxAgentCount: number; + minAgentCount: number; + }; + }; + storage: { + audioMutedKey: string; + vibeKey: string; + }; + telemetry: { + enabled: boolean; + intervalMs: number; + }; + toolbar: { + eraser: { + controlScaleMax: number; + controlScaleMin: number; + default: number; + max: number; + min: number; + step: number; + }; + mirror: { + default: number; + max: number; + min: number; + names: Record; + step: number; + }; + }; + tuningPane: { + expandedDepth: number; + startHidden: boolean; + title: string; + }; + vibes: { + defaultVibeId: string; + presets: Array; + }; +} + +export type GardenAudioEngineConfig = GardenAppConfig['audioEngine']; +export type GardenSimulationConfig = GardenAppConfig['simulation']; +export type GardenStorageConfig = GardenAppConfig['storage']; diff --git a/src/config/vibe-presets.ts b/src/config/vibe-presets.ts new file mode 100644 index 0000000..d4fd90f --- /dev/null +++ b/src/config/vibe-presets.ts @@ -0,0 +1,204 @@ +import type { + GardenAudioChord, + GardenAudioVibeProfile, +} from '../audio/garden-audio-config'; +import { createColorInteractionSettings } from './color-interactions'; +import type { VibePreset } from './types'; + +const majorProgression: Array = [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 5, quality: 'major' }, + { rootOffset: 7, quality: 'major' }, +]; + +const minorProgression: Array = [ + { rootOffset: 0, quality: 'minor' }, + { rootOffset: 8, quality: 'major' }, + { rootOffset: 3, quality: 'major' }, + { rootOffset: 10, quality: 'major' }, +]; + +const majorPentatonic = [0, 2, 4, 7, 9]; +const minorPentatonic = [0, 3, 5, 7, 10]; + +export const defaultVibeId = 'candy-rain'; + +export const vibePresets: Array = [ + { + id: 'candy-rain', + name: 'Candy Rain', + colors: ['#ff5da2', '#36d7d0', '#ffd84d'], + backgroundColor: '#10151f', + settings: { + agentBudgetMax: 1_000_000, + brushSize: 14, + clarity: 0.62, + decayRateTrails: 965, + diffusionRateTrails: 0.22, + individualTrailWeight: 0.07, + moveSpeed: 82, + sensorOffsetAngle: 34, + sensorOffsetDistance: 38, + spawnPerPixel: 0.22, + turnSpeed: 58, + ...createColorInteractionSettings('candy-rain'), + }, + audio: { + rootMidi: 57, + scale: majorPentatonic, + brightness: 1.04, + delayTimeMultiplier: 0.92, + progression: majorProgression, + }, + }, + { + id: 'sunlit-moss', + name: 'Sunlit Moss', + colors: ['#83d483', '#f6d76b', '#5ec1a1'], + backgroundColor: '#172016', + settings: { + agentBudgetMax: 1_000_000, + brushSize: 16, + clarity: 0.68, + decayRateTrails: 975, + diffusionRateTrails: 0.18, + individualTrailWeight: 0.06, + moveSpeed: 70, + sensorOffsetAngle: 28, + sensorOffsetDistance: 46, + spawnPerPixel: 0.18, + turnSpeed: 44, + ...createColorInteractionSettings('sunlit-moss'), + }, + audio: { + rootMidi: 53, + scale: majorPentatonic, + brightness: 0.92, + delayTimeMultiplier: 1.08, + progression: [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 7, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 5, quality: 'major' }, + ], + }, + }, + { + id: 'coral-tide', + name: 'Coral Tide', + colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'], + backgroundColor: '#0f1822', + settings: { + agentBudgetMax: 1_000_000, + brushSize: 13, + clarity: 0.58, + decayRateTrails: 955, + diffusionRateTrails: 0.28, + individualTrailWeight: 0.055, + moveSpeed: 90, + sensorOffsetAngle: 36, + sensorOffsetDistance: 35, + spawnPerPixel: 0.25, + turnSpeed: 62, + ...createColorInteractionSettings('coral-tide'), + }, + audio: { + rootMidi: 50, + scale: minorPentatonic, + brightness: 1, + delayTimeMultiplier: 1.12, + progression: minorProgression, + }, + }, + { + id: 'moon-orchid', + name: 'Moon Orchid', + colors: ['#c993ff', '#7dd8ff', '#f0f4ff'], + backgroundColor: '#14121d', + settings: { + agentBudgetMax: 1_000_000, + brushSize: 12, + clarity: 0.64, + decayRateTrails: 968, + diffusionRateTrails: 0.2, + individualTrailWeight: 0.065, + moveSpeed: 76, + sensorOffsetAngle: 32, + sensorOffsetDistance: 42, + spawnPerPixel: 0.2, + turnSpeed: 52, + ...createColorInteractionSettings('moon-orchid'), + }, + audio: { + rootMidi: 49, + scale: minorPentatonic, + brightness: 0.9, + delayTimeMultiplier: 1.24, + progression: minorProgression, + }, + }, + { + id: 'peach-neon', + name: 'Peach Neon', + colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'], + backgroundColor: '#191716', + settings: { + agentBudgetMax: 1_000_000, + brushSize: 15, + clarity: 0.55, + decayRateTrails: 948, + diffusionRateTrails: 0.32, + individualTrailWeight: 0.05, + moveSpeed: 96, + sensorOffsetAngle: 40, + sensorOffsetDistance: 32, + spawnPerPixel: 0.24, + turnSpeed: 70, + ...createColorInteractionSettings('peach-neon'), + }, + audio: { + rootMidi: 56, + scale: majorPentatonic, + brightness: 1.08, + delayTimeMultiplier: 0.86, + progression: majorProgression, + }, + }, + { + id: 'frost-bloom', + name: 'Frost Bloom', + colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'], + backgroundColor: '#101820', + settings: { + agentBudgetMax: 1_000_000, + brushSize: 18, + clarity: 0.7, + decayRateTrails: 982, + diffusionRateTrails: 0.14, + individualTrailWeight: 0.075, + moveSpeed: 62, + sensorOffsetAngle: 26, + sensorOffsetDistance: 52, + spawnPerPixel: 0.16, + turnSpeed: 40, + ...createColorInteractionSettings('frost-bloom'), + }, + audio: { + rootMidi: 62, + scale: majorPentatonic, + brightness: 0.88, + delayTimeMultiplier: 1.32, + progression: [ + { rootOffset: 0, quality: 'major' }, + { rootOffset: 5, quality: 'major' }, + { rootOffset: 9, quality: 'minor' }, + { rootOffset: 7, quality: 'major' }, + ], + }, + }, +]; + +export const audioVibes = Object.fromEntries( + vibePresets.map((vibe) => [vibe.id, vibe.audio]) +) as Record; 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..93dbcba --- /dev/null +++ b/src/game-loop/agent-population.test.ts @@ -0,0 +1,86 @@ +import { vec2 } from 'gl-matrix'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.hoisted(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + }, + }); +}); + +import { appConfig } from '../config'; +import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { settings } from '../settings'; +import { AgentPopulation } from './agent-population'; + +const originalAgentBudgetMax = settings.agentBudgetMax; +const originalBrushSize = settings.brushSize; +const originalSelectedColorIndex = settings.selectedColorIndex; +const originalSpawnPerPixel = settings.spawnPerPixel; + +const createPopulation = () => { + const pipeline = { + maxAgentCount: 10_000_000, + writeAgents: vi.fn(), + resizeAgents: vi.fn(), + compactAgents: vi.fn(), + } as unknown as AgentGenerationPipeline; + + return new AgentPopulation(pipeline); +}; + +const setPopulationCounts = ( + population: AgentPopulation, + activeCount: number, + targetBudget: number +) => { + Object.assign(population as unknown as Record, { + activeCount, + targetBudget, + }); +}; + +describe('AgentPopulation adaptive budget', () => { + beforeEach(() => { + settings.agentBudgetMax = 1_000_000; + settings.brushSize = 1; + settings.selectedColorIndex = 0; + settings.spawnPerPixel = 1; + }); + + afterEach(() => { + settings.agentBudgetMax = originalAgentBudgetMax; + settings.brushSize = originalBrushSize; + settings.selectedColorIndex = originalSelectedColorIndex; + settings.spawnPerPixel = originalSpawnPerPixel; + }); + + it('expands beyond the 1M start cap only when new agents arrive under healthy FPS', () => { + const population = createPopulation(); + setPopulationCounts(population, 1_000_000, 1_000_000); + + population.growBudget(1 / 60, 60, 60); + population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); + + expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000); + expect(population.activeAgentCount).toBeGreaterThan(1_000_000); + expect(settings.agentBudgetMax).toBeLessThanOrEqual( + appConfig.simulation.globalAgentCap + ); + }); + + it('decreases the cap and active count slowly when FPS falls below the threshold', () => { + const population = createPopulation(); + setPopulationCounts(population, 1_000_000, 1_000_000); + + population.growBudget(10, 50, 60); + + expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin); + expect(population.activeAgentCount).toBe( + appConfig.simulation.budget.adaptiveCapMin + ); + }); +}); diff --git a/src/game-loop/agent-population.ts b/src/game-loop/agent-population.ts new file mode 100644 index 0000000..8919472 --- /dev/null +++ b/src/game-loop/agent-population.ts @@ -0,0 +1,247 @@ +import { vec2 } from 'gl-matrix'; + +import { appConfig } from '../config'; +import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent'; +import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; +import { settings } from '../settings'; +import { createIntroTitleAgents } from './intro-title-agents'; + +export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap; + +const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount; +const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount; +const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount; +const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier; +const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin; +const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = + appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond; + +export class AgentPopulation { + private activeCount = 0; + private targetBudget = appConfig.simulation.budget.initialTargetAgentBudget; + private replacementCursor = 0; + private canExpandAdaptiveCap = true; + private shouldCompactAfterErase = false; + private isCompacting = false; + private readonly strokeAgentData = new Float32Array( + MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT + ); + + public constructor(private readonly pipeline: AgentGenerationPipeline) {} + + public get activeAgentCount(): number { + return this.activeCount; + } + + public get targetAgentBudget(): number { + return this.targetBudget; + } + + public get maxAgentCount(): number { + return this.pipeline.maxAgentCount; + } + + public initializeIntroAgents(canvasSize: vec2): void { + settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); + this.targetBudget = Math.min( + this.pipeline.maxAgentCount, + settings.agentBudgetMax, + INITIAL_AGENT_COUNT + ); + this.writeAgentBatch( + createIntroTitleAgents({ + count: this.targetBudget, + width: canvasSize[0], + height: canvasSize[1], + }) + ); + } + + public onVibeChanged(): void { + settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); + this.targetBudget = Math.min( + this.targetBudget, + settings.agentBudgetMax, + this.pipeline.maxAgentCount + ); + } + + public growBudget( + deltaTime: number, + smoothedFps: number, + refreshTargetFps: number + ): void { + this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps); + + const cap = this.clampAdaptiveCap(settings.agentBudgetMax); + if ( + this.targetBudget < cap && + smoothedFps > refreshTargetFps * appConfig.simulation.budget.fpsHeadroom + ) { + this.targetBudget = Math.min( + cap, + this.targetBudget + + Math.ceil(appConfig.simulation.budget.rampAgentsPerSecond * deltaTime) + ); + } + } + + public resizeAgents(scale: vec2): void { + this.pipeline.resizeAgents(this.activeCount, scale); + } + + public requestCompactionAfterErase(): void { + this.shouldCompactAfterErase = true; + } + + public async compactAfterErase(isSwipeActive: boolean): Promise { + if (!this.shouldCompactAfterErase || this.isCompacting || isSwipeActive) { + return; + } + + this.shouldCompactAfterErase = false; + if (this.activeCount === 0) { + return; + } + + this.isCompacting = true; + try { + const compactedAgentCount = await this.pipeline.compactAgents(this.activeCount); + this.activeCount = compactedAgentCount; + this.replacementCursor = + compactedAgentCount === 0 ? 0 : this.replacementCursor % compactedAgentCount; + this.targetBudget = Math.max(this.targetBudget, compactedAgentCount); + } finally { + this.isCompacting = false; + } + } + + public spawnStrokeAgents(from: vec2, to: vec2): void { + const length = Math.max(1, vec2.dist(from, to)); + const count = Math.max( + MIN_STROKE_AGENT_COUNT, + Math.min( + MAX_STROKE_AGENT_COUNT, + Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER) + ) + ); + const direction = vec2.sub(vec2.create(), to, from); + const baseAngle = Math.atan2(direction[1], direction[0]); + + for (let i = 0; i < count; i++) { + const t = count === 1 ? 1 : i / (count - 1); + const x = from[0] + (to[0] - from[0]) * t; + const y = from[1] + (to[1] - from[1]) * t; + const angle = + (Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) + + (Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians; + const base = i * AGENT_FLOAT_COUNT; + this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize; + this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize; + this.strokeAgentData[base + 2] = angle; + this.strokeAgentData[base + 3] = settings.selectedColorIndex; + this.strokeAgentData[base + 4] = -1; + this.strokeAgentData[base + 5] = -1; + this.strokeAgentData[base + 6] = angle; + this.strokeAgentData[base + 7] = 0; + } + + this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT)); + } + + private writeAgentBatch(data: Float32Array): void { + if (data.length === 0) { + return; + } + + const count = data.length / AGENT_FLOAT_COUNT; + this.expandAdaptiveCapForPendingAgents(count); + + const available = Math.max(0, this.targetBudget - 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 updateAdaptiveCap( + deltaTime: number, + smoothedFps: number, + refreshTargetFps: number + ): void { + const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax); + this.canExpandAdaptiveCap = + smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom; + + if (this.canExpandAdaptiveCap) { + settings.agentBudgetMax = previousCap; + return; + } + + const decrease = Math.max( + 1, + Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime) + ); + const nextCap = this.clampAdaptiveCap(previousCap - decrease); + settings.agentBudgetMax = nextCap; + this.targetBudget = Math.min(this.targetBudget, nextCap); + + if (this.activeCount > this.targetBudget) { + this.activeCount = Math.max(this.targetBudget, this.activeCount - decrease); + this.replacementCursor = + this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount; + } + } + + private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void { + const available = Math.max(0, this.targetBudget - this.activeCount); + if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) { + return; + } + + const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax); + if (this.targetBudget < currentCap) { + return; + } + + const pendingAgentCount = requestedAgentCount - available; + const nextCap = this.clampAdaptiveCap(currentCap + pendingAgentCount); + settings.agentBudgetMax = nextCap; + this.targetBudget = Math.max( + this.targetBudget, + Math.min(nextCap, this.activeCount + requestedAgentCount) + ); + } + + private clampAdaptiveCap(value: number): number { + const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount)); + const minCap = Math.min(ADAPTIVE_CAP_MIN, pipelineCap); + const finiteValue = Number.isFinite(value) ? value : minCap; + return Math.min(pipelineCap, Math.max(minCap, Math.round(finiteValue))); + } +} diff --git a/src/game-loop/eraser-preview.ts b/src/game-loop/eraser-preview.ts new file mode 100644 index 0000000..61e6d9d --- /dev/null +++ b/src/game-loop/eraser-preview.ts @@ -0,0 +1,80 @@ +import { settings } from '../settings'; + +export class EraserPreview { + private previewClientPosition: { x: number; y: number } | null = null; + private isErasing = false; + private isPointerHoveringCanvas = false; + private previousSize: number | null = null; + private previousLeft = ''; + private previousTop = ''; + private isVisible = false; + + public constructor( + private readonly canvas: HTMLCanvasElement, + private readonly element: HTMLElement + ) {} + + public setEraseMode(isErasing: boolean, isSwipeActive: boolean): void { + this.isErasing = isErasing; + this.update(undefined, isSwipeActive); + } + + public setPointerHoveringCanvas(isHovering: boolean): void { + this.isPointerHoveringCanvas = isHovering; + } + + public update(event?: PointerEvent, isSwipeActive = false): void { + 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 && !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); + } + + public 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 setVisible(isVisible: boolean): void { + if (this.isVisible === isVisible) { + return; + } + + this.isVisible = isVisible; + this.element.classList.toggle('visible', isVisible); + } +} diff --git a/src/game-loop/export-4k-renderer.ts b/src/game-loop/export-4k-renderer.ts new file mode 100644 index 0000000..3a40616 --- /dev/null +++ b/src/game-loop/export-4k-renderer.ts @@ -0,0 +1,194 @@ +import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { + estimateExport4KMemory, + getAspectFitExport4KDimensions, + getBrowserExportMemoryInfo, + getExport4KPreflightError, +} from './export-4k'; + +interface Export4KRendererOptions { + device: GPUDevice; + renderPipeline: RenderPipeline; + statusElement: HTMLElement; + seed: string; + getSourceSize: () => { width: number; height: number }; + getColorTextureView: () => GPUTextureView; + getSourceTextureView: () => GPUTextureView; + getVibeId: () => string; +} + +export class Export4KRenderer { + private isExporting = false; + + public constructor(private readonly options: Export4KRendererOptions) {} + + public async export(): Promise { + if (this.isExporting) { + this.statusElement.textContent = '4K upscale already rendering...'; + return; + } + + this.isExporting = true; + this.statusElement.textContent = 'Rendering 4K upscale...'; + + try { + const sourceSize = this.options.getSourceSize(); + const exportDimensions = getAspectFitExport4KDimensions( + sourceSize.width, + sourceSize.height + ); + const estimate = estimateExport4KMemory( + exportDimensions.width, + exportDimensions.height + ); + const preflightError = getExport4KPreflightError({ + limits: this.device.limits, + memoryInfo: getBrowserExportMemoryInfo(), + estimate, + }); + if (preflightError) { + this.statusElement.textContent = '4K upscale unavailable'; + throw preflightError; + } + + await this.renderExport(estimate); + this.statusElement.textContent = ''; + } finally { + this.isExporting = false; + } + } + + private async renderExport( + estimate: ReturnType + ): Promise { + const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate; + const format = navigator.gpu.getPreferredCanvasFormat(); + let texture: GPUTexture | null = null; + let output: GPUBuffer | null = null; + let isOutputMapped = false; + + try { + texture = this.device.createTexture({ + size: { width, height }, + format, + usage: + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.COPY_SRC | + GPUTextureUsage.TEXTURE_BINDING, + }); + output = this.device.createBuffer({ + size: estimate.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() + ); + 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 = readExportPixels({ + mapped: new Uint8Array(output.getMappedRange()), + width, + height, + unpaddedBytesPerRow, + bytesPerRow, + isBgra: format === 'bgra8unorm', + }); + output.unmap(); + isOutputMapped = false; + output.destroy(); + output = null; + texture.destroy(); + texture = null; + + await this.downloadPixels(pixels, width, height); + } catch (error) { + this.statusElement.textContent = '4K upscale failed'; + throw error; + } 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: 'image/png' }); + const link = document.createElement('a'); + const objectUrl = URL.createObjectURL(blob); + try { + link.href = objectUrl; + link.download = `fleeting-garden_${this.options.getVibeId()}_${ + this.options.seed + }_${width}x${height}-upscale.png`; + link.click(); + } finally { + URL.revokeObjectURL(objectUrl); + } + } + + private get device(): GPUDevice { + return this.options.device; + } + + private get statusElement(): HTMLElement { + return this.options.statusElement; + } +} + +const readExportPixels = ({ + 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 * 4; + const target = targetOffset + x * 4; + 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/export-4k.test.ts b/src/game-loop/export-4k.test.ts new file mode 100644 index 0000000..e0918aa --- /dev/null +++ b/src/game-loop/export-4k.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + estimateExport4KMemory, + formatByteSize, + getAspectFitExport4KDimensions, + getExport4KPreflightError, +} from './export-4k'; + +const generousLimits = { + maxBufferSize: Number.MAX_SAFE_INTEGER, + maxTextureDimension2D: Number.MAX_SAFE_INTEGER, +}; + +describe('4K export preflight', () => { + it('fits export dimensions inside 4K while preserving source aspect ratio', () => { + expect(getAspectFitExport4KDimensions(3840, 2160)).toEqual({ + width: 3840, + height: 2160, + }); + expect(getAspectFitExport4KDimensions(800, 600)).toEqual({ + width: 2880, + height: 2160, + }); + expect(getAspectFitExport4KDimensions(600, 800)).toEqual({ + width: 1620, + height: 2160, + }); + expect(getAspectFitExport4KDimensions(1000, 1000)).toEqual({ + width: 2160, + height: 2160, + }); + }); + + it('estimates padded readback and temporary memory for the export', () => { + const estimate = estimateExport4KMemory(); + + expect(estimate.width).toBe(3840); + expect(estimate.height).toBe(2160); + expect(estimate.bytesPerRow % 256).toBe(0); + expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes); + expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/); + }); + + it('rejects GPUs that cannot allocate the export texture', () => { + const error = getExport4KPreflightError({ + limits: { + maxBufferSize: Number.MAX_SAFE_INTEGER, + maxTextureDimension2D: 2048, + }, + }); + + expect(error?.code).toBe('export-4k-texture-too-large'); + }); + + it('rejects GPUs that cannot allocate the readback buffer', () => { + const estimate = estimateExport4KMemory(); + const error = getExport4KPreflightError({ + limits: { + maxBufferSize: estimate.readbackBufferBytes - 1, + maxTextureDimension2D: Number.MAX_SAFE_INTEGER, + }, + estimate, + }); + + expect(error?.code).toBe('export-4k-readback-too-large'); + }); + + it('rejects browser-reported low-memory devices', () => { + const error = getExport4KPreflightError({ + limits: generousLimits, + memoryInfo: { + deviceMemoryBytes: 2 * 1024 ** 3, + }, + }); + + expect(error?.code).toBe('export-4k-low-device-memory'); + }); + + it('allows export when memory hints are unavailable', () => { + expect( + getExport4KPreflightError({ + limits: generousLimits, + }) + ).toBeNull(); + }); +}); diff --git a/src/game-loop/export-4k.ts b/src/game-loop/export-4k.ts new file mode 100644 index 0000000..dffec92 --- /dev/null +++ b/src/game-loop/export-4k.ts @@ -0,0 +1,222 @@ +import { appConfig } from '../config'; +import { RuntimeError } from '../utils/error-handler'; + +const EXPORT_4K_WIDTH = appConfig.export4k.width; +const EXPORT_4K_HEIGHT = appConfig.export4k.height; + +const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel; +const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes; +const GIBIBYTE = 1024 ** 3; +const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB; +const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction; +const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier; + +interface Export4KMemoryEstimate { + width: number; + height: number; + bytesPerPixel: number; + unpaddedBytesPerRow: number; + bytesPerRow: number; + textureBytes: number; + readbackBufferBytes: number; + pixelBytes: number; + canvasBytes: number; + encoderSafetyBytes: number; + estimatedJsHeapBytes: number; + estimatedPeakBytes: number; +} + +interface Export4KDimensions { + width: number; + height: number; +} + +interface BrowserMemoryInfo { + deviceMemoryBytes?: number; + jsHeapSizeLimitBytes?: number; + usedJsHeapSizeBytes?: number; +} + +interface Export4KPreflightOptions { + limits: Pick; + memoryInfo?: BrowserMemoryInfo; + estimate?: Export4KMemoryEstimate; +} + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; + +const getPositiveFiniteNumber = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; + +export const formatByteSize = (bytes: number): string => + `${Math.ceil(bytes / 1024 / 1024)} MiB`; + +export const getAspectFitExport4KDimensions = ( + sourceWidth: number, + sourceHeight: number, + maxWidth = EXPORT_4K_WIDTH, + maxHeight = EXPORT_4K_HEIGHT +): Export4KDimensions => { + if ( + !Number.isFinite(sourceWidth) || + !Number.isFinite(sourceHeight) || + sourceWidth <= 0 || + sourceHeight <= 0 + ) { + return { width: maxWidth, height: maxHeight }; + } + + const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight); + + return { + width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))), + height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))), + }; +}; + +export const estimateExport4KMemory = ( + width = EXPORT_4K_WIDTH, + height = EXPORT_4K_HEIGHT +): Export4KMemoryEstimate => { + const unpaddedBytesPerRow = width * BYTES_PER_PIXEL; + const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES); + const textureBytes = unpaddedBytesPerRow * height; + const readbackBufferBytes = bytesPerRow * height; + const pixelBytes = textureBytes; + const canvasBytes = textureBytes; + const encoderSafetyBytes = textureBytes * 2; + const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes; + + return { + width, + height, + bytesPerPixel: BYTES_PER_PIXEL, + unpaddedBytesPerRow, + bytesPerRow, + textureBytes, + readbackBufferBytes, + pixelBytes, + canvasBytes, + encoderSafetyBytes, + estimatedJsHeapBytes, + estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes, + }; +}; + +export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => { + const navigatorWithMemory = + typeof navigator === 'undefined' + ? undefined + : (navigator as Navigator & { deviceMemory?: number }); + const performanceWithMemory = + typeof performance === 'undefined' + ? undefined + : (performance as Performance & { + memory?: { + jsHeapSizeLimit?: number; + usedJSHeapSize?: number; + }; + }); + + const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory); + const jsHeapSizeLimitBytes = getPositiveFiniteNumber( + performanceWithMemory?.memory?.jsHeapSizeLimit + ); + const usedJsHeapSizeBytes = getPositiveFiniteNumber( + performanceWithMemory?.memory?.usedJSHeapSize + ); + + return { + ...(deviceMemoryGib === undefined + ? {} + : { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }), + ...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }), + ...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }), + }; +}; + +export const getExport4KPreflightError = ({ + limits, + memoryInfo = {}, + estimate = estimateExport4KMemory(), +}: Export4KPreflightOptions): RuntimeError | null => { + if ( + estimate.width > limits.maxTextureDimension2D || + estimate.height > limits.maxTextureDimension2D + ) { + return new RuntimeError( + 'export-4k-texture-too-large', + 'This GPU cannot create a 3840x2160 export texture.', + { + details: { + exportWidth: estimate.width, + exportHeight: estimate.height, + maxTextureDimension2D: limits.maxTextureDimension2D, + }, + } + ); + } + + if (estimate.readbackBufferBytes > limits.maxBufferSize) { + return new RuntimeError( + 'export-4k-readback-too-large', + 'This GPU cannot allocate the 4K export readback buffer.', + { + details: { + readbackBufferBytes: estimate.readbackBufferBytes, + maxBufferSize: limits.maxBufferSize, + }, + } + ); + } + + if ( + memoryInfo.deviceMemoryBytes !== undefined && + memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE && + estimate.estimatedPeakBytes > + memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION + ) { + return new RuntimeError( + 'export-4k-low-device-memory', + `4K upscale export needs about ${formatByteSize( + estimate.estimatedPeakBytes + )} of temporary memory, which is not safe on this low-memory device.`, + { + details: { + deviceMemoryBytes: memoryInfo.deviceMemoryBytes, + estimatedPeakBytes: estimate.estimatedPeakBytes, + }, + } + ); + } + + if ( + memoryInfo.jsHeapSizeLimitBytes !== undefined && + memoryInfo.usedJsHeapSizeBytes !== undefined + ) { + const availableJsHeapBytes = + memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes; + if ( + availableJsHeapBytes < + estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER + ) { + return new RuntimeError( + 'export-4k-low-js-heap', + `4K upscale export needs about ${formatByteSize( + estimate.estimatedJsHeapBytes + )} of JavaScript heap, and this browser does not report enough free heap.`, + { + details: { + availableJsHeapBytes, + estimatedJsHeapBytes: estimate.estimatedJsHeapBytes, + jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes, + usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes, + }, + } + ); + } + } + + return null; +}; diff --git a/src/game-loop/frame-performance.ts b/src/game-loop/frame-performance.ts new file mode 100644 index 0000000..6edb415 --- /dev/null +++ b/src/game-loop/frame-performance.ts @@ -0,0 +1,73 @@ +import { appConfig } from '../config'; + +interface TelemetrySnapshot { + frameCpuStartedAt: number; + encodeCpuMs: number; + activeAgentCount: number; + targetAgentBudget: number; + canvas: HTMLCanvasElement; + devicePixelRatio: number; + renderSpeed: number; +} + +export class FramePerformance { + public latestFps = 60; + public smoothedFps = 60; + public refreshTargetFps = 60; + + private lastTelemetryAt = 0; + + public markCpuStart(): number { + return appConfig.telemetry.enabled ? performance.now() : 0; + } + + public measureSince(startedAt: number): number { + return appConfig.telemetry.enabled ? performance.now() - startedAt : 0; + } + + public update(deltaTime: number): void { + const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds); + this.latestFps = fps; + this.refreshTargetFps = Math.max( + this.refreshTargetFps * appConfig.simulation.budget.refreshTargetDecay, + fps + ); + this.smoothedFps = + this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain + + fps * appConfig.simulation.budget.fpsSmoothingNew; + } + + public renderTelemetry({ + frameCpuStartedAt, + encodeCpuMs, + activeAgentCount, + targetAgentBudget, + canvas, + devicePixelRatio, + renderSpeed, + }: TelemetrySnapshot): void { + if (!appConfig.telemetry.enabled) { + return; + } + + const now = performance.now(); + if (now - this.lastTelemetryAt < appConfig.telemetry.intervalMs) { + return; + } + + this.lastTelemetryAt = now; + console.debug('Fleeting Garden telemetry', { + fps: Math.round(this.latestFps), + smoothedFps: Math.round(this.smoothedFps), + refreshTargetFps: Math.round(this.refreshTargetFps), + activeAgentCount, + targetAgentBudget, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + dpr: devicePixelRatio, + renderSpeed, + frameCpuMs: now - frameCpuStartedAt, + encodeCpuMs, + }); + } +} diff --git a/src/game-loop/game-loop-intro.test.ts b/src/game-loop/game-loop-intro.test.ts new file mode 100644 index 0000000..9131f18 --- /dev/null +++ b/src/game-loop/game-loop-intro.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const gameLoopSource = readFileSync( + join(process.cwd(), 'src/game-loop/game-loop.ts'), + 'utf8' +); + +const getStartDrawingHandlerSource = () => { + const start = gameLoopSource.indexOf('onStartDrawing:'); + const end = gameLoopSource.indexOf('onEraseGestureEnded:', start); + + if (start < 0 || end < 0) { + throw new Error('Could not find the pointer drawing intro handler'); + } + + return gameLoopSource.slice(start, end); +}; + +describe('GameLoop intro drawing policy', () => { + it('allows drawing to start without completing the intro sequence', () => { + const handlerSource = getStartDrawingHandlerSource(); + + expect(handlerSource).toContain('this.introPrompt.markStartedDrawing()'); + expect(handlerSource).not.toContain('this.introPrompt.complete('); + }); +}); diff --git a/src/game-loop/game-loop-ping-pong.test.ts b/src/game-loop/game-loop-ping-pong.test.ts new file mode 100644 index 0000000..921c8e4 --- /dev/null +++ b/src/game-loop/game-loop-ping-pong.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const simulationFrameSource = readFileSync( + join(process.cwd(), 'src/game-loop/simulation-frame.ts'), + 'utf8' +); +const simulationTexturesSource = readFileSync( + join(process.cwd(), 'src/game-loop/simulation-textures.ts'), + 'utf8' +); + +const getRenderStepSource = () => { + const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)'); + const end = simulationFrameSource.indexOf(' public clearSwipes', start); + + if (start < 0 || end < 0) { + throw new Error('Could not find the render-speed simulation loop'); + } + + return simulationFrameSource.slice(start, end); +}; + +describe('GameLoop ping-pong texture flow', () => { + it('copies only the trail map and swaps source/influence references after diffusion', () => { + const renderStepSource = getRenderStepSource(); + + expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1); + expect(renderStepSource).toMatch( + /this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/ + ); + expect(renderStepSource).toMatch( + /this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/ + ); + }); + + it('keeps ping-pong texture references mutable and swaps A/B identities', () => { + expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;'); + expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;'); + expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;'); + expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;'); + expect(simulationTexturesSource).toContain( + '[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];' + ); + expect(simulationTexturesSource).toContain( + '[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];' + ); + }); +}); diff --git a/src/game-loop/game-loop-resources.ts b/src/game-loop/game-loop-resources.ts new file mode 100644 index 0000000..f1f9d0a --- /dev/null +++ b/src/game-loop/game-loop-resources.ts @@ -0,0 +1,192 @@ +import { vec2 } from 'gl-matrix'; + +import { appConfig } 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 { CopyPipeline } from '../pipelines/copy/copy-pipeline'; +import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; +import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; +import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; +import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { settings } from '../settings'; +import { initializeContext } from '../utils/graphics/initialize-context'; +import { GLOBAL_AGENT_CAP } from './agent-population'; +import { RenderInputs } from './game-loop-types'; +import { SimulationFrameRenderer } from './simulation-frame'; +import { SimulationTextures } from './simulation-textures'; + +interface FrameParameters extends RenderInputs { + time: number; + deltaTime: number; + canvasSize: vec2; + activeAgentCount: number; + introProgress: number; + selectedColorIndex: number; + isErasing: boolean; + cameraCenter: [number, number]; + cameraZoom: number; + eraserPixelSize: number; +} + +export class GameLoopResources { + public readonly textures: SimulationTextures; + public readonly commonState: CommonState; + public readonly copyPipeline: CopyPipeline; + public readonly agentGenerationPipeline: AgentGenerationPipeline; + public readonly agentPipeline: AgentPipeline; + public readonly brushPipeline: BrushPipeline; + public readonly eraserAgentPipeline: EraserAgentPipeline; + public readonly eraserTexturePipeline: EraserTexturePipeline; + public readonly diffusionPipeline: DiffusionPipeline; + public readonly brushEffectDiffusionPipeline: DiffusionPipeline; + public readonly renderPipeline: RenderPipeline; + + private readonly frameRenderer: SimulationFrameRenderer; + + public constructor( + canvas: HTMLCanvasElement, + private readonly device: GPUDevice, + canvasSize: vec2 + ) { + const context = initializeContext({ device, canvas }); + + this.textures = new SimulationTextures(this.device, canvasSize); + this.copyPipeline = new CopyPipeline(this.device); + + this.commonState = new CommonState(this.device); + this.commonState.setParameters({ + canvasSize, + time: 0, + deltaTime: 0, + }); + + this.agentGenerationPipeline = new AgentGenerationPipeline( + this.device, + this.commonState, + GLOBAL_AGENT_CAP + ); + + 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.commonState, + this.agentGenerationPipeline.agentsBuffer + ); + this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); + this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState); + this.brushEffectDiffusionPipeline = new DiffusionPipeline( + this.device, + this.commonState + ); + this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); + + this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, { + copyPipeline: this.copyPipeline, + agentPipeline: this.agentPipeline, + brushPipeline: this.brushPipeline, + eraserAgentPipeline: this.eraserAgentPipeline, + eraserTexturePipeline: this.eraserTexturePipeline, + diffusionPipeline: this.diffusionPipeline, + brushEffectDiffusionPipeline: this.brushEffectDiffusionPipeline, + renderPipeline: this.renderPipeline, + }); + } + + public resizeSimulationTo(nextSize: vec2): vec2 | null { + return this.textures.resizeTo(nextSize); + } + + public setFrameParameters({ + time, + deltaTime, + canvasSize, + activeAgentCount, + introProgress, + selectedColorIndex, + isErasing, + channelColors, + backgroundColor, + cameraCenter, + cameraZoom, + eraserPixelSize, + }: FrameParameters): void { + this.commonState.setParameters({ + canvasSize, + time, + deltaTime, + }); + this.agentPipeline.setParameters({ + ...settings, + deltaTime, + agentCount: activeAgentCount, + moveSpeed: + settings.moveSpeed * + (introProgress >= 1 + ? 1 + : appConfig.simulation.introMoveSpeedBaseMultiplier + + introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier), + introProgress, + }); + this.brushPipeline.setParameters({ + ...settings, + selectedColorIndex, + isErasing, + }); + this.diffusionPipeline.setParameters(settings); + this.renderPipeline.setParameters({ + ...settings, + channelColors, + backgroundColor, + cameraCenter, + cameraZoom, + }); + this.eraserAgentPipeline.setParameters({ + agentCount: activeAgentCount, + eraserSize: eraserPixelSize, + }); + this.eraserTexturePipeline.setParameters({ + eraserSize: eraserPixelSize, + }); + this.setBrushEffectDiffusionParameters(); + } + + public executeFrame(renderSpeed: number, isErasing: boolean): void { + this.frameRenderer.execute(renderSpeed, isErasing); + } + + public clearSwipes(): void { + this.frameRenderer.clearSwipes(); + } + + public destroy(): void { + this.copyPipeline.destroy(); + this.agentGenerationPipeline.destroy(); + this.agentPipeline.destroy(); + this.brushPipeline.destroy(); + this.eraserAgentPipeline.destroy(); + this.eraserTexturePipeline.destroy(); + this.diffusionPipeline.destroy(); + this.brushEffectDiffusionPipeline.destroy(); + this.renderPipeline.destroy(); + this.commonState.destroy(); + this.textures.destroy(); + } + + private setBrushEffectDiffusionParameters(): void { + const framesToOneE = Math.max( + 1, + settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond + ); + this.brushEffectDiffusionPipeline.setParameters({ + ...settings, + decayRateTrails: Math.exp(-1 / framesToOneE) * 1000, + }); + } +} diff --git a/src/game-loop/game-loop-settings.ts b/src/game-loop/game-loop-settings.ts index e71ebc8..bd3a756 100644 --- a/src/game-loop/game-loop-settings.ts +++ b/src/game-loop/game-loop-settings.ts @@ -1,8 +1,10 @@ export interface GameLoopSettings { - maxAgentCountUpperLimit: number; + agentBudgetMax: number; agentCount: number; renderSpeed: number; simulatedDelayMs: number; + selectedColorIndex: number; + spawnPerPixel: 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..9ecb9cf --- /dev/null +++ b/src/game-loop/game-loop-types.ts @@ -0,0 +1,17 @@ +import { vec2 } from 'gl-matrix'; + +export interface GardenUi { + prompt: HTMLElement; + eraserPreview: HTMLElement; + exportStatus: HTMLElement; +} + +export interface RenderInputs { + channelColors: Array<[number, number, number]>; + backgroundColor: [number, number, number]; +} + +export interface StrokeSegment { + from: vec2; + to: vec2; +} diff --git a/src/game-loop/game-loop.ts b/src/game-loop/game-loop.ts index b10d843..01c8448 100644 --- a/src/game-loop/game-loop.ts +++ b/src/game-loop/game-loop.ts @@ -1,258 +1,286 @@ 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 { gardenAudioConfig } from '../audio/garden-audio-config'; +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 { AgentPopulation } from './agent-population'; +import { EraserPreview } from './eraser-preview'; +import { Export4KRenderer } from './export-4k-renderer'; +import { FramePerformance } from './frame-performance'; +import { GameLoopResources } from './game-loop-resources'; +import { GardenUi } from './game-loop-types'; +import { IntroPrompt } from './intro-prompt'; +import { GardenPointerInput } from './pointer-input'; +import { RenderInputCache } from './render-input-cache'; export default class GameLoop { - private readonly trailMapA: ResizableTexture; - private readonly trailMapB: ResizableTexture; + private static readonly MAX_MIRROR_SEGMENT_COUNT = + appConfig.simulation.maxMirrorSegmentCount; + private static readonly DEV_STATS_INTERVAL_MS = 250; - 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(gardenAudioConfig); + private readonly renderInputs = new RenderInputCache(); + private readonly introPrompt: IntroPrompt; + private readonly eraserPreview: EraserPreview; + private readonly pointerInput: GardenPointerInput; + private readonly agentPopulation: AgentPopulation; + private readonly export4KRenderer: Export4KRenderer; + private readonly framePerformance = new FramePerformance(); + private readonly devStatsElement: HTMLDivElement | null = null; + private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16); + private readonly resizeListener = this.resize.bind(this); + private readonly keydownListener: (event: KeyboardEvent) => void; + private lastDevStatsUpdateAt = 0; private hasFinished = false; private readonly finished = Promise.withResolvers(); - private activePointerId: number | null = null; - public constructor( private readonly canvas: HTMLCanvasElement, - private readonly device: GPUDevice, + device: GPUDevice, private readonly deltaTimeCalculator: DeltaTimeCalculator, - private readonly gameRules: GameRules + 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, + if (import.meta.env.DEV) { + this.devStatsElement = this.createDevStatsElement(); + } + this.resources = new GameLoopResources(canvas, device, this.canvasSize); + this.introPrompt = new IntroPrompt(ui.prompt); + this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); + this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline); + this.agentPopulation.initializeIntroAgents(this.canvasSize); + this.pointerInput = new GardenPointerInput({ + canvas, + audio: this.audio, + brushPipeline: this.resources.brushPipeline, + eraserAgentPipeline: this.resources.eraserAgentPipeline, + eraserTexturePipeline: this.resources.eraserTexturePipeline, + eraserPreview: this.eraserPreview, + getCanvasSize: () => this.canvasSize, + getDevicePixelRatio: () => this.devicePixelRatio, + getMirrorSegmentCount: () => this.mirrorSegmentCount, + onStartDrawing: () => this.introPrompt.markStartedDrawing(), + onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(), + spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to), }); + this.export4KRenderer = new Export4KRenderer({ + device, + renderPipeline: this.resources.renderPipeline, + statusElement: ui.exportStatus, + seed: this.seed, + getSourceSize: () => ({ + width: this.canvas.width, + height: this.canvas.height, + }), + getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(), + getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(), + getVibeId: () => activeVibe.id, + }); + this.keydownListener = (event: KeyboardEvent) => { + this.audio.start(activeVibe, { userGesture: event.isTrusted }); + this.introPrompt.complete(); + }; - 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)); + window.addEventListener('resize', this.resizeListener); + window.addEventListener('keydown', this.keydownListener, { once: true }); + this.pointerInput.attach(); } - 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 setEraseMode(isErasing: boolean): void { + this.pointerInput.setEraseMode(isErasing); } - private onPointerMove(event: PointerEvent) { - if (event.pointerId !== this.activePointerId) { - return; - } - this.addSwipeAt(event); + public updateEraserPreview(event?: PointerEvent): void { + this.pointerInput.updateEraserPreview(event); } - private onPointerUp(event: PointerEvent) { - if (event.pointerId !== this.activePointerId) { - return; - } - this.addSwipeAt(event); - this.canvas.releasePointerCapture(event.pointerId); - this.activePointerId = null; + public onVibeChanged(): void { + this.agentPopulation.onVibeChanged(); + this.renderInputs.invalidate(); } - private addSwipeAt(event: PointerEvent) { - const position = vec2.fromValues( - event.clientX * this.devicePixelRatio, - this.canvas.height - event.clientY * this.devicePixelRatio - ); - this.brushPipeline.addSwipe(position); + public setAudioMuted(isMuted: boolean): void { + this.audio.setMuted(isMuted); } - private get isSwipeActive(): boolean { - return this.activePointerId !== null; + 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)); + requestAnimationFrame(this.render); return this.finished.promise; } - private async updateCounts(): Promise { - if (this.hasFinished) { - return; - } - const generationCounts = await this.agentGenerationPipeline.countAgents( - settings.agentCount - ); - this.gameRules.updateGenerationCounts(generationCounts); - requestAnimationFrame(this.updateCounts.bind(this)); - } - - public get aliveAgentCounts(): { - currentGenerationCount: number; - nextGenerationCount: number; - } { - return this.gameRules.generationCounts; - } - public get maxAgentCount(): number { - return this.agentGenerationPipeline.maxAgentCount; + return this.agentPopulation.maxAgentCount; } - private resize() { - this.canvas.width = this.canvas.clientWidth * this.devicePixelRatio; - this.canvas.height = this.canvas.clientHeight * this.devicePixelRatio; + public async export4K(): Promise { + return this.export4KRenderer.export(); } - private async render(time: DOMHighResTimeStamp) { + public async destroy(): Promise { + this.hasFinished = true; + await this.finished.promise; + + window.removeEventListener('resize', this.resizeListener); + window.removeEventListener('keydown', this.keydownListener); + this.pointerInput.detach(); + this.devStatsElement?.remove(); + this.introPrompt.destroy(); + this.resources.destroy(); + await this.audio.destroy(); + } + + private readonly render = async (time: DOMHighResTimeStamp) => { 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 frameCpuStartedAt = this.framePerformance.markCpuStart(); const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); - - time *= settings.renderSpeed; - const timeInSeconds = time / 1000; - const spawnAction = this.gameRules.getSpawnAction(timeInSeconds, this.canvasSize); - - [ - 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.framePerformance.update(deltaTime); + this.agentPopulation.growBudget( + deltaTime, + this.framePerformance.smoothedFps, + this.framePerformance.refreshTargetFps ); + this.introPrompt.update(); + this.resize(); + this.resizeSimulationToCanvas(); - for (let i = 0; i < settings.renderSpeed; i++) { - const commandEncoder = this.device.createCommandEncoder(); + const scaledTime = time * settings.renderSpeed; + const { channelColors, backgroundColor } = this.renderInputs.get(); + const introProgress = this.introPrompt.progress; + const cameraZoom = 1; + const cameraCenter: [number, number] = [ + this.canvas.width / 2, + this.canvas.height / 2, + ]; + const eraserPixelSize = settings.eraserSize * this.devicePixelRatio; + const isErasing = this.pointerInput.isEraseMode; + const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; + this.renderInputs.updateAccentColor(accentColor); + this.audio.update({ + vibe: activeVibe, + selectedColorIndex: settings.selectedColorIndex, + isErasing, + mirrorSegmentCount: this.mirrorSegmentCount, + }); - 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.resources.setFrameParameters({ + time: scaledTime, + deltaTime, + canvasSize: this.canvasSize, + activeAgentCount: this.agentPopulation.activeAgentCount, + introProgress, + selectedColorIndex: settings.selectedColorIndex, + isErasing, + channelColors, + backgroundColor, + cameraCenter, + cameraZoom, + eraserPixelSize, + }); - this.device.queue.submit([commandEncoder.finish()]); - } + const encodeCpuStartedAt = this.framePerformance.markCpuStart(); + this.resources.executeFrame(settings.renderSpeed, isErasing); + const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt); - if (!this.isSwipeActive) { - this.brushPipeline.clearSwipes(); - } + this.pointerInput.clearSwipesIfIdle(); + await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); + + this.framePerformance.renderTelemetry({ + frameCpuStartedAt, + encodeCpuMs, + activeAgentCount: this.agentPopulation.activeAgentCount, + targetAgentBudget: this.agentPopulation.targetAgentBudget, + canvas: this.canvas, + devicePixelRatio: this.devicePixelRatio, + renderSpeed: settings.renderSpeed, + }); + this.updateDevStats(time); 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); + }; - requestAnimationFrame(this.render.bind(this)); + private createDevStatsElement(): HTMLDivElement | null { + const container = this.canvas.parentElement; + if (!container) { + return null; + } + + const element = document.createElement('div'); + element.className = 'dev-stats-overlay'; + element.setAttribute('aria-hidden', 'true'); + container.appendChild(element); + return element; } - public async destroy() { - this.hasFinished = true; - await this.finished.promise; + private updateDevStats(time: DOMHighResTimeStamp): void { + if ( + !this.devStatsElement || + time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS + ) { + 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.lastDevStatsUpdateAt = time; + this.devStatsElement.textContent = [ + `FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${Math.round( + this.framePerformance.refreshTargetFps + )}`, + `Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`, + `Target ${this.formatDevStatNumber(this.agentPopulation.targetAgentBudget)}`, + `Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`, + ].join('\n'); + } + + private formatDevStatNumber(value: number): string { + return Math.max(0, Math.round(value)).toLocaleString('en-US'); + } + + private resize(): void { + const width = Math.max( + 1, + Math.floor(this.canvas.clientWidth * this.devicePixelRatio) + ); + const height = Math.max( + 1, + Math.floor(this.canvas.clientHeight * this.devicePixelRatio) + ); + + if (this.canvas.width === width && this.canvas.height === height) { + return; + } + + this.canvas.width = width; + this.canvas.height = height; + } + + private resizeSimulationToCanvas(): void { + const scale = this.resources.resizeSimulationTo(this.canvasSize); + if (!scale) { + return; + } + + this.agentPopulation.resizeAgents(scale); + this.pointerInput.scaleLastPointerPosition(scale); } private get canvasSize(): vec2 { @@ -260,6 +288,14 @@ export default class GameLoop { } private get devicePixelRatio(): number { - return window.devicePixelRatio || 1; + const ratio = window.devicePixelRatio; + return Number.isFinite(ratio) && ratio > 0 ? ratio : 1; + } + + private get mirrorSegmentCount(): number { + const count = Number.isFinite(settings.mirrorSegmentCount) + ? settings.mirrorSegmentCount + : 1; + return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count))); } } 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/intro-prompt.ts b/src/game-loop/intro-prompt.ts new file mode 100644 index 0000000..e77ac69 --- /dev/null +++ b/src/game-loop/intro-prompt.ts @@ -0,0 +1,80 @@ +import { appConfig } from '../config'; + +const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000; + +export class IntroPrompt { + private introComplete = false; + private introStartedAt = performance.now(); + private introCompletedAt: number | null = null; + private hasStartedDrawing = false; + private isDrawHintVisible = false; + + public constructor(private readonly prompt: HTMLElement) {} + + public get progress(): number { + return this.introComplete + ? 1 + : Math.min(1, (performance.now() - this.introStartedAt) / INTRO_TITLE_DURATION_MS); + } + + public update(): void { + const now = performance.now(); + + if (!this.introComplete && now - this.introStartedAt > INTRO_TITLE_DURATION_MS) { + 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.isDrawHintVisible) { + return; + } + + this.isDrawHintVisible = true; + this.prompt.classList.add(appConfig.simulation.intro.drawHintClass); + this.prompt.innerHTML = ` + + Draw on the screen + `; + } + + private hideDrawHint(): void { + this.isDrawHintVisible = false; + this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass); + 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..735393c --- /dev/null +++ b/src/game-loop/intro-title-agents.ts @@ -0,0 +1,354 @@ +import { appConfig } from '../config'; +import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent'; + +interface IntroTitlePoint { + x: number; + y: number; + tangent: number | null; + colorIndex: number; +} + +interface IntroTitleAgentOptions { + count: number; + width: number; + height: number; +} + +const INTRO_TITLE = appConfig.simulation.intro.title; + +export const createIntroTitleAgents = ({ + count, + width, + height, +}: IntroTitleAgentOptions): Float32Array => { + if (count <= 0) { + return new Float32Array(); + } + + 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(Math.random() * points.length)]; + const targetX = Math.max( + 0, + Math.min(safeWidth - 1, point.x + (Math.random() - 0.5) * targetJitter) + ); + const targetY = Math.max( + 0, + Math.min(safeHeight - 1, point.y + (Math.random() - 0.5) * targetJitter) + ); + const [startX, startY] = getIntroRadialStart( + targetX, + targetY, + safeWidth, + safeHeight, + introCircleRadius, + entryJitter + ); + 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 base = i * AGENT_FLOAT_COUNT; + data[base] = startX; + data[base + 1] = startY; + data[base + 2] = + approachAngle + + (Math.random() - 0.5) * appConfig.simulation.intro.angleJitterRadians; + data[base + 3] = point.colorIndex; + data[base + 4] = targetX; + data[base + 5] = targetY; + data[base + 6] = targetAngle; + data[base + 7] = Math.min( + appConfig.simulation.intro.targetDelayMax, + distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier + + Math.random() * appConfig.simulation.intro.targetDelayRandomMultiplier + ); + } + + return data; +}; + +const getIntroRadialStart = ( + targetX: number, + targetY: number, + width: number, + height: number, + radius: number, + jitter: number +): [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 > 0.001 ? Math.atan2(offsetY, offsetX) : Math.random() * Math.PI * 2; + const directionX = Math.cos(angle); + const directionY = Math.sin(angle); + const tangentX = -directionY; + const tangentY = directionX; + const tangentJitter = (Math.random() - 0.5) * jitter; + const radialJitter = + (Math.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 maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const context = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!context) { + return []; + } + + const fontSize = getIntroTitleFontSize(context, width, height); + context.clearRect(0, 0, width, height); + context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`; + 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, + width / 2, + height * appConfig.simulation.intro.verticalAnchor, + letterSpacing, + 'stroke' + ); + drawIntroTitleText( + context, + width / 2, + height * appConfig.simulation.intro.verticalAnchor, + letterSpacing, + 'fill' + ); + + const { data } = context.getImageData(0, 0, width, height); + const step = Math.max( + 1, + Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity) + ); + const points: Array = []; + const characterColorBoundaries = getIntroTitleColorBoundaries( + context, + width, + letterSpacing + ); + + for (let y = 0; y < height; y += step) { + for (let x = 0; x < width; x += step) { + const alpha = getMaskAlpha(data, width, height, x, y); + if (alpha < appConfig.simulation.intro.maskAlphaThreshold) { + continue; + } + + points.push({ + x, + y, + tangent: estimateMaskTangent(data, width, height, 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 [firstCutLetter, secondCutLetter] = + appConfig.simulation.intro.titleColorCutLetters; + 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 Comfortaa, "Open Sans", sans-serif`; + 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]; +}; diff --git a/src/game-loop/pointer-input.test.ts b/src/game-loop/pointer-input.test.ts new file mode 100644 index 0000000..8f99179 --- /dev/null +++ b/src/game-loop/pointer-input.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type PointerListener = (event: PointerEvent) => void; + +const makePointerEvent = ( + type: string, + event: Partial = {} +): PointerEvent => + ({ + buttons: 1, + clientX: 10, + clientY: 20, + isTrusted: true, + pointerId: 1, + pointerType: 'mouse', + pressure: 0.5, + timeStamp: 100, + type, + ...event, + }) as PointerEvent; + +const toPoint = (point: ArrayLike): Array => Array.from(point); + +class FakeCanvas { + public readonly capturedPointerIds: Array = []; + public readonly releasedPointerIds: Array = []; + public width = 300; + public height = 200; + + private readonly listeners = new Map>(); + + public addEventListener( + type: string, + listener: EventListenerOrEventListenerObject + ): void { + const listeners = this.listeners.get(type) ?? new Set(); + const pointerListener = + typeof listener === 'function' + ? listener + : (event: Event) => listener.handleEvent(event); + listeners.add(pointerListener as PointerListener); + this.listeners.set(type, listeners); + } + + public removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject + ): void { + const listeners = this.listeners.get(type); + if (!listeners) { + return; + } + + listeners.delete(listener as PointerListener); + } + + public dispatchPointerEvent(type: string, event: Partial = {}): void { + const pointerEvent = makePointerEvent(type, event); + + this.listeners.get(type)?.forEach((listener) => listener(pointerEvent)); + } + + public getBoundingClientRect(): DOMRect { + return { + bottom: this.height, + height: this.height, + left: 0, + right: this.width, + toJSON: () => ({}), + top: 0, + width: this.width, + x: 0, + y: 0, + } as DOMRect; + } + + public setPointerCapture(pointerId: number): void { + this.capturedPointerIds.push(pointerId); + } + + public releasePointerCapture(pointerId: number): void { + this.releasedPointerIds.push(pointerId); + } +} + +const makeSwipePipeline = () => ({ + addSwipeSegment: vi.fn(), + clearSwipes: vi.fn(), +}); + +const createPointerInput = async () => { + const { GardenPointerInput } = await import('./pointer-input'); + const { settings: runtimeSettings } = await import('../settings'); + const canvas = new FakeCanvas(); + const audio = { + beginGesture: vi.fn(), + endGesture: vi.fn(), + start: vi.fn(), + stroke: vi.fn(), + touchDown: vi.fn(), + }; + const brushPipeline = makeSwipePipeline(); + const eraserAgentPipeline = makeSwipePipeline(); + const eraserTexturePipeline = makeSwipePipeline(); + const eraserPreview = { + isPointerInsideCanvas: vi.fn(() => true), + setEraseMode: vi.fn(), + setPointerHoveringCanvas: vi.fn(), + update: vi.fn(), + }; + const onStartDrawing = vi.fn(); + const onEraseGestureEnded = vi.fn(); + const spawnStrokeAgents = vi.fn(); + const input = new GardenPointerInput({ + audio, + brushPipeline, + canvas: canvas as unknown as HTMLCanvasElement, + eraserAgentPipeline, + eraserPreview, + eraserTexturePipeline, + getCanvasSize: () => [canvas.width, canvas.height], + getDevicePixelRatio: () => 1, + getMirrorSegmentCount: () => 1, + onEraseGestureEnded, + onStartDrawing, + spawnStrokeAgents, + } as unknown as ConstructorParameters[0]); + + input.attach(); + + return { + audio, + brushPipeline, + canvas, + input, + onStartDrawing, + runtimeSettings, + spawnStrokeAgents, + }; +}; + +describe('GardenPointerInput drawing startup', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('localStorage', { + clear: vi.fn(), + getItem: vi.fn(() => null), + removeItem: vi.fn(), + setItem: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('allows pointer drawing immediately', async () => { + const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } = + await createPointerInput(); + + canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 }); + canvas.dispatchPointerEvent('pointermove', { + clientX: 60, + clientY: 80, + pointerId: 7, + timeStamp: 120, + }); + + expect(onStartDrawing).toHaveBeenCalledTimes(1); + expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true }); + expect(audio.beginGesture).toHaveBeenCalledTimes(1); + expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2); + expect(spawnStrokeAgents).toHaveBeenCalledTimes(2); + expect(canvas.capturedPointerIds).toEqual([7]); + }); + + it('starts drawing from a fresh pointerdown', async () => { + const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } = + await createPointerInput(); + + canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); + + expect(onStartDrawing).toHaveBeenCalledTimes(1); + expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true }); + expect(audio.beginGesture).toHaveBeenCalledTimes(1); + expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1); + expect(spawnStrokeAgents).toHaveBeenCalledTimes(1); + expect(canvas.capturedPointerIds).toEqual([9]); + }); + + it('flushes the delayed smoothed stroke tail on pointerup', async () => { + const { brushPipeline, canvas } = await createPointerInput(); + + canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); + canvas.dispatchPointerEvent('pointermove', { + clientX: 60, + clientY: 80, + pointerId: 9, + timeStamp: 120, + }); + canvas.dispatchPointerEvent('pointerup', { + clientX: 60, + clientY: 80, + pointerId: 9, + timeStamp: 140, + }); + + expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3); + expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]); + expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]); + expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]); + expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]); + }); + + it('uses coalesced pointer samples for smoother brush segments', async () => { + const { audio, brushPipeline, canvas, spawnStrokeAgents } = + await createPointerInput(); + + canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); + audio.stroke.mockClear(); + brushPipeline.addSwipeSegment.mockClear(); + spawnStrokeAgents.mockClear(); + + canvas.dispatchPointerEvent('pointermove', { + clientX: 40, + clientY: 20, + getCoalescedEvents: () => [ + makePointerEvent('pointermove', { + clientX: 20, + clientY: 20, + pointerId: 9, + timeStamp: 110, + }), + makePointerEvent('pointermove', { + clientX: 30, + clientY: 20, + pointerId: 9, + timeStamp: 115, + }), + makePointerEvent('pointermove', { + clientX: 40, + clientY: 20, + pointerId: 9, + timeStamp: 120, + }), + ], + pointerId: 9, + timeStamp: 120, + }); + + expect(audio.stroke).toHaveBeenCalledTimes(3); + expect(spawnStrokeAgents).toHaveBeenCalledTimes(3); + expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3); + }); + + it('caps curve tessellation with the brush curve resolution setting', async () => { + const { brushPipeline, canvas, runtimeSettings } = await createPointerInput(); + runtimeSettings.brushCurveResolution = 2; + runtimeSettings.brushSize = 1; + + canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 }); + canvas.dispatchPointerEvent('pointermove', { + clientX: 10, + clientY: 60, + pointerId: 9, + timeStamp: 120, + }); + canvas.dispatchPointerEvent('pointermove', { + clientX: 60, + clientY: 60, + pointerId: 9, + timeStamp: 140, + }); + + expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/game-loop/pointer-input.ts b/src/game-loop/pointer-input.ts new file mode 100644 index 0000000..6ac1c35 --- /dev/null +++ b/src/game-loop/pointer-input.ts @@ -0,0 +1,406 @@ +import { vec2 } from 'gl-matrix'; + +import { GardenAudio } from '../audio/garden-audio'; +import { gardenAudioConfig } from '../audio/garden-audio-config'; +import { appConfig } from '../config'; +import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; +import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; +import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; +import { activeVibe, settings } from '../settings'; +import { EraserPreview } from './eraser-preview'; +import { StrokeSegment } from './game-loop-types'; + +interface GardenPointerInputOptions { + canvas: HTMLCanvasElement; + audio: GardenAudio; + brushPipeline: BrushPipeline; + eraserAgentPipeline: EraserAgentPipeline; + eraserTexturePipeline: EraserTexturePipeline; + eraserPreview: EraserPreview; + getCanvasSize: () => vec2; + getDevicePixelRatio: () => number; + getMirrorSegmentCount: () => number; + onStartDrawing: () => void; + onEraseGestureEnded: () => void; + spawnStrokeAgents: (from: vec2, to: vec2) => void; +} + +export class GardenPointerInput { + private static readonly MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED = 0.25; + private static readonly MIN_CURVE_SEGMENT_SPACING_PIXELS = 4; + private static readonly CURVE_SEGMENT_BRUSH_RADIUS_RATIO = 0.65; + + private activePointerId: number | null = null; + private lastPointerPosition: vec2 | null = null; + private lastPointerEventTimeMs: number | null = null; + private lastPointerPressure = 0.5; + private smoothedStrokePoints: Array = []; + private lastSmoothedBrushPosition: vec2 | null = null; + private isErasing = false; + + public constructor(private readonly options: GardenPointerInputOptions) {} + + 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.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive); + } + + public updateEraserPreview(event?: PointerEvent): void { + this.options.eraserPreview.update(event, this.isSwipeActive); + } + + public clearSwipesIfIdle(): void { + if (this.isSwipeActive) { + return; + } + + this.options.brushPipeline.clearSwipes(); + this.options.eraserAgentPipeline.clearSwipes(); + this.options.eraserTexturePipeline.clearSwipes(); + } + + public scaleLastPointerPosition(scale: vec2): void { + if (this.lastPointerPosition !== null) { + vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale); + } + + this.smoothedStrokePoints.forEach((point) => { + vec2.mul(point, point, scale); + }); + + if (this.lastSmoothedBrushPosition !== null) { + vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, 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) => { + this.options.eraserPreview.setPointerHoveringCanvas(true); + this.updateEraserPreview(event); + if (this.activePointerId !== null) { + return; + } + + this.options.audio.start(activeVibe, { userGesture: event.isTrusted }); + this.options.audio.beginGesture(); + this.options.audio.touchDown({ + vibe: activeVibe, + colorIndex: settings.selectedColorIndex, + mirrorSegmentCount: this.options.getMirrorSegmentCount(), + pressure: this.getPointerPressure(event), + pointerType: event.pointerType, + }); + this.options.onStartDrawing(); + this.activePointerId = event.pointerId; + this.canvas.setPointerCapture(event.pointerId); + this.options.brushPipeline.clearSwipes(); + this.options.eraserAgentPipeline.clearSwipes(); + this.options.eraserTexturePipeline.clearSwipes(); + this.lastPointerPosition = null; + this.lastPointerEventTimeMs = null; + this.clearSmoothedStroke(); + this.lastPointerPressure = this.getPointerPressure(event); + this.addSwipeAt(event, { emitAudio: false }); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + this.updateEraserPreview(event); + 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.finishSmoothedStroke(); + this.options.audio.endGesture(); + if (this.isErasing) { + this.options.onEraseGestureEnded(); + } + this.canvas.releasePointerCapture(event.pointerId); + this.activePointerId = null; + this.lastPointerPosition = null; + this.lastPointerEventTimeMs = null; + this.clearSmoothedStroke(); + this.options.eraserPreview.setPointerHoveringCanvas( + this.options.eraserPreview.isPointerInsideCanvas(event) + ); + this.updateEraserPreview(event); + }; + + private readonly onPointerEnter = (event: PointerEvent) => { + this.options.eraserPreview.setPointerHoveringCanvas(true); + this.updateEraserPreview(event); + }; + + private readonly onPointerLeave = () => { + this.options.eraserPreview.setPointerHoveringCanvas(false); + this.updateEraserPreview(); + }; + + private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { + const rect = this.canvas.getBoundingClientRect(); + const devicePixelRatio = this.options.getDevicePixelRatio(); + const position = vec2.fromValues( + (event.clientX - rect.left) * devicePixelRatio, + (event.clientY - rect.top) * devicePixelRatio + ); + const previousPosition = this.lastPointerPosition ?? position; + const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp; + const elapsedSeconds = Math.max( + appConfig.deltaTime.minDeltaTimeSeconds, + (event.timeStamp - previousTimeMs) / 1000 + ); + const distancePixels = vec2.distance(previousPosition, position); + const velocityPixelsPerSecond = distancePixels / elapsedSeconds; + const pressure = this.getPointerPressure(event); + this.lastPointerPressure = pressure > 0 ? pressure : this.lastPointerPressure; + + const segments = this.isErasing + ? [{ from: previousPosition, to: position }] + : this.getMirroredStrokeSegments(previousPosition, position); + + if (this.isErasing) { + segments.forEach((segment) => { + this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to); + this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to); + }); + } else { + this.addSmoothedBrushSample(position); + } + + if (!this.isErasing) { + segments.forEach((segment) => { + this.options.spawnStrokeAgents(segment.from, segment.to); + }); + } + if (options.emitAudio !== false) { + this.options.audio.stroke({ + vibe: activeVibe, + from: previousPosition, + to: position, + canvasSize: this.options.getCanvasSize(), + colorIndex: settings.selectedColorIndex, + isErasing: this.isErasing, + pressure: pressure > 0 ? pressure : this.lastPointerPressure, + velocityPixelsPerSecond, + eraserSizePixels: settings.eraserSize * devicePixelRatio, + mirrorSegmentCount: this.options.getMirrorSegmentCount(), + pointerType: event.pointerType, + }); + } + this.lastPointerPosition = position; + this.lastPointerEventTimeMs = event.timeStamp; + } + + private addSmoothedBrushSample(position: vec2): void { + const previousSample = + this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1]; + if ( + previousSample !== undefined && + vec2.squaredDistance(previousSample, position) <= + GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED + ) { + return; + } + + this.smoothedStrokePoints.push(vec2.clone(position)); + + if (this.smoothedStrokePoints.length > 3) { + this.smoothedStrokePoints.shift(); + } + + if (this.smoothedStrokePoints.length === 1) { + this.addMirroredBrushSegment(position, position); + this.lastSmoothedBrushPosition = vec2.clone(position); + return; + } + + if (this.smoothedStrokePoints.length === 2) { + const [start, end] = this.smoothedStrokePoints; + const midpoint = getMidpoint(start, end); + this.addMirroredBrushSegment(start, midpoint); + this.lastSmoothedBrushPosition = midpoint; + return; + } + + const [start, control, end] = this.smoothedStrokePoints; + const curveStart = getMidpoint(start, control); + const curveEnd = getMidpoint(control, end); + this.addQuadraticBrushSegments(curveStart, control, curveEnd); + this.lastSmoothedBrushPosition = curveEnd; + } + + private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void { + const curveLength = vec2.distance(start, control) + vec2.distance(control, end); + const brushRadius = Math.max(1, settings.brushSize / 2); + const segmentSpacing = Math.max( + GardenPointerInput.MIN_CURVE_SEGMENT_SPACING_PIXELS, + brushRadius * GardenPointerInput.CURVE_SEGMENT_BRUSH_RADIUS_RATIO + ); + const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount()); + const curveResolution = getBrushCurveResolution(); + const maxCurveSegments = Math.max( + 1, + Math.floor(curveResolution / Math.sqrt(mirrorSegmentCount)) + ); + const segmentCount = Math.min( + maxCurveSegments, + Math.max(1, Math.ceil(curveLength / segmentSpacing)) + ); + + let previousPoint = start; + for (let i = 1; i <= segmentCount; i++) { + const point = getQuadraticPoint(start, control, end, i / segmentCount); + this.addMirroredBrushSegment(previousPoint, point); + previousPoint = point; + } + } + + private addMirroredBrushSegment(from: vec2, to: vec2): void { + this.getMirroredStrokeSegments(from, to).forEach((segment) => { + this.options.brushPipeline.addSwipeSegment(segment.from, segment.to); + }); + } + + private finishSmoothedStroke(): void { + if (this.isErasing || this.smoothedStrokePoints.length === 0) { + return; + } + + const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1]; + if ( + this.lastSmoothedBrushPosition !== null && + vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) > + GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED + ) { + this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample); + } + } + + private clearSmoothedStroke(): void { + this.smoothedStrokePoints.length = 0; + this.lastSmoothedBrushPosition = null; + } + + 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 getMirroredStrokeSegments(from: vec2, to: vec2): Array { + const segmentCount = this.options.getMirrorSegmentCount(); + if (segmentCount <= 1) { + return [{ from, to }]; + } + + const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 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; + } + + private getPointerPressure(event: PointerEvent): number { + if (Number.isFinite(event.pressure) && event.pressure > 0) { + return Math.min(1, Math.max(0, event.pressure)); + } + + return event.buttons > 0 || event.type === 'pointerdown' + ? gardenAudioConfig.input.pressureFallback + : 0; + } +} + +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 + ); +}; + +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.runtimeSettings.defaults.brushCurveResolution; + return Math.max(1, Math.floor(resolution)); +}; + +const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean => + left.clientX === right.clientX && + left.clientY === right.clientY && + left.pressure === right.pressure && + left.buttons === right.buttons; diff --git a/src/game-loop/render-input-cache.ts b/src/game-loop/render-input-cache.ts new file mode 100644 index 0000000..11969d5 --- /dev/null +++ b/src/game-loop/render-input-cache.ts @@ -0,0 +1,40 @@ +import { activeVibe } from '../settings'; +import { hexToRgb } from '../vibes'; +import { RenderInputs } from './game-loop-types'; + +export class RenderInputCache { + private cachedVibeId: string | null = null; + private cachedRenderInputs?: RenderInputs; + private previousAccentColor = ''; + + public invalidate(): void { + this.cachedVibeId = null; + this.cachedRenderInputs = undefined; + } + + public get(): RenderInputs { + if (this.cachedRenderInputs && this.cachedVibeId === activeVibe.id) { + return this.cachedRenderInputs; + } + + this.cachedVibeId = activeVibe.id; + this.cachedRenderInputs = { + channelColors: activeVibe.colors.map(hexToRgb), + backgroundColor: hexToRgb(activeVibe.backgroundColor), + }; + + return this.cachedRenderInputs; + } + + public updateAccentColor(color: [number, number, number]): void { + const accentColor = `rgb(${Math.round(color[0] * 255)},${Math.round( + color[1] * 255 + )},${Math.round(color[2] * 255)})`; + if (this.previousAccentColor === accentColor) { + return; + } + + this.previousAccentColor = accentColor; + document.documentElement.style.setProperty('--accent-color', accentColor); + } +} diff --git a/src/game-loop/simulation-frame.ts b/src/game-loop/simulation-frame.ts new file mode 100644 index 0000000..43ad661 --- /dev/null +++ b/src/game-loop/simulation-frame.ts @@ -0,0 +1,99 @@ +import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; +import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; +import { CopyPipeline } from '../pipelines/copy/copy-pipeline'; +import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline'; +import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; +import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; +import { RenderPipeline } from '../pipelines/render/render-pipeline'; +import { SimulationTextures } from './simulation-textures'; + +interface SimulationFramePipelines { + copyPipeline: CopyPipeline; + agentPipeline: AgentPipeline; + brushPipeline: BrushPipeline; + eraserAgentPipeline: EraserAgentPipeline; + eraserTexturePipeline: EraserTexturePipeline; + diffusionPipeline: DiffusionPipeline; + brushEffectDiffusionPipeline: DiffusionPipeline; + renderPipeline: RenderPipeline; +} + +export class SimulationFrameRenderer { + public constructor( + private readonly device: GPUDevice, + private readonly textures: SimulationTextures, + private readonly pipelines: SimulationFramePipelines + ) {} + + public execute(renderSpeed: number, isErasing: boolean): void { + for (let i = 0; i < renderSpeed; i++) { + const commandEncoder = this.device.createCommandEncoder(); + + this.pipelines.copyPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView() + ); + if (isErasing) { + this.pipelines.eraserTexturePipeline.execute( + commandEncoder, + this.textures.sourceMapA.getTextureView() + ); + this.pipelines.eraserTexturePipeline.execute( + commandEncoder, + this.textures.influenceMapA.getTextureView() + ); + this.pipelines.eraserTexturePipeline.execute( + commandEncoder, + this.textures.trailMapB.getTextureView() + ); + this.pipelines.eraserAgentPipeline.execute(commandEncoder); + } else { + this.pipelines.brushPipeline.execute( + commandEncoder, + this.textures.sourceMapA.getTextureView() + ); + this.pipelines.brushPipeline.execute( + commandEncoder, + this.textures.influenceMapA.getTextureView() + ); + } + this.pipelines.agentPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.trailMapB.getTextureView(), + this.textures.influenceMapA.getTextureView() + ); + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.trailMapB.getTextureView(), + this.textures.trailMapA.getTextureView() + ); + this.pipelines.renderPipeline.execute( + commandEncoder, + this.textures.trailMapA.getTextureView(), + this.textures.sourceMapA.getTextureView() + ); + this.pipelines.diffusionPipeline.execute( + commandEncoder, + this.textures.sourceMapA.getTextureView(), + this.textures.sourceMapB.getTextureView() + ); + this.pipelines.brushEffectDiffusionPipeline.execute( + commandEncoder, + this.textures.influenceMapA.getTextureView(), + this.textures.influenceMapB.getTextureView() + ); + + this.device.queue.submit([commandEncoder.finish()]); + this.textures.swapSourceMaps(); + this.textures.swapInfluenceMaps(); + } + } + + public clearSwipes(): void { + this.pipelines.brushPipeline.clearSwipes(); + this.pipelines.eraserAgentPipeline.clearSwipes(); + this.pipelines.eraserTexturePipeline.clearSwipes(); + } +} diff --git a/src/game-loop/simulation-textures.ts b/src/game-loop/simulation-textures.ts new file mode 100644 index 0000000..9309411 --- /dev/null +++ b/src/game-loop/simulation-textures.ts @@ -0,0 +1,58 @@ +import { vec2 } from 'gl-matrix'; + +import { ResizableTexture } from '../utils/graphics/resizable-texture'; + +export class SimulationTextures { + public readonly trailMapA: ResizableTexture; + public readonly trailMapB: ResizableTexture; + public sourceMapA: ResizableTexture; + public sourceMapB: ResizableTexture; + public influenceMapA: ResizableTexture; + public influenceMapB: ResizableTexture; + + public constructor( + private readonly device: GPUDevice, + canvasSize: vec2 + ) { + this.trailMapA = new ResizableTexture(this.device, canvasSize); + this.trailMapB = new ResizableTexture(this.device, canvasSize); + this.sourceMapA = new ResizableTexture(this.device, canvasSize); + this.sourceMapB = new ResizableTexture(this.device, canvasSize); + this.influenceMapA = new ResizableTexture(this.device, canvasSize); + this.influenceMapB = new ResizableTexture(this.device, 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); + this.trailMapA.resize(nextSize); + this.trailMapB.resize(nextSize); + this.sourceMapA.resize(nextSize); + this.sourceMapB.resize(nextSize); + this.influenceMapA.resize(nextSize); + this.influenceMapB.resize(nextSize); + + return scale; + } + + public swapSourceMaps(): void { + [this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA]; + } + + public swapInfluenceMaps(): void { + [this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA]; + } + + public destroy(): void { + this.trailMapA.destroy(); + this.trailMapB.destroy(); + this.sourceMapA.destroy(); + this.sourceMapB.destroy(); + this.influenceMapA.destroy(); + this.influenceMapB.destroy(); + } +} diff --git a/src/index.dom-contract.test.ts b/src/index.dom-contract.test.ts new file mode 100644 index 0000000..a1fd0a6 --- /dev/null +++ b/src/index.dom-contract.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const projectRoot = process.cwd(); +const indexSource = readFileSync(join(projectRoot, 'src/index.ts'), 'utf8'); +const html = readFileSync(join(projectRoot, 'index.html'), 'utf8'); + +const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const hasClass = (className: string, tagName?: string) => { + const tagPattern = tagName ? `<${tagName}\\b[^>]*` : '<[a-z][^>]*'; + return new RegExp( + `${tagPattern}class="[^"]*\\b${escapeRegex(className)}\\b[^"]*"`, + 'i' + ).test(html); +}; + +const hasId = (id: string) => new RegExp(`\\bid="${escapeRegex(id)}"`, 'i').test(html); + +const hasTag = (tagName: string) => + new RegExp(`<${escapeRegex(tagName)}(?:\\s|>|/)`, 'i').test(html); + +const selectorExists = (selector: string) => { + const idSelector = /^#(?[\w-]+)$/.exec(selector); + if (idSelector?.groups?.id) { + return hasId(idSelector.groups.id); + } + + const classSelector = /^\.([\w-]+)$/.exec(selector); + if (classSelector?.[1]) { + return hasClass(classSelector[1]); + } + + const tagClassSelector = /^(?[a-z]+)\.(?[\w-]+)$/.exec(selector); + if (tagClassSelector?.groups) { + return hasClass(tagClassSelector.groups.className, tagClassSelector.groups.tagName); + } + + if (/^[a-z]+$/.test(selector)) { + return hasTag(selector); + } + + throw new Error(`Unsupported selector contract syntax: ${selector}`); +}; + +describe('index DOM selector contract', () => { + it('keeps every boot-time required selector target present in index.html', () => { + const selectors = Array.from( + indexSource.matchAll(/queryRequiredElements?\(\s*'([^']+)'\s*,/g), + (match) => match[1] + ); + + expect(selectors.length).toBeGreaterThan(0); + expect(selectors.filter((selector) => !selectorExists(selector))).toEqual([]); + }); + + it('keeps the three color swatches expected by the palette UI', () => { + const colorSwatchCount = Array.from( + html.matchAll(/class="[^"]*\bcolor-swatch\b[^"]*"/g) + ).length; + + expect(colorSwatchCount).toBe(3); + }); +}); diff --git a/src/index.scss b/src/index.scss index 8c4500a..84ff5c0 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,275 +1,9 @@ -@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/panels'; +@use 'style/config-pane'; +@use 'style/loading'; +@use 'style/motion'; diff --git a/src/index.ts b/src/index.ts index 1e2c2d9..c4a78a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,196 @@ -import { isProduction } from './constants'; import GameLoop from './game-loop/game-loop'; -import { GameRules } from './game-loop/game-rules'; import './index.scss'; +import { appConfig } from './config'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; +import { ConfigPane } from './page/config-pane'; 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 { activeVibe, applyVibeSettings, resetSettings, settings } from './settings'; +import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage'; import { DeltaTimeCalculator } from './utils/delta-time-calculator'; +import { queryRequiredElement, queryRequiredElements } from './utils/dom'; import { ErrorHandler, Severity } from './utils/error-handler'; import { initializeGpu } from './utils/graphics/initialize-gpu'; +import { VIBE_PRESETS } from './vibes'; + +const clampEraserSize = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.eraser.default; + return Math.min( + appConfig.toolbar.eraser.max, + Math.max(appConfig.toolbar.eraser.min, Math.round(safeValue)) + ); +}; + +const getEraserSizeRatio = (size: number): number => + (size - appConfig.toolbar.eraser.min) / + (appConfig.toolbar.eraser.max - appConfig.toolbar.eraser.min); + +const clampMirrorSegmentCount = (value: number): number => { + const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.mirror.default; + return Math.min( + appConfig.toolbar.mirror.max, + Math.max(appConfig.toolbar.mirror.min, Math.round(safeValue)) + ); +}; + +const getMirrorSegmentRatio = (count: number): number => + (count - appConfig.toolbar.mirror.min) / + (appConfig.toolbar.mirror.max - appConfig.toolbar.mirror.min); + +const mirrorSegmentNames: Readonly> = + appConfig.toolbar.mirror.names; + +const formatMirrorSegmentCount = (count: number): string => + count === appConfig.toolbar.mirror.default + ? 'Mirror off' + : `${count} ${mirrorSegmentNames[count] ?? 'slices'}`; + +const renderRuntimeMessage = ( + container: HTMLElement, + error: Parameters[0]>[0] +) => { + 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 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, + aside: queryRequiredElement('aside', HTMLDivElement), + infoButton: queryRequiredElement('button.info', HTMLButtonElement), + infoElement: queryRequiredElement('.info-page', HTMLDivElement), + minimizeFullScreenButton: queryRequiredElement( + 'button.minimize-full-screen', + HTMLButtonElement + ), + maximizeFullScreenButton: queryRequiredElement( + 'button.maximize-full-screen', + HTMLButtonElement + ), + settingsButton: queryRequiredElement('button.settings', HTMLButtonElement), + soundButton: queryRequiredElement('button.sound', HTMLButtonElement), + restartButton: queryRequiredElement('button.restart', HTMLButtonElement), + canvas: queryRequiredElement('canvas', HTMLCanvasElement), + eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement), + errorContainer: queryRequiredElement('.errors-container', HTMLDivElement), + previousVibe: queryRequiredElement('.previous-vibe', HTMLButtonElement), + nextVibe: queryRequiredElement('.next-vibe', HTMLButtonElement), + swatches: queryRequiredElements('.color-swatch', HTMLButtonElement), + eraserSizeControl: queryRequiredElement('.eraser-size-control', HTMLLabelElement), + eraserSizeSlider: queryRequiredElement('.eraser-size-slider', HTMLInputElement), + mirrorSegmentControl: queryRequiredElement( + '.mirror-segment-control', + HTMLLabelElement + ), + mirrorSegmentSlider: queryRequiredElement( + '.mirror-segment-slider', + HTMLInputElement + ), + export4k: queryRequiredElement('.export-4k', HTMLButtonElement), + exportStatus: queryRequiredElement('.export-status', HTMLSpanElement), + prompt: queryRequiredElement('.garden-prompt', HTMLDivElement), + loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement), + loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement), + loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement), +}; + +const setLoadingStage = (label: string, ratio: number) => { + const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100); + elements.loadingStatus.textContent = label; + elements.loadingIndicator.style.setProperty('--loading-progress', `${percent}%`); + elements.loadingProgress.setAttribute('aria-valuenow', String(percent)); +}; + +let isAudioMuted = readBrowserStorage(appConfig.storage.audioMutedKey) === '1'; + +const renderAudioUi = (game: GameLoop | null) => { + elements.soundButton.classList.toggle('muted', isAudioMuted); + elements.soundButton.setAttribute('aria-pressed', String(isAudioMuted)); + elements.soundButton.setAttribute( + 'aria-label', + isAudioMuted ? 'Unmute audio' : 'Mute audio' + ); + elements.soundButton.title = isAudioMuted ? 'Unmute audio' : 'Mute audio'; + game?.setAudioMuted(isAudioMuted); +}; + +const renderPaletteUi = (game: GameLoop | null) => { + const isErasing = elements.eraserSizeControl.dataset.active === '1'; + elements.swatches.forEach((swatch, index) => { + swatch.style.backgroundColor = activeVibe.colors[index]; + swatch.classList.toggle( + 'active', + settings.selectedColorIndex === index && !isErasing + ); + }); + elements.eraserSizeControl.classList.toggle('active', isErasing); + game?.setEraseMode(isErasing); + document.documentElement.style.setProperty( + '--garden-background', + activeVibe.backgroundColor + ); + game?.onVibeChanged(); +}; + +const renderEraserSizeUi = (game: GameLoop | null) => { + const size = clampEraserSize(settings.eraserSize); + if (settings.eraserSize !== size) { + settings.eraserSize = size; + } + + elements.eraserSizeSlider.min = appConfig.toolbar.eraser.min.toString(); + elements.eraserSizeSlider.max = appConfig.toolbar.eraser.max.toString(); + elements.eraserSizeSlider.step = appConfig.toolbar.eraser.step.toString(); + elements.eraserSizeSlider.value = size.toString(); + elements.eraserSizeSlider.setAttribute('aria-valuetext', `${size}px`); + + const ratio = getEraserSizeRatio(size); + const scale = + appConfig.toolbar.eraser.controlScaleMin + + (appConfig.toolbar.eraser.controlScaleMax - + appConfig.toolbar.eraser.controlScaleMin) * + ratio; + elements.eraserSizeControl.style.setProperty('--eraser-progress', `${ratio * 100}%`); + elements.eraserSizeControl.style.setProperty( + '--eraser-control-scale', + scale.toFixed(3) + ); + game?.updateEraserPreview(); +}; + +const renderMirrorSegmentUi = () => { + const count = clampMirrorSegmentCount(settings.mirrorSegmentCount); + if (settings.mirrorSegmentCount !== count) { + settings.mirrorSegmentCount = count; + } + + elements.mirrorSegmentSlider.min = appConfig.toolbar.mirror.min.toString(); + elements.mirrorSegmentSlider.max = appConfig.toolbar.mirror.max.toString(); + elements.mirrorSegmentSlider.step = appConfig.toolbar.mirror.step.toString(); + elements.mirrorSegmentSlider.value = count.toString(); + + const label = formatMirrorSegmentCount(count); + const ratio = getMirrorSegmentRatio(count); + elements.mirrorSegmentSlider.setAttribute('aria-valuetext', label); + elements.mirrorSegmentControl.title = label; + elements.mirrorSegmentControl.classList.toggle('active', count > 1); + elements.mirrorSegmentControl.style.setProperty('--mirror-progress', `${ratio * 100}%`); + elements.mirrorSegmentControl.style.setProperty( + '--mirror-angle', + `${(360 / count).toFixed(3)}deg` + ); }; const main = async () => { @@ -38,37 +198,51 @@ const main = async () => { let shouldStop = false; let game: GameLoop | null = null; + elements.errorContainer.setAttribute('aria-live', 'assertive'); ErrorHandler.addOnErrorListener((error, _metadata) => { - elements.errorContainer.innerHTML += ` -
${error.message}
-      `;
-      game?.destroy();
-      shouldStop = true;
+      renderRuntimeMessage(elements.errorContainer, error);
+      if (error.severity === Severity.ERROR) {
+        document.body.classList.remove('is-loading');
+        game?.destroy();
+        shouldStop = true;
+      }
     });
 
+    const syncRuntimeUi = () => {
+      renderEraserSizeUi(game);
+      renderMirrorSegmentUi();
+      renderPaletteUi(game);
+    };
+
     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();
-    }
+    const configPane = new ConfigPane({
+      settingsButton: elements.settingsButton,
+      onConfigChange: syncRuntimeUi,
+      onRuntimeChange: syncRuntimeUi,
+      onRuntimeReset: () => {
+        resetSettings();
+        syncRuntimeUi();
+      },
+      onRestart: () => game?.destroy(),
+      onVibeChange: (vibeId) => {
+        applyVibeSettings(vibeId);
+        syncRuntimeUi();
+        game?.playVibeChangeAudio(false);
+      },
+    });
+    infoPageHandler.onOpen = configPane.close.bind(configPane);
 
     new MenuHider(
       elements.aside,
       () =>
         FullScreenHandler.isInFullScreenMode() &&
-        !settingsPageHandler.isOpen &&
-        !infoPageHandler.isOpen
+        !configPane.isOpen &&
+        !infoPageHandler.isOpen,
+      { persistentElement: elements.settingsButton }
     );
     new FullScreenHandler(
       elements.minimizeFullScreenButton,
@@ -76,31 +250,126 @@ const main = async () => {
       document.body
     );
 
+    const fontsReady = document.fonts.ready.catch(() => undefined);
+    setLoadingStage('Connecting to GPU…', 0.1);
     const gpu = await initializeGpu();
+    setLoadingStage('Loading fonts…', 0.4);
+    await fontsReady;
+    setLoadingStage('Compiling shaders…', 0.7);
 
     elements.restartButton.addEventListener('click', () => game?.destroy());
-
-    const deltaTimeCalculator = new DeltaTimeCalculator();
-    let sliders: Array> = [];
-
-    elements.applyDefaults.addEventListener('click', () => {
-      resetSettings();
-      sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
+    elements.soundButton.addEventListener('click', (event) => {
+      isAudioMuted = !isAudioMuted;
+      writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
+      renderAudioUi(game);
+      if (!isAudioMuted) {
+        game?.startAudio(event.isTrusted);
+      }
     });
 
-    while (!shouldStop) {
-      const gameRules = new GameRules(performance.now() / 1000);
-      game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
+    const deltaTimeCalculator = new DeltaTimeCalculator();
 
-      if (sliders.length === 0) {
-        sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
+    elements.previousVibe.addEventListener('click', (event) => {
+      const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
+      const vibe =
+        VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length];
+      applyVibeSettings(vibe.id);
+      configPane.refresh();
+      syncRuntimeUi();
+      game?.playVibeChangeAudio(event.isTrusted);
+    });
+
+    elements.nextVibe.addEventListener('click', (event) => {
+      const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
+      const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length];
+      applyVibeSettings(vibe.id);
+      configPane.refresh();
+      syncRuntimeUi();
+      game?.playVibeChangeAudio(event.isTrusted);
+    });
+
+    elements.swatches.forEach((swatch, index) => {
+      swatch.addEventListener('click', () => {
+        settings.selectedColorIndex = index;
+        elements.eraserSizeControl.dataset.active = '0';
+        game?.setEraseMode(false);
+        renderPaletteUi(game);
+        configPane.refresh();
+      });
+    });
+
+    const activateEraser = () => {
+      elements.eraserSizeControl.dataset.active = '1';
+      renderPaletteUi(game);
+    };
+
+    elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
+    elements.eraserSizeControl.addEventListener('click', activateEraser);
+    elements.eraserSizeSlider.addEventListener('focus', activateEraser);
+
+    elements.eraserSizeSlider.addEventListener('input', () => {
+      settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
+      elements.eraserSizeControl.dataset.active = '1';
+      renderEraserSizeUi(game);
+      renderPaletteUi(game);
+      configPane.refresh();
+    });
+
+    elements.mirrorSegmentSlider.addEventListener('input', () => {
+      settings.mirrorSegmentCount = clampMirrorSegmentCount(
+        Number(elements.mirrorSegmentSlider.value)
+      );
+      elements.eraserSizeControl.dataset.active = '0';
+      renderMirrorSegmentUi();
+      renderPaletteUi(game);
+      configPane.refresh();
+    });
+
+    elements.export4k.addEventListener('click', async () => {
+      if (!game || elements.export4k.disabled) {
+        return;
       }
 
-      await game.start();
+      elements.export4k.disabled = true;
+      try {
+        await game.export4K();
+      } catch (error) {
+        ErrorHandler.addException(error, { severity: Severity.WARNING });
+      } finally {
+        elements.export4k.disabled = false;
+      }
+    });
+
+    renderPaletteUi(game);
+    renderEraserSizeUi(game);
+    renderMirrorSegmentUi();
+    renderAudioUi(game);
+
+    let isFirstStart = true;
+    while (!shouldStop) {
+      game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
+        prompt: elements.prompt,
+        eraserPreview: elements.eraserPreview,
+        exportStatus: elements.exportStatus,
+      });
+      renderPaletteUi(game);
+      renderEraserSizeUi(game);
+      renderMirrorSegmentUi();
+      renderAudioUi(game);
+
+      const startPromise = game.start();
+      if (isFirstStart) {
+        isFirstStart = false;
+        setLoadingStage('Ready', 1);
+        requestAnimationFrame(() =>
+          requestAnimationFrame(() => document.body.classList.remove('is-loading'))
+        );
+      }
+      await startPromise;
     }
   } catch (e) {
-    const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
-    ErrorHandler.addError(Severity.ERROR, message);
+    document.body.classList.remove('is-loading');
+    ErrorHandler.addException(e);
     console.error(e);
   }
 };
diff --git a/src/page/collapsible-panel-animator.ts b/src/page/collapsible-panel-animator.ts
index d4c91fa..b5a4c6a 100644
--- a/src/page/collapsible-panel-animator.ts
+++ b/src/page/collapsible-panel-animator.ts
@@ -1,5 +1,8 @@
 export class CollapsiblePanelAnimator {
+  private static nextPanelId = 0;
+
   private _isOpen = false;
+  private focusBeforeOpen: HTMLElement | null = null;
 
   public onOpen: () => unknown = () => {};
   public onClose: () => unknown = () => {};
@@ -9,25 +12,64 @@ export class CollapsiblePanelAnimator {
     private readonly collapsibleContent: HTMLElement,
     ignoreForCloseOnClick: HTMLElement
   ) {
+    const panelId =
+      collapsibleContent.id ||
+      `collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`;
+    collapsibleContent.id = panelId;
+
+    toggleButton.setAttribute('aria-controls', panelId);
+    if (!collapsibleContent.hasAttribute('role')) {
+      collapsibleContent.setAttribute('role', 'region');
+    }
+    if (!collapsibleContent.hasAttribute('aria-label')) {
+      const label =
+        toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim();
+      collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`);
+    }
+    if (!collapsibleContent.hasAttribute('tabindex')) {
+      collapsibleContent.tabIndex = -1;
+    }
+
     toggleButton.addEventListener('click', this.toggle.bind(this));
     window.addEventListener(
       'click',
       (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
     );
+    window.addEventListener('keydown', (event) => {
+      if (this._isOpen && event.key === 'Escape') {
+        event.preventDefault();
+        this.close();
+      }
+    });
+    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.syncAccessibility();
     this.onOpen();
+    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.syncAccessibility();
     this.onClose();
+
+    if (focusWasInside) {
+      (this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
+    }
   }
 
   public toggle() {
@@ -41,4 +83,20 @@ export class CollapsiblePanelAnimator {
   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/config-pane.ts b/src/page/config-pane.ts
new file mode 100644
index 0000000..bf27c78
--- /dev/null
+++ b/src/page/config-pane.ts
@@ -0,0 +1,434 @@
+import { Pane, type BindingParams, type FolderApi } from 'tweakpane';
+
+import {
+  appConfig,
+  type GardenRuntimeSettings,
+  type NumberControlConfig,
+} from '../config';
+import { activeVibe, settings } from '../settings';
+import { VIBE_PRESETS } from '../vibes';
+
+type PaneContainer = Pick;
+type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
+
+const colorReactionRows = [
+  {
+    colorIndex: 0,
+    label: '1',
+    keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
+  },
+  {
+    colorIndex: 1,
+    label: '2',
+    keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
+  },
+  {
+    colorIndex: 2,
+    label: '3',
+    keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
+  },
+] as const;
+
+const colorReactionKeySet = new Set(
+  colorReactionRows.flatMap((row) => [...row.keys])
+);
+
+const isColorReactionKey = (key: string): key is ColorReactionKey =>
+  colorReactionKeySet.has(key);
+
+interface ConfigPaneOptions {
+  onConfigChange: () => void;
+  onRestart: () => void;
+  onRuntimeChange: () => void;
+  onRuntimeReset: () => void;
+  onVibeChange: (vibeId: string) => void;
+  settingsButton: HTMLButtonElement;
+}
+
+const isPlainObject = (value: unknown): value is Record =>
+  typeof value === 'object' && value !== null;
+
+const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
+  ['boolean', 'number', 'string'].includes(typeof value);
+
+const isColorString = (value: unknown): value is string =>
+  typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
+
+const toLabel = (value: string): string =>
+  value
+    .replace(/\[(\d+)\]/g, ' $1')
+    .replace(/([A-Z])/g, ' $1')
+    .replace(/[-_]/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+
+const normalizeNumber = (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);
+  }
+
+  const finiteValue = Number.isFinite(value) ? value : config.min;
+  const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
+  return config.integer ? Math.round(clampedValue) : clampedValue;
+};
+
+const getNumberBindingParams = (
+  key: keyof GardenRuntimeSettings & string,
+  config: NumberControlConfig
+): BindingParams => ({
+  label: config.label ?? toLabel(key),
+  min: config.min,
+  max: config.max,
+  options: config.options,
+  step: config.step,
+});
+
+export class ConfigPane {
+  private readonly container: HTMLDivElement;
+  private readonly pane: Pane;
+  private readonly colorReactionSelects = new Map<
+    ColorReactionKey,
+    HTMLSelectElement
+  >();
+  private readonly colorReactionSwatches: Array<{
+    colorIndex: number;
+    element: HTMLElement;
+  }> = [];
+  private readonly state = {
+    activeVibeId: activeVibe.id,
+  };
+
+  public constructor(private readonly options: ConfigPaneOptions) {
+    this.container = document.createElement('div');
+    this.container.className = 'config-pane-container';
+    Object.assign(this.container.style, {
+      boxSizing: 'border-box',
+      maxHeight: 'calc(100vh - 24px)',
+      pointerEvents: 'none',
+      position: 'fixed',
+      right: 'max(12px, env(safe-area-inset-right, 0px))',
+      top: 'max(12px, env(safe-area-inset-top, 0px))',
+      width:
+        'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
+      zIndex: '20',
+    });
+    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.style.boxSizing = 'border-box';
+    this.pane.element.style.maxHeight = 'calc(100vh - 24px)';
+    this.pane.element.style.overflowY = 'auto';
+    this.pane.element.style.pointerEvents = 'auto';
+    this.pane.element.style.width = '100%';
+
+    this.options.settingsButton.addEventListener('click', this.toggle);
+
+    const tabs = this.pane.addTab({
+      pages: [{ title: 'Runtime' }, { title: 'Config' }],
+    });
+
+    this.setUpRuntimeTab(tabs.pages[0]);
+    this.setUpConfigTab(tabs.pages[1]);
+    this.syncButton();
+  }
+
+  public get isOpen(): boolean {
+    return !this.pane.hidden;
+  }
+
+  public refresh(): void {
+    this.state.activeVibeId = activeVibe.id;
+    this.pane.refresh();
+    this.syncColorReactionMatrix();
+    this.syncButton();
+  }
+
+  private readonly toggle = () => {
+    this.pane.hidden = !this.pane.hidden;
+    this.syncButton();
+  };
+
+  private setHidden(isHidden: boolean): void {
+    this.pane.hidden = isHidden;
+    this.syncButton();
+  }
+
+  private setUpRuntimeTab(container: PaneContainer): void {
+    container
+      .addBinding(this.state, 'activeVibeId', {
+        label: 'active vibe',
+        options: Object.fromEntries(
+          VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
+        ) as Record,
+      })
+      .on('change', ({ value }) => {
+        this.options.onVibeChange(value);
+        this.refresh();
+      });
+
+    container
+      .addButton({
+        title: 'Reset runtime settings',
+      })
+      .on('click', () => {
+        this.options.onRuntimeReset();
+        this.refresh();
+      });
+
+    container
+      .addButton({
+        title: 'Restart simulation',
+      })
+      .on('click', () => this.options.onRestart());
+
+    const folders = new Map();
+    let hasAddedColorReactionMatrix = false;
+    Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
+      const settingKey = key as keyof GardenRuntimeSettings & string;
+      settings[settingKey] = normalizeNumber(settings[settingKey], config);
+
+      if (isColorReactionKey(key)) {
+        if (!hasAddedColorReactionMatrix) {
+          this.addColorReactionMatrix(container);
+          hasAddedColorReactionMatrix = true;
+        }
+        return;
+      }
+
+      const folder =
+        folders.get(config.folder) ??
+        container.addFolder({
+          title: config.folder,
+          expanded: config.folder !== 'Runtime',
+        });
+      folders.set(config.folder, folder);
+
+      folder
+        .addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
+        .on('change', () => {
+          const nextValue = normalizeNumber(settings[settingKey], config);
+          if (nextValue !== settings[settingKey]) {
+            settings[settingKey] = nextValue;
+            this.pane.refresh();
+          }
+          this.options.onRuntimeChange();
+        });
+    });
+    this.syncColorReactionMatrix();
+  }
+
+  private addColorReactionMatrix(container: PaneContainer): void {
+    const folder = container.addFolder({
+      title: 'Color Reactions',
+      expanded: true,
+    });
+    folder.element.classList.add('color-reaction-folder');
+
+    const content = Array.from(folder.element.children).find((child) =>
+      child.classList.contains('tp-fldv_c')
+    );
+    if (!(content instanceof HTMLElement)) {
+      return;
+    }
+
+    const doc = folder.element.ownerDocument;
+    const matrix = doc.createElement('div');
+    matrix.className = 'color-reaction-matrix';
+
+    matrix.appendChild(this.createColorReactionCorner(doc));
+    colorReactionRows.forEach((row) => {
+      matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+    });
+
+    colorReactionRows.forEach((row) => {
+      matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+      row.keys.forEach((key, columnIndex) => {
+        matrix.appendChild(
+          this.createColorReactionCell(doc, key, row.colorIndex, columnIndex)
+        );
+      });
+    });
+
+    content.appendChild(matrix);
+    this.syncColorReactionMatrix();
+  }
+
+  private createColorReactionCorner(doc: Document): HTMLDivElement {
+    const corner = doc.createElement('div');
+    corner.className = 'color-reaction-matrix__corner';
+    corner.textContent = 'agent';
+    return corner;
+  }
+
+  private createColorReactionHeader(
+    doc: Document,
+    colorIndex: number,
+    label: string
+  ): HTMLDivElement {
+    const header = doc.createElement('div');
+    header.className = 'color-reaction-matrix__header';
+
+    const swatch = doc.createElement('span');
+    swatch.className = 'color-reaction-matrix__swatch';
+    this.colorReactionSwatches.push({ colorIndex, element: swatch });
+    header.appendChild(swatch);
+
+    const text = doc.createElement('span');
+    text.textContent = label;
+    header.appendChild(text);
+
+    return header;
+  }
+
+  private createColorReactionCell(
+    doc: Document,
+    key: ColorReactionKey,
+    sourceColorIndex: number,
+    targetColorIndex: number
+  ): HTMLLabelElement {
+    const cell = doc.createElement('label');
+    cell.className = 'color-reaction-matrix__cell';
+
+    const select = doc.createElement('select');
+    select.setAttribute(
+      'aria-label',
+      `Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}`
+    );
+
+    const config = appConfig.runtimeSettings.controls[key];
+    Object.entries(config.options ?? {}).forEach(([label, value]) => {
+      const option = doc.createElement('option');
+      option.value = String(value);
+      option.textContent = label;
+      select.appendChild(option);
+    });
+
+    select.addEventListener('change', () => {
+      settings[key] = normalizeNumber(Number(select.value), config);
+      select.value = String(settings[key]);
+      this.options.onRuntimeChange();
+    });
+
+    this.colorReactionSelects.set(key, select);
+    cell.appendChild(select);
+
+    return cell;
+  }
+
+  private syncColorReactionMatrix(): void {
+    this.colorReactionSelects.forEach((select, key) => {
+      const config = appConfig.runtimeSettings.controls[key];
+      settings[key] = normalizeNumber(settings[key], config);
+      select.value = String(settings[key]);
+    });
+
+    this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
+      element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff';
+    });
+  }
+
+  private setUpConfigTab(container: PaneContainer): void {
+    this.addObjectBindings(
+      container,
+      appConfig as unknown as Record,
+      []
+    );
+  }
+
+  private addObjectBindings(
+    container: PaneContainer,
+    source: Record,
+    path: Array
+  ): void {
+    Object.entries(source).forEach(([key, value]) => {
+      if (isBindablePrimitive(value)) {
+        this.addPrimitiveBinding(container, source, key, value);
+        return;
+      }
+
+      if (Array.isArray(value)) {
+        const folder = container.addFolder({
+          title: toLabel(`${key}[]`),
+          expanded: path.length < appConfig.tuningPane.expandedDepth,
+        });
+        value.forEach((item, index) => {
+          if (isBindablePrimitive(item)) {
+            this.addPrimitiveBinding(
+              folder,
+              value as unknown as Record,
+              `${index}`,
+              item
+            );
+            return;
+          }
+
+          if (isPlainObject(item)) {
+            this.addObjectBindings(
+              folder.addFolder({
+                title: `[${index}]`,
+                expanded: false,
+              }),
+              item,
+              [...path, key, String(index)]
+            );
+          }
+        });
+        return;
+      }
+
+      if (isPlainObject(value)) {
+        this.addObjectBindings(
+          container.addFolder({
+            title: toLabel(key),
+            expanded: path.length < appConfig.tuningPane.expandedDepth,
+          }),
+          value,
+          [...path, key]
+        );
+      }
+    });
+  }
+
+  private addPrimitiveBinding(
+    container: PaneContainer,
+    source: Record,
+    key: string,
+    value: boolean | number | string
+  ): void {
+    const params: BindingParams = {
+      label: toLabel(key),
+      ...(isColorString(value) ? { color: { type: 'int' } } : {}),
+      ...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
+    };
+
+    container
+      .addBinding(source, key, params)
+      .on('change', () => this.options.onConfigChange());
+  }
+
+  private syncButton(): void {
+    this.options.settingsButton.classList.toggle('active', this.isOpen);
+    this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
+    this.options.settingsButton.setAttribute(
+      'aria-label',
+      this.isOpen ? 'Hide config overlay' : 'Show config overlay'
+    );
+    this.options.settingsButton.title = this.isOpen
+      ? 'Hide config overlay'
+      : 'Show config overlay';
+  }
+
+  public close(): void {
+    this.setHidden(true);
+  }
+}
diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts
index 84c9150..6bf081e 100644
--- a/src/page/full-screen-handler.ts
+++ b/src/page/full-screen-handler.ts
@@ -32,12 +32,11 @@ export class FullScreenHandler {
     return document.fullscreenElement !== null;
   }
 
-  private updateButtons() {
-    this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
-      ? 'block'
-      : 'none';
-    this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
-      ? 'none'
-      : 'block';
+  private updateButtons(): void {
+    const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
+    this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
+    this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
+    this.minimizeButton.classList.toggle('active', isInFullScreenMode);
+    this.maximizeButton.classList.toggle('active', isInFullScreenMode);
   }
 }
diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts
index 173a5b6..b2ea459 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,17 +1,107 @@
-export class MenuHider {
-  private static readonly DEFAULT_TIME_TO_LIVE = 3500;
-  private static readonly INTERVAL = 50;
-  private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+import { appConfig } from '../config';
+
+interface MenuHiderOptions {
+  persistentElement?: HTMLElement;
+}
+
+export class MenuHider {
+  private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
+  private static readonly INTERVAL = appConfig.menuHider.intervalMs;
+  private static readonly BOTTOM_REVEAL_DISTANCE =
+    appConfig.menuHider.bottomRevealDistancePx;
+  private readonly interactiveElements: Array;
+  private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+  private isHidden = false;
+
+  public constructor(
+    private readonly element: HTMLElement,
+    private readonly shouldBeHidden: () => boolean,
+    private readonly options: MenuHiderOptions = {}
+  ) {
+    this.interactiveElements = Array.from(
+      element.querySelectorAll(
+        'a[href], button, input, select, textarea, [tabindex]'
+      )
+    );
+
+    if (options.persistentElement) {
+      element.classList.add('has-persistent-settings');
+    }
 
-  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';
+      this.updateVisibility();
     }, MenuHider.INTERVAL);
 
-    element.addEventListener(
-      'mouseover',
-      () => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
-    );
+    element.addEventListener('mouseover', this.wakeUp);
+    element.addEventListener('focusin', this.wakeUp);
+    element.addEventListener('pointerdown', this.wakeUp);
+    window.addEventListener('pointermove', this.wakeUpNearViewportBottom, {
+      passive: true,
+    });
+    window.addEventListener('pointerdown', this.wakeUp, {
+      capture: true,
+      passive: true,
+    });
+    window.addEventListener('touchstart', this.wakeUp, {
+      capture: true,
+      passive: true,
+    });
+    window.addEventListener('keydown', this.wakeUp, { capture: true });
+    window.addEventListener('focusin', this.wakeUp, { capture: true });
+
+    this.updateVisibility();
+  }
+
+  private readonly wakeUp = () => {
+    this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+    this.updateVisibility();
+  };
+
+  private readonly wakeUpNearViewportBottom = (event: PointerEvent) => {
+    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+    const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE;
+
+    if (event.clientY >= revealStart) {
+      this.wakeUp();
+    }
+  };
+
+  private updateVisibility() {
+    const focusWithin = this.element.contains(document.activeElement);
+    const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin;
+
+    if (this.isHidden === shouldHide) {
+      return;
+    }
+
+    this.isHidden = shouldHide;
+    this.element.classList.toggle('menu-hidden', shouldHide);
+    this.syncAccessibility(shouldHide);
+  }
+
+  private syncAccessibility(shouldHide: boolean): void {
+    const persistentElement = this.options.persistentElement;
+
+    if (!persistentElement) {
+      this.element.style.opacity = shouldHide ? '0' : '1';
+      this.element.setAttribute('aria-hidden', String(shouldHide));
+      this.element.inert = shouldHide;
+      return;
+    }
+
+    this.element.style.opacity = '';
+    this.element.setAttribute('aria-hidden', 'false');
+    this.element.inert = false;
+
+    this.interactiveElements.forEach((interactiveElement) => {
+      const isPersistentElement = interactiveElement === persistentElement;
+
+      interactiveElement.inert = shouldHide && !isPersistentElement;
+      interactiveElement.toggleAttribute(
+        'aria-hidden',
+        shouldHide && !isPersistentElement
+      );
+    });
   }
 }
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/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
new file mode 100644
index 0000000..6be9e0e
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
@@ -0,0 +1,36 @@
+struct Settings {
+  agentCount: u32,
+  padding0: u32,
+  padding1: u32,
+  padding2: u32,
+};
+
+struct Counters {
+  aliveAgentCount: atomic,
+  padding0: atomic,
+  padding1: atomic,
+};
+
+@group(1) @binding(0) var settings: Settings;
+@group(1) @binding(2) var counters: Counters;
+@group(1) @binding(3) var compactedAgents: array;
+
+@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 >= settings.agentCount {
+    return;
+  }
+
+  let agent = agents[id];
+  if agent.colorIndex < 0.0 {
+    return;
+  }
+
+  let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1);
+  compactedAgents[compactedIndex] = agent;
+}
diff --git a/src/pipelines/agents/agent-generation/agent-counting.wgsl b/src/pipelines/agents/agent-generation/agent-counting.wgsl
index 9d4c1b6..964125a 100644
--- a/src/pipelines/agents/agent-generation/agent-counting.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-counting.wgsl
@@ -5,8 +5,8 @@ struct Settings {
 @group(1) @binding(0) var settings: Settings;
 
 struct Counters {
-  evenGenerationAlive: atomic,
-  oddGenerationAlive: atomic,
+  redAgentsAlive: atomic,
+  greenAgentsAlive: atomic,
 };
 
 @group(1) @binding(2) var counters: Counters;
@@ -23,9 +23,13 @@ fn main(
     return;
   }
 
-  if agents[id].generation % 2 == 0 {
-    atomicAdd(&counters.evenGenerationAlive, 1);
+  if agents[id].colorIndex < 0.0 {
+    return;
+  }
+
+  if agents[id].colorIndex < 0.5 {
+    atomicAdd(&counters.redAgentsAlive, 1);
   } else {
-    atomicAdd(&counters.oddGenerationAlive, 1);
+    atomicAdd(&counters.greenAgentsAlive, 1);
   }
 }
diff --git a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
index 0f2bb34..a91ae14 100644
--- a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
@@ -30,5 +30,8 @@ fn main(
     randomPosition.xz * state.size,
     random.r * 3.14 * 2,
     0,
+    vec2(-1.0, -1.0),
+    0.0,
+    0.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..3626449 100644
--- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
+++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
@@ -1,27 +1,42 @@
+import { vec2 } from 'gl-matrix';
+
 import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts';
 import { smartCompile } from '../../../utils/graphics/smart-compile';
 import { CommonState } from '../../common-state/common-state';
 import { AGENT_SIZE_IN_BYTES } from './agent';
+import compactionShader from './agent-compaction.wgsl?raw';
 import countingShader from './agent-counting.wgsl?raw';
 import firstGenerationShader from './agent-first-generation.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 UNIFORM_COUNT = 4;
   private static readonly COUNTER_COUNT = 3;
 
   private readonly bindGroupLayout: GPUBindGroupLayout;
+  private readonly compactionBindGroupLayout: GPUBindGroupLayout;
   private readonly uniforms: GPUBuffer;
   private readonly bindGroup: GPUBindGroup;
+  private readonly compactionBindGroup: GPUBindGroup;
 
   private readonly firstGenerationPipeline: GPUComputePipeline;
   private readonly countingPipeline: GPUComputePipeline;
+  private readonly resizePipeline: GPUComputePipeline;
+  private readonly compactionPipeline: GPUComputePipeline;
 
   public readonly agentsBuffer: GPUBuffer;
+  private readonly compactedAgentsBuffer: GPUBuffer;
   public readonly countersBuffer: GPUBuffer;
   public readonly countersStagingBuffer: GPUBuffer;
+  private readonly counterClearValues = new Uint32Array(
+    AgentGenerationPipeline.COUNTER_COUNT
+  );
+  private readonly agentCountUniformValues = new Uint32Array(
+    AgentGenerationPipeline.UNIFORM_COUNT
+  );
 
   public constructor(
     private readonly device: GPUDevice,
@@ -54,9 +69,47 @@ export class AgentGenerationPipeline {
       ],
     });
 
+    this.compactionBindGroupLayout = device.createBindGroupLayout({
+      entries: [
+        {
+          binding: 0,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: {
+            type: 'uniform',
+          },
+        },
+        {
+          binding: 1,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: {
+            type: 'storage',
+          },
+        },
+        {
+          binding: 2,
+          visibility: GPUShaderStage.COMPUTE,
+          buffer: {
+            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,
+      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+    });
+
+    this.compactedAgentsBuffer = this.device.createBuffer({
+      size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
+      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
     });
 
     this.countersBuffer = this.device.createBuffer({
@@ -98,6 +151,36 @@ export class AgentGenerationPipeline {
       ],
     });
 
+    this.compactionBindGroup = this.device.createBindGroup({
+      layout: this.compactionBindGroupLayout,
+      entries: [
+        {
+          binding: 0,
+          resource: {
+            buffer: this.uniforms,
+          },
+        },
+        {
+          binding: 1,
+          resource: {
+            buffer: this.agentsBuffer,
+          },
+        },
+        {
+          binding: 2,
+          resource: {
+            buffer: this.countersBuffer,
+          },
+        },
+        {
+          binding: 3,
+          resource: {
+            buffer: this.compactedAgentsBuffer,
+          },
+        },
+      ],
+    });
+
     this.firstGenerationPipeline = device.createComputePipeline({
       layout: device.createPipelineLayout({
         bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
@@ -122,16 +205,79 @@ export class AgentGenerationPipeline {
         entryPoint: 'main',
       },
     });
+
+    this.resizePipeline = device.createComputePipeline({
+      layout: device.createPipelineLayout({
+        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+      }),
+      compute: {
+        module: smartCompile(device, CommonState.shaderCode, agentSchema, resizeShader),
+        entryPoint: 'main',
+      },
+    });
+
+    this.compactionPipeline = device.createComputePipeline({
+      layout: device.createPipelineLayout({
+        bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
+      }),
+      compute: {
+        module: smartCompile(
+          device,
+          CommonState.shaderCode,
+          agentSchema,
+          compactionShader
+        ),
+        entryPoint: 'main',
+      },
+    });
   }
 
   public get maxAgentCount(): number {
     return Math.min(
-      this.maxAgentCountUpperLimit,
+      Number.isFinite(this.maxAgentCountUpperLimit)
+        ? this.maxAgentCountUpperLimit
+        : Number.POSITIVE_INFINITY,
       Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
       this.device.limits.maxComputeWorkgroupsPerDimension ** 3
     );
   }
 
+  public writeAgents(agentOffset: number, data: Float32Array): void {
+    this.device.queue.writeBuffer(
+      this.agentsBuffer,
+      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.device.queue.writeBuffer(
+      this.uniforms,
+      0,
+      new Float32Array([scale[0], scale[1], agentCount, 0])
+    );
+
+    const commandEncoder = this.device.createCommandEncoder();
+    const passEncoder = commandEncoder.beginComputePass();
+    this.commonState.execute(passEncoder);
+    passEncoder.setPipeline(this.resizePipeline);
+    passEncoder.setBindGroup(1, this.bindGroup);
+    passEncoder.dispatchWorkgroups(
+      ...getWorkgroupCounts(
+        this.device,
+        agentCount,
+        AgentGenerationPipeline.WORKGROUP_SIZE
+      )
+    );
+    passEncoder.end();
+
+    this.device.queue.submit([commandEncoder.finish()]);
+  }
+
   public spawnFirstGeneration(): void {
     const commandEncoder = this.device.createCommandEncoder();
 
@@ -152,8 +298,11 @@ export class AgentGenerationPipeline {
   }
 
   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]));
+    this.counterClearValues.fill(0);
+    this.agentCountUniformValues.fill(0);
+    this.agentCountUniformValues[0] = agentCount;
+    this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
+    this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
 
     const commandEncoder = this.device.createCommandEncoder();
 
@@ -190,10 +339,62 @@ export class AgentGenerationPipeline {
     };
   }
 
+  public async compactAgents(agentCount: number): Promise {
+    if (agentCount <= 0) {
+      return 0;
+    }
+
+    this.counterClearValues.fill(0);
+    this.agentCountUniformValues.fill(0);
+    this.agentCountUniformValues[0] = agentCount;
+    this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
+    this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
+
+    const commandEncoder = this.device.createCommandEncoder();
+    const passEncoder = commandEncoder.beginComputePass();
+    passEncoder.setPipeline(this.compactionPipeline);
+    this.commonState.execute(passEncoder);
+    passEncoder.setBindGroup(1, this.compactionBindGroup);
+    passEncoder.dispatchWorkgroups(
+      ...getWorkgroupCounts(
+        this.device,
+        agentCount,
+        AgentGenerationPipeline.WORKGROUP_SIZE
+      )
+    );
+    passEncoder.end();
+
+    commandEncoder.copyBufferToBuffer(
+      this.compactedAgentsBuffer,
+      0,
+      this.agentsBuffer,
+      0,
+      agentCount * AGENT_SIZE_IN_BYTES
+    );
+    commandEncoder.copyBufferToBuffer(
+      this.countersBuffer,
+      0,
+      this.countersStagingBuffer,
+      0,
+      Uint32Array.BYTES_PER_ELEMENT
+    );
+
+    this.device.queue.submit([commandEncoder.finish()]);
+
+    await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
+    const compactedCount = new Uint32Array(
+      this.countersStagingBuffer.getMappedRange().slice(0, Uint32Array.BYTES_PER_ELEMENT)
+    )[0];
+    this.countersStagingBuffer.unmap();
+
+    return compactedCount;
+  }
+
   public destroy() {
     this.uniforms.destroy();
     this.countersBuffer.destroy();
     this.countersStagingBuffer.destroy();
+    this.compactedAgentsBuffer.destroy();
     this.agentsBuffer.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..3f02c46
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl
@@ -0,0 +1,28 @@
+struct ResizeSettings {
+  scale: vec2,
+  agentCount: f32,
+  padding: f32,
+};
+
+@group(1) @binding(0) var resizeSettings: ResizeSettings;
+
+@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 >= u32(resizeSettings.agentCount) {
+    return;
+  }
+
+  var agent = agents[id];
+  agent.position *= resizeSettings.scale;
+
+  if agent.targetPosition.x >= 0.0 && agent.targetPosition.y >= 0.0 {
+    agent.targetPosition *= resizeSettings.scale;
+  }
+
+  agents[id] = agent;
+}
diff --git a/src/pipelines/agents/agent-generation/agent-schema.test.ts b/src/pipelines/agents/agent-generation/agent-schema.test.ts
new file mode 100644
index 0000000..96a419e
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-schema.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest';
+
+import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
+import compactionShader from './agent-compaction.wgsl?raw';
+import countingShader from './agent-counting.wgsl?raw';
+import firstGenerationShader from './agent-first-generation.wgsl?raw';
+import resizeShader from './agent-resize.wgsl?raw';
+import agentSchema from './agent-schema.wgsl?raw';
+
+const wgslFloatCountByType: Record = {
+  f32: 1,
+  'vec2': 2,
+};
+
+const getAgentStructFields = () => {
+  const match = /struct Agent\s*\{(?[\s\S]*?)\n\}/.exec(agentSchema);
+  if (!match?.groups?.body) {
+    throw new Error('Agent struct was not found in agent-schema.wgsl');
+  }
+
+  return match.groups.body
+    .split('\n')
+    .map((line) => line.trim().replace(/,$/, ''))
+    .filter(Boolean)
+    .map((line) => {
+      const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line);
+      if (!fieldMatch?.groups) {
+        throw new Error(`Unsupported Agent field syntax: ${line}`);
+      }
+
+      return {
+        name: fieldMatch.groups.name,
+        type: fieldMatch.groups.type,
+      };
+    });
+};
+
+describe('Agent TS/WGSL contract', () => {
+  it('keeps the TypeScript float count aligned with the WGSL Agent struct', () => {
+    const fields = getAgentStructFields();
+    const wgslFloatCount = fields.reduce((sum, field) => {
+      const count = wgslFloatCountByType[field.type];
+      if (!count) {
+        throw new Error(`Unsupported WGSL Agent field type: ${field.type}`);
+      }
+
+      return sum + count;
+    }, 0);
+
+    expect(fields.map((field) => field.name)).toEqual([
+      'position',
+      'angle',
+      'colorIndex',
+      'targetPosition',
+      'targetAngle',
+      'introDelay',
+    ]);
+    expect(wgslFloatCount).toBe(AGENT_FLOAT_COUNT);
+    expect(AGENT_SIZE_IN_BYTES).toBe(AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT);
+  });
+
+  it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
+    [firstGenerationShader, countingShader, resizeShader, compactionShader].forEach(
+      (shader) => {
+        expect(shader).toMatch(/@workgroup_size\(64\)/);
+      }
+    );
+
+    expect(agentSchema).toContain('workgroup_count.x * 64');
+    expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64');
+    expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);');
+    expect(compactionShader).toContain('if id >= settings.agentCount');
+  });
+});
diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl
index 3b37725..d40471e 100644
--- a/src/pipelines/agents/agent-generation/agent-schema.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl
@@ -1,7 +1,10 @@
 struct Agent {
   position: vec2,
   angle: f32,
-  generation: f32,
+  colorIndex: f32,
+  targetPosition: vec2,
+  targetAngle: f32,
+  introDelay: f32,
 }
 
 @group(1) @binding(1) var agents: array;
diff --git a/src/pipelines/agents/agent-generation/agent.ts b/src/pipelines/agents/agent-generation/agent.ts
index b950f32..630e017 100644
--- a/src/pipelines/agents/agent-generation/agent.ts
+++ b/src/pipelines/agents/agent-generation/agent.ts
@@ -1,9 +1,2 @@
-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;
+export const AGENT_FLOAT_COUNT = 8;
+export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index efcf9ed..4e1515f 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -1,5 +1,7 @@
-import { vec2 } from 'gl-matrix';
-
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
 import { smartCompile } from '../../utils/graphics/smart-compile';
 import { CommonState } from '../common-state/common-state';
@@ -9,14 +11,19 @@ import shader from './agent.wgsl?raw';
 
 export class AgentPipeline {
   private static readonly WORKGROUP_SIZE = 64;
-  private static readonly UNIFORM_COUNT = 19;
+  private static readonly UNIFORM_COUNT = 17;
 
   private readonly bindGroupLayout: GPUBindGroupLayout;
   private readonly pipeline: GPUComputePipeline;
   private readonly uniforms: GPUBuffer;
-  private bindGroup?: GPUBindGroup;
-  private previousTrailMapIn?: GPUTextureView;
-  private previousTrailMapOut?: GPUTextureView;
+  private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    AgentPipeline.UNIFORM_COUNT
+  );
+  private readonly bindGroupsByTexture = new WeakMap<
+    GPUTextureView,
+    WeakMap>
+  >();
 
   private agentCount = 0;
 
@@ -45,115 +52,126 @@ export class AgentPipeline {
 
   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,
     agentCount,
+    introProgress,
   }: AgentSettings & {
     deltaTime: number;
-    currentGenerationAggression: number;
-    nextGenerationAggression: number;
-    nextGenerationSensorOffsetDistance: number;
-    nextGenerationSpeed: number;
-    isNextGenerationOdd: number;
-    center: vec2;
-    radius: number;
-    infectionProbability: number;
     agentCount: number;
+    introProgress?: number;
   }) {
     this.agentCount = agentCount;
-    this.device.queue.writeBuffer(
+    this.uniformValues[0] = moveSpeed * deltaTime;
+    this.uniformValues[1] = turnSpeed * deltaTime;
+    this.uniformValues[2] = (sensorOffsetAngle * Math.PI) / 180;
+    this.uniformValues[3] = sensorOffsetDistance;
+    this.uniformValues[4] = turnWhenLost;
+    this.uniformValues[5] = individualTrailWeight;
+    this.uniformValues[6] = agentCount;
+    this.uniformValues[7] = introProgress ?? 1;
+    this.uniformValues[8] = color1ToColor1;
+    this.uniformValues[9] = color1ToColor2;
+    this.uniformValues[10] = color1ToColor3;
+    this.uniformValues[11] = color2ToColor1;
+    this.uniformValues[12] = color2ToColor2;
+    this.uniformValues[13] = color2ToColor3;
+    this.uniformValues[14] = color3ToColor1;
+    this.uniformValues[15] = color3ToColor2;
+    this.uniformValues[16] = color3ToColor3;
+    writeFloat32BufferIfChanged(
+      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,
+    sourceMap: GPUTextureView
   ) {
-    this.ensureBindGroupExists(trailMapIn, trailMapOut);
+    const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
 
     const passEncoder = commandEncoder.beginComputePass();
     passEncoder.setPipeline(this.pipeline);
     this.commonState.execute(passEncoder);
-    passEncoder.setBindGroup(1, this.bindGroup);
+    passEncoder.setBindGroup(1, 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;
+  private getBindGroup(
+    trailMapIn: GPUTextureView,
+    trailMapOut: GPUTextureView,
+    sourceMap: GPUTextureView
+  ): GPUBindGroup {
+    let outputCache = this.bindGroupsByTexture.get(trailMapIn);
+    if (!outputCache) {
+      outputCache = new WeakMap>();
+      this.bindGroupsByTexture.set(trailMapIn, outputCache);
     }
+
+    let sourceCache = outputCache.get(trailMapOut);
+    if (!sourceCache) {
+      sourceCache = new WeakMap();
+      outputCache.set(trailMapOut, sourceCache);
+    }
+
+    const cached = sourceCache.get(sourceMap);
+    if (cached) {
+      return cached;
+    }
+
+    const 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,
+        },
+        {
+          binding: 4,
+          resource: sourceMap,
+        },
+      ],
+    });
+
+    sourceCache.set(sourceMap, bindGroup);
+    return bindGroup;
   }
 
   public destroy() {
@@ -191,6 +209,13 @@ export class AgentPipeline {
             format: 'rgba16float',
           },
         },
+        {
+          binding: 4,
+          visibility: GPUShaderStage.COMPUTE,
+          texture: {
+            sampleType: 'float',
+          },
+        },
       ],
     };
   }
diff --git a/src/pipelines/agents/agent-settings.ts b/src/pipelines/agents/agent-settings.ts
index 53b639c..cdd4601 100644
--- a/src/pipelines/agents/agent-settings.ts
+++ b/src/pipelines/agents/agent-settings.ts
@@ -1,11 +1,17 @@
 export interface AgentSettings {
-  brushTrailWeight: number;
+  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;
-  currentGenerationAggression: number;
-  nextGenerationAggression: number;
 }
diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl
index 576b3cc..1516f7a 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -1,37 +1,27 @@
 struct Settings {
-  center: vec2,
-  radius: f32,
-
-  brushTrailWeight: f32,
-  currentGenerationMoveRate: f32,
+  moveRate: f32,
   turnRate: f32,
-
   sensorAngle: 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: f32,
+  introProgress: f32,
+  color1ToColor1: f32,
+  color1ToColor2: f32,
+  color1ToColor3: f32,
+  color2ToColor1: f32,
+  color2ToColor2: f32,
+  color2ToColor3: f32,
+  color3ToColor1: f32,
+  color3ToColor2: f32,
+  color3ToColor3: f32,
 };
 
-
 @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(4) var sourceMap: texture_2d;
 
 @compute @workgroup_size(64)
 fn main(
@@ -45,90 +35,150 @@ fn main(
   }
 
   var agent = agents[id];
+  if agent.colorIndex < 0.0 {
+    return;
+  }
+
+  let hasIntroTarget =
+    settings.introProgress < 0.999 &&
+    agent.targetPosition.x >= 0.0 &&
+    agent.targetPosition.y >= 0.0;
+
+  if hasIntroTarget && settings.introProgress < agent.introDelay {
+    return;
+  }
 
   let random = textureSampleLevel(
     noise,
     noiseSampler,
-    vec2(
-      f32(id) % 23647 / 2000,
-      state.time % 3243 / 2000
-    ),
+    fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)),
     0
   );
 
-  let isFromCurrentGeneration = abs(agent.generation - settings.isNextGenerationOdd);
-  let isFromNextGeneration = 1.0 - isFromCurrentGeneration;
-  let isFromOddGeneration = agent.generation % 2;
+  let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
+  let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
+  let rightSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, -settings.sensorAngle);
 
-  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);
+  let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
+  let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
+  let trailRight = textureLoad(trailMapIn, rightSensor, 0);
+  let sourceForwardSample = textureLoad(sourceMap, forwardSensor, 0);
+  let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
+  let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
 
-  var weightForward = brushWeight * trailForward.a;
-  var weightLeft = brushWeight * trailLeft.a;
-  var weightRight = brushWeight * trailRight.a;
+  let channelMask = get_channel_mask(agent.colorIndex);
+  let reactionMask = get_reaction_mask(agent.colorIndex);
 
-  let agression = mix(settings.currentGenerationAggression, settings.nextGenerationAggression, isFromNextGeneration) + weightForward; 
+  let trailForwardWeight = dot(trailForward.rgb, reactionMask);
+  let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
+  let trailRightWeight = dot(trailRight.rgb, reactionMask);
 
-  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;
+  let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
+  let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
+  let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
+
+  let weightForward = trailForwardWeight + sourceForwardWeight * 24.0;
+  let weightLeft = trailLeftWeight + sourceLeftWeight * 24.0;
+  let weightRight = trailRightWeight + sourceRightWeight * 24.0;
+
+  var rotation = (random.r - 0.5) * settings.turnWhenLost;
   if weightForward >= weightLeft && weightForward >= weightRight {
-    rotation = 0;
+    rotation = rotation * 0.25;
   } else {
-    rotation = sign(weightLeft - weightRight) * settings.turnRate;
+    rotation += sign(weightLeft - weightRight) * settings.turnRate;
   }
 
-  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;
+  let sourceAtAgent = textureLoad(sourceMap, vec2(agent.position), 0);
+  let positiveReactionMask = max(reactionMask, vec3(0.0));
+  let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
+  var moveRate = settings.moveRate * mix(1.0, 0.08, sourceAtAgentStrength);
+  var introTargetOffset = vec2(0.0, 0.0);
+  var introTargetDistance = 0.0;
+
+  if hasIntroTarget {
+    introTargetOffset = agent.targetPosition - agent.position;
+    introTargetDistance = length(introTargetOffset);
+    let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
+    let nearTitle = 1.0 - smoothstep(4.0, max(28.0, settings.sensorOffset * 0.75), introTargetDistance);
+    let desiredAngle = mix(targetAngle, agent.targetAngle, nearTitle * 0.2);
+    let introTurn = angle_delta(agent.angle, desiredAngle);
+
+    rotation = clamp(introTurn, -settings.turnRate * 3.4, settings.turnRate * 3.4)
+      + (random.g - 0.5) * settings.turnWhenLost * 0.18;
+    moveRate = min(settings.moveRate * mix(2.65, 0.01, nearTitle), introTargetDistance);
   }
 
-  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;
+  var step = vec2(cos(agent.angle), sin(agent.angle)) * moveRate;
+  if hasIntroTarget {
+    step = vec2(0.0, 0.0);
+    if introTargetDistance > 0.5 {
+      step = introTargetOffset / introTargetDistance * moveRate;
     }
   }
 
-  textureStore(trailMapOut, vec2(nextPosition), trailBelow);
+  let maxPosition = state.size - vec2(1.0, 1.0);
+  let nextPosition = clamp(agent.position + step, vec2(0, 0), maxPosition);
+  if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
+    rotation = 3.14159265359 + random.a - 0.5;
+  }
+
+  let sourceBelow = textureLoad(sourceMap, vec2(nextPosition), 0);
+  let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
+  let trailWeight = settings.individualTrailWeight * (1.0 + sourceBelowStrength * 16.0);
+  var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0);
+  trailBelow = vec4(
+    trailBelow.rgb + channelMask * trailWeight,
+    max(trailBelow.a, 0.0)
+  );
+
+  agent.angle += rotation;
   agent.position = nextPosition;
+
+  textureStore(trailMapOut, vec2(nextPosition), trailBelow);
   agents[id] = agent;
 }
 
-fn sense(agentPosition: vec2, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4 {
+fn sensor_position(agentPosition: vec2, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec2 {
   let sensorAngle = agentAngle + sensorOffsetAngle;
-  let sensorPosition = vec2(agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset);
-  return textureLoad(trailMapIn, sensorPosition, 0); 
+  return vec2(clamp(
+    agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset,
+    vec2(0, 0),
+    state.size - vec2(1, 1)
+  ));
+}
+
+fn get_channel_mask(colorIndex: f32) -> vec3 {
+  if colorIndex < 0.5 {
+    return vec3(1, 0, 0);
+  }
+  if colorIndex < 1.5 {
+    return vec3(0, 1, 0);
+  }
+  return vec3(0, 0, 1);
+}
+
+fn get_reaction_mask(colorIndex: f32) -> vec3 {
+  if colorIndex < 0.5 {
+    return vec3(
+      settings.color1ToColor1,
+      settings.color1ToColor2,
+      settings.color1ToColor3
+    );
+  }
+  if colorIndex < 1.5 {
+    return vec3(
+      settings.color2ToColor1,
+      settings.color2ToColor2,
+      settings.color2ToColor3
+    );
+  }
+  return vec3(
+    settings.color3ToColor1,
+    settings.color3ToColor2,
+    settings.color3ToColor3
+  );
+}
+
+fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
+  return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
 }
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index 93a0cd3..d228b79 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -1,14 +1,24 @@
 import { vec2 } from 'gl-matrix';
 
+import { appConfig } from '../../config';
 import { clamp } from '../../utils/clamp';
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} 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 shader from './brush.wgsl?raw';
 
+interface LineSegment {
+  from: vec2;
+  to: vec2;
+}
+
 export class BrushPipeline {
-  private static readonly UNIFORM_COUNT = 2;
-  private static readonly MAX_LINE_COUNT = 20;
+  private static readonly UNIFORM_COUNT = 8;
+  private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
   private static readonly VERTICES_PER_LINE_SEGMENT = 6;
   private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
 
@@ -16,10 +26,20 @@ export class BrushPipeline {
   private readonly bindGroup: GPUBindGroup;
   private readonly pipeline: GPURenderPipeline;
   private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    BrushPipeline.UNIFORM_COUNT
+  );
   private readonly vertexBuffer: GPUBuffer;
+  private readonly vertexUploadData = new Float32Array(
+    BrushPipeline.MAX_LINE_COUNT *
+      BrushPipeline.VERTICES_PER_LINE_SEGMENT *
+      BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT
+  );
 
   private linePoints: Array = [];
-  private actualPoints: Array = [];
+  private lineSegments: Array = [];
+  private actualSegments: Array = [];
 
   public constructor(
     private readonly device: GPUDevice,
@@ -72,18 +92,6 @@ export class BrushPipeline {
         targets: [
           {
             format: 'rgba16float',
-            blend: {
-              color: {
-                operation: 'add',
-                srcFactor: 'zero',
-                dstFactor: 'one',
-              },
-              alpha: {
-                operation: 'max',
-                srcFactor: 'one',
-                dstFactor: 'one',
-              },
-            },
           },
         ],
       },
@@ -111,112 +119,188 @@ export class BrushPipeline {
   }
 
   public addSwipe(position: vec2) {
-    this.linePoints.push(position);
+    const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
+    this.addSwipeSegment(previousPosition, position);
+    this.linePoints.push(vec2.clone(position));
+  }
+
+  public addSwipeSegment(from: vec2, to: vec2) {
+    this.lineSegments.push({
+      from: vec2.clone(from),
+      to: vec2.clone(to),
+    });
   }
 
   public clearSwipes() {
     this.linePoints.length = 0;
+    this.lineSegments.length = 0;
+    this.actualSegments.length = 0;
   }
 
-  public setParameters({ brushSize, brushSizeVariation }: BrushSettings) {
-    this.device.queue.writeBuffer(
+  public setParameters({
+    brushSize,
+    brushSizeVariation,
+    selectedColorIndex,
+    isErasing,
+  }: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) {
+    this.uniformValues[0] = brushSize / 2;
+    this.uniformValues[1] = Math.floor((brushSize / 2) * brushSizeVariation);
+    this.uniformValues[2] = 0;
+    this.uniformValues[3] = 0;
+    this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0;
+    this.uniformValues[5] = !isErasing && selectedColorIndex === 1 ? 1 : 0;
+    this.uniformValues[6] = !isErasing && selectedColorIndex === 2 ? 1 : 0;
+    this.uniformValues[7] = isErasing ? 0 : 1;
+    writeFloat32BufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([brushSize / 2, Math.floor((brushSize / 2) * brushSizeVariation)])
+      this.uniformValues,
+      this.uniformCache
     );
 
-    this.actualPoints = this.linePoints.slice();
-    this.linePoints.splice(0, this.linePoints.length - 1);
+    this.actualSegments = this.lineSegments.slice();
+    this.lineSegments.length = 0;
 
-    if (this.actualPoints.length === 0) {
+    if (this.actualSegments.length === 0) {
       return;
     }
 
-    if (this.actualPoints.length === 1) {
-      this.actualPoints.push(this.actualPoints[0]); // allow single point swipes
+    if (this.actualSegments.length > BrushPipeline.MAX_LINE_COUNT) {
+      this.actualSegments = BrushPipeline.subsampleSegments(this.actualSegments);
     }
 
-    if (this.actualPoints.length > BrushPipeline.MAX_LINE_COUNT + 1) {
-      this.actualPoints = BrushPipeline.subsampleLinePoints(this.actualPoints);
+    const lineCount = this.lineCount;
+    let floatOffset = 0;
+    for (let i = 0; i < lineCount; i++) {
+      const segment = this.actualSegments[i];
+      floatOffset = this.writeSegmentVertices(
+        this.vertexUploadData,
+        floatOffset,
+        segment.from,
+        segment.to,
+        brushSize / 2
+      );
     }
 
     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.vertexUploadData,
+      0,
+      floatOffset
     );
   }
 
-  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]),
-      });
+  private static subsampleSegments(segments: Array): Array {
+    if (segments.length <= BrushPipeline.MAX_LINE_COUNT) {
+      return segments;
     }
 
-    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
+    const result: Array = [];
+    for (let i = 0; i < BrushPipeline.MAX_LINE_COUNT; i++) {
+      const index = Math.round(
+        (i * (segments.length - 1)) / (BrushPipeline.MAX_LINE_COUNT - 1)
       );
-
-      result.push(position);
+      result.push(segments[index]);
     }
 
-    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);
+  private writeSegmentVertices(
+    target: Float32Array,
+    offset: number,
+    from: vec2,
+    to: vec2,
+    width: number
+  ): number {
+    const dx = to[0] - from[0];
+    const dy = to[1] - from[1];
+    const length = Math.hypot(dx, dy);
+    const directionX = length > 0 ? dx / length : 1;
+    const directionY = length > 0 ? dy / length : 0;
+    const scaledDirectionX = directionX * width;
+    const scaledDirectionY = directionY * width;
+    const perpendicularX = directionY * width;
+    const perpendicularY = -directionX * width;
 
-    if (vec2.len(dir) === 0) {
-      dir = vec2.fromValues(1, 0); // allow single point swipes
-    }
+    const startX = from[0] - scaledDirectionX;
+    const startY = from[1] - scaledDirectionY;
+    const endX = to[0] + scaledDirectionX;
+    const endY = to[1] + scaledDirectionY;
 
-    const perp = vec2.fromValues(dir[1], -dir[0]);
+    offset = this.writeVertex(
+      target,
+      offset,
+      startX + perpendicularX,
+      startY + perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      startX - perpendicularX,
+      startY - perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      endX + perpendicularX,
+      endY + perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      startX - perpendicularX,
+      startY - perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      endX + perpendicularX,
+      endY + perpendicularY,
+      from,
+      to
+    );
+    return this.writeVertex(
+      target,
+      offset,
+      endX - perpendicularX,
+      endY - perpendicularY,
+      from,
+      to
+    );
+  }
 
-    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),
-    ];
+  private writeVertex(
+    target: Float32Array,
+    offset: number,
+    screenX: number,
+    screenY: number,
+    from: vec2,
+    to: vec2
+  ): number {
+    target[offset++] = screenX;
+    target[offset++] = screenY;
+    target[offset++] = from[0];
+    target[offset++] = from[1];
+    target[offset++] = to[0];
+    target[offset++] = to[1];
+    return offset;
   }
 
   public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
+    if (this.lineCount === 0) {
+      return;
+    }
+
     const renderPassDescriptor: GPURenderPassDescriptor = {
       colorAttachments: [
         {
@@ -256,6 +340,6 @@ export class BrushPipeline {
   }
 
   private get lineCount() {
-    return clamp(this.actualPoints.length - 1, 0, BrushPipeline.MAX_LINE_COUNT);
+    return clamp(this.actualSegments.length, 0, BrushPipeline.MAX_LINE_COUNT);
   }
 }
diff --git a/src/pipelines/brush/brush-settings.ts b/src/pipelines/brush/brush-settings.ts
index cecb7a1..15ef872 100644
--- a/src/pipelines/brush/brush-settings.ts
+++ b/src/pipelines/brush/brush-settings.ts
@@ -1,4 +1,7 @@
 export interface BrushSettings {
   brushSize: number;
+  brushCurveResolution: number;
+  eraserSize: number;
+  mirrorSegmentCount: number;
   brushSizeVariation: number;
 }
diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl
index f705ead..831927f 100644
--- a/src/pipelines/brush/brush.wgsl
+++ b/src/pipelines/brush/brush.wgsl
@@ -1,6 +1,9 @@
 struct Settings {
   brushSize: f32,
-  brushSizeVariation: f32
+  brushSizeVariation: f32,
+  padding0: f32,
+  padding1: f32,
+  brushValue: vec4,
 };
 
 @group(1) @binding(0) var settings: Settings;
@@ -19,7 +22,7 @@ fn vertex(
   @location(2) @interpolate(flat) end: vec2
 ) -> VertexOutput {
   let uv = screenPosition / state.size;
-  let position = uv * 2.0 - 1.0;
+  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, end);
 }
 
@@ -29,20 +32,34 @@ fn fragment(
   @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;
+    let distance = distanceFromLine(screenPosition, start, end);
+    let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
+    let grainNoise = textureSample(
+      noise,
+      noiseSampler,
+      fract(screenPosition / 22.0 + vec2(0.31, 0.67))
+    ).r;
+    let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
+    let feather = max(1.0, settings.brushSize * 0.22);
+    let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
+    let strength = edge * mix(0.45, 1.0, grainNoise);
 
-    if(distance > settings.brushSize) {
+    if(strength < 0.02) {
       discard;
     }
 
-    return vec4(0, 0, 0, 1);
+    return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
 }
 
 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);
+  let denominator = dot(direction, direction);
+
+  if denominator <= 0.0001 {
+    return length(pa);
+  }
+
+  let q = clamp(dot(pa, direction) / denominator, 0, 1);
   return length(pa - direction * q);
 }
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index 1dda653..9000a61 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -1,11 +1,20 @@
 import { vec2 } from 'gl-matrix';
 
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { generateNoise } from '../../utils/graphics/noise';
 
 export class CommonState {
   private static readonly UNIFORM_COUNT = 4;
+  private static readonly NOISE_TEXTURE_SIZE = 2048;
 
   private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    CommonState.UNIFORM_COUNT
+  );
   private readonly noise: GPUTextureView;
   private readonly bindGroup: GPUBindGroup;
 
@@ -31,8 +40,8 @@ export class CommonState {
 
     this.noise = generateNoise({
       device,
-      width: 2048,
-      height: 2048,
+      width: CommonState.NOISE_TEXTURE_SIZE,
+      height: CommonState.NOISE_TEXTURE_SIZE,
     });
 
     this.bindGroupLayout = device.createBindGroupLayout({
@@ -95,10 +104,15 @@ export class CommonState {
     deltaTime: number;
     time: number;
   }) {
-    this.device.queue.writeBuffer(
+    this.uniformValues[0] = canvasSize[0];
+    this.uniformValues[1] = canvasSize[1];
+    this.uniformValues[2] = deltaTime;
+    this.uniformValues[3] = time;
+    writeFloat32BufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([...canvasSize, deltaTime, time])
+      this.uniformValues,
+      this.uniformCache
     );
   }
 
diff --git a/src/pipelines/copy/copy-pipeline.ts b/src/pipelines/copy/copy-pipeline.ts
index b64cedf..248d9b2 100644
--- a/src/pipelines/copy/copy-pipeline.ts
+++ b/src/pipelines/copy/copy-pipeline.ts
@@ -1,19 +1,28 @@
 import { vec2 } from 'gl-matrix';
 
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
 import { smartCompile } from '../../utils/graphics/smart-compile';
 import shader from './copy.wgsl?raw';
 
 export class CopyPipeline {
   private static readonly UNIFORM_COUNT = 2;
+  private static readonly DEFAULT_SCALE = vec2.fromValues(1, 1);
 
   private readonly bindGroupLayout: GPUBindGroupLayout;
   private readonly pipeline: GPURenderPipeline;
   private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(CopyPipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    CopyPipeline.UNIFORM_COUNT
+  );
+  private readonly sampler: GPUSampler;
 
   private readonly vertexBuffer: GPUBuffer;
 
-  private bindGroup?: GPUBindGroup;
-  private previousTrailMapIn?: GPUTextureView;
+  private readonly bindGroupsByInput = new WeakMap();
 
   public constructor(private readonly device: GPUDevice) {
     this.bindGroupLayout = device.createBindGroupLayout(CopyPipeline.bindGroupLayout);
@@ -23,6 +32,11 @@ export class CopyPipeline {
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
 
+    this.sampler = this.device.createSampler({
+      magFilter: 'linear',
+      minFilter: 'linear',
+    });
+
     this.vertexBuffer = device.createBuffer({
       size: 2 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec2
       usage: GPUBufferUsage.VERTEX,
@@ -79,9 +93,16 @@ export class CopyPipeline {
     commandEncoder: GPUCommandEncoder,
     trailMapIn: GPUTextureView,
     trailMapOut: GPUTextureView,
-    scale: vec2 = vec2.fromValues(1, 1)
+    scale: vec2 = CopyPipeline.DEFAULT_SCALE
   ) {
-    this.device.queue.writeBuffer(this.uniforms, 0, new Float32Array(scale));
+    this.uniformValues[0] = scale[0];
+    this.uniformValues[1] = scale[1];
+    writeFloat32BufferIfChanged(
+      this.device,
+      this.uniforms,
+      this.uniformValues,
+      this.uniformCache
+    );
 
     const renderPassDescriptor: GPURenderPassDescriptor = {
       colorAttachments: [
@@ -93,10 +114,10 @@ export class CopyPipeline {
       ],
     };
 
-    this.ensureBindGroupExists(trailMapIn);
+    const bindGroup = this.getBindGroup(trailMapIn);
     const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
     passEncoder.setPipeline(this.pipeline);
-    passEncoder.setBindGroup(0, this.bindGroup);
+    passEncoder.setBindGroup(0, bindGroup);
     passEncoder.setVertexBuffer(0, this.vertexBuffer);
     passEncoder.draw(4, 1);
     passEncoder.end();
@@ -104,35 +125,37 @@ export class CopyPipeline {
 
   public destroy() {
     this.vertexBuffer.destroy();
+    this.uniforms.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 getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
+    const cached = this.bindGroupsByInput.get(trailMapIn);
+    if (cached) {
+      return cached;
     }
+
+    const bindGroup = this.device.createBindGroup({
+      layout: this.bindGroupLayout,
+      entries: [
+        {
+          binding: 0,
+          resource: {
+            buffer: this.uniforms,
+          },
+        },
+        {
+          binding: 1,
+          resource: this.sampler,
+        },
+        {
+          binding: 2,
+          resource: trailMapIn,
+        },
+      ],
+    });
+
+    this.bindGroupsByInput.set(trailMapIn, bindGroup);
+    return bindGroup;
   }
 
   private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
diff --git a/src/pipelines/diffusion/diffusion-pipeline.test.ts b/src/pipelines/diffusion/diffusion-pipeline.test.ts
new file mode 100644
index 0000000..87c4e13
--- /dev/null
+++ b/src/pipelines/diffusion/diffusion-pipeline.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  getSafeInverseDiffusionRate,
+  setDiffusionUniformValues,
+} from './diffusion-pipeline';
+
+describe('diffusion pipeline parameters', () => {
+  it('keeps zero diffusion rates finite before writing shader uniforms', () => {
+    const uniformValues = new Float32Array(4);
+
+    setDiffusionUniformValues(uniformValues, {
+      decayRateBrush: 900,
+      decayRateTrails: 970,
+      diffusionRateBrush: 0,
+      diffusionRateTrails: 0,
+    });
+
+    expect(Number.isFinite(uniformValues[0])).toBe(true);
+    expect(Number.isFinite(uniformValues[2])).toBe(true);
+    expect(uniformValues[0]).toBeGreaterThan(0);
+    expect(uniformValues[2]).toBeGreaterThan(0);
+  });
+
+  it('passes valid diffusion rates through as inverse values', () => {
+    expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
+    expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
+  });
+});
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index 3bb3422..69875c4 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -1,19 +1,56 @@
+import { appConfig } from '../../config';
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} 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 shader from './diffuse.wgsl?raw';
 import { DiffusionSettings } from './diffusion-settings';
 
+const MIN_DIFFUSION_RATE = appConfig.pipelines.diffusion.minDiffusionRate;
+
+type DiffusionUniformSettings = Pick<
+  DiffusionSettings,
+  'diffusionRateTrails' | 'decayRateTrails' | 'diffusionRateBrush' | 'decayRateBrush'
+>;
+
+export const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
+  1 /
+  (Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
+    ? diffusionRate
+    : MIN_DIFFUSION_RATE);
+
+export const setDiffusionUniformValues = (
+  target: Float32Array,
+  {
+    diffusionRateTrails,
+    decayRateTrails,
+    diffusionRateBrush,
+    decayRateBrush,
+  }: DiffusionUniformSettings
+): void => {
+  target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
+  target[1] = decayRateTrails / 1000;
+  target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
+  target[3] = decayRateBrush / 1000;
+};
+
 export class DiffusionPipeline {
   private static readonly UNIFORM_COUNT = 4;
 
   private readonly bindGroupLayout: GPUBindGroupLayout;
   private readonly pipeline: GPURenderPipeline;
   private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    DiffusionPipeline.UNIFORM_COUNT
+  );
+  private readonly sampler: GPUSampler;
   private readonly vertexBuffer: GPUBuffer;
 
-  private bindGroup?: GPUBindGroup;
-  private previousTrailMapIn?: GPUTextureView;
+  private readonly bindGroupsByInput = new WeakMap();
 
   public constructor(
     private readonly device: GPUDevice,
@@ -49,6 +86,11 @@ export class DiffusionPipeline {
       size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
+
+    this.sampler = this.device.createSampler({
+      magFilter: 'linear',
+      minFilter: 'linear',
+    });
   }
 
   public setParameters({
@@ -57,15 +99,17 @@ export class DiffusionPipeline {
     diffusionRateBrush,
     decayRateBrush,
   }: DiffusionSettings) {
-    this.device.queue.writeBuffer(
+    setDiffusionUniformValues(this.uniformValues, {
+      diffusionRateTrails,
+      decayRateTrails,
+      diffusionRateBrush,
+      decayRateBrush,
+    });
+    writeFloat32BufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([
-        1 / diffusionRateTrails,
-        decayRateTrails / 1000,
-        1 / diffusionRateBrush,
-        decayRateBrush / 1000,
-      ])
+      this.uniformValues,
+      this.uniformCache
     );
   }
 
@@ -74,7 +118,7 @@ export class DiffusionPipeline {
     trailMapIn: GPUTextureView,
     trailMapOut: GPUTextureView
   ) {
-    this.ensureBindGroupExists(trailMapIn);
+    const bindGroup = this.getBindGroup(trailMapIn);
 
     const renderPassDescriptor: GPURenderPassDescriptor = {
       colorAttachments: [
@@ -91,38 +135,39 @@ export class DiffusionPipeline {
     passEncoder.setPipeline(this.pipeline);
     passEncoder.setVertexBuffer(0, this.vertexBuffer);
     this.commonState.execute(passEncoder);
-    passEncoder.setBindGroup(1, this.bindGroup);
+    passEncoder.setBindGroup(1, bindGroup);
     passEncoder.draw(4, 1);
     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;
+  private getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
+    const cached = this.bindGroupsByInput.get(trailMapIn);
+    if (cached) {
+      return cached;
     }
+
+    const bindGroup = this.device.createBindGroup({
+      layout: this.bindGroupLayout,
+      entries: [
+        {
+          binding: 0,
+          resource: {
+            buffer: this.uniforms,
+          },
+        },
+        {
+          binding: 1,
+          resource: this.sampler,
+        },
+        {
+          binding: 2,
+          resource: trailMapIn,
+        },
+      ],
+    });
+
+    this.bindGroupsByInput.set(trailMapIn, bindGroup);
+    return bindGroup;
   }
 
   public destroy() {
diff --git a/src/pipelines/diffusion/diffusion-settings.ts b/src/pipelines/diffusion/diffusion-settings.ts
index 909101b..3221bab 100644
--- a/src/pipelines/diffusion/diffusion-settings.ts
+++ b/src/pipelines/diffusion/diffusion-settings.ts
@@ -3,4 +3,5 @@ export interface DiffusionSettings {
   decayRateTrails: number;
   diffusionRateBrush: number;
   decayRateBrush: number;
+  brushEffectDuration: 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..9e2493f
--- /dev/null
+++ b/src/pipelines/eraser/eraser-agent-pipeline.ts
@@ -0,0 +1,244 @@
+import { vec2 } from 'gl-matrix';
+
+import { appConfig } from '../../config';
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
+import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
+import { smartCompile } from '../../utils/graphics/smart-compile';
+import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
+import { CommonState } from '../common-state/common-state';
+import shader from './eraser-agent.wgsl?raw';
+
+interface LineSegment {
+  from: vec2;
+  to: vec2;
+}
+
+const shaderWithConfig = shader.replace(
+  'const MAX_SEGMENT_COUNT = 384u;',
+  `const MAX_SEGMENT_COUNT = ${Math.round(appConfig.pipelines.eraser.maxSegmentCount)}u;`
+);
+
+export class EraserAgentPipeline {
+  private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize;
+  private static readonly UNIFORM_COUNT = 4;
+  private static readonly MAX_SEGMENT_COUNT = appConfig.pipelines.eraser.maxSegmentCount;
+  private static readonly SEGMENT_FLOAT_COUNT =
+    appConfig.pipelines.eraser.segmentFloatCount;
+
+  private readonly bindGroupLayout: GPUBindGroupLayout;
+  private readonly bindGroup: GPUBindGroup;
+  private readonly pipeline: GPUComputePipeline;
+  private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    EraserAgentPipeline.UNIFORM_COUNT
+  );
+  private readonly segmentsBuffer: GPUBuffer;
+  private readonly segmentUploadData = new Float32Array(
+    EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
+  );
+
+  private linePoints: Array = [];
+  private lineSegments: Array = [];
+  private actualSegments: Array = [];
+  private segmentCount = 0;
+  private agentCount = 0;
+
+  public constructor(
+    private readonly device: GPUDevice,
+    private readonly commonState: CommonState,
+    private readonly agentsBuffer: GPUBuffer
+  ) {
+    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,
+          buffer: {
+            type: 'read-only-storage',
+          },
+        },
+      ],
+    });
+
+    this.uniforms = this.device.createBuffer({
+      size: EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+    });
+
+    this.segmentsBuffer = this.device.createBuffer({
+      size:
+        EraserAgentPipeline.MAX_SEGMENT_COUNT *
+        EraserAgentPipeline.SEGMENT_FLOAT_COUNT *
+        Float32Array.BYTES_PER_ELEMENT,
+      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+    });
+
+    this.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.segmentsBuffer,
+          },
+        },
+      ],
+    });
+
+    this.pipeline = device.createComputePipeline({
+      layout: device.createPipelineLayout({
+        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+      }),
+      compute: {
+        module: smartCompile(
+          device,
+          CommonState.shaderCode,
+          agentSchema,
+          shaderWithConfig
+        ),
+        entryPoint: 'main',
+      },
+    });
+  }
+
+  public addSwipe(position: vec2): void {
+    const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
+    this.addSwipeSegment(previousPosition, position);
+    this.linePoints.push(vec2.clone(position));
+  }
+
+  public addSwipeSegment(from: vec2, to: vec2): void {
+    this.lineSegments.push({
+      from: vec2.clone(from),
+      to: vec2.clone(to),
+    });
+  }
+
+  public clearSwipes(): void {
+    this.linePoints.length = 0;
+    this.lineSegments.length = 0;
+    this.actualSegments.length = 0;
+    this.segmentCount = 0;
+  }
+
+  public setParameters({
+    agentCount,
+    eraserSize,
+  }: {
+    agentCount: number;
+    eraserSize: number;
+  }): void {
+    this.agentCount = agentCount;
+    this.actualSegments = this.lineSegments.slice();
+    this.lineSegments.length = 0;
+
+    if (this.actualSegments.length > EraserAgentPipeline.MAX_SEGMENT_COUNT) {
+      this.actualSegments = EraserAgentPipeline.subsampleSegments(this.actualSegments);
+    }
+
+    this.segmentCount = Math.max(0, this.actualSegments.length);
+
+    const eraserRadius = eraserSize / 2;
+    this.uniformValues[0] = eraserRadius;
+    this.uniformValues[1] = this.segmentCount;
+    this.uniformValues[2] = agentCount;
+    this.uniformValues[3] = eraserRadius * eraserRadius;
+    writeFloat32BufferIfChanged(
+      this.device,
+      this.uniforms,
+      this.uniformValues,
+      this.uniformCache
+    );
+
+    if (this.segmentCount === 0) {
+      return;
+    }
+
+    for (let i = 0; i < this.segmentCount; i++) {
+      const { from, to } = this.actualSegments[i];
+      const offset = i * EraserAgentPipeline.SEGMENT_FLOAT_COUNT;
+      this.segmentUploadData[offset] = from[0];
+      this.segmentUploadData[offset + 1] = from[1];
+      this.segmentUploadData[offset + 2] = to[0];
+      this.segmentUploadData[offset + 3] = to[1];
+    }
+
+    this.device.queue.writeBuffer(
+      this.segmentsBuffer,
+      0,
+      this.segmentUploadData,
+      0,
+      this.segmentCount * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
+    );
+  }
+
+  public execute(commandEncoder: GPUCommandEncoder): void {
+    if (this.segmentCount === 0 || this.agentCount === 0) {
+      return;
+    }
+
+    const passEncoder = commandEncoder.beginComputePass();
+    passEncoder.setPipeline(this.pipeline);
+    this.commonState.execute(passEncoder);
+    passEncoder.setBindGroup(1, this.bindGroup);
+    passEncoder.dispatchWorkgroups(
+      ...getWorkgroupCounts(
+        this.device,
+        this.agentCount,
+        EraserAgentPipeline.WORKGROUP_SIZE
+      )
+    );
+    passEncoder.end();
+  }
+
+  public destroy(): void {
+    this.uniforms.destroy();
+    this.segmentsBuffer.destroy();
+  }
+
+  private static subsampleSegments(segments: Array): Array {
+    if (segments.length <= EraserAgentPipeline.MAX_SEGMENT_COUNT) {
+      return segments;
+    }
+
+    const result: Array = [];
+    for (let i = 0; i < EraserAgentPipeline.MAX_SEGMENT_COUNT; i++) {
+      const index = Math.round(
+        (i * (segments.length - 1)) / (EraserAgentPipeline.MAX_SEGMENT_COUNT - 1)
+      );
+      result.push(segments[index]);
+    }
+
+    return result;
+  }
+}
diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl
new file mode 100644
index 0000000..12048be
--- /dev/null
+++ b/src/pipelines/eraser/eraser-agent.wgsl
@@ -0,0 +1,63 @@
+struct Settings {
+  eraserRadius: f32,
+  segmentCount: f32,
+  agentCount: f32,
+  eraserRadiusSquared: f32,
+};
+
+const MAX_SEGMENT_COUNT = 384u;
+
+@group(1) @binding(0) var settings: Settings;
+@group(1) @binding(2) var segments: array>;
+
+@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 >= u32(settings.agentCount) {
+    return;
+  }
+
+  var agent = agents[id];
+  if agent.colorIndex < 0.0 {
+    return;
+  }
+
+  for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) {
+    if i >= u32(settings.segmentCount) {
+      break;
+    }
+
+    let segment = segments[i];
+    let distanceSquared = distanceSquaredFromLine(
+      agent.position,
+      segment.xy,
+      segment.zw
+    );
+
+    if distanceSquared <= settings.eraserRadiusSquared {
+      agent.position = vec2(-1.0, -1.0);
+      agent.targetPosition = vec2(-1.0, -1.0);
+      agent.colorIndex = -1.0;
+      agents[id] = agent;
+      return;
+    }
+  }
+}
+
+fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
+  let pa = position - start;
+  let direction = end - start;
+  let denominator = dot(direction, direction);
+
+  if denominator <= 0.0001 {
+    return dot(pa, pa);
+  }
+
+  let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
+  let nearestOffset = pa - direction * q;
+  return dot(nearestOffset, nearestOffset);
+}
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
new file mode 100644
index 0000000..c2db414
--- /dev/null
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -0,0 +1,333 @@
+import { vec2 } from 'gl-matrix';
+
+import { appConfig } from '../../config';
+import { clamp } from '../../utils/clamp';
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
+import { smartCompile } from '../../utils/graphics/smart-compile';
+import { CommonState } from '../common-state/common-state';
+import shader from './eraser-texture.wgsl?raw';
+
+interface LineSegment {
+  from: vec2;
+  to: vec2;
+}
+
+export class EraserTexturePipeline {
+  private static readonly UNIFORM_COUNT = 4;
+  private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount;
+  private static readonly VERTICES_PER_LINE_SEGMENT = 6;
+  private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
+
+  private readonly bindGroupLayout: GPUBindGroupLayout;
+  private readonly bindGroup: GPUBindGroup;
+  private readonly pipeline: GPURenderPipeline;
+  private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    EraserTexturePipeline.UNIFORM_COUNT
+  );
+  private readonly vertexBuffer: GPUBuffer;
+  private readonly vertexUploadData = new Float32Array(
+    EraserTexturePipeline.MAX_LINE_COUNT *
+      EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
+      EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT
+  );
+
+  private linePoints: Array = [];
+  private lineSegments: Array = [];
+  private actualSegments: Array = [];
+
+  public constructor(
+    private readonly device: GPUDevice,
+    private readonly commonState: CommonState
+  ) {
+    this.bindGroupLayout = device.createBindGroupLayout({
+      entries: [
+        {
+          binding: 0,
+          visibility: GPUShaderStage.FRAGMENT,
+          buffer: {
+            type: 'uniform',
+          },
+        },
+      ],
+    });
+
+    this.vertexBuffer = device.createBuffer({
+      size:
+        EraserTexturePipeline.MAX_LINE_COUNT *
+        EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
+        EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT *
+        Float32Array.BYTES_PER_ELEMENT,
+      usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
+    });
+
+    this.pipeline = device.createRenderPipeline({
+      layout: device.createPipelineLayout({
+        bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+      }),
+      vertex: {
+        module: smartCompile(device, CommonState.shaderCode, shader),
+        entryPoint: 'vertex',
+        buffers: [
+          {
+            arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
+            attributes: [
+              {
+                shaderLocation: 0,
+                format: 'float32x2',
+                offset: 0,
+              },
+              {
+                shaderLocation: 1,
+                format: 'float32x2',
+                offset: Float32Array.BYTES_PER_ELEMENT * 2,
+              },
+              {
+                shaderLocation: 2,
+                format: 'float32x2',
+                offset: Float32Array.BYTES_PER_ELEMENT * 4,
+              },
+            ],
+          },
+        ],
+      },
+      fragment: {
+        module: smartCompile(device, CommonState.shaderCode, shader),
+        entryPoint: 'fragment',
+        targets: [
+          {
+            format: 'rgba16float',
+          },
+        ],
+      },
+      primitive: {
+        topology: 'triangle-list',
+      },
+    });
+
+    this.uniforms = this.device.createBuffer({
+      size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+    });
+
+    this.bindGroup = this.device.createBindGroup({
+      layout: this.bindGroupLayout,
+      entries: [
+        {
+          binding: 0,
+          resource: {
+            buffer: this.uniforms,
+          },
+        },
+      ],
+    });
+  }
+
+  public addSwipe(position: vec2): void {
+    const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
+    this.addSwipeSegment(previousPosition, position);
+    this.linePoints.push(vec2.clone(position));
+  }
+
+  public addSwipeSegment(from: vec2, to: vec2): void {
+    this.lineSegments.push({
+      from: vec2.clone(from),
+      to: vec2.clone(to),
+    });
+  }
+
+  public clearSwipes(): void {
+    this.linePoints.length = 0;
+    this.lineSegments.length = 0;
+    this.actualSegments.length = 0;
+  }
+
+  public setParameters({ eraserSize }: { eraserSize: number }): void {
+    const eraserRadius = eraserSize / 2;
+
+    this.uniformValues[0] = eraserRadius;
+    this.uniformValues[1] = eraserRadius * eraserRadius;
+    this.uniformValues[2] = 0;
+    this.uniformValues[3] = 0;
+    writeFloat32BufferIfChanged(
+      this.device,
+      this.uniforms,
+      this.uniformValues,
+      this.uniformCache
+    );
+
+    this.actualSegments = this.lineSegments.slice();
+    this.lineSegments.length = 0;
+
+    if (this.actualSegments.length === 0) {
+      return;
+    }
+
+    if (this.actualSegments.length > EraserTexturePipeline.MAX_LINE_COUNT) {
+      this.actualSegments = EraserTexturePipeline.subsampleSegments(this.actualSegments);
+    }
+
+    const lineCount = this.lineCount;
+    let floatOffset = 0;
+    for (let i = 0; i < lineCount; i++) {
+      const segment = this.actualSegments[i];
+      floatOffset = this.writeSegmentVertices(
+        this.vertexUploadData,
+        floatOffset,
+        segment.from,
+        segment.to,
+        eraserRadius
+      );
+    }
+
+    this.device.queue.writeBuffer(
+      this.vertexBuffer,
+      0,
+      this.vertexUploadData,
+      0,
+      floatOffset
+    );
+  }
+
+  public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
+    if (this.lineCount === 0) {
+      return;
+    }
+
+    const renderPassDescriptor: GPURenderPassDescriptor = {
+      colorAttachments: [
+        {
+          view: textureOut,
+          loadOp: 'load',
+          storeOp: 'store',
+        },
+      ],
+    };
+
+    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
+    passEncoder.setPipeline(this.pipeline);
+    this.commonState.execute(passEncoder);
+    passEncoder.setBindGroup(1, this.bindGroup);
+    passEncoder.setVertexBuffer(0, this.vertexBuffer);
+    passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
+    passEncoder.end();
+  }
+
+  public destroy(): void {
+    this.vertexBuffer.destroy();
+    this.uniforms.destroy();
+  }
+
+  private static subsampleSegments(segments: Array): Array {
+    if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
+      return segments;
+    }
+
+    const result: Array = [];
+    for (let i = 0; i < EraserTexturePipeline.MAX_LINE_COUNT; i++) {
+      const index = Math.round(
+        (i * (segments.length - 1)) / (EraserTexturePipeline.MAX_LINE_COUNT - 1)
+      );
+      result.push(segments[index]);
+    }
+
+    return result;
+  }
+
+  private writeSegmentVertices(
+    target: Float32Array,
+    offset: number,
+    from: vec2,
+    to: vec2,
+    width: number
+  ): number {
+    const dx = to[0] - from[0];
+    const dy = to[1] - from[1];
+    const length = Math.hypot(dx, dy);
+    const directionX = length > 0 ? dx / length : 1;
+    const directionY = length > 0 ? dy / length : 0;
+    const scaledDirectionX = directionX * width;
+    const scaledDirectionY = directionY * width;
+    const perpendicularX = directionY * width;
+    const perpendicularY = -directionX * width;
+
+    const startX = from[0] - scaledDirectionX;
+    const startY = from[1] - scaledDirectionY;
+    const endX = to[0] + scaledDirectionX;
+    const endY = to[1] + scaledDirectionY;
+
+    offset = this.writeVertex(
+      target,
+      offset,
+      startX + perpendicularX,
+      startY + perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      startX - perpendicularX,
+      startY - perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      endX + perpendicularX,
+      endY + perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      startX - perpendicularX,
+      startY - perpendicularY,
+      from,
+      to
+    );
+    offset = this.writeVertex(
+      target,
+      offset,
+      endX + perpendicularX,
+      endY + perpendicularY,
+      from,
+      to
+    );
+    return this.writeVertex(
+      target,
+      offset,
+      endX - perpendicularX,
+      endY - perpendicularY,
+      from,
+      to
+    );
+  }
+
+  private writeVertex(
+    target: Float32Array,
+    offset: number,
+    screenX: number,
+    screenY: number,
+    from: vec2,
+    to: vec2
+  ): number {
+    target[offset++] = screenX;
+    target[offset++] = screenY;
+    target[offset++] = from[0];
+    target[offset++] = from[1];
+    target[offset++] = to[0];
+    target[offset++] = to[1];
+    return offset;
+  }
+
+  private get lineCount(): number {
+    return clamp(this.actualSegments.length, 0, EraserTexturePipeline.MAX_LINE_COUNT);
+  }
+}
diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl
new file mode 100644
index 0000000..c1bfe28
--- /dev/null
+++ b/src/pipelines/eraser/eraser-texture.wgsl
@@ -0,0 +1,53 @@
+struct Settings {
+  eraserRadius: f32,
+  eraserRadiusSquared: f32,
+  padding1: f32,
+  padding2: f32,
+};
+
+@group(1) @binding(0) var settings: Settings;
+
+struct VertexOutput {
+  @builtin(position) position: vec4,
+  @location(0) screenPosition: vec2,
+  @location(1) start: vec2,
+  @location(2) end: vec2
+}
+
+@vertex
+fn vertex(
+  @location(0) screenPosition: vec2,
+  @location(1) @interpolate(flat) start: vec2,
+  @location(2) @interpolate(flat) end: vec2
+) -> VertexOutput {
+  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, end);
+}
+
+@fragment
+fn fragment(
+  @location(0) screenPosition: vec2,
+  @location(1) start: vec2,
+  @location(2) end: vec2
+) -> @location(0) vec4 {
+  if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared {
+    discard;
+  }
+
+  return vec4(0.0, 0.0, 0.0, 0.0);
+}
+
+fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
+  let pa = position - start;
+  let direction = end - start;
+  let denominator = dot(direction, direction);
+
+  if denominator <= 0.0001 {
+    return dot(pa, pa);
+  }
+
+  let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
+  let nearestOffset = pa - direction * q;
+  return dot(nearestOffset, nearestOffset);
+}
diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts
index 73ac288..21a4de4 100644
--- a/src/pipelines/render/render-pipeline.ts
+++ b/src/pipelines/render/render-pipeline.ts
@@ -1,5 +1,7 @@
-import { vec3 } from 'gl-matrix';
-
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} 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';
@@ -7,15 +9,23 @@ import { RenderSettings } from './render-settings';
 import shader from './render.wgsl?raw';
 
 export class RenderPipeline {
-  private static readonly UNIFORM_COUNT = 13;
+  private static readonly UNIFORM_COUNT = 20;
 
   private readonly bindGroupLayout: GPUBindGroupLayout;
-  private readonly pipeline: GPURenderPipeline;
+  private readonly canvasPipeline: GPURenderPipeline;
+  private readonly exportPipeline: GPURenderPipeline;
+  private readonly sampler: GPUSampler;
   private readonly uniforms: GPUBuffer;
+  private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
+  private readonly uniformCache = createCachedFloat32BufferWrite(
+    RenderPipeline.UNIFORM_COUNT
+  );
   private readonly vertexBuffer: GPUBuffer;
 
-  private bindGroup?: GPUBindGroup;
-  private previousColorTexture?: GPUTextureView;
+  private readonly bindGroupsByTexture = new WeakMap<
+    GPUTextureView,
+    WeakMap
+  >();
 
   public constructor(
     private readonly context: GPUCanvasContext,
@@ -27,104 +37,179 @@ export class RenderPipeline {
     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.sampler = device.createSampler({
+      magFilter: 'linear',
+      minFilter: 'linear',
     });
 
+    const format = navigator.gpu.getPreferredCanvasFormat();
+    this.canvasPipeline = this.createPipeline(format, vertex);
+    this.exportPipeline = this.createPipeline(format, vertex);
+
     this.uniforms = this.device.createBuffer({
       size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
       usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
     });
   }
 
+  private createPipeline(
+    format: GPUTextureFormat,
+    vertex: GPUVertexState
+  ): GPURenderPipeline {
+    return this.device.createRenderPipeline({
+      layout: this.device.createPipelineLayout({
+        bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
+      }),
+      vertex,
+      fragment: {
+        module: smartCompile(this.device, CommonState.shaderCode, shader),
+        entryPoint: 'fragment',
+        targets: [
+          {
+            format,
+          },
+        ],
+      },
+      primitive: {
+        topology: 'triangle-strip',
+      },
+    });
+  }
+
   public setParameters({
-    brushColor,
-    evenGenerationColor,
-    oddGenerationColor,
+    channelColors,
+    backgroundColor,
+    cameraCenter,
+    cameraZoom,
     clarity,
   }: RenderSettings & {
-    brushColor: vec3;
-    evenGenerationColor: vec3;
-    oddGenerationColor: vec3;
+    channelColors: Array<[number, number, number]>;
+    backgroundColor: [number, number, number];
+    cameraCenter: [number, number];
+    cameraZoom: number;
   }) {
-    this.device.queue.writeBuffer(
+    const [a, b, c] = channelColors;
+    this.uniformValues[0] = a[0];
+    this.uniformValues[1] = a[1];
+    this.uniformValues[2] = a[2];
+    this.uniformValues[3] = 0;
+    this.uniformValues[4] = b[0];
+    this.uniformValues[5] = b[1];
+    this.uniformValues[6] = b[2];
+    this.uniformValues[7] = 0;
+    this.uniformValues[8] = c[0];
+    this.uniformValues[9] = c[1];
+    this.uniformValues[10] = c[2];
+    this.uniformValues[11] = 0;
+    this.uniformValues[12] = backgroundColor[0];
+    this.uniformValues[13] = backgroundColor[1];
+    this.uniformValues[14] = backgroundColor[2];
+    this.uniformValues[15] = clarity;
+    this.uniformValues[16] = cameraCenter[0];
+    this.uniformValues[17] = cameraCenter[1];
+    this.uniformValues[18] = cameraZoom;
+    this.uniformValues[19] = 0;
+    writeFloat32BufferIfChanged(
+      this.device,
       this.uniforms,
-      0,
-      new Float32Array([
-        ...brushColor,
-        0, //padding
-        ...evenGenerationColor,
-        0, //padding
-        ...oddGenerationColor,
-        clarity,
-      ])
+      this.uniformValues,
+      this.uniformCache
     );
   }
 
-  public execute(commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView) {
-    this.ensureBindGroupExists(colorTexture);
+  public execute(
+    commandEncoder: GPUCommandEncoder,
+    colorTexture: GPUTextureView,
+    sourceTexture: GPUTextureView
+  ) {
+    const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
 
     const renderPassDescriptor: GPURenderPassDescriptor = {
       colorAttachments: [
         {
           view: this.context.getCurrentTexture().createView(),
-          clearValue: { r: 0, g: 1, b: 1, a: 1 },
+          clearValue: { r: 0, g: 0, b: 0, a: 1 },
           loadOp: 'clear',
           storeOp: 'store',
         },
       ],
     };
     const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
-    passEncoder.setPipeline(this.pipeline);
+    passEncoder.setPipeline(this.canvasPipeline);
     this.commonState.execute(passEncoder);
     passEncoder.setVertexBuffer(0, this.vertexBuffer);
-    passEncoder.setBindGroup(1, this.bindGroup);
+    passEncoder.setBindGroup(1, 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,
-          },
-        ],
-      });
+  public executeToView(
+    commandEncoder: GPUCommandEncoder,
+    colorTexture: GPUTextureView,
+    sourceTexture: GPUTextureView,
+    outputTexture: GPUTextureView
+  ) {
+    const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
 
-      this.previousColorTexture = colorTexture;
+    const passEncoder = commandEncoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: outputTexture,
+          clearValue: { r: 0, g: 0, b: 0, a: 1 },
+          loadOp: 'clear',
+          storeOp: 'store',
+        },
+      ],
+    });
+    passEncoder.setPipeline(this.exportPipeline);
+    this.commonState.execute(passEncoder);
+    passEncoder.setVertexBuffer(0, this.vertexBuffer);
+    passEncoder.setBindGroup(1, bindGroup);
+    passEncoder.draw(4, 1);
+    passEncoder.end();
+  }
+
+  private getBindGroup(
+    colorTexture: GPUTextureView,
+    sourceTexture: GPUTextureView
+  ): GPUBindGroup {
+    let sourceTextureCache = this.bindGroupsByTexture.get(colorTexture);
+    if (!sourceTextureCache) {
+      sourceTextureCache = new WeakMap();
+      this.bindGroupsByTexture.set(colorTexture, sourceTextureCache);
     }
+
+    const cached = sourceTextureCache.get(sourceTexture);
+    if (cached) {
+      return cached;
+    }
+
+    const bindGroup = this.device.createBindGroup({
+      layout: this.bindGroupLayout,
+      entries: [
+        {
+          binding: 0,
+          resource: {
+            buffer: this.uniforms,
+          },
+        },
+        {
+          binding: 1,
+          resource: this.sampler,
+        },
+        {
+          binding: 2,
+          resource: colorTexture,
+        },
+        {
+          binding: 3,
+          resource: sourceTexture,
+        },
+      ],
+    });
+
+    sourceTextureCache.set(sourceTexture, bindGroup);
+    return bindGroup;
   }
 
   public destroy() {
@@ -156,6 +241,13 @@ export class RenderPipeline {
             sampleType: 'float',
           },
         },
+        {
+          binding: 3,
+          visibility: GPUShaderStage.FRAGMENT,
+          texture: {
+            sampleType: 'float',
+          },
+        },
       ],
     };
   }
diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl
index 8607d7c..5864693 100644
--- a/src/pipelines/render/render.wgsl
+++ b/src/pipelines/render/render.wgsl
@@ -1,39 +1,56 @@
 struct Settings {
-  brushColor: vec3,
-  evenGenerationColor: vec3,
-  oddGenerationColor: vec3,
+  colorA: vec3,
+  backgroundColorPadding0: f32,
+  colorB: vec3,
+  backgroundColorPadding1: f32,
+  colorC: vec3,
+  backgroundColorPadding2: f32,
+  backgroundColor: vec3,
   clarity: f32,
+  cameraCenter: vec2,
+  cameraZoom: f32,
+  padding0: f32,
 };
 
 @group(1) @binding(0) var settings: Settings;
 @group(1) @binding(1) var Sampler: sampler;
 @group(1) @binding(2) var trailMap: texture_2d;
+@group(1) @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);
+  let cameraUv = settings.cameraCenter / state.size;
+  let viewUv = (uv - vec2(0.5)) / settings.cameraZoom + cameraUv;
+  let traces = textureSample(trailMap, Sampler, viewUv);
+  let sources = textureSample(sourceMap, Sampler, viewUv);
 
-  let backgroundColor = vec3(0.9) + 0.075 * random.r;
-
-  let evenGenerationStrength = clarity(traces.r);
-  let oddGenerationStrength = clarity(traces.g);
-  let brushStrength = traces.a;
-
-  let color = max(
-    mix(
-      evenGenerationStrength * settings.evenGenerationColor,
-      oddGenerationStrength * settings.oddGenerationColor,
-      oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
-    ),
-    brushStrength * settings.brushColor
+  let traceStrengths = vec3(
+    clarity(traces.r),
+    clarity(traces.g),
+    clarity(traces.b)
   );
+  let sourceStrengths = vec3(
+    clarity(sources.r),
+    clarity(sources.g),
+    clarity(sources.b)
+  );
+  let strengths = max(traceStrengths, sourceStrengths);
+  let traceColor =
+      strengths.r * settings.colorA
+    + strengths.g * settings.colorB
+    + strengths.b * settings.colorC;
+  let brushColor =
+      sourceStrengths.r * settings.colorA
+    + sourceStrengths.g * settings.colorB
+    + sourceStrengths.b * settings.colorC;
+  let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1);
+  let color = max(traceColor, brushColor * (1.2 + brushStrength * 1.6));
 
-  let strength = max(evenGenerationStrength, max(oddGenerationStrength, brushStrength));
+  let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
 
-  return vec4(mix(backgroundColor, color, strength), 1);
+  return vec4(mix(settings.backgroundColor, clamp(color, vec3(0), vec3(1)), strength), 1);
 }
 
 fn clarity(strength: f32) -> f32 {
-  return pow(strength, settings.clarity);
+  return pow(clamp(strength, 0, 1), settings.clarity);
 }
diff --git a/src/pipelines/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts
new file mode 100644
index 0000000..e611f17
--- /dev/null
+++ b/src/pipelines/wgsl-uniform-layout.test.ts
@@ -0,0 +1,202 @@
+import { describe, expect, it } from 'vitest';
+
+import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw';
+import countingShader from './agents/agent-generation/agent-counting.wgsl?raw';
+import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline';
+import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw';
+import { AgentPipeline } from './agents/agent-pipeline';
+import agentShader from './agents/agent.wgsl?raw';
+import { BrushPipeline } from './brush/brush-pipeline';
+import brushShader from './brush/brush.wgsl?raw';
+import { CommonState } from './common-state/common-state';
+import { CopyPipeline } from './copy/copy-pipeline';
+import copyShader from './copy/copy.wgsl?raw';
+import diffusionShader from './diffusion/diffuse.wgsl?raw';
+import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
+import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
+import eraserAgentShader from './eraser/eraser-agent.wgsl?raw';
+import { EraserTexturePipeline } from './eraser/eraser-texture-pipeline';
+import eraserTextureShader from './eraser/eraser-texture.wgsl?raw';
+import { RenderPipeline } from './render/render-pipeline';
+import renderShader from './render/render.wgsl?raw';
+
+const wgslFloatCountsByType: Record = {
+  f32: 1,
+  u32: 1,
+  'vec2': 2,
+  'vec3': 3,
+  'vec4': 4,
+};
+
+const stripComments = (source: string): string =>
+  source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
+
+const getStructFields = (source: string, structName: string) => {
+  const match = new RegExp(
+    `struct ${structName}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`
+  ).exec(stripComments(source));
+  if (!match?.groups?.body) {
+    throw new Error(`${structName} struct was not found`);
+  }
+
+  return match.groups.body
+    .split('\n')
+    .map((line) => line.trim().replace(/,$/, ''))
+    .filter(Boolean)
+    .map((line) => {
+      const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line);
+      if (!fieldMatch?.groups) {
+        throw new Error(`Unsupported WGSL struct field syntax: ${line}`);
+      }
+
+      return {
+        name: fieldMatch.groups.name,
+        type: fieldMatch.groups.type,
+      };
+    });
+};
+
+const countUniformScalars = (source: string, structName: string): number =>
+  getStructFields(source, structName).reduce((sum, field) => {
+    const count = wgslFloatCountsByType[field.type];
+    if (!count) {
+      throw new Error(`Unsupported WGSL uniform field type: ${field.type}`);
+    }
+
+    return sum + count;
+  }, 0);
+
+const getUniformCount = (pipeline: unknown): number =>
+  (pipeline as { UNIFORM_COUNT: number }).UNIFORM_COUNT;
+
+const expectStructUniformLayout = ({
+  pipeline,
+  source,
+  structName,
+  fieldNames,
+}: {
+  pipeline: unknown;
+  source: string;
+  structName: string;
+  fieldNames: Array;
+}) => {
+  const fields = getStructFields(source, structName);
+
+  expect(fields.map((field) => field.name)).toEqual(fieldNames);
+  expect(countUniformScalars(source, structName)).toBe(getUniformCount(pipeline));
+};
+
+describe('WGSL uniform layout contracts', () => {
+  it('keeps shared common-state uniforms aligned with WGSL', () => {
+    expectStructUniformLayout({
+      pipeline: CommonState,
+      source: CommonState.shaderCode,
+      structName: 'State',
+      fieldNames: ['size', 'deltaTime', 'time'],
+    });
+  });
+
+  it('keeps render and simulation uniforms aligned with WGSL', () => {
+    expectStructUniformLayout({
+      pipeline: AgentPipeline,
+      source: agentShader,
+      structName: 'Settings',
+      fieldNames: [
+        'moveRate',
+        'turnRate',
+        'sensorAngle',
+        'sensorOffset',
+        'turnWhenLost',
+        'individualTrailWeight',
+        'agentCount',
+        'introProgress',
+        'color1ToColor1',
+        'color1ToColor2',
+        'color1ToColor3',
+        'color2ToColor1',
+        'color2ToColor2',
+        'color2ToColor3',
+        'color3ToColor1',
+        'color3ToColor2',
+        'color3ToColor3',
+      ],
+    });
+    expectStructUniformLayout({
+      pipeline: BrushPipeline,
+      source: brushShader,
+      structName: 'Settings',
+      fieldNames: [
+        'brushSize',
+        'brushSizeVariation',
+        'padding0',
+        'padding1',
+        'brushValue',
+      ],
+    });
+    expectStructUniformLayout({
+      pipeline: DiffusionPipeline,
+      source: diffusionShader,
+      structName: 'Settings',
+      fieldNames: [
+        'inverseDiffusionRateTrails',
+        'decayRateTrails',
+        'inverseDiffusionRateBrush',
+        'decayRateBrush',
+      ],
+    });
+    expectStructUniformLayout({
+      pipeline: RenderPipeline,
+      source: renderShader,
+      structName: 'Settings',
+      fieldNames: [
+        'colorA',
+        'backgroundColorPadding0',
+        'colorB',
+        'backgroundColorPadding1',
+        'colorC',
+        'backgroundColorPadding2',
+        'backgroundColor',
+        'clarity',
+        'cameraCenter',
+        'cameraZoom',
+        'padding0',
+      ],
+    });
+  });
+
+  it('keeps eraser uniforms aligned with WGSL', () => {
+    expectStructUniformLayout({
+      pipeline: EraserAgentPipeline,
+      source: eraserAgentShader,
+      structName: 'Settings',
+      fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'],
+    });
+    expectStructUniformLayout({
+      pipeline: EraserTexturePipeline,
+      source: eraserTextureShader,
+      structName: 'Settings',
+      fieldNames: ['eraserRadius', 'eraserRadiusSquared', 'padding1', 'padding2'],
+    });
+  });
+
+  it('keeps copy uniforms aligned with WGSL', () => {
+    const match = /var\s+sourceScaler:\s*(?[^;]+);/.exec(copyShader);
+
+    expect(match?.groups?.type).toBe('vec2');
+    expect(wgslFloatCountsByType[match?.groups?.type ?? '']).toBe(
+      getUniformCount(CopyPipeline)
+    );
+  });
+
+  it('keeps agent-generation uniforms large enough for every generation shader', () => {
+    const generationUniformCounts = [
+      countUniformScalars(countingShader, 'Settings'),
+      countUniformScalars(resizeShader, 'ResizeSettings'),
+      countUniformScalars(compactionShader, 'Settings'),
+    ];
+
+    expect(Math.max(...generationUniformCounts)).toBe(
+      getUniformCount(AgentGenerationPipeline)
+    );
+  });
+});
diff --git a/src/settings.ts b/src/settings.ts
index 4715928..b043fb1 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,54 +1,39 @@
-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, type GardenRuntimeSettings } from './config';
+import { writeBrowserStorage } from './utils/browser-storage';
+import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes';
 
-const initialValues: GameLoopSettings &
-  AgentSettings &
-  BrushSettings &
-  DiffusionSettings &
-  RenderSettings = {
-  agentCount: 1_001_500,
+const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({
+  ...appConfig.runtimeSettings.defaults,
+  ...vibe.settings,
+});
 
-  currentGenerationAggression: -5,
-  nextGenerationAggression: 0.2,
+export let activeVibe = getInitialVibe();
 
-  moveSpeed: 74,
-  turnSpeed: 45,
-  sensorOffsetAngle: 31,
-  sensorOffsetDistance: 43,
-  turnWhenLost: 0.01,
-
-  brushTrailWeight: 500,
-  individualTrailWeight: 0.05,
-
-  diffusionRateTrails: 0,
-  decayRateTrails: 944,
-  diffusionRateBrush: 0.35,
-  decayRateBrush: 18,
-
-  clarity: 0.7,
-  brushSize: 12,
-
-  brushSizeVariation: 0.5, // hidden on the UI
-
-  startColorHue: 200,
-
-  maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart
-
-  // debug options
-  renderSpeed: 1,
-  simulatedDelayMs: 0,
+export const settings: { [key: string]: number } & GardenRuntimeSettings = {
+  ...buildInitialValues(activeVibe),
 };
 
-export const settings: { [key: string]: number } & GameLoopSettings &
-  AgentSettings &
-  BrushSettings &
-  DiffusionSettings &
-  RenderSettings = persist({ ...initialValues });
-
 export const resetSettings = () => {
-  Object.assign(settings, initialValues);
+  Object.assign(settings, buildInitialValues(activeVibe));
+};
+
+export const applyVibeSettings = (vibeId: string) => {
+  const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
+  if (!vibe) {
+    return activeVibe;
+  }
+
+  activeVibe = vibe;
+  Object.assign(settings, {
+    ...buildInitialValues(vibe),
+    agentCount: settings.agentCount,
+    brushEffectDuration: settings.brushEffectDuration,
+    eraserSize: settings.eraserSize,
+    mirrorSegmentCount: settings.mirrorSegmentCount,
+    selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
+  });
+
+  writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
+
+  return activeVibe;
 };
diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss
new file mode 100644
index 0000000..86d78b9
--- /dev/null
+++ b/src/style/_app-shell.scss
@@ -0,0 +1,91 @@
+html > body {
+  width: 100%;
+  min-height: 100vh;
+  min-height: 100dvh;
+  height: 100vh;
+  height: 100dvh;
+  overflow: hidden;
+  display: flex;
+  position: relative;
+  background: var(--garden-background, #10151f);
+
+  > .canvas-container {
+    min-height: 100vh;
+    min-height: 100dvh;
+    height: 100%;
+    width: 100%;
+    display: flex;
+    position: relative;
+    overflow: hidden;
+
+    > canvas {
+      height: 100%;
+      width: 100%;
+      touch-action: none;
+      cursor:
+        url('../../assets/icons/brush.svg') 0 24,
+        auto;
+    }
+
+    > .eraser-preview {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 1;
+      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;
+      }
+    }
+
+    > .dev-stats-overlay {
+      position: absolute;
+      top: max(10px, env(safe-area-inset-top, 0px));
+      left: max(10px, env(safe-area-inset-left, 0px));
+      z-index: 6;
+      padding: 7px 9px;
+      border: 1px solid rgb(255 255 255 / 18%);
+      border-radius: 6px;
+      background: rgb(9 12 18 / 72%);
+      box-shadow: 0 8px 24px rgb(0 0 0 / 22%);
+      color: rgb(255 255 255 / 90%);
+      font-family:
+        ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+        monospace;
+      font-size: 11px;
+      line-height: 1.45;
+      pointer-events: none;
+      user-select: none;
+      white-space: pre;
+    }
+
+    > .errors-container {
+      position: absolute;
+      top: 0;
+      left: 0;
+      margin: var(--normal-margin);
+      z-index: 5;
+
+      pre {
+        font-size: 20px;
+        color: red;
+      }
+    }
+  }
+}
diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss
new file mode 100644
index 0000000..6fafa4b
--- /dev/null
+++ b/src/style/_config-pane.scss
@@ -0,0 +1,69 @@
+.config-pane {
+  .color-reaction-folder > .tp-fldv_c {
+    padding: 6px 8px 8px;
+  }
+}
+
+.color-reaction-matrix {
+  display: grid;
+  grid-template-columns: minmax(42px, max-content) 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;
+  gap: 5px;
+  color: rgb(255 255 255 / 76%);
+  font-size: 11px;
+  line-height: 1;
+}
+
+.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 {
+  display: block;
+  min-width: 0;
+}
+
+.color-reaction-matrix__cell > select {
+  width: 100%;
+  min-width: 0;
+  height: 28px;
+  border: 1px solid rgb(255 255 255 / 16%);
+  border-radius: 4px;
+  padding: 0 4px;
+  appearance: auto;
+  background: rgb(255 255 255 / 8%);
+  color: white;
+  font: inherit;
+  font-size: 11px;
+}
+
+.color-reaction-matrix__cell > select:focus-visible {
+  outline: 2px solid rgb(255 255 255 / 72%);
+  outline-offset: 1px;
+}
+
+.color-reaction-matrix__cell > select > option {
+  background: rgb(28 31 38);
+  color: white;
+}
diff --git a/src/style/_control-dock.scss b/src/style/_control-dock.scss
new file mode 100644
index 0000000..6949610
--- /dev/null
+++ b/src/style/_control-dock.scss
@@ -0,0 +1,65 @@
+html > body > aside.control-dock {
+  position: absolute;
+  left: 50%;
+  bottom: env(safe-area-inset-bottom);
+  z-index: 4;
+  width: min(calc(100vw - 1rem), 980px);
+  transform: translate(-50%, 0);
+  translate: 0 0;
+  visibility: visible;
+  pointer-events: none;
+  transition:
+    opacity var(--transition-time-long),
+    transform var(--transition-time-long),
+    translate var(--transition-time-long),
+    visibility 0s;
+
+  > .toolbar-row,
+  > .pages {
+    pointer-events: auto;
+  }
+
+  &.menu-hidden {
+    opacity: 0;
+    visibility: hidden;
+    transform: translate(-50%, 10px);
+    pointer-events: none;
+    transition:
+      opacity var(--transition-time-long),
+      transform var(--transition-time-long),
+      visibility 0s var(--transition-time-long);
+
+    > .toolbar-row,
+    > .pages {
+      pointer-events: none;
+    }
+  }
+
+  &.menu-hidden.has-persistent-settings {
+    opacity: 1;
+    visibility: visible;
+    transform: translate(-50%, 0);
+
+    > .pages,
+    > .toolbar-row > .vibe-button,
+    > .toolbar-row > .toolbar-shell > .garden-controls,
+    > .toolbar-row > .toolbar-shell > nav.buttons > button:not(.settings),
+    > .toolbar-row > .toolbar-shell > nav.buttons > .export-status {
+      opacity: 0;
+      visibility: hidden;
+      pointer-events: none;
+    }
+
+    > .toolbar-row,
+    > .toolbar-row > .toolbar-shell,
+    > .toolbar-row > .toolbar-shell > nav.buttons {
+      pointer-events: none;
+    }
+
+    > .toolbar-row > .toolbar-shell > nav.buttons > button.settings {
+      visibility: visible;
+      opacity: 1;
+      pointer-events: auto;
+    }
+  }
+}
diff --git a/src/style/_garden-prompt.scss b/src/style/_garden-prompt.scss
new file mode 100644
index 0000000..ed56320
--- /dev/null
+++ b/src/style/_garden-prompt.scss
@@ -0,0 +1,137 @@
+@use 'mixins' as *;
+
+html > body > .canvas-container > .garden-prompt {
+  position: absolute;
+  left: 50%;
+  bottom: calc(7.25rem + env(safe-area-inset-bottom));
+  transform: translateX(-50%);
+  max-width: min(92vw, 780px);
+  color: white;
+  text-align: center;
+  font-size: 46px;
+  line-height: 1.15;
+  text-shadow: 0 2px 18px rgb(0 0 0 / 60%);
+  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));
+    bottom: auto;
+    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:
+      600 20px/1.2 'Open Sans',
+      sans-serif;
+    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 {
+    bottom: calc(10rem + env(safe-area-inset-bottom));
+    font-size: 24px;
+
+    &.draw-hint {
+      top: calc(0.75rem + env(safe-area-inset-top));
+      bottom: auto;
+      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..ff97098
--- /dev/null
+++ b/src/style/_loading.scss
@@ -0,0 +1,120 @@
+.loading-indicator {
+  --loading-progress: 0%;
+
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+  align-items: center;
+  justify-content: center;
+  z-index: 3;
+  width: min(78vw, 320px);
+  transform: translate(-50%, -50%);
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity var(--transition-time-long);
+
+  > .loading-dots {
+    display: flex;
+    gap: 14px;
+    align-items: center;
+    justify-content: center;
+
+    > .loading-dot {
+      width: 14px;
+      height: 14px;
+      border-radius: 50%;
+      background: rgb(255 255 255 / 92%);
+      box-shadow:
+        0 0 18px rgb(255 255 255 / 38%),
+        0 0 4px rgb(255 255 255 / 60%);
+      transform: scale(0.5);
+      opacity: 0.4;
+      animation: loading-bloom 1.4s ease-in-out infinite;
+
+      &:nth-child(2) {
+        animation-delay: 0.18s;
+      }
+
+      &:nth-child(3) {
+        animation-delay: 0.36s;
+      }
+    }
+  }
+
+  > .loading-status {
+    color: rgb(255 255 255 / 88%);
+    font:
+      600 16px/1.25 'Open Sans',
+      sans-serif;
+    text-align: center;
+    text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
+    letter-spacing: 0.01em;
+    min-height: 1.25em;
+  }
+
+  > .loading-progress {
+    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%);
+
+    > .loading-progress-fill {
+      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 {
+    display: none;
+  }
+
+  aside.control-dock {
+    opacity: 0;
+    visibility: hidden;
+    translate: 0 36px;
+  }
+}
+
+@keyframes loading-bloom {
+  0%,
+  100% {
+    transform: scale(0.5);
+    opacity: 0.35;
+  }
+
+  50% {
+    transform: scale(1);
+    opacity: 1;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .loading-indicator > .loading-dots > .loading-dot {
+    animation: none;
+    transform: scale(0.85);
+    opacity: 0.85;
+  }
+}
diff --git a/src/style/_motion.scss b/src/style/_motion.scss
new file mode 100644
index 0000000..005a69f
--- /dev/null
+++ b/src/style/_motion.scss
@@ -0,0 +1,34 @@
+@media (prefers-reduced-motion: reduce) {
+  html > body {
+    > .canvas-container > .garden-prompt {
+      .draw-hint-stroke {
+        stroke-dashoffset: 0;
+      }
+
+      .draw-hint-end {
+        opacity: 1;
+        transform: none;
+      }
+    }
+
+    > aside.control-dock {
+      &,
+      &.menu-hidden {
+        transform: translateX(-50%);
+      }
+
+      > .toolbar-row {
+        button:hover,
+        button:active,
+        > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover,
+        > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover {
+          transform: none;
+        }
+
+        > .toolbar-shell > nav.buttons > button:hover::after {
+          transform: none;
+        }
+      }
+    }
+  }
+}
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
new file mode 100644
index 0000000..5b3fe9a
--- /dev/null
+++ b/src/style/_panels.scss
@@ -0,0 +1,178 @@
+@use 'mixins' as *;
+@use 'range-input' as *;
+
+html > body > aside.control-dock > .pages {
+  @include blurred-background(#fff);
+  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 / 54%);
+  border-radius: 8px;
+  box-shadow:
+    0 18px 48px rgb(0 0 0 / 28%),
+    0 2px 10px rgb(0 0 0 / 16%);
+  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;
+  }
+
+  &.info-page {
+    background:
+      linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
+      rgb(255 255 255);
+    border-color: rgb(255 255 255 / 78%);
+    color: rgb(24 30 27);
+    box-shadow:
+      0 20px 54px rgb(0 0 0 / 38%),
+      0 2px 12px rgb(0 0 0 / 22%);
+
+    > section {
+      gap: 0.85rem;
+
+      h1 {
+        margin-bottom: 0;
+        color: rgb(16 24 20);
+      }
+
+      p {
+        max-width: 54ch;
+        margin-bottom: 0;
+        color: rgb(42 48 45);
+      }
+
+      a {
+        color: rgb(0 84 120);
+        font-weight: 700;
+
+        &: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;
+  }
+
+  > section {
+    display: flex;
+    flex-direction: column;
+    padding: var(--normal-margin);
+
+    h1 {
+      font-size: 2rem;
+      line-height: 1.1;
+    }
+
+    p {
+      @include main-font();
+      margin-bottom: var(--small-margin);
+      line-height: 1.65;
+    }
+
+    a {
+      color: var(--accent-color);
+    }
+
+    .slider {
+      $track-height: 8px;
+      $thumb-size: 22px;
+      margin-bottom: var(--small-margin);
+      user-select: none;
+
+      p {
+        display: flex;
+        justify-content: space-between;
+        gap: var(--small-margin);
+        margin-bottom: 0.35rem;
+        font-size: 0.95rem;
+      }
+
+      input[type='range'] {
+        @include settings-range-input();
+
+        &::-webkit-slider-runnable-track {
+          @include range-track(rgb(49 52 63 / 14%), $track-height, 1000px, null);
+        }
+
+        &::-webkit-slider-thumb {
+          @include range-thumb-base(
+            $thumb-size,
+            $thumb-size,
+            2px solid var(--accent-color),
+            1000px
+          );
+          margin-top: -7px;
+          appearance: none;
+          background: white;
+          box-shadow: 0 3px 10px rgb(0 0 0 / 20%);
+          transition: transform var(--transition-time);
+
+          &:hover {
+            transform: scale(1.08);
+          }
+        }
+
+        &::-moz-range-track {
+          @include range-track(rgb(49 52 63 / 14%), $track-height, 1000px, null);
+        }
+
+        &::-moz-range-thumb {
+          @include range-thumb-base(
+            $thumb-size,
+            $thumb-size,
+            2px solid var(--accent-color),
+            1000px
+          );
+          background: white;
+          box-shadow: 0 3px 10px rgb(0 0 0 / 20%);
+        }
+      }
+    }
+
+    .large-button {
+      margin: var(--small-margin) 0 0 auto;
+      border-radius: 8px;
+    }
+  }
+
+  @include on-small-screen {
+    max-height: min(54vh, 500px);
+    max-height: min(54dvh, 500px);
+
+    > section {
+      padding: var(--small-margin);
+    }
+  }
+}
diff --git a/src/style/_range-input.scss b/src/style/_range-input.scss
new file mode 100644
index 0000000..a55899e
--- /dev/null
+++ b/src/style/_range-input.scss
@@ -0,0 +1,49 @@
+@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 {
+    outline: 2px solid white;
+    outline-offset: 2px;
+    border-radius: 8px;
+  }
+}
+
+@mixin settings-range-input() {
+  width: 100%;
+  height: 44px;
+  appearance: none;
+  background: transparent;
+  outline: none;
+}
+
+@mixin range-track(
+  $background,
+  $height: 7px,
+  $border-radius: 999px,
+  $box-shadow: inset 0 1px 2px rgb(0 0 0 / 24%)
+) {
+  height: $height;
+  cursor: pointer;
+  border-radius: $border-radius;
+  background: $background;
+
+  @if $box-shadow != null {
+    box-shadow: $box-shadow;
+  }
+}
+
+@mixin range-thumb-base($width, $height, $border, $border-radius) {
+  width: $width;
+  height: $height;
+  cursor: pointer;
+  border: $border;
+  border-radius: $border-radius;
+}
diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss
new file mode 100644
index 0000000..74a8592
--- /dev/null
+++ b/src/style/_toolbar.scss
@@ -0,0 +1,720 @@
+@use 'mixins' as *;
+@use 'range-input' as *;
+
+html > body > aside.control-dock > .toolbar-row {
+  display: flex;
+  align-items: stretch;
+  justify-content: center;
+  width: fit-content;
+  max-width: 100%;
+  margin: 0 auto;
+  gap: clamp(6px, 1.8vw, 14px);
+  color: rgb(245 250 244 / 92%);
+  font:
+    600 13px/1 'Open Sans',
+    sans-serif;
+
+  button {
+    min-width: 44px;
+    min-height: 44px;
+    border: 0;
+    font: inherit;
+    cursor: pointer;
+    transition:
+      background-color var(--transition-time),
+      border-color var(--transition-time),
+      color var(--transition-time),
+      box-shadow var(--transition-time),
+      transform var(--transition-time);
+
+    &:focus-visible {
+      outline: 2px solid white;
+      outline-offset: 2px;
+    }
+  }
+
+  > .toolbar-shell {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    grid-template-areas:
+      'swatches'
+      'nav';
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    min-width: 0;
+    min-height: 86px;
+    padding: 8px 9px;
+    border: 1px solid transparent;
+    border-radius: 10px;
+    background: transparent;
+    backdrop-filter: none;
+    box-shadow: none;
+  }
+
+  > .vibe-button {
+    display: grid;
+    place-items: center;
+    position: relative;
+    width: 52px;
+    height: auto;
+    min-height: 66px;
+    flex: 0 0 auto;
+    padding: 0;
+    border: 0;
+    border-radius: 0;
+    background: transparent;
+    color: rgb(255 255 255 / 70%);
+    font-size: 0;
+    line-height: 1;
+    text-shadow: none;
+    box-shadow: none;
+
+    &::before {
+      content: '';
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      width: 18px;
+      height: 18px;
+      border-color: currentColor;
+      border-style: solid;
+      border-width: 0 0 3px 3px;
+      transform: translate(-35%, -50%) rotate(45deg);
+    }
+
+    &.next-vibe::before {
+      border-width: 3px 3px 0 0;
+      transform: translate(-65%, -50%) rotate(45deg);
+    }
+
+    &:hover {
+      background: transparent;
+      color: color-mix(in srgb, var(--accent-color) 70%, white);
+      box-shadow: none;
+      transform: translateY(-2px);
+    }
+
+    &:active {
+      transform: translateY(0);
+    }
+  }
+
+  > .toolbar-shell > nav.buttons {
+    grid-area: nav;
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: center;
+    gap: 4px;
+    min-width: 0;
+    padding-top: 7px;
+    border-top: 1px solid rgb(255 255 255 / 12%);
+
+    > button {
+      position: relative;
+      width: 44px;
+      height: 44px;
+      border: 1px solid transparent;
+      border-radius: 8px;
+      background: transparent;
+
+      &::after {
+        content: '';
+        position: absolute;
+        inset: 0;
+        z-index: 1;
+        width: 20px;
+        height: 20px;
+        margin: auto;
+        background-color: rgb(245 250 244 / 76%);
+        mask-position: center;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        transition:
+          background-color var(--transition-time),
+          transform var(--transition-time);
+      }
+
+      &:hover,
+      &.active {
+        border-color: rgb(255 255 255 / 10%);
+        background: rgb(255 255 255 / 9%);
+      }
+
+      &:hover::after {
+        transform: scale(1.08);
+      }
+
+      &.active {
+        border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%);
+        background: color-mix(in srgb, var(--accent-color) 30%, transparent);
+        box-shadow: none;
+      }
+
+      &.active::after {
+        background-color: white;
+      }
+
+      &.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');
+      }
+
+      &.sound::after {
+        mask-image: url('../../assets/icons/sound.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%);
+      }
+
+      &.export-4k::after {
+        mask-image: url('../../assets/icons/download.svg');
+      }
+
+      &.restart::after {
+        mask-image: url('../../assets/icons/restart.svg');
+      }
+    }
+
+    > .export-status {
+      flex: 0 1 140px;
+      min-height: 20px;
+      max-width: 140px;
+      overflow: hidden;
+      color: rgb(255 255 255 / 82%);
+      font-size: 13px;
+      line-height: 1.2;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+
+      &:empty {
+        display: none;
+      }
+    }
+  }
+
+  > .toolbar-shell > .garden-controls {
+    grid-area: swatches;
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: center;
+    gap: 12px;
+    min-width: 0;
+    padding: 0 4px;
+
+    > .swatches {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 12px;
+      min-height: 58px;
+      padding: 6px 10px;
+
+      > button {
+        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 {
+        --eraser-control-scale: 1;
+        --eraser-progress: 33%;
+
+        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:
+          radial-gradient(
+            circle at 24% 78%,
+            rgb(255 226 215 / 42%) 0 1px,
+            transparent 1.5px
+          ),
+          radial-gradient(
+            circle at 47% 72%,
+            rgb(255 226 215 / 34%) 0 1px,
+            transparent 1.5px
+          ),
+          radial-gradient(
+            circle at 67% 81%,
+            rgb(255 226 215 / 38%) 0 1px,
+            transparent 1.5px
+          ),
+          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);
+
+        &::before {
+          content: '';
+          position: absolute;
+          right: 12px;
+          bottom: 8px;
+          left: 12px;
+          height: 2px;
+          border-radius: 999px;
+          background: linear-gradient(
+            90deg,
+            rgb(255 140 117 / 56%) 0 var(--eraser-progress),
+            rgb(255 255 255 / 18%) var(--eraser-progress) 100%
+          );
+          box-shadow: 0 1px 4px rgb(0 0 0 / 22%);
+        }
+
+        &:hover {
+          transform: translateY(-2px);
+          border-color: rgb(255 255 255 / 24%);
+        }
+
+        &.active {
+          border-color: rgb(255 212 202 / 72%);
+          background-color: rgb(255 140 117 / 11%);
+          box-shadow:
+            inset 0 0 0 1px rgb(255 255 255 / 10%),
+            0 0 0 5px rgb(255 140 117 / 34%),
+            0 6px 15px rgb(0 0 0 / 22%);
+        }
+
+        input[type='range'] {
+          @include toolbar-range-input();
+
+          &::-webkit-slider-runnable-track {
+            @include range-track(
+              linear-gradient(
+                90deg,
+                rgb(255 140 117 / 72%) 0 var(--eraser-progress),
+                rgb(255 255 255 / 24%) var(--eraser-progress) 100%
+              )
+            );
+          }
+
+          &::-webkit-slider-thumb {
+            @include range-thumb-base(
+              calc(34px * var(--eraser-control-scale)),
+              calc(21px * var(--eraser-control-scale)),
+              2px solid rgb(255 239 233 / 94%),
+              calc(6px * var(--eraser-control-scale))
+            );
+            margin-top: calc((7px - (21px * var(--eraser-control-scale))) / 2);
+            appearance: none;
+            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%
+              );
+            box-shadow:
+              inset 0 -2px 3px rgb(117 46 58 / 22%),
+              inset 0 2px 3px rgb(255 255 255 / 28%),
+              0 4px 10px rgb(0 0 0 / 28%);
+            transform: rotate(-10deg);
+            transition:
+              height var(--transition-time),
+              margin-top var(--transition-time),
+              width var(--transition-time);
+          }
+
+          &::-moz-range-track {
+            @include range-track(
+              linear-gradient(
+                90deg,
+                rgb(255 140 117 / 72%) 0 var(--eraser-progress),
+                rgb(255 255 255 / 24%) var(--eraser-progress) 100%
+              )
+            );
+          }
+
+          &::-moz-range-thumb {
+            @include range-thumb-base(
+              calc(34px * var(--eraser-control-scale)),
+              calc(21px * var(--eraser-control-scale)),
+              2px solid rgb(255 239 233 / 94%),
+              calc(6px * var(--eraser-control-scale))
+            );
+            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%
+              );
+            box-shadow:
+              inset 0 -2px 3px rgb(117 46 58 / 22%),
+              inset 0 2px 3px rgb(255 255 255 / 28%),
+              0 4px 10px rgb(0 0 0 / 28%);
+            transform: rotate(-10deg);
+            transition:
+              height var(--transition-time),
+              width var(--transition-time);
+          }
+        }
+      }
+
+      > .mirror-segment-control {
+        --mirror-progress: 0%;
+        --mirror-angle: 360deg;
+
+        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:
+          radial-gradient(
+            circle at 24% 78%,
+            rgb(197 255 234 / 38%) 0 1px,
+            transparent 1.5px
+          ),
+          radial-gradient(
+            circle at 47% 72%,
+            rgb(197 255 234 / 30%) 0 1px,
+            transparent 1.5px
+          ),
+          radial-gradient(
+            circle at 67% 81%,
+            rgb(197 255 234 / 34%) 0 1px,
+            transparent 1.5px
+          ),
+          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);
+
+        &::before {
+          content: '';
+          position: absolute;
+          right: 12px;
+          bottom: 8px;
+          left: 12px;
+          height: 2px;
+          border-radius: 999px;
+          background: linear-gradient(
+            90deg,
+            rgb(148 233 203 / 56%) 0 var(--mirror-progress),
+            rgb(255 255 255 / 18%) var(--mirror-progress) 100%
+          );
+          box-shadow: 0 1px 4px rgb(0 0 0 / 22%);
+        }
+
+        &:hover {
+          transform: translateY(-2px);
+          border-color: rgb(255 255 255 / 24%);
+        }
+
+        &.active {
+          border-color: rgb(167 245 219 / 74%);
+          background-color: rgb(92 206 177 / 12%);
+          box-shadow:
+            inset 0 0 0 1px rgb(255 255 255 / 10%),
+            0 0 0 5px rgb(92 206 177 / 28%),
+            0 6px 15px rgb(0 0 0 / 22%);
+        }
+
+        input[type='range'] {
+          @include toolbar-range-input();
+
+          &::-webkit-slider-runnable-track {
+            @include range-track(
+              linear-gradient(
+                90deg,
+                rgb(148 233 203 / 78%) 0 var(--mirror-progress),
+                rgb(255 255 255 / 24%) var(--mirror-progress) 100%
+              )
+            );
+          }
+
+          &::-webkit-slider-thumb {
+            @include range-thumb-base(44px, 44px, 2px solid rgb(240 255 251 / 94%), 50%);
+            margin-top: -18.5px;
+            appearance: none;
+            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)
+              );
+            box-shadow:
+              inset 0 0 0 7px rgb(0 0 0 / 18%),
+              0 0 0 5px rgb(92 206 177 / 16%),
+              0 5px 14px rgb(0 0 0 / 30%);
+            transition:
+              box-shadow var(--transition-time),
+              transform var(--transition-time);
+          }
+
+          &::-webkit-slider-thumb:hover {
+            box-shadow:
+              inset 0 0 0 7px rgb(0 0 0 / 18%),
+              0 0 0 7px rgb(92 206 177 / 24%),
+              0 6px 16px rgb(0 0 0 / 34%);
+            transform: scale(1.04);
+          }
+
+          &::-moz-range-track {
+            @include range-track(
+              linear-gradient(
+                90deg,
+                rgb(148 233 203 / 78%) 0 var(--mirror-progress),
+                rgb(255 255 255 / 24%) var(--mirror-progress) 100%
+              )
+            );
+          }
+
+          &::-moz-range-thumb {
+            @include range-thumb-base(44px, 44px, 2px solid rgb(240 255 251 / 94%), 50%);
+            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)
+              );
+            box-shadow:
+              inset 0 0 0 7px rgb(0 0 0 / 18%),
+              0 0 0 5px rgb(92 206 177 / 16%),
+              0 5px 14px rgb(0 0 0 / 30%);
+          }
+        }
+      }
+    }
+  }
+
+  @include on-small-screen {
+    width: 100%;
+    gap: 6px;
+
+    > .vibe-button {
+      width: 44px;
+      min-height: 44px;
+
+      &::before {
+        width: 14px;
+        height: 14px;
+      }
+    }
+
+    > .toolbar-shell {
+      flex: 1 1 auto;
+      min-width: 0;
+      gap: 8px;
+      padding: 4px 8px;
+
+      > nav.buttons {
+        grid-area: nav;
+        justify-content: center;
+        gap: 2px;
+        padding-top: 3px;
+        border-top: 1px solid rgb(255 255 255 / 12%);
+
+        > button {
+          width: 44px;
+          height: 38px;
+          min-height: 38px;
+
+          &::after {
+            width: 17px;
+            height: 17px;
+          }
+        }
+
+        > .export-status {
+          flex-basis: 100%;
+          max-width: 100%;
+          text-align: center;
+        }
+      }
+
+      > .garden-controls {
+        grid-area: swatches;
+        display: flex;
+        justify-content: center;
+        gap: 8px;
+        padding: 2px 4px;
+
+        > .swatches {
+          display: grid;
+          grid-template-columns: repeat(6, minmax(0, 1fr));
+          flex: 1 1 100%;
+          align-items: center;
+          justify-items: center;
+          justify-content: stretch;
+          column-gap: 7px;
+          row-gap: 8px;
+          width: 100%;
+          min-width: 0;
+          min-height: 54px;
+          padding: 4px 6px;
+
+          > .color-swatch {
+            grid-column: span 2;
+            width: 44px;
+            height: 44px;
+          }
+
+          > .eraser-size-control {
+            grid-column: 1 / span 3;
+            justify-self: stretch;
+            width: 100%;
+            min-width: 0;
+            height: 42px;
+            flex-basis: auto;
+            padding: 0 8px;
+
+            &::before {
+              right: 8px;
+              left: 8px;
+            }
+          }
+
+          > .mirror-segment-control {
+            grid-column: 4 / span 3;
+            justify-self: stretch;
+            width: 100%;
+            min-width: 0;
+            height: 42px;
+            flex-basis: auto;
+            padding: 0 8px;
+
+            &::before {
+              right: 8px;
+              left: 8px;
+            }
+
+            input[type='range'] {
+              &::-webkit-slider-thumb {
+                @include range-thumb-base(
+                  38px,
+                  38px,
+                  2px solid rgb(240 255 251 / 94%),
+                  50%
+                );
+                margin-top: -15.5px;
+                background:
+                  radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px),
+                  repeating-conic-gradient(
+                    from -90deg,
+                    rgb(218 255 241) 0 8deg,
+                    rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
+                  );
+                box-shadow:
+                  inset 0 0 0 6px rgb(0 0 0 / 18%),
+                  0 0 0 3px rgb(92 206 177 / 16%),
+                  0 4px 10px rgb(0 0 0 / 28%);
+              }
+
+              &::-webkit-slider-thumb:hover {
+                box-shadow:
+                  inset 0 0 0 6px rgb(0 0 0 / 18%),
+                  0 0 0 4px rgb(92 206 177 / 24%),
+                  0 5px 12px rgb(0 0 0 / 32%);
+              }
+
+              &::-moz-range-thumb {
+                @include range-thumb-base(
+                  38px,
+                  38px,
+                  2px solid rgb(240 255 251 / 94%),
+                  50%
+                );
+                background:
+                  radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px),
+                  repeating-conic-gradient(
+                    from -90deg,
+                    rgb(218 255 241) 0 8deg,
+                    rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
+                  );
+                box-shadow:
+                  inset 0 0 0 6px rgb(0 0 0 / 18%),
+                  0 0 0 3px rgb(92 206 177 / 16%),
+                  0 4px 10px rgb(0 0 0 / 28%);
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/src/style/common.scss b/src/style/common.scss
index 8954439..f33c2e1 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -9,7 +9,7 @@
   padding: 0;
   box-sizing: border-box;
 
-  @media (prefers-reduced-motion) {
+  @media (prefers-reduced-motion: reduce) {
     transition: none !important;
     animation: none !important;
   }
@@ -36,7 +36,21 @@ html {
   text-rendering: optimizeLegibility;
 }
 
+.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;
+}
+
 .large-button {
+  min-height: 44px;
   border: none;
   background-color: var(--accent-color);
   cursor: pointer;
diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts
new file mode 100644
index 0000000..b02db6c
--- /dev/null
+++ b/src/utils/browser-storage.ts
@@ -0,0 +1,17 @@
+export const readBrowserStorage = (key: string): string | null => {
+  try {
+    return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
+  } catch {
+    return null;
+  }
+};
+
+export const writeBrowserStorage = (key: string, value: string): void => {
+  try {
+    if (typeof localStorage !== 'undefined') {
+      localStorage.setItem(key, value);
+    }
+  } catch {
+    // Storage can be unavailable in private browsing or embedded contexts.
+  }
+};
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index fb1ef5a..011e584 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -1,15 +1,19 @@
+import { appConfig } from '../config';
 import { clamp } from './clamp';
 import { exponentialDecay } from './exponential-decay';
 
 export class DeltaTimeCalculator {
-  private static FPS_EXPONENTIAL_DECAY_STRENGTH = 0.01;
+  private static FPS_EXPONENTIAL_DECAY_STRENGTH =
+    appConfig.deltaTime.fpsExponentialDecayStrength;
 
   private previousTime: DOMHighResTimeStamp | null = null;
   private deltaTimeAccumulator: number | null = null;
 
   constructor(
-    private readonly maxDeltaTimeInSeconds: number = 1 / 30,
-    private readonly minDeltaTimeInSeconds: number = 1 / 240
+    private readonly maxDeltaTimeInSeconds: number =
+      appConfig.deltaTime.maxDeltaTimeSeconds,
+    private readonly minDeltaTimeInSeconds: number =
+      appConfig.deltaTime.minDeltaTimeSeconds
   ) {
     document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
   }
diff --git a/src/utils/dom.ts b/src/utils/dom.ts
new file mode 100644
index 0000000..7ba4aed
--- /dev/null
+++ b/src/utils/dom.ts
@@ -0,0 +1,63 @@
+import { ErrorCode, RuntimeError } from './error-handler';
+
+type ElementConstructor = abstract new () => T;
+
+export const queryRequiredElement = (
+  selector: string,
+  constructor: ElementConstructor,
+  root: ParentNode = document
+): T => {
+  const element = root.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;
+};
+
+export const queryRequiredElements = (
+  selector: string,
+  constructor: ElementConstructor,
+  root: ParentNode = document
+): Array => {
+  const elements = Array.from(root.querySelectorAll(selector));
+  if (elements.length === 0) {
+    throw new RuntimeError(
+      ErrorCode.DOM_ELEMENT_MISSING,
+      `Missing required DOM elements: ${selector}`,
+      {
+        details: {
+          expectedType: constructor.name,
+          selector,
+        },
+      }
+    );
+  }
+
+  return elements.map((element) => {
+    if (!(element instanceof constructor)) {
+      throw new RuntimeError(
+        ErrorCode.DOM_ELEMENT_MISSING,
+        `DOM element has the wrong type: ${selector}`,
+        {
+          details: {
+            actualType: element.constructor.name,
+            expectedType: constructor.name,
+            selector,
+          },
+        }
+      );
+    }
+
+    return element;
+  });
+};
diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts
index ca91a8a..d969a2e 100644
--- a/src/utils/error-handler.ts
+++ b/src/utils/error-handler.ts
@@ -4,12 +4,176 @@ export enum Severity {
   ERROR = 'error',
 }
 
-export interface ErrorHandlerError {
-  severity: Severity;
-  message: string;
+export enum ErrorCode {
+  UNKNOWN = 'unknown',
+  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 = [];
@@ -18,23 +182,46 @@ export class ErrorHandler {
     (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.errors.push(error);
     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(
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/cached-buffer-write.test.ts b/src/utils/graphics/cached-buffer-write.test.ts
new file mode 100644
index 0000000..3fb15b3
--- /dev/null
+++ b/src/utils/graphics/cached-buffer-write.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+  createCachedFloat32BufferWrite,
+  writeFloat32BufferIfChanged,
+} from './cached-buffer-write';
+
+const createGpuWriteStub = () => {
+  const writeBuffer = vi.fn();
+  const device = {
+    queue: {
+      writeBuffer,
+    },
+  } as unknown as GPUDevice;
+
+  return { device, writeBuffer };
+};
+
+describe('cached float32 buffer writes', () => {
+  it('writes the first value set and skips unchanged values', () => {
+    const { device, writeBuffer } = createGpuWriteStub();
+    const buffer = {} as GPUBuffer;
+    const cache = createCachedFloat32BufferWrite(3);
+    const values = new Float32Array([1, 2, 3]);
+
+    expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(true);
+    expect(writeBuffer).toHaveBeenCalledTimes(1);
+    expect(writeBuffer).toHaveBeenLastCalledWith(buffer, 0, values);
+
+    expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(false);
+    expect(writeBuffer).toHaveBeenCalledTimes(1);
+  });
+
+  it('writes again when any float changes', () => {
+    const { device, writeBuffer } = createGpuWriteStub();
+    const buffer = {} as GPUBuffer;
+    const cache = createCachedFloat32BufferWrite(3);
+
+    expect(
+      writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 3]), cache)
+    ).toBe(true);
+    expect(
+      writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 4]), cache)
+    ).toBe(true);
+    expect(writeBuffer).toHaveBeenCalledTimes(2);
+  });
+
+  it('rejects cache length mismatches before writing', () => {
+    const { device, writeBuffer } = createGpuWriteStub();
+    const buffer = {} as GPUBuffer;
+    const cache = createCachedFloat32BufferWrite(2);
+
+    expect(() =>
+      writeFloat32BufferIfChanged(device, buffer, new Float32Array([1]), cache)
+    ).toThrow('Cached buffer write length mismatch');
+    expect(writeBuffer).not.toHaveBeenCalled();
+  });
+});
diff --git a/src/utils/graphics/cached-buffer-write.ts b/src/utils/graphics/cached-buffer-write.ts
new file mode 100644
index 0000000..bab79a7
--- /dev/null
+++ b/src/utils/graphics/cached-buffer-write.ts
@@ -0,0 +1,36 @@
+interface CachedFloat32BufferWrite {
+  hasValue: boolean;
+  previous: Float32Array;
+}
+
+export const createCachedFloat32BufferWrite = (
+  length: number
+): CachedFloat32BufferWrite => ({
+  hasValue: false,
+  previous: new Float32Array(length),
+});
+
+export const writeFloat32BufferIfChanged = (
+  device: GPUDevice,
+  buffer: GPUBuffer,
+  values: Float32Array,
+  cache: CachedFloat32BufferWrite
+): boolean => {
+  if (values.length !== cache.previous.length) {
+    throw new Error('Cached buffer write length mismatch');
+  }
+
+  let hasChanged = !cache.hasValue;
+  for (let i = 0; i < values.length && !hasChanged; i++) {
+    hasChanged = !Object.is(values[i], cache.previous[i]);
+  }
+
+  if (!hasChanged) {
+    return false;
+  }
+
+  cache.previous.set(values);
+  cache.hasValue = true;
+  device.queue.writeBuffer(buffer, 0, values);
+  return true;
+};
diff --git a/src/utils/graphics/initialize-context.ts b/src/utils/graphics/initialize-context.ts
index 5e21820..94d29c1 100644
--- a/src/utils/graphics/initialize-context.ts
+++ b/src/utils/graphics/initialize-context.ts
@@ -1,3 +1,5 @@
+import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
+
 export const initializeContext = ({
   device,
   canvas,
@@ -5,13 +7,49 @@ export const initializeContext = ({
   device: GPUDevice;
   canvas: HTMLCanvasElement;
 }): GPUCanvasContext => {
-  const context = canvas.getContext('webgpu') as any as GPUCanvasContext;
+  const context = canvas.getContext('webgpu' as any) as GPUCanvasContext | null;
 
-  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,
+        },
+      }
+    );
+  }
+
+  const gpu = navigator.gpu;
+  if (!gpu) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_UNSUPPORTED,
+      'WebGPU is no longer available while configuring the canvas context.'
+    );
+  }
+
+  try {
+    context.configure({
+      device: device,
+      format: gpu.getPreferredCanvasFormat(),
+      alphaMode: 'premultiplied',
+    });
+  } catch (error) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_CONTEXT_CONFIGURATION_FAILED,
+      'Could not configure the WebGPU canvas context.',
+      {
+        cause: error,
+        details: {
+          causeMessage: getErrorMessage(error),
+          canvasHeight: canvas.height,
+          canvasWidth: canvas.width,
+        },
+      }
+    );
+  }
 
   return context;
 };
diff --git a/src/utils/graphics/initialize-gpu.ts b/src/utils/graphics/initialize-gpu.ts
index 18ba035..a47140f 100644
--- a/src/utils/graphics/initialize-gpu.ts
+++ b/src/utils/graphics/initialize-gpu.ts
@@ -1,33 +1,150 @@
-import { ErrorHandler, Severity } from '../error-handler';
+import {
+  ErrorCode,
+  ErrorHandler,
+  getErrorMessage,
+  RuntimeError,
+  Severity,
+} from '../error-handler';
+
+const WEBGPU_BROWSER_SUPPORT_MESSAGE =
+  'Fleeting Garden needs WebGPU. Try the latest Chrome, Edge, or another browser with WebGPU enabled.';
+
+const REQUESTED_LIMIT_NAMES = [
+  'maxBufferSize',
+  'maxStorageBufferBindingSize',
+  'maxComputeWorkgroupsPerDimension',
+] as const satisfies ReadonlyArray;
+
+const getRequiredLimits = (
+  limits: GPUSupportedLimits
+): Record<(typeof REQUESTED_LIMIT_NAMES)[number], number> =>
+  Object.fromEntries(REQUESTED_LIMIT_NAMES.map((name) => [name, limits[name]])) as Record<
+    (typeof REQUESTED_LIMIT_NAMES)[number],
+    number
+  >;
+
+const getAdapterInfo = (adapter: GPUAdapter): Record => {
+  try {
+    const info = adapter.info;
+    return {
+      architecture: info.architecture,
+      description: info.description,
+      device: info.device,
+      isFallbackAdapter: info.isFallbackAdapter,
+      subgroupMaxSize: info.subgroupMaxSize,
+      subgroupMinSize: info.subgroupMinSize,
+      vendor: info.vendor,
+    };
+  } catch (error) {
+    return {
+      unavailableReason: getErrorMessage(error),
+    };
+  }
+};
+
+const requestAdapter = async (
+  gpu: GPU,
+  options?: GPURequestAdapterOptions
+): Promise => {
+  try {
+    return await gpu.requestAdapter(options);
+  } catch (error) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
+      'Could not request a WebGPU adapter.',
+      {
+        cause: error,
+        details: {
+          causeMessage: getErrorMessage(error),
+          powerPreference: options?.powerPreference ?? 'default',
+        },
+      }
+    );
+  }
+};
 
 export const initializeGpu = async (): Promise => {
+  if (window.isSecureContext === false) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_INSECURE_CONTEXT,
+      'WebGPU requires a secure context. Open Fleeting Garden over HTTPS or from localhost.'
+    );
+  }
+
   const gpu = navigator.gpu;
   if (!gpu) {
-    throw new Error('WebGPU is not supported in your browser');
+    throw new RuntimeError(ErrorCode.WEBGPU_UNSUPPORTED, WEBGPU_BROWSER_SUPPORT_MESSAGE, {
+      details: {
+        hasNavigatorGpu: false,
+        isSecureContext: window.isSecureContext,
+      },
+    });
   }
 
-  const adapter = await gpu.requestAdapter({
-    powerPreference: 'high-performance',
-  });
+  const adapter =
+    (await requestAdapter(gpu, {
+      powerPreference: 'high-performance',
+    })) ?? (await requestAdapter(gpu));
 
   if (!adapter) {
-    throw new Error('Could not request adatper');
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_ADAPTER_UNAVAILABLE,
+      'WebGPU is available, but this browser could not provide a compatible GPU adapter.'
+    );
   }
 
-  ErrorHandler.addMetadata('features', adapter.features);
-  ErrorHandler.addMetadata('limits', adapter.limits);
-
-  const gpuDevice = await adapter.requestDevice({
-    requiredLimits: {
-      maxBufferSize: adapter.limits.maxBufferSize,
-      maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize,
-      maxComputeWorkgroupsPerDimension: adapter.limits.maxComputeWorkgroupsPerDimension,
-    },
+  const requiredLimits = getRequiredLimits(adapter.limits);
+  ErrorHandler.addMetadata('webgpuAdapter', {
+    features: Array.from(adapter.features).sort(),
+    info: getAdapterInfo(adapter),
+    requiredLimits,
   });
 
+  let gpuDevice: GPUDevice;
+  try {
+    gpuDevice = await adapter.requestDevice({
+      requiredLimits,
+    });
+  } catch (error) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
+      'Could not create a WebGPU device for this adapter.',
+      {
+        cause: error,
+        details: {
+          causeMessage: getErrorMessage(error),
+          requiredLimits,
+        },
+      }
+    );
+  }
+
+  if (!gpuDevice) {
+    throw new RuntimeError(
+      ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
+      'The browser returned an empty WebGPU device.'
+    );
+  }
+
   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..da68d39 100644
--- a/src/utils/graphics/noise.ts
+++ b/src/utils/graphics/noise.ts
@@ -1,7 +1,8 @@
 import { setUpFullScreenQuad } from './full-screen-quad';
 import { smartCompile } from './smart-compile';
 
-const textureCache = new Map();
+const textureCache = new WeakMap>();
+const NOISE_TEXTURE_FORMAT: GPUTextureFormat = 'rgba8unorm';
 
 export const generateNoise = ({
   device,
@@ -13,7 +14,13 @@ export const generateNoise = ({
   height: number;
 }): GPUTextureView => {
   const cacheKey = `${width}x${height}`;
-  const cached = textureCache.get(cacheKey);
+  let deviceCache = textureCache.get(device);
+  if (!deviceCache) {
+    deviceCache = new Map();
+    textureCache.set(device, deviceCache);
+  }
+
+  const cached = deviceCache.get(cacheKey);
   if (cached) {
     return cached.createView();
   }
@@ -45,7 +52,7 @@ export const generateNoise = ({
       entryPoint: 'fragment',
       targets: [
         {
-          format: 'rgba16float',
+          format: NOISE_TEXTURE_FORMAT,
         },
       ],
     },
@@ -60,7 +67,7 @@ export const generateNoise = ({
       height,
       depthOrArrayLayers: 1,
     },
-    format: 'rgba16float',
+    format: NOISE_TEXTURE_FORMAT,
     usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
   });
 
@@ -84,6 +91,6 @@ export const generateNoise = ({
   passEncoder.end();
 
   device.queue.submit([commandEncoder.finish()]);
-  textureCache.set(cacheKey, colorTexture);
+  deviceCache.set(cacheKey, colorTexture);
   return colorTexture.createView();
 };
diff --git a/src/utils/graphics/resizable-texture.ts b/src/utils/graphics/resizable-texture.ts
index 54b21ed..44770e7 100644
--- a/src/utils/graphics/resizable-texture.ts
+++ b/src/utils/graphics/resizable-texture.ts
@@ -13,7 +13,7 @@ export class ResizableTexture {
     size: vec2
   ) {
     this.copyPipeline = new CopyPipeline(this.device);
-    this.size = size;
+    this.size = vec2.clone(size);
     this.texture = this.createTexture(size);
     this.textureView = this.texture.createView();
   }
@@ -36,11 +36,15 @@ export class ResizableTexture {
     this.device.queue.submit([commandEncoder.finish()]);
     this.texture.destroy();
 
-    this.size = size;
+    this.size = vec2.clone(size);
     this.texture = newTexture;
     this.textureView = newTextureView;
   }
 
+  public getSize(): vec2 {
+    return vec2.clone(this.size);
+  }
+
   public getTextureView(): GPUTextureView {
     return this.textureView;
   }
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/vibes.test.ts b/src/vibes.test.ts
new file mode 100644
index 0000000..dd8dd9f
--- /dev/null
+++ b/src/vibes.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { gardenAudioConfig } from './audio/garden-audio-config';
+import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
+
+const originalLocalStorage = globalThis.localStorage;
+const colorInteractionKeys = [
+  'color1ToColor1',
+  'color1ToColor2',
+  'color1ToColor3',
+  'color2ToColor1',
+  'color2ToColor2',
+  'color2ToColor3',
+  'color3ToColor1',
+  'color3ToColor2',
+  'color3ToColor3',
+] as const;
+
+const setBrowserVibeState = ({
+  storedVibeId = null,
+}: {
+  storedVibeId?: string | null;
+}) => {
+  Object.defineProperty(globalThis, 'localStorage', {
+    configurable: true,
+    value: {
+      getItem: vi.fn((key: string) =>
+        key === 'fleeting-garden:vibe' ? storedVibeId : null
+      ),
+    },
+  });
+};
+
+describe('vibe selection', () => {
+  afterEach(() => {
+    Object.defineProperty(globalThis, 'localStorage', {
+      configurable: true,
+      value: originalLocalStorage,
+    });
+  });
+
+  it('uses a valid stored vibe id', () => {
+    setBrowserVibeState({ storedVibeId: 'sunlit-moss' });
+
+    expect(getInitialVibe().id).toBe('sunlit-moss');
+  });
+
+  it('falls back to the default preset for an unknown stored vibe id', () => {
+    setBrowserVibeState({ storedVibeId: 'unknown' });
+
+    expect(getInitialVibe()).toBe(VIBE_PRESETS[0]);
+  });
+});
+
+describe('vibe and audio config contract', () => {
+  it('keeps preset ids unique, URL-safe, and covered by audio profiles', () => {
+    const vibeIds = VIBE_PRESETS.map((vibe) => vibe.id);
+    const audioIds = Object.keys(gardenAudioConfig.vibes);
+
+    expect(new Set(vibeIds).size).toBe(vibeIds.length);
+    expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true);
+    expect(audioIds.slice().sort()).toEqual(vibeIds.slice().sort());
+    expect(vibeIds).toContain(gardenAudioConfig.fallbackVibeId);
+  });
+
+  it('keeps each vibe palette and audio profile complete', () => {
+    VIBE_PRESETS.forEach((vibe) => {
+      expect(vibe.colors).toHaveLength(3);
+      vibe.colors.forEach((color) => {
+        expect(color).toMatch(/^#[0-9a-f]{6}$/i);
+        hexToRgb(color).forEach((channel) => {
+          expect(channel).toBeGreaterThanOrEqual(0);
+          expect(channel).toBeLessThanOrEqual(1);
+        });
+      });
+
+      const profile = gardenAudioConfig.vibes[vibe.id];
+      expect(Number.isFinite(profile.rootMidi)).toBe(true);
+      expect(profile.scale.length).toBeGreaterThan(0);
+      expect(profile.scale.every((degree) => Number.isFinite(degree))).toBe(true);
+      expect(profile.brightness).toBeGreaterThan(0);
+      expect(profile.delayTimeMultiplier).toBeGreaterThan(0);
+      expect(profile.progression.length).toBeGreaterThan(0);
+      profile.progression.forEach((chord) => {
+        expect(Number.isFinite(chord.rootOffset)).toBe(true);
+        expect(['major', 'minor']).toContain(chord.quality);
+      });
+    });
+  });
+
+  it('keeps audio color voices aligned with the three vibe palette slots', () => {
+    expect(gardenAudioConfig.colorVoices).toHaveLength(3);
+    gardenAudioConfig.colorVoices.forEach((voice) => {
+      expect(Number.isFinite(voice.scaleDegreeOffset)).toBe(true);
+      expect(voice.velocityMultiplier).toBeGreaterThan(0);
+      expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
+    });
+  });
+
+  it('uses discrete color interaction matrices for every vibe', () => {
+    VIBE_PRESETS.forEach((vibe) => {
+      colorInteractionKeys.forEach((key) => {
+        expect([-1, 0, 1]).toContain(vibe.settings[key]);
+      });
+      expect(vibe.settings.color1ToColor1).toBe(1);
+      expect(vibe.settings.color2ToColor2).toBe(1);
+      expect(vibe.settings.color3ToColor3).toBe(1);
+    });
+  });
+});
diff --git a/src/vibes.ts b/src/vibes.ts
new file mode 100644
index 0000000..085f10d
--- /dev/null
+++ b/src/vibes.ts
@@ -0,0 +1,24 @@
+import { appConfig, type VibePreset } from './config';
+import { readBrowserStorage } from './utils/browser-storage';
+
+export type { GardenVibeSettings, VibePreset } from './config';
+
+export const VIBE_PRESETS: Array = appConfig.vibes.presets;
+
+export const hexToRgb = (hex: string): [number, number, number] => {
+  const value = hex.replace('#', '');
+  return [
+    parseInt(value.slice(0, 2), 16) / 255,
+    parseInt(value.slice(2, 4), 16) / 255,
+    parseInt(value.slice(4, 6), 16) / 255,
+  ];
+};
+
+export const getInitialVibe = (): VibePreset => {
+  const id = readBrowserStorage(appConfig.storage.vibeKey);
+  return (
+    VIBE_PRESETS.find((vibe) => vibe.id === id) ??
+    VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
+    VIBE_PRESETS[0]
+  );
+};
diff --git a/tsconfig.playwright.json b/tsconfig.playwright.json
new file mode 100644
index 0000000..139efca
--- /dev/null
+++ b/tsconfig.playwright.json
@@ -0,0 +1,7 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "types": ["node", "@playwright/test"]
+  },
+  "include": ["playwright.config.ts", "e2e/**/*.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
index 7215fa6..795e630 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,12 +1,17 @@
+import basicSsl from '@vitejs/plugin-basic-ssl';
 import browserslist from 'browserslist';
 import { browserslistToTargets } from 'lightningcss';
-import { defineConfig } from 'vitest/config';
 import { viteSingleFile } from 'vite-plugin-singlefile';
+import { defineConfig } from 'vitest/config';
 
 const cssTargets = browserslistToTargets(browserslist());
 
-export default defineConfig({
-  plugins: [viteSingleFile()],
+export default defineConfig(({ command }) => ({
+  base: command === 'build' ? './' : '/',
+  plugins: [
+    viteSingleFile({ useRecommendedBuildConfig: false }),
+    ...(command === 'serve' ? [basicSsl()] : []),
+  ],
   css: {
     transformer: 'lightningcss',
     lightningcss: {
@@ -18,12 +23,14 @@ export default defineConfig({
     cssCodeSplit: false,
     cssMinify: 'lightningcss',
     assetsInlineLimit: Number.MAX_SAFE_INTEGER,
+    assetsDir: '',
   },
   server: {
     open: true,
+    host: true,
   },
   test: {
     environment: 'node',
     include: ['src/**/*.test.ts'],
   },
-});
+}));