Compare commits

..

5 commits

Author SHA1 Message Date
c40c5d97db Final clean up
Some checks failed
Check & deploy / build (pull_request) Failing after 1m16s
2026-05-24 10:52:20 +01:00
05c8a39bd8 Small improvements 2026-05-24 09:34:46 +01:00
a7c04b2bd8 fix zoom in 2026-05-22 08:08:31 +01:00
646564fc73 Fixes 2026-05-22 08:03:13 +01:00
f300dbd394 Getting there 2026-05-22 07:54:38 +01:00
90 changed files with 2188 additions and 1151 deletions

View file

@ -32,6 +32,7 @@ jobs:
- name: Test - name: Test
run: | run: |
npm run lint:check npm run lint:check
npm run format:check
npm run typecheck npm run typecheck
npm run typecheck:e2e npm run typecheck:e2e
npm test npm test
@ -40,6 +41,10 @@ jobs:
run: | run: |
npm run test:e2e npm run test:e2e
- name: Build
run: |
npm run build
- name: Upload Playwright report - name: Upload Playwright report
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
node_modules node_modules
dist dist
test-results test-results
.DS_Store
*.log

2
.nvmrc
View file

@ -1 +1 @@
22 22.13.0

View file

@ -6,5 +6,5 @@
"endOfLine": "lf", "endOfLine": "lf",
"plugins": ["@ianvs/prettier-plugin-sort-imports"], "plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"], "importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
"importOrderTypeScriptVersion": "5.0.0" "importOrderTypeScriptVersion": "6.0.3"
} }

View file

@ -2,9 +2,9 @@
<path <path
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3" d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
fill="none" fill="none"
stroke="white" stroke="currentColor"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="1.5"
/> />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 239 B

After

Width:  |  Height:  |  Size: 248 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
<line x1="12" y1="8" x2="12.01" y2="8" /> <line x1="12" y1="8" x2="12.01" y2="8" />

Before

Width:  |  Height:  |  Size: 342 B

After

Width:  |  Height:  |  Size: 349 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" /> <path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
<path d="M4 16v2a2 2 0 0 0 2 2h2" /> <path d="M4 16v2a2 2 0 0 0 2 2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" /> <path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" /> <path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 382 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" /> <path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
<path d="M15 5v2a2 2 0 0 0 2 2h2" /> <path d="M15 5v2a2 2 0 0 0 2 2h2" />
<path d="M5 15h2a2 2 0 0 1 2 2v2" /> <path d="M5 15h2a2 2 0 0 1 2 2v2" />
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" /> <path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 382 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" /> <path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" /> <path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />

Before

Width:  |  Height:  |  Size: 324 B

After

Width:  |  Height:  |  Size: 331 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/> <path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /> <path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" /> <path d="M4 6l8 0" />

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 541 B

Before After
Before After

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" /> <path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 384 B

After

Width:  |  Height:  |  Size: 391 B

Before After
Before After

8
definitions.d.ts vendored
View file

@ -1,8 +0,0 @@
declare module '*.wgsl?raw' {
const content: string;
export default content;
}
interface HTMLCanvasElement {
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
}

View file

@ -1,7 +1,12 @@
import { expect, test, type Page } from '@playwright/test'; import { test as base, expect, type Page } from '@playwright/test';
const canvasName = 'Interactive generative garden canvas'; const canvasName = 'Interactive generative garden canvas';
interface BrowserDiagnostics {
browserFailures: Array<string>;
consoleErrors: Array<string>;
}
const isLocalUrl = (url: string) => { const isLocalUrl = (url: string) => {
const { hostname } = new URL(url); const { hostname } = new URL(url);
return hostname === '127.0.0.1' || hostname === 'localhost'; return hostname === '127.0.0.1' || hostname === 'localhost';
@ -29,6 +34,27 @@ const collectLocalBrowserFailures = (page: Page) => {
return failures; return failures;
}; };
const test = base.extend<{ browserDiagnostics: BrowserDiagnostics }>({
browserDiagnostics: [
async ({ page }, use) => {
const browserFailures = collectLocalBrowserFailures(page);
const consoleErrors: Array<string> = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await use({ browserFailures, consoleErrors });
expect(consoleErrors).toEqual([]);
expect(browserFailures).toEqual([]);
},
{ auto: true },
],
});
const disableWebGpu = async (page: Page) => { const disableWebGpu = async (page: Page) => {
await page.addInitScript(() => { await page.addInitScript(() => {
Object.defineProperty(navigator, 'gpu', { Object.defineProperty(navigator, 'gpu', {
@ -39,14 +65,6 @@ const disableWebGpu = async (page: Page) => {
}; };
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
const browserFailures = collectLocalBrowserFailures(page);
const consoleErrors: Array<string> = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await page.addInitScript((expectedCanvasName) => { await page.addInitScript((expectedCanvasName) => {
const captureState = { count: 0 }; const captureState = { count: 0 };
Object.defineProperty(window, '__fleetingGardenPointerCaptures', { Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
@ -68,10 +86,10 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
}, canvasName); }, canvasName);
await page.goto('/'); await page.goto('/');
const startButton = page.getByRole('button', { name: 'Start' }); const startButton = page.getByRole('button', { exact: true, name: 'Start' });
await expect(startButton).toBeVisible(); await expect(startButton).toBeVisible();
await expect(startButton).toBeEnabled({ timeout: 30_000 }); await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click(); await page.keyboard.press('Enter');
await expect(page.locator('body')).not.toHaveClass(/is-loading/, { await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000, timeout: 30_000,
}); });
@ -117,13 +135,21 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
) )
.toBeGreaterThan(0); .toBeGreaterThan(0);
expect(consoleErrors).toEqual([]); await expect
expect(browserFailures).toEqual([]); .poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__fleetingGardenBrushPasses?: number;
}
).__fleetingGardenBrushPasses ?? 0
)
)
.toBeGreaterThan(0);
}); });
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => { test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
const browserFailures = collectLocalBrowserFailures(page);
await disableWebGpu(page); await disableWebGpu(page);
await page.goto('/'); await page.goto('/');
@ -135,7 +161,19 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
const fallback = page.getByRole('alert'); const fallback = page.getByRole('alert');
await expect(fallback).toContainText('Fleeting Garden needs WebGPU'); await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
await expect(fallback).toContainText('webgpu-unsupported'); await expect(fallback).toContainText('webgpu-unsupported');
expect(browserFailures).toEqual([]); });
test('syncs the selected vibe with the URI', async ({ page }) => {
await disableWebGpu(page);
await page.goto('/?vibe=Aurora%20Mycelium');
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
await page.getByRole('button', { name: 'Next vibe' }).click();
await expect(page).toHaveURL(/vibe=velvet-observatory/);
await page.goBack();
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
}); });
test('keeps audio focus outlines scoped to the active control', async ({ page }) => { test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
@ -144,8 +182,8 @@ test('keeps audio focus outlines scoped to the active control', async ({ page })
await expect(page.locator('body')).not.toHaveClass(/is-loading/); await expect(page.locator('body')).not.toHaveClass(/is-loading/);
const audioControl = page.locator('.audio-control'); const audioControl = page.locator('.audio-control');
const soundButton = page.locator('button.sound'); const soundButton = page.getByRole('button', { name: /audio/i });
const volumeSlider = page.locator('.volume-slider'); const volumeSlider = page.getByRole('slider', { name: 'Master volume' });
await soundButton.click(); await soundButton.click();
await expect(audioControl).toHaveCSS('outline-style', 'none'); await expect(audioControl).toHaveCSS('outline-style', 'none');
@ -178,14 +216,14 @@ test('keeps the config overlay scrollable and dismissible on mobile', async ({
await page.setViewportSize({ width: 390, height: 640 }); await page.setViewportSize({ width: 390, height: 640 });
await page.goto('/'); await page.goto('/');
const startButton = page.getByRole('button', { name: 'Start' }); const startButton = page.getByRole('button', { exact: true, name: 'Start' });
await expect(startButton).toBeEnabled({ timeout: 30_000 }); await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click(); await startButton.click();
await expect(page.locator('body')).not.toHaveClass(/is-loading/, { await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000, timeout: 30_000,
}); });
const settingsButton = page.locator('button.settings'); const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
await settingsButton.click(); await settingsButton.click();
const pane = page.locator('.config-pane'); const pane = page.locator('.config-pane');

12
package-lock.json generated
View file

@ -10,13 +10,13 @@
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@plausible-analytics/tracker": "^0.4.5", "@plausible-analytics/tracker": "^0.4.5",
"tweakpane": "^4.0.5" "tweakpane": "~4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tweakpane/core": "^2.0.5", "@tweakpane/core": "~2.0.5",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-basic-ssl": "^2.3.0",
@ -38,7 +38,7 @@
"vitest": "^4.1.5" "vitest": "^4.1.5"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=22.13.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -2774,9 +2774,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View file

@ -22,7 +22,7 @@
"generate-icons": "pwa-assets-generator" "generate-icons": "pwa-assets-generator"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=22.13.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -48,7 +48,7 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tweakpane/core": "^2.0.5", "@tweakpane/core": "~2.0.5",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-basic-ssl": "^2.3.0",
@ -71,6 +71,6 @@
}, },
"dependencies": { "dependencies": {
"@plausible-analytics/tracker": "^0.4.5", "@plausible-analytics/tracker": "^0.4.5",
"tweakpane": "^4.0.5" "tweakpane": "~4.0.5"
} }
} }

View file

@ -9,7 +9,7 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: isCi, forbidOnly: isCi,
retries: isCi ? 2 : 0, retries: isCi ? 2 : 0,
workers: isCi ? 1 : undefined, workers: 1,
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list', reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
use: { use: {
baseURL, baseURL,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Before After
Before After

View file

@ -4,6 +4,7 @@ import {
type PlausibleEventOptions, type PlausibleEventOptions,
} from '@plausible-analytics/tracker'; } from '@plausible-analytics/tracker';
import { appConfig } from './config';
import type { VibeId } from './vibes'; import type { VibeId } from './vibes';
let isInitialized = false; let isInitialized = false;
@ -23,10 +24,10 @@ export const initAnalytics = () => {
try { try {
plausibleInit({ plausibleInit({
domain: 'schmelczer.dev/floating', domain: appConfig.analytics.domain,
endpoint: 'https://stats.schmelczer.dev/status', endpoint: appConfig.analytics.endpoint,
autoCapturePageviews: true, autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
logging: true, logging: appConfig.analytics.logging,
}); });
isInitialized = true; isInitialized = true;
} catch (error) { } catch (error) {

View file

@ -1,9 +1,13 @@
import { DEFAULT_AUDIO_VOLUME } from '../consts';
import type { PianoNoteRole } from './garden-audio-types'; import type { PianoNoteRole } from './garden-audio-types';
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const SILENT_AUDIO_GAIN = 0.0001;
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
export interface GardenAudioChord { export interface GardenAudioChord {
rootOffset: number; rootOffset: number;
quality: 'major' | 'minor'; quality: GardenAudioChordQuality;
} }
export interface GardenAudioVibeSettings { export interface GardenAudioVibeSettings {
@ -14,6 +18,8 @@ export interface GardenAudioVibeSettings {
noteLength: number; noteLength: number;
notePitchOffset: number; notePitchOffset: number;
brightness: number; brightness: number;
scale?: Array<number>;
progression?: Array<GardenAudioChord>;
} }
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings { export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
@ -37,7 +43,9 @@ export const createGardenAudioConfig = () => ({
fadeInSeconds: 0.45, fadeInSeconds: 0.45,
updateRampSeconds: 0.08, updateRampSeconds: 0.08,
delay: { delay: {
timeSeconds: 0.405, timeBeats: 0.5,
timeMinSeconds: 0.18,
timeMaxSeconds: 0.72,
feedback: 0.12, feedback: 0.12,
wetGain: 0.044, wetGain: 0.044,
erasingActivity: 0.12, erasingActivity: 0.12,

View file

@ -1,7 +1,9 @@
import { clamp } from '../utils/math'; import { clamp } from '../utils/math';
import type { GardenAudioConfig } from './garden-audio-config'; import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config';
import type { PianoNoteRole } from './garden-audio-types'; import type { PianoNoteRole } from './garden-audio-types';
type AudioSessionType = NonNullable<NavigatorWithAudioSession['audioSession']>['type'];
type NavigatorWithAudioSession = Navigator & { type NavigatorWithAudioSession = Navigator & {
audioSession?: { audioSession?: {
type: type:
@ -17,14 +19,14 @@ type NavigatorWithAudioSession = Navigator & {
const outputHighPassFrequencyHz = 45; const outputHighPassFrequencyHz = 45;
const noiseBufferDurationSeconds = 1; const noiseBufferDurationSeconds = 1;
const graphTuning = { const graphTuning = {
closeGain: 0.0001, closeGain: SILENT_AUDIO_GAIN,
closeRampSeconds: 0.015, closeRampSeconds: 0.015,
delayMaxSeconds: 2, delayMaxSeconds: 2,
eventBusGain: 1, eventBusGain: 1,
noiseMax: 1, noiseMax: 1,
noiseMin: -1, noiseMin: -1,
latencyHint: 'interactive' as AudioContextLatencyCategory, latencyHint: 'interactive',
outputFilterType: 'highpass' as BiquadFilterType, outputFilterType: 'highpass',
compressor: { compressor: {
thresholdDb: -18, thresholdDb: -18,
kneeDb: 18, kneeDb: 18,
@ -32,7 +34,7 @@ const graphTuning = {
attackSeconds: 0.018, attackSeconds: 0.018,
releaseSeconds: 0.18, releaseSeconds: 0.18,
}, },
}; } as const;
const delayFilterTuning = { const delayFilterTuning = {
feedbackHighPassHz: 180, feedbackHighPassHz: 180,
feedbackLowPassHz: 5200, feedbackLowPassHz: 5200,
@ -54,6 +56,7 @@ export class GardenAudioGraph {
private pianoBusGainScale = 1; private pianoBusGainScale = 1;
private pianoBusGainScaleAutomationUntil = 0; private pianoBusGainScaleAutomationUntil = 0;
private pianoBusGainScaleTimeConstantSeconds = 0; private pianoBusGainScaleTimeConstantSeconds = 0;
private previousAudioSessionType: AudioSessionType | null = null;
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>(); private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
public constructor(private readonly config: GardenAudioConfig) {} public constructor(private readonly config: GardenAudioConfig) {}
@ -77,6 +80,7 @@ export class GardenAudioGraph {
// Audio Session API. // Audio Session API.
const audioSession = (navigator as NavigatorWithAudioSession).audioSession; const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) { if (audioSession) {
this.previousAudioSessionType ??= audioSession.type;
audioSession.type = 'playback'; audioSession.type = 'playback';
} }
@ -121,19 +125,19 @@ export class GardenAudioGraph {
); );
} }
public applyDelayProfile(): void { public applyDelayProfile(bpm: number): void {
if (!this.context || !this.delayNode) { if (!this.context || !this.delayNode) {
return; return;
} }
this.delayNode.delayTime.setTargetAtTime( this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds, this.getDelayTimeSecondsForBpm(bpm),
this.context.currentTime, this.context.currentTime,
this.config.delay.timeRampSeconds this.config.delay.timeRampSeconds
); );
} }
public updateDelay(activity: number): void { public updateDelay(activity: number, bpm: number): void {
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) { if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
return; return;
} }
@ -141,7 +145,7 @@ export class GardenAudioGraph {
const now = this.context.currentTime; const now = this.context.currentTime;
const normalizedActivity = clamp(activity, 0, 1); const normalizedActivity = clamp(activity, 0, 1);
this.delayNode.delayTime.setTargetAtTime( this.delayNode.delayTime.setTargetAtTime(
this.config.delay.timeSeconds, this.getDelayTimeSecondsForBpm(bpm),
now, now,
this.config.delay.timeRampSeconds this.config.delay.timeRampSeconds
); );
@ -203,6 +207,21 @@ export class GardenAudioGraph {
if (context.state !== 'closed') { if (context.state !== 'closed') {
await context.close().catch(() => undefined); await context.close().catch(() => undefined);
} }
this.restoreAudioSessionType();
}
private restoreAudioSessionType(): void {
const previousType = this.previousAudioSessionType;
this.previousAudioSessionType = null;
if (previousType === null) {
return;
}
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
if (audioSession) {
audioSession.type = previousType;
}
} }
private createDelay(context: AudioContext, masterGain: GainNode): void { private createDelay(context: AudioContext, masterGain: GainNode): void {
@ -214,7 +233,7 @@ export class GardenAudioGraph {
const feedbackLowPass = context.createBiquadFilter(); const feedbackLowPass = context.createBiquadFilter();
const returnLowPass = context.createBiquadFilter(); const returnLowPass = context.createBiquadFilter();
delayNode.delayTime.value = this.config.delay.timeSeconds; delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
delayFeedback.gain.value = this.config.delay.feedback; delayFeedback.gain.value = this.config.delay.feedback;
delayOutput.gain.value = this.config.delay.wetGain; delayOutput.gain.value = this.config.delay.wetGain;
feedbackHighPass.type = 'highpass'; feedbackHighPass.type = 'highpass';
@ -283,6 +302,15 @@ export class GardenAudioGraph {
}); });
} }
private getDelayTimeSecondsForBpm(bpm: number): number {
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
return clamp(
(60 / safeBpm) * this.config.delay.timeBeats,
this.config.delay.timeMinSeconds,
this.config.delay.timeMaxSeconds
);
}
private createNoiseBuffer(context: AudioContext): AudioBuffer { private createNoiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer( const buffer = context.createBuffer(
1, 1,

View file

@ -13,22 +13,21 @@ const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
const DEFAULT_ROOT_MIDI = 57; const DEFAULT_ROOT_MIDI = 57;
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9]; const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>(); const getProfileScale = (vibe: VibePreset): Array<number> => {
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
return [...scale];
};
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
(chord) => ({ ...chord })
);
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => { export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
let profile = profileCache.get(vibe); return {
if (!profile) { ...vibe.audio,
profile = { rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
...vibe.audio, scale: getProfileScale(vibe),
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset, progression: getProfileProgression(vibe),
scale: DEFAULT_SCALE as Array<number>, };
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
};
profileCache.set(vibe, profile);
return profile;
}
Object.assign(profile, vibe.audio);
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
return profile;
}; };

View file

@ -1,4 +1,4 @@
import { VibePreset } from '../vibes'; import type { VibePreset } from '../vibes';
export interface GardenAudioSnapshot { export interface GardenAudioSnapshot {
vibe: VibePreset; vibe: VibePreset;

View file

@ -1,7 +1,11 @@
import { ErrorHandler, Severity } from '../utils/error-handler'; import { ErrorHandler, Severity } from '../utils/error-handler';
import { clamp01 } from '../utils/math'; import { clamp01 } from '../utils/math';
import type { VibeId, VibePreset } from '../vibes'; import type { VibeId, VibePreset } from '../vibes';
import type { GardenAudioConfig } from './garden-audio-config'; import {
SILENT_AUDIO_GAIN,
type GardenAudioConfig,
type GardenAudioVibeProfile,
} from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy'; import { GardenAudioEnergy } from './garden-audio-energy';
import { GardenAudioGestureState } from './garden-audio-gesture-state'; import { GardenAudioGestureState } from './garden-audio-gesture-state';
import { GardenAudioGraph } from './garden-audio-graph'; import { GardenAudioGraph } from './garden-audio-graph';
@ -13,8 +17,12 @@ import { NoiseBurstPlayer } from './noise-burst-player';
import { PianoSampler } from './piano-sampler'; import { PianoSampler } from './piano-sampler';
type AudioLifecycle = 'idle' | 'started' | 'destroyed'; type AudioLifecycle = 'idle' | 'started' | 'destroyed';
type PianoReleasePhase =
| { kind: 'idle' }
| { kind: 'awaiting-fade' }
| { kind: 'scheduled-fade'; fadeAt: number }
| { kind: 'settling'; stopAt: number };
const muteGain = 0.0001;
const muteRampSeconds = 0.02; const muteRampSeconds = 0.02;
const brushUpPianoBusFadeSeconds = 2.4; const brushUpPianoBusFadeSeconds = 2.4;
const brushUpPianoBusFadeSettleSeconds = 3.2; const brushUpPianoBusFadeSettleSeconds = 3.2;
@ -29,16 +37,16 @@ export class GardenAudio {
private readonly pianoEngine: GenerativePianoEngine; private readonly pianoEngine: GenerativePianoEngine;
private currentVibeId: VibeId | null = null; private currentVibeId: VibeId | null = null;
private currentVibe: VibePreset | null = null;
private lifecycle: AudioLifecycle = 'idle'; private lifecycle: AudioLifecycle = 'idle';
private isReleasingPiano = false; private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
private isMuted = false; private isMuted = false;
private isGestureActive = false; private isGestureActive = false;
private fadePianoAt: number | null = null;
private masterVolume: number; private masterVolume: number;
private stopPianoAt: number | null = null;
private lastEraserAt = Number.NEGATIVE_INFINITY; private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
private startRequestId = 0; private startRequestId = 0;
private hasLoadedPiano = false;
public constructor(private readonly config: GardenAudioConfig) { public constructor(private readonly config: GardenAudioConfig) {
this.masterVolume = clamp01(config.masterVolume); this.masterVolume = clamp01(config.masterVolume);
@ -60,7 +68,8 @@ export class GardenAudio {
if ( if (
this.lifecycle === 'started' && this.lifecycle === 'started' &&
this.currentVibeId === vibe.id && this.currentVibeId === vibe.id &&
this.graph.context?.state === 'running' this.graph.context?.state === 'running' &&
this.hasLoadedPiano
) { ) {
return; return;
} }
@ -74,6 +83,7 @@ export class GardenAudio {
? muteRampSeconds ? muteRampSeconds
: this.config.fadeInSeconds; : this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed'; const needsResume = context.state !== 'running' && context.state !== 'closed';
const startRequestId = ++this.startRequestId;
if (needsResume) { if (needsResume) {
if (!isUserGesture) { if (!isUserGesture) {
@ -83,7 +93,7 @@ export class GardenAudio {
.resume() .resume()
.then(() => { .then(() => {
if (this.graph.context === context && this.lifecycle !== 'destroyed') { if (this.graph.context === context && this.lifecycle !== 'destroyed') {
this.completeStart(vibe, { context, startupRampSeconds }); this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
} }
}) })
.catch((error) => { .catch((error) => {
@ -95,16 +105,18 @@ export class GardenAudio {
return; return;
} }
this.completeStart(vibe, { context, startupRampSeconds }); this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
} }
private completeStart( private completeStart(
vibe: VibePreset, vibe: VibePreset,
{ {
context, context,
startRequestId,
startupRampSeconds, startupRampSeconds,
}: { }: {
context: AudioContext; context: AudioContext;
startRequestId: number;
startupRampSeconds: number; startupRampSeconds: number;
} }
): void { ): void {
@ -113,11 +125,11 @@ export class GardenAudio {
} }
if (this.isMuted) { if (this.isMuted) {
this.graph.setMasterGain(muteGain, muteRampSeconds); this.activateMutedStart(vibe, context);
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
return; return;
} }
const startRequestId = ++this.startRequestId;
void this.piano void this.piano
.load(context) .load(context)
.then(() => { .then(() => {
@ -155,11 +167,25 @@ export class GardenAudio {
): void { ): void {
this.lifecycle = 'started'; this.lifecycle = 'started';
this.currentVibeId = vibe.id; this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(); this.currentVibe = vibe;
const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile(profile.bpm);
this.graph.setMasterGain(this.masterVolume, startupRampSeconds); this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
if (cuePiano) { if (cuePiano) {
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe)); this.hasLoadedPiano = true;
this.pianoEngine.cue(context.currentTime, profile);
}
}
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
this.lifecycle = 'started';
this.currentVibeId = vibe.id;
this.currentVibe = vibe;
this.hasLoadedPiano = false;
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
if (this.graph.context === context) {
this.pianoEngine.reset();
} }
} }
@ -170,6 +196,7 @@ export class GardenAudio {
if (didChangeVibe) { if (didChangeVibe) {
this.piano.stopAll(); this.piano.stopAll();
this.hasLoadedPiano = false;
} }
const context = this.graph.context; const context = this.graph.context;
@ -191,9 +218,13 @@ export class GardenAudio {
this.isMuted = isMuted; this.isMuted = isMuted;
this.graph.setMasterGain( this.graph.setMasterGain(
isMuted ? muteGain : this.masterVolume, isMuted ? SILENT_AUDIO_GAIN : this.masterVolume,
isMuted ? muteRampSeconds : this.config.fadeInSeconds isMuted ? muteRampSeconds : this.config.fadeInSeconds
); );
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
this.start(this.currentVibe);
}
} }
public setMasterVolume(masterVolume: number): void { public setMasterVolume(masterVolume: number): void {
@ -210,9 +241,7 @@ export class GardenAudio {
} }
this.isGestureActive = true; this.isGestureActive = true;
this.isReleasingPiano = false; this.pianoReleasePhase = { kind: 'idle' };
this.fadePianoAt = null;
this.stopPianoAt = null;
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds); this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
this.gestureState.reset(); this.gestureState.reset();
this.energy.beginGesture(context.currentTime); this.energy.beginGesture(context.currentTime);
@ -222,9 +251,7 @@ export class GardenAudio {
public endGesture(): void { public endGesture(): void {
this.gestureState.reset(); this.gestureState.reset();
this.isGestureActive = false; this.isGestureActive = false;
this.isReleasingPiano = true; this.pianoReleasePhase = { kind: 'awaiting-fade' };
this.fadePianoAt = null;
this.stopPianoAt = null;
this.energy.endGesture(); this.energy.endGesture();
this.pianoEngine.endGesture(); this.pianoEngine.endGesture();
} }
@ -243,9 +270,9 @@ export class GardenAudio {
this.energy.silence(); this.energy.silence();
} }
if (!this.isGestureActive && this.isReleasingPiano) { if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
this.updatePianoRelease(snapshot.vibe, context.currentTime); this.updatePianoRelease(snapshot.vibe, context.currentTime);
this.updateDelay(snapshot); this.updateDelay(snapshot, profile);
return; return;
} }
@ -256,7 +283,7 @@ export class GardenAudio {
? this.config.eraser.pianoActivity ? this.config.eraser.pianoActivity
: this.energy.getLevel(), : this.energy.getLevel(),
}); });
this.updateDelay(snapshot); this.updateDelay(snapshot, profile);
} }
public stroke(stroke: GardenAudioStroke): void { public stroke(stroke: GardenAudioStroke): void {
@ -298,14 +325,14 @@ export class GardenAudio {
await this.graph.close(); await this.graph.close();
this.piano.reset(); this.piano.reset();
this.hasLoadedPiano = false;
this.energy.reset(); this.energy.reset();
this.gestureState.reset(); this.gestureState.reset();
this.pianoEngine.reset(); this.pianoEngine.reset();
this.currentVibeId = null; this.currentVibeId = null;
this.currentVibe = null;
this.isGestureActive = false; this.isGestureActive = false;
this.isReleasingPiano = false; this.pianoReleasePhase = { kind: 'idle' };
this.fadePianoAt = null;
this.stopPianoAt = null;
this.lastEraserAt = Number.NEGATIVE_INFINITY; this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY; this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
} }
@ -326,21 +353,41 @@ export class GardenAudio {
} }
private updatePianoRelease(vibe: VibePreset, now: number): void { private updatePianoRelease(vibe: VibePreset, now: number): void {
if (this.fadePianoAt === null && this.stopPianoAt === null) { if (this.pianoReleasePhase.kind === 'awaiting-fade') {
this.fadePianoAt = this.pianoEngine.release(vibe, now); const fadeAt = this.pianoEngine.release(vibe, now);
} if (now < fadeAt) {
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
return;
}
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds); this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.fadePianoAt = null; this.pianoReleasePhase = {
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds; kind: 'settling',
stopAt: now + brushUpPianoBusFadeSettleSeconds,
};
return;
} }
if (this.stopPianoAt !== null && now >= this.stopPianoAt) { if (
this.pianoReleasePhase.kind === 'scheduled-fade' &&
now >= this.pianoReleasePhase.fadeAt
) {
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
this.pianoReleasePhase = {
kind: 'settling',
stopAt: now + brushUpPianoBusFadeSettleSeconds,
};
return;
}
if (
this.pianoReleasePhase.kind === 'settling' &&
now >= this.pianoReleasePhase.stopAt
) {
this.piano.stopAll(); this.piano.stopAll();
this.pianoEngine.reset(); this.pianoEngine.reset();
this.stopPianoAt = null; this.hasLoadedPiano = false;
this.isReleasingPiano = false; this.pianoReleasePhase = { kind: 'idle' };
} }
} }
@ -371,7 +418,10 @@ export class GardenAudio {
} }
} }
private updateDelay(snapshot: GardenAudioSnapshot): void { private updateDelay(
snapshot: GardenAudioSnapshot,
profile: GardenAudioVibeProfile
): void {
const context = this.graph.context; const context = this.graph.context;
if (!context) { if (!context) {
return; return;
@ -380,7 +430,7 @@ export class GardenAudio {
const activity = snapshot.isErasing const activity = snapshot.isErasing
? this.config.delay.erasingActivity ? this.config.delay.erasingActivity
: this.energy.getLevel(); : this.energy.getLevel();
this.graph.updateDelay(activity); this.graph.updateDelay(activity, profile.bpm);
} }
private applyVibe(vibe: VibePreset): void { private applyVibe(vibe: VibePreset): void {
@ -389,8 +439,10 @@ export class GardenAudio {
} }
this.currentVibeId = vibe.id; this.currentVibeId = vibe.id;
this.currentVibe = vibe;
const profile = getVibeProfile(vibe); const profile = getVibeProfile(vibe);
this.graph.applyDelayProfile(); this.graph.applyDelayProfile(profile.bpm);
this.pianoEngine.cue(this.graph.context.currentTime, profile); this.pianoEngine.cue(this.graph.context.currentTime, profile);
this.hasLoadedPiano = true;
} }
} }

View file

@ -152,6 +152,12 @@ interface GenerativePianoTuning {
min: number; min: number;
max: number; max: number;
}; };
stereoWidth: {
idle: number;
active: number;
intense: number;
intenseThreshold: number;
};
stylePanOffsetScale: number; stylePanOffsetScale: number;
lowpass: { lowpass: {
midiBase: number; midiBase: number;
@ -371,6 +377,12 @@ export const generativePianoTuning: GenerativePianoTuning = {
min: -3, min: -3,
max: 3, max: 3,
}, },
stereoWidth: {
idle: 0.46,
active: 0.9,
intense: 1.16,
intenseThreshold: 0.72,
},
stylePanOffsetScale: 0.35, stylePanOffsetScale: 0.35,
lowpass: { lowpass: {
midiBase: 48, midiBase: 48,

View file

@ -17,24 +17,37 @@ import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3; const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
const GENERATIVE_START_DELAY_SECONDS = 0.02; const GENERATIVE_START_DELAY_SECONDS = 0.02;
const TEXTURE_ONSET_EXPRESSION = 0.15;
const SUPPORT_ONSET_EXPRESSION = 0.4;
const chordVoicings = { const chordVoicings: Record<
majorOpen: [0, 7, 12, 16], GardenAudioChord['quality'],
minorOpen: [0, 7, 12, 15], { closed: Array<number>; open: Array<number> }
majorClosed: [0, 4, 7, 12, 16], > = {
minorClosed: [0, 3, 7, 12, 15], major: {
closed: [0, 4, 7, 12, 16],
open: [0, 7, 12, 16],
},
minor: {
closed: [0, 3, 7, 12, 15],
open: [0, 7, 12, 15],
},
sus2: {
closed: [0, 2, 7, 12, 14],
open: [0, 7, 12, 14],
},
sus4: {
closed: [0, 5, 7, 12, 17],
open: [0, 7, 12, 17],
},
}; };
const getChordIntervals = ( const getChordIntervals = (
chord: GardenAudioChord, chord: GardenAudioChord,
openVoicing: boolean openVoicing: boolean
): Array<number> => { ): Array<number> => {
if (openVoicing) { const voicing = chordVoicings[chord.quality];
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen; return openVoicing ? voicing.open : voicing.closed;
}
return chord.quality === 'major'
? chordVoicings.majorClosed
: chordVoicings.minorClosed;
}; };
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => { const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
@ -406,7 +419,7 @@ export class GenerativePianoEngine {
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight, velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
startTime, startTime,
durationSeconds, durationSeconds,
pan: register.pan, pan: this.getActivityPan(register.pan, expression),
role: 'pad', role: 'pad',
delaySend: generativePianoTuning.padChord.delaySend, delaySend: generativePianoTuning.padChord.delaySend,
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(
@ -446,7 +459,7 @@ export class GenerativePianoEngine {
velocity: release.velocities[index], velocity: release.velocities[index],
startTime: startTime + index * release.strumSeconds, startTime: startTime + index * release.strumSeconds,
durationSeconds: release.durationSeconds, durationSeconds: release.durationSeconds,
pan: register.pan, pan: this.getActivityPan(register.pan, 0),
role: 'pad', role: 'pad',
delaySend: release.delaySend, delaySend: release.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression), lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
@ -487,7 +500,7 @@ export class GenerativePianoEngine {
durationSeconds: durationSeconds:
generativePianoTuning.supportNote.durationBaseSeconds + generativePianoTuning.supportNote.durationBaseSeconds +
expression * generativePianoTuning.supportNote.durationExpressionSeconds, expression * generativePianoTuning.supportNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex, expression),
role: 'support', role: 'support',
delaySend: delaySend:
generativePianoTuning.supportNote.delaySendBase + generativePianoTuning.supportNote.delaySendBase +
@ -533,7 +546,7 @@ export class GenerativePianoEngine {
durationSeconds: durationSeconds:
generativePianoTuning.textureNote.durationBaseSeconds + generativePianoTuning.textureNote.durationBaseSeconds +
expression * generativePianoTuning.textureNote.durationExpressionSeconds, expression * generativePianoTuning.textureNote.durationExpressionSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex, expression),
role: 'texture', role: 'texture',
delaySend: delaySend:
generativePianoTuning.textureNote.delaySendBase + generativePianoTuning.textureNote.delaySendBase +
@ -582,7 +595,7 @@ export class GenerativePianoEngine {
durationSeconds: durationSeconds:
generativePianoTuning.gestureAccent.durationBaseSeconds + generativePianoTuning.gestureAccent.durationBaseSeconds +
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds, strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex, strength),
role: 'gesture', role: 'gesture',
delaySend: generativePianoTuning.gestureAccent.delaySend, delaySend: generativePianoTuning.gestureAccent.delaySend,
lowpassHz: this.getLowpassHz(profile, midi, strength), lowpassHz: this.getLowpassHz(profile, midi, strength),
@ -627,7 +640,7 @@ export class GenerativePianoEngine {
durationSeconds: durationSeconds:
generativePianoTuning.touchNote.durationBaseSeconds + generativePianoTuning.touchNote.durationBaseSeconds +
strength * generativePianoTuning.touchNote.durationStrengthSeconds, strength * generativePianoTuning.touchNote.durationStrengthSeconds,
pan: this.getStylePan(styleIndex), pan: this.getStylePan(styleIndex, strength),
role: 'gesture', role: 'gesture',
delaySend: generativePianoTuning.touchNote.delaySend, delaySend: generativePianoTuning.touchNote.delaySend,
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(
@ -813,7 +826,7 @@ export class GenerativePianoEngine {
chordOffsets: this.getChordOffsets(chord, chordIntervals), chordOffsets: this.getChordOffsets(chord, chordIntervals),
}; };
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getStylePan(styleIndex); const pan = this.getStylePan(styleIndex, intensity);
const durationSeconds = clamp( const durationSeconds = clamp(
generativePianoTuning.brushStream.durationBaseSeconds + generativePianoTuning.brushStream.durationBaseSeconds +
intensity * generativePianoTuning.brushStream.durationIntensitySeconds - intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
@ -1076,6 +1089,9 @@ export class GenerativePianoEngine {
} }
private shouldPlaySupport(expression: number, barIndex: number): boolean { private shouldPlaySupport(expression: number, barIndex: number): boolean {
if (expression < SUPPORT_ONSET_EXPRESSION) {
return false;
}
if (expression >= generativePianoTuning.supportNote.expressionThreshold) { if (expression >= generativePianoTuning.supportNote.expressionThreshold) {
return true; return true;
} }
@ -1087,19 +1103,17 @@ export class GenerativePianoEngine {
} }
private shouldPlayTexture(expression: number, barIndex: number): boolean { private shouldPlayTexture(expression: number, barIndex: number): boolean {
if (expression < TEXTURE_ONSET_EXPRESSION) {
return false;
}
if (expression >= generativePianoTuning.textureNote.mediumExpressionThreshold) {
return barIndex % generativePianoTuning.textureNote.intenseSpacing === 0;
}
const spacing = const spacing =
expression < generativePianoTuning.textureNote.idleExpressionThreshold expression < generativePianoTuning.textureNote.idleExpressionThreshold
? generativePianoTuning.idleTextureBarSpacing ? generativePianoTuning.idleTextureBarSpacing
: expression < generativePianoTuning.textureNote.mediumExpressionThreshold : generativePianoTuning.mediumTextureBarSpacing;
? generativePianoTuning.mediumTextureBarSpacing return barIndex % spacing === generativePianoTuning.textureNote.idlePhase % spacing;
: generativePianoTuning.textureNote.intenseSpacing;
return (
barIndex % spacing ===
(spacing === generativePianoTuning.textureNote.intenseSpacing
? 0
: generativePianoTuning.textureNote.idlePhase)
);
} }
private getSupportOffsets( private getSupportOffsets(
@ -1128,16 +1142,29 @@ export class GenerativePianoEngine {
styleCount) as GardenAudioStyleIndex; styleCount) as GardenAudioStyleIndex;
} }
private getStylePan(styleIndex: GardenAudioStyleIndex): number { private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number {
const pool = generativePianoTuning.stylePools[styleIndex]; const pool = generativePianoTuning.stylePools[styleIndex];
const styleVoice = styleVoices[styleIndex]; const styleVoice = styleVoices[styleIndex];
return clamp( return this.getActivityPan(
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale, pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
-1, activity
1
); );
} }
private getActivityPan(pan: number, activity: number): number {
const { active, idle, intense, intenseThreshold } = generativePianoTuning.stereoWidth;
const normalizedActivity = clamp01(activity);
const safeThreshold = clamp(intenseThreshold, 0.001, 0.999);
const width =
normalizedActivity < safeThreshold
? idle + ((active - idle) * normalizedActivity) / safeThreshold
: active +
((intense - active) * (normalizedActivity - safeThreshold)) /
(1 - safeThreshold);
return clamp(pan * width, -1, 1);
}
private getLowpassHz( private getLowpassHz(
profile: GardenAudioVibeProfile, profile: GardenAudioVibeProfile,
midi: number, midi: number,

View file

@ -8,8 +8,8 @@ const noiseBurstTuning = {
offsetRandomSeconds: 0.4, offsetRandomSeconds: 0.4,
scheduleAheadSeconds: 0.002, scheduleAheadSeconds: 0.002,
silentGain: 0.0001, silentGain: 0.0001,
filterType: 'bandpass' as BiquadFilterType, filterType: 'bandpass',
}; } as const;
export class NoiseBurstPlayer { export class NoiseBurstPlayer {
public constructor(private readonly graph: GardenAudioGraph) {} public constructor(private readonly graph: GardenAudioGraph) {}
@ -45,7 +45,10 @@ export class NoiseBurstPlayer {
filter.connect(envelope); filter.connect(envelope);
envelope.connect(panner); envelope.connect(panner);
panner.connect(noiseBus); panner.connect(noiseBus);
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds); const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds);
const offsetSeconds =
Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds);
source.start(scheduledStart, offsetSeconds);
source.stop(stopAt); source.stop(stopAt);
source.addEventListener( source.addEventListener(
'ended', 'ended',

View file

@ -14,15 +14,16 @@ interface ActivePianoVoice {
} }
const pianoSamplerTuning = { const pianoSamplerTuning = {
filterType: 'lowpass' as BiquadFilterType, filterType: 'lowpass',
filterQ: 0.7, filterQ: 0.7,
minDurationSeconds: 0.08, minDurationSeconds: 0.08,
minFadeSeconds: 0.08, minFadeSeconds: 0.08,
minGain: 0.0001, minGain: 0.0001,
releaseTimeConstantCount: 5,
tailStopExtraSeconds: 0.05, tailStopExtraSeconds: 0.05,
voiceStealFadeSeconds: 0.025, voiceStealFadeSeconds: 0.025,
voiceStealStopSeconds: 0.05, voiceStealStopSeconds: 0.05,
}; } as const;
export class PianoSampler { export class PianoSampler {
private samples: Array<LoadedPianoSample> = []; private samples: Array<LoadedPianoSample> = [];
@ -84,7 +85,9 @@ export class PianoSampler {
const sustainAt = const sustainAt =
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds); scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
const releaseAt = sustainAt + sustainSeconds; const releaseAt = sustainAt + sustainSeconds;
const stopAt = releaseAt + this.config.piano.releaseSeconds; const stopAt =
releaseAt +
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
const source = context.createBufferSource(); const source = context.createBufferSource();
source.buffer = sample.buffer; source.buffer = sample.buffer;
@ -178,7 +181,11 @@ export class PianoSampler {
this.trimActiveVoices(scheduledStart); this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) { while (this.activeVoices.length >= this.config.piano.maxVoices) {
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart); const oldest = this.activeVoices.shift();
if (!oldest) {
break;
}
this.stopVoice(oldest, scheduledStart);
} }
filter.type = pianoSamplerTuning.filterType; filter.type = pianoSamplerTuning.filterType;
@ -220,11 +227,8 @@ export class PianoSampler {
); );
} }
private computeNoteGain(velocity: number, scale = 1): number { private computeNoteGain(velocity: number): number {
return Math.max( return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
pianoSamplerTuning.minGain,
this.config.piano.gain * velocity * scale
);
} }
private findNearestSample(midi: number): LoadedPianoSample | null { private findNearestSample(midi: number): LoadedPianoSample | null {

View file

@ -31,51 +31,56 @@ import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline'; import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
interface PianoSampleDefinition { interface PianoSampleDefinition {
midi: number; note: string;
path: string;
url: string; url: string;
} }
export interface PianoSampleLoadProgress { export interface PianoSampleLoadProgress {
failedCount: number;
loadedCount: number; loadedCount: number;
settledCount: number;
totalCount: number; totalCount: number;
} }
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [ const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
{ url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 }, { url: a0SampleUrl, note: 'A0' },
{ url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 }, { url: c1SampleUrl, note: 'C1' },
{ url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 }, { url: dSharp1SampleUrl, note: 'Dsharp1' },
{ url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 }, { url: fSharp1SampleUrl, note: 'Fsharp1' },
{ url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 }, { url: a1SampleUrl, note: 'A1' },
{ url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 }, { url: c2SampleUrl, note: 'C2' },
{ url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 }, { url: dSharp2SampleUrl, note: 'Dsharp2' },
{ url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 }, { url: fSharp2SampleUrl, note: 'Fsharp2' },
{ url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 }, { url: a2SampleUrl, note: 'A2' },
{ url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 }, { url: c3SampleUrl, note: 'C3' },
{ url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 }, { url: dSharp3SampleUrl, note: 'Dsharp3' },
{ url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 }, { url: fSharp3SampleUrl, note: 'Fsharp3' },
{ url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 }, { url: a3SampleUrl, note: 'A3' },
{ url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 }, { url: c4SampleUrl, note: 'C4' },
{ url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 }, { url: dSharp4SampleUrl, note: 'Dsharp4' },
{ url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 }, { url: fSharp4SampleUrl, note: 'Fsharp4' },
{ url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 }, { url: a4SampleUrl, note: 'A4' },
{ url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 }, { url: c5SampleUrl, note: 'C5' },
{ url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 }, { url: dSharp5SampleUrl, note: 'Dsharp5' },
{ url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 }, { url: fSharp5SampleUrl, note: 'Fsharp5' },
{ url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 }, { url: a5SampleUrl, note: 'A5' },
{ url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 }, { url: c6SampleUrl, note: 'C6' },
{ url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 }, { url: dSharp6SampleUrl, note: 'Dsharp6' },
{ url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 }, { url: fSharp6SampleUrl, note: 'Fsharp6' },
{ url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 }, { url: a6SampleUrl, note: 'A6' },
{ url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 }, { url: c7SampleUrl, note: 'C7' },
{ url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 }, { url: dSharp7SampleUrl, note: 'Dsharp7' },
{ url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 }, { url: fSharp7SampleUrl, note: 'Fsharp7' },
{ url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 }, { url: a7SampleUrl, note: 'A7' },
{ url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 }, { url: c8SampleUrl, note: 'C8' },
]; ];
let loadedPianoSamples: Array<LoadedPianoSample> | null = null; let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null; let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
const pianoSampleProgressListeners = new Set<
(progress: PianoSampleLoadProgress) => void
>();
const sampleLoadTuning = { const sampleLoadTuning = {
concurrency: 4, concurrency: 4,
@ -102,50 +107,65 @@ export const loadPianoSamples = (
decodeContext: BaseAudioContext, decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => { ): Promise<Array<LoadedPianoSample>> => {
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
if (loadedPianoSamples) { if (loadedPianoSamples) {
onProgress?.({ emitPianoSampleProgress({
failedCount: 0,
loadedCount: loadedPianoSamples.length, loadedCount: loadedPianoSamples.length,
settledCount: loadedPianoSamples.length,
totalCount: pianoSampleDefinitions.length, totalCount: pianoSampleDefinitions.length,
}); });
unsubscribeProgress();
return Promise.resolve([...loadedPianoSamples]); return Promise.resolve([...loadedPianoSamples]);
} }
if (pianoSampleLoadPromise) { if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise; return pianoSampleLoadPromise.finally(unsubscribeProgress);
} }
let loadedCount = 0; let loadedCount = 0;
let failedCount = 0;
let settledCount = 0;
const totalCount = pianoSampleDefinitions.length; const totalCount = pianoSampleDefinitions.length;
onProgress?.({ loadedCount, totalCount }); emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
pianoSampleLoadPromise = loadPianoSampleBatch( pianoSampleLoadPromise = loadPianoSampleBatch(
pianoSampleDefinitions, pianoSampleDefinitions,
async (sample) => { async (sample) => {
try { try {
return await withTimeout( const loadedSample = await withTimeout(
(signal) => loadPianoSample(decodeContext, sample, signal), (signal) => loadPianoSample(decodeContext, sample, signal),
sampleLoadTuning.sampleTimeoutMs sampleLoadTuning.sampleTimeoutMs
); );
} finally {
loadedCount += 1; loadedCount += 1;
onProgress?.({ loadedCount, totalCount }); return loadedSample;
} catch (error) {
failedCount += 1;
throw error;
} finally {
settledCount += 1;
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
} }
} }
).then( )
(samples) => { .then(
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi); (samples) => {
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) { loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
throw new Error( if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.` throw new Error(
); `Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
);
}
return [...loadedPianoSamples];
},
(error: unknown) => {
pianoSampleLoadPromise = null;
pianoSampleProgressListeners.clear();
throw error;
} }
return [...loadedPianoSamples]; )
}, .finally(unsubscribeProgress);
(error: unknown) => {
pianoSampleLoadPromise = null;
throw error;
}
);
return pianoSampleLoadPromise; return pianoSampleLoadPromise;
}; };
@ -160,12 +180,12 @@ const loadPianoSample = async (
): Promise<LoadedPianoSample> => { ): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url, { signal }); const response = await fetch(sample.url, { signal });
if (!response.ok) { if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.path}`); throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
} }
const audioData = await response.arrayBuffer(); const audioData = await response.arrayBuffer();
const buffer = await decodeContext.decodeAudioData(audioData); const buffer = await decodeContext.decodeAudioData(audioData);
return { midi: sample.midi, buffer }; return { midi: getMidiForPianoSample(sample), buffer };
}; };
const loadPianoSampleBatch = async ( const loadPianoSampleBatch = async (
@ -205,3 +225,47 @@ const withTimeout = <T>(
} }
); );
}); });
const subscribeToPianoSampleProgress = (
onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined
): (() => void) => {
if (!onProgress) {
return () => undefined;
}
pianoSampleProgressListeners.add(onProgress);
if (lastPianoSampleProgress) {
onProgress(lastPianoSampleProgress);
}
return () => {
pianoSampleProgressListeners.delete(onProgress);
};
};
const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
lastPianoSampleProgress = progress;
pianoSampleProgressListeners.forEach((listener) => listener(progress));
};
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
`./samples/${sample.note}v12.m4a`;
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
if (!match?.groups) {
throw new Error(`Invalid piano sample note ${sample.note}`);
}
const semitoneByName: Record<string, number> = {
C: 0,
D: 2,
E: 4,
F: 5,
G: 7,
A: 9,
B: 11,
};
const octave = Number(match.groups.octave);
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
return (octave + 1) * 12 + semitone;
};

View file

@ -5,3 +5,12 @@ under CC BY 3.0.
Source package: @audio-samples/piano-velocity12 Source package: @audio-samples/piano-velocity12
Source recording: https://archive.org/details/SalamanderGrandPianoV3 Source recording: https://archive.org/details/SalamanderGrandPianoV3
License: https://creativecommons.org/licenses/by/3.0/ License: https://creativecommons.org/licenses/by/3.0/
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
The app derives MIDI values from those note names in `piano-samples.ts`.
Repro notes: start from the matching `v12` OGG files in the source package and
transcode each selected sample to AAC/M4A without renaming the note/velocity
stem. The expected output filenames are `<note>v12.m4a`, for example
`C4v12.m4a`.

View file

@ -1,9 +1,11 @@
import { createGardenAudioConfig } from './audio/garden-audio-config'; import {
createGardenAudioConfig,
DEFAULT_AUDIO_VOLUME,
} from './audio/garden-audio-config';
import { defaultSettings } from './config/default-settings'; import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls'; import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types'; import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets'; import { defaultVibeId, vibePresets } from './config/vibe-presets';
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
export { export {
normalizeNumberControlValue, normalizeNumberControlValue,
@ -18,6 +20,12 @@ export type {
export const appConfig = { export const appConfig = {
audio: createGardenAudioConfig(), audio: createGardenAudioConfig(),
analytics: {
autoCapturePageviews: true,
domain: 'fleeting.garden',
endpoint: 'https://stats.schmelczer.dev/status',
logging: import.meta.env.DEV,
},
deltaTime: { deltaTime: {
maxDeltaTimeSeconds: 1 / 30, maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240, minDeltaTimeSeconds: 1 / 240,
@ -84,13 +92,14 @@ export const appConfig = {
letterSpacingEm: 0.07, letterSpacingEm: 0.07,
maskAlphaThreshold: 32, maskAlphaThreshold: 32,
maskGradientThreshold: 8, maskGradientThreshold: 8,
maskMaxPixels: 1_000_000,
maskSampleDensity: 540, maskSampleDensity: 540,
maxHeightRatio: 0.25, maxHeightRatio: 0.25,
maxWidthRatio: 0.76, maxWidthRatio: 0.76,
minEntryJitterPx: 6, minEntryJitterPx: 6,
minFontSizePx: 18, minFontSizePx: 18,
minTargetJitterPx: 1, minTargetJitterPx: 1,
pathEasing: 'easeOutQuad' as GardenAppConfig['simulation']['intro']['pathEasing'], pathEasing: 'easeOutQuad',
pathProgressEpsilon: 0.001, pathProgressEpsilon: 0.001,
radialJitterRatio: 0.35, radialJitterRatio: 0.35,
radialStartEpsilon: 0.001, radialStartEpsilon: 0.001,
@ -107,29 +116,28 @@ export const appConfig = {
titleStrokeWidthRatio: 0.11, titleStrokeWidthRatio: 0.11,
verticalAnchor: 0.47, verticalAnchor: 0.47,
}, },
introMoveSpeedBaseMultiplier: 1.8, introMoveSpeed: 280,
introMoveSpeedProgressMultiplier: 0.35,
stroke: { stroke: {
densityMultiplier: 110, densityMultiplier: 110,
maxAgentCount: 2_400, maxAgentCount: 2_400,
}, },
}, },
storage: { storage: {
audioMutedKey: APP_STORAGE_KEYS.audioMuted, audioMutedKey: 'fleeting-garden:audio-muted',
audioVolumeKey: APP_STORAGE_KEYS.audioVolume, audioVolumeKey: 'fleeting-garden:audio-volume',
vibeKey: APP_STORAGE_KEYS.vibe, vibeKey: 'fleeting-garden:vibe',
}, },
toolbar: { toolbar: {
eraser: { eraser: {
controlScaleMax: 1.34, controlScaleMax: 1.34,
controlScaleMin: 0.74, controlScaleMin: 0.74,
default: 96, default: 96,
max: 240, max: 480,
min: 24, min: 24,
step: 1, step: 1,
}, },
mirror: { mirror: {
default: 1, default: 8,
fallbackSegmentName: 'slices', fallbackSegmentName: 'slices',
max: 12, max: 12,
min: 1, min: 1,

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
getBrushRenderQualityScale,
getRenderQualityBrushSize,
} from './brush-size';
describe('render-quality brush sizing', () => {
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
expect(
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
).toBe(21);
});
it('scales linear brush size with the square root of render area', () => {
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
});
it('falls back to baseline scaling for invalid render areas', () => {
expect(getBrushRenderQualityScale(0)).toBe(1);
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
});
});

19
src/config/brush-size.ts Normal file
View file

@ -0,0 +1,19 @@
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
? renderAreaMegapixels
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
Math.sqrt(
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
);
export const getRenderQualityBrushSize = (
brushSize: number,
renderAreaMegapixels: number
): number =>
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
getBrushRenderQualityScale(renderAreaMegapixels);

View file

@ -1,17 +1,5 @@
import type { NumberControlConfig } from './types'; import type { NumberControlConfig } from './types';
export const colorInteractionSettings = {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 0,
color2ToColor1: 0,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: 0,
color3ToColor2: 0,
color3ToColor3: 1,
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({ export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions', folder: 'Color Reactions',
label, label,

View file

@ -1,15 +1,10 @@
import { colorInteractionSettings } from './color-interactions'; import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
import { runtimeControls } from './runtime-controls';
import type { GardenAppConfig } from './types'; import type { GardenAppConfig } from './types';
// Mirrors the historical render-scale cap so the default render area stays // Mirrors the historical render-scale cap so the default render area stays
// roughly equivalent to native rendering on high-DPR phones without the // roughly equivalent to native rendering on high-DPR phones without the
// pipeline applying its own clamp. The slider can override freely. // pipeline applying its own clamp. The slider can override freely.
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2; const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
const INTERNAL_RENDER_AREA_BOUNDS = {
min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5,
max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6,
};
const computeDefaultInternalRenderAreaMegapixels = (): number => { const computeDefaultInternalRenderAreaMegapixels = (): number => {
const rawDpr = const rawDpr =
@ -21,18 +16,14 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080; const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000; const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
return Math.min( return Math.min(
INTERNAL_RENDER_AREA_BOUNDS.max, INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels) Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
); );
}; };
export const defaultSettings: GardenAppConfig['defaultSettings'] = { export const defaultSettings: GardenAppConfig['defaultSettings'] = {
...colorInteractionSettings,
selectedColorIndex: 0, selectedColorIndex: 0,
turnWhenLost: 0.8,
forwardRotationScale: 0.25,
sensorOffsetAngle: 32,
introNearDistanceMin: 28, introNearDistanceMin: 28,
introNearDistanceInner: 4, introNearDistanceInner: 4,
introNearSensorOffsetMultiplier: 0.75, introNearSensorOffsetMultiplier: 0.75,
@ -40,8 +31,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
introProgressCutoff: 0.999, introProgressCutoff: 0.999,
introTurnRateMultiplier: 3.4, introTurnRateMultiplier: 3.4,
introRandomTurnMultiplier: 0.18, introRandomTurnMultiplier: 0.18,
introFarMoveMultiplier: 2.65,
introNearMoveMultiplier: 0.01,
introStepStopDistance: 0.5, introStepStopDistance: 0.5,
randomTimeScale: 0.34816, randomTimeScale: 0.34816,
@ -58,7 +47,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
brushCurveMirrorResolutionExponent: 0.5, brushCurveMirrorResolutionExponent: 0.5,
brushCurveSegmentBrushRadiusRatio: 0.65, brushCurveSegmentBrushRadiusRatio: 0.65,
brushSmoothingMinSampleDistance: 0.5, brushSmoothingMinSampleDistance: 0.5,
strokeAngleJitterRadians: Math.PI * 0.7,
brushAlpha: 1, brushAlpha: 1,
brushDiscardThreshold: 0.02, brushDiscardThreshold: 0.02,
@ -78,7 +66,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
adaptiveCapInitial: 1_000_000, adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 50_000, adaptiveCapMin: 50_000,
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(), internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
maxAgentCount: 700_000, maxAgentCount: 1_500_000,
renderTraceNormalizationFloor: 1, renderTraceNormalizationFloor: 1,
renderBrushColorBase: 1.2, renderBrushColorBase: 1.2,

View file

@ -1,9 +1,20 @@
import { colorInteractionControl } from './color-interactions'; import { colorInteractionControl } from './color-interactions';
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
import type { GardenAppConfig } from './types'; import type { GardenAppConfig } from './types';
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`; const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
const formatRadiansAsDegrees = (value: number): string => const formatRadiansAsDegrees = (value: number): string =>
`${Math.round((value * 180) / Math.PI)} deg`; `${Math.round((value * 180) / Math.PI)} deg`;
const formatCompactNumber = (value: number): string => {
if (value >= 1_000_000) {
const millions = value / 1_000_000;
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return `${value}`;
};
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'), color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
@ -20,14 +31,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
folder: 'Brush', folder: 'Brush',
label: 'Brush Size', label: 'Brush Size',
min: 1, min: 1,
max: 60, max: 36,
step: 0.25, step: 0.25,
}, },
spawnPerPixel: { spawnPerPixel: {
folder: 'Brush', folder: 'Brush',
label: 'Density', label: 'Density',
min: 0.01, min: 0.01,
max: 1, max: 0.38,
step: 0.001, step: 0.001,
}, },
strokeAngleJitterRadians: { strokeAngleJitterRadians: {
@ -35,7 +46,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
format: formatRadiansAsDegrees, format: formatRadiansAsDegrees,
label: 'Spawn Spread', label: 'Spawn Spread',
min: 0, min: 0,
max: Math.PI * 2, max: Math.PI,
step: 0.01, step: 0.01,
}, },
sensorOffsetDistance: { sensorOffsetDistance: {
@ -45,6 +56,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 200, max: 200,
step: 1, step: 1,
}, },
sensorOffsetAngle: {
folder: 'Movement',
label: 'Sensor Angle',
min: 0,
max: 180,
step: 1,
},
moveSpeed: { moveSpeed: {
folder: 'Movement', folder: 'Movement',
label: 'Travel Speed', label: 'Travel Speed',
@ -71,7 +89,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
folder: 'Movement', folder: 'Movement',
label: 'Wander Turn', label: 'Wander Turn',
min: 0, min: 0,
max: 6.28, max: Math.PI * 2,
step: 0.01, step: 0.01,
}, },
individualTrailWeight: { individualTrailWeight: {
@ -81,6 +99,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
diffusionRateTrails: {
folder: 'Movement',
label: 'Diffusion Rate',
min: 0.01,
max: 1,
step: 0.01,
},
decayRateTrails: { decayRateTrails: {
folder: 'Movement', folder: 'Movement',
label: 'Trail Fade', label: 'Trail Fade',
@ -106,6 +131,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
maxAgentCount: { maxAgentCount: {
folder: 'Performance', folder: 'Performance',
format: formatCompactNumber,
integer: true, integer: true,
label: 'Population Limit', label: 'Population Limit',
min: 0, min: 0,
@ -114,8 +140,8 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
internalRenderAreaMegapixels: { internalRenderAreaMegapixels: {
folder: 'Performance', folder: 'Performance',
label: 'Render Quality (MP)', label: 'Render Quality (MP)',
min: 0.5, min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
max: 16.6, max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
step: 0.1, step: 0.1,
}, },
}; };

View file

@ -52,17 +52,30 @@ type RuntimeSettingControlConfig = Partial<
Record<keyof GardenRuntimeSettings, NumberControlConfig> Record<keyof GardenRuntimeSettings, NumberControlConfig>
>; >;
type GardenVibeSettings = Pick< export type GardenVibeSettings = Pick<
GardenRuntimeSettings, GardenRuntimeSettings,
| 'backgroundGrainStrength' | 'backgroundGrainStrength'
| 'brushSize' | 'brushSize'
| 'clarity' | 'clarity'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'decayRateTrails' | 'decayRateTrails'
| 'forwardRotationScale'
| 'individualTrailWeight' | 'individualTrailWeight'
| 'moveSpeed' | 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance' | 'sensorOffsetDistance'
| 'spawnPerPixel' | 'spawnPerPixel'
| 'strokeAngleJitterRadians'
| 'turnSpeed' | 'turnSpeed'
| 'turnWhenLost'
>; >;
type GardenDefaultSettings = Omit< type GardenDefaultSettings = Omit<
@ -72,10 +85,8 @@ type GardenDefaultSettings = Omit<
export enum VibeId { export enum VibeId {
AuroraMycelium = 'aurora-mycelium', AuroraMycelium = 'aurora-mycelium',
EmberCircuit = 'ember-circuit',
VelvetObservatory = 'velvet-observatory', VelvetObservatory = 'velvet-observatory',
LichenSignal = 'lichen-signal', LichenSignal = 'lichen-signal',
UltravioletSiren = 'ultraviolet-siren',
TidepoolLantern = 'tidepool-lantern', TidepoolLantern = 'tidepool-lantern',
PaperLanternFog = 'paper-lantern-fog', PaperLanternFog = 'paper-lantern-fog',
ChromePollen = 'chrome-pollen', ChromePollen = 'chrome-pollen',
@ -92,6 +103,12 @@ export interface VibePreset {
export interface GardenAppConfig { export interface GardenAppConfig {
audio: GardenAudioConfig; audio: GardenAudioConfig;
analytics: {
autoCapturePageviews: boolean;
domain: string;
endpoint: string;
logging: boolean;
};
deltaTime: { deltaTime: {
maxDeltaTimeSeconds: number; maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number; minDeltaTimeSeconds: number;
@ -156,6 +173,7 @@ export interface GardenAppConfig {
letterSpacingEm: number; letterSpacingEm: number;
maskAlphaThreshold: number; maskAlphaThreshold: number;
maskGradientThreshold: number; maskGradientThreshold: number;
maskMaxPixels: number;
maskSampleDensity: number; maskSampleDensity: number;
maxHeightRatio: number; maxHeightRatio: number;
maxWidthRatio: number; maxWidthRatio: number;
@ -179,8 +197,7 @@ export interface GardenAppConfig {
titleStrokeWidthRatio: number; titleStrokeWidthRatio: number;
verticalAnchor: number; verticalAnchor: number;
}; };
introMoveSpeedBaseMultiplier: number; introMoveSpeed: number;
introMoveSpeedProgressMultiplier: number;
stroke: { stroke: {
densityMultiplier: number; densityMultiplier: number;
maxAgentCount: number; maxAgentCount: number;

View file

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { runtimeControls } from './runtime-controls';
import { vibePresets } from './vibe-presets';
const FINAL_VIBE_NAMES = [
'Aurora Mycelium Copy',
'Velvet Observatory Copy',
'Lichen Signal',
'Tidepool Lantern',
'Paper Lantern Fog',
'Chrome Pollen',
];
const BLENDED_BRUSH_SIZE_MIN = 17;
const BLENDED_CLARITY_MAX = 0.56;
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
// Performance guardrails — bumping any of these is an explicit perf trade-off.
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
const HIGH_DENSITY_DECAY_LIMIT = 940;
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
describe('vibePresets', () => {
it('keeps the classic preset set distinct', () => {
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
const ids = vibePresets.map((preset) => preset.id);
expect(new Set(ids).size).toBe(vibePresets.length);
});
it('includes both blended and visibly particulate styles', () => {
const blendedNames = vibePresets
.filter(
(preset) =>
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
preset.settings.clarity <= BLENDED_CLARITY_MAX
)
.map((preset) => preset.name);
const softParticleNames = vibePresets
.filter(
(preset) =>
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
)
.map((preset) => preset.name);
expect(blendedNames).toEqual(['Tidepool Lantern']);
expect(softParticleNames).toEqual(['Chrome Pollen']);
});
it('stays inside interactive performance guardrails', () => {
const violations = vibePresets.flatMap((preset) => {
const { name, settings } = preset;
const presetViolations: Array<string> = [];
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
}
if (settings.brushSize > MAX_BRUSH_SIZE) {
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
}
if (
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
) {
presetViolations.push(`${name} combines high density with too much persistence`);
}
return presetViolations;
});
expect(violations).toEqual([]);
});
});

View file

@ -1,162 +1,252 @@
import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config'; import {
import { VibeId, type VibePreset } from './types'; defaultGardenAudioVibeSettings,
type GardenAudioChord,
} from '../audio/garden-audio-config';
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
type ColorReactionSettings = Pick<
GardenVibeSettings,
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
>;
const colorReactions = {
auroraMycelium: {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 0,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: -1,
color3ToColor2: -1,
color3ToColor3: 1,
},
velvetObservatory: {
color1ToColor1: 1,
color1ToColor2: -1,
color1ToColor3: -1,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: -1,
color3ToColor1: -1,
color3ToColor2: -1,
color3ToColor3: 1,
},
lichenSignal: {
color1ToColor1: 0,
color1ToColor2: -1,
color1ToColor3: 1,
color2ToColor1: -1,
color2ToColor2: 0,
color2ToColor3: -1,
color3ToColor1: 1,
color3ToColor2: -1,
color3ToColor3: 1,
},
tidepoolLantern: {
color1ToColor1: 0,
color1ToColor2: 1,
color1ToColor3: 0,
color2ToColor1: 0,
color2ToColor2: 0,
color2ToColor3: 1,
color3ToColor1: 1,
color3ToColor2: 0,
color3ToColor3: 0,
},
paperLanternFog: {
color1ToColor1: 1,
color1ToColor2: 1,
color1ToColor3: 1,
color2ToColor1: 1,
color2ToColor2: 1,
color2ToColor3: 1,
color3ToColor1: 1,
color3ToColor2: 1,
color3ToColor3: 1,
},
chromePollen: {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 1,
color2ToColor1: -1,
color2ToColor2: 1,
color2ToColor3: 0,
color3ToColor1: 1,
color3ToColor2: 0,
color3ToColor3: 1,
},
} satisfies Record<string, ColorReactionSettings>;
const musicScales = {
dorian: [0, 2, 3, 5, 7, 9, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
} satisfies Record<string, Array<number>>;
const musicProgressions = {
aurora: [
{ rootOffset: 0, quality: 'sus2' },
{ rootOffset: 7, quality: 'major' },
{ rootOffset: 9, quality: 'minor' },
{ rootOffset: 5, quality: 'sus4' },
],
chrome: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 2, quality: 'major' },
{ rootOffset: 7, quality: 'sus2' },
{ rootOffset: 9, quality: 'minor' },
],
lichen: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 5, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
],
paperLantern: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 5, quality: 'minor' },
{ rootOffset: 10, quality: 'sus4' },
],
tidepool: [
{ rootOffset: 0, quality: 'major' },
{ rootOffset: 10, quality: 'major' },
{ rootOffset: 5, quality: 'sus2' },
{ rootOffset: 9, quality: 'minor' },
],
velvet: [
{ rootOffset: 0, quality: 'minor' },
{ rootOffset: 8, quality: 'major' },
{ rootOffset: 3, quality: 'major' },
{ rootOffset: 5, quality: 'sus4' },
],
} satisfies Record<string, Array<GardenAudioChord>>;
export const defaultVibeId = VibeId.AuroraMycelium; export const defaultVibeId = VibeId.AuroraMycelium;
export const vibePresets: Array<VibePreset> = [ export const vibePresets: Array<VibePreset> = [
{ {
id: VibeId.AuroraMycelium, id: VibeId.AuroraMycelium,
name: 'Aurora Mycelium', name: 'Aurora Mycelium Copy',
colors: [ colors: [
[78, 255, 176], [251, 210, 94],
[154, 99, 255], [154, 99, 255],
[169, 238, 255], [255, 31, 199],
], ],
backgroundColor: [6, 13, 22], backgroundColor: [6, 13, 22],
settings: { settings: {
backgroundGrainStrength: 0.016, ...colorReactions.auroraMycelium,
brushSize: 20, backgroundGrainStrength: 0.003,
clarity: 0.52, brushSize: 8.75,
decayRateTrails: 988, clarity: 1,
individualTrailWeight: 0.085, decayRateTrails: 973,
moveSpeed: 54, forwardRotationScale: 0.37,
sensorOffsetDistance: 72, individualTrailWeight: 0.053000000000000005,
spawnPerPixel: 0.13, moveSpeed: 144,
turnSpeed: 35, sensorOffsetAngle: 35,
sensorOffsetDistance: 52,
spawnPerPixel: 0.13999999999999999,
strokeAngleJitterRadians: 0.45,
turnSpeed: 13,
turnWhenLost: 0,
}, },
audio: { audio: {
...defaultGardenAudioVibeSettings, ...defaultGardenAudioVibeSettings,
idleIntensity: 0.12, idleIntensity: 0.12000000000000002,
bpm: 60, bpm: 60,
rampUpIntensity: 0.7, rampUpIntensity: 0.7,
rampUpTime: 0.14, rampUpTime: 0.14,
noteLength: 0.86, noteLength: 0.8599999999999999,
notePitchOffset: -2, notePitchOffset: -2,
brightness: 0.84, brightness: 0.84,
}, scale: musicScales.lydian,
}, progression: musicProgressions.aurora,
{
id: VibeId.EmberCircuit,
name: 'Ember Circuit',
colors: [
[255, 95, 38],
[255, 43, 132],
[43, 219, 255],
],
backgroundColor: [17, 10, 8],
settings: {
backgroundGrainStrength: 0.03,
brushSize: 8,
clarity: 0.82,
decayRateTrails: 918,
individualTrailWeight: 0.04,
moveSpeed: 150,
sensorOffsetDistance: 24,
spawnPerPixel: 0.31,
turnSpeed: 130,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.03,
bpm: 124,
rampUpIntensity: 1.35,
rampUpTime: 0.04,
noteLength: 0.18,
notePitchOffset: 7,
brightness: 1.34,
}, },
}, },
{ {
id: VibeId.VelvetObservatory, id: VibeId.VelvetObservatory,
name: 'Velvet Observatory', name: 'Velvet Observatory Copy',
colors: [ colors: [
[72, 98, 255], [178, 76, 62],
[255, 89, 176], [2, 174, 255],
[235, 236, 255], [213, 193, 9],
], ],
backgroundColor: [7, 8, 20], backgroundColor: [7, 4, 22],
settings: { settings: {
backgroundGrainStrength: 0.01, ...colorReactions.velvetObservatory,
brushSize: 24, backgroundGrainStrength: 0.005,
clarity: 0.45, brushSize: 9.75,
decayRateTrails: 992, clarity: 1,
individualTrailWeight: 0.095, decayRateTrails: 974,
moveSpeed: 45, forwardRotationScale: 0,
sensorOffsetDistance: 86, individualTrailWeight: 0.232,
spawnPerPixel: 0.1, moveSpeed: 121,
turnSpeed: 24, sensorOffsetAngle: 24,
sensorOffsetDistance: 17,
spawnPerPixel: 0.11499999999999999,
strokeAngleJitterRadians: 0.17,
turnSpeed: 33,
turnWhenLost: 0.42,
}, },
audio: { audio: {
...defaultGardenAudioVibeSettings, ...defaultGardenAudioVibeSettings,
idleIntensity: 0.14, idleIntensity: 0.24000000000000002,
bpm: 56, bpm: 72,
rampUpIntensity: 0.6, rampUpIntensity: 1.42,
rampUpTime: 0.16, rampUpTime: 0.07,
noteLength: 1.15, noteLength: 0.7,
notePitchOffset: -5, notePitchOffset: 0,
brightness: 0.72, brightness: 0.94,
scale: musicScales.naturalMinor,
progression: musicProgressions.velvet,
}, },
}, },
{ {
id: VibeId.LichenSignal, id: VibeId.LichenSignal,
name: 'Lichen Signal', name: 'Lichen Signal',
colors: [ colors: [
[174, 205, 91], [183, 216, 92],
[71, 162, 126], [65, 166, 128],
[229, 117, 71], [238, 120, 76],
], ],
backgroundColor: [18, 24, 17], backgroundColor: [0, 0, 0],
settings: {
backgroundGrainStrength: 0.028,
brushSize: 17,
clarity: 0.66,
decayRateTrails: 974,
individualTrailWeight: 0.065,
moveSpeed: 68,
sensorOffsetDistance: 52,
spawnPerPixel: 0.19,
turnSpeed: 38,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.1,
bpm: 68,
rampUpIntensity: 0.8,
rampUpTime: 0.1,
noteLength: 0.62,
notePitchOffset: -3,
brightness: 0.82,
},
},
{
id: VibeId.UltravioletSiren,
name: 'Ultraviolet Siren',
colors: [
[184, 75, 255],
[0, 224, 255],
[214, 255, 72],
],
backgroundColor: [13, 9, 31],
settings: { settings: {
...colorReactions.lichenSignal,
backgroundGrainStrength: 0.02, backgroundGrainStrength: 0.02,
brushSize: 11, brushSize: 6.5,
clarity: 0.72, clarity: 0.74,
decayRateTrails: 946, decayRateTrails: 962,
forwardRotationScale: 0.3,
individualTrailWeight: 0.052, individualTrailWeight: 0.052,
moveSpeed: 118, moveSpeed: 72,
sensorOffsetDistance: 30, sensorOffsetAngle: 42,
spawnPerPixel: 0.28, sensorOffsetDistance: 54,
turnSpeed: 96, spawnPerPixel: 0.16,
strokeAngleJitterRadians: 3.14,
turnSpeed: 44,
turnWhenLost: 0.92,
}, },
audio: { audio: {
...defaultGardenAudioVibeSettings, ...defaultGardenAudioVibeSettings,
idleIntensity: 0.04, idleIntensity: 0.13,
bpm: 112, bpm: 68,
rampUpIntensity: 1.2, rampUpIntensity: 1.46,
rampUpTime: 0.05, rampUpTime: 0.1,
noteLength: 0.25, noteLength: 0.6,
notePitchOffset: 5, notePitchOffset: -3,
brightness: 1.22, brightness: 1.21,
scale: musicScales.dorian,
progression: musicProgressions.lichen,
}, },
}, },
{ {
@ -167,89 +257,110 @@ export const vibePresets: Array<VibePreset> = [
[61, 118, 255], [61, 118, 255],
[255, 191, 91], [255, 191, 91],
], ],
backgroundColor: [5, 20, 28], backgroundColor: [4, 18, 29],
settings: { settings: {
...colorReactions.tidepoolLantern,
backgroundGrainStrength: 0.018, backgroundGrainStrength: 0.018,
brushSize: 15, brushSize: 17,
clarity: 0.6, clarity: 0.56,
decayRateTrails: 963, decayRateTrails: 968,
individualTrailWeight: 0.058, forwardRotationScale: 0.38,
individualTrailWeight: 0.06,
moveSpeed: 88, moveSpeed: 88,
sensorOffsetDistance: 44, sensorOffsetAngle: 64,
sensorOffsetDistance: 46,
spawnPerPixel: 0.22, spawnPerPixel: 0.22,
turnSpeed: 60, strokeAngleJitterRadians: 1.8,
turnSpeed: 66,
turnWhenLost: 1.05,
}, },
audio: { audio: {
...defaultGardenAudioVibeSettings, ...defaultGardenAudioVibeSettings,
idleIntensity: 0.08, idleIntensity: 0.08,
bpm: 82, bpm: 84,
rampUpIntensity: 0.95, rampUpIntensity: 0.95,
rampUpTime: 0.08, rampUpTime: 0.08,
noteLength: 0.48, noteLength: 0.46,
notePitchOffset: 0, notePitchOffset: 0,
brightness: 0.98, brightness: 0.98,
scale: musicScales.mixolydian,
progression: musicProgressions.tidepool,
}, },
}, },
{ {
id: VibeId.PaperLanternFog, id: VibeId.PaperLanternFog,
name: 'Paper Lantern Fog', name: 'Paper Lantern Fog',
colors: [ colors: [
[255, 174, 104], [255, 176, 108],
[242, 102, 107], [239, 90, 108],
[132, 211, 185], [128, 213, 184],
], ],
backgroundColor: [31, 23, 20], backgroundColor: [30, 23, 20],
settings: { settings: {
backgroundGrainStrength: 0.036, ...colorReactions.paperLanternFog,
brushSize: 22, backgroundGrainStrength: 0.038,
clarity: 0.5, brushSize: 3.5,
decayRateTrails: 984, clarity: 1,
individualTrailWeight: 0.08, decayRateTrails: 999,
moveSpeed: 56, forwardRotationScale: 0.24,
sensorOffsetDistance: 64, individualTrailWeight: 0.937,
spawnPerPixel: 0.14, moveSpeed: 28,
turnSpeed: 32, sensorOffsetAngle: 34,
sensorOffsetDistance: 66,
spawnPerPixel: 0.055,
strokeAngleJitterRadians: 0,
turnSpeed: 30,
turnWhenLost: 1.52,
}, },
audio: { audio: {
...defaultGardenAudioVibeSettings, ...defaultGardenAudioVibeSettings,
idleIntensity: 0.13, idleIntensity: 0.33,
bpm: 64, bpm: 127,
rampUpIntensity: 0.72, rampUpIntensity: 0.66,
rampUpTime: 0.12, rampUpTime: 0.03,
noteLength: 0.9, noteLength: 0.92,
notePitchOffset: -4, notePitchOffset: 10,
brightness: 0.76, brightness: 1.42,
scale: musicScales.naturalMinor,
progression: musicProgressions.paperLantern,
}, },
}, },
{ {
id: VibeId.ChromePollen, id: VibeId.ChromePollen,
name: 'Chrome Pollen', name: 'Chrome Pollen',
colors: [ colors: [
[235, 255, 238], [178, 34, 34],
[255, 214, 48], [255, 214, 48],
[77, 240, 157], [77, 240, 157],
], ],
backgroundColor: [9, 13, 12], backgroundColor: [7, 12, 11],
settings: { settings: {
...colorReactions.chromePollen,
backgroundGrainStrength: 0.012, backgroundGrainStrength: 0.012,
brushSize: 10, brushSize: 4.5,
clarity: 0.9, clarity: 0.1,
decayRateTrails: 935, decayRateTrails: 922,
individualTrailWeight: 0.045, forwardRotationScale: 0.5,
moveSpeed: 104, individualTrailWeight: 0.026,
sensorOffsetDistance: 36, moveSpeed: 86,
spawnPerPixel: 0.24, sensorOffsetAngle: 46,
turnSpeed: 78, sensorOffsetDistance: 14,
spawnPerPixel: 0.36,
strokeAngleJitterRadians: 3,
turnSpeed: 34,
turnWhenLost: 1.35,
}, },
audio: { audio: {
...defaultGardenAudioVibeSettings, ...defaultGardenAudioVibeSettings,
idleIntensity: 0.05, idleIntensity: 0.11,
bpm: 96, bpm: 150,
rampUpIntensity: 1.05, rampUpIntensity: 2,
rampUpTime: 0.07, rampUpTime: 0.06,
noteLength: 0.3, noteLength: 1.8,
notePitchOffset: 3, notePitchOffset: -12,
brightness: 1.18, brightness: 0.5,
scale: musicScales.lydian,
progression: musicProgressions.chrome,
}, },
}, },
]; ];

View file

@ -1,10 +0,0 @@
export const ENABLED_FLAG_VALUE = '1';
export const DISABLED_FLAG_VALUE = '0';
export const DEFAULT_AUDIO_VOLUME = 0.5;
export const APP_STORAGE_KEYS = {
audioMuted: 'fleeting-garden:audio-muted',
audioVolume: 'fleeting-garden:audio-volume',
vibe: 'fleeting-garden:vibe',
} as const;

View file

@ -145,6 +145,18 @@ describe('AgentPopulation stroke spawning', () => {
expect(pipeline.writtenBatches[0][2]).toBe(0); expect(pipeline.writtenBatches[0][2]).toBe(0);
}); });
it('clears active agents when an intro replacement has no generated agents', () => {
const { population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
expect(population.activeAgentCount).toBe(3);
settings.maxAgentCount = 0;
population.replaceIntroAgents(vec2.fromValues(100, 100), 0);
expect(population.activeAgentCount).toBe(0);
});
it('queues stroke writes while async compaction is in flight', async () => { it('queues stroke writes while async compaction is in flight', async () => {
const { pipeline, population } = createPopulation(); const { pipeline, population } = createPopulation();

View file

@ -1,6 +1,7 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
@ -58,6 +59,8 @@ export class AgentPopulation {
}); });
if (data.length === 0) { if (data.length === 0) {
this.activeCount = 0;
this.replacementCursor = 0;
return; return;
} }
@ -162,7 +165,11 @@ export class AgentPopulation {
} }
const baseAngle = Math.atan2(deltaY, deltaX); const baseAngle = Math.atan2(deltaY, deltaX);
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio()); const spread =
getRenderQualityBrushSize(
settings.brushSize,
settings.internalRenderAreaMegapixels
) * getSafePixelRatio(this.getCanvasPixelRatio());
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT; const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
if (batchCapacity <= 0) { if (batchCapacity <= 0) {
return; return;

View file

@ -1,6 +1,7 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { getRenderQualityBrushSize } from '../config/brush-size';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings'; import { settings } from '../settings';
import { type StrokeSegment } from './game-loop-types'; import { type StrokeSegment } from './game-loop-types';
@ -90,9 +91,13 @@ export class BrushStrokeSmoother {
): Array<StrokeSegment> { ): Array<StrokeSegment> {
const curveLength = vec2.distance(start, control) + vec2.distance(control, end); const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio()); const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
const brushSize = getRenderQualityBrushSize(
settings.brushSize,
settings.internalRenderAreaMegapixels
);
const brushRadius = Math.max( const brushRadius = Math.max(
settings.brushCurveMinBrushRadius * canvasPixelRatio, settings.brushCurveMinBrushRadius * canvasPixelRatio,
(settings.brushSize * canvasPixelRatio) / 2 (brushSize * canvasPixelRatio) / 2
); );
const segmentSpacing = Math.max( const segmentSpacing = Math.max(
settings.brushCurveMinSegmentSpacing * canvasPixelRatio, settings.brushCurveMinSegmentSpacing * canvasPixelRatio,

View file

@ -1,19 +1,18 @@
import { settings } from '../settings'; import { settings } from '../settings';
export class FramePerformance { const ADAPTIVE_REFRESH_TARGET_FPS = 60;
private readonly adaptiveRefreshTargetFps = 60; const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000;
private readonly initialFps = this.adaptiveRefreshTargetFps; const FRAME_GAP_RESET_SECONDS = 1;
public smoothedFps = this.initialFps; const FPS_HEADROOM = 0.9;
const FPS_SMOOTHING_NEW = 0.06;
const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW;
export class FramePerformance {
public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
public measuredFps = 0; public measuredFps = 0;
public frameDeltaSeconds = 0; public frameDeltaSeconds = 0;
public measuredFrameTimeMs = 0; public measuredFrameTimeMs = 0;
private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
private readonly frameGapResetSeconds = 1;
private readonly fpsHeadroom = 0.9;
private readonly fpsSmoothingNew = 0.06;
private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
private previousFrameTime: DOMHighResTimeStamp | null = null; private previousFrameTime: DOMHighResTimeStamp | null = null;
public get adaptiveCapInitial(): number { public get adaptiveCapInitial(): number {
@ -25,13 +24,13 @@ export class FramePerformance {
} }
public get hasAdaptiveCapHeadroom(): boolean { public get hasAdaptiveCapHeadroom(): boolean {
return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom; return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM;
} }
public get adaptiveCapDecreaseAgents(): number { public get adaptiveCapDecreaseAgents(): number {
return Math.max( return Math.max(
1, 1,
Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds) Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds)
); );
} }
@ -47,15 +46,16 @@ export class FramePerformance {
return; return;
} }
const fps = 1 / deltaSeconds;
this.frameDeltaSeconds = deltaSeconds;
this.measuredFrameTimeMs = deltaSeconds * 1000; this.measuredFrameTimeMs = deltaSeconds * 1000;
const fps = 1 / deltaSeconds;
this.measuredFps = fps; this.measuredFps = fps;
if (deltaSeconds > this.frameGapResetSeconds) { if (deltaSeconds > FRAME_GAP_RESET_SECONDS) {
this.frameDeltaSeconds = 0;
this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
return; return;
} }
this.smoothedFps = this.frameDeltaSeconds = deltaSeconds;
this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew; this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW;
} }
} }

View file

@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config'; import { appConfig, type GardenRuntimeSettings } from '../config';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentPipeline } from '../pipelines/agents/agent-pipeline'; import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline'; import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
@ -9,7 +9,6 @@ import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline'; import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline'; import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context'; import { initializeContext } from '../utils/graphics/initialize-context';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { GpuProfiler } from './gpu-profiler'; import { GpuProfiler } from './gpu-profiler';
@ -25,6 +24,7 @@ interface FrameParameters extends RenderInputs {
introProgress: number; introProgress: number;
selectedColorIndex: number; selectedColorIndex: number;
eraserPixelSize: number; eraserPixelSize: number;
runtimeSettings: GardenRuntimeSettings;
} }
export class GameLoopResources { export class GameLoopResources {
@ -46,7 +46,8 @@ export class GameLoopResources {
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly canvasFormat: GPUTextureFormat, private readonly canvasFormat: GPUTextureFormat,
canvasSize: vec2, canvasSize: vec2,
initialAgentCapacity: number initialAgentCapacity: number,
initialMaxAgentCount: number
) { ) {
const context = initializeContext({ device, canvas, format: canvasFormat }); const context = initializeContext({ device, canvas, format: canvasFormat });
@ -59,7 +60,7 @@ export class GameLoopResources {
this.agentGenerationPipeline = new AgentGenerationPipeline( this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device, this.device,
Math.min(settings.maxAgentCount, initialAgentCapacity) Math.min(initialMaxAgentCount, initialAgentCapacity)
); );
this.agentPipeline = new AgentPipeline( this.agentPipeline = new AgentPipeline(
@ -74,12 +75,7 @@ export class GameLoopResources {
); );
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device); this.diffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline( this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
context,
this.device,
this.commonState,
this.canvasFormat
);
this.gpuProfiler = GpuProfiler.create( this.gpuProfiler = GpuProfiler.create(
this.device, this.device,
() => appConfig.tuningPane.showFpsOverlay () => appConfig.tuningPane.showFpsOverlay
@ -128,47 +124,43 @@ export class GameLoopResources {
channelColors, channelColors,
backgroundColor, backgroundColor,
eraserPixelSize, eraserPixelSize,
runtimeSettings,
}: FrameParameters): void { }: FrameParameters): void {
this.commonState.setParameters({ this.commonState.setParameters({
canvasSize, canvasSize,
}); });
this.agentPipeline.setParameters({ this.agentPipeline.setParameters({
...settings, ...runtimeSettings,
deltaTime, deltaTime,
time, time,
agentCount: activeAgentCount, agentCount: activeAgentCount,
moveSpeed: introMoveSpeed: appConfig.simulation.introMoveSpeed,
settings.moveSpeed *
(introProgress >= 1
? 1
: appConfig.simulation.introMoveSpeedBaseMultiplier +
introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier),
introProgress, introProgress,
}); });
this.brushPipeline.setParameters({ this.brushPipeline.setParameters({
...settings, ...runtimeSettings,
pixelRatio: canvasPixelRatio, pixelRatio: canvasPixelRatio,
selectedColorIndex, selectedColorIndex,
}); });
this.diffusionPipeline.setParameters(settings); this.diffusionPipeline.setParameters(runtimeSettings);
this.renderPipeline.setParameters({ this.renderPipeline.setParameters({
...settings, ...runtimeSettings,
channelColors, channelColors,
backgroundColor, backgroundColor,
}); });
this.eraserAgentPipeline.setParameters({ this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount, agentCount: activeAgentCount,
eraserSize: eraserPixelSize, eraserSize: eraserPixelSize,
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold, eraserMaskAlphaThreshold: runtimeSettings.eraserMaskAlphaThreshold,
maskSize: canvasSize, maskSize: canvasSize,
}); });
this.eraserTexturePipeline.setParameters({ this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize, eraserSize: eraserPixelSize,
eraserLineDistanceEpsilon: settings.eraserLineDistanceEpsilon, eraserLineDistanceEpsilon: runtimeSettings.eraserLineDistanceEpsilon,
eraserClearRed: settings.eraserClearRed, eraserClearRed: runtimeSettings.eraserClearRed,
eraserClearGreen: settings.eraserClearGreen, eraserClearGreen: runtimeSettings.eraserClearGreen,
eraserClearBlue: settings.eraserClearBlue, eraserClearBlue: runtimeSettings.eraserClearBlue,
eraserClearAlpha: settings.eraserClearAlpha, eraserClearAlpha: runtimeSettings.eraserClearAlpha,
}); });
} }

View file

@ -31,7 +31,6 @@ export default class GameLoop {
private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly seedValue = Math.floor(Math.random() * 0xffffffff); private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
private readonly seed = this.seedValue.toString(16); private readonly seed = this.seedValue.toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly _canvasSize: vec2 = vec2.create(); private readonly _canvasSize: vec2 = vec2.create();
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null; private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
@ -55,7 +54,8 @@ export default class GameLoop {
device, device,
this.canvasFormat, this.canvasFormat,
this.canvasSize, this.canvasSize,
this.framePerformance.adaptiveCapInitial this.framePerformance.adaptiveCapInitial,
settings.maxAgentCount
); );
this.introPrompt = new IntroPrompt(ui.prompt); this.introPrompt = new IntroPrompt(ui.prompt);
this.toolbarContrastMonitor = new ToolbarContrastMonitor( this.toolbarContrastMonitor = new ToolbarContrastMonitor(
@ -112,13 +112,12 @@ export default class GameLoop {
getVibeId: () => activeVibe.id, getVibeId: () => activeVibe.id,
}); });
window.addEventListener('resize', this.resizeListener);
this.eraserPreview.attach();
this.syncPerfStatsOverlay(); this.syncPerfStatsOverlay();
} }
public attachPointerInput(): void { public attachPointerInput(): void {
this.pointerInput.attach(); this.pointerInput.attach();
this.eraserPreview.attach();
} }
public setEraseMode(isErasing: boolean): void { public setEraseMode(isErasing: boolean): void {
@ -173,9 +172,6 @@ export default class GameLoop {
cancelAnimationFrame(this.animationFrameId); cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null; this.animationFrameId = null;
} }
this.finished.resolve();
window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach(); this.pointerInput.detach();
this.eraserPreview.detach(); this.eraserPreview.detach();
this.perfStatsOverlay?.destroy(); this.perfStatsOverlay?.destroy();
@ -185,6 +181,7 @@ export default class GameLoop {
await this.agentPopulation.waitForCompaction(); await this.agentPopulation.waitForCompaction();
this.resources.destroy(); this.resources.destroy();
await this.audio.destroy(); await this.audio.destroy();
this.finished.resolve();
} }
private readonly render = (time: DOMHighResTimeStamp) => { private readonly render = (time: DOMHighResTimeStamp) => {
@ -204,13 +201,15 @@ export default class GameLoop {
const channelColors = activeVibe.colors; const channelColors = activeVibe.colors;
const backgroundColor = activeVibe.backgroundColor; const backgroundColor = activeVibe.backgroundColor;
const runtimeSettings = { ...settings };
const introProgress = this.introPrompt.progress; const introProgress = this.introPrompt.progress;
const canvasPixelRatio = this.canvasPixelRatio; const canvasPixelRatio = this.canvasPixelRatio;
const eraserPixelSize = settings.eraserSize * canvasPixelRatio; const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
const isErasing = this.pointerInput.isEraseMode; const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; const accentColor =
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
this.updateAccentColor(accentColor); this.updateAccentColor(accentColor);
this.updateGrainOverlay(settings.backgroundGrainStrength); this.updateGrainOverlay(runtimeSettings.backgroundGrainStrength);
this.audio.update({ this.audio.update({
vibe: activeVibe, vibe: activeVibe,
isErasing, isErasing,
@ -223,10 +222,11 @@ export default class GameLoop {
activeAgentCount: this.agentPopulation.activeAgentCount, activeAgentCount: this.agentPopulation.activeAgentCount,
canvasPixelRatio, canvasPixelRatio,
introProgress, introProgress,
selectedColorIndex: settings.selectedColorIndex, selectedColorIndex: runtimeSettings.selectedColorIndex,
channelColors, channelColors,
backgroundColor, backgroundColor,
eraserPixelSize, eraserPixelSize,
runtimeSettings,
}); });
this.resources.executeFrame( this.resources.executeFrame(

View file

@ -1,4 +1,4 @@
import { appConfig } from '../config'; import { appConfig, type GardenAppConfig } from '../config';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits'; import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math'; import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
@ -18,8 +18,11 @@ interface IntroTitleAgentOptions {
} }
type RandomSource = () => number; type RandomSource = () => number;
type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
const INTRO_TITLE = appConfig.simulation.intro.title; const INTRO_TITLE = appConfig.simulation.intro.title;
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
pathEasing === 'linear';
export const createIntroTitleAgents = ({ export const createIntroTitleAgents = ({
count, count,
@ -169,16 +172,22 @@ const createIntroTitlePoints = (
width: number, width: number,
height: number height: number
): Array<IntroTitlePoint> => { ): Array<IntroTitlePoint> => {
const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height)));
const maskWidth = Math.max(1, Math.round(width * maskScale));
const maskHeight = Math.max(1, Math.round(height * maskScale));
const pointScaleX = width / maskWidth;
const pointScaleY = height / maskHeight;
const maskCanvas = document.createElement('canvas'); const maskCanvas = document.createElement('canvas');
maskCanvas.width = width; maskCanvas.width = maskWidth;
maskCanvas.height = height; maskCanvas.height = maskHeight;
const context = maskCanvas.getContext('2d', { willReadFrequently: true }); const context = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!context) { if (!context) {
return []; return [];
} }
const fontSize = getIntroTitleFontSize(context, width, height); const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
context.clearRect(0, 0, width, height); context.clearRect(0, 0, maskWidth, maskHeight);
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`; context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
context.textAlign = 'center'; context.textAlign = 'center';
context.textBaseline = 'middle'; context.textBaseline = 'middle';
@ -192,42 +201,44 @@ const createIntroTitlePoints = (
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm; const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
drawIntroTitleText( drawIntroTitleText(
context, context,
width / 2, maskWidth / 2,
height * appConfig.simulation.intro.verticalAnchor, maskHeight * appConfig.simulation.intro.verticalAnchor,
letterSpacing, letterSpacing,
'stroke' 'stroke'
); );
drawIntroTitleText( drawIntroTitleText(
context, context,
width / 2, maskWidth / 2,
height * appConfig.simulation.intro.verticalAnchor, maskHeight * appConfig.simulation.intro.verticalAnchor,
letterSpacing, letterSpacing,
'fill' 'fill'
); );
const { data } = context.getImageData(0, 0, width, height); const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
const step = Math.max( const step = Math.max(
1, 1,
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity) Math.floor(
Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
)
); );
const points: Array<IntroTitlePoint> = []; const points: Array<IntroTitlePoint> = [];
const characterColorBoundaries = getIntroTitleColorBoundaries( const characterColorBoundaries = getIntroTitleColorBoundaries(
context, context,
width, maskWidth,
letterSpacing letterSpacing
); );
for (let y = 0; y < height; y += step) { for (let y = 0; y < maskHeight; y += step) {
for (let x = 0; x < width; x += step) { for (let x = 0; x < maskWidth; x += step) {
const alpha = getMaskAlpha(data, width, height, x, y); const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y);
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) { if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
continue; continue;
} }
points.push({ points.push({
x, x: x * pointScaleX,
y, y: y * pointScaleY,
tangent: estimateMaskTangent(data, width, height, x, y), tangent: estimateMaskTangent(data, maskWidth, maskHeight, x, y),
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries), colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
}); });
} }
@ -244,8 +255,10 @@ const getIntroTitleColorBoundaries = (
const letters = Array.from(INTRO_TITLE); const letters = Array.from(INTRO_TITLE);
const totalWidth = measureIntroTitleText(context, letters, letterSpacing); const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
let x = width / 2 - totalWidth / 2; let x = width / 2 - totalWidth / 2;
const [firstCutLetter, secondCutLetter] = const cutLetters = appConfig.simulation.intro.titleColorCutLetters
appConfig.simulation.intro.titleColorCutLetters; .map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter))))
.sort((a, b) => a - b);
const [firstCutLetter, secondCutLetter] = cutLetters;
const letterBoxes = letters.map((letter, index) => { const letterBoxes = letters.map((letter, index) => {
const letterWidth = context.measureText(letter).width; const letterWidth = context.measureText(letter).width;
const box = { const box = {
@ -401,7 +414,7 @@ const createSeededRandom = (seed: number): RandomSource => {
}; };
const easePathProgress = (amount: number): number => { const easePathProgress = (amount: number): number => {
if (appConfig.simulation.intro.pathEasing === 'linear') { if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
return amount; return amount;
} }

View file

@ -1,4 +1,5 @@
const PERF_STATS_REFRESH_MS = 200; const PERF_STATS_REFRESH_MS = 200;
const UNAVAILABLE_STAT_TEXT = 'n/a';
const ZERO_STAT_TEXT = '0'; const ZERO_STAT_TEXT = '0';
const ZERO_FRAME_TIME_TEXT = '0ms'; const ZERO_FRAME_TIME_TEXT = '0ms';
const ZERO_RESOLUTION_TEXT = '0x0'; const ZERO_RESOLUTION_TEXT = '0x0';
@ -39,7 +40,7 @@ export class PerfStatsOverlay {
} }
this.previousUpdateTime = time; this.previousUpdateTime = time;
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatOptionalFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
if (text !== this.previousText) { if (text !== this.previousText) {
this.element.textContent = text; this.element.textContent = text;
this.previousText = text; this.previousText = text;
@ -68,6 +69,11 @@ const formatFrameTime = (frameTimeMs: number | undefined): string => {
return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`; return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`;
}; };
const formatOptionalFrameTime = (frameTimeMs: number | undefined): string =>
typeof frameTimeMs === 'number' && Number.isFinite(frameTimeMs)
? formatFrameTime(frameTimeMs)
: UNAVAILABLE_STAT_TEXT;
const formatResolution = (width: number, height: number): string => const formatResolution = (width: number, height: number): string =>
Number.isFinite(width) && Number.isFinite(height) Number.isFinite(width) && Number.isFinite(height)
? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}` ? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}`

View file

@ -96,7 +96,6 @@ export class GardenPointerInput {
this.options.onStartDrawing(); this.options.onStartDrawing();
this.activePointerId = event.pointerId; this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId); this.canvas.setPointerCapture(event.pointerId);
this.options.strokeOutput.clearSwipes();
this.lastPointerPosition = null; this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null; this.lastPointerEventTimeMs = null;
this.brushSmoother.clear(); this.brushSmoother.clear();
@ -122,11 +121,16 @@ export class GardenPointerInput {
if (this.isErasing) { if (this.isErasing) {
this.options.onEraseGestureEnded(); this.options.onEraseGestureEnded();
} }
this.canvas.releasePointerCapture(event.pointerId); try {
this.activePointerId = null; if (this.canvas.hasPointerCapture(event.pointerId)) {
this.lastPointerPosition = null; this.canvas.releasePointerCapture(event.pointerId);
this.lastPointerEventTimeMs = null; }
this.brushSmoother.clear(); } finally {
this.activePointerId = null;
this.lastPointerPosition = null;
this.lastPointerEventTimeMs = null;
this.brushSmoother.clear();
}
}; };
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void { private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {

View file

@ -46,16 +46,19 @@ export class SimulationFrameRenderer {
const commandEncoder = this.device.createCommandEncoder(); const commandEncoder = this.device.createCommandEncoder();
this.gpuProfiler?.beginFrame(); this.gpuProfiler?.beginFrame();
this.textures.copyTrailMapAToB(commandEncoder); // Clear the deposit map up-front so agents write fresh deposits each frame
// and diffuse sees only this frame's contributions added to trailMapA.
this.textures.clearDepositMap(commandEncoder);
let wroteSourceMap = false; let wroteSourceMap = false;
if (isErasing) { if (isErasing) {
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) { if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
const eraserMask = this.textures.eraserMask.getTextureView(); const eraserMask = this.textures.eraserMask.getTextureView();
// Erase trailMapA directly — it's what agent and diffuse will read.
this.pipelines.eraserTexturePipeline.executeCombined( this.pipelines.eraserTexturePipeline.executeCombined(
commandEncoder, commandEncoder,
eraserMask, eraserMask,
this.textures.sourceMapA.getTextureView(), this.textures.sourceMapA.getTextureView(),
this.textures.trailMapB.getTextureView(), this.textures.trailMapA.getTextureView(),
this.gpuProfiler?.timestampWrites('eraserTexture') this.gpuProfiler?.timestampWrites('eraserTexture')
); );
this.pipelines.eraserAgentPipeline.execute( this.pipelines.eraserAgentPipeline.execute(
@ -65,7 +68,7 @@ export class SimulationFrameRenderer {
); );
} }
} else { } else {
wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget( wroteSourceMap = this.pipelines.brushPipeline.executeSource(
commandEncoder, commandEncoder,
this.textures.sourceMapA.getTextureView(), this.textures.sourceMapA.getTextureView(),
this.gpuProfiler?.timestampWrites('brush') this.gpuProfiler?.timestampWrites('brush')
@ -86,19 +89,20 @@ export class SimulationFrameRenderer {
this.pipelines.agentPipeline.execute( this.pipelines.agentPipeline.execute(
commandEncoder, commandEncoder,
this.textures.trailMapA.getTextureView(), this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView(), this.textures.depositMap.getTextureView(),
this.gpuProfiler?.timestampWrites('agent') this.gpuProfiler?.timestampWrites('agent')
); );
this.pipelines.diffusionPipeline.execute( this.pipelines.diffusionPipeline.execute(
commandEncoder, commandEncoder,
this.textures.trailMapB.getTextureView(),
this.textures.trailMapA.getTextureView(), this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView(),
this.textures.trailMapA.getSize(), this.textures.trailMapA.getSize(),
this.textures.depositMap.getTextureView(),
this.gpuProfiler?.timestampWrites('trailDiffusion') this.gpuProfiler?.timestampWrites('trailDiffusion')
); );
const canvasTexture = this.pipelines.renderPipeline.execute( const canvasTexture = this.pipelines.renderPipeline.execute(
commandEncoder, commandEncoder,
this.textures.trailMapA.getTextureView(), this.textures.trailMapB.getTextureView(),
this.textures.sourceMapA.getTextureView(), this.textures.sourceMapA.getTextureView(),
useSourceMap, useSourceMap,
this.gpuProfiler?.timestampWrites('render') this.gpuProfiler?.timestampWrites('render')
@ -111,6 +115,7 @@ export class SimulationFrameRenderer {
this.textures.sourceMapA.getTextureView(), this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView(), this.textures.sourceMapB.getTextureView(),
this.textures.sourceMapB.getSize(), this.textures.sourceMapB.getSize(),
null,
this.gpuProfiler?.timestampWrites('sourceDiffusion') this.gpuProfiler?.timestampWrites('sourceDiffusion')
); );
} }
@ -118,6 +123,10 @@ export class SimulationFrameRenderer {
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
afterGpuProfileSubmit?.(); afterGpuProfileSubmit?.();
canvasReadbackRequest?.afterSubmit(); canvasReadbackRequest?.afterSubmit();
// After this frame's diffuse, trailMapB holds the fresh trail; swap so
// trailMapA is "current trail" again for the next frame and any external
// readers (e.g. export snapshot).
this.textures.swapTrailMaps();
if (useSourceMap) { if (useSourceMap) {
this.textures.swapSourceMaps(); this.textures.swapSourceMaps();
this.sourceActiveFramesRemaining -= 1; this.sourceActiveFramesRemaining -= 1;

View file

@ -8,10 +8,15 @@ import {
} from '../utils/graphics/resizable-texture'; } from '../utils/graphics/resizable-texture';
export class SimulationTextures { export class SimulationTextures {
public readonly trailMapA: ResizableTexture; // trailMapA holds the current trail (read by agent and diffuse). trailMapB
public readonly trailMapB: ResizableTexture; // receives the diffuse output; the two swap each frame so the freshly
// diffused texture becomes trailMapA for the next frame.
public trailMapA: ResizableTexture;
public trailMapB: ResizableTexture;
// Per-frame last-writer deposit map: cleared each frame, written sparsely by
// agents, then read by diffuse alongside trailMapA.
public readonly depositMap: ResizableTexture;
public readonly eraserMask: ResizableTexture; public readonly eraserMask: ResizableTexture;
// A/B are swapped each frame to ping-pong the diffusion pass.
public sourceMapA: ResizableTexture; public sourceMapA: ResizableTexture;
public sourceMapB: ResizableTexture; public sourceMapB: ResizableTexture;
@ -21,6 +26,7 @@ export class SimulationTextures {
) { ) {
this.trailMapA = this.createTexture(canvasSize); this.trailMapA = this.createTexture(canvasSize);
this.trailMapB = this.createTexture(canvasSize); this.trailMapB = this.createTexture(canvasSize);
this.depositMap = this.createTexture(canvasSize);
this.sourceMapA = this.createTexture(canvasSize); this.sourceMapA = this.createTexture(canvasSize);
this.sourceMapB = this.createTexture(canvasSize); this.sourceMapB = this.createTexture(canvasSize);
this.eraserMask = this.createEraserMask(canvasSize); this.eraserMask = this.createEraserMask(canvasSize);
@ -36,6 +42,7 @@ export class SimulationTextures {
const resizes = [ const resizes = [
this.trailMapA, this.trailMapA,
this.trailMapB, this.trailMapB,
this.depositMap,
this.sourceMapA, this.sourceMapA,
this.sourceMapB, this.sourceMapB,
this.eraserMask, this.eraserMask,
@ -67,6 +74,7 @@ export class SimulationTextures {
[ [
this.trailMapA, this.trailMapA,
this.trailMapB, this.trailMapB,
this.depositMap,
this.sourceMapA, this.sourceMapA,
this.sourceMapB, this.sourceMapB,
this.eraserMask, this.eraserMask,
@ -86,14 +94,25 @@ export class SimulationTextures {
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
} }
public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void { public clearDepositMap(commandEncoder: GPUCommandEncoder): void {
const size = this.trailMapA.getSize(); // Hardware fast-clear via a render pass with loadOp 'clear' and an empty
// body. Cheaper than copyTextureToTexture and writes no actual color data
// on tile-based GPUs.
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: this.depositMap.getTextureView(),
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.end();
}
commandEncoder.copyTextureToTexture( public swapTrailMaps(): void {
{ texture: this.trailMapA.getTexture() }, [this.trailMapA, this.trailMapB] = [this.trailMapB, this.trailMapA];
{ texture: this.trailMapB.getTexture() },
{ width: size[0], height: size[1] }
);
} }
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void { public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
@ -119,6 +138,7 @@ export class SimulationTextures {
public destroy(): void { public destroy(): void {
this.trailMapA.destroy(); this.trailMapA.destroy();
this.trailMapB.destroy(); this.trailMapB.destroy();
this.depositMap.destroy();
this.sourceMapA.destroy(); this.sourceMapA.destroy();
this.sourceMapB.destroy(); this.sourceMapB.destroy();
this.eraserMask.destroy(); this.eraserMask.destroy();

View file

@ -7,6 +7,14 @@ interface CanvasSamplePoint {
y: number; y: number;
} }
interface CanvasSampleRegion {
bytesPerRow: number;
height: number;
origin: CanvasSamplePoint;
sampleOffsets: Array<number>;
width: number;
}
interface ToolbarContrastMetrics { interface ToolbarContrastMetrics {
averageLuminance: number; averageLuminance: number;
backgroundOpacity: number; backgroundOpacity: number;
@ -16,6 +24,7 @@ interface ToolbarContrastMetrics {
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity'; const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength'; const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
const getLinearChannel = (channel: number): number => { const getLinearChannel = (channel: number): number => {
const normalized = channel / 255; const normalized = channel / 255;
@ -33,16 +42,13 @@ const getRelativeLuminance = (red: number, green: number, blue: number): number
const getToolbarContrastMetrics = ( const getToolbarContrastMetrics = (
pixels: Uint8Array, pixels: Uint8Array,
sampleCount: number, sampleOffsets: ReadonlyArray<number>,
isBgra: boolean isBgra: boolean
): ToolbarContrastMetrics => { ): ToolbarContrastMetrics => {
const count = Math.max( const count = sampleOffsets.filter(
0, (offset) =>
Math.min( offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
sampleCount, ).length;
Math.floor(pixels.length / appConfig.toolbar.contrast.bytesPerSample)
)
);
if (count === 0) { if (count === 0) {
return { return {
averageLuminance: 0, averageLuminance: 0,
@ -56,8 +62,14 @@ const getToolbarContrastMetrics = (
let brightCount = 0; let brightCount = 0;
let lowContrastCount = 0; let lowContrastCount = 0;
for (let i = 0; i < count; i++) { sampleOffsets.forEach((offset) => {
const offset = i * appConfig.toolbar.contrast.bytesPerSample; if (
offset < 0 ||
offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
) {
return;
}
const red = pixels[offset + (isBgra ? 2 : 0)]; const red = pixels[offset + (isBgra ? 2 : 0)];
const green = pixels[offset + 1]; const green = pixels[offset + 1];
const blue = pixels[offset + (isBgra ? 0 : 2)]; const blue = pixels[offset + (isBgra ? 0 : 2)];
@ -73,7 +85,7 @@ const getToolbarContrastMetrics = (
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) { if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
lowContrastCount++; lowContrastCount++;
} }
} });
const averageLuminance = luminanceTotal / count; const averageLuminance = luminanceTotal / count;
const brightRatio = brightCount / count; const brightRatio = brightCount / count;
@ -100,6 +112,8 @@ export class ToolbarContrastMonitor {
private isDestroyed = false; private isDestroyed = false;
private isReadbackPending = false; private isReadbackPending = false;
private lastSampleAt = Number.NEGATIVE_INFINITY; private lastSampleAt = Number.NEGATIVE_INFINITY;
private readbackBuffer: GPUBuffer | null = null;
private readbackBufferSize = 0;
public constructor( public constructor(
private readonly canvas: HTMLCanvasElement, private readonly canvas: HTMLCanvasElement,
@ -119,45 +133,29 @@ export class ToolbarContrastMonitor {
return null; return null;
} }
const samplePoints = this.getSamplePoints(); const sampleRegion = this.getSampleRegion();
if (samplePoints.length === 0) { if (sampleRegion.sampleOffsets.length === 0) {
return null; return null;
} }
let buffer: GPUBuffer; const bufferSize = sampleRegion.bytesPerRow * sampleRegion.height;
try { const buffer = this.getReadbackBuffer(bufferSize);
buffer = this.device.createBuffer({ if (!buffer) {
size: samplePoints.length * appConfig.toolbar.contrast.bytesPerSample,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
} catch {
return null; return null;
} }
this.isReadbackPending = true; this.isReadbackPending = true;
this.lastSampleAt = time; this.lastSampleAt = time;
let isBufferDestroyed = false;
let isCancelled = false; let isCancelled = false;
let isEncoded = false; let isEncoded = false;
const destroyBuffer = () => { const cancel = () => {
if (isBufferDestroyed) {
return;
}
isBufferDestroyed = true;
buffer.destroy();
};
const cancel = (destroyNow = true) => {
if (isCancelled) { if (isCancelled) {
return; return;
} }
isCancelled = true; isCancelled = true;
this.isReadbackPending = false; this.isReadbackPending = false;
if (destroyNow) {
destroyBuffer();
}
}; };
return { return {
@ -167,31 +165,28 @@ export class ToolbarContrastMonitor {
} }
try { try {
samplePoints.forEach((point, index) => { commandEncoder.copyTextureToBuffer(
commandEncoder.copyTextureToBuffer( {
{ origin: sampleRegion.origin,
origin: point, texture,
texture, },
}, {
{ buffer,
buffer, bytesPerRow: sampleRegion.bytesPerRow,
offset: index * appConfig.toolbar.contrast.bytesPerSample, },
}, {
{ depthOrArrayLayers: 1,
depthOrArrayLayers: 1, height: sampleRegion.height,
height: 1, width: sampleRegion.width,
width: 1, }
} );
);
});
isEncoded = true; isEncoded = true;
} catch { } catch {
cancel(false); cancel();
} }
}, },
afterSubmit: () => { afterSubmit: () => {
if (isCancelled) { if (isCancelled) {
destroyBuffer();
return; return;
} }
@ -200,13 +195,16 @@ export class ToolbarContrastMonitor {
return; return;
} }
void this.readBuffer(buffer, samplePoints.length); void this.readBuffer(buffer, sampleRegion.sampleOffsets);
}, },
}; };
} }
public destroy(): void { public destroy(): void {
this.isDestroyed = true; this.isDestroyed = true;
this.readbackBuffer?.destroy();
this.readbackBuffer = null;
this.readbackBufferSize = 0;
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY); this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY); this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
} }
@ -231,7 +229,14 @@ export class ToolbarContrastMonitor {
); );
} }
private getSamplePoints(): Array<CanvasSamplePoint> { private getSampleRegion(): CanvasSampleRegion {
const emptyRegion = {
bytesPerRow: 0,
height: 0,
origin: { x: 0, y: 0 },
sampleOffsets: [],
width: 0,
};
const canvasRect = this.canvas.getBoundingClientRect(); const canvasRect = this.canvas.getBoundingClientRect();
const toolbarRect = this.toolbar.getBoundingClientRect(); const toolbarRect = this.toolbar.getBoundingClientRect();
if ( if (
@ -240,7 +245,7 @@ export class ToolbarContrastMonitor {
toolbarRect.width <= 0 || toolbarRect.width <= 0 ||
toolbarRect.height <= 0 toolbarRect.height <= 0
) { ) {
return []; return emptyRegion;
} }
const left = Math.max(canvasRect.left, toolbarRect.left); const left = Math.max(canvasRect.left, toolbarRect.left);
@ -248,17 +253,40 @@ export class ToolbarContrastMonitor {
const top = Math.max(canvasRect.top, toolbarRect.top); const top = Math.max(canvasRect.top, toolbarRect.top);
const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom); const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom);
if (left >= right || top >= bottom) { if (left >= right || top >= bottom) {
return []; return emptyRegion;
} }
const xScale = this.canvas.width / canvasRect.width; const xScale = this.canvas.width / canvasRect.width;
const yScale = this.canvas.height / canvasRect.height; const yScale = this.canvas.height / canvasRect.height;
const width = right - left; const cssWidth = right - left;
const height = bottom - top; const cssHeight = bottom - top;
const origin = {
x: Math.max(0, Math.floor((left - canvasRect.left) * xScale)),
y: Math.max(0, Math.floor((top - canvasRect.top) * yScale)),
};
const regionRight = Math.min(
this.canvas.width,
Math.ceil((right - canvasRect.left) * xScale)
);
const regionBottom = Math.min(
this.canvas.height,
Math.ceil((bottom - canvasRect.top) * yScale)
);
const width = Math.max(0, regionRight - origin.x);
const height = Math.max(0, regionBottom - origin.y);
if (width === 0 || height === 0) {
return emptyRegion;
}
const bytesPerRow = alignTo(
width * appConfig.toolbar.contrast.bytesPerSample,
GPU_COPY_BYTES_PER_ROW_ALIGNMENT
);
const points = new Map<string, CanvasSamplePoint>(); const points = new Map<string, CanvasSamplePoint>();
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) { for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
const cssY = top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * height; const cssY =
top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
const y = Math.min( const y = Math.min(
this.canvas.height - 1, this.canvas.height - 1,
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale)) Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
@ -266,7 +294,7 @@ export class ToolbarContrastMonitor {
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) { for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
const cssX = const cssX =
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * width; left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
const x = Math.min( const x = Math.min(
this.canvas.width - 1, this.canvas.width - 1,
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale)) Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
@ -275,10 +303,43 @@ export class ToolbarContrastMonitor {
} }
} }
return [...points.values()]; return {
bytesPerRow,
height,
origin,
sampleOffsets: [...points.values()].map(
(point) =>
(point.y - origin.y) * bytesPerRow +
(point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
),
width,
};
} }
private async readBuffer(buffer: GPUBuffer, sampleCount: number): Promise<void> { private getReadbackBuffer(size: number): GPUBuffer | null {
if (this.readbackBuffer && this.readbackBufferSize >= size) {
return this.readbackBuffer;
}
this.readbackBuffer?.destroy();
try {
this.readbackBuffer = this.device.createBuffer({
size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
this.readbackBufferSize = size;
return this.readbackBuffer;
} catch {
this.readbackBuffer = null;
this.readbackBufferSize = 0;
return null;
}
}
private async readBuffer(
buffer: GPUBuffer,
sampleOffsets: Array<number>
): Promise<void> {
let isMapped = false; let isMapped = false;
try { try {
await buffer.mapAsync(GPUMapMode.READ); await buffer.mapAsync(GPUMapMode.READ);
@ -286,7 +347,7 @@ export class ToolbarContrastMonitor {
if (!this.isDestroyed) { if (!this.isDestroyed) {
const pixels = new Uint8Array(buffer.getMappedRange()); const pixels = new Uint8Array(buffer.getMappedRange());
const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra); const metrics = getToolbarContrastMetrics(pixels, sampleOffsets, this.isBgra);
this.setToolbarBackgroundOpacity(metrics.backgroundOpacity); this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
} }
} catch { } catch {
@ -295,8 +356,10 @@ export class ToolbarContrastMonitor {
if (isMapped) { if (isMapped) {
buffer.unmap(); buffer.unmap();
} }
buffer.destroy();
this.isReadbackPending = false; this.isReadbackPending = false;
} }
} }
} }
const alignTo = (value: number, alignment: number): number =>
Math.ceil(value / alignment) * alignment;

View file

@ -82,11 +82,13 @@ const main = async () => {
); );
const splash = new SplashScreen(); const splash = new SplashScreen();
let eraserSizeControl: EraserSizeControl | null = null;
const paletteControl = new PaletteControl({ const paletteControl = new PaletteControl({
getGame, getGame,
onChange: () => configPane?.refresh(), onChange: () => configPane?.refresh(),
onModeChange: (isEraserActive) => eraserSizeControl?.setActive(isEraserActive),
}); });
const eraserSizeControl = new EraserSizeControl({ eraserSizeControl = new EraserSizeControl({
getGame, getGame,
onActivate: () => paletteControl.setEraserActive(true), onActivate: () => paletteControl.setEraserActive(true),
onChange: () => configPane?.refresh(), onChange: () => configPane?.refresh(),
@ -104,7 +106,8 @@ const main = async () => {
}); });
const syncRuntimeUi = () => { const syncRuntimeUi = () => {
eraserSizeControl.render(); eraserSizeControl?.render();
eraserSizeControl?.setActive(paletteControl.isEraserActive);
mirrorSegmentControl.render(); mirrorSegmentControl.render();
paletteControl.render(); paletteControl.render();
}; };
@ -120,12 +123,12 @@ const main = async () => {
new FullScreenHandler(fullScreenButton, document.documentElement); new FullScreenHandler(fullScreenButton, document.documentElement);
new VibeNavigator({ new VibeNavigator({
onChange: ({ vibeId, vibeName, source }) => { onChange: ({ vibeId, vibeName, source, userGesture }) => {
trackVibeChange({ vibeId, vibeName, source }); trackVibeChange({ vibeId, vibeName, source });
game?.onVibeChanged(); game?.onVibeChanged();
syncRuntimeUi(); syncRuntimeUi();
configPane?.refresh(); configPane?.refresh();
game?.playVibeChangeAudio(true); game?.playVibeChangeAudio(userGesture);
}, },
}); });
@ -243,7 +246,6 @@ const main = async () => {
ErrorPresenter.renderStartup(e); ErrorPresenter.renderStartup(e);
ErrorHandler.addException(e); ErrorHandler.addException(e);
} }
console.error(e);
} }
}; };

View file

@ -1,5 +1,4 @@
import { appConfig } from '../config'; import { appConfig } from '../config';
import { DISABLED_FLAG_VALUE, ENABLED_FLAG_VALUE } from '../consts';
import type GameLoop from '../game-loop/game-loop'; import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage'; import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
@ -21,6 +20,9 @@ const readInitialAudioVolume = (): number => {
const formatStoredAudioVolume = (volume: number): string => const formatStoredAudioVolume = (volume: number): string =>
clampAudioVolume(volume).toFixed(2); clampAudioVolume(volume).toFixed(2);
const STORED_MUTED_TRUE = '1';
const STORED_MUTED_FALSE = '0';
interface AudioControlOptions { interface AudioControlOptions {
getGame: () => GameLoop | null; getGame: () => GameLoop | null;
hasStarted: () => boolean; hasStarted: () => boolean;
@ -43,7 +45,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume(); private audioVolume = readInitialAudioVolume();
private isMutedState = private isMutedState =
readBrowserStorage(appConfig.storage.audioMutedKey) === ENABLED_FLAG_VALUE || readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
this.audioVolume <= 0; this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) { public constructor(private readonly options: AudioControlOptions) {
@ -140,7 +142,7 @@ export class AudioControl {
private persist(): void { private persist(): void {
writeBrowserStorage( writeBrowserStorage(
appConfig.storage.audioMutedKey, appConfig.storage.audioMutedKey,
this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
); );
writeBrowserStorage( writeBrowserStorage(
appConfig.storage.audioVolumeKey, appConfig.storage.audioVolumeKey,

View file

@ -1,6 +1,7 @@
export class CollapsiblePanelAnimator { export class CollapsiblePanelAnimator {
private _isOpen = false; private _isOpen = false;
private focusBeforeOpen: HTMLElement | null = null; private focusBeforeOpen: HTMLElement | null = null;
private readonly abortController = new AbortController();
public onOpen?: () => void; public onOpen?: () => void;
public constructor( public constructor(
@ -8,17 +9,23 @@ export class CollapsiblePanelAnimator {
private readonly collapsibleContent: HTMLElement, private readonly collapsibleContent: HTMLElement,
ignoreForCloseOnClick: HTMLElement ignoreForCloseOnClick: HTMLElement
) { ) {
toggleButton.addEventListener('click', this.toggle.bind(this)); const { signal } = this.abortController;
toggleButton.addEventListener('click', this.toggle, { signal });
window.addEventListener( window.addEventListener(
'click', 'click',
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close() (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close(),
{ signal }
);
window.addEventListener(
'keydown',
(event) => {
if (this._isOpen && event.key === 'Escape') {
event.preventDefault();
this.close();
}
},
{ signal }
); );
window.addEventListener('keydown', (event) => {
if (this._isOpen && event.key === 'Escape') {
event.preventDefault();
this.close();
}
});
this.syncAccessibility(); this.syncAccessibility();
} }
@ -49,12 +56,16 @@ export class CollapsiblePanelAnimator {
} }
} }
public toggle() { public readonly toggle = () => {
if (this._isOpen) { if (this._isOpen) {
this.close(); this.close();
} else { } else {
this.open(); this.open();
} }
};
public destroy(): void {
this.abortController.abort();
} }
public get isOpen() { public get isOpen() {

View file

@ -9,18 +9,17 @@ import {
type NumberControlConfig, type NumberControlConfig,
} from '../config'; } from '../config';
import { activeVibe, settings } from '../settings'; import { activeVibe, settings } from '../settings';
import { import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
hexColorToRgbColor, import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
rgbColorToCss,
rgbColorToHex,
type RgbColor,
} from '../utils/rgb-color';
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>; type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
type RuntimeControlKey = keyof GardenRuntimeSettings & string; type RuntimeControlKey = keyof GardenRuntimeSettings & string;
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor'; type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
type VibeNumberKey = keyof GardenAudioVibeSettings; type NumberPropertyKey<T> = {
[Key in keyof T]-?: T[Key] extends number ? Key : never;
}[keyof T] &
string;
type VibeNumberKey = NumberPropertyKey<GardenAudioVibeSettings>;
interface PaneState extends GardenAudioVibeSettings { interface PaneState extends GardenAudioVibeSettings {
backgroundColor: string; backgroundColor: string;
@ -29,31 +28,6 @@ interface PaneState extends GardenAudioVibeSettings {
color3: string; color3: string;
} }
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
const COLOR_REACTION_STATES = [
{ id: 'follow', label: 'Move Toward', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 },
{ id: 'avoid', label: 'Move Away', value: -1 },
] as const;
const colorReactionRows = [
{
colorIndex: 0,
label: COLOR_REACTION_LABELS[0],
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
},
{
colorIndex: 1,
label: COLOR_REACTION_LABELS[1],
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
},
{
colorIndex: 2,
label: COLOR_REACTION_LABELS[2],
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
},
] as const;
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const; const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
const MUSIC_CONTROLS: ReadonlyArray<{ const MUSIC_CONTROLS: ReadonlyArray<{
@ -107,37 +81,11 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
return params; return params;
}; };
const getColorReactionStateIndex = (value: number): number =>
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
const getNextColorReactionState = (
value: number
): (typeof COLOR_REACTION_STATES)[number] => {
const index = getColorReactionStateIndex(value);
return COLOR_REACTION_STATES[
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
];
};
export class ConfigPane { export class ConfigPane {
private readonly container: HTMLDivElement; private readonly container: HTMLDivElement;
private readonly closeButton: HTMLButtonElement; private readonly closeButton: HTMLButtonElement;
private readonly colorReactionMatrix: ColorReactionMatrixControl;
private readonly pane: Pane; private readonly pane: Pane;
private readonly colorReactionButtons = new Map<
ColorReactionKey,
{
element: HTMLButtonElement;
sourceColorIndex: number;
targetColorIndex: number;
}
>();
private readonly colorReactionSwatches: Array<{
colorIndex: number;
element: HTMLElement;
}> = [];
private readonly state: PaneState = { private readonly state: PaneState = {
backgroundColor: rgbColorToHex(activeVibe.backgroundColor), backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
color1: rgbColorToHex(activeVibe.colors[0]), color1: rgbColorToHex(activeVibe.colors[0]),
@ -147,6 +95,9 @@ export class ConfigPane {
}; };
public constructor(private readonly options: ConfigPaneOptions) { public constructor(private readonly options: ConfigPaneOptions) {
this.colorReactionMatrix = new ColorReactionMatrixControl(
this.options.onRuntimeChange
);
this.container = document.createElement('div'); this.container = document.createElement('div');
this.container.className = 'config-pane-container'; this.container.className = 'config-pane-container';
@ -187,7 +138,7 @@ export class ConfigPane {
public refresh(): void { public refresh(): void {
this.syncVibeState(); this.syncVibeState();
this.pane.refresh(); this.pane.refresh();
this.syncColorReactionMatrix(); this.colorReactionMatrix.sync();
this.syncOpenState(); this.syncOpenState();
} }
@ -233,7 +184,7 @@ export class ConfigPane {
this.setUpVibeSection(container); this.setUpVibeSection(container);
this.addRuntimeSection(container, runtimeFolderOrder[0], true); this.addRuntimeSection(container, runtimeFolderOrder[0], true);
this.addRuntimeSection(container, runtimeFolderOrder[1], true); this.addRuntimeSection(container, runtimeFolderOrder[1], true);
this.addColorReactionMatrix(container); this.colorReactionMatrix.addTo(container);
this.addRuntimeSection(container, runtimeFolderOrder[2], true); this.addRuntimeSection(container, runtimeFolderOrder[2], true);
const performanceFolder = this.addRuntimeSection( const performanceFolder = this.addRuntimeSection(
container, container,
@ -242,7 +193,7 @@ export class ConfigPane {
); );
this.addFpsOverlayBinding(performanceFolder); this.addFpsOverlayBinding(performanceFolder);
this.setUpMusicSection(container); this.setUpMusicSection(container);
this.syncColorReactionMatrix(); this.colorReactionMatrix.sync();
} }
private setUpVibeSection(container: PaneContainer): void { private setUpVibeSection(container: PaneContainer): void {
@ -291,7 +242,7 @@ export class ConfigPane {
} }
updateColor(color); updateColor(color);
this.syncColorReactionMatrix(); this.colorReactionMatrix.sync();
this.options.onConfigChange(); this.options.onConfigChange();
}); });
} }
@ -348,132 +299,6 @@ export class ConfigPane {
.on('change', () => this.options.onConfigChange()); .on('change', () => this.options.onConfigChange());
} }
private addColorReactionMatrix(container: PaneContainer): void {
const folder = container.addFolder({
title: 'Color Behavior',
expanded: true,
});
const matrix = document.createElement('div');
matrix.className = 'color-reaction-matrix';
matrix.appendChild(this.createColorReactionCorner());
colorReactionRows.forEach((row) => {
matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
});
colorReactionRows.forEach((row) => {
matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
row.keys.forEach((key, columnIndex) => {
matrix.appendChild(
this.createColorReactionCell(key, row.colorIndex, columnIndex)
);
});
});
const matrixBlade = folder.addBlade({ view: 'separator' });
matrixBlade.element.classList.add('color-reaction-matrix-blade');
matrixBlade.element.replaceChildren(matrix);
this.syncColorReactionMatrix();
}
private createColorReactionCorner(): HTMLDivElement {
const corner = document.createElement('div');
corner.className = 'color-reaction-matrix__corner';
return corner;
}
private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
const header = document.createElement('div');
header.className = 'color-reaction-matrix__header';
header.setAttribute('aria-label', label);
header.title = label;
const swatch = document.createElement('span');
swatch.className = 'color-reaction-matrix__swatch';
this.colorReactionSwatches.push({ colorIndex, element: swatch });
header.appendChild(swatch);
return header;
}
private createColorReactionCell(
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): HTMLDivElement {
const cell = document.createElement('div');
cell.className = 'color-reaction-matrix__cell';
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return cell;
}
const button = document.createElement('button');
button.className = 'color-reaction-matrix__button';
button.type = 'button';
const icon = document.createElement('span');
icon.className = 'color-reaction-matrix__icon';
button.appendChild(icon);
button.addEventListener('click', () => {
const currentValue = normalizeNumberControlValue(settings[key], config);
const nextState = getNextColorReactionState(currentValue);
settings[key] = nextState.value;
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
this.options.onRuntimeChange();
});
this.colorReactionButtons.set(key, {
element: button,
sourceColorIndex,
targetColorIndex,
});
cell.appendChild(button);
return cell;
}
private syncColorReactionMatrix(): void {
this.colorReactionButtons.forEach(
({ element, sourceColorIndex, targetColorIndex }, key) => {
this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex);
}
);
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
});
}
private syncColorReactionButton(
button: HTMLButtonElement,
key: ColorReactionKey,
sourceColorIndex: number,
targetColorIndex: number
): void {
const config = appConfig.runtimeSettings.controls[key];
if (!config) {
return;
}
settings[key] = normalizeNumberControlValue(settings[key], config);
const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]);
const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
button.dataset.reaction = state.id;
button.setAttribute(
'aria-label',
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
);
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
}
private setUpMusicSection(container: PaneContainer): void { private setUpMusicSection(container: PaneContainer): void {
const folder = container.addFolder({ title: 'Music', expanded: true }); const folder = container.addFolder({ title: 'Music', expanded: true });
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => { MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {

View file

@ -26,14 +26,15 @@ export class EraserSizeControl {
HTMLLabelElement HTMLLabelElement
); );
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement); private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
private isActive = false;
public constructor(private readonly options: EraserSizeControlOptions) { public constructor(private readonly options: EraserSizeControlOptions) {
this.control.addEventListener('pointerdown', this.options.onActivate); this.control.addEventListener('pointerdown', this.activate);
this.control.addEventListener('click', this.options.onActivate); this.control.addEventListener('click', this.activate);
this.slider.addEventListener('focus', this.options.onActivate); this.slider.addEventListener('focus', this.activate);
this.slider.addEventListener('input', () => { this.slider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(this.slider.value)); settings.eraserSize = clampEraserSize(Number(this.slider.value));
this.options.onActivate(); this.activate();
this.render(); this.render();
this.options.onChange(); this.options.onChange();
}); });
@ -59,6 +60,25 @@ export class EraserSizeControl {
ratio; ratio;
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.syncActiveState();
this.options.getGame()?.updateEraserPreview(); this.options.getGame()?.updateEraserPreview();
} }
public setActive(isActive: boolean): void {
this.isActive = isActive;
this.syncActiveState();
}
private readonly activate = () => {
this.setActive(true);
this.options.onActivate();
};
private syncActiveState(): void {
this.control.classList.toggle('active', this.isActive);
this.slider.setAttribute(
'aria-label',
this.isActive ? 'Eraser size, active' : 'Eraser size'
);
}
} }

View file

@ -1,4 +1,6 @@
export class FullScreenHandler { export class FullScreenHandler {
private readonly abortController = new AbortController();
public constructor( public constructor(
private readonly toggleButton: HTMLElement, private readonly toggleButton: HTMLElement,
target: HTMLElement target: HTMLElement
@ -10,26 +12,35 @@ export class FullScreenHandler {
this.updateButtons(); this.updateButtons();
addEventListener('fullscreenchange', this.updateButtons.bind(this)); const { signal } = this.abortController;
toggleButton.addEventListener('click', () => { addEventListener('fullscreenchange', this.updateButtons, { signal });
if (FullScreenHandler.isInFullScreenMode()) { toggleButton.addEventListener(
void document.exitFullscreen(); 'click',
return; () => {
} if (FullScreenHandler.isInFullScreenMode()) {
void document.exitFullscreen();
return;
}
void target.requestFullscreen().catch(() => undefined); void target.requestFullscreen().catch(() => undefined);
}); },
{ signal }
);
} }
public static isInFullScreenMode(): boolean { public static isInFullScreenMode(): boolean {
return document.fullscreenElement !== null; return document.fullscreenElement !== null;
} }
private updateButtons(): void { public destroy(): void {
this.abortController.abort();
}
private readonly updateButtons = (): void => {
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode(); const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen'; const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
this.toggleButton.classList.toggle('active', isInFullScreenMode); this.toggleButton.classList.toggle('active', isInFullScreenMode);
this.toggleButton.setAttribute('aria-label', label); this.toggleButton.setAttribute('aria-label', label);
this.toggleButton.title = label; this.toggleButton.title = label;
} };
} }

View file

@ -99,18 +99,18 @@ export class MenuHider {
} }
private reveal(): void { private reveal(): void {
if (!this.isHidden && this.hideTimeout === undefined) {
return;
}
this.clearHideTimeout(); this.clearHideTimeout();
this.isHidden = false; this.isHidden = false;
this.element.classList.remove('menu-hidden'); this.element.classList.remove('menu-hidden');
this.element.setAttribute('aria-hidden', 'false');
this.element.inert = false;
} }
private hide(): void { private hide(): void {
this.isHidden = true; this.isHidden = true;
this.element.classList.add('menu-hidden'); this.element.classList.add('menu-hidden');
this.element.setAttribute('aria-hidden', 'true');
this.element.inert = true;
} }
private clearHideTimeout(): void { private clearHideTimeout(): void {

View file

@ -14,7 +14,7 @@ const getMirrorSegmentRatio = (count: number): number => {
}; };
const formatMirrorSegmentCount = (count: number): string => const formatMirrorSegmentCount = (count: number): string =>
count === appConfig.toolbar.mirror.default count <= 1
? appConfig.toolbar.mirror.offLabel ? appConfig.toolbar.mirror.offLabel
: `${count} ${ : `${count} ${
appConfig.toolbar.mirror.names[ appConfig.toolbar.mirror.names[

View file

@ -1,20 +1,16 @@
import type GameLoop from '../game-loop/game-loop'; import type GameLoop from '../game-loop/game-loop';
import { activeVibe, settings } from '../settings'; import { activeVibe, settings } from '../settings';
import { queryRequiredElement } from '../utils/dom';
import { ErrorCode, RuntimeError } from '../utils/error-handler'; import { ErrorCode, RuntimeError } from '../utils/error-handler';
import { rgbColorToCss } from '../utils/rgb-color'; import { rgbColorToCss } from '../utils/rgb-color';
interface PaletteControlOptions { interface PaletteControlOptions {
getGame: () => GameLoop | null; getGame: () => GameLoop | null;
onChange: () => void; onChange: () => void;
onModeChange?: (isEraserActive: boolean) => void;
} }
export class PaletteControl { export class PaletteControl {
private readonly swatches = queryRequiredColorSwatches(); private readonly swatches = queryRequiredColorSwatches();
private readonly eraserControl = queryRequiredElement(
'.eraser-size-control',
HTMLLabelElement
);
private isEraserActiveState = false; private isEraserActiveState = false;
public constructor(private readonly options: PaletteControlOptions) { public constructor(private readonly options: PaletteControlOptions) {
@ -23,6 +19,7 @@ export class PaletteControl {
settings.selectedColorIndex = index; settings.selectedColorIndex = index;
this.isEraserActiveState = false; this.isEraserActiveState = false;
this.render(); this.render();
this.options.onModeChange?.(false);
this.options.onChange(); this.options.onChange();
}); });
}); });
@ -35,17 +32,16 @@ export class PaletteControl {
public setEraserActive(active: boolean): void { public setEraserActive(active: boolean): void {
this.isEraserActiveState = active; this.isEraserActiveState = active;
this.render(); this.render();
this.options.onModeChange?.(active);
} }
public render(): void { public render(): void {
this.swatches.forEach((swatch, index) => { this.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]); swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
swatch.classList.toggle( const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState;
'active', swatch.classList.toggle('active', isActive);
settings.selectedColorIndex === index && !this.isEraserActiveState swatch.setAttribute('aria-pressed', String(isActive));
);
}); });
this.eraserControl.classList.toggle('active', this.isEraserActiveState);
this.options.getGame()?.setEraseMode(this.isEraserActiveState); this.options.getGame()?.setEraseMode(this.isEraserActiveState);
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
'--garden-background', '--garden-background',

View file

@ -30,13 +30,24 @@ export class SplashScreen {
public awaitStart(onStart: () => void): Promise<void> { public awaitStart(onStart: () => void): Promise<void> {
this.startButton.disabled = false; this.startButton.disabled = false;
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.defaultPrevented) {
return;
}
event.preventDefault();
this.startButton.click();
};
const onClick = () => { const onClick = () => {
this.startButton.removeEventListener('click', onClick); this.startButton.removeEventListener('click', onClick);
document.removeEventListener('keydown', onKeyDown);
onStart(); onStart();
this.setVisible(this.splash, false); this.setVisible(this.splash, false);
resolve(); resolve();
}; };
this.startButton.addEventListener('click', onClick); this.startButton.addEventListener('click', onClick);
document.addEventListener('keydown', onKeyDown);
}); });
} }

View file

@ -1,9 +1,11 @@
import { activeVibe, applyVibeSettings } from '../settings'; import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
import { VIBE_PRESETS, type VibeId } from '../vibes'; import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri';
import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes';
interface VibeSelection { interface VibeSelection {
source: string; source: string;
userGesture: boolean;
vibeId: VibeId; vibeId: VibeId;
vibeName: string; vibeName: string;
} }
@ -13,6 +15,7 @@ interface VibeNavigatorOptions {
} }
export class VibeNavigator { export class VibeNavigator {
private readonly abortController = new AbortController();
private readonly previousButton = queryRequiredElement( private readonly previousButton = queryRequiredElement(
'.previous-vibe', '.previous-vibe',
HTMLButtonElement HTMLButtonElement
@ -20,18 +23,60 @@ export class VibeNavigator {
private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement); private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
public constructor(private readonly options: VibeNavigatorOptions) { public constructor(private readonly options: VibeNavigatorOptions) {
this.previousButton.addEventListener('click', () => rememberActiveVibeSelection();
this.select(-1, 'previous-button') writeCurrentVibeUri(activeVibe.id, 'replace');
const { signal } = this.abortController;
this.previousButton.addEventListener(
'click',
() => this.select(-1, 'previous-button'),
{ signal }
); );
this.nextButton.addEventListener('click', () => this.select(1, 'next-button')); this.nextButton.addEventListener('click', () => this.select(1, 'next-button'), {
signal,
});
window.addEventListener('popstate', this.selectFromCurrentUri, { signal });
}
public destroy(): void {
this.abortController.abort();
} }
private select(offset: number, source: string): void { private select(offset: number, source: string): void {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const currentIndex = current >= 0 ? current : 0;
const vibe = const vibe =
VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length]; VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
const activePreset = applyVibeSettings(vibe); const activePreset = applyVibeSettings(vibe);
writeCurrentVibeUri(activePreset.id, 'push');
this.notifyChange(activePreset, source, true);
}
private readonly selectFromCurrentUri = (): void => {
const vibeId = getCurrentUriVibeId();
if (!vibeId || vibeId === activeVibe.id) {
writeCurrentVibeUri(activeVibe.id, 'replace');
return;
}
const vibe = getVibeById(vibeId);
if (!vibe) {
writeCurrentVibeUri(activeVibe.id, 'replace');
return;
}
const activePreset = applyVibeSettings(vibe);
writeCurrentVibeUri(activePreset.id, 'replace');
this.notifyChange(activePreset, 'uri-popstate', false);
};
private notifyChange(
activePreset: typeof activeVibe,
source: string,
userGesture: boolean
): void {
this.options.onChange({ this.options.onChange({
userGesture,
vibeId: activePreset.id, vibeId: activePreset.id,
vibeName: activePreset.name, vibeName: activePreset.name,
source, source,

View file

@ -1,15 +1,43 @@
// Use the device's max workgroup size so we get full SIMD/wave occupancy on const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const;
// hardware that supports more than the WebGPU minimum of 256.
export const getAgentWorkgroupSize = (device: GPUDevice): number => export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number];
device.limits.maxComputeInvocationsPerWorkgroup;
const AGENT_WORKGROUP_SIZE_TARGETS = {
// Keep shader-specific targets conservative. Using the device maximum can
// hurt occupancy and makes compaction's workgroup scan more expensive.
simulation: 256,
eraser: 256,
resize: 256,
compaction: 256,
} satisfies Record<AgentWorkgroupKind, number>;
export const getAgentWorkgroupSize = (
device: GPUDevice,
kind: AgentWorkgroupKind = 'simulation'
): number => {
const deviceLimit = Math.max(
1,
Math.floor(
Math.min(
device.limits.maxComputeInvocationsPerWorkgroup,
device.limits.maxComputeWorkgroupSizeX
)
)
);
return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit);
};
export const getMinAgentWorkgroupSize = (device: GPUDevice): number =>
Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind)));
export const substituteAgentWorkgroupSize = ( export const substituteAgentWorkgroupSize = (
device: GPUDevice, device: GPUDevice,
shaderCode: string shaderCode: string,
kind: AgentWorkgroupKind = 'simulation'
): string => ): string =>
shaderCode.replaceAll( shaderCode.replaceAll(
'__AGENT_WORKGROUP_SIZE__', '__AGENT_WORKGROUP_SIZE__',
String(getAgentWorkgroupSize(device)) String(getAgentWorkgroupSize(device, kind))
); );
export const dispatchAgentWorkgroups = ( export const dispatchAgentWorkgroups = (

View file

@ -9,7 +9,7 @@ struct Counters {
aliveAgentCount: atomic<u32>, aliveAgentCount: atomic<u32>,
}; };
const clearCompactedTailStride = 4u; const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u;
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var<storage, read_write> counters: Counters; @group(1) @binding(2) var<storage, read_write> counters: Counters;
@ -30,7 +30,7 @@ fn main(
var isAlive = false; var isAlive = false;
var agent: Agent; var agent: Agent;
if id < settings.agentCount { if id < settings.agentCount {
isAlive = agents[id].colorIndex >= 0.0; isAlive = agents[id].colorIndex >= 0.0 && agents[id].colorIndex < 2.5;
if isAlive { if isAlive {
agent = agents[id]; agent = agents[id];
} }

View file

@ -20,7 +20,7 @@ export class AgentGenerationPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUBuffer>( private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUBuffer]>(
(active, inactive) => (active, inactive) =>
this.device.createBindGroup({ this.device.createBindGroup({
layout: this.bindGroupLayout, layout: this.bindGroupLayout,
@ -36,7 +36,8 @@ export class AgentGenerationPipeline {
private readonly resizePipeline: GPUComputePipeline; private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline;
private readonly clearCompactedTailPipeline: GPUComputePipeline; private readonly clearCompactedTailPipeline: GPUComputePipeline;
private readonly workgroupSize: number; private readonly resizeWorkgroupSize: number;
private readonly compactionWorkgroupSize: number;
private activeAgentsBuffer: GPUBuffer; private activeAgentsBuffer: GPUBuffer;
private inactiveAgentsBuffer: GPUBuffer; private inactiveAgentsBuffer: GPUBuffer;
@ -110,20 +111,33 @@ export class AgentGenerationPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
this.workgroupSize = getAgentWorkgroupSize(device); this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize');
const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema); this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction');
const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize');
const compactionSchema = substituteAgentWorkgroupSize(
device,
agentSchema,
'compaction'
);
this.resizePipeline = device.createComputePipeline({ this.resizePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: smartCompile(device, sizedSchema, resizeShader), module: smartCompile(device, resizeSchema, resizeShader),
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
const compactionModule = smartCompile(device, sizedSchema, compactionShader); const compactionModule = smartCompile(
device,
compactionSchema,
compactionShader.replaceAll(
'__CLEAR_COMPACTED_TAIL_STRIDE__',
AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE.toString()
)
);
this.compactionPipeline = device.createComputePipeline({ this.compactionPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
@ -248,7 +262,7 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.resizePipeline); passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.getBindGroup()); passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount); dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount);
passEncoder.end(); passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
@ -267,11 +281,11 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline); passEncoder.setPipeline(this.compactionPipeline);
passEncoder.setBindGroup(1, this.getBindGroup()); passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount); dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount);
passEncoder.setPipeline(this.clearCompactedTailPipeline); passEncoder.setPipeline(this.clearCompactedTailPipeline);
dispatchAgentWorkgroups( dispatchAgentWorkgroups(
passEncoder, passEncoder,
this.workgroupSize, this.compactionWorkgroupSize,
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE) Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
); );
passEncoder.end(); passEncoder.end();

View file

@ -1,4 +1,4 @@
import { getAgentWorkgroupSize } from './agent-dispatch'; import { getMinAgentWorkgroupSize } from './agent-dispatch';
export const AGENT_FLOAT_COUNT = 8; export const AGENT_FLOAT_COUNT = 8;
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
@ -58,7 +58,7 @@ export const getMaxSupportedAgentCount = (
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES), Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
getAgentWorkgroupSize(device) getMinAgentWorkgroupSize(device)
) )
); );
}; };

View file

@ -1,4 +1,4 @@
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache'; import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedBufferWrite, createCachedBufferWrite,
writeBufferIfChanged, writeBufferIfChanged,
@ -38,28 +38,34 @@ export interface AgentSettings {
introNearDistanceInner: number; introNearDistanceInner: number;
introTurnRateMultiplier: number; introTurnRateMultiplier: number;
introRandomTurnMultiplier: number; introRandomTurnMultiplier: number;
introFarMoveMultiplier: number;
introNearMoveMultiplier: number;
introStepStopDistance: number; introStepStopDistance: number;
randomTimeScale: number; randomTimeScale: number;
} }
const UNIFORM_COUNT = 30; // The Settings struct in WGSL starts with a mat3x3<f32> reactionMatrix.
// In uniform layout each of its 3 columns is stored as a vec3<f32> padded to
// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused
// padding). Remaining scalars pack tightly from float 12 onward.
const UNIFORM_COUNT = 32;
const REACTION_MATRIX_COL0 = 0;
const REACTION_MATRIX_COL1 = 4;
const REACTION_MATRIX_COL2 = 8;
const SCALAR_BASE = 12;
export class AgentPipeline { export class AgentPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline; private readonly pipelineFull: GPUComputePipeline;
private readonly pipelineSteady: GPUComputePipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly workgroupSize: number; private readonly workgroupSize: number;
private useSteadyPipeline = false;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer); private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedBufferWrite( private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
); );
private readonly bindGroupCache = createBindGroupCache3< private readonly bindGroupCache = createBindGroupCache<
GPUBuffer, [GPUBuffer, GPUTextureView, GPUTextureView]
GPUTextureView,
GPUTextureView
>((agentsBuffer, trailMapIn, trailMapOut) => >((agentsBuffer, trailMapIn, trailMapOut) =>
this.device.createBindGroup({ this.device.createBindGroup({
layout: this.bindGroupLayout, layout: this.bindGroupLayout,
@ -104,23 +110,30 @@ export class AgentPipeline {
], ],
}); });
this.workgroupSize = getAgentWorkgroupSize(device); this.workgroupSize = getAgentWorkgroupSize(device, 'simulation');
const shaderModule = smartCompile( const shaderModule = smartCompile(
device, device,
CommonState.shaderCode, CommonState.shaderCode,
substituteAgentWorkgroupSize(device, agentSchema), substituteAgentWorkgroupSize(device, agentSchema, 'simulation'),
shader shader
); );
const pipelineLayout = device.createPipelineLayout({ const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}); });
this.pipeline = device.createComputePipeline({ this.pipelineFull = device.createComputePipeline({
layout: pipelineLayout, layout: pipelineLayout,
compute: { compute: {
module: shaderModule, module: shaderModule,
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
this.pipelineSteady = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: shaderModule,
entryPoint: 'mainSteady',
},
});
this.uniforms = device.createBuffer({ this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -153,8 +166,7 @@ export class AgentPipeline {
introProgressCutoff, introProgressCutoff,
introTurnRateMultiplier, introTurnRateMultiplier,
introRandomTurnMultiplier, introRandomTurnMultiplier,
introFarMoveMultiplier, introMoveSpeed,
introNearMoveMultiplier,
introStepStopDistance, introStepStopDistance,
randomTimeScale, randomTimeScale,
time, time,
@ -164,40 +176,46 @@ export class AgentPipeline {
deltaTime: number; deltaTime: number;
time: number; time: number;
agentCount: number; agentCount: number;
introMoveSpeed: number;
introProgress?: number; introProgress?: number;
}) { }) {
this.agentCount = agentCount; this.agentCount = agentCount;
this.uniformValues[0] = moveSpeed * deltaTime; const resolvedIntroProgress = introProgress ?? 1;
this.uniformValues[1] = turnSpeed * deltaTime; // Once the intro target phase ends nothing reads intro fields again, so the
// steady-only pipeline can replace the full one for the rest of the session.
this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff;
// Reaction matrix: column N holds the weights for source colorIndex == N.
this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1;
this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2;
this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3;
this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1;
this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2;
this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3;
this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1;
this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2;
this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3;
this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime;
this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime;
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180; const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
this.uniformValues[2] = Math.sin(sensorAngle); this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle);
this.uniformValues[3] = Math.cos(sensorAngle); this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle);
this.uniformValues[4] = sensorOffsetDistance; this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance;
this.uniformValues[5] = turnWhenLost; this.uniformValues[SCALAR_BASE + 5] = turnWhenLost;
this.uniformValues[6] = individualTrailWeight; this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight;
this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount)); this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount));
this.uniformValues[8] = introProgress ?? 1; this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress;
this.uniformValues[9] = color1ToColor1; this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale;
this.uniformValues[10] = color1ToColor2; this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner;
this.uniformValues[11] = color1ToColor3; this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin;
this.uniformValues[12] = color2ToColor1; this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier;
this.uniformValues[13] = color2ToColor2; this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend;
this.uniformValues[14] = color2ToColor3; this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff;
this.uniformValues[15] = color3ToColor1; this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier;
this.uniformValues[16] = color3ToColor2; this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier;
this.uniformValues[17] = color3ToColor3; this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime;
this.uniformValues[18] = forwardRotationScale; this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance;
this.uniformValues[19] = introNearDistanceInner; this.uniformUintValues[SCALAR_BASE + 19] =
this.uniformValues[20] = introNearDistanceMin; Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
this.uniformValues[21] = introNearSensorOffsetMultiplier;
this.uniformValues[22] = introTargetAngleBlend;
this.uniformValues[23] = introProgressCutoff;
this.uniformValues[24] = introTurnRateMultiplier;
this.uniformValues[25] = introRandomTurnMultiplier;
this.uniformValues[26] = introFarMoveMultiplier;
this.uniformValues[27] = introNearMoveMultiplier;
this.uniformValues[28] = introStepStopDistance;
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
writeBufferIfChanged( writeBufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
@ -219,7 +237,9 @@ export class AgentPipeline {
const passEncoder = commandEncoder.beginComputePass( const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined timestampWrites ? { timestampWrites } : undefined
); );
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(
this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull
);
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setBindGroup( passEncoder.setBindGroup(
1, 1,

View file

@ -2,7 +2,16 @@ const PI: f32 = 3.14159265359;
const TAU: f32 = 6.28318530718; const TAU: f32 = 6.28318530718;
const INV_TAU: f32 = 0.15915494309; const INV_TAU: f32 = 0.15915494309;
const CHANNEL_MASKS = array<vec3<f32>, 3>(
vec3<f32>(1.0, 0.0, 0.0),
vec3<f32>(0.0, 1.0, 0.0),
vec3<f32>(0.0, 0.0, 1.0),
);
struct Settings { struct Settings {
// Columns are indexed by source colorIndex; each column holds the per-target
// weights (colorXToColor1, colorXToColor2, colorXToColor3).
reactionMatrix: mat3x3<f32>,
moveRate: f32, moveRate: f32,
turnRate: f32, turnRate: f32,
sensorAngleSin: f32, sensorAngleSin: f32,
@ -12,15 +21,6 @@ struct Settings {
individualTrailWeight: f32, individualTrailWeight: f32,
agentCount: u32, agentCount: u32,
introProgress: f32, introProgress: f32,
color1ToColor1: f32,
color1ToColor2: f32,
color1ToColor3: f32,
color2ToColor1: f32,
color2ToColor2: f32,
color2ToColor3: f32,
color3ToColor1: f32,
color3ToColor2: f32,
color3ToColor3: f32,
forwardRotationScale: f32, forwardRotationScale: f32,
introNearDistanceInner: f32, introNearDistanceInner: f32,
introNearDistanceMin: f32, introNearDistanceMin: f32,
@ -29,8 +29,7 @@ struct Settings {
introProgressCutoff: f32, introProgressCutoff: f32,
introTurnRateMultiplier: f32, introTurnRateMultiplier: f32,
introRandomTurnMultiplier: f32, introRandomTurnMultiplier: f32,
introFarMoveMultiplier: f32, introMoveRate: f32,
introNearMoveMultiplier: f32,
introStepStopDistance: f32, introStepStopDistance: f32,
randomTimeSeed: u32, randomTimeSeed: u32,
}; };
@ -39,6 +38,11 @@ struct Settings {
@group(1) @binding(2) var trailMapIn: texture_2d<f32>; @group(1) @binding(2) var trailMapIn: texture_2d<f32>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>; @group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
struct AgentMovement {
rotation: f32,
step: vec2<f32>,
}
@compute @workgroup_size(agentWorkgroupSize) @compute @workgroup_size(agentWorkgroupSize)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32> @builtin(global_invocation_id) global_id: vec3<u32>
@ -54,8 +58,8 @@ fn main(
return; return;
} }
var position = agents[id].position; let position = agents[id].position;
var angle = agents[id].angle; let angle = agents[id].angle;
var targetPosition = vec2<f32>(-1.0, -1.0); var targetPosition = vec2<f32>(-1.0, -1.0);
var hasIntroTarget = false; var hasIntroTarget = false;
if settings.introProgress < settings.introProgressCutoff { if settings.introProgress < settings.introProgressCutoff {
@ -70,92 +74,153 @@ fn main(
let reactionMask = get_reaction_mask(colorIndex); let reactionMask = get_reaction_mask(colorIndex);
let randomSeed = random_seed(id); let randomSeed = random_seed(id);
let maxPosition = state.size - vec2<f32>(1.0, 1.0); let maxPosition = state.size - vec2<f32>(1.0, 1.0);
var rotation = 0.0;
var step = vec2<f32>(0.0, 0.0);
var movement = AgentMovement(0.0, vec2<f32>(0.0, 0.0));
if hasIntroTarget { if hasIntroTarget {
let introTargetOffset = targetPosition - position; movement = intro_decide(id, position, angle, targetPosition, randomSeed);
let introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep(
settings.introNearDistanceInner,
max(
settings.introNearDistanceMin,
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
),
introTargetDistance
);
let desiredAngle = mix(
targetAngle,
agents[id].targetAngle,
nearTitle * settings.introTargetAngleBlend
);
let introTurn = angle_delta(angle, desiredAngle);
rotation = clamp(
introTurn,
-settings.turnRate * settings.introTurnRateMultiplier,
settings.turnRate * settings.introTurnRateMultiplier
)
+ (random_float(randomSeed + 1013904223u) - 0.5) *
settings.turnWhenLost *
settings.introRandomTurnMultiplier;
let moveRate = min(
settings.moveRate *
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
introTargetDistance
);
if introTargetDistance > settings.introStepStopDistance {
step = introTargetOffset / introTargetDistance * moveRate;
}
} else { } else {
let randomTurn = random_float(randomSeed); movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let weightForward = dot(trailForward.rgb, reactionMask);
let weightLeft = dot(trailLeft.rgb, reactionMask);
let weightRight = dot(trailRight.rgb, reactionMask);
rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
step = direction * settings.moveRate;
} }
let nextPosition = clamp(position + step, vec2<f32>(0, 0), maxPosition); agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
}
// Steady-state-only entry point used after introProgress >= introProgressCutoff.
// Drops the intro target reads, atan2/smoothstep math, and introDelay check
// once intro completes those paths are dead for the rest of the session.
@compute @workgroup_size(agentWorkgroupSize)
fn mainSteady(
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id);
if id >= settings.agentCount {
return;
}
let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 || colorIndex >= 2.5 {
return;
}
let position = agents[id].position;
let angle = agents[id].angle;
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
let randomSeed = random_seed(id);
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
}
fn steady_decide(
position: vec2<f32>,
angle: f32,
reactionMask: vec3<f32>,
randomSeed: u32,
maxPosition: vec2<f32>
) -> AgentMovement {
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let weightForward = dot(trailForward.rgb, reactionMask);
let weightLeft = dot(trailLeft.rgb, reactionMask);
let weightRight = dot(trailRight.rgb, reactionMask);
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
return AgentMovement(rotation, direction * settings.moveRate);
}
fn intro_decide(
id: u32,
position: vec2<f32>,
angle: f32,
targetPosition: vec2<f32>,
randomSeed: u32
) -> AgentMovement {
let introTargetOffset = targetPosition - position;
let introTargetDistance = length(introTargetOffset);
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
let nearTitle = 1.0 - smoothstep(
settings.introNearDistanceInner,
max(
settings.introNearDistanceMin,
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
),
introTargetDistance
);
let desiredAngle = mix(
targetAngle,
agents[id].targetAngle,
nearTitle * settings.introTargetAngleBlend
);
let introTurn = angle_delta(angle, desiredAngle);
let rotation = clamp(
introTurn,
-settings.turnRate * settings.introTurnRateMultiplier,
settings.turnRate * settings.introTurnRateMultiplier
)
+ (random_float(randomSeed + 1013904223u) - 0.5) *
settings.turnWhenLost *
settings.introRandomTurnMultiplier;
let moveRate = min(settings.introMoveRate, introTargetDistance);
var step = vec2<f32>(0.0, 0.0);
if introTargetDistance > settings.introStepStopDistance {
step = introTargetOffset / introTargetDistance * moveRate;
}
return AgentMovement(rotation, step);
}
fn agent_finalize(
id: u32,
position: vec2<f32>,
angle: f32,
channelMask: vec3<f32>,
randomSeed: u32,
maxPosition: vec2<f32>,
movement: AgentMovement
) {
let nextPosition = clamp(position + movement.step, vec2<f32>(0, 0), maxPosition);
var rotation = movement.rotation;
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y { if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
rotation = PI + random_float(randomSeed + 22695477u) - 0.5; rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
} }
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0); // Writes only this agent's last-writer-wins deposit into a per-frame-cleared
trailBelow = vec4<f32>( // depositMap. Storage textures do not blend concurrent compute writes, so
trailBelow.rgb + channelMask * settings.individualTrailWeight, // overlapping agents intentionally collapse to whichever write wins. The
max(trailBelow.a, 0.0) // diffusion pass then sums trailMap + depositMap at tile-load time.
textureStore(
trailMapOut,
vec2<i32>(nextPosition),
vec4<f32>(channelMask * settings.individualTrailWeight, 0.0)
); );
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
agents[id].angle = angle + rotation; agents[id].angle = angle + rotation;
agents[id].position = nextPosition; agents[id].position = nextPosition;
} }
@ -181,41 +246,11 @@ fn rotate_direction(direction: vec2<f32>, angleSin: f32, angleCos: f32) -> vec2<
} }
fn get_channel_mask(colorIndex: f32) -> vec3<f32> { fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
if colorIndex < 0.5 { return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))];
return vec3<f32>(1, 0, 0);
}
if colorIndex < 1.5 {
return vec3<f32>(0, 1, 0);
}
if colorIndex < 2.5 {
return vec3<f32>(0, 0, 1);
}
return vec3<f32>(0.0, 0.0, 0.0);
} }
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> { fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
if colorIndex < 0.5 { return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))];
return vec3<f32>(
settings.color1ToColor1,
settings.color1ToColor2,
settings.color1ToColor3
);
}
if colorIndex < 1.5 {
return vec3<f32>(
settings.color2ToColor1,
settings.color2ToColor2,
settings.color2ToColor3
);
}
if colorIndex < 2.5 {
return vec3<f32>(
settings.color3ToColor1,
settings.color3ToColor2,
settings.color3ToColor3
);
}
return vec3<f32>(0.0, 0.0, 0.0);
} }
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 { fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {

View file

@ -1,6 +1,7 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { getRenderQualityBrushSize } from '../../config/brush-size';
import { import {
createCachedBufferWrite, createCachedBufferWrite,
writeBufferIfChanged, writeBufferIfChanged,
@ -28,6 +29,7 @@ export interface BrushSettings {
} }
interface BrushParameters extends BrushSettings { interface BrushParameters extends BrushSettings {
internalRenderAreaMegapixels: number;
pixelRatio?: number; pixelRatio?: number;
selectedColorIndex: number; selectedColorIndex: number;
} }
@ -50,12 +52,16 @@ const setBrushUniformValues = (
brushGrainNoiseOffsetY, brushGrainNoiseOffsetY,
brushGrainMinStrength, brushGrainMinStrength,
brushGrainMaxStrength, brushGrainMaxStrength,
internalRenderAreaMegapixels,
selectedColorIndex, selectedColorIndex,
pixelRatio, pixelRatio,
}: BrushParameters }: BrushParameters
): void => { ): void => {
const safePixelRatio = getSafePixelRatio(pixelRatio); const safePixelRatio = getSafePixelRatio(pixelRatio);
const brushRadius = (brushSize * safePixelRatio) / 2; const brushRadius =
(getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) *
safePixelRatio) /
2;
target[0] = brushRadius; target[0] = brushRadius;
target[1] = brushRadius * brushRadius; target[1] = brushRadius * brushRadius;
@ -116,7 +122,7 @@ export class BrushPipeline {
}, },
fragment: { fragment: {
module: shaderModule, module: shaderModule,
entryPoint: 'fragmentMrt', entryPoint: 'fragment',
targets: [ targets: [
{ {
format: TRAIL_SOURCE_TEXTURE_FORMAT, format: TRAIL_SOURCE_TEXTURE_FORMAT,
@ -160,7 +166,7 @@ export class BrushPipeline {
this.segments.flush(); this.segments.flush();
} }
public executeMultiTarget( public executeSource(
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView, sourceMapOut: GPUTextureView,
timestampWrites?: GPURenderPassTimestampWrites timestampWrites?: GPURenderPassTimestampWrites
@ -170,6 +176,7 @@ export class BrushPipeline {
return false; return false;
} }
recordBrushPassForE2e();
const passEncoder = commandEncoder.beginRenderPass({ const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }], colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
timestampWrites, timestampWrites,
@ -188,3 +195,12 @@ export class BrushPipeline {
this.uniforms.destroy(); this.uniforms.destroy();
} }
} }
const recordBrushPassForE2e = (): void => {
if (typeof window === 'undefined') {
return;
}
const state = window as Window & { __fleetingGardenBrushPasses?: number };
state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1;
};

View file

@ -50,7 +50,7 @@ fn vertex(
} }
@fragment @fragment
fn fragmentMrt( fn fragment(
@location(0) screenPosition: vec2<f32>, @location(0) screenPosition: vec2<f32>,
@location(1) @interpolate(flat) start: vec2<f32>, @location(1) @interpolate(flat) start: vec2<f32>,
@location(2) @interpolate(flat) direction: vec2<f32>, @location(2) @interpolate(flat) direction: vec2<f32>,

View file

@ -27,9 +27,13 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
@group(0) @binding(0) var<uniform> settings: Settings; @group(0) @binding(0) var<uniform> settings: Settings;
@group(0) @binding(1) var trailMap: texture_2d<f32>; @group(0) @binding(1) var trailMap: texture_2d<f32>;
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>; @group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
// Per-frame deposit accumulator written sparsely by agents. Summed with
// trailMap at tile-load so deposits propagate through the diffusion kernel
// in the same frame.
@group(0) @binding(3) var depositMap: texture_2d<f32>;
var<workgroup> tile: array<vec4<f32>, 324>; var<workgroup> tile: array<vec4<f32>, TILE_TEXEL_COUNT>;
var<workgroup> tileTrailStrength: array<f32, 324>; var<workgroup> tileTrailStrength: array<f32, TILE_TEXEL_COUNT>;
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__) @compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
fn main( fn main(
@ -49,7 +53,8 @@ fn main(
vec2<i32>(0, 0), vec2<i32>(0, 0),
textureBound textureBound
); );
let texel = textureLoad(trailMap, sourcePixel, 0); let texel = textureLoad(trailMap, sourcePixel, 0)
+ textureLoad(depositMap, sourcePixel, 0);
tile[tileIndex] = texel; tile[tileIndex] = texel;
tileTrailStrength[tileIndex] = length(texel.rgb); tileTrailStrength[tileIndex] = length(texel.rgb);
} }

View file

@ -69,20 +69,27 @@ export class DiffusionPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline; private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
// 1x1 zero texture used as the depositMap binding when callers don't supply
// one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for
// out-of-bounds coordinates, so the diffusion shader sums in zeros.
private readonly emptyDepositTexture: GPUTexture;
private readonly emptyDepositTextureView: GPUTextureView;
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedBufferWrite( private readonly uniformCache = createCachedBufferWrite(
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
); );
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>( private readonly getBindGroup = createBindGroupCache<
(trailMapIn, trailMapOut) => [GPUTextureView, GPUTextureView, GPUTextureView]
this.device.createBindGroup({ >((trailMapIn, trailMapOut, depositMap) =>
layout: this.bindGroupLayout, this.device.createBindGroup({
entries: [ layout: this.bindGroupLayout,
{ binding: 0, resource: { buffer: this.uniforms } }, entries: [
{ binding: 1, resource: trailMapIn }, { binding: 0, resource: { buffer: this.uniforms } },
{ binding: 2, resource: trailMapOut }, { binding: 1, resource: trailMapIn },
], { binding: 2, resource: trailMapOut },
}) { binding: 3, resource: depositMap },
],
})
); );
public constructor(private readonly device: GPUDevice) { public constructor(private readonly device: GPUDevice) {
@ -104,6 +111,26 @@ export class DiffusionPipeline {
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
this.emptyDepositTexture = device.createTexture({
format: TRAIL_SOURCE_TEXTURE_FORMAT,
size: { width: 1, height: 1 },
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
});
this.emptyDepositTextureView = this.emptyDepositTexture.createView();
const clearEncoder = device.createCommandEncoder();
const clearPass = clearEncoder.beginRenderPass({
colorAttachments: [
{
view: this.emptyDepositTextureView,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
clearPass.end();
device.queue.submit([clearEncoder.finish()]);
} }
public setParameters({ public setParameters({
@ -135,9 +162,14 @@ export class DiffusionPipeline {
trailMapIn: GPUTextureView, trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView, trailMapOut: GPUTextureView,
size: vec2, size: vec2,
depositMap: GPUTextureView | null,
timestampWrites?: GPUComputePassTimestampWrites timestampWrites?: GPUComputePassTimestampWrites
) { ) {
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut); const bindGroup = this.getBindGroup(
trailMapIn,
trailMapOut,
depositMap ?? this.emptyDepositTextureView
);
const passEncoder = commandEncoder.beginComputePass( const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined timestampWrites ? { timestampWrites } : undefined
@ -153,6 +185,7 @@ export class DiffusionPipeline {
public destroy() { public destroy() {
this.uniforms.destroy(); this.uniforms.destroy();
this.emptyDepositTexture.destroy();
} }
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor { private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
@ -180,6 +213,13 @@ export class DiffusionPipeline {
format: TRAIL_SOURCE_TEXTURE_FORMAT, format: TRAIL_SOURCE_TEXTURE_FORMAT,
}, },
}, },
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
texture: {
sampleType: 'float',
},
},
], ],
}; };
} }

View file

@ -32,7 +32,7 @@ export class EraserAgentPipeline {
private readonly uniformCache = createCachedBufferWrite( private readonly uniformCache = createCachedBufferWrite(
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
); );
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>( private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>(
(agentsBuffer, eraserMask) => (agentsBuffer, eraserMask) =>
this.device.createBindGroup({ this.device.createBindGroup({
layout: this.bindGroupLayout, layout: this.bindGroupLayout,
@ -86,7 +86,7 @@ export class EraserAgentPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
this.workgroupSize = getAgentWorkgroupSize(device); this.workgroupSize = getAgentWorkgroupSize(device, 'eraser');
this.pipeline = device.createComputePipeline({ this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
@ -94,7 +94,7 @@ export class EraserAgentPipeline {
compute: { compute: {
module: smartCompile( module: smartCompile(
device, device,
substituteAgentWorkgroupSize(device, agentSchema), substituteAgentWorkgroupSize(device, agentSchema, 'eraser'),
shader shader
), ),
entryPoint: 'main', entryPoint: 'main',

View file

@ -21,7 +21,7 @@ fn main(
} }
let colorIndex = agents[id].colorIndex; let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 { if colorIndex < 0.0 || colorIndex >= 2.5 {
return; return;
} }

View file

@ -6,7 +6,6 @@ import {
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad'; import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color'; import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
import { CommonState } from '../common-state/common-state';
import shader from './render.wgsl?raw'; import shader from './render.wgsl?raw';
export interface RenderSettings { export interface RenderSettings {
@ -30,7 +29,7 @@ export class RenderPipeline {
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
); );
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>( private readonly getBindGroup = createBindGroupCache<[GPUTextureView, GPUTextureView]>(
(colorTexture, sourceTexture) => (colorTexture, sourceTexture) =>
this.device.createBindGroup({ this.device.createBindGroup({
layout: this.bindGroupLayout, layout: this.bindGroupLayout,
@ -45,7 +44,6 @@ export class RenderPipeline {
public constructor( public constructor(
private readonly context: GPUCanvasContext, private readonly context: GPUCanvasContext,
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly canvasFormat: GPUTextureFormat private readonly canvasFormat: GPUTextureFormat
) { ) {
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
@ -68,10 +66,10 @@ export class RenderPipeline {
], ],
}); });
const shaderModule = smartCompile(device, CommonState.shaderCode, shader); const shaderModule = smartCompile(device, shader);
const vertex = setUpFullScreenQuad(device); const vertex = setUpFullScreenQuad(device);
const pipelineLayout = device.createPipelineLayout({ const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [this.bindGroupLayout],
}); });
this.pipeline = this.createPipeline( this.pipeline = this.createPipeline(
pipelineLayout, pipelineLayout,
@ -207,8 +205,7 @@ export class RenderPipeline {
timestampWrites, timestampWrites,
}); });
passEncoder.setPipeline(this.getPipeline(useSourceTexture)); passEncoder.setPipeline(this.getPipeline(useSourceTexture));
this.commonState.execute(passEncoder); passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture));
passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture));
passEncoder.draw(3, 1); passEncoder.draw(3, 1);
passEncoder.end(); passEncoder.end();
} }

View file

@ -12,9 +12,17 @@ struct Settings {
brushColorStrengthMultiplier: f32, brushColorStrengthMultiplier: f32,
}; };
@group(1) @binding(0) var<uniform> settings: Settings; const COMMON_CHANNEL_REDUCTION: f32 = 0.75;
@group(1) @binding(2) var trailMap: texture_2d<f32>; const OVERLAP_SATURATION_BOOST: f32 = 1.35;
@group(1) @binding(3) var sourceMap: texture_2d<f32>; const LOW_SATURATION_RESCUE_AMOUNT: f32 = 0.65;
const LOW_SATURATION_RESCUE_MIN: f32 = 0.08;
const LOW_SATURATION_RESCUE_MAX: f32 = 0.22;
const COLOR_WEIGHT_EPSILON: f32 = 0.0001;
const LUMA_WEIGHTS: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
@group(0) @binding(0) var<uniform> settings: Settings;
@group(0) @binding(2) var trailMap: texture_2d<f32>;
@group(0) @binding(3) var sourceMap: texture_2d<f32>;
@fragment @fragment
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> { fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
@ -41,25 +49,13 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) ->
} }
if brushStrength <= 0.0 { if brushStrength <= 0.0 {
let traceColor = let traceColor = colorFromChannelStrengths(traceStrengths);
traceStrengths.r * settings.colorA return vec4(mix(background, clamp(traceColor, vec3(0), vec3(1)), traceStrength), 1);
+ traceStrengths.g * settings.colorB
+ traceStrengths.b * settings.colorC;
let normalizedTraceColor = normalizeColorIntensity(traceColor);
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
} }
let strengths = max(traceStrengths, sourceStrengths); let strengths = max(traceStrengths, sourceStrengths);
let traceColor = let traceColor = colorFromChannelStrengths(strengths);
strengths.r * settings.colorA let brushColor = colorFromChannelStrengths(sourceStrengths);
+ strengths.g * settings.colorB
+ strengths.b * settings.colorC;
let normalizedTraceColor = normalizeColorIntensity(traceColor);
let brushColor =
sourceStrengths.r * settings.colorA
+ sourceStrengths.g * settings.colorB
+ sourceStrengths.b * settings.colorC;
let normalizedBrushColor = normalizeColorIntensity(brushColor);
let brushVisibility = clamp( let brushVisibility = clamp(
brushStrength * ( brushStrength * (
settings.brushColorBase + settings.brushColorBase +
@ -68,7 +64,7 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) ->
0, 0,
1 1
); );
let color = max(normalizedTraceColor, normalizedBrushColor); let color = mix(traceColor, brushColor, brushVisibility);
let strength = max(maxComponent(strengths), brushVisibility); let strength = max(maxComponent(strengths), brushVisibility);
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1); return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
@ -78,10 +74,80 @@ fn maxComponent(v: vec3<f32>) -> f32 {
return max(max(v.r, v.g), v.b); return max(max(v.r, v.g), v.b);
} }
fn minComponent(v: vec3<f32>) -> f32 {
return min(min(v.r, v.g), v.b);
}
fn componentSum(v: vec3<f32>) -> f32 {
return v.r + v.g + v.b;
}
fn clarity(strength: vec3<f32>) -> vec3<f32> { fn clarity(strength: vec3<f32>) -> vec3<f32> {
return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity)); return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
} }
fn colorFromChannelStrengths(strengths: vec3<f32>) -> vec3<f32> {
if maxComponent(strengths) <= 0.0 {
return vec3<f32>(0.0);
}
let weights = colorWeights(strengths);
let color =
weights.r * settings.colorA
+ weights.g * settings.colorB
+ weights.b * settings.colorC;
return preserveOverlapVibrancy(normalizeColorIntensity(color), strengths);
}
fn colorWeights(strengths: vec3<f32>) -> vec3<f32> {
let commonStrength = minComponent(strengths);
var weightBase = max(
strengths - vec3<f32>(commonStrength * COMMON_CHANNEL_REDUCTION),
vec3<f32>(0.0)
);
if componentSum(weightBase) <= COLOR_WEIGHT_EPSILON {
weightBase = strengths;
}
let sharpenedWeights = weightBase * weightBase;
return sharpenedWeights / max(COLOR_WEIGHT_EPSILON, componentSum(sharpenedWeights));
}
fn preserveOverlapVibrancy(color: vec3<f32>, strengths: vec3<f32>) -> vec3<f32> {
let strongest = maxComponent(strengths);
let overlapAmount = clamp(
(componentSum(strengths) - strongest) / max(COLOR_WEIGHT_EPSILON, strongest),
0.0,
1.0
);
let luminance = dot(color, LUMA_WEIGHTS);
var vibrantColor = clamp(
vec3<f32>(luminance) +
(color - vec3<f32>(luminance)) *
mix(1.0, OVERLAP_SATURATION_BOOST, overlapAmount),
vec3<f32>(0.0),
vec3<f32>(1.0)
);
let saturation = maxComponent(vibrantColor) - minComponent(vibrantColor);
let rescueAmount =
overlapAmount *
(1.0 - smoothstep(LOW_SATURATION_RESCUE_MIN, LOW_SATURATION_RESCUE_MAX, saturation)) *
LOW_SATURATION_RESCUE_AMOUNT;
return mix(vibrantColor, dominantColor(strengths), rescueAmount);
}
fn dominantColor(strengths: vec3<f32>) -> vec3<f32> {
if strengths.r >= strengths.g && strengths.r >= strengths.b {
return normalizeColorIntensity(settings.colorA);
}
if strengths.g >= strengths.b {
return normalizeColorIntensity(settings.colorB);
}
return normalizeColorIntensity(settings.colorC);
}
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> { fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
let brightestChannel = maxComponent(color); let brightestChannel = maxComponent(color);
return color / max(settings.traceNormalizationFloor, brightestChannel); return color / max(settings.traceNormalizationFloor, brightestChannel);

View file

@ -18,12 +18,20 @@ const preservedRuntimeSettingKeys = [
const cloneRgbColor = <T extends [number, number, number]>(color: T): T => const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
[...color] as T; [...color] as T;
const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({
...audio,
...(audio.scale ? { scale: [...audio.scale] } : {}),
...(audio.progression
? { progression: audio.progression.map((chord) => ({ ...chord })) }
: {}),
});
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({ const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
...vibe, ...vibe,
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'], colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
backgroundColor: cloneRgbColor(vibe.backgroundColor), backgroundColor: cloneRgbColor(vibe.backgroundColor),
settings: { ...vibe.settings }, settings: { ...vibe.settings },
audio: { ...vibe.audio }, audio: cloneVibeAudio(vibe.audio),
}); });
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
@ -43,6 +51,10 @@ export const settings: GardenRuntimeSettings = {
...buildSettings(activeVibe), ...buildSettings(activeVibe),
}; };
export const rememberActiveVibeSelection = (): void => {
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
};
export const applyVibeSettings = (vibe: VibePreset) => { export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe = cloneVibePreset(vibe); activeVibe = cloneVibePreset(vibe);
const nextSettings = buildSettings(activeVibe); const nextSettings = buildSettings(activeVibe);
@ -59,7 +71,7 @@ export const applyVibeSettings = (vibe: VibePreset) => {
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls) normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
); );
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id); rememberActiveVibeSelection();
return activeVibe; return activeVibe;
}; };

View file

@ -1,3 +1,5 @@
@use 'mixins' as *;
.config-pane-container { .config-pane-container {
--config-pane-available-height: calc( --config-pane-available-height: calc(
100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) 100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
@ -41,6 +43,9 @@
touch-action: pan-y; touch-action: pan-y;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
// Tweakpane v4 internal classes re-verify on upgrade.
// No public theming hook exists for label padding or the slider/number
// flex ratio; if a fourth override appears here, switch to a custom plugin.
.tp-lblv_l { .tp-lblv_l {
padding-right: 10px; padding-right: 10px;
} }
@ -139,6 +144,7 @@
font-size: 11px; font-size: 11px;
// Tweakpane v4 internal class re-verify on upgrade.
.tp-sldtxtv_t { .tp-sldtxtv_t {
flex-basis: 48px; flex-basis: 48px;
} }
@ -150,7 +156,7 @@
} }
} }
@media (max-width: 599px), (hover: none) and (pointer: coarse) { @include on-mobile-input {
@include mobile-config-pane; @include mobile-config-pane;
} }

View file

@ -16,6 +16,7 @@
html { html {
height: 100%; height: 100%;
touch-action: manipulation;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -23,6 +24,7 @@ html {
body { body {
font-family: 'Open Sans', sans-serif; font-family: 'Open Sans', sans-serif;
touch-action: manipulation;
} }
.visually-hidden { .visually-hidden {

View file

@ -5,3 +5,9 @@ $breakpoint-width: 600px !default;
@content; @content;
} }
} }
@mixin on-mobile-input() {
@media (max-width: ($breakpoint-width - 1px)), (hover: none) and (pointer: coarse) {
@content;
}
}

View file

@ -5,16 +5,20 @@
--toolbar-background-strength: 0; --toolbar-background-strength: 0;
--toolbar-divider-space: clamp(6px, 1.8vw, 14px); --toolbar-divider-space: clamp(6px, 1.8vw, 14px);
--toolbar-top-max-width: 594px; --toolbar-top-max-width: 594px;
--vibe-button-hit-size: 64px;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'previous controls next' 'previous controls next'
'previous divider next' 'previous divider next'
'previous buttons next'; 'previous buttons next';
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns:
var(--vibe-button-hit-size)
minmax(0, var(--toolbar-top-max-width))
var(--vibe-button-hit-size);
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
width: 100%; width: fit-content;
max-width: 100%; max-width: 100%;
margin: 0 auto; margin: 0 auto;
padding-inline: clamp(8px, 1.4vw, 14px); padding-inline: clamp(8px, 1.4vw, 14px);
@ -82,40 +86,72 @@
} }
> .vibe-button { > .vibe-button {
--vibe-button-surface-inset-block: 10px;
--vibe-button-surface-inset-inline: 8px;
--vibe-chevron-size: 22px;
--vibe-chevron-stroke: 4px;
position: relative; position: relative;
isolation: isolate;
display: grid; display: grid;
place-items: center; place-items: center;
width: 52px; width: var(--vibe-button-hit-size);
height: auto; height: auto;
min-height: 66px; min-height: 72px;
flex: 0 0 auto; flex: 0 0 auto;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: rgb(255 255 255 / 70%); color: rgb(255 255 255 / 88%);
font-size: 0; font-size: 0;
line-height: 1; line-height: 1;
&::after {
content: '';
position: absolute;
z-index: 0;
inset: var(--vibe-button-surface-inset-block)
var(--vibe-button-surface-inset-inline);
border-radius: 8px;
background: rgb(255 255 255 / calc(9% + var(--toolbar-background-strength) * 10%));
box-shadow:
inset 0 0 0 1px rgb(255 255 255 / 18%),
0 8px 18px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 22%));
transition:
background var(--transition-time),
box-shadow var(--transition-time),
opacity var(--transition-time);
}
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
z-index: 1;
top: 50%; top: 50%;
left: 50%; left: 50%;
width: 18px; width: var(--vibe-chevron-size);
height: 18px; height: var(--vibe-chevron-size);
border-color: currentColor; border-color: currentColor;
border-style: solid; border-style: solid;
border-width: 0 0 3px 3px; border-width: 0 0 var(--vibe-chevron-stroke) var(--vibe-chevron-stroke);
filter: drop-shadow(0 1px 3px rgb(0 0 0 / 70%));
transform: translate(-35%, -50%) rotate(45deg); transform: translate(-35%, -50%) rotate(45deg);
} }
&.next-vibe::before { &.next-vibe::before {
border-width: 3px 3px 0 0; border-width: var(--vibe-chevron-stroke) var(--vibe-chevron-stroke) 0 0;
transform: translate(-65%, -50%) rotate(45deg); transform: translate(-65%, -50%) rotate(45deg);
} }
&:hover { &:hover {
color: color-mix(in srgb, var(--accent-color) 70%, white); color: white;
}
&:hover::after {
background: color-mix(in srgb, var(--accent-color) 34%, rgb(255 255 255 / 18%));
box-shadow:
inset 0 0 0 1px rgb(255 255 255 / 28%),
0 10px 22px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 30%));
} }
&.previous-vibe:hover { &.previous-vibe:hover {

View file

@ -4,6 +4,7 @@
@include on-small-screen { @include on-small-screen {
--toolbar-divider-space: 4px; --toolbar-divider-space: 4px;
--toolbar-top-max-width: 329px; --toolbar-top-max-width: 329px;
--vibe-button-hit-size: 44px;
grid-template-areas: grid-template-areas:
'previous controls next' 'previous controls next'
@ -15,13 +16,13 @@
row-gap: 0; row-gap: 0;
> .vibe-button { > .vibe-button {
width: 36px; --vibe-button-surface-inset-block: 5px;
min-height: 44px; --vibe-button-surface-inset-inline: 3px;
--vibe-chevron-size: 17px;
--vibe-chevron-stroke: 3px;
&::before { width: var(--vibe-button-hit-size);
width: 14px; min-height: 44px;
height: 14px;
}
} }
> .toolbar-shell { > .toolbar-shell {

View file

@ -1,7 +1,7 @@
:root { :root {
--transition-time: 200ms; --transition-time: 200ms;
--transition-time-long: 350ms; --transition-time-long: 350ms;
--accent-color: rgb(255, 93, 162); --accent-color: rgb(255 93 162);
--main-color: #aaa; --main-color: #aaa;
--normal-margin: 2rem; --normal-margin: 2rem;
--small-margin: 1rem; --small-margin: 1rem;

View file

@ -223,7 +223,12 @@ export class ErrorHandler {
public static addOnErrorListener( public static addOnErrorListener(
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
) { ): () => void {
ErrorHandler.onErrorListeners.push(listener); ErrorHandler.onErrorListeners.push(listener);
return () => {
ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter(
(registeredListener) => registeredListener !== listener
);
};
} }
} }

View file

@ -1,48 +1,38 @@
export const createBindGroupCache = <K1 extends object, K2 extends object>( type BindGroupCacheKeys = readonly [object, ...object[]];
factory: (key1: K1, key2: K2) => GPUBindGroup
): ((key1: K1, key2: K2) => GPUBindGroup) => { interface BindGroupCacheNode {
const outer = new WeakMap<K1, WeakMap<K2, GPUBindGroup>>(); bindGroup?: GPUBindGroup;
return (key1, key2) => { children: WeakMap<object, BindGroupCacheNode>;
let inner = outer.get(key1); }
if (!inner) {
inner = new WeakMap(); const createNode = (): BindGroupCacheNode => ({
outer.set(key1, inner); children: new WeakMap(),
} });
const cached = inner.get(key2);
if (cached) { const getOrCreateNode = (
return cached; children: WeakMap<object, BindGroupCacheNode>,
} key: object
const bindGroup = factory(key1, key2); ): BindGroupCacheNode => {
inner.set(key2, bindGroup); let node = children.get(key);
return bindGroup; if (!node) {
}; node = createNode();
children.set(key, node);
}
return node;
}; };
export const createBindGroupCache3 = < export const createBindGroupCache = <Keys extends BindGroupCacheKeys>(
K1 extends object, factory: (...keys: Keys) => GPUBindGroup
K2 extends object, ): ((...keys: Keys) => GPUBindGroup) => {
K3 extends object, const root = new WeakMap<object, BindGroupCacheNode>();
>(
factory: (key1: K1, key2: K2, key3: K3) => GPUBindGroup return (...keys) => {
): ((key1: K1, key2: K2, key3: K3) => GPUBindGroup) => { let node = getOrCreateNode(root, keys[0]);
const outer = new WeakMap<K1, WeakMap<K2, WeakMap<K3, GPUBindGroup>>>(); for (const key of keys.slice(1)) {
return (key1, key2, key3) => { node = getOrCreateNode(node.children, key);
let mid = outer.get(key1);
if (!mid) {
mid = new WeakMap();
outer.set(key1, mid);
} }
let inner = mid.get(key2);
if (!inner) { node.bindGroup ??= factory(...keys);
inner = new WeakMap(); return node.bindGroup;
mid.set(key2, inner);
}
const cached = inner.get(key3);
if (cached) {
return cached;
}
const bindGroup = factory(key1, key2, key3);
inner.set(key3, bindGroup);
return bindGroup;
}; };
}; };

54
src/vibe-uri.test.ts Normal file
View file

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { VibeId } from './config/types';
import { createVibeUri, getVibeIdFromUri } from './vibe-uri';
describe('vibe URI handling', () => {
it('loads vibes from slug IDs and display names', () => {
expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe(
VibeId.AuroraMycelium
);
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
VibeId.AuroraMycelium
);
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
VibeId.VelvetObservatory
);
});
it('uses query values before path or hash fallbacks', () => {
expect(
getVibeIdFromUri(
'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium'
)
).toBe(VibeId.LichenSignal);
});
it('accepts explicit path segments and hash fallbacks', () => {
expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe(
VibeId.TidepoolLantern
);
expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe(
VibeId.PaperLanternFog
);
});
it('ignores unknown or malformed vibe values', () => {
expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull();
expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull();
expect(getVibeIdFromUri('not a url')).toBeNull();
});
it('creates a canonical query URI without dropping other URL parts', () => {
expect(
createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen)
).toBe('/garden?debug=1&vibe=chrome-pollen#panel');
expect(
createVibeUri(
'https://example.test/garden?vibe=aurora-mycelium&debug=1',
VibeId.LichenSignal
)
).toBe('/garden?vibe=lichen-signal&debug=1');
});
});

148
src/vibe-uri.ts Normal file
View file

@ -0,0 +1,148 @@
import type { VibeId } from './config/types';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
const VIBE_URI_QUERY_PARAM = 'vibe';
const FALLBACK_URL_ORIGIN = 'https://fleeting.garden';
const slugifyVibeName = (value: string): string =>
value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const safeDecodeURIComponent = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const normalizeVibeIdentifier = (value: string): string =>
slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, ''));
const vibeIdByIdentifier = new Map<string, VibeId>();
for (const vibe of VIBE_PRESETS) {
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id);
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id);
}
const toUrl = (url: string | URL): URL | null => {
try {
return new URL(url, FALLBACK_URL_ORIGIN);
} catch {
return null;
}
};
const resolveVibeId = (value: string | null | undefined): VibeId | null => {
if (!value) {
return null;
}
return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null;
};
const getHashSearchParam = (hash: string): string | null => {
const hashValue = hash.replace(/^#/, '');
if (!hashValue.includes('=')) {
return null;
}
const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue;
try {
return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM);
} catch {
return null;
}
};
const getPathVibeCandidates = (pathname: string): Array<string> => {
const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean);
const explicitVibeIndex = segments.findIndex((segment) =>
['vibe', 'vibes'].includes(segment.toLowerCase())
);
return [
explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined,
segments.at(-1),
].filter((candidate): candidate is string => typeof candidate === 'string');
};
export const getVibeIdFromUri = (url: string | URL): VibeId | null => {
const parsedUrl = toUrl(url);
if (!parsedUrl) {
return null;
}
const candidates = [
parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM),
getHashSearchParam(parsedUrl.hash),
...getPathVibeCandidates(parsedUrl.pathname),
parsedUrl.hash.replace(/^#/, ''),
];
for (const candidate of candidates) {
const vibeId = resolveVibeId(candidate);
if (vibeId) {
return vibeId;
}
}
return null;
};
export const getCurrentUriVibeId = (): VibeId | null => {
if (typeof window === 'undefined') {
return null;
}
return getVibeIdFromUri(window.location.href);
};
const getVibeSlug = (vibeId: VibeId): string => {
const vibe = getVibeById(vibeId);
return vibe ? vibe.id : vibeId;
};
export const createVibeUri = (url: string | URL, vibeId: VibeId): string => {
const parsedUrl = toUrl(url);
if (!parsedUrl) {
return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`;
}
parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId));
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
};
export const writeCurrentVibeUri = (
vibeId: VibeId,
mode: 'push' | 'replace' = 'replace'
): void => {
if (typeof window === 'undefined') {
return;
}
const nextUri = createVibeUri(window.location.href, vibeId);
const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (nextUri === currentUri) {
return;
}
const nextState =
typeof window.history.state === 'object' && window.history.state !== null
? { ...window.history.state, vibeId }
: { vibeId };
if (mode === 'push') {
window.history.pushState(nextState, '', nextUri);
return;
}
window.history.replaceState(nextState, '', nextUri);
};

View file

@ -1,21 +1,26 @@
import { appConfig } from './config'; import { appConfig } from './config';
import { VibeId, type VibePreset } from './config/types'; import { VibeId, type VibePreset } from './config/types';
import { readBrowserStorage } from './utils/browser-storage'; import { readBrowserStorage } from './utils/browser-storage';
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri';
export { VibeId }; export { VibeId };
export { getVibeById, VIBE_PRESETS };
export type { VibePreset }; export type { VibePreset };
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id)); const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
const isVibeId = (value: unknown): value is VibeId => const isVibeId = (value: unknown): value is VibeId =>
typeof value === 'string' && VIBE_IDS.has(value as VibeId); typeof value === 'string' && VIBE_IDS.has(value as VibeId);
export const getInitialVibe = (): VibePreset => { export const getInitialVibe = (): VibePreset => {
const uriVibeId = getCurrentUriVibeId();
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey); const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
const initialVibeId = isVibeId(storedVibeId) const storedOrLegacyVibeId = isVibeId(storedVibeId)
? storedVibeId ? storedVibeId
: appConfig.vibes.defaultVibeId; : getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
const initialVibeId =
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0]; return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
}; };

View file

@ -17,5 +17,5 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true "noUnusedParameters": true
}, },
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"] "include": ["src/**/*", "pwa-assets.config.ts", "vite.config.ts"]
} }

View file

@ -21,9 +21,7 @@ export default defineConfig(({ command }) => ({
cssMinify: 'lightningcss', cssMinify: 'lightningcss',
}, },
server: { server: {
open: true,
host: true, host: true,
hmr: false,
}, },
test: { test: {
environment: 'node', environment: 'node',