more cleaning up

This commit is contained in:
Andras Schmelczer 2026-05-16 20:45:42 +01:00
commit 560398fefb
110 changed files with 931 additions and 2645 deletions

View file

@ -1,7 +1,5 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects';
const disableWebGpu = async (page: Page) => { const disableWebGpu = async (page: Page) => {
await page.addInitScript(() => { await page.addInitScript(() => {
Object.defineProperty(navigator, 'gpu', { Object.defineProperty(navigator, 'gpu', {
@ -11,54 +9,6 @@ const disableWebGpu = async (page: Page) => {
}); });
}; };
const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => {
await page.addInitScript((failureMode) => {
const limits = {
maxBufferSize: 256 * 1024 * 1024,
maxComputeWorkgroupsPerDimension: 65_535,
maxStorageBufferBindingSize: 128 * 1024 * 1024,
};
const adapter = {
features: new Set(),
info: {
architecture: 'test',
description: 'Playwright fake adapter',
device: 'test-device',
isFallbackAdapter: false,
subgroupMaxSize: 0,
subgroupMinSize: 0,
vendor: 'test-vendor',
},
limits,
requestDevice: async () => {
if (failureMode === 'device-rejects') {
throw new Error('Playwright fake device failure');
}
return {};
},
};
Object.defineProperty(navigator, 'gpu', {
configurable: true,
value: {
getPreferredCanvasFormat: () => 'rgba8unorm',
requestAdapter: async () => {
if (failureMode === 'adapter-null') {
return null;
}
if (failureMode === 'adapter-rejects') {
throw new Error('Playwright fake adapter failure');
}
return adapter;
},
},
});
}, mode);
};
const getFirstSwatchColor = (page: Page) => const getFirstSwatchColor = (page: Page) =>
page page
.locator('.color-swatch') .locator('.color-swatch')
@ -71,6 +21,18 @@ const getGardenBackground = (page: Page) =>
); );
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
const browserFailures: Array<string> = [];
page.on('requestfailed', (request) => {
const failure = request.failure();
browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
});
page.on('response', (response) => {
if (response.status() >= 400) {
browserFailures.push(`${response.status()} ${response.url()}`);
}
});
await disableWebGpu(page); await disableWebGpu(page);
await page.goto('/'); await page.goto('/');
@ -85,6 +47,7 @@ test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) =>
await page.getByRole('button', { name: 'About' }).click(); await page.getByRole('button', { name: 'About' }).click();
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
expect(browserFailures).toEqual([]);
}); });
test('keeps fallback controls interactive and accessible', async ({ page }) => { test('keeps fallback controls interactive and accessible', async ({ page }) => {
@ -106,13 +69,21 @@ test('keeps fallback controls interactive and accessible', async ({ page }) => {
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true'); await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true');
await expect(aboutPanel).toHaveAttribute('inert', ''); await expect(aboutPanel).toHaveAttribute('inert', '');
const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); const settingsButton = page.locator('button.settings');
await expect(settingsButton).toHaveAttribute('aria-label', 'Show config overlay');
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
await settingsButton.click(); await settingsButton.click();
await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute( await expect(settingsButton).toHaveAttribute('aria-expanded', 'true');
'aria-expanded', await expect(settingsButton).toHaveAttribute('aria-label', 'Hide config overlay');
'true'
);
await expect(page.locator('.config-pane')).toBeVisible(); await expect(page.locator('.config-pane')).toBeVisible();
await expect(page.locator('.config-pane')).toContainText('Runtime');
await expect(page.locator('.color-reaction-matrix')).toBeVisible();
const colorReaction = page.getByLabel('Color 1 agents reacting to color 2');
await colorReaction.selectOption('-1');
await expect(colorReaction).toHaveValue('-1');
await settingsButton.click();
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
const soundButton = page.locator('button.sound'); const soundButton = page.locator('button.sound');
await expect(soundButton).toHaveAttribute('aria-pressed', 'false'); await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
@ -146,70 +117,8 @@ test('keeps fallback controls interactive and accessible', async ({ page }) => {
await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/); await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/);
}); });
( test('keeps the fallback shell usable on mobile', async ({ page }) => {
[ await page.setViewportSize({ height: 844, width: 390 });
{
expectedCode: 'webgpu-adapter-unavailable',
expectedMessage:
'WebGPU is available, but this browser could not provide a compatible GPU adapter.',
mode: 'adapter-null',
},
{
expectedCode: 'webgpu-adapter-unavailable',
expectedMessage: 'Could not request a WebGPU adapter.',
mode: 'adapter-rejects',
},
{
expectedCode: 'webgpu-device-unavailable',
expectedMessage: 'Could not create a WebGPU device for this adapter.',
mode: 'device-rejects',
},
] satisfies Array<{
expectedCode: string;
expectedMessage: string;
mode: WebGpuFailureMode;
}>
).forEach(({ expectedCode, expectedMessage, mode }) => {
test(`reports ${mode} startup failures without leaving the shell loading`, async ({
page,
}) => {
await emulateWebGpuFailure(page, mode);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
await expect(page.getByRole('alert')).toContainText(expectedMessage);
await expect(page.getByRole('alert')).toContainText(expectedCode);
});
});
test('serves the production bundle without missing browser assets', async ({ page }) => {
const browserFailures: Array<string> = [];
page.on('requestfailed', (request) => {
const failure = request.failure();
browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
});
page.on('response', (response) => {
if (response.status() >= 400) {
browserFailures.push(`${response.status()} ${response.url()}`);
}
});
await disableWebGpu(page);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
expect(browserFailures).toEqual([]);
});
[
{ height: 720, name: 'desktop', width: 1280 },
{ height: 844, name: 'mobile', width: 390 },
].forEach(({ height, name, width }) => {
test(`keeps the fallback shell usable on ${name}`, async ({ page }) => {
await page.setViewportSize({ height, width });
await disableWebGpu(page); await disableWebGpu(page);
await page.goto('/'); await page.goto('/');
@ -237,7 +146,6 @@ test('serves the production bundle without missing browser assets', async ({ pag
}); });
expect(aboutButtonReceivesPointer).toBe(true); expect(aboutButtonReceivesPointer).toBe(true);
});
}); });
test('hides the bottom dock after the cursor leaves fullscreen controls', async ({ test('hides the bottom dock after the cursor leaves fullscreen controls', async ({
@ -305,5 +213,5 @@ test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => {
await page.waitForTimeout(5200); await page.waitForTimeout(5200);
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible(); await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
}); });

View file

@ -22,10 +22,9 @@
<meta property="og:image:width" content="1920" /> <meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" /> <meta property="og:image:height" content="1920" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="apple-touch-icon" href="apple-touch-icon-180x180.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" /> <link rel="manifest" href="manifest.webmanifest" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Fleeting Garden</title> <title>Fleeting Garden</title>
</head> </head>
@ -41,13 +40,13 @@
</canvas> </canvas>
<p id="canvas-description" class="visually-hidden"> <p id="canvas-description" class="visually-hidden">
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
to paint coloured paths, then use the toolbar to change colours, erase, adjust the to paint coloured paths, then use the toolbar to change colours, erase, export,
config overlay, export, restart, or open more information. adjust the config overlay, restart, or open more information.
</p> </p>
<div class="eraser-preview" aria-hidden="true"></div> <div class="eraser-preview" aria-hidden="true"></div>
<div class="garden-prompt" aria-live="polite"></div> <div class="garden-prompt" aria-live="polite"></div>
<div class="loading-indicator" role="status" aria-live="polite"> <div class="loading-indicator" role="status">
<div class="loading-dots" aria-hidden="true"> <div class="loading-dots" aria-hidden="true">
<span class="loading-dot"></span> <span class="loading-dot"></span>
<span class="loading-dot"></span> <span class="loading-dot"></span>
@ -61,9 +60,7 @@
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
aria-valuenow="0" aria-valuenow="0"
> ></div>
<div class="loading-progress-fill"></div>
</div>
</div> </div>
<section class="errors-container"> <section class="errors-container">
@ -72,7 +69,15 @@
</main> </main>
<aside class="control-dock"> <aside class="control-dock">
<section id="info-panel" class="pages hidden info-page" aria-hidden="true" inert> <section
id="info-panel"
class="hidden info-page"
role="region"
aria-label="About panel"
aria-hidden="true"
tabindex="-1"
inert
>
<section> <section>
<h1>Fleeting Garden</h1> <h1>Fleeting Garden</h1>
<p> <p>
@ -125,22 +130,12 @@
title="Draw colour 3" title="Draw colour 3"
></button> ></button>
<label class="eraser-size-control" title="Erase and resize"> <label class="eraser-size-control" title="Erase and resize">
<input <input class="eraser-size-slider" type="range" aria-label="Eraser size" />
class="eraser-size-slider"
type="range"
min="24"
max="240"
step="1"
aria-label="Eraser size"
/>
</label> </label>
<label class="mirror-segment-control" title="Mirror off"> <label class="mirror-segment-control" title="Mirror off">
<input <input
class="mirror-segment-slider" class="mirror-segment-slider"
type="range" type="range"
min="1"
max="12"
step="1"
aria-label="Mirror segments" aria-label="Mirror segments"
/> />
</label> </label>

114
package-lock.json generated
View file

@ -24,13 +24,10 @@
"browserslist": "^4.28.2", "browserslist": "^4.28.2",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"gl-matrix": "^3.4.4", "gl-matrix": "^3.4.4",
"globals": "^17.6.0", "globals": "^17.6.0",
"knip": "^6.14.1", "knip": "^6.14.1",
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"npm-check-updates": "^22.1.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@ -1924,19 +1921,6 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@plausible-analytics/tracker": { "node_modules/@plausible-analytics/tracker": {
"version": "0.4.5", "version": "0.4.5",
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz", "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz",
@ -3169,53 +3153,6 @@
"eslint": ">=7.0.0" "eslint": ">=7.0.0"
} }
}, },
"node_modules/eslint-plugin-prettier": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.1",
"synckit": "^0.11.12"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-unused-imports": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz",
"integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
"eslint": "^10.0.0 || ^9.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/eslint-plugin": {
"optional": true
}
}
},
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "9.1.2", "version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
@ -3339,13 +3276,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -4125,21 +4055,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/npm-check-updates": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-22.1.0.tgz",
"integrity": "sha512-zKjYAa205S6UyHkNJGmiLFmm6M31175cvUA3OdHvVlCdtyTfkyQbPWoov/GJEc6PWVbCV5e+60c7S2eVp0ybOA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"ncu": "build/cli.js",
"npm-check-updates": "build/cli.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@ -4430,19 +4345,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/prettier-linter-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -4704,22 +4606,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View file

@ -8,7 +8,7 @@
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint:check": "eslint --rule \"prettier/prettier: off\" \"src/**/*.ts\" && npm run unused:check", "lint:check": "eslint \"src/**/*.ts\" && npm run unused:check",
"lint:fix": "eslint --fix \"src/**/*.ts\"", "lint:fix": "eslint --fix \"src/**/*.ts\"",
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", "format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", "format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
@ -18,9 +18,8 @@
"test:e2e": "npm run build && playwright test", "test:e2e": "npm run build && playwright test",
"test:e2e:ui": "npm run build && playwright test --ui", "test:e2e:ui": "npm run build && playwright test --ui",
"test:watch": "vitest", "test:watch": "vitest",
"unused:check": "knip --exports --include-entry-exports", "unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
"generate-icons": "pwa-assets-generator", "generate-icons": "pwa-assets-generator"
"update": "ncu"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@ -40,6 +39,11 @@
"browserslist": [ "browserslist": [
"supports webgpu and last 2 years" "supports webgpu and last 2 years"
], ],
"knip": {
"ignoreFiles": [
"pwa-assets.config.ts"
]
},
"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",
@ -52,13 +56,10 @@
"browserslist": "^4.28.2", "browserslist": "^4.28.2",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"gl-matrix": "^3.4.4", "gl-matrix": "^3.4.4",
"globals": "^17.6.0", "globals": "^17.6.0",
"knip": "^6.14.1", "knip": "^6.14.1",
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"npm-check-updates": "^22.1.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",

View file

@ -17,7 +17,7 @@ export default defineConfig({
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
webServer: { webServer: {
command: `npm run preview -- --host 127.0.0.1 --port ${port}`, command: `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`,
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,

View file

@ -3,23 +3,14 @@
"short_name": "Garden", "short_name": "Garden",
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.", "description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
"start_url": "./", "start_url": "./",
"scope": "./",
"display": "fullscreen", "display": "fullscreen",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#10151f", "background_color": "#10151f",
"theme_color": "#10151f", "theme_color": "#10151f",
"icons": [ "icons": [
{ {
"src": "favicon.svg", "src": "favicon.svg",
"sizes": "any", "sizes": "any",
"type": "image/svg+xml", "type": "image/svg+xml"
"purpose": "any"
},
{
"src": "pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
}, },
{ {
"src": "pwa-192x192.png", "src": "pwa-192x192.png",

View file

@ -1,2 +0,0 @@
User-agent: *
Allow: /

View file

@ -24,11 +24,7 @@ export const initAnalytics = () => {
domain: 'schmelczer.dev/floating', domain: 'schmelczer.dev/floating',
endpoint: 'https://stats.schmelczer.dev/status', endpoint: 'https://stats.schmelczer.dev/status',
autoCapturePageviews: true, autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true, logging: true,
fileDownloads: true,
outboundLinks: true,
hashBasedRouting: true,
}); });
isInitialized = true; isInitialized = true;
} catch (error) { } catch (error) {
@ -63,7 +59,3 @@ export const trackExport = ({ vibeId }: { vibeId: string }) => {
}, },
}); });
}; };
export const trackSettingsOpen = () => {
track('Settings Open');
};

View file

@ -7,7 +7,7 @@ export interface GardenAudioChord {
quality: GardenAudioChordQuality; quality: GardenAudioChordQuality;
} }
interface GardenAudioColorVoice { interface GardenAudioStyleVoice {
scaleDegreeOffset: number; scaleDegreeOffset: number;
velocityMultiplier: number; velocityMultiplier: number;
panOffset: number; panOffset: number;
@ -20,13 +20,14 @@ export interface GardenAudioRegister {
pan: number; pan: number;
} }
export interface GardenAudioColorPool extends GardenAudioRegister { export interface GardenAudioStylePool extends GardenAudioRegister {
scaleDegrees: Array<number>; scaleDegrees: Array<number>;
} }
interface GardenAudioGenerativePianoConfig { interface GardenAudioGenerativePianoConfig {
colorPools: [GardenAudioColorPool, GardenAudioColorPool, GardenAudioColorPool]; stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister]; padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
styleRotationSeconds: number;
chordBars: number; chordBars: number;
supportBarSpacing: number; supportBarSpacing: number;
supportBarOffset: number; supportBarOffset: number;
@ -38,16 +39,14 @@ interface GardenAudioGenerativePianoConfig {
noteScorePreferenceWeight: number; noteScorePreferenceWeight: number;
noteScoreRegisterWeight: number; noteScoreRegisterWeight: number;
noteScoreRepeatPenalty: number; noteScoreRepeatPenalty: number;
gestureAccentSpacingSeconds: number;
gestureAccentMinIntervalSeconds: number; gestureAccentMinIntervalSeconds: number;
strokeAccentMinIntervalSeconds: number; strokeAccentMinIntervalSeconds: number;
strokeAccentThreshold: number; strokeAccentThreshold: number;
stingerSpacingSeconds: number;
stingerDurationSeconds: number; stingerDurationSeconds: number;
stingerSpacingSeconds: number;
maxBrushPhraseLayers: number; maxBrushPhraseLayers: number;
brushLayerBaseSeconds: number; brushLayerBaseSeconds: number;
brushLayerEnergySeconds: number; brushLayerEnergySeconds: number;
brushLayerMirrorSeconds: number;
brushLayerMinIntensity: number; brushLayerMinIntensity: number;
brushStreamIdleIntervalBeats: number; brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number; brushStreamActiveIntervalBeats: number;
@ -137,12 +136,10 @@ export interface GardenAudioConfig {
unlockTickSeconds: number; unlockTickSeconds: number;
}; };
input: { input: {
activeActivityThreshold: number;
distanceWindowForFullActivityPixels: number; distanceWindowForFullActivityPixels: number;
distanceWindowSeconds: number; distanceWindowSeconds: number;
fallbackFrameSeconds: number; fallbackFrameSeconds: number;
manicActivityThreshold: number; manicActivityThreshold: number;
manicModeThreshold: number;
}; };
muteGain: number; muteGain: number;
muteRampSeconds: number; muteRampSeconds: number;
@ -156,8 +153,7 @@ export interface GardenAudioConfig {
startDelaySeconds: number; startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number; vibeChangeStingerMinIntervalSeconds: number;
generativePiano: GardenAudioGenerativePianoConfig; generativePiano: GardenAudioGenerativePianoConfig;
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice]; styleVoices: [GardenAudioStyleVoice, GardenAudioStyleVoice, GardenAudioStyleVoice];
vibes: Record<string, GardenAudioVibeProfile>;
} }
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio; export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;

View file

@ -2,10 +2,7 @@ import { clamp01 } from '../utils/clamp';
import type { GardenAudioConfig } from './garden-audio-config'; import type { GardenAudioConfig } from './garden-audio-config';
import type { GardenAudioStrokeMetrics } from './garden-audio-input'; import type { GardenAudioStrokeMetrics } from './garden-audio-input';
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
interface GardenAudioGestureFrame { interface GardenAudioGestureFrame {
mode: GardenAudioGestureMode;
activity: number; activity: number;
maniaAmount: number; maniaAmount: number;
} }
@ -15,44 +12,18 @@ interface GestureDistanceSample {
distancePixels: number; distancePixels: number;
} }
const DEFAULT_FRAME: GardenAudioGestureFrame = {
mode: 'calm',
activity: 0,
maniaAmount: 0,
};
export class GardenAudioGestureState { export class GardenAudioGestureState {
private readonly samples: Array<GestureDistanceSample> = []; private readonly samples: Array<GestureDistanceSample> = [];
private gestureClockSeconds = 0; private gestureClockSeconds = 0;
private peakActivity = 0;
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {} public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public beginGesture(): void { public beginGesture(): void {
this.samples.length = 0; this.reset();
this.gestureClockSeconds = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
} }
public endGesture(): GardenAudioGestureFrame { public endGesture(): void {
this.samples.length = 0; this.reset();
this.gestureClockSeconds = 0;
this.lastFrame = {
...DEFAULT_FRAME,
mode:
this.peakActivity >= this.inputConfig.activeActivityThreshold
? 'afterglow'
: 'calm',
};
this.peakActivity = 0;
return this.lastFrame;
}
public recordTouchDown(): GardenAudioGestureFrame {
this.lastFrame = DEFAULT_FRAME;
return this.lastFrame;
} }
public recordStroke({ public recordStroke({
@ -77,28 +48,16 @@ export class GardenAudioGestureState {
const activity = clamp01( const activity = clamp01(
windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels
); );
const maniaAmount = smoothstep(this.inputConfig.manicActivityThreshold, 1, activity);
this.peakActivity = Math.max(this.peakActivity, activity); return {
this.lastFrame = {
...DEFAULT_FRAME,
mode: this.getMode(activity, maniaAmount),
activity, activity,
maniaAmount, maniaAmount: smoothstep(this.inputConfig.manicActivityThreshold, 1, activity),
}; };
return this.lastFrame;
}
public getFrame(): GardenAudioGestureFrame {
return this.lastFrame;
} }
public reset(): void { public reset(): void {
this.samples.length = 0; this.samples.length = 0;
this.gestureClockSeconds = 0; this.gestureClockSeconds = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
} }
private trimSamples(): void { private trimSamples(): void {
@ -107,14 +66,6 @@ export class GardenAudioGestureState {
this.samples.shift(); this.samples.shift();
} }
} }
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
if (maniaAmount >= this.inputConfig.manicModeThreshold) {
return 'manic';
}
return activity >= this.inputConfig.activeActivityThreshold ? 'active' : 'calm';
}
} }
const smoothstep = (edge0: number, edge1: number, value: number): number => { const smoothstep = (edge0: number, edge1: number, value: number): number => {

View file

@ -1,25 +1,7 @@
import { VibePreset } from '../vibes'; import { VibePreset } from '../vibes';
import { import { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
GardenAudioChord,
GardenAudioConfig,
GardenAudioVibeProfile,
} from './garden-audio-config';
import { GardenAudioColorIndex } from './garden-audio-types';
export const normalizeColorIndex = (index: number): GardenAudioColorIndex => export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
export const getVibeProfile = (
config: GardenAudioConfig,
vibe: VibePreset
): GardenAudioVibeProfile => {
const profile = config.vibes[vibe.id];
if (!profile) {
throw new Error(`Missing audio profile for vibe "${vibe.id}"`);
}
return profile;
};
export const getChordIntervals = ( export const getChordIntervals = (
chord: GardenAudioChord, chord: GardenAudioChord,

View file

@ -1,10 +1,7 @@
import { VibePreset } from '../vibes'; import { VibePreset } from '../vibes';
export type GardenAudioColorIndex = 0 | 1 | 2;
export interface GardenAudioSnapshot { export interface GardenAudioSnapshot {
vibe: VibePreset; vibe: VibePreset;
selectedColorIndex: number;
isErasing: boolean; isErasing: boolean;
} }
@ -12,15 +9,10 @@ export interface GardenAudioStroke {
vibe: VibePreset; vibe: VibePreset;
from: ArrayLike<number>; from: ArrayLike<number>;
to: ArrayLike<number>; to: ArrayLike<number>;
colorIndex: number;
isErasing: boolean; isErasing: boolean;
elapsedSeconds?: number; elapsedSeconds?: number;
} }
export interface GardenAudioTouchDown {
colorIndex: number;
}
export interface GardenAudioStartOptions { export interface GardenAudioStartOptions {
userGesture?: boolean; userGesture?: boolean;
} }
@ -33,7 +25,6 @@ export interface LoadedPianoSample {
export interface ActivePianoVoice { export interface ActivePianoVoice {
gain: GainNode; gain: GainNode;
source: AudioScheduledSourceNode; source: AudioScheduledSourceNode;
startAt: number;
stopAt: number; stopAt: number;
} }

View file

@ -1,11 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { VIBE_PRESETS } from '../vibes'; import { VIBE_PRESETS } from '../vibes';
import { GardenAudio } from './garden-audio';
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config'; import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
import { loadPianoSamples, resetPianoSampleCacheForTest } from './piano-samples';
type FakeScheduledSourceNode = { type FakeScheduledSourceNode = {
start: ReturnType<typeof vi.fn>; start: ReturnType<typeof vi.fn>;
@ -21,6 +18,10 @@ const calls = {
let contextState: AudioContextState = 'suspended'; let contextState: AudioContextState = 'suspended';
let resumeError: Error | null = null; let resumeError: Error | null = null;
let ErrorHandler: typeof import('../utils/error-handler').ErrorHandler;
let GardenAudio: typeof import('./garden-audio').GardenAudio;
let loadPianoSamples: typeof import('./piano-samples').loadPianoSamples;
let Severity: typeof import('../utils/error-handler').Severity;
class FakeAudioParam { class FakeAudioParam {
public value = 0; public value = 0;
@ -140,14 +141,18 @@ const makeConfig = (): GardenAudioConfig => ({
}); });
describe('GardenAudio startup policy', () => { describe('GardenAudio startup policy', () => {
beforeEach(() => { beforeEach(async () => {
vi.resetModules();
({ ErrorHandler, Severity } = await import('../utils/error-handler'));
({ GardenAudio } = await import('./garden-audio'));
({ loadPianoSamples } = await import('./piano-samples'));
calls.constructed = 0; calls.constructed = 0;
calls.resumed = 0; calls.resumed = 0;
calls.sourcesStarted = 0; calls.sourcesStarted = 0;
calls.sources = []; calls.sources = [];
contextState = 'suspended'; contextState = 'suspended';
resumeError = null; resumeError = null;
resetPianoSampleCacheForTest();
vi.stubGlobal('AudioContext', FakeAudioContext); vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests'))); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
}); });
@ -155,7 +160,6 @@ describe('GardenAudio startup policy', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
resetPianoSampleCacheForTest();
}); });
it('does not create an AudioContext from passive audio paths', () => { it('does not create an AudioContext from passive audio paths', () => {
@ -167,7 +171,6 @@ describe('GardenAudio startup policy', () => {
vibe, vibe,
from: [0, 0], from: [0, 0],
to: [12, 0], to: [12, 0],
colorIndex: 0,
isErasing: false, isErasing: false,
}); });
@ -215,14 +218,10 @@ describe('GardenAudio startup policy', () => {
expect(calls.sourcesStarted).toBe(1); expect(calls.sourcesStarted).toBe(1);
audio.beginGesture(); audio.beginGesture();
audio.touchDown({
colorIndex: 1,
});
audio.stroke({ audio.stroke({
vibe, vibe,
from: [30, 40], from: [30, 40],
to: [60, 60], to: [60, 60],
colorIndex: 1,
isErasing: false, isErasing: false,
elapsedSeconds: 0.05, elapsedSeconds: 0.05,
}); });
@ -233,7 +232,6 @@ describe('GardenAudio startup policy', () => {
vibe, vibe,
from: [60, 60], from: [60, 60],
to: [75, 80], to: [75, 80],
colorIndex: 1,
isErasing: true, isErasing: true,
elapsedSeconds: 0.05, elapsedSeconds: 0.05,
}); });
@ -256,14 +254,10 @@ describe('GardenAudio startup policy', () => {
audio.start(vibe, { userGesture: true }); audio.start(vibe, { userGesture: true });
audio.beginGesture(); audio.beginGesture();
audio.touchDown({
colorIndex: 1,
});
audio.stroke({ audio.stroke({
vibe, vibe,
from: [30, 40], from: [30, 40],
to: [90, 40], to: [90, 40],
colorIndex: 1,
elapsedSeconds: 0.05, elapsedSeconds: 0.05,
isErasing: false, isErasing: false,
}); });

View file

@ -6,13 +6,11 @@ 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';
import { getStrokeMetrics } from './garden-audio-input'; import { getStrokeMetrics } from './garden-audio-input';
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music'; import { getVibeProfile } from './garden-audio-music';
import type { import type {
GardenAudioColorIndex,
GardenAudioSnapshot, GardenAudioSnapshot,
GardenAudioStartOptions, GardenAudioStartOptions,
GardenAudioStroke, GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types'; } from './garden-audio-types';
import { GenerativePianoEngine } from './generative-piano'; import { GenerativePianoEngine } from './generative-piano';
import { NoiseBurstPlayer } from './noise-burst-player'; import { NoiseBurstPlayer } from './noise-burst-player';
@ -22,7 +20,6 @@ export type {
GardenAudioSnapshot, GardenAudioSnapshot,
GardenAudioStartOptions, GardenAudioStartOptions,
GardenAudioStroke, GardenAudioStroke,
GardenAudioTouchDown,
} from './garden-audio-types'; } from './garden-audio-types';
export class GardenAudio { export class GardenAudio {
@ -38,7 +35,6 @@ export class GardenAudio {
private isDestroyed = false; private isDestroyed = false;
private isMuted = false; private isMuted = false;
private isGestureActive = false; private isGestureActive = false;
private selectedColorIndex: GardenAudioColorIndex = 0;
private hasQueuedPianoLoad = false; private hasQueuedPianoLoad = false;
private lastEraserAt = Number.NEGATIVE_INFINITY; private lastEraserAt = Number.NEGATIVE_INFINITY;
private lastVibeStingerAt = Number.NEGATIVE_INFINITY; private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -162,20 +158,6 @@ export class GardenAudio {
this.pianoEngine.endGesture(); this.pianoEngine.endGesture();
} }
public touchDown(touch: GardenAudioTouchDown): void {
if (this.isDestroyed || this.isMuted) {
return;
}
const context = this.graph.context;
if (!context || !this.isGestureActive) {
return;
}
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
this.gestureState.recordTouchDown();
}
public update(snapshot: GardenAudioSnapshot): void { public update(snapshot: GardenAudioSnapshot): void {
const context = this.graph.context; const context = this.graph.context;
if (!this.hasStarted || !context || this.isMuted) { if (!this.hasStarted || !context || this.isMuted) {
@ -183,7 +165,6 @@ export class GardenAudio {
} }
this.applyVibe(snapshot.vibe); this.applyVibe(snapshot.vibe);
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
this.energy.update(context.currentTime); this.energy.update(context.currentTime);
if (snapshot.isErasing) { if (snapshot.isErasing) {
@ -194,7 +175,6 @@ export class GardenAudio {
vibe: snapshot.vibe, vibe: snapshot.vibe,
now: context.currentTime, now: context.currentTime,
activity: snapshot.isErasing ? 0 : this.energy.getLevel(), activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
selectedColorIndex: this.selectedColorIndex,
}); });
this.updateDelay(snapshot); this.updateDelay(snapshot);
} }
@ -216,7 +196,6 @@ export class GardenAudio {
const metrics = getStrokeMetrics(stroke, this.config.input); const metrics = getStrokeMetrics(stroke, this.config.input);
const now = context.currentTime; const now = context.currentTime;
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
const frame = this.gestureState.recordStroke({ metrics }); const frame = this.gestureState.recordStroke({ metrics });
const strokeEnergy = frame.activity; const strokeEnergy = frame.activity;
@ -231,7 +210,6 @@ export class GardenAudio {
vibe: stroke.vibe, vibe: stroke.vibe,
now, now,
activity: strokeEnergy, activity: strokeEnergy,
selectedColorIndex: this.selectedColorIndex,
maniaAmount: frame.maniaAmount, maniaAmount: frame.maniaAmount,
}); });
} }
@ -247,7 +225,6 @@ export class GardenAudio {
this.currentVibeId = null; this.currentVibeId = null;
this.hasStarted = false; this.hasStarted = false;
this.isGestureActive = false; this.isGestureActive = false;
this.selectedColorIndex = 0;
this.hasQueuedPianoLoad = false; this.hasQueuedPianoLoad = false;
this.lastEraserAt = Number.NEGATIVE_INFINITY; this.lastEraserAt = Number.NEGATIVE_INFINITY;
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY; this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
@ -301,7 +278,7 @@ export class GardenAudio {
return; return;
} }
const profile = getVibeProfile(this.config, snapshot.vibe); const profile = getVibeProfile(snapshot.vibe);
const activity = snapshot.isErasing const activity = snapshot.isErasing
? this.config.delay.erasingActivity ? this.config.delay.erasingActivity
: this.energy.getLevel(); : this.energy.getLevel();
@ -314,7 +291,7 @@ export class GardenAudio {
} }
this.currentVibeId = vibe.id; this.currentVibeId = vibe.id;
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe)); this.graph.applyDelayProfile(getVibeProfile(vibe));
this.pianoEngine.cue(this.graph.context.currentTime); this.pianoEngine.cue(this.graph.context.currentTime);
} }
} }

View file

@ -24,14 +24,13 @@ const getBeatsPerBar = (): number =>
const renderBars = ( const renderBars = (
engine: GenerativePianoEngine, engine: GenerativePianoEngine,
activity: number, activity: number,
selectedColorIndex = 0, bars = 8,
bars = 8 now = 0
) => { ) => {
engine.renderLookahead({ engine.renderLookahead({
vibe: VIBE_PRESETS[0], vibe: VIBE_PRESETS[0],
now: 0, now,
activity, activity,
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars, lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
}); });
}; };
@ -64,7 +63,7 @@ describe('GenerativePianoEngine', () => {
it('keeps the background sparse instead of filling every beat', () => { it('keeps the background sparse instead of filling every beat', () => {
const { engine, notes } = makeEngine(); const { engine, notes } = makeEngine();
renderBars(engine, 0, 1, 4); renderBars(engine, 0, 4);
expect(uniqueStartTimes(notes).length).toBeLessThan(8); expect(uniqueStartTimes(notes).length).toBeLessThan(8);
}); });
@ -74,8 +73,8 @@ describe('GenerativePianoEngine', () => {
const active = makeEngine(); const active = makeEngine();
const startDelaySeconds = 0.02; const startDelaySeconds = 0.02;
renderBars(idle.engine, 0, 1, 8); renderBars(idle.engine, 0, 8);
renderBars(active.engine, 1, 1, 8); renderBars(active.engine, 1, 8);
expect(active.notes.length).toBeGreaterThan(idle.notes.length); expect(active.notes.length).toBeGreaterThan(idle.notes.length);
active.notes.forEach((note) => { active.notes.forEach((note) => {
@ -84,29 +83,34 @@ describe('GenerativePianoEngine', () => {
}); });
}); });
it('uses color pools with multiple notes instead of one key per color', () => { it('uses style pools with multiple notes instead of one repeating key', () => {
([0, 1, 2] as const).forEach((selectedColorIndex) => {
const { engine, notes } = makeEngine(); const { engine, notes } = makeEngine();
renderBars(engine, 1, selectedColorIndex, 16); renderBars(engine, 1, 16);
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3); expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
}); });
});
it('keeps the upper color higher and wider than the lower color', () => { it('changes musical style over time without a color change', () => {
const lower = makeEngine(); const { engine, notes } = makeEngine();
const upper = makeEngine();
renderBars(lower.engine, 1, 0, 16); renderBars(engine, 1, 32);
renderBars(upper.engine, 1, 2, 16);
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan( const styleWindows = [
average(lower.notes.map((note) => note.midi)) notes.filter((note) => note.startTime >= 0 && note.startTime < 8),
notes.filter((note) => note.startTime >= 8 && note.startTime < 16),
notes.filter((note) => note.startTime >= 16 && note.startTime < 24),
];
const averageMidiByWindow = styleWindows.map((windowNotes) =>
Math.round(average(windowNotes.map((note) => note.midi)))
); );
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan( const averagePanByWindow = styleWindows.map((windowNotes) =>
average(lower.notes.map((note) => note.pan)) Number(average(windowNotes.map((note) => note.pan)).toFixed(2))
); );
expect(styleWindows.every((windowNotes) => windowNotes.length > 0)).toBe(true);
expect(new Set(averageMidiByWindow).size).toBeGreaterThan(1);
expect(new Set(averagePanByWindow).size).toBeGreaterThan(1);
}); });
it('starts a fading brush phrase layer with each new brush gesture', () => { it('starts a fading brush phrase layer with each new brush gesture', () => {
@ -118,7 +122,6 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0], vibe: VIBE_PRESETS[0],
now, now,
activity: 0.35, activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12, lookaheadSeconds: 12,
}); });
@ -127,14 +130,11 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0], vibe: VIBE_PRESETS[0],
now, now,
activity: 0.85, activity: 0.85,
selectedColorIndex: 1,
mirrorAmount: 0.45,
}); });
layered.engine.renderLookahead({ layered.engine.renderLookahead({
vibe: VIBE_PRESETS[0], vibe: VIBE_PRESETS[0],
now, now,
activity: 0.35, activity: 0.35,
selectedColorIndex: 1,
lookaheadSeconds: 12, lookaheadSeconds: 12,
}); });
@ -149,46 +149,6 @@ describe('GenerativePianoEngine', () => {
expect(lateExtra).toBe(0); expect(lateExtra).toBe(0);
}); });
it('makes brush phrase layers denser at higher mirror amounts', () => {
const lowMirror = makeEngine();
const highMirror = makeEngine();
const now = 4;
lowMirror.engine.beginGesture();
lowMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 0,
});
lowMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
highMirror.engine.beginGesture();
highMirror.engine.recordStroke({
vibe: VIBE_PRESETS[0],
now,
activity: 0.85,
selectedColorIndex: 2,
mirrorAmount: 1,
});
highMirror.engine.renderLookahead({
vibe: VIBE_PRESETS[0],
now,
activity: 0.35,
selectedColorIndex: 2,
lookaheadSeconds: 9,
});
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
});
it('plays one immediate touch note and throttles later stroke accents', () => { it('plays one immediate touch note and throttles later stroke accents', () => {
const { engine, notes } = makeEngine(); const { engine, notes } = makeEngine();
const now = 4; const now = 4;
@ -198,13 +158,11 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0], vibe: VIBE_PRESETS[0],
now, now,
activity: 0.9, activity: 0.9,
selectedColorIndex: 1,
}); });
engine.recordStroke({ engine.recordStroke({
vibe: VIBE_PRESETS[0], vibe: VIBE_PRESETS[0],
now: now + 1, now: now + 1,
activity: 0.95, activity: 0.95,
selectedColorIndex: 1,
}); });
expect(notes).toHaveLength(1); expect(notes).toHaveLength(1);
@ -214,7 +172,6 @@ describe('GenerativePianoEngine', () => {
vibe: VIBE_PRESETS[0], vibe: VIBE_PRESETS[0],
now: now + 6, now: now + 6,
activity: 0.95, activity: 0.95,
selectedColorIndex: 1,
}); });
expect(notes).toHaveLength(2); expect(notes).toHaveLength(2);
@ -225,8 +182,8 @@ describe('GenerativePianoEngine', () => {
const first = makeEngine(); const first = makeEngine();
const second = makeEngine(); const second = makeEngine();
renderBars(first.engine, 0.78, 2, 16); renderBars(first.engine, 0.78, 16);
renderBars(second.engine, 0.78, 2, 16); renderBars(second.engine, 0.78, 16);
expect(second.notes).toEqual(first.notes); expect(second.notes).toEqual(first.notes);
}); });

View file

@ -2,9 +2,9 @@ import { clamp, clamp01 } from '../utils/clamp';
import { VibePreset } from '../vibes'; import { VibePreset } from '../vibes';
import { import {
GardenAudioChord, GardenAudioChord,
GardenAudioColorPool,
GardenAudioConfig, GardenAudioConfig,
GardenAudioRegister, GardenAudioRegister,
GardenAudioStylePool,
GardenAudioVibeProfile, GardenAudioVibeProfile,
} from './garden-audio-config'; } from './garden-audio-config';
import { import {
@ -12,13 +12,14 @@ import {
getChordIntervals, getChordIntervals,
getVibeProfile, getVibeProfile,
} from './garden-audio-music'; } from './garden-audio-music';
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types'; import { PianoNote } from './garden-audio-types';
type GardenAudioStyleIndex = 0 | 1 | 2;
interface RenderLookaheadRequest { interface RenderLookaheadRequest {
vibe: VibePreset; vibe: VibePreset;
now: number; now: number;
activity: number; activity: number;
selectedColorIndex: GardenAudioColorIndex;
lookaheadSeconds?: number; lookaheadSeconds?: number;
} }
@ -26,14 +27,6 @@ interface StrokeAccentRequest {
vibe: VibePreset; vibe: VibePreset;
now: number; now: number;
activity: number; activity: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount?: number;
panBias?: number;
registerBias?: number;
brightnessBias?: number;
contour?: number;
pressureAmount?: number;
pressureDelta?: number;
maniaAmount?: number; maniaAmount?: number;
} }
@ -41,14 +34,6 @@ interface TouchDownRequest {
vibe: VibePreset; vibe: VibePreset;
now: number; now: number;
strength: number; strength: number;
selectedColorIndex: GardenAudioColorIndex;
mirrorAmount?: number;
panBias?: number;
registerBias?: number;
brightnessBias?: number;
contour?: number;
pressureAmount?: number;
pressureDelta?: number;
maniaAmount?: number; maniaAmount?: number;
} }
@ -66,16 +51,9 @@ interface BrushPhraseLayer {
vibe: VibePreset; vibe: VibePreset;
startedAt: number; startedAt: number;
expiresAt: number; expiresAt: number;
selectedColorIndex: GardenAudioColorIndex; styleIndex: GardenAudioStyleIndex;
energy: number; energy: number;
mirrorAmount: number;
motifOffsets: Array<number>; motifOffsets: Array<number>;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number; maniaAmount: number;
} }
@ -86,7 +64,7 @@ export class GenerativePianoEngine {
private isWaitingForGestureAccent = false; private isWaitingForGestureAccent = false;
private lastGestureAccentAt = Number.NEGATIVE_INFINITY; private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
private lastStrokeAccentAt = Number.NEGATIVE_INFINITY; private lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
private readonly lastMidiByColor: [number | null, number | null, number | null] = [ private readonly lastMidiByStyle: [number | null, number | null, number | null] = [
null, null,
null, null,
null, null,
@ -130,31 +108,15 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false; this.isWaitingForGestureAccent = false;
} }
public recordTouchDown({ private recordTouchDown({
vibe, vibe,
now, now,
strength, strength,
selectedColorIndex,
mirrorAmount = 0,
panBias = 0,
registerBias = 0,
brightnessBias = 0.5,
contour = 0,
pressureAmount = 0,
pressureDelta = 0,
maniaAmount = 0, maniaAmount = 0,
}: TouchDownRequest): void { }: TouchDownRequest): void {
const normalizedStrength = clamp01(strength); const normalizedStrength = clamp01(strength);
const normalizedMirrorAmount = clamp01(mirrorAmount); const normalizedManiaAmount = clamp01(maniaAmount);
const normalizedMotif = this.normalizeMotif({ const styleIndex = this.getStyleIndex(now);
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
});
this.isWaitingForGestureAccent = false; this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = now; this.lastGestureAccentAt = now;
@ -163,18 +125,14 @@ export class GenerativePianoEngine {
vibe, vibe,
now, now,
strength: normalizedStrength, strength: normalizedStrength,
selectedColorIndex, styleIndex,
mirrorAmount: normalizedMirrorAmount, maniaAmount: normalizedManiaAmount,
...normalizedMotif,
}); });
this.playTouchNote({ this.playTouchNote({
vibe, vibe,
now, now,
selectedColorIndex, styleIndex,
strength: normalizedStrength, strength: normalizedStrength,
panBias: normalizedMotif.panBias,
registerBias: normalizedMotif.registerBias,
brightnessBias: normalizedMotif.brightnessBias,
}); });
} }
@ -182,27 +140,11 @@ export class GenerativePianoEngine {
vibe, vibe,
now, now,
activity, activity,
selectedColorIndex,
mirrorAmount = 0,
panBias = 0,
registerBias = 0,
brightnessBias = 0.5,
contour = 0,
pressureAmount = 0,
pressureDelta = 0,
maniaAmount = 0, maniaAmount = 0,
}: StrokeAccentRequest): void { }: StrokeAccentRequest): void {
const strength = clamp01(activity); const strength = clamp01(activity);
const normalizedMirrorAmount = clamp01(mirrorAmount); const normalizedManiaAmount = clamp01(maniaAmount);
const normalizedMotif = this.normalizeMotif({ const styleIndex = this.getStyleIndex(now);
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
});
if ( if (
this.isWaitingForGestureAccent && this.isWaitingForGestureAccent &&
@ -212,9 +154,7 @@ export class GenerativePianoEngine {
vibe, vibe,
now, now,
strength, strength,
selectedColorIndex, maniaAmount: normalizedManiaAmount,
mirrorAmount: normalizedMirrorAmount,
...normalizedMotif,
}); });
return; return;
} }
@ -223,16 +163,15 @@ export class GenerativePianoEngine {
this.updateBrushPhraseLayer({ this.updateBrushPhraseLayer({
now, now,
strength, strength,
selectedColorIndex, styleIndex,
mirrorAmount: normalizedMirrorAmount, maniaAmount: normalizedManiaAmount,
...normalizedMotif,
}); });
if ( if (
strength >= this.generation.strokeAccentThreshold && strength >= this.generation.strokeAccentThreshold &&
now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds
) { ) {
this.lastStrokeAccentAt = now; this.lastStrokeAccentAt = now;
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1); this.playGestureAccent(vibe, now, styleIndex, strength);
} }
} }
@ -240,7 +179,6 @@ export class GenerativePianoEngine {
vibe, vibe,
now, now,
activity, activity,
selectedColorIndex,
lookaheadSeconds = this.config.rhythm.lookaheadSeconds, lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
}: RenderLookaheadRequest): void { }: RenderLookaheadRequest): void {
this.prime(now); this.prime(now);
@ -250,7 +188,7 @@ export class GenerativePianoEngine {
return; return;
} }
const profile = getVibeProfile(this.config, vibe); const profile = getVibeProfile(vibe);
const lookaheadEnd = now + lookaheadSeconds; const lookaheadEnd = now + lookaheadSeconds;
while (this.nextBeatAt <= lookaheadEnd) { while (this.nextBeatAt <= lookaheadEnd) {
this.renderBeat({ this.renderBeat({
@ -258,7 +196,6 @@ export class GenerativePianoEngine {
beatIndex: this.beatIndex, beatIndex: this.beatIndex,
startTime: this.nextBeatAt, startTime: this.nextBeatAt,
expression: this.getExpression(activity), expression: this.getExpression(activity),
selectedColorIndex,
}); });
this.nextBeatAt += this.getBeatDurationSeconds(); this.nextBeatAt += this.getBeatDurationSeconds();
this.beatIndex += 1; this.beatIndex += 1;
@ -268,12 +205,11 @@ export class GenerativePianoEngine {
now, now,
lookaheadEnd, lookaheadEnd,
activity, activity,
selectedColorIndex,
}); });
} }
public playVibeChangeStinger(vibe: VibePreset, now: number): void { public playVibeChangeStinger(vibe: VibePreset, now: number): void {
const profile = getVibeProfile(this.config, vibe); const profile = getVibeProfile(vibe);
const chord = this.getChord(profile, 0); const chord = this.getChord(profile, 0);
const intervals = getChordIntervals(chord, true); const intervals = getChordIntervals(chord, true);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
@ -324,9 +260,9 @@ export class GenerativePianoEngine {
this.isWaitingForGestureAccent = false; this.isWaitingForGestureAccent = false;
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY; this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY; this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
this.lastMidiByColor[0] = null; this.lastMidiByStyle[0] = null;
this.lastMidiByColor[1] = null; this.lastMidiByStyle[1] = null;
this.lastMidiByColor[2] = null; this.lastMidiByStyle[2] = null;
this.brushPhraseLayers = []; this.brushPhraseLayers = [];
this.nextBrushStreamAt = null; this.nextBrushStreamAt = null;
this.brushStreamNoteIndex = 0; this.brushStreamNoteIndex = 0;
@ -338,31 +274,30 @@ export class GenerativePianoEngine {
beatIndex, beatIndex,
startTime, startTime,
expression, expression,
selectedColorIndex,
}: { }: {
profile: GardenAudioVibeProfile; profile: GardenAudioVibeProfile;
beatIndex: number; beatIndex: number;
startTime: number; startTime: number;
expression: number; expression: number;
selectedColorIndex: GardenAudioColorIndex;
}): void { }): void {
const beatsPerBar = this.getBeatsPerBar(); const beatsPerBar = this.getBeatsPerBar();
const beatInBar = beatIndex % beatsPerBar; const beatInBar = beatIndex % beatsPerBar;
const barIndex = Math.floor(beatIndex / beatsPerBar); const barIndex = Math.floor(beatIndex / beatsPerBar);
const styleIndex = this.getStyleIndex(startTime);
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) { if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) {
this.playPadChord(profile, barIndex, startTime, expression); this.playPadChord(profile, barIndex, startTime, expression);
} }
if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) { if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) {
this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex); this.playSupportNote(profile, barIndex, startTime, expression, styleIndex);
} }
if ( if (
beatInBar === this.generation.textureBeat && beatInBar === this.generation.textureBeat &&
this.shouldPlayTexture(expression, barIndex) this.shouldPlayTexture(expression, barIndex)
) { ) {
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex); this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
} }
if ( if (
@ -374,7 +309,7 @@ export class GenerativePianoEngine {
barIndex + 1, barIndex + 1,
startTime, startTime,
expression * 0.9, expression * 0.9,
selectedColorIndex styleIndex
); );
} }
} }
@ -429,31 +364,31 @@ export class GenerativePianoEngine {
barIndex: number, barIndex: number,
startTime: number, startTime: number,
expression: number, expression: number,
selectedColorIndex: GardenAudioColorIndex styleIndex: GardenAudioStyleIndex
): void { ): void {
const pool = this.generation.colorPools[selectedColorIndex]; const pool = this.generation.stylePools[styleIndex];
const chord = this.getChord(profile, barIndex); const chord = this.getChord(profile, barIndex);
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const midi = this.chooseMidi( const midi = this.chooseMidi(
{ {
baseMidi: rootMidi, baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex), offsets: this.getSupportOffsets(chordIntervals, styleIndex),
}, },
pool, pool,
this.lastMidiByColor[selectedColorIndex], this.lastMidiByStyle[styleIndex],
true true
); );
this.lastMidiByColor[selectedColorIndex] = midi; this.lastMidiByStyle[styleIndex] = midi;
this.playNote({ this.playNote({
midi, midi,
velocity: velocity:
(0.105 + expression * 0.07) * (0.105 + expression * 0.07) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier, this.config.styleVoices[styleIndex].velocityMultiplier,
startTime, startTime,
durationSeconds: 1.35 + expression * 0.4, durationSeconds: 1.35 + expression * 0.4,
pan: this.getColorPan(selectedColorIndex), pan: this.getStylePan(styleIndex),
delaySend: 0.016 + expression * 0.006, delaySend: 0.016 + expression * 0.006,
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7), lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7),
}); });
@ -464,29 +399,29 @@ export class GenerativePianoEngine {
barIndex: number, barIndex: number,
startTime: number, startTime: number,
expression: number, expression: number,
selectedColorIndex: GardenAudioColorIndex styleIndex: GardenAudioStyleIndex
): void { ): void {
const pool = this.generation.colorPools[selectedColorIndex]; const pool = this.generation.stylePools[styleIndex];
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex); const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex);
const midi = this.chooseMidi( const midi = this.chooseMidi(
{ {
baseMidi: profile.rootMidi, baseMidi: profile.rootMidi,
offsets: degrees.map((degree) => degreeToSemitone(profile, degree)), offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
}, },
pool, pool,
this.lastMidiByColor[selectedColorIndex], this.lastMidiByStyle[styleIndex],
true true
); );
this.lastMidiByColor[selectedColorIndex] = midi; this.lastMidiByStyle[styleIndex] = midi;
this.playNote({ this.playNote({
midi, midi,
velocity: velocity:
(0.09 + expression * 0.08) * (0.09 + expression * 0.08) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier, this.config.styleVoices[styleIndex].velocityMultiplier,
startTime, startTime,
durationSeconds: 0.62 + expression * 0.24, durationSeconds: 0.62 + expression * 0.24,
pan: this.getColorPan(selectedColorIndex), pan: this.getStylePan(styleIndex),
delaySend: 0.016 + expression * 0.006, delaySend: 0.016 + expression * 0.006,
lowpassHz: this.getLowpassHz(profile, midi, expression), lowpassHz: this.getLowpassHz(profile, midi, expression),
}); });
@ -495,95 +430,74 @@ export class GenerativePianoEngine {
private playGestureAccent( private playGestureAccent(
vibe: VibePreset, vibe: VibePreset,
now: number, now: number,
selectedColorIndex: GardenAudioColorIndex, styleIndex: GardenAudioStyleIndex,
strength: number, strength: number
noteCount: number
): void { ): void {
const profile = getVibeProfile(this.config, vibe); const profile = getVibeProfile(vibe);
const pool = this.generation.colorPools[selectedColorIndex]; const pool = this.generation.stylePools[styleIndex];
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3)); const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
for (let index = 0; index < noteCount; index += 1) {
const midi = this.chooseMidi( const midi = this.chooseMidi(
{ {
baseMidi: profile.rootMidi, baseMidi: profile.rootMidi,
offsets: degrees offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
.slice(index)
.concat(degrees.slice(0, index))
.map((degree) => degreeToSemitone(profile, degree)),
}, },
pool, pool,
this.lastMidiByColor[selectedColorIndex], this.lastMidiByStyle[styleIndex],
true true
); );
this.lastMidiByColor[selectedColorIndex] = midi; this.lastMidiByStyle[styleIndex] = midi;
this.playNote({ this.playNote({
midi, midi,
velocity: velocity:
(0.12 + strength * 0.09) * (0.12 + strength * 0.09) * this.config.styleVoices[styleIndex].velocityMultiplier,
this.config.colorVoices[selectedColorIndex].velocityMultiplier, startTime: now + this.config.startDelaySeconds,
startTime:
now +
this.config.startDelaySeconds +
index * this.generation.gestureAccentSpacingSeconds,
durationSeconds: 0.48 + strength * 0.22, durationSeconds: 0.48 + strength * 0.22,
pan: this.getColorPan(selectedColorIndex), pan: this.getStylePan(styleIndex),
delaySend: 0.012, delaySend: 0.012,
lowpassHz: this.getLowpassHz(profile, midi, strength), lowpassHz: this.getLowpassHz(profile, midi, strength),
}); });
} }
}
private playTouchNote({ private playTouchNote({
vibe, vibe,
now, now,
selectedColorIndex, styleIndex,
strength, strength,
panBias,
registerBias,
brightnessBias,
}: { }: {
vibe: VibePreset; vibe: VibePreset;
now: number; now: number;
selectedColorIndex: GardenAudioColorIndex; styleIndex: GardenAudioStyleIndex;
strength: number; strength: number;
panBias: number;
registerBias: number;
brightnessBias: number;
}): void { }): void {
const profile = getVibeProfile(this.config, vibe); const profile = getVibeProfile(vibe);
const pool = this.generation.colorPools[selectedColorIndex]; const pool = this.generation.stylePools[styleIndex];
const register = this.getBiasedRegister(pool, registerBias, 0); const register = this.getBiasedRegister(pool, 0);
const chord = this.getChord(profile, this.getGlobalBarIndex(now)); const chord = this.getChord(profile, this.getGlobalBarIndex(now));
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const midi = this.chooseMidi( const midi = this.chooseMidi(
{ {
baseMidi: rootMidi, baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex), offsets: this.getSupportOffsets(chordIntervals, styleIndex),
}, },
register, register,
this.lastMidiByColor[selectedColorIndex], this.lastMidiByStyle[styleIndex],
true true
); );
this.lastMidiByColor[selectedColorIndex] = midi; this.lastMidiByStyle[styleIndex] = midi;
this.lastBrushStreamMidi = midi; this.lastBrushStreamMidi = midi;
this.playNote({ this.playNote({
midi, midi,
velocity: velocity:
(0.14 + strength * 0.11) * (0.14 + strength * 0.11) * this.config.styleVoices[styleIndex].velocityMultiplier,
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime: now, startTime: now,
durationSeconds: 0.55 + strength * 0.18, durationSeconds: 0.55 + strength * 0.18,
pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0), pan: this.getStylePan(styleIndex),
delaySend: 0.006, delaySend: 0.006,
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.55 + strength * 0.35)),
profile,
midi,
clamp01(0.45 + strength * 0.35 + brightnessBias * 0.2)
),
}); });
} }
@ -591,52 +505,26 @@ export class GenerativePianoEngine {
vibe, vibe,
now, now,
strength, strength,
selectedColorIndex, styleIndex,
mirrorAmount,
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount, maniaAmount,
}: { }: {
vibe: VibePreset; vibe: VibePreset;
now: number; now: number;
strength: number; strength: number;
selectedColorIndex: GardenAudioColorIndex; styleIndex: GardenAudioStyleIndex;
mirrorAmount: number;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number; maniaAmount: number;
}): void { }): void {
const lifetimeSeconds = const lifetimeSeconds =
this.generation.brushLayerBaseSeconds + this.generation.brushLayerBaseSeconds +
strength * this.generation.brushLayerEnergySeconds + strength * this.generation.brushLayerEnergySeconds;
mirrorAmount * this.generation.brushLayerMirrorSeconds;
this.brushPhraseLayers.push({ this.brushPhraseLayers.push({
vibe, vibe,
startedAt: now, startedAt: now,
expiresAt: now + lifetimeSeconds, expiresAt: now + lifetimeSeconds,
selectedColorIndex, styleIndex,
energy: strength, energy: strength,
mirrorAmount, motifOffsets: [styleIndex - 1],
motifOffsets: this.getInitialMotifOffsets({
selectedColorIndex,
registerBias,
contour,
}),
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount, maniaAmount,
}); });
@ -650,26 +538,12 @@ export class GenerativePianoEngine {
private updateBrushPhraseLayer({ private updateBrushPhraseLayer({
now, now,
strength, strength,
selectedColorIndex, styleIndex,
mirrorAmount,
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount, maniaAmount,
}: { }: {
now: number; now: number;
strength: number; strength: number;
selectedColorIndex: GardenAudioColorIndex; styleIndex: GardenAudioStyleIndex;
mirrorAmount: number;
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number; maniaAmount: number;
}): void { }): void {
const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1]; const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1];
@ -677,20 +551,10 @@ export class GenerativePianoEngine {
return; return;
} }
const followAmount = 0.24 + clamp01(strength) * 0.24; layer.styleIndex = styleIndex;
layer.selectedColorIndex = selectedColorIndex;
layer.energy = Math.max(layer.energy * 0.94, strength); layer.energy = Math.max(layer.energy * 0.94, strength);
layer.mirrorAmount = Math.max(layer.mirrorAmount * 0.96, mirrorAmount);
layer.panBias = mix(layer.panBias, panBias, followAmount);
layer.registerBias = mix(layer.registerBias, registerBias, followAmount);
layer.brightnessBias = mix(layer.brightnessBias, brightnessBias, followAmount);
layer.contour = mix(layer.contour, contour, followAmount);
layer.pressureAmount = mix(layer.pressureAmount, pressureAmount, followAmount);
layer.pressureDelta = pressureDelta;
layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount); layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount);
layer.motifOffsets.push( layer.motifOffsets.push(this.getMotifOffset(strength));
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
);
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) { if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps); layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
} }
@ -701,13 +565,11 @@ export class GenerativePianoEngine {
now, now,
lookaheadEnd, lookaheadEnd,
activity, activity,
selectedColorIndex,
}: { }: {
vibe: VibePreset; vibe: VibePreset;
now: number; now: number;
lookaheadEnd: number; lookaheadEnd: number;
activity: number; activity: number;
selectedColorIndex: GardenAudioColorIndex;
}): void { }): void {
const earliestStart = now + this.config.piano.scheduleAheadSeconds; const earliestStart = now + this.config.piano.scheduleAheadSeconds;
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds; this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
@ -729,7 +591,7 @@ export class GenerativePianoEngine {
vibe, vibe,
startTime: this.nextBrushStreamAt, startTime: this.nextBrushStreamAt,
intensity: frame.intensity, intensity: frame.intensity,
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex, styleIndex: this.getStyleIndex(this.nextBrushStreamAt),
layer: frame.layer, layer: frame.layer,
}); });
} }
@ -742,23 +604,19 @@ export class GenerativePianoEngine {
vibe, vibe,
startTime, startTime,
intensity, intensity,
selectedColorIndex, styleIndex,
layer, layer,
}: { }: {
vibe: VibePreset; vibe: VibePreset;
startTime: number; startTime: number;
intensity: number; intensity: number;
selectedColorIndex: GardenAudioColorIndex; styleIndex: GardenAudioStyleIndex;
layer: BrushPhraseLayer | null; layer: BrushPhraseLayer | null;
}): void { }): void {
const profile = getVibeProfile(this.config, vibe); const profile = getVibeProfile(vibe);
const pool = this.generation.colorPools[selectedColorIndex]; const pool = this.generation.stylePools[styleIndex];
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18); const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
const register = this.getBiasedRegister( const register = this.getBiasedRegister(pool, maniaAmount * 0.45);
pool,
layer?.registerBias ?? 0,
maniaAmount * 0.45
);
const chord = this.getChord(profile, this.getGlobalBarIndex(startTime)); const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
@ -766,44 +624,35 @@ export class GenerativePianoEngine {
const source = useChordTone const source = useChordTone
? { ? {
baseMidi: rootMidi, baseMidi: rootMidi,
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex), offsets: this.getSupportOffsets(chordIntervals, styleIndex),
} }
: { : {
baseMidi: profile.rootMidi, baseMidi: profile.rootMidi,
offsets: this.getBrushMotifDegrees({ offsets: this.getBrushMotifDegrees({
layer, layer,
pool, pool,
selectedColorIndex, styleIndex,
}).map((degree) => degreeToSemitone(profile, degree)), }).map((degree) => degreeToSemitone(profile, degree)),
}; };
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true); const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
const pan = this.getLayerPan( const pan = this.getStylePan(styleIndex);
selectedColorIndex,
layer?.panBias ?? 0,
maniaAmount,
layer?.mirrorAmount ?? 0
);
const durationSeconds = clamp( const durationSeconds = clamp(
0.48 + intensity * 0.08 - maniaAmount * 0.34, 0.48 + intensity * 0.08 - maniaAmount * 0.34,
0.14, 0.14,
0.62 0.62
); );
const delaySend = clamp( const delaySend = clamp(
0.012 + 0.012 + intensity * 0.011 - maniaAmount * 0.006,
intensity * 0.011 +
(layer?.mirrorAmount ?? 0) * 0.004 -
maniaAmount * 0.006,
0.006, 0.006,
0.032 0.032
); );
this.lastBrushStreamMidi = midi; this.lastBrushStreamMidi = midi;
this.lastMidiByColor[selectedColorIndex] = midi; this.lastMidiByStyle[styleIndex] = midi;
this.playNote({ this.playNote({
midi, midi,
velocity: velocity:
(0.1 + intensity * 0.13) * (0.1 + intensity * 0.13) * this.config.styleVoices[styleIndex].velocityMultiplier,
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
startTime, startTime,
durationSeconds, durationSeconds,
pan, pan,
@ -811,12 +660,7 @@ export class GenerativePianoEngine {
lowpassHz: this.getLowpassHz( lowpassHz: this.getLowpassHz(
profile, profile,
midi, midi,
clamp01( clamp01(0.39 + intensity * 0.48 + maniaAmount * 0.18)
0.32 +
intensity * 0.48 +
(layer?.brightnessBias ?? 0.5) * 0.14 +
maniaAmount * 0.18
)
), ),
}); });
@ -829,11 +673,8 @@ export class GenerativePianoEngine {
midi: echoMidi, midi: echoMidi,
velocity: velocity:
(0.045 + intensity * 0.05) * (0.045 + intensity * 0.05) *
this.config.colorVoices[selectedColorIndex].velocityMultiplier, this.config.styleVoices[styleIndex].velocityMultiplier,
startTime: startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
startTime +
this.generation.brushMotifCanonDelaySeconds +
(layer?.mirrorAmount ?? 0) * 0.04,
durationSeconds: Math.max(0.11, durationSeconds * 0.68), durationSeconds: Math.max(0.11, durationSeconds * 0.68),
pan: clamp(-pan * 0.75, -1, 1), pan: clamp(-pan * 0.75, -1, 1),
delaySend: Math.max(0.006, delaySend * 0.72), delaySend: Math.max(0.006, delaySend * 0.72),
@ -847,7 +688,6 @@ export class GenerativePianoEngine {
activity: number activity: number
): { ): {
intensity: number; intensity: number;
selectedColorIndex: GardenAudioColorIndex | null;
layer: BrushPhraseLayer | null; layer: BrushPhraseLayer | null;
} { } {
const layerStates = this.brushPhraseLayers.map((layer) => ({ const layerStates = this.brushPhraseLayers.map((layer) => ({
@ -855,7 +695,7 @@ export class GenerativePianoEngine {
intensity: intensity:
layer.energy * layer.energy *
this.getBrushPhraseFade(layer, startTime) * this.getBrushPhraseFade(layer, startTime) *
(0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42), (0.8 + layer.maniaAmount * 0.42),
})); }));
const dominant = layerStates.reduce<{ const dominant = layerStates.reduce<{
layer: BrushPhraseLayer; layer: BrushPhraseLayer;
@ -875,7 +715,6 @@ export class GenerativePianoEngine {
intensity: clamp01( intensity: clamp01(
activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18 activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18
), ),
selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
layer: dominant?.layer ?? null, layer: dominant?.layer ?? null,
}; };
} }
@ -898,98 +737,28 @@ export class GenerativePianoEngine {
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds)); return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
} }
private normalizeMotif({ private getMotifOffset(strength: number): number {
panBias,
registerBias,
brightnessBias,
contour,
pressureAmount,
pressureDelta,
maniaAmount,
}: {
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
}): {
panBias: number;
registerBias: number;
brightnessBias: number;
contour: number;
pressureAmount: number;
pressureDelta: number;
maniaAmount: number;
} {
return {
panBias: clamp(panBias, -1, 1),
registerBias: clamp(registerBias, -1, 1),
brightnessBias: clamp01(brightnessBias),
contour: clamp(contour, -1, 1),
pressureAmount: clamp01(pressureAmount),
pressureDelta: clamp(pressureDelta, -1, 1),
maniaAmount: clamp01(maniaAmount),
};
}
private getInitialMotifOffsets({
selectedColorIndex,
registerBias,
contour,
}: {
selectedColorIndex: GardenAudioColorIndex;
registerBias: number;
contour: number;
}): Array<number> {
const start = selectedColorIndex - 1 + Math.round(registerBias);
const motion = contour > 0.2 ? 1 : contour < -0.2 ? -1 : 0;
return [start, start + motion, start + motion * 2, start + motion];
}
private getMotifOffset({
registerBias,
contour,
pressureDelta,
strength,
}: {
registerBias: number;
contour: number;
pressureDelta: number;
strength: number;
}): number {
const contourStep = contour > 0.3 ? 1 : contour < -0.3 ? -1 : 0;
const registerStep = Math.round(registerBias * 2);
const pressureStep = pressureDelta > 0.08 ? 1 : pressureDelta < -0.08 ? -1 : 0;
const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1; const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1;
return clamp(contourStep + registerStep + pressureStep + energyStep, -3, 4); return clamp(energyStep, -3, 4);
} }
private getBrushMotifDegrees({ private getBrushMotifDegrees({
layer, layer,
pool, pool,
selectedColorIndex, styleIndex,
}: { }: {
layer: BrushPhraseLayer | null; layer: BrushPhraseLayer | null;
pool: GardenAudioColorPool; pool: GardenAudioStylePool;
selectedColorIndex: GardenAudioColorIndex; styleIndex: GardenAudioStyleIndex;
}): Array<number> { }): Array<number> {
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset; const styleOffset = this.config.styleVoices[styleIndex].scaleDegreeOffset;
if (!layer || layer.motifOffsets.length === 0) { if (!layer || layer.motifOffsets.length === 0) {
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + colorOffset); return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset);
} }
const motifOffset = const motifOffset =
layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length]; layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length];
const contourOffset = const baseOffset = styleOffset + motifOffset;
layer.contour > 0.28
? this.brushStreamNoteIndex % 3
: layer.contour < -0.28
? -(this.brushStreamNoteIndex % 3)
: 0;
const pressureLift = layer.pressureAmount > 0.68 ? 1 : 0;
const baseOffset = colorOffset + motifOffset + contourOffset + pressureLift;
return this.rotate( return this.rotate(
pool.scaleDegrees.map((degree) => degree + baseOffset), pool.scaleDegrees.map((degree) => degree + baseOffset),
@ -999,10 +768,9 @@ export class GenerativePianoEngine {
private getBiasedRegister( private getBiasedRegister(
register: GardenAudioRegister, register: GardenAudioRegister,
registerBias: number,
maniaAmount: number maniaAmount: number
): GardenAudioRegister { ): GardenAudioRegister {
const shift = Math.round(registerBias * 7 + maniaAmount * 4); const shift = Math.round(maniaAmount * 4);
const midiMin = clamp(register.midiMin + shift, 36, 86); const midiMin = clamp(register.midiMin + shift, 36, 86);
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91); const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
@ -1014,26 +782,6 @@ export class GenerativePianoEngine {
}; };
} }
private getLayerPan(
selectedColorIndex: GardenAudioColorIndex,
panBias: number,
maniaAmount: number,
mirrorAmount: number
): number {
const shimmer =
maniaAmount > 0.4
? Math.sin(this.brushStreamNoteIndex * Math.PI * 0.5) * mirrorAmount * 0.14
: 0;
return clamp(
this.getColorPan(selectedColorIndex) +
panBias * (0.18 + maniaAmount * 0.42) +
shimmer,
-1,
1
);
}
private chooseMidi( private chooseMidi(
pitchSource: PitchSource, pitchSource: PitchSource,
register: GardenAudioRegister, register: GardenAudioRegister,
@ -1113,13 +861,13 @@ export class GenerativePianoEngine {
private getSupportOffsets( private getSupportOffsets(
chordIntervals: ReadonlyArray<number>, chordIntervals: ReadonlyArray<number>,
selectedColorIndex: GardenAudioColorIndex styleIndex: GardenAudioStyleIndex
): Array<number> { ): Array<number> {
if (selectedColorIndex === 0) { if (styleIndex === 0) {
return [0, chordIntervals[2], 12]; return [0, chordIntervals[2], 12];
} }
if (selectedColorIndex === 1) { if (styleIndex === 1) {
return [chordIntervals[1], chordIntervals[2], 0, 12]; return [chordIntervals[1], chordIntervals[2], 0, 12];
} }
@ -1138,10 +886,19 @@ export class GenerativePianoEngine {
return Math.floor(elapsedSeconds / this.getBarDurationSeconds()); return Math.floor(elapsedSeconds / this.getBarDurationSeconds());
} }
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number { private getStyleIndex(startTime: number): GardenAudioStyleIndex {
const pool = this.generation.colorPools[selectedColorIndex]; const timelineStartedAt = this.timelineStartedAt ?? startTime;
const colorVoice = this.config.colorVoices[selectedColorIndex]; const elapsedSeconds = Math.max(0, startTime - timelineStartedAt);
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1); const styleCount = this.generation.stylePools.length;
const rotationSeconds = Math.max(0.001, this.generation.styleRotationSeconds);
return (Math.floor(elapsedSeconds / rotationSeconds) %
styleCount) as GardenAudioStyleIndex;
}
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
const pool = this.generation.stylePools[styleIndex];
const styleVoice = this.config.styleVoices[styleIndex];
return clamp(pool.pan + styleVoice.panOffset * 0.35, -1, 1);
} }
private getLowpassHz( private getLowpassHz(
@ -1199,6 +956,3 @@ export class GenerativePianoEngine {
return values.map((_, index) => values[(index + offset) % values.length]); return values.map((_, index) => values[(index + offset) % values.length]);
} }
} }
const mix = (from: number, to: number, amount: number): number =>
from + (to - from) * clamp01(amount);

View file

@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { gardenAudioConfig } from './garden-audio-config'; import { gardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph'; import type { GardenAudioGraph } from './garden-audio-graph';
import { PianoSampler } from './piano-sampler'; import type { PianoSampler } from './piano-sampler';
import { pianoSampleDefinitions, resetPianoSampleCacheForTest } from './piano-samples';
const calls = { const calls = {
bufferSourcesStarted: 0, bufferSourcesStarted: 0,
}; };
const sampleCount = 30;
class FakeAudioParam { class FakeAudioParam {
public value = 0; public value = 0;
@ -61,7 +61,8 @@ class FakeAudioContext {
} }
} }
const makeSampler = (context: AudioContext): PianoSampler => { const makeSampler = async (context: AudioContext): Promise<PianoSampler> => {
const { PianoSampler } = await import('./piano-sampler');
const eventBus = new FakeAudioNode() as unknown as GainNode; const eventBus = new FakeAudioNode() as unknown as GainNode;
const graph = { const graph = {
context, context,
@ -75,17 +76,16 @@ const makeSampler = (context: AudioContext): PianoSampler => {
describe('PianoSampler', () => { describe('PianoSampler', () => {
beforeEach(() => { beforeEach(() => {
calls.bufferSourcesStarted = 0; calls.bufferSourcesStarted = 0;
resetPianoSampleCacheForTest(); vi.resetModules();
}); });
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
resetPianoSampleCacheForTest();
}); });
it('loads every piano sample before playback', async () => { it('loads every piano sample before playback', async () => {
const context = new FakeAudioContext() as unknown as AudioContext; const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = makeSampler(context); const sampler = await makeSampler(context);
const fetch = vi.fn(async () => { const fetch = vi.fn(async () => {
return { return {
arrayBuffer: async () => new ArrayBuffer(8), arrayBuffer: async () => new ArrayBuffer(8),
@ -103,15 +103,14 @@ describe('PianoSampler', () => {
velocity: 0.5, velocity: 0.5,
}); });
expect(fetch).toHaveBeenCalledTimes(pianoSampleDefinitions.length); expect(fetch).toHaveBeenCalledTimes(sampleCount);
expect(context.decodeAudioData).toHaveBeenCalledTimes(pianoSampleDefinitions.length); expect(context.decodeAudioData).toHaveBeenCalledTimes(sampleCount);
expect(calls.bufferSourcesStarted).toBe(1); expect(calls.bufferSourcesStarted).toBe(1);
}); });
it('stays silent when no decoded sample is available', () => { it('stays silent when no decoded sample is available', () => {
const context = new FakeAudioContext() as unknown as AudioContext; const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = makeSampler(context); return makeSampler(context).then((sampler) => {
sampler.play({ sampler.play({
durationSeconds: 0.2, durationSeconds: 0.2,
midi: 60, midi: 60,
@ -122,4 +121,5 @@ describe('PianoSampler', () => {
expect(calls.bufferSourcesStarted).toBe(0); expect(calls.bufferSourcesStarted).toBe(0);
}); });
});
}); });

View file

@ -124,7 +124,7 @@ export class PianoSampler {
source.start(scheduledStart); source.start(scheduledStart);
source.stop(stopAt + this.config.piano.tailStopExtraSeconds); source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt }); this.activeVoices.push({ gain, source, stopAt });
source.addEventListener( source.addEventListener(
'ended', 'ended',

View file

@ -1,6 +1,6 @@
import type { LoadedPianoSample } from './garden-audio-types'; import type { LoadedPianoSample } from './garden-audio-types';
export interface PianoSampleDefinition { interface PianoSampleDefinition {
midi: number; midi: number;
url: string; url: string;
} }
@ -46,7 +46,7 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
['C8v12.m4a', 108], ['C8v12.m4a', 108],
]; ];
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
.map(([fileName, midi]) => ({ .map(([fileName, midi]) => ({
midi, midi,
url: `${sampleBaseUrl}${fileName}`, url: `${sampleBaseUrl}${fileName}`,
@ -116,11 +116,6 @@ export const loadPianoSamples = (
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null => export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? [...loadedPianoSamples] : null; loadedPianoSamples ? [...loadedPianoSamples] : null;
export const resetPianoSampleCacheForTest = (): void => {
loadedPianoSamples = null;
pianoSampleLoadPromise = null;
};
const loadPianoSample = async ( const loadPianoSample = async (
decodeContext: BaseAudioContext, decodeContext: BaseAudioContext,
sample: PianoSampleDefinition sample: PianoSampleDefinition

View file

@ -1,7 +1,7 @@
import { ADAPTIVE_AGENT_CAP_MAX } from './config/agent-budget'; import { defaultSettings } from './config/default-settings';
import { runtimeSettings } from './config/runtime-settings'; import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types'; import type { GardenAppConfig } from './config/types';
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets'; import { defaultVibeId, vibePresets } from './config/vibe-presets';
export type { export type {
GardenAppConfig, GardenAppConfig,
@ -82,12 +82,10 @@ export const appConfig = {
unlockTickSeconds: 0.035, unlockTickSeconds: 0.035,
}, },
input: { input: {
activeActivityThreshold: 0.38,
distanceWindowForFullActivityPixels: 140, distanceWindowForFullActivityPixels: 140,
distanceWindowSeconds: 0.5, distanceWindowSeconds: 0.5,
fallbackFrameSeconds: 1 / 60, fallbackFrameSeconds: 1 / 60,
manicActivityThreshold: 0.82, manicActivityThreshold: 0.82,
manicModeThreshold: 0.72,
}, },
muteGain: 0.0001, muteGain: 0.0001,
muteRampSeconds: 0.02, muteRampSeconds: 0.02,
@ -101,7 +99,7 @@ export const appConfig = {
startDelaySeconds: 0.02, startDelaySeconds: 0.02,
vibeChangeStingerMinIntervalSeconds: 0.45, vibeChangeStingerMinIntervalSeconds: 0.45,
generativePiano: { generativePiano: {
colorPools: [ stylePools: [
{ {
midiMin: 48, midiMin: 48,
midiMax: 67, midiMax: 67,
@ -144,6 +142,7 @@ export const appConfig = {
pan: 0.2, pan: 0.2,
}, },
], ],
styleRotationSeconds: 8,
chordBars: 4, chordBars: 4,
supportBarSpacing: 2, supportBarSpacing: 2,
supportBarOffset: 1, supportBarOffset: 1,
@ -155,7 +154,6 @@ export const appConfig = {
noteScorePreferenceWeight: 1.8, noteScorePreferenceWeight: 1.8,
noteScoreRegisterWeight: 0.28, noteScoreRegisterWeight: 0.28,
noteScoreRepeatPenalty: 3.2, noteScoreRepeatPenalty: 3.2,
gestureAccentSpacingSeconds: 0.26,
gestureAccentMinIntervalSeconds: 2.5, gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinIntervalSeconds: 3.2, strokeAccentMinIntervalSeconds: 3.2,
strokeAccentThreshold: 0.58, strokeAccentThreshold: 0.58,
@ -164,7 +162,6 @@ export const appConfig = {
maxBrushPhraseLayers: 5, maxBrushPhraseLayers: 5,
brushLayerBaseSeconds: 5.5, brushLayerBaseSeconds: 5.5,
brushLayerEnergySeconds: 2.5, brushLayerEnergySeconds: 2.5,
brushLayerMirrorSeconds: 3,
brushLayerMinIntensity: 0.08, brushLayerMinIntensity: 0.08,
brushStreamIdleIntervalBeats: 2, brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1, brushStreamActiveIntervalBeats: 1,
@ -174,7 +171,7 @@ export const appConfig = {
brushMotifCanonDelaySeconds: 0.055, brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46, padDurationBarScale: 0.46,
}, },
colorVoices: [ styleVoices: [
{ {
scaleDegreeOffset: 0, scaleDegreeOffset: 0,
velocityMultiplier: 0.92, velocityMultiplier: 0.92,
@ -191,10 +188,8 @@ export const appConfig = {
panOffset: 0.14, panOffset: 0.14,
}, },
], ],
vibes: audioVibes,
}, },
deltaTime: { deltaTime: {
fpsExponentialDecayStrength: 0.01,
maxDeltaTimeSeconds: 1 / 30, maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240, minDeltaTimeSeconds: 1 / 240,
}, },
@ -220,30 +215,29 @@ export const appConfig = {
minDiffusionRate: 0.000001, minDiffusionRate: 0.000001,
}, },
eraser: { eraser: {
maxSegmentCount: 384,
maxTextureLineCount: 384, maxTextureLineCount: 384,
segmentFloatCount: 4,
workgroupSize: 64,
}, },
}, },
runtimeSettings, defaultSettings,
runtimeSettings: {
controls: runtimeControls,
},
simulation: { simulation: {
budget: { budget: {
adaptiveCapDecreaseAgentsPerSecond: 50_000, adaptiveCapDecreaseAgentsPerSecond: 50_000,
adaptiveCapMax: ADAPTIVE_AGENT_CAP_MAX, adaptiveCapInitial: 1_000_000,
adaptiveCapMax: 2_000_000,
adaptiveCapMin: 500_000, adaptiveCapMin: 500_000,
fpsHeadroom: 0.95, fpsHeadroom: 0.95,
fpsSmoothingNew: 0.06, fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94, fpsSmoothingRetain: 0.94,
}, },
brushEffectFramesPerSecond: 60, brushEffectFramesPerSecond: 60,
globalAgentCap: ADAPTIVE_AGENT_CAP_MAX,
initialAgentCount: 180_000, initialAgentCount: 180_000,
intro: { intro: {
angleJitterRadians: Math.PI * 0.08, angleJitterRadians: Math.PI * 0.08,
circleMaxSideRatio: 0.46, circleMaxSideRatio: 0.46,
circleMinSideRatio: 0.32, circleMinSideRatio: 0.32,
drawHintClass: 'draw-hint',
drawHintDelayMs: 3000, drawHintDelayMs: 3000,
durationSeconds: 4, durationSeconds: 4,
entryJitterSideRatio: 0.035, entryJitterSideRatio: 0.035,
@ -273,7 +267,6 @@ export const appConfig = {
}, },
introMoveSpeedBaseMultiplier: 1.8, introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35, introMoveSpeedProgressMultiplier: 0.35,
maxMirrorSegmentCount: 12,
stroke: { stroke: {
angleJitterRadians: Math.PI * 0.7, angleJitterRadians: Math.PI * 0.7,
densityMultiplier: 110, densityMultiplier: 110,

View file

@ -1 +0,0 @@
export const ADAPTIVE_AGENT_CAP_MAX = 2_000_000;

View file

@ -1,12 +1,6 @@
import type { AgentColorInteractionSettings, NumberControlConfig } from './types'; import type { NumberControlConfig } from './types';
const agentInteractionOptions: Record<string, number> = { export const colorInteractionSettings = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
color1ToColor1: 1, color1ToColor1: 1,
color1ToColor2: 0, color1ToColor2: 0,
color1ToColor3: 0, color1ToColor3: 0,
@ -18,6 +12,12 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
color3ToColor3: 1, color3ToColor3: 1,
}; };
const agentInteractionOptions: Record<string, number> = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({ export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions', folder: 'Color Reactions',
label, label,

View file

@ -0,0 +1,17 @@
import { colorInteractionSettings } from './color-interactions';
import type { GardenAppConfig } from './types';
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
selectedColorIndex: 0,
...colorInteractionSettings,
turnWhenLost: 0.8,
diffusionRateBrush: 0.35,
decayRateBrush: 18,
brushEffectDuration: 8,
brushCurveResolution: 12,
brushSizeVariation: 0.5,
};

View file

@ -0,0 +1,133 @@
import { colorInteractionControl } from './color-interactions';
import type { GardenAppConfig } from './types';
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('1 -> 1'),
color1ToColor2: colorInteractionControl('1 -> 2'),
color1ToColor3: colorInteractionControl('1 -> 3'),
color2ToColor1: colorInteractionControl('2 -> 1'),
color2ToColor2: colorInteractionControl('2 -> 2'),
color2ToColor3: colorInteractionControl('2 -> 3'),
color3ToColor1: colorInteractionControl('3 -> 1'),
color3ToColor2: colorInteractionControl('3 -> 2'),
color3ToColor3: colorInteractionControl('3 -> 3'),
brushEffectDuration: {
folder: 'Diffusion',
min: 0.5,
max: 20,
step: 0.05,
},
brushSize: {
folder: 'Brush',
min: 1,
max: 60,
step: 0.25,
},
brushSizeVariation: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.01,
},
brushCurveResolution: {
folder: 'Brush',
integer: true,
label: 'curve resolution',
min: 1,
max: 32,
step: 1,
},
clarity: {
folder: 'Render',
min: 0.00001,
max: 1,
step: 0.001,
},
decayRateBrush: {
folder: 'Diffusion',
min: 0.1,
max: 100,
step: 0.1,
},
decayRateTrails: {
folder: 'Diffusion',
min: 0.1,
max: 5000,
step: 1,
},
diffusionRateBrush: {
folder: 'Diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
diffusionRateTrails: {
folder: 'Diffusion',
min: 0,
max: 2,
step: 0.001,
},
eraserSize: {
folder: 'Brush',
integer: true,
min: 24,
max: 240,
step: 1,
},
individualTrailWeight: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
mirrorSegmentCount: {
folder: 'Brush',
integer: true,
min: 1,
max: 12,
step: 1,
},
moveSpeed: {
folder: 'Agent',
min: 10,
max: 500,
step: 1,
},
selectedColorIndex: {
folder: 'Brush',
integer: true,
min: 0,
max: 2,
step: 1,
},
sensorOffsetAngle: {
folder: 'Agent',
min: 0,
max: 90,
step: 1,
},
sensorOffsetDistance: {
folder: 'Agent',
min: 0,
max: 200,
step: 1,
},
spawnPerPixel: {
folder: 'Agent',
min: 0.01,
max: 1,
step: 0.001,
},
turnSpeed: {
folder: 'Agent',
min: 1,
max: 200,
step: 1,
},
turnWhenLost: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
};

View file

@ -1,191 +0,0 @@
import { ADAPTIVE_AGENT_CAP_MAX } from './agent-budget';
import {
colorInteractionControl,
defaultColorInteractionSettings,
} from './color-interactions';
import type { GardenAppConfig } from './types';
export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
defaults: {
agentBudgetMax: 1_000_000,
agentCount: 0,
selectedColorIndex: 0,
spawnPerPixel: 0.22,
moveSpeed: 82,
turnSpeed: 58,
sensorOffsetAngle: 34,
sensorOffsetDistance: 38,
turnWhenLost: 0.8,
individualTrailWeight: 0.07,
...defaultColorInteractionSettings,
diffusionRateTrails: 0.22,
decayRateTrails: 965,
diffusionRateBrush: 0.35,
decayRateBrush: 18,
brushEffectDuration: 8,
clarity: 0.62,
brushSize: 14,
brushCurveResolution: 12,
eraserSize: 96,
mirrorSegmentCount: 1,
brushSizeVariation: 0.5,
simulatedDelayMs: 0,
},
controls: {
agentBudgetMax: {
folder: 'Runtime',
integer: true,
min: 500_000,
max: ADAPTIVE_AGENT_CAP_MAX,
step: 50_000,
},
agentCount: {
folder: 'Runtime',
integer: true,
min: 0,
max: 1_000_000,
step: 1_000,
},
color1ToColor1: colorInteractionControl('1 -> 1'),
color1ToColor2: colorInteractionControl('1 -> 2'),
color1ToColor3: colorInteractionControl('1 -> 3'),
color2ToColor1: colorInteractionControl('2 -> 1'),
color2ToColor2: colorInteractionControl('2 -> 2'),
color2ToColor3: colorInteractionControl('2 -> 3'),
color3ToColor1: colorInteractionControl('3 -> 1'),
color3ToColor2: colorInteractionControl('3 -> 2'),
color3ToColor3: colorInteractionControl('3 -> 3'),
brushEffectDuration: {
folder: 'Diffusion',
min: 0.5,
max: 20,
step: 0.05,
},
brushSize: {
folder: 'Brush',
min: 1,
max: 60,
step: 0.25,
},
brushSizeVariation: {
folder: 'Brush',
min: 0,
max: 1,
step: 0.01,
},
brushCurveResolution: {
folder: 'Brush',
integer: true,
label: 'curve resolution',
min: 1,
max: 32,
step: 1,
},
clarity: {
folder: 'Render',
min: 0.00001,
max: 1,
step: 0.001,
},
decayRateBrush: {
folder: 'Diffusion',
min: 0.1,
max: 100,
step: 0.1,
},
decayRateTrails: {
folder: 'Diffusion',
min: 0.1,
max: 5000,
step: 1,
},
diffusionRateBrush: {
folder: 'Diffusion',
min: 0.001,
max: 1,
step: 0.001,
},
diffusionRateTrails: {
folder: 'Diffusion',
min: 0,
max: 2,
step: 0.001,
},
eraserSize: {
folder: 'Brush',
integer: true,
min: 24,
max: 240,
step: 1,
},
individualTrailWeight: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
mirrorSegmentCount: {
folder: 'Brush',
integer: true,
min: 1,
max: 12,
step: 1,
},
moveSpeed: {
folder: 'Agent',
min: 10,
max: 500,
step: 1,
},
selectedColorIndex: {
folder: 'Brush',
integer: true,
min: 0,
max: 2,
step: 1,
},
sensorOffsetAngle: {
folder: 'Agent',
min: 0,
max: 90,
step: 1,
},
sensorOffsetDistance: {
folder: 'Agent',
min: 0,
max: 200,
step: 1,
},
simulatedDelayMs: {
folder: 'Runtime',
integer: true,
min: 0,
max: 2000,
step: 1,
},
spawnPerPixel: {
folder: 'Agent',
min: 0.01,
max: 1,
step: 0.001,
},
turnSpeed: {
folder: 'Agent',
min: 1,
max: 200,
step: 1,
},
turnWhenLost: {
folder: 'Agent',
min: 0,
max: 1,
step: 0.001,
},
},
};

View file

@ -2,66 +2,11 @@ import type {
GardenAudioConfig, GardenAudioConfig,
GardenAudioVibeProfile, GardenAudioVibeProfile,
} from '../audio/garden-audio-config'; } from '../audio/garden-audio-config';
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
import type { AgentSettings } from '../pipelines/agents/agent-settings'; import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings'; import type { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings'; import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings'; import type { RenderSettings } from '../pipelines/render/render-settings';
export type GardenRuntimeSettings = GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
export type AgentColorInteractionSettings = Pick<
AgentSettings,
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
>;
type GardenVibeSettings = Partial<
Pick<
GardenRuntimeSettings,
| 'agentBudgetMax'
| 'brushSize'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'clarity'
| 'decayRateTrails'
| 'diffusionRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
>
>;
export interface VibePreset {
id: string;
name: string;
colors: [string, string, string];
backgroundColor: string;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
export interface NumberControlConfig { export interface NumberControlConfig {
folder: string; folder: string;
integer?: boolean; integer?: boolean;
@ -72,14 +17,53 @@ export interface NumberControlConfig {
step?: number; step?: number;
} }
export type GardenRuntimeSettings = {
brushCurveResolution: number;
brushEffectDuration: number;
eraserSize: number;
mirrorSegmentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
} & AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
type RuntimeSettingControlConfig = { type RuntimeSettingControlConfig = {
[Key in keyof GardenRuntimeSettings]: NumberControlConfig; [Key in keyof GardenRuntimeSettings]: NumberControlConfig;
}; };
type GardenVibeSettings = Pick<
GardenRuntimeSettings,
| 'brushSize'
| 'clarity'
| 'decayRateTrails'
| 'diffusionRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
>;
type GardenDefaultSettings = Omit<
GardenRuntimeSettings,
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
>;
export interface VibePreset {
id: string;
name: string;
colors: [string, string, string];
backgroundColor: string;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
export interface GardenAppConfig { export interface GardenAppConfig {
audio: GardenAudioConfig; audio: GardenAudioConfig;
deltaTime: { deltaTime: {
fpsExponentialDecayStrength: number;
maxDeltaTimeSeconds: number; maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number; minDeltaTimeSeconds: number;
}; };
@ -105,19 +89,17 @@ export interface GardenAppConfig {
minDiffusionRate: number; minDiffusionRate: number;
}; };
eraser: { eraser: {
maxSegmentCount: number;
maxTextureLineCount: number; maxTextureLineCount: number;
segmentFloatCount: number;
workgroupSize: number;
}; };
}; };
defaultSettings: GardenDefaultSettings;
runtimeSettings: { runtimeSettings: {
controls: RuntimeSettingControlConfig; controls: RuntimeSettingControlConfig;
defaults: GardenRuntimeSettings;
}; };
simulation: { simulation: {
budget: { budget: {
adaptiveCapDecreaseAgentsPerSecond: number; adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapInitial: number;
adaptiveCapMax: number; adaptiveCapMax: number;
adaptiveCapMin: number; adaptiveCapMin: number;
fpsHeadroom: number; fpsHeadroom: number;
@ -125,13 +107,11 @@ export interface GardenAppConfig {
fpsSmoothingRetain: number; fpsSmoothingRetain: number;
}; };
brushEffectFramesPerSecond: number; brushEffectFramesPerSecond: number;
globalAgentCap: number;
initialAgentCount: number; initialAgentCount: number;
intro: { intro: {
angleJitterRadians: number; angleJitterRadians: number;
circleMaxSideRatio: number; circleMaxSideRatio: number;
circleMinSideRatio: number; circleMinSideRatio: number;
drawHintClass: string;
drawHintDelayMs: number; drawHintDelayMs: number;
durationSeconds: number; durationSeconds: number;
entryJitterSideRatio: number; entryJitterSideRatio: number;
@ -161,7 +141,6 @@ export interface GardenAppConfig {
}; };
introMoveSpeedBaseMultiplier: number; introMoveSpeedBaseMultiplier: number;
introMoveSpeedProgressMultiplier: number; introMoveSpeedProgressMultiplier: number;
maxMirrorSegmentCount: number;
stroke: { stroke: {
angleJitterRadians: number; angleJitterRadians: number;
densityMultiplier: number; densityMultiplier: number;

View file

@ -1,8 +1,4 @@
import type { import type { GardenAudioChord } from '../audio/garden-audio-config';
GardenAudioChord,
GardenAudioVibeProfile,
} from '../audio/garden-audio-config';
import { defaultColorInteractionSettings } from './color-interactions';
import type { VibePreset } from './types'; import type { VibePreset } from './types';
const majorProgression: Array<GardenAudioChord> = [ const majorProgression: Array<GardenAudioChord> = [
@ -31,7 +27,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#ff5da2', '#36d7d0', '#ffd84d'], colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
backgroundColor: '#10151f', backgroundColor: '#10151f',
settings: { settings: {
agentBudgetMax: 1_000_000,
brushSize: 14, brushSize: 14,
clarity: 0.62, clarity: 0.62,
decayRateTrails: 965, decayRateTrails: 965,
@ -42,7 +37,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 38, sensorOffsetDistance: 38,
spawnPerPixel: 0.22, spawnPerPixel: 0.22,
turnSpeed: 58, turnSpeed: 58,
...defaultColorInteractionSettings,
}, },
audio: { audio: {
rootMidi: 57, rootMidi: 57,
@ -58,7 +52,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#83d483', '#f6d76b', '#5ec1a1'], colors: ['#83d483', '#f6d76b', '#5ec1a1'],
backgroundColor: '#172016', backgroundColor: '#172016',
settings: { settings: {
agentBudgetMax: 1_000_000,
brushSize: 16, brushSize: 16,
clarity: 0.68, clarity: 0.68,
decayRateTrails: 975, decayRateTrails: 975,
@ -69,7 +62,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 46, sensorOffsetDistance: 46,
spawnPerPixel: 0.18, spawnPerPixel: 0.18,
turnSpeed: 44, turnSpeed: 44,
...defaultColorInteractionSettings,
}, },
audio: { audio: {
rootMidi: 53, rootMidi: 53,
@ -90,7 +82,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'], colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
backgroundColor: '#0f1822', backgroundColor: '#0f1822',
settings: { settings: {
agentBudgetMax: 1_000_000,
brushSize: 13, brushSize: 13,
clarity: 0.58, clarity: 0.58,
decayRateTrails: 955, decayRateTrails: 955,
@ -101,7 +92,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 35, sensorOffsetDistance: 35,
spawnPerPixel: 0.25, spawnPerPixel: 0.25,
turnSpeed: 62, turnSpeed: 62,
...defaultColorInteractionSettings,
}, },
audio: { audio: {
rootMidi: 50, rootMidi: 50,
@ -117,7 +107,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'], colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
backgroundColor: '#14121d', backgroundColor: '#14121d',
settings: { settings: {
agentBudgetMax: 1_000_000,
brushSize: 12, brushSize: 12,
clarity: 0.64, clarity: 0.64,
decayRateTrails: 968, decayRateTrails: 968,
@ -128,7 +117,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 42, sensorOffsetDistance: 42,
spawnPerPixel: 0.2, spawnPerPixel: 0.2,
turnSpeed: 52, turnSpeed: 52,
...defaultColorInteractionSettings,
}, },
audio: { audio: {
rootMidi: 49, rootMidi: 49,
@ -144,7 +132,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'], colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
backgroundColor: '#191716', backgroundColor: '#191716',
settings: { settings: {
agentBudgetMax: 1_000_000,
brushSize: 15, brushSize: 15,
clarity: 0.55, clarity: 0.55,
decayRateTrails: 948, decayRateTrails: 948,
@ -155,7 +142,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 32, sensorOffsetDistance: 32,
spawnPerPixel: 0.24, spawnPerPixel: 0.24,
turnSpeed: 70, turnSpeed: 70,
...defaultColorInteractionSettings,
}, },
audio: { audio: {
rootMidi: 56, rootMidi: 56,
@ -171,7 +157,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'], colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
backgroundColor: '#101820', backgroundColor: '#101820',
settings: { settings: {
agentBudgetMax: 1_000_000,
brushSize: 18, brushSize: 18,
clarity: 0.7, clarity: 0.7,
decayRateTrails: 982, decayRateTrails: 982,
@ -182,7 +167,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 52, sensorOffsetDistance: 52,
spawnPerPixel: 0.16, spawnPerPixel: 0.16,
turnSpeed: 40, turnSpeed: 40,
...defaultColorInteractionSettings,
}, },
audio: { audio: {
rootMidi: 62, rootMidi: 62,
@ -198,7 +182,3 @@ export const vibePresets: Array<VibePreset> = [
}, },
}, },
]; ];
export const audioVibes = Object.fromEntries(
vibePresets.map((vibe) => [vibe.id, vibe.audio])
) as Record<string, GardenAudioVibeProfile>;

View file

@ -16,7 +16,6 @@ vi.hoisted(() => {
}); });
}); });
const originalAgentBudgetMax = settings.agentBudgetMax;
const originalBrushSize = settings.brushSize; const originalBrushSize = settings.brushSize;
const originalSelectedColorIndex = settings.selectedColorIndex; const originalSelectedColorIndex = settings.selectedColorIndex;
const originalSpawnPerPixel = settings.spawnPerPixel; const originalSpawnPerPixel = settings.spawnPerPixel;
@ -38,16 +37,23 @@ const setPopulationActiveCount = (population: AgentPopulation, activeCount: numb
}); });
}; };
const setPopulationAdaptiveCap = (population: AgentPopulation, adaptiveCap: number) => {
Object.assign(population as unknown as Record<string, number>, {
adaptiveCap,
});
};
const getPopulationAdaptiveCap = (population: AgentPopulation): number =>
(population as unknown as { adaptiveCap: number }).adaptiveCap;
describe('AgentPopulation adaptive budget', () => { describe('AgentPopulation adaptive budget', () => {
beforeEach(() => { beforeEach(() => {
settings.agentBudgetMax = 1_000_000;
settings.brushSize = 1; settings.brushSize = 1;
settings.selectedColorIndex = 0; settings.selectedColorIndex = 0;
settings.spawnPerPixel = 1; settings.spawnPerPixel = 1;
}); });
afterEach(() => { afterEach(() => {
settings.agentBudgetMax = originalAgentBudgetMax;
settings.brushSize = originalBrushSize; settings.brushSize = originalBrushSize;
settings.selectedColorIndex = originalSelectedColorIndex; settings.selectedColorIndex = originalSelectedColorIndex;
settings.spawnPerPixel = originalSpawnPerPixel; settings.spawnPerPixel = originalSpawnPerPixel;
@ -57,12 +63,16 @@ describe('AgentPopulation adaptive budget', () => {
const population = createPopulation(); const population = createPopulation();
setPopulationActiveCount(population, 1_000_000); setPopulationActiveCount(population, 1_000_000);
population.growBudget(1 / 60, 60, 60); population.growBudget(1 / 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000); expect(getPopulationAdaptiveCap(population)).toBeGreaterThan(
expect(population.activeAgentCount).toBeGreaterThan(1_000_000); appConfig.simulation.budget.adaptiveCapInitial
expect(settings.agentBudgetMax).toBeLessThanOrEqual( );
expect(population.activeAgentCount).toBeGreaterThan(
appConfig.simulation.budget.adaptiveCapInitial
);
expect(getPopulationAdaptiveCap(population)).toBeLessThanOrEqual(
appConfig.simulation.budget.adaptiveCapMax appConfig.simulation.budget.adaptiveCapMax
); );
}); });
@ -70,25 +80,25 @@ describe('AgentPopulation adaptive budget', () => {
it('does not grow the cap above the adaptive max agent count', () => { it('does not grow the cap above the adaptive max agent count', () => {
const population = createPopulation(); const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax; const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount - 1; setPopulationAdaptiveCap(population, maxAgentCount - 1);
setPopulationActiveCount(population, maxAgentCount - 1); setPopulationActiveCount(population, maxAgentCount - 1);
population.growBudget(1 / 60, 60, 60); population.growBudget(1 / 60, 60);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount); expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount); expect(population.activeAgentCount).toBe(maxAgentCount);
}); });
it('clamps a manually raised cap before adding agents', () => { it('clamps a stale cap before adding agents', () => {
const population = createPopulation(); const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax; const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount + 1_000; setPopulationAdaptiveCap(population, maxAgentCount + 1_000);
setPopulationActiveCount(population, maxAgentCount); setPopulationActiveCount(population, maxAgentCount);
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0)); population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(16, 0));
expect(settings.agentBudgetMax).toBe(maxAgentCount); expect(getPopulationAdaptiveCap(population)).toBe(maxAgentCount);
expect(population.activeAgentCount).toBe(maxAgentCount); expect(population.activeAgentCount).toBe(maxAgentCount);
}); });
@ -96,9 +106,11 @@ describe('AgentPopulation adaptive budget', () => {
const population = createPopulation(); const population = createPopulation();
setPopulationActiveCount(population, 1_000_000); setPopulationActiveCount(population, 1_000_000);
population.growBudget(10, 50, 60); population.growBudget(10, 50);
expect(settings.agentBudgetMax).toBe(appConfig.simulation.budget.adaptiveCapMin); expect(getPopulationAdaptiveCap(population)).toBe(
appConfig.simulation.budget.adaptiveCapMin
);
expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin); expect(population.activeAgentCount).toBe(appConfig.simulation.budget.adaptiveCapMin);
}); });
}); });

View file

@ -6,19 +6,20 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag
import { settings } from '../settings'; import { settings } from '../settings';
import { createIntroTitleAgents } from './intro-title-agents'; import { createIntroTitleAgents } from './intro-title-agents';
export const GLOBAL_AGENT_CAP = appConfig.simulation.globalAgentCap;
const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount; const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount; const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount; const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier; const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
const ADAPTIVE_CAP_MAX = appConfig.simulation.budget.adaptiveCapMax; const ADAPTIVE_CAP_MAX = appConfig.simulation.budget.adaptiveCapMax;
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin; const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
const ADAPTIVE_CAP_INITIAL = appConfig.simulation.budget.adaptiveCapInitial;
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond; appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
export class AgentPopulation { export class AgentPopulation {
private activeCount = 0; private activeCount = 0;
private adaptiveCap: number;
private replacementCursor = 0; private replacementCursor = 0;
private canExpandAdaptiveCap = true; private canExpandAdaptiveCap = true;
private shouldCompactAfterErase = false; private shouldCompactAfterErase = false;
@ -27,19 +28,17 @@ export class AgentPopulation {
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
); );
public constructor(private readonly pipeline: AgentGenerationPipeline) {} public constructor(private readonly pipeline: AgentGenerationPipeline) {
this.adaptiveCap = this.clampAdaptiveCap(ADAPTIVE_CAP_INITIAL);
}
public get activeAgentCount(): number { public get activeAgentCount(): number {
return this.activeCount; return this.activeCount;
} }
public get maxAgentCount(): number {
return this.pipeline.maxAgentCount;
}
public initializeIntroAgents(canvasSize: vec2): void { public initializeIntroAgents(canvasSize: vec2): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
const introAgentCount = Math.min(settings.agentBudgetMax, INITIAL_AGENT_COUNT); const introAgentCount = Math.min(this.adaptiveCap, INITIAL_AGENT_COUNT);
this.writeAgentBatch( this.writeAgentBatch(
createIntroTitleAgents({ createIntroTitleAgents({
count: introAgentCount, count: introAgentCount,
@ -50,16 +49,12 @@ export class AgentPopulation {
} }
public onVibeChanged(): void { public onVibeChanged(): void {
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget(); this.trimActiveCountToBudget();
} }
public growBudget( public growBudget(deltaTime: number, smoothedFps: number): void {
deltaTime: number, this.updateAdaptiveCap(deltaTime, smoothedFps);
smoothedFps: number,
refreshTargetFps: number
): void {
this.updateAdaptiveCap(deltaTime, smoothedFps, refreshTargetFps);
} }
public resizeAgents(scale: vec2): void { public resizeAgents(scale: vec2): void {
@ -108,7 +103,7 @@ export class AgentPopulation {
const x = from[0] + (to[0] - from[0]) * t; const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t; const y = from[1] + (to[1] - from[1]) * t;
const angle = const angle =
(Number.isFinite(baseAngle) ? baseAngle : Math.random() * Math.PI * 2) + baseAngle +
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians; (Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
const base = i * AGENT_FLOAT_COUNT; const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize; this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
@ -130,10 +125,10 @@ export class AgentPopulation {
} }
const count = data.length / AGENT_FLOAT_COUNT; const count = data.length / AGENT_FLOAT_COUNT;
settings.agentBudgetMax = this.clampAdaptiveCap(settings.agentBudgetMax); this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count); this.expandAdaptiveCapForPendingAgents(count);
const available = Math.max(0, settings.agentBudgetMax - this.activeCount); const available = Math.max(0, this.adaptiveCap - this.activeCount);
const appendCount = Math.min(count, available); const appendCount = Math.min(count, available);
if (appendCount > 0) { if (appendCount > 0) {
@ -165,18 +160,14 @@ export class AgentPopulation {
} }
} }
private updateAdaptiveCap( private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
deltaTime: number, const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
smoothedFps: number,
refreshTargetFps: number
): void {
const previousCap = this.clampAdaptiveCap(settings.agentBudgetMax);
this.canExpandAdaptiveCap = this.canExpandAdaptiveCap =
refreshTargetFps <= 0 || smoothedFps >=
smoothedFps >= refreshTargetFps * appConfig.simulation.budget.fpsHeadroom; ADAPTIVE_REFRESH_TARGET_FPS * appConfig.simulation.budget.fpsHeadroom;
if (this.canExpandAdaptiveCap) { if (this.canExpandAdaptiveCap) {
settings.agentBudgetMax = previousCap; this.adaptiveCap = previousCap;
this.trimActiveCountToBudget(); this.trimActiveCountToBudget();
return; return;
} }
@ -186,28 +177,28 @@ export class AgentPopulation {
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime) Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
); );
const nextCap = this.clampAdaptiveCap(previousCap - decrease); const nextCap = this.clampAdaptiveCap(previousCap - decrease);
settings.agentBudgetMax = nextCap; this.adaptiveCap = nextCap;
this.trimActiveCountToBudget(decrease); this.trimActiveCountToBudget(decrease);
} }
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void { private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
const available = Math.max(0, settings.agentBudgetMax - this.activeCount); const available = Math.max(0, this.adaptiveCap - this.activeCount);
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) { if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
return; return;
} }
const currentCap = this.clampAdaptiveCap(settings.agentBudgetMax); const currentCap = this.clampAdaptiveCap(this.adaptiveCap);
const pendingAgentCount = requestedAgentCount - available; const pendingAgentCount = requestedAgentCount - available;
settings.agentBudgetMax = this.clampAdaptiveCap(currentCap + pendingAgentCount); this.adaptiveCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
} }
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void { private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
if (this.activeCount <= settings.agentBudgetMax) { if (this.activeCount <= this.adaptiveCap) {
return; return;
} }
this.activeCount = Math.max( this.activeCount = Math.max(
settings.agentBudgetMax, this.adaptiveCap,
this.activeCount - Math.max(1, Math.ceil(maxDecrease)) this.activeCount - Math.max(1, Math.ceil(maxDecrease))
); );
this.replacementCursor = this.replacementCursor =

View file

@ -71,10 +71,7 @@ export class Export4KRenderer {
texture = this.device.createTexture({ texture = this.device.createTexture({
size: { width, height }, size: { width, height },
format, format,
usage: usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.TEXTURE_BINDING,
}); });
output = this.device.createBuffer({ output = this.device.createBuffer({
size: estimate.readbackBufferBytes, size: estimate.readbackBufferBytes,

View file

@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import { import {
estimateExport4KMemory, estimateExport4KMemory,
formatByteSize,
getAspectFitExport4KDimensions, getAspectFitExport4KDimensions,
getExport4KPreflightError, getExport4KPreflightError,
} from './export-4k'; } from './export-4k';
@ -39,7 +38,6 @@ describe('4K export preflight', () => {
expect(estimate.height).toBe(2160); expect(estimate.height).toBe(2160);
expect(estimate.bytesPerRow % 256).toBe(0); expect(estimate.bytesPerRow % 256).toBe(0);
expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes); expect(estimate.estimatedPeakBytes).toBeGreaterThan(estimate.textureBytes);
expect(formatByteSize(estimate.estimatedPeakBytes)).toMatch(/MiB$/);
}); });
it('rejects GPUs that cannot allocate the export texture', () => { it('rejects GPUs that cannot allocate the export texture', () => {

View file

@ -14,14 +14,10 @@ const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
interface Export4KMemoryEstimate { interface Export4KMemoryEstimate {
width: number; width: number;
height: number; height: number;
bytesPerPixel: number;
unpaddedBytesPerRow: number; unpaddedBytesPerRow: number;
bytesPerRow: number; bytesPerRow: number;
textureBytes: number; textureBytes: number;
readbackBufferBytes: number; readbackBufferBytes: number;
pixelBytes: number;
canvasBytes: number;
encoderSafetyBytes: number;
estimatedJsHeapBytes: number; estimatedJsHeapBytes: number;
estimatedPeakBytes: number; estimatedPeakBytes: number;
} }
@ -49,8 +45,7 @@ const alignTo = (value: number, alignment: number): number =>
const getPositiveFiniteNumber = (value: unknown): number | undefined => const getPositiveFiniteNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
export const formatByteSize = (bytes: number): string => const formatByteSize = (bytes: number): string => `${Math.ceil(bytes / 1024 / 1024)} MiB`;
`${Math.ceil(bytes / 1024 / 1024)} MiB`;
export const getAspectFitExport4KDimensions = ( export const getAspectFitExport4KDimensions = (
sourceWidth: number, sourceWidth: number,
@ -83,22 +78,15 @@ export const estimateExport4KMemory = (
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES); const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
const textureBytes = unpaddedBytesPerRow * height; const textureBytes = unpaddedBytesPerRow * height;
const readbackBufferBytes = bytesPerRow * height; const readbackBufferBytes = bytesPerRow * height;
const pixelBytes = textureBytes; const estimatedJsHeapBytes = textureBytes * 4;
const canvasBytes = textureBytes;
const encoderSafetyBytes = textureBytes * 2;
const estimatedJsHeapBytes = pixelBytes + canvasBytes + encoderSafetyBytes;
return { return {
width, width,
height, height,
bytesPerPixel: BYTES_PER_PIXEL,
unpaddedBytesPerRow, unpaddedBytesPerRow,
bytesPerRow, bytesPerRow,
textureBytes, textureBytes,
readbackBufferBytes, readbackBufferBytes,
pixelBytes,
canvasBytes,
encoderSafetyBytes,
estimatedJsHeapBytes, estimatedJsHeapBytes,
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes, estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
}; };

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { FramePerformance } from './frame-performance'; import { FramePerformance } from './frame-performance';
const INITIAL_FPS = 60;
function createScenario() { function createScenario() {
const performance = new FramePerformance(); const performance = new FramePerformance();
let time = 0; let time = 0;
@ -13,52 +15,31 @@ function createScenario() {
return { performance, advance }; return { performance, advance };
} }
describe('FramePerformance refresh target', () => { describe('FramePerformance', () => {
it('uses 60 FPS as the fixed adaptive budget target', () => { it('starts at the adaptive budget target', () => {
const { performance, advance } = createScenario(); const { performance } = createScenario();
[123, 126, 130, 121, 60, 30].forEach(advance); expect(performance.smoothedFps).toBe(INITIAL_FPS);
expect(performance.refreshTargetFps).toBe(60);
}); });
it('keeps latest and smoothed FPS separate from the fixed target', () => { it('smooths measured frame rates', () => {
const { performance, advance } = createScenario(); const { performance, advance } = createScenario();
advance(120); advance(120);
expect(performance.latestFps).toBe(120); expect(performance.smoothedFps).toBeGreaterThan(INITIAL_FPS);
expect(performance.smoothedFps).toBeGreaterThan(60); expect(performance.smoothedFps).toBeLessThan(120);
expect(performance.refreshTargetFps).toBe(60);
}); });
it('reports true FPS even when the simulation delta would clamp', () => { it('ignores long gaps before smoothing resumes', () => {
const { performance, advance } = createScenario(); const performance = new FramePerformance();
performance.update(0);
performance.update(2_000);
[5, 5, 5, 5, 5].forEach(advance); expect(performance.smoothedFps).toBe(INITIAL_FPS);
expect(performance.latestFps).toBeCloseTo(5, 5); performance.update(2_000 + 1000 / 30);
});
it('snaps the display refresh estimate to a stable screen frequency', () => { expect(performance.smoothedFps).toBeLessThan(INITIAL_FPS);
const { performance, advance } = createScenario();
[123, 126, 130, 121, 124, 127, 125, 122].forEach(advance);
expect(performance.refreshTargetFps).toBe(60);
expect(performance.displayRefreshFps).toBe(120);
});
it('ignores a single startup spike before settling the display refresh estimate', () => {
const { performance, advance } = createScenario();
advance(240);
expect(performance.displayRefreshFps).toBe(60);
Array.from({ length: 8 }).forEach(() => advance(120));
expect(performance.refreshTargetFps).toBe(60);
expect(performance.displayRefreshFps).toBe(120);
}); });
}); });

View file

@ -1,22 +1,12 @@
import { appConfig } from '../config'; import { appConfig } from '../config';
const COMMON_DISPLAY_REFRESH_RATES = [ const INITIAL_FPS = 60;
50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 240,
] as const;
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
const FRAME_GAP_RESET_SECONDS = 1; const FRAME_GAP_RESET_SECONDS = 1;
export class FramePerformance { export class FramePerformance {
public latestFps = 60; public smoothedFps = INITIAL_FPS;
public smoothedFps = 60;
public displayRefreshFps = 60;
public readonly refreshTargetFps = 60;
private previousFrameTime: DOMHighResTimeStamp | null = null; private previousFrameTime: DOMHighResTimeStamp | null = null;
private hasConfirmedDisplayRefreshFps = false;
private pendingDisplayRefreshFps = 0;
private pendingDisplayRefreshFrameCount = 0;
public update(time: DOMHighResTimeStamp): void { public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime; const previous = this.previousFrameTime;
@ -31,67 +21,8 @@ export class FramePerformance {
} }
const fps = 1 / deltaSeconds; const fps = 1 / deltaSeconds;
this.latestFps = fps;
this.updateDisplayRefreshEstimate(fps);
this.smoothedFps = this.smoothedFps =
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain + this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
fps * appConfig.simulation.budget.fpsSmoothingNew; fps * appConfig.simulation.budget.fpsSmoothingNew;
} }
private updateDisplayRefreshEstimate(fps: number): void {
const displayRefreshFps = this.snapDisplayRefreshRate(fps);
if (displayRefreshFps === null) {
this.resetPendingDisplayRefreshEstimate();
return;
}
if (
this.hasConfirmedDisplayRefreshFps &&
displayRefreshFps < this.displayRefreshFps
) {
this.resetPendingDisplayRefreshEstimate();
return;
}
if (displayRefreshFps !== this.pendingDisplayRefreshFps) {
this.pendingDisplayRefreshFps = displayRefreshFps;
this.pendingDisplayRefreshFrameCount = 1;
} else {
this.pendingDisplayRefreshFrameCount += 1;
}
if (this.pendingDisplayRefreshFrameCount < DISPLAY_REFRESH_CONFIRMATION_FRAMES) {
return;
}
this.displayRefreshFps = displayRefreshFps;
this.hasConfirmedDisplayRefreshFps = true;
this.resetPendingDisplayRefreshEstimate();
}
private snapDisplayRefreshRate(fps: number): number | null {
if (!Number.isFinite(fps) || fps <= 0) {
return null;
}
let nearestRefreshRate: number = COMMON_DISPLAY_REFRESH_RATES[0];
let nearestDifference = Math.abs(fps - nearestRefreshRate);
COMMON_DISPLAY_REFRESH_RATES.forEach((refreshRate) => {
const difference = Math.abs(fps - refreshRate);
if (difference < nearestDifference) {
nearestRefreshRate = refreshRate;
nearestDifference = difference;
}
});
return nearestDifference / nearestRefreshRate <= DISPLAY_REFRESH_SNAP_TOLERANCE
? nearestRefreshRate
: null;
}
private resetPendingDisplayRefreshEstimate(): void {
this.pendingDisplayRefreshFps = 0;
this.pendingDisplayRefreshFrameCount = 0;
}
} }

View file

@ -19,7 +19,8 @@ const getRenderStepSource = () => {
const start = simulationFrameSource.indexOf( const start = simulationFrameSource.indexOf(
'const commandEncoder = this.device.createCommandEncoder();' 'const commandEncoder = this.device.createCommandEncoder();'
); );
const end = simulationFrameSource.indexOf(' public clearSwipes', start); const swapCall = ' this.textures.swapBrushEffectMaps();';
const end = simulationFrameSource.indexOf(swapCall, start) + swapCall.length;
if (start < 0 || end < 0) { if (start < 0 || end < 0) {
throw new Error('Could not find the simulation frame execution body'); throw new Error('Could not find the simulation frame execution body');
@ -38,7 +39,7 @@ describe('GameLoop ping-pong texture flow', () => {
/commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/ /commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/
); );
expect(renderStepSource).toMatch( expect(renderStepSource).toMatch(
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/ /this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapBrushEffectMaps\(\);/
); );
}); });
@ -47,6 +48,7 @@ describe('GameLoop ping-pong texture flow', () => {
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC'); expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST'); expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
expect(resizableTextureSource).toContain('this.copyPipeline.execute('); expect(resizableTextureSource).toContain('this.copyPipeline.execute(');
expect(simulationTexturesSource).toContain('private readonly copyPipeline');
}); });
it('keeps ping-pong texture references mutable and swaps A/B identities', () => { it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
@ -54,6 +56,7 @@ describe('GameLoop ping-pong texture flow', () => {
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;'); expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;'); expect(simulationTexturesSource).toContain('public influenceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;'); expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain('public swapBrushEffectMaps(): void');
expect(simulationTexturesSource).toContain( expect(simulationTexturesSource).toContain(
'[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];' '[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];'
); );

View file

@ -11,7 +11,6 @@ import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeli
import { RenderPipeline } from '../pipelines/render/render-pipeline'; import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings'; import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context'; import { initializeContext } from '../utils/graphics/initialize-context';
import { GLOBAL_AGENT_CAP } from './agent-population';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types'; import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { SimulationFrameRenderer } from './simulation-frame'; import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures'; import { SimulationTextures } from './simulation-textures';
@ -24,8 +23,6 @@ interface FrameParameters extends RenderInputs {
introProgress: number; introProgress: number;
selectedColorIndex: number; selectedColorIndex: number;
isErasing: boolean; isErasing: boolean;
cameraCenter: [number, number];
cameraZoom: number;
eraserPixelSize: number; eraserPixelSize: number;
} }
@ -56,13 +53,11 @@ export class GameLoopResources {
this.commonState.setParameters({ this.commonState.setParameters({
canvasSize, canvasSize,
time: 0, time: 0,
deltaTime: 0,
}); });
this.agentGenerationPipeline = new AgentGenerationPipeline( this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device, this.device,
this.commonState, appConfig.simulation.budget.adaptiveCapMax
GLOBAL_AGENT_CAP
); );
this.agentPipeline = new AgentPipeline( this.agentPipeline = new AgentPipeline(
@ -73,15 +68,11 @@ export class GameLoopResources {
this.brushPipeline = new BrushPipeline(this.device, this.commonState); this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.eraserAgentPipeline = new EraserAgentPipeline( this.eraserAgentPipeline = new EraserAgentPipeline(
this.device, this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer this.agentGenerationPipeline.agentsBuffer
); );
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState); this.diffusionPipeline = new DiffusionPipeline(this.device);
this.brushEffectDiffusionPipeline = new DiffusionPipeline( this.brushEffectDiffusionPipeline = new DiffusionPipeline(this.device);
this.device,
this.commonState
);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, { this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
@ -106,17 +97,13 @@ export class GameLoopResources {
activeAgentCount, activeAgentCount,
introProgress, introProgress,
selectedColorIndex, selectedColorIndex,
isErasing,
channelColors, channelColors,
backgroundColor, backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize, eraserPixelSize,
}: FrameParameters): void { }: FrameParameters): void {
this.commonState.setParameters({ this.commonState.setParameters({
canvasSize, canvasSize,
time, time,
deltaTime,
}); });
this.agentPipeline.setParameters({ this.agentPipeline.setParameters({
...settings, ...settings,
@ -133,19 +120,15 @@ export class GameLoopResources {
this.brushPipeline.setParameters({ this.brushPipeline.setParameters({
...settings, ...settings,
selectedColorIndex, selectedColorIndex,
isErasing,
}); });
this.diffusionPipeline.setParameters(settings); this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({ this.renderPipeline.setParameters({
...settings, ...settings,
channelColors, channelColors,
backgroundColor, backgroundColor,
cameraCenter,
cameraZoom,
}); });
this.eraserAgentPipeline.setParameters({ this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount, agentCount: activeAgentCount,
eraserSize: eraserPixelSize,
}); });
this.eraserTexturePipeline.setParameters({ this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize, eraserSize: eraserPixelSize,
@ -160,10 +143,6 @@ export class GameLoopResources {
this.frameRenderer.execute(isErasing, canvasReadbackRequest); this.frameRenderer.execute(isErasing, canvasReadbackRequest);
} }
public clearSwipes(): void {
this.frameRenderer.clearSwipes();
}
public destroy(): void { public destroy(): void {
this.agentGenerationPipeline.destroy(); this.agentGenerationPipeline.destroy();
this.agentPipeline.destroy(); this.agentPipeline.destroy();

View file

@ -1,7 +0,0 @@
export interface GameLoopSettings {
agentBudgetMax: number;
agentCount: number;
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;
}

View file

@ -5,7 +5,6 @@ import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { activeVibe, settings } from '../settings'; import { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator'; import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { sleep } from '../utils/sleep';
import { AgentPopulation } from './agent-population'; import { AgentPopulation } from './agent-population';
import { EraserPreview } from './eraser-preview'; import { EraserPreview } from './eraser-preview';
import { Export4KRenderer } from './export-4k-renderer'; import { Export4KRenderer } from './export-4k-renderer';
@ -18,9 +17,7 @@ import { RenderInputCache } from './render-input-cache';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor'; import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
export default class GameLoop { export default class GameLoop {
private static readonly MAX_MIRROR_SEGMENT_COUNT = private static readonly MAX_MIRROR_SEGMENT_COUNT = appConfig.toolbar.mirror.max;
appConfig.simulation.maxMirrorSegmentCount;
private static readonly DEV_STATS_INTERVAL_MS = 250;
private readonly resources: GameLoopResources; private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig); private readonly audio = new GardenAudio(gardenAudioConfig);
@ -32,13 +29,10 @@ export default class GameLoop {
private readonly export4KRenderer: Export4KRenderer; private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance(); private readonly framePerformance = new FramePerformance();
private readonly toolbarContrastMonitor: ToolbarContrastMonitor; private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly devStatsElement: HTMLDivElement | null;
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16); private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this); private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void; private readonly keydownListener: (event: KeyboardEvent) => void;
private lastDevStatsUpdateAt = 0;
private isStatsOverlayPinned = false;
private hasFinished = false; private hasFinished = false;
private readonly finished = Promise.withResolvers<void>(); private readonly finished = Promise.withResolvers<void>();
@ -49,8 +43,6 @@ export default class GameLoop {
ui: GardenUi ui: GardenUi
) { ) {
this.resize(); this.resize();
this.devStatsElement = this.createDevStatsElement();
this.syncDevStatsVisibility();
this.resources = new GameLoopResources(canvas, device, this.canvasSize); this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt); this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview); this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@ -102,25 +94,14 @@ export default class GameLoop {
} }
public onVibeChanged(): void { public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.renderInputs.invalidate(); this.renderInputs.invalidate();
this.agentPopulation.onVibeChanged();
} }
public setAudioMuted(isMuted: boolean): void { public setAudioMuted(isMuted: boolean): void {
this.audio.setMuted(isMuted); this.audio.setMuted(isMuted);
} }
public setStatsOverlayPinned(isPinned: boolean): void {
const wasVisible = this.shouldShowDevStats;
this.isStatsOverlayPinned = isPinned;
this.syncDevStatsVisibility();
if (!wasVisible && this.shouldShowDevStats) {
this.lastDevStatsUpdateAt = Number.NEGATIVE_INFINITY;
this.updateDevStats(performance.now());
}
}
public startAudio(userGesture = false): void { public startAudio(userGesture = false): void {
this.audio.start(activeVibe, { userGesture }); this.audio.start(activeVibe, { userGesture });
} }
@ -134,10 +115,6 @@ export default class GameLoop {
return this.finished.promise; return this.finished.promise;
} }
public get maxAgentCount(): number {
return this.agentPopulation.maxAgentCount;
}
public async export4K(): Promise<void> { public async export4K(): Promise<void> {
return this.export4KRenderer.export(); return this.export4KRenderer.export();
} }
@ -150,7 +127,6 @@ export default class GameLoop {
window.removeEventListener('keydown', this.keydownListener); window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach(); this.pointerInput.detach();
this.toolbarContrastMonitor.destroy(); this.toolbarContrastMonitor.destroy();
this.devStatsElement?.remove();
this.introPrompt.destroy(); this.introPrompt.destroy();
this.resources.destroy(); this.resources.destroy();
await this.audio.destroy(); await this.audio.destroy();
@ -164,29 +140,19 @@ export default class GameLoop {
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time); const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time); this.framePerformance.update(time);
this.agentPopulation.growBudget( this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.introPrompt.update(); this.introPrompt.update();
this.resize(); this.resize();
this.resizeSimulationToCanvas(); this.resizeSimulationToCanvas();
const { channelColors, backgroundColor } = this.renderInputs.get(); const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress; const introProgress = this.introPrompt.progress;
const cameraZoom = 1;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
];
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio; const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
const isErasing = this.pointerInput.isEraseMode; const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0]; const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor); this.renderInputs.updateAccentColor(accentColor);
this.audio.update({ this.audio.update({
vibe: activeVibe, vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing, isErasing,
}); });
@ -200,8 +166,6 @@ export default class GameLoop {
isErasing, isErasing,
channelColors, channelColors,
backgroundColor, backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize, eraserPixelSize,
}); });
@ -213,60 +177,9 @@ export default class GameLoop {
this.pointerInput.clearSwipesIfIdle(); this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive); await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
}
requestAnimationFrame(this.render); requestAnimationFrame(this.render);
}; };
private createDevStatsElement(): HTMLDivElement | null {
const container = this.canvas.parentElement;
if (!container) {
return null;
}
const element = document.createElement('div');
element.className = 'dev-stats-overlay';
element.setAttribute('aria-hidden', 'true');
container.appendChild(element);
return element;
}
private updateDevStats(time: DOMHighResTimeStamp): void {
if (
!this.devStatsElement ||
!this.shouldShowDevStats ||
time - this.lastDevStatsUpdateAt < GameLoop.DEV_STATS_INTERVAL_MS
) {
return;
}
this.lastDevStatsUpdateAt = time;
const displayRefreshFps = Math.round(this.framePerformance.displayRefreshFps);
this.devStatsElement.textContent = [
`FPS ${this.framePerformance.smoothedFps.toFixed(1)} / ${displayRefreshFps}`,
`Agents ${this.formatDevStatNumber(this.agentPopulation.activeAgentCount)}`,
`Cap ${this.formatDevStatNumber(settings.agentBudgetMax)}`,
].join('\n');
}
private syncDevStatsVisibility(): void {
if (!this.devStatsElement) {
return;
}
const isVisible = this.shouldShowDevStats;
this.devStatsElement.hidden = !isVisible;
this.devStatsElement.setAttribute('aria-hidden', String(!isVisible));
}
private formatDevStatNumber(value: number): string {
return Math.max(0, Math.round(value)).toLocaleString('en-US');
}
private resize(): void { private resize(): void {
const width = Math.max( const width = Math.max(
1, 1,
@ -310,8 +223,4 @@ export default class GameLoop {
: 1; : 1;
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count))); return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
} }
private get shouldShowDevStats(): boolean {
return import.meta.env.DEV || this.isStatsOverlayPinned;
}
} }

View file

@ -1,13 +1,13 @@
import { appConfig } from '../config'; import { appConfig } from '../config';
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000; const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
const DRAW_HINT_CLASS = 'draw-hint';
export class IntroPrompt { export class IntroPrompt {
private introComplete = false; private introComplete = false;
private introStartedAt = performance.now(); private introStartedAt = performance.now();
private introCompletedAt: number | null = null; private introCompletedAt: number | null = null;
private hasStartedDrawing = false; private hasStartedDrawing = false;
private isDrawHintVisible = false;
public constructor(private readonly prompt: HTMLElement) {} public constructor(private readonly prompt: HTMLElement) {}
@ -55,12 +55,11 @@ export class IntroPrompt {
} }
private showDrawHint(): void { private showDrawHint(): void {
if (this.isDrawHintVisible) { if (this.prompt.classList.contains(DRAW_HINT_CLASS)) {
return; return;
} }
this.isDrawHintVisible = true; this.prompt.classList.add(DRAW_HINT_CLASS);
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
this.prompt.innerHTML = ` this.prompt.innerHTML = `
<svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false"> <svg class="draw-hint-mark" viewBox="0 0 128 72" aria-hidden="true" focusable="false">
<path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" /> <path class="draw-hint-shadow" d="M12 50 C34 18 52 62 70 36 S102 18 116 42" />
@ -68,13 +67,12 @@ export class IntroPrompt {
<circle class="draw-hint-start" cx="12" cy="50" r="4" /> <circle class="draw-hint-start" cx="12" cy="50" r="4" />
<circle class="draw-hint-end" cx="116" cy="42" r="7" /> <circle class="draw-hint-end" cx="116" cy="42" r="7" />
</svg> </svg>
<span class="draw-hint-text">Draw on the screen</span> <span>Draw on the screen</span>
`; `;
} }
private hideDrawHint(): void { private hideDrawHint(): void {
this.isDrawHintVisible = false; this.prompt.classList.remove(DRAW_HINT_CLASS);
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
this.prompt.replaceChildren(); this.prompt.replaceChildren();
} }
} }

View file

@ -97,7 +97,6 @@ const createPointerInput = async () => {
endGesture: vi.fn(), endGesture: vi.fn(),
start: vi.fn(), start: vi.fn(),
stroke: vi.fn(), stroke: vi.fn(),
touchDown: vi.fn(),
}; };
const brushPipeline = makeSwipePipeline(); const brushPipeline = makeSwipePipeline();
const eraserAgentPipeline = makeSwipePipeline(); const eraserAgentPipeline = makeSwipePipeline();
@ -182,11 +181,6 @@ describe('GardenPointerInput drawing startup', () => {
expect(onStartDrawing).toHaveBeenCalledTimes(1); expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true }); expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1); expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(audio.touchDown).toHaveBeenCalledWith(
expect.objectContaining({
colorIndex: 0,
})
);
expect(audio.stroke).not.toHaveBeenCalled(); expect(audio.stroke).not.toHaveBeenCalled();
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1); expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1); expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);

View file

@ -111,9 +111,6 @@ export class GardenPointerInput {
this.options.audio.start(activeVibe, { userGesture: true }); this.options.audio.start(activeVibe, { userGesture: true });
} }
this.options.audio.beginGesture(); this.options.audio.beginGesture();
this.options.audio.touchDown({
colorIndex: settings.selectedColorIndex,
});
this.options.onStartDrawing(); this.options.onStartDrawing();
this.activePointerId = event.pointerId; this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId); this.canvas.setPointerCapture(event.pointerId);
@ -183,14 +180,11 @@ export class GardenPointerInput {
if (this.isErasing) { if (this.isErasing) {
segments.forEach((segment) => { segments.forEach((segment) => {
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to); this.options.eraserAgentPipeline.addSwipeSegment();
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to); this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
}); });
} else { } else {
this.addSmoothedBrushSample(position); this.addSmoothedBrushSample(position);
}
if (!this.isErasing) {
segments.forEach((segment) => { segments.forEach((segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to); this.options.spawnStrokeAgents(segment.from, segment.to);
}); });
@ -200,7 +194,6 @@ export class GardenPointerInput {
vibe: activeVibe, vibe: activeVibe,
from: previousPosition, from: previousPosition,
to: position, to: position,
colorIndex: settings.selectedColorIndex,
isErasing: this.isErasing, isErasing: this.isErasing,
elapsedSeconds, elapsedSeconds,
}); });
@ -375,7 +368,7 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
const getBrushCurveResolution = (): number => { const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution) const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution ? settings.brushCurveResolution
: appConfig.runtimeSettings.defaults.brushCurveResolution; : appConfig.defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution)); return Math.max(1, Math.floor(resolution));
}; };

View file

@ -80,13 +80,6 @@ export class SimulationFrameRenderer {
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
canvasReadbackRequest?.afterSubmit(); canvasReadbackRequest?.afterSubmit();
this.textures.swapSourceMaps(); this.textures.swapBrushEffectMaps();
this.textures.swapInfluenceMaps();
}
public clearSwipes(): void {
this.pipelines.brushPipeline.clearSwipes();
this.pipelines.eraserAgentPipeline.clearSwipes();
this.pipelines.eraserTexturePipeline.clearSwipes();
} }
} }

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { ResizableTexture } from '../utils/graphics/resizable-texture'; import { ResizableTexture } from '../utils/graphics/resizable-texture';
export class SimulationTextures { export class SimulationTextures {
@ -10,18 +11,20 @@ export class SimulationTextures {
public influenceMapA: ResizableTexture; public influenceMapA: ResizableTexture;
public influenceMapB: ResizableTexture; public influenceMapB: ResizableTexture;
public eraserMask: ResizableTexture; public eraserMask: ResizableTexture;
private readonly copyPipeline: CopyPipeline;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
canvasSize: vec2 canvasSize: vec2
) { ) {
this.trailMapA = new ResizableTexture(this.device, canvasSize); this.copyPipeline = new CopyPipeline(this.device);
this.trailMapB = new ResizableTexture(this.device, canvasSize); this.trailMapA = this.createTexture(canvasSize);
this.sourceMapA = new ResizableTexture(this.device, canvasSize); this.trailMapB = this.createTexture(canvasSize);
this.sourceMapB = new ResizableTexture(this.device, canvasSize); this.sourceMapA = this.createTexture(canvasSize);
this.influenceMapA = new ResizableTexture(this.device, canvasSize); this.sourceMapB = this.createTexture(canvasSize);
this.influenceMapB = new ResizableTexture(this.device, canvasSize); this.influenceMapA = this.createTexture(canvasSize);
this.eraserMask = new ResizableTexture(this.device, canvasSize); this.influenceMapB = this.createTexture(canvasSize);
this.eraserMask = this.createTexture(canvasSize);
} }
public resizeTo(nextSize: vec2): vec2 | null { public resizeTo(nextSize: vec2): vec2 | null {
@ -69,11 +72,8 @@ export class SimulationTextures {
); );
} }
public swapSourceMaps(): void { public swapBrushEffectMaps(): void {
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA]; [this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
}
public swapInfluenceMaps(): void {
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA]; [this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
} }
@ -85,5 +85,10 @@ export class SimulationTextures {
this.influenceMapA.destroy(); this.influenceMapA.destroy();
this.influenceMapB.destroy(); this.influenceMapB.destroy();
this.eraserMask.destroy(); this.eraserMask.destroy();
this.copyPipeline.destroy();
}
private createTexture(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, this.copyPipeline, size);
} }
} }

View file

@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import { getToolbarContrastMetrics } from './toolbar-contrast-monitor';
getToolbarContrastMetrics,
shouldDimToolbarBackground,
} from './toolbar-contrast-monitor';
const makePixels = ( const makePixels = (
samples: ReadonlyArray<readonly [number, number, number]> samples: ReadonlyArray<readonly [number, number, number]>
@ -27,12 +24,28 @@ describe('toolbar contrast monitoring', () => {
false false
); );
expect(metrics.dimmingStrength).toBe(0); expect(metrics.backgroundOpacity).toBe(0);
expect(metrics.lowContrastRatio).toBe(0); expect(metrics.lowContrastRatio).toBe(0);
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
}); });
it('dims the toolbar when enough samples have poor contrast with white controls', () => { it('ramps background opacity as canvas samples get lighter', () => {
const dimMetrics = getToolbarContrastMetrics(
makePixels(Array.from({ length: 91 }, () => [130, 130, 130])),
91,
false
);
const brightMetrics = getToolbarContrastMetrics(
makePixels(Array.from({ length: 91 }, () => [210, 210, 210])),
91,
false
);
expect(dimMetrics.backgroundOpacity).toBeGreaterThan(0);
expect(brightMetrics.backgroundOpacity).toBeGreaterThan(dimMetrics.backgroundOpacity);
expect(brightMetrics.backgroundOpacity).toBeLessThanOrEqual(0.82);
});
it('raises background opacity when enough samples have poor contrast with white controls', () => {
const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const); const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const);
const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const); const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const);
const metrics = getToolbarContrastMetrics( const metrics = getToolbarContrastMetrics(
@ -42,21 +55,7 @@ describe('toolbar contrast monitoring', () => {
); );
expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08); expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08);
expect(shouldDimToolbarBackground(metrics, false)).toBe(true); expect(metrics.backgroundOpacity).toBeGreaterThan(0);
});
it('keeps the dimmed state until contrast has clearly recovered', () => {
const metrics = getToolbarContrastMetrics(
makePixels([
...Array.from({ length: 86 }, () => [8, 12, 18] as const),
...Array.from({ length: 5 }, () => [245, 240, 218] as const),
]),
91,
false
);
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
expect(shouldDimToolbarBackground(metrics, true)).toBe(true);
}); });
it('reads bgra canvas samples in the correct channel order', () => { it('reads bgra canvas samples in the correct channel order', () => {

View file

@ -7,8 +7,8 @@ interface CanvasSamplePoint {
interface ToolbarContrastMetrics { interface ToolbarContrastMetrics {
averageLuminance: number; averageLuminance: number;
backgroundOpacity: number;
brightRatio: number; brightRatio: number;
dimmingStrength: number;
lowContrastRatio: number; lowContrastRatio: number;
} }
@ -16,10 +16,9 @@ const BYTES_PER_SAMPLE = 4;
const SAMPLE_COLUMNS = 13; const SAMPLE_COLUMNS = 13;
const SAMPLE_ROWS = 7; const SAMPLE_ROWS = 7;
const SAMPLE_INTERVAL_MS = 300; const SAMPLE_INTERVAL_MS = 300;
const LOW_CONTRAST_RATIO_TO_DIM = 0.08; const BACKGROUND_OPACITY_MAX = 0.82;
const LOW_CONTRAST_RATIO_TO_CLEAR = 0.04; const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
const DIMMING_STRENGTH_TO_DIM = 0.18; const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
const DIMMING_STRENGTH_TO_CLEAR = 0.1;
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
@ -44,8 +43,8 @@ export const getToolbarContrastMetrics = (
if (count === 0) { if (count === 0) {
return { return {
averageLuminance: 0, averageLuminance: 0,
backgroundOpacity: 0,
brightRatio: 0, brightRatio: 0,
dimmingStrength: 0,
lowContrastRatio: 0, lowContrastRatio: 0,
}; };
} }
@ -74,34 +73,24 @@ export const getToolbarContrastMetrics = (
const averageLuminance = luminanceTotal / count; const averageLuminance = luminanceTotal / count;
const brightRatio = brightCount / count; const brightRatio = brightCount / count;
const lowContrastRatio = lowContrastCount / count; const lowContrastRatio = lowContrastCount / count;
const dimmingStrength = clamp01( const backgroundStrength = clamp01(
Math.max(0, averageLuminance - 0.11) / 0.28 + Math.max(0, averageLuminance - 0.11) / 0.28 +
brightRatio * 0.65 + brightRatio * 0.65 +
lowContrastRatio * 1.8 lowContrastRatio * 1.8
); );
const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
return { return {
averageLuminance, averageLuminance,
backgroundOpacity,
brightRatio, brightRatio,
dimmingStrength,
lowContrastRatio, lowContrastRatio,
}; };
}; };
export const shouldDimToolbarBackground = (
metrics: ToolbarContrastMetrics,
wasDimmed: boolean
): boolean =>
wasDimmed
? metrics.dimmingStrength > DIMMING_STRENGTH_TO_CLEAR ||
metrics.lowContrastRatio > LOW_CONTRAST_RATIO_TO_CLEAR
: metrics.dimmingStrength > DIMMING_STRENGTH_TO_DIM ||
metrics.lowContrastRatio >= LOW_CONTRAST_RATIO_TO_DIM;
export class ToolbarContrastMonitor { export class ToolbarContrastMonitor {
private readonly isBgra: boolean; private readonly isBgra: boolean;
private isDestroyed = false; private isDestroyed = false;
private isDimmed = false;
private isReadbackPending = false; private isReadbackPending = false;
private lastSampleAt = Number.NEGATIVE_INFINITY; private lastSampleAt = Number.NEGATIVE_INFINITY;
@ -210,7 +199,28 @@ export class ToolbarContrastMonitor {
public destroy(): void { public destroy(): void {
this.isDestroyed = true; this.isDestroyed = true;
this.toolbar.classList.remove('needs-contrast-background'); this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
}
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
const safeBackgroundOpacity = Math.min(
BACKGROUND_OPACITY_MAX,
Math.max(0, backgroundOpacity)
);
const backgroundStrength =
BACKGROUND_OPACITY_MAX > 0
? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
: 0;
this.toolbar.style.setProperty(
TOOLBAR_BACKGROUND_OPACITY_PROPERTY,
`${(safeBackgroundOpacity * 100).toFixed(1)}%`
);
this.toolbar.style.setProperty(
TOOLBAR_BACKGROUND_STRENGTH_PROPERTY,
backgroundStrength.toFixed(3)
);
} }
private getSamplePoints(): Array<CanvasSamplePoint> { private getSamplePoints(): Array<CanvasSamplePoint> {
@ -268,8 +278,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, sampleCount, this.isBgra);
this.isDimmed = shouldDimToolbarBackground(metrics, this.isDimmed); this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
this.toolbar.classList.toggle('needs-contrast-background', this.isDimmed);
} }
} catch { } catch {
// Readback is an enhancement; leave rendering alone if the GPU rejects it. // Readback is an enhancement; leave rendering alone if the GPU rejects it.

View file

@ -3,7 +3,7 @@
@use 'style/garden-prompt'; @use 'style/garden-prompt';
@use 'style/control-dock'; @use 'style/control-dock';
@use 'style/toolbar'; @use 'style/toolbar';
@use 'style/panels';
@use 'style/config-pane'; @use 'style/config-pane';
@use 'style/panels';
@use 'style/loading'; @use 'style/loading';
@use 'style/motion'; @use 'style/motion';

View file

@ -2,12 +2,7 @@ import GameLoop from './game-loop/game-loop';
import './index.scss'; import './index.scss';
import { import { initAnalytics, trackExport, trackVibeChange } from './analytics';
initAnalytics,
trackExport,
trackSettingsOpen,
trackVibeChange,
} from './analytics';
import { preloadPianoSamples } from './audio/piano-samples'; import { preloadPianoSamples } from './audio/piano-samples';
import { appConfig } from './config'; import { appConfig } from './config';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator'; import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
@ -131,7 +126,6 @@ const queryAppElements = () => ({
export4k: queryRequiredElement('.export-4k', HTMLButtonElement), export4k: queryRequiredElement('.export-4k', HTMLButtonElement),
exportStatus: queryRequiredElement('.export-status', HTMLSpanElement), exportStatus: queryRequiredElement('.export-status', HTMLSpanElement),
prompt: queryRequiredElement('.garden-prompt', HTMLDivElement), prompt: queryRequiredElement('.garden-prompt', HTMLDivElement),
loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement),
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement), loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement), loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
}); });
@ -143,11 +137,12 @@ let elements: AppElements;
const setLoadingStage = (label: string, ratio: number) => { const setLoadingStage = (label: string, ratio: number) => {
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100); const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
elements.loadingStatus.textContent = label; elements.loadingStatus.textContent = label;
elements.loadingIndicator.style.setProperty('--loading-progress', `${percent}%`); elements.loadingProgress.style.setProperty('--loading-progress', `${percent}%`);
elements.loadingProgress.setAttribute('aria-valuenow', String(percent)); elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
}; };
let isAudioMuted = readBrowserStorage(appConfig.storage.audioMutedKey) === '1'; let isAudioMuted = readBrowserStorage(appConfig.storage.audioMutedKey) === '1';
let isEraserActive = false;
const renderAudioUi = (game: GameLoop | null) => { const renderAudioUi = (game: GameLoop | null) => {
elements.soundButton.classList.toggle('muted', isAudioMuted); elements.soundButton.classList.toggle('muted', isAudioMuted);
@ -161,21 +156,19 @@ const renderAudioUi = (game: GameLoop | null) => {
}; };
const renderPaletteUi = (game: GameLoop | null) => { const renderPaletteUi = (game: GameLoop | null) => {
const isErasing = elements.eraserSizeControl.dataset.active === '1';
elements.swatches.forEach((swatch, index) => { elements.swatches.forEach((swatch, index) => {
swatch.style.backgroundColor = activeVibe.colors[index]; swatch.style.backgroundColor = activeVibe.colors[index];
swatch.classList.toggle( swatch.classList.toggle(
'active', 'active',
settings.selectedColorIndex === index && !isErasing settings.selectedColorIndex === index && !isEraserActive
); );
}); });
elements.eraserSizeControl.classList.toggle('active', isErasing); elements.eraserSizeControl.classList.toggle('active', isEraserActive);
game?.setEraseMode(isErasing); game?.setEraseMode(isEraserActive);
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
'--garden-background', '--garden-background',
activeVibe.backgroundColor activeVibe.backgroundColor
); );
game?.onVibeChanged();
}; };
const renderEraserSizeUi = (game: GameLoop | null) => { const renderEraserSizeUi = (game: GameLoop | null) => {
@ -234,11 +227,11 @@ const main = async () => {
let shouldStop = false; let shouldStop = false;
let game: GameLoop | null = null; let game: GameLoop | null = null;
let wasConfigPaneOpen = false; let configPane: ConfigPane | null = null;
elements = queryAppElements(); elements = queryAppElements();
elements.errorContainer.setAttribute('aria-live', 'assertive'); elements.errorContainer.setAttribute('aria-live', 'assertive');
ErrorHandler.addOnErrorListener((error, _metadata) => { ErrorHandler.addOnErrorListener((error) => {
renderRuntimeMessage(elements.errorContainer, error); renderRuntimeMessage(elements.errorContainer, error);
if (error.severity === Severity.ERROR) { if (error.severity === Severity.ERROR) {
document.body.classList.remove('is-loading'); document.body.classList.remove('is-loading');
@ -248,10 +241,10 @@ const main = async () => {
}); });
hasRuntimeErrorListener = true; hasRuntimeErrorListener = true;
const syncRuntimeUi = () => { const syncRuntimeUi = (activeGame = game) => {
renderEraserSizeUi(game); renderEraserSizeUi(game);
renderMirrorSegmentUi(); renderMirrorSegmentUi();
renderPaletteUi(game); renderPaletteUi(activeGame);
}; };
const infoPageHandler = new CollapsiblePanelAnimator( const infoPageHandler = new CollapsiblePanelAnimator(
@ -259,29 +252,33 @@ const main = async () => {
elements.infoElement, elements.infoElement,
elements.aside elements.aside
); );
const configPane = new ConfigPane({ configPane = new ConfigPane({
settingsButton: elements.settingsButton, settingsButton: elements.settingsButton,
onConfigChange: syncRuntimeUi, onConfigChange: () => {
onOpenChange: (isOpen) => { game?.onVibeChanged();
game?.setStatsOverlayPinned(isOpen); syncRuntimeUi();
if (isOpen && !wasConfigPaneOpen) {
trackSettingsOpen();
}
wasConfigPaneOpen = isOpen;
}, },
onOpenChange: () => undefined,
onRuntimeChange: syncRuntimeUi, onRuntimeChange: syncRuntimeUi,
onRuntimeReset: () => { onRuntimeReset: () => {
resetSettings(); resetSettings();
game?.onVibeChanged();
syncRuntimeUi(); syncRuntimeUi();
}, },
onRestart: () => game?.destroy(), onRestart: () => game?.destroy(),
onVibeChange: (vibeId) => { onVibeChange: (vibeId) => {
const vibe = applyVibeSettings(vibeId); const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
if (!vibe) {
return;
}
const activePreset = applyVibeSettings(vibe);
trackVibeChange({ trackVibeChange({
vibeId: vibe.id, vibeId: activePreset.id,
vibeName: vibe.name, vibeName: activePreset.name,
source: 'settings', source: 'settings',
}); });
game?.onVibeChanged();
syncRuntimeUi(); syncRuntimeUi();
game?.playVibeChangeAudio(false); game?.playVibeChangeAudio(false);
}, },
@ -292,7 +289,7 @@ const main = async () => {
elements.aside, elements.aside,
() => () =>
FullScreenHandler.isInFullScreenMode() && FullScreenHandler.isInFullScreenMode() &&
!configPane.isOpen && !configPane?.isOpen &&
!infoPageHandler.isOpen !infoPageHandler.isOpen
); );
new FullScreenHandler( new FullScreenHandler(
@ -333,47 +330,41 @@ const main = async () => {
} }
}); });
elements.previousVibe.addEventListener('click', () => { const selectRelativeVibe = (offset: number, source: string) => {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const vibe = const vibe =
VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length]; VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
const activePreset = applyVibeSettings(vibe.id); const activePreset = applyVibeSettings(vibe);
trackVibeChange({ trackVibeChange({
vibeId: activePreset.id, vibeId: activePreset.id,
vibeName: activePreset.name, vibeName: activePreset.name,
source: 'previous-button', source,
}); });
configPane.refresh(); game?.onVibeChanged();
syncRuntimeUi(); syncRuntimeUi();
configPane?.refresh();
game?.playVibeChangeAudio(true); game?.playVibeChangeAudio(true);
}); };
elements.nextVibe.addEventListener('click', () => { elements.previousVibe.addEventListener('click', () =>
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id); selectRelativeVibe(-1, 'previous-button')
const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length]; );
const activePreset = applyVibeSettings(vibe.id);
trackVibeChange({ elements.nextVibe.addEventListener('click', () =>
vibeId: activePreset.id, selectRelativeVibe(1, 'next-button')
vibeName: activePreset.name, );
source: 'next-button',
});
configPane.refresh();
syncRuntimeUi();
game?.playVibeChangeAudio(true);
});
elements.swatches.forEach((swatch, index) => { elements.swatches.forEach((swatch, index) => {
swatch.addEventListener('click', () => { swatch.addEventListener('click', () => {
settings.selectedColorIndex = index; settings.selectedColorIndex = index;
elements.eraserSizeControl.dataset.active = '0'; isEraserActive = false;
game?.setEraseMode(false);
renderPaletteUi(game); renderPaletteUi(game);
configPane.refresh(); configPane?.refresh();
}); });
}); });
const activateEraser = () => { const activateEraser = () => {
elements.eraserSizeControl.dataset.active = '1'; isEraserActive = true;
renderPaletteUi(game); renderPaletteUi(game);
}; };
@ -383,20 +374,20 @@ const main = async () => {
elements.eraserSizeSlider.addEventListener('input', () => { elements.eraserSizeSlider.addEventListener('input', () => {
settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value)); settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
elements.eraserSizeControl.dataset.active = '1'; isEraserActive = true;
renderEraserSizeUi(game); renderEraserSizeUi(game);
renderPaletteUi(game); renderPaletteUi(game);
configPane.refresh(); configPane?.refresh();
}); });
elements.mirrorSegmentSlider.addEventListener('input', () => { elements.mirrorSegmentSlider.addEventListener('input', () => {
settings.mirrorSegmentCount = clampMirrorSegmentCount( settings.mirrorSegmentCount = clampMirrorSegmentCount(
Number(elements.mirrorSegmentSlider.value) Number(elements.mirrorSegmentSlider.value)
); );
elements.eraserSizeControl.dataset.active = '0'; isEraserActive = false;
renderMirrorSegmentUi(); renderMirrorSegmentUi();
renderPaletteUi(game); renderPaletteUi(game);
configPane.refresh(); configPane?.refresh();
}); });
elements.export4k.addEventListener('click', async () => { elements.export4k.addEventListener('click', async () => {
@ -450,7 +441,6 @@ const main = async () => {
eraserPreview: elements.eraserPreview, eraserPreview: elements.eraserPreview,
exportStatus: elements.exportStatus, exportStatus: elements.exportStatus,
}); });
game.setStatsOverlayPinned(configPane.isOpen);
renderPaletteUi(game); renderPaletteUi(game);
renderEraserSizeUi(game); renderEraserSizeUi(game);
renderMirrorSegmentUi(); renderMirrorSegmentUi();

View file

@ -1,35 +1,13 @@
export class CollapsiblePanelAnimator { export class CollapsiblePanelAnimator {
private static nextPanelId = 0;
private _isOpen = false; private _isOpen = false;
private focusBeforeOpen: HTMLElement | null = null; private focusBeforeOpen: HTMLElement | null = null;
public onOpen?: () => void;
public onOpen: () => unknown = () => {};
public onClose: () => unknown = () => {};
public constructor( public constructor(
private readonly toggleButton: HTMLButtonElement, private readonly toggleButton: HTMLButtonElement,
private readonly collapsibleContent: HTMLElement, private readonly collapsibleContent: HTMLElement,
ignoreForCloseOnClick: HTMLElement ignoreForCloseOnClick: HTMLElement
) { ) {
const panelId =
collapsibleContent.id ||
`collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`;
collapsibleContent.id = panelId;
toggleButton.setAttribute('aria-controls', panelId);
if (!collapsibleContent.hasAttribute('role')) {
collapsibleContent.setAttribute('role', 'region');
}
if (!collapsibleContent.hasAttribute('aria-label')) {
const label =
toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim();
collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`);
}
if (!collapsibleContent.hasAttribute('tabindex')) {
collapsibleContent.tabIndex = -1;
}
toggleButton.addEventListener('click', this.toggle.bind(this)); toggleButton.addEventListener('click', this.toggle.bind(this));
window.addEventListener( window.addEventListener(
'click', 'click',
@ -52,8 +30,8 @@ export class CollapsiblePanelAnimator {
this.focusBeforeOpen = this.focusBeforeOpen =
document.activeElement instanceof HTMLElement ? document.activeElement : null; document.activeElement instanceof HTMLElement ? document.activeElement : null;
this._isOpen = true; this._isOpen = true;
this.onOpen?.();
this.syncAccessibility(); this.syncAccessibility();
this.onOpen();
this.focusPanel(); this.focusPanel();
} }
@ -65,7 +43,6 @@ export class CollapsiblePanelAnimator {
const focusWasInside = this.collapsibleContent.contains(document.activeElement); const focusWasInside = this.collapsibleContent.contains(document.activeElement);
this._isOpen = false; this._isOpen = false;
this.syncAccessibility(); this.syncAccessibility();
this.onClose();
if (focusWasInside) { if (focusWasInside) {
(this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true }); (this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });

View file

@ -1,4 +1,5 @@
import { Pane, type BindingParams, type FolderApi } from 'tweakpane'; import type { BindingParams, FolderApi } from '@tweakpane/core';
import { Pane } from 'tweakpane';
import { import {
appConfig, appConfig,
@ -151,6 +152,10 @@ export class ConfigPane {
this.syncOpenState(); this.syncOpenState();
} }
public close(): void {
this.setHidden(true);
}
private readonly toggle = () => { private readonly toggle = () => {
this.pane.hidden = !this.pane.hidden; this.pane.hidden = !this.pane.hidden;
this.syncOpenState(); this.syncOpenState();
@ -174,11 +179,7 @@ export class ConfigPane {
this.refresh(); this.refresh();
}); });
container container.addButton({ title: 'Reset runtime settings' }).on('click', () => {
.addButton({
title: 'Reset runtime settings',
})
.on('click', () => {
this.options.onRuntimeReset(); this.options.onRuntimeReset();
this.refresh(); this.refresh();
}); });
@ -430,8 +431,4 @@ export class ConfigPane {
this.syncButton(); this.syncButton();
this.options.onOpenChange?.(this.isOpen); this.options.onOpenChange?.(this.isOpen);
} }
public close(): void {
this.setHidden(true);
}
} }

View file

@ -5,24 +5,13 @@ export class FullScreenHandler {
target: HTMLElement target: HTMLElement
) { ) {
if (!document.fullscreenEnabled) { if (!document.fullscreenEnabled) {
minimizeButton.style.display = 'none'; minimizeButton.hidden = true;
maximizeButton.style.display = 'none'; maximizeButton.hidden = true;
return; return;
} }
this.updateButtons(); this.updateButtons();
addEventListener('keydown', (e) => {
// on full screen request, only apply it to the target
if (e.key === 'F11') {
e.preventDefault();
if (FullScreenHandler.isInFullScreenMode()) {
document.exitFullscreen();
} else {
target.requestFullscreen();
}
}
});
addEventListener('fullscreenchange', this.updateButtons.bind(this)); addEventListener('fullscreenchange', this.updateButtons.bind(this));
maximizeButton.addEventListener('click', () => target.requestFullscreen()); maximizeButton.addEventListener('click', () => target.requestFullscreen());
minimizeButton.addEventListener('click', () => document.exitFullscreen()); minimizeButton.addEventListener('click', () => document.exitFullscreen());
@ -34,9 +23,7 @@ export class FullScreenHandler {
private updateButtons(): void { private updateButtons(): void {
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode(); const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none'; this.minimizeButton.hidden = !isInFullScreenMode;
this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block'; this.maximizeButton.hidden = isInFullScreenMode;
this.minimizeButton.classList.toggle('active', isInFullScreenMode);
this.maximizeButton.classList.toggle('active', isInFullScreenMode);
} }
} }

View file

@ -76,11 +76,6 @@ export class MenuHider {
}; };
private readonly onVisibilityContextChange = () => { private readonly onVisibilityContextChange = () => {
if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
this.reveal();
return;
}
this.scheduleHide(); this.scheduleHide();
}; };

View file

@ -7,8 +7,6 @@ struct Settings {
struct Counters { struct Counters {
aliveAgentCount: atomic<u32>, aliveAgentCount: atomic<u32>,
padding0: atomic<u32>,
padding1: atomic<u32>,
}; };
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@ -22,10 +20,9 @@ var<workgroup> workgroupCopyCount: u32;
@compute @workgroup_size(64) @compute @workgroup_size(64)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>, @builtin(local_invocation_id) local_id: vec3<u32>
@builtin(num_workgroups) workgroup_count: vec3<u32>
) { ) {
let id = get_id(global_id, workgroup_count); let id = get_id(global_id);
if local_id.x == 0u { if local_id.x == 0u {
atomicStore(&workgroupAliveCount, 0u); atomicStore(&workgroupAliveCount, 0u);
@ -65,10 +62,9 @@ fn main(
@compute @workgroup_size(64) @compute @workgroup_size(64)
fn copyCompactedAgents( fn copyCompactedAgents(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>, @builtin(local_invocation_id) local_id: vec3<u32>
@builtin(num_workgroups) workgroup_count: vec3<u32>
) { ) {
let id = get_id(global_id, workgroup_count); let id = get_id(global_id);
if local_id.x == 0u { if local_id.x == 0u {
workgroupCopyCount = atomicLoad(&counters.aliveAgentCount); workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);

View file

@ -1,35 +0,0 @@
struct Settings {
agentCount: u32 // might be smaller than the length of the agents array
};
@group(1) @binding(0) var<uniform> settings: Settings;
struct Counters {
redAgentsAlive: atomic<u32>,
greenAgentsAlive: atomic<u32>,
};
@group(1) @binding(2) var<storage, read_write> counters: Counters;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
if id >= settings.agentCount {
return;
}
if agents[id].colorIndex < 0.0 {
return;
}
if agents[id].colorIndex < 0.5 {
atomicAdd(&counters.redAgentsAlive, 1);
} else {
atomicAdd(&counters.greenAgentsAlive, 1);
}
}

View file

@ -1,37 +0,0 @@
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
if id >= arrayLength(&agents) {
return;
}
let clusterId = f32(id % 1000);
let random = textureSampleLevel(
noise,
noiseSampler,
vec2(f32(id % 1999) / 2000, f32(id) / 1999 / 2000),
0
);
let randomPosition = textureSampleLevel(
noise,
noiseSampler,
vec2(clusterId / 2000, clusterId / 2000),
0
);
agents[id] = Agent(
randomPosition.xz * state.size,
random.r * 3.14 * 2,
0,
vec2<f32>(-1.0, -1.0),
0.0,
0.0,
);
}

View file

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent'; import { AGENT_SIZE_IN_BYTES } from './agent';
import { AgentGenerationPipeline } from './agent-generation-pipeline'; import { AgentGenerationPipeline } from './agent-generation-pipeline';
@ -52,7 +51,7 @@ class FakeBuffer {
private readonly mappedRange: ArrayBuffer; private readonly mappedRange: ArrayBuffer;
public readonly destroy = vi.fn(); public readonly destroy = vi.fn();
public readonly mapAsync = vi.fn(async (_mode: number) => undefined); public readonly mapAsync = vi.fn(async () => undefined);
public readonly getMappedRange = vi.fn(() => this.mappedRange); public readonly getMappedRange = vi.fn(() => this.mappedRange);
public readonly unmap = vi.fn(); public readonly unmap = vi.fn();
@ -72,9 +71,7 @@ class FakeComputePass {
public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => { public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => {
this.pipeline = pipeline as unknown as FakePipeline; this.pipeline = pipeline as unknown as FakePipeline;
}); });
public readonly setBindGroup = vi.fn( public readonly setBindGroup = vi.fn(() => undefined);
(_index: number, _bindGroup: GPUBindGroup) => undefined
);
public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => { public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => {
this.device.dispatchCalls.push({ this.device.dispatchCalls.push({
entryPoint: this.pipeline?.entryPoint ?? 'unset', entryPoint: this.pipeline?.entryPoint ?? 'unset',
@ -111,12 +108,8 @@ class FakeCommandEncoder {
} }
class FakeQueue { class FakeQueue {
public readonly writeBuffer = vi.fn( public readonly writeBuffer = vi.fn(() => undefined);
(_buffer: GPUBuffer, _offset: number, _data: BufferSource) => undefined public readonly submit = vi.fn(() => undefined);
);
public readonly submit = vi.fn(
(_commandBuffers: Iterable<GPUCommandBuffer>) => undefined
);
} }
class FakeShaderModule { class FakeShaderModule {
@ -137,9 +130,7 @@ class FakeDevice {
private bufferIndex = 0; private bufferIndex = 0;
public readonly createBindGroupLayout = vi.fn( public readonly createBindGroupLayout = vi.fn(() => ({}) as GPUBindGroupLayout);
(_descriptor: GPUBindGroupLayoutDescriptor) => ({}) as GPUBindGroupLayout
);
public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => { public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => {
const label = const label =
['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][ ['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][
@ -155,15 +146,10 @@ class FakeDevice {
isMappedReadBuffer ? this.compactedCount : 0 isMappedReadBuffer ? this.compactedCount : 0
) as unknown as GPUBuffer; ) as unknown as GPUBuffer;
}); });
public readonly createBindGroup = vi.fn( public readonly createBindGroup = vi.fn(() => ({}) as GPUBindGroup);
(_descriptor: GPUBindGroupDescriptor) => ({}) as GPUBindGroup public readonly createPipelineLayout = vi.fn(() => ({}) as GPUPipelineLayout);
);
public readonly createPipelineLayout = vi.fn(
(_descriptor: GPUPipelineLayoutDescriptor) => ({}) as GPUPipelineLayout
);
public readonly createShaderModule = vi.fn( public readonly createShaderModule = vi.fn(
(_descriptor: GPUShaderModuleDescriptor) => () => new FakeShaderModule() as unknown as GPUShaderModule
new FakeShaderModule() as unknown as GPUShaderModule
); );
public readonly createComputePipeline = vi.fn( public readonly createComputePipeline = vi.fn(
(descriptor: GPUComputePipelineDescriptor) => { (descriptor: GPUComputePipelineDescriptor) => {
@ -183,18 +169,10 @@ const createPipeline = (compactedCount: number) => {
installGpuConstants(); installGpuConstants();
const device = new FakeDevice(compactedCount); const device = new FakeDevice(compactedCount);
const commonState = {
bindGroupLayout: {} as GPUBindGroupLayout,
execute: vi.fn(),
} as unknown as CommonState;
return { return {
device, device,
pipeline: new AgentGenerationPipeline( pipeline: new AgentGenerationPipeline(device as unknown as GPUDevice, 1024),
device as unknown as GPUDevice,
commonState,
1024
),
}; };
}; };

View file

@ -1,37 +1,29 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts'; import { getWorkgroupCount } from '../../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../../utils/graphics/smart-compile'; import { smartCompile } from '../../../utils/graphics/smart-compile';
import { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent'; import { AGENT_SIZE_IN_BYTES } from './agent';
import compactionShader from './agent-compaction.wgsl?raw'; import compactionShader from './agent-compaction.wgsl?raw';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw';
import { GenerationCounts } from './generation-counts';
export class AgentGenerationPipeline { export class AgentGenerationPipeline {
private static readonly WORKGROUP_SIZE = 64; private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 4; private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 3; private static readonly COUNTER_COUNT = 1;
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly compactionBindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly bindGroup: GPUBindGroup; private readonly bindGroup: GPUBindGroup;
private readonly compactionBindGroup: GPUBindGroup;
private readonly firstGenerationPipeline: GPUComputePipeline;
private readonly countingPipeline: GPUComputePipeline;
private readonly resizePipeline: GPUComputePipeline; private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline;
private readonly compactedAgentsCopyPipeline: GPUComputePipeline; private readonly compactedAgentsCopyPipeline: GPUComputePipeline;
public readonly agentsBuffer: GPUBuffer; public readonly agentsBuffer: GPUBuffer;
private readonly compactedAgentsBuffer: GPUBuffer; private readonly compactedAgentsBuffer: GPUBuffer;
public readonly countersBuffer: GPUBuffer; private readonly countersBuffer: GPUBuffer;
public readonly countersStagingBuffer: GPUBuffer; private readonly countersStagingBuffer: GPUBuffer;
private readonly counterClearValues = new Uint32Array( private readonly counterClearValues = new Uint32Array(
AgentGenerationPipeline.COUNTER_COUNT AgentGenerationPipeline.COUNTER_COUNT
); );
@ -41,36 +33,10 @@ export class AgentGenerationPipeline {
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly maxAgentCountUpperLimit: number private readonly maxAgentCountUpperLimit: number
) { ) {
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
],
});
this.compactionBindGroupLayout = device.createBindGroupLayout({
entries: [ entries: [
{ {
binding: 0, binding: 0,
@ -130,30 +96,6 @@ export class AgentGenerationPipeline {
this.bindGroup = this.device.createBindGroup({ this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout, layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.countersBuffer,
},
},
],
});
this.compactionBindGroup = this.device.createBindGroup({
layout: this.compactionBindGroupLayout,
entries: [ entries: [
{ {
binding: 0, binding: 0,
@ -182,51 +124,21 @@ export class AgentGenerationPipeline {
], ],
}); });
this.firstGenerationPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(
device,
CommonState.shaderCode,
agentSchema,
firstGenerationShader
),
entryPoint: 'main',
},
});
this.countingPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, countingShader),
entryPoint: 'main',
},
});
this.resizePipeline = device.createComputePipeline({ this.resizePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, resizeShader), module: smartCompile(device, agentSchema, resizeShader),
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
const compactionModule = smartCompile( const compactionModule = smartCompile(device, agentSchema, compactionShader);
device,
CommonState.shaderCode,
agentSchema,
compactionShader
);
this.compactionPipeline = device.createComputePipeline({ this.compactionPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: compactionModule, module: compactionModule,
@ -236,7 +148,7 @@ export class AgentGenerationPipeline {
this.compactedAgentsCopyPipeline = device.createComputePipeline({ this.compactedAgentsCopyPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: compactionModule, module: compactionModule,
@ -251,7 +163,8 @@ export class AgentGenerationPipeline {
? this.maxAgentCountUpperLimit ? this.maxAgentCountUpperLimit
: Number.POSITIVE_INFINITY, : Number.POSITIVE_INFINITY,
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1, Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
this.device.limits.maxComputeWorkgroupsPerDimension ** 3 this.device.limits.maxComputeWorkgroupsPerDimension *
AgentGenerationPipeline.WORKGROUP_SIZE
); );
} }
@ -276,82 +189,16 @@ export class AgentGenerationPipeline {
const commandEncoder = this.device.createCommandEncoder(); const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
this.commonState.execute(passEncoder);
passEncoder.setPipeline(this.resizePipeline); passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.bindGroup); passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups( passEncoder.dispatchWorkgroups(
...getWorkgroupCounts( getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
); );
passEncoder.end(); passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
} }
public spawnFirstGeneration(): void {
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
this.commonState.execute(passEncoder);
passEncoder.setPipeline(this.firstGenerationPipeline);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
this.maxAgentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
);
passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]);
}
public async countAgents(agentCount: number): Promise<GenerationCounts> {
this.counterClearValues.fill(0);
this.agentCountUniformValues.fill(0);
this.agentCountUniformValues[0] = agentCount;
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.countingPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
);
passEncoder.end();
commandEncoder.copyBufferToBuffer(
this.countersBuffer,
0,
this.countersStagingBuffer,
0,
AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT
);
this.device.queue.submit([commandEncoder.finish()]);
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
const data = new Uint32Array(this.countersStagingBuffer.getMappedRange().slice(0));
this.countersStagingBuffer.unmap();
return {
evenGenerationCount: data[0],
oddGenerationCount: data[1],
};
}
public async compactAgents(agentCount: number): Promise<number> { public async compactAgents(agentCount: number): Promise<number> {
if (agentCount <= 0) { if (agentCount <= 0) {
return 0; return 0;
@ -366,27 +213,17 @@ export class AgentGenerationPipeline {
const commandEncoder = this.device.createCommandEncoder(); const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline); passEncoder.setPipeline(this.compactionPipeline);
this.commonState.execute(passEncoder); passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setBindGroup(1, this.compactionBindGroup);
passEncoder.dispatchWorkgroups( passEncoder.dispatchWorkgroups(
...getWorkgroupCounts( getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
); );
passEncoder.end(); passEncoder.end();
const copyPassEncoder = commandEncoder.beginComputePass(); const copyPassEncoder = commandEncoder.beginComputePass();
copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline); copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline);
this.commonState.execute(copyPassEncoder); copyPassEncoder.setBindGroup(1, this.bindGroup);
copyPassEncoder.setBindGroup(1, this.compactionBindGroup);
copyPassEncoder.dispatchWorkgroups( copyPassEncoder.dispatchWorkgroups(
...getWorkgroupCounts( getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
); );
copyPassEncoder.end(); copyPassEncoder.end();

View file

@ -8,10 +8,9 @@ struct ResizeSettings {
@compute @workgroup_size(64) @compute @workgroup_size(64)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>
@builtin(num_workgroups) workgroup_count: vec3<u32>
) { ) {
let id = get_id(global_id, workgroup_count); let id = get_id(global_id);
if id >= u32(resizeSettings.agentCount) { if id >= u32(resizeSettings.agentCount) {
return; return;

View file

@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest';
import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent'; import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
import compactionShader from './agent-compaction.wgsl?raw'; import compactionShader from './agent-compaction.wgsl?raw';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw';
@ -60,15 +58,12 @@ describe('Agent TS/WGSL contract', () => {
}); });
it('keeps generation shader workgroup sizes aligned with agent indexing', () => { it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
[firstGenerationShader, countingShader, resizeShader, compactionShader].forEach( [resizeShader, compactionShader].forEach((shader) => {
(shader) => {
expect(shader).toMatch(/@workgroup_size\(64\)/); expect(shader).toMatch(/@workgroup_size\(64\)/);
} });
);
expect(agentSchema).toContain('workgroup_count.x * 64'); expect(agentSchema).toContain('return global_id.x;');
expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64'); expect(compactionShader).toContain('let id = get_id(global_id);');
expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);');
expect(compactionShader).toContain('if id < settings.agentCount'); expect(compactionShader).toContain('if id < settings.agentCount');
}); });

View file

@ -9,6 +9,6 @@ struct Agent {
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>; @group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
fn get_id(global_id: vec3<u32>, workgroup_count: vec3<u32>) -> u32 { fn get_id(global_id: vec3<u32>) -> u32 {
return global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64); return global_id.x;
} }

View file

@ -1,4 +0,0 @@
export interface GenerationCounts {
evenGenerationCount: number;
oddGenerationCount: number;
}

View file

@ -2,7 +2,7 @@ import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts'; import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; import { CommonState } from '../common-state/common-state';
import agentSchema from './agent-generation/agent-schema.wgsl?raw'; import agentSchema from './agent-generation/agent-schema.wgsl?raw';
@ -117,7 +117,7 @@ export class AgentPipeline {
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup); passEncoder.setBindGroup(1, bindGroup);
passEncoder.dispatchWorkgroups( passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE) getWorkgroupCount(this.agentCount, AgentPipeline.WORKGROUP_SIZE)
); );
passEncoder.end(); passEncoder.end();
} }

View file

@ -25,10 +25,9 @@ struct Settings {
@compute @workgroup_size(64) @compute @workgroup_size(64)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>
@builtin(num_workgroups) workgroup_count: vec3<u32>
) { ) {
let id = get_id(global_id, workgroup_count); let id = get_id(global_id);
if id >= u32(settings.agentCount) { if id >= u32(settings.agentCount) {
return; return;

View file

@ -1,7 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { clamp } from '../../utils/clamp';
import { import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
@ -25,7 +24,6 @@ export class BrushPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup; private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly multiTargetPipeline: GPURenderPipeline; private readonly multiTargetPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
@ -39,7 +37,6 @@ export class BrushPipeline {
BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT
); );
private linePoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = []; private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = []; private actualSegments: Array<LineSegment> = [];
@ -59,7 +56,6 @@ export class BrushPipeline {
}); });
const shaderModule = smartCompile(device, CommonState.shaderCode, shader); const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2); this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2);
this.uniforms = this.device.createBuffer({ this.uniforms = this.device.createBuffer({
@ -80,12 +76,6 @@ export class BrushPipeline {
}); });
} }
public addSwipe(position: vec2) {
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2) { public addSwipeSegment(from: vec2, to: vec2) {
this.lineSegments.push({ this.lineSegments.push({
from: vec2.clone(from), from: vec2.clone(from),
@ -94,7 +84,6 @@ export class BrushPipeline {
} }
public clearSwipes() { public clearSwipes() {
this.linePoints.length = 0;
this.lineSegments.length = 0; this.lineSegments.length = 0;
this.actualSegments.length = 0; this.actualSegments.length = 0;
} }
@ -103,8 +92,7 @@ export class BrushPipeline {
brushSize, brushSize,
brushSizeVariation, brushSizeVariation,
selectedColorIndex, selectedColorIndex,
isErasing, }: BrushSettings & { selectedColorIndex: number }) {
}: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) {
const brushRadius = brushSize / 2; const brushRadius = brushSize / 2;
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation); const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
const brushFeather = Math.max(1, brushRadius * BrushPipeline.FEATHER_RADIUS_RATIO); const brushFeather = Math.max(1, brushRadius * BrushPipeline.FEATHER_RADIUS_RATIO);
@ -115,10 +103,10 @@ export class BrushPipeline {
this.uniformValues[1] = brushRadiusVariation; this.uniformValues[1] = brushRadiusVariation;
this.uniformValues[2] = 0; this.uniformValues[2] = 0;
this.uniformValues[3] = 0; this.uniformValues[3] = 0;
this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0; this.uniformValues[4] = selectedColorIndex === 0 ? 1 : 0;
this.uniformValues[5] = !isErasing && selectedColorIndex === 1 ? 1 : 0; this.uniformValues[5] = selectedColorIndex === 1 ? 1 : 0;
this.uniformValues[6] = !isErasing && selectedColorIndex === 2 ? 1 : 0; this.uniformValues[6] = selectedColorIndex === 2 ? 1 : 0;
this.uniformValues[7] = isErasing ? 0 : 1; this.uniformValues[7] = 1;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
@ -264,10 +252,6 @@ export class BrushPipeline {
return offset; return offset;
} }
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView): void {
this.executeWithPipeline(commandEncoder, this.pipeline, [trailMapOut]);
}
public executeMultiTarget( public executeMultiTarget(
commandEncoder: GPUCommandEncoder, commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView, sourceMapOut: GPUTextureView,
@ -392,6 +376,6 @@ export class BrushPipeline {
} }
private get lineCount() { private get lineCount() {
return clamp(this.actualSegments.length, 0, BrushPipeline.MAX_LINE_COUNT); return this.actualSegments.length;
} }
} }

View file

@ -1,7 +1,4 @@
export interface BrushSettings { export interface BrushSettings {
brushSize: number; brushSize: number;
brushCurveResolution: number;
eraserSize: number;
mirrorSegmentCount: number;
brushSizeVariation: number; brushSizeVariation: number;
} }

View file

@ -31,21 +31,6 @@ fn vertex(
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end); return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
} }
@fragment
fn fragment(
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
let strength = brushStrength(screenPosition, start, end);
if(strength < 0.02) {
discard;
}
return brushOutput(strength);
}
@fragment @fragment
fn fragmentMrt( fn fragmentMrt(
@location(0) screenPosition: vec2<f32>, @location(0) screenPosition: vec2<f32>,

View file

@ -23,8 +23,8 @@ export class CommonState {
public static readonly shaderCode = /* wgsl */ ` public static readonly shaderCode = /* wgsl */ `
struct State { struct State {
size: vec2<f32>, size: vec2<f32>,
deltaTime: f32,
time: f32, time: f32,
padding0: f32,
}; };
@group(0) @binding(0) var<uniform> state: State; @group(0) @binding(0) var<uniform> state: State;
@ -95,19 +95,11 @@ export class CommonState {
}); });
} }
public setParameters({ public setParameters({ canvasSize, time }: { canvasSize: vec2; time: number }) {
canvasSize,
deltaTime,
time,
}: {
canvasSize: vec2;
deltaTime: number;
time: number;
}) {
this.uniformValues[0] = canvasSize[0]; this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1]; this.uniformValues[1] = canvasSize[1];
this.uniformValues[2] = deltaTime; this.uniformValues[2] = time;
this.uniformValues[3] = time; this.uniformValues[3] = 0;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,

View file

@ -53,12 +53,13 @@ export class CopyPipeline {
new Float32Array(this.vertexBuffer.getMappedRange()).set(vertexData); new Float32Array(this.vertexBuffer.getMappedRange()).set(vertexData);
this.vertexBuffer.unmap(); this.vertexBuffer.unmap();
const shaderModule = smartCompile(device, shader);
this.pipeline = device.createRenderPipeline({ this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [this.bindGroupLayout], bindGroupLayouts: [this.bindGroupLayout],
}), }),
vertex: { vertex: {
module: smartCompile(device, shader), module: shaderModule,
entryPoint: 'vertex', entryPoint: 'vertex',
buffers: [ buffers: [
{ {
@ -75,7 +76,7 @@ export class CopyPipeline {
], ],
}, },
fragment: { fragment: {
module: smartCompile(device, shader), module: shaderModule,
entryPoint: 'fragment', entryPoint: 'fragment',
targets: [ targets: [
{ {

View file

@ -6,8 +6,8 @@ struct Settings {
}; };
@group(1) @binding(0) var<uniform> settings: Settings; @group(0) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var trailMap: texture_2d<f32>; @group(0) @binding(2) var trailMap: texture_2d<f32>;
@fragment @fragment

View file

@ -34,4 +34,10 @@ describe('diffusion pipeline parameters', () => {
expect(shader).not.toContain('pow('); expect(shader).not.toContain('pow(');
expect(shader).not.toContain('noise'); expect(shader).not.toContain('noise');
}); });
it('keeps shader resource groups aligned with the simplified pipeline layout', () => {
expect(shader).toContain('@group(0) @binding(0) var<uniform> settings');
expect(shader).toContain('@group(0) @binding(2) var trailMap');
expect(shader).not.toContain('@group(1)');
});
}); });

View file

@ -5,7 +5,6 @@ import {
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
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 { CommonState } from '../common-state/common-state';
import shader from './diffuse.wgsl?raw'; import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings'; import { DiffusionSettings } from './diffusion-settings';
@ -51,10 +50,7 @@ export class DiffusionPipeline {
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>(); private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
public constructor( public constructor(private readonly device: GPUDevice) {
private readonly device: GPUDevice,
private readonly commonState: CommonState
) {
this.bindGroupLayout = device.createBindGroupLayout( this.bindGroupLayout = device.createBindGroupLayout(
DiffusionPipeline.bindGroupLayout DiffusionPipeline.bindGroupLayout
); );
@ -64,11 +60,11 @@ export class DiffusionPipeline {
this.pipeline = device.createRenderPipeline({ this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [this.bindGroupLayout],
}), }),
vertex, vertex,
fragment: { fragment: {
module: smartCompile(device, CommonState.shaderCode, shader), module: smartCompile(device, shader),
entryPoint: 'fragment', entryPoint: 'fragment',
targets: [ targets: [
{ {
@ -128,8 +124,7 @@ export class DiffusionPipeline {
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.pipeline);
passEncoder.setVertexBuffer(0, this.vertexBuffer); passEncoder.setVertexBuffer(0, this.vertexBuffer);
this.commonState.execute(passEncoder); passEncoder.setBindGroup(0, bindGroup);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(4, 1); passEncoder.draw(4, 1);
passEncoder.end(); passEncoder.end();
} }

View file

@ -3,5 +3,4 @@ export interface DiffusionSettings {
decayRateTrails: number; decayRateTrails: number;
diffusionRateBrush: number; diffusionRateBrush: number;
decayRateBrush: number; decayRateBrush: number;
brushEffectDuration: number;
} }

View file

@ -1,18 +1,14 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts'; import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw'; import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import { CommonState } from '../common-state/common-state';
import shader from './eraser-agent.wgsl?raw'; import shader from './eraser-agent.wgsl?raw';
export class EraserAgentPipeline { export class EraserAgentPipeline {
private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize; private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 4; private static readonly UNIFORM_COUNT = 4;
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
@ -24,16 +20,15 @@ export class EraserAgentPipeline {
); );
private readonly bindGroupsByMask = new WeakMap<GPUTextureView, GPUBindGroup>(); private readonly bindGroupsByMask = new WeakMap<GPUTextureView, GPUBindGroup>();
private linePoints: Array<vec2> = [];
private pendingSegmentCount = 0; private pendingSegmentCount = 0;
private activeSegmentCount = 0; private activeSegmentCount = 0;
private agentCount = 0; private agentCount = 0;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly agentsBuffer: GPUBuffer private readonly agentsBuffer: GPUBuffer
) { ) {
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
entries: [ entries: [
{ {
@ -67,41 +62,25 @@ export class EraserAgentPipeline {
this.pipeline = device.createComputePipeline({ this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader), module: smartCompile(device, agentSchema, shader),
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
} }
public addSwipe(position: vec2): void { public addSwipeSegment(): void {
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2): void {
void from;
void to;
this.pendingSegmentCount += 1; this.pendingSegmentCount += 1;
} }
public clearSwipes(): void { public clearSwipes(): void {
this.linePoints.length = 0;
this.pendingSegmentCount = 0; this.pendingSegmentCount = 0;
this.activeSegmentCount = 0; this.activeSegmentCount = 0;
} }
public setParameters({ public setParameters({ agentCount }: { agentCount: number }): void {
agentCount,
eraserSize: _eraserSize,
}: {
agentCount: number;
eraserSize: number;
}): void {
void _eraserSize;
this.agentCount = agentCount; this.agentCount = agentCount;
this.activeSegmentCount = this.pendingSegmentCount; this.activeSegmentCount = this.pendingSegmentCount;
this.pendingSegmentCount = 0; this.pendingSegmentCount = 0;
@ -129,14 +108,9 @@ export class EraserAgentPipeline {
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask)); passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
passEncoder.dispatchWorkgroups( passEncoder.dispatchWorkgroups(
...getWorkgroupCounts( getWorkgroupCount(this.agentCount, EraserAgentPipeline.WORKGROUP_SIZE)
this.device,
this.agentCount,
EraserAgentPipeline.WORKGROUP_SIZE
)
); );
passEncoder.end(); passEncoder.end();
} }

View file

@ -10,10 +10,9 @@ struct Settings {
@compute @workgroup_size(64) @compute @workgroup_size(64)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>
@builtin(num_workgroups) workgroup_count: vec3<u32>
) { ) {
let id = get_id(global_id, workgroup_count); let id = get_id(global_id);
if id >= u32(settings.agentCount) { if id >= u32(settings.agentCount) {
return; return;

View file

@ -1,7 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { clamp } from '../../utils/clamp';
import { import {
createCachedFloat32BufferWrite, createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged, writeFloat32BufferIfChanged,
@ -37,7 +36,6 @@ export class EraserTexturePipeline {
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT
); );
private linePoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = []; private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = []; private actualSegments: Array<LineSegment> = [];
@ -88,12 +86,6 @@ export class EraserTexturePipeline {
}); });
} }
public addSwipe(position: vec2): void {
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2): void { public addSwipeSegment(from: vec2, to: vec2): void {
this.lineSegments.push({ this.lineSegments.push({
from: vec2.clone(from), from: vec2.clone(from),
@ -102,7 +94,6 @@ export class EraserTexturePipeline {
} }
public clearSwipes(): void { public clearSwipes(): void {
this.linePoints.length = 0;
this.lineSegments.length = 0; this.lineSegments.length = 0;
this.actualSegments.length = 0; this.actualSegments.length = 0;
} }
@ -110,8 +101,8 @@ export class EraserTexturePipeline {
public setParameters({ eraserSize }: { eraserSize: number }): void { public setParameters({ eraserSize }: { eraserSize: number }): void {
const eraserRadius = eraserSize / 2; const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius; this.uniformValues[0] = eraserRadius * eraserRadius;
this.uniformValues[1] = eraserRadius * eraserRadius; this.uniformValues[1] = 0;
this.uniformValues[2] = 0; this.uniformValues[2] = 0;
this.uniformValues[3] = 0; this.uniformValues[3] = 0;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
@ -356,6 +347,6 @@ export class EraserTexturePipeline {
} }
private get lineCount(): number { private get lineCount(): number {
return clamp(this.actualSegments.length, 0, EraserTexturePipeline.MAX_LINE_COUNT); return this.actualSegments.length;
} }
} }

View file

@ -1,6 +1,6 @@
struct Settings { struct Settings {
eraserRadius: f32,
eraserRadiusSquared: f32, eraserRadiusSquared: f32,
padding0: f32,
padding1: f32, padding1: f32,
padding2: f32, padding2: f32,
}; };

View file

@ -9,11 +9,10 @@ import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw'; import shader from './render.wgsl?raw';
export class RenderPipeline { export class RenderPipeline {
private static readonly UNIFORM_COUNT = 20; private static readonly UNIFORM_COUNT = 16;
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly canvasPipeline: GPURenderPipeline; private readonly pipeline: GPURenderPipeline;
private readonly exportPipeline: GPURenderPipeline;
private readonly sampler: GPUSampler; private readonly sampler: GPUSampler;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
@ -43,8 +42,7 @@ export class RenderPipeline {
}); });
const format = navigator.gpu.getPreferredCanvasFormat(); const format = navigator.gpu.getPreferredCanvasFormat();
this.canvasPipeline = this.createPipeline(format, vertex); this.pipeline = this.createPipeline(format, vertex);
this.exportPipeline = this.createPipeline(format, vertex);
this.uniforms = this.device.createBuffer({ this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -79,14 +77,10 @@ export class RenderPipeline {
public setParameters({ public setParameters({
channelColors, channelColors,
backgroundColor, backgroundColor,
cameraCenter,
cameraZoom,
clarity, clarity,
}: RenderSettings & { }: RenderSettings & {
channelColors: Array<[number, number, number]>; channelColors: Array<[number, number, number]>;
backgroundColor: [number, number, number]; backgroundColor: [number, number, number];
cameraCenter: [number, number];
cameraZoom: number;
}) { }) {
const [a, b, c] = channelColors; const [a, b, c] = channelColors;
this.uniformValues[0] = a[0]; this.uniformValues[0] = a[0];
@ -105,10 +99,6 @@ export class RenderPipeline {
this.uniformValues[13] = backgroundColor[1]; this.uniformValues[13] = backgroundColor[1];
this.uniformValues[14] = backgroundColor[2]; this.uniformValues[14] = backgroundColor[2];
this.uniformValues[15] = clarity; this.uniformValues[15] = clarity;
this.uniformValues[16] = cameraCenter[0];
this.uniformValues[17] = cameraCenter[1];
this.uniformValues[18] = cameraZoom;
this.uniformValues[19] = 0;
writeFloat32BufferIfChanged( writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
@ -136,7 +126,7 @@ export class RenderPipeline {
], ],
}; };
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.canvasPipeline); passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setVertexBuffer(0, this.vertexBuffer); passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.setBindGroup(1, bindGroup); passEncoder.setBindGroup(1, bindGroup);
@ -164,7 +154,7 @@ export class RenderPipeline {
}, },
], ],
}); });
passEncoder.setPipeline(this.exportPipeline); passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setVertexBuffer(0, this.vertexBuffer); passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.setBindGroup(1, bindGroup); passEncoder.setBindGroup(1, bindGroup);

View file

@ -7,9 +7,6 @@ struct Settings {
backgroundColorPadding2: f32, backgroundColorPadding2: f32,
backgroundColor: vec3<f32>, backgroundColor: vec3<f32>,
clarity: f32, clarity: f32,
cameraCenter: vec2<f32>,
cameraZoom: f32,
padding0: f32,
}; };
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@ -19,10 +16,8 @@ struct Settings {
@fragment @fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> { fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let cameraUv = settings.cameraCenter / state.size; let traces = textureSample(trailMap, Sampler, uv);
let viewUv = (uv - vec2(0.5)) / settings.cameraZoom + cameraUv; let sources = textureSample(sourceMap, Sampler, uv);
let traces = textureSample(trailMap, Sampler, viewUv);
let sources = textureSample(sourceMap, Sampler, viewUv);
let traceStrengths = vec3( let traceStrengths = vec3(
clarity(traces.r), clarity(traces.r),

View file

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw'; import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw';
import countingShader from './agents/agent-generation/agent-counting.wgsl?raw';
import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline'; import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline';
import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw'; import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw';
import { AgentPipeline } from './agents/agent-pipeline'; import { AgentPipeline } from './agents/agent-pipeline';
@ -92,7 +91,7 @@ describe('WGSL uniform layout contracts', () => {
pipeline: CommonState, pipeline: CommonState,
source: CommonState.shaderCode, source: CommonState.shaderCode,
structName: 'State', structName: 'State',
fieldNames: ['size', 'deltaTime', 'time'], fieldNames: ['size', 'time', 'padding0'],
}); });
}); });
@ -157,9 +156,6 @@ describe('WGSL uniform layout contracts', () => {
'backgroundColorPadding2', 'backgroundColorPadding2',
'backgroundColor', 'backgroundColor',
'clarity', 'clarity',
'cameraCenter',
'cameraZoom',
'padding0',
], ],
}); });
}); });
@ -175,7 +171,7 @@ describe('WGSL uniform layout contracts', () => {
pipeline: EraserTexturePipeline, pipeline: EraserTexturePipeline,
source: eraserTextureShader, source: eraserTextureShader,
structName: 'Settings', structName: 'Settings',
fieldNames: ['eraserRadius', 'eraserRadiusSquared', 'padding1', 'padding2'], fieldNames: ['eraserRadiusSquared', 'padding0', 'padding1', 'padding2'],
}); });
}); });
@ -190,7 +186,6 @@ describe('WGSL uniform layout contracts', () => {
it('keeps agent-generation uniforms large enough for every generation shader', () => { it('keeps agent-generation uniforms large enough for every generation shader', () => {
const generationUniformCounts = [ const generationUniformCounts = [
countUniformScalars(countingShader, 'Settings'),
countUniformScalars(resizeShader, 'ResizeSettings'), countUniformScalars(resizeShader, 'ResizeSettings'),
countUniformScalars(compactionShader, 'Settings'), countUniformScalars(compactionShader, 'Settings'),
]; ];

View file

@ -1,33 +1,29 @@
import { appConfig, type GardenRuntimeSettings } from './config'; import { appConfig, type GardenRuntimeSettings } from './config';
import { writeBrowserStorage } from './utils/browser-storage'; import { writeBrowserStorage } from './utils/browser-storage';
import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes'; import { getInitialVibe, type VibePreset } from './vibes';
const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({ const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => ({
...appConfig.runtimeSettings.defaults, ...appConfig.defaultSettings,
eraserSize: appConfig.toolbar.eraser.default,
mirrorSegmentCount: appConfig.toolbar.mirror.default,
...vibe.settings, ...vibe.settings,
}); });
export let activeVibe = getInitialVibe(); export let activeVibe = getInitialVibe();
export const settings: { [key: string]: number } & GardenRuntimeSettings = { export const settings: GardenRuntimeSettings = {
...buildInitialValues(activeVibe), ...buildSettings(activeVibe),
}; };
export const resetSettings = () => { export const resetSettings = (): GardenRuntimeSettings => {
Object.assign(settings, buildInitialValues(activeVibe)); Object.assign(settings, buildSettings(activeVibe));
return settings;
}; };
export const applyVibeSettings = (vibeId: string) => { export const applyVibeSettings = (vibe: VibePreset) => {
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
if (!vibe) {
return activeVibe;
}
activeVibe = vibe; activeVibe = vibe;
Object.assign(settings, { Object.assign(settings, {
...buildInitialValues(vibe), ...buildSettings(vibe),
agentCount: settings.agentCount,
brushEffectDuration: settings.brushEffectDuration,
eraserSize: settings.eraserSize, eraserSize: settings.eraserSize,
mirrorSegmentCount: settings.mirrorSegmentCount, mirrorSegmentCount: settings.mirrorSegmentCount,
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1), selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),

View file

@ -47,27 +47,6 @@ html > body {
} }
} }
> .dev-stats-overlay {
position: absolute;
top: max(10px, env(safe-area-inset-top, 0px));
left: max(10px, env(safe-area-inset-left, 0px));
z-index: 6;
padding: 7px 9px;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 6px;
background: rgb(9 12 18 / 72%);
box-shadow: 0 8px 24px rgb(0 0 0 / 22%);
color: rgb(255 255 255 / 90%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
monospace;
font-size: 11px;
line-height: 1.45;
pointer-events: none;
user-select: none;
white-space: pre;
}
> .errors-container { > .errors-container {
position: absolute; position: absolute;
top: 0; top: 0;

View file

@ -17,7 +17,7 @@ html > body > aside.control-dock {
visibility 0s; visibility 0s;
> .toolbar-row, > .toolbar-row,
> .pages { > .info-page {
pointer-events: auto; pointer-events: auto;
} }
@ -32,7 +32,7 @@ html > body > aside.control-dock {
visibility 0s var(--transition-time-long); visibility 0s var(--transition-time-long);
> .toolbar-row, > .toolbar-row,
> .pages { > .info-page {
pointer-events: none; pointer-events: none;
} }
} }

View file

@ -29,9 +29,9 @@ html > body > .canvas-container > .garden-prompt {
inset 0 0 0 1px rgb(255 255 255 / 5%); inset 0 0 0 1px rgb(255 255 255 / 5%);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
color: rgb(255 255 255 / 94%); color: rgb(255 255 255 / 94%);
font: font-size: 20px;
400 20px/1.2 'Open Sans', font-weight: 400;
sans-serif; line-height: 1.2;
text-shadow: 0 1px 12px rgb(0 0 0 / 58%); text-shadow: 0 1px 12px rgb(0 0 0 / 58%);
} }

View file

@ -1,6 +1,4 @@
.loading-indicator { .loading-indicator {
--loading-progress: 0%;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@ -46,9 +44,9 @@
> .loading-status { > .loading-status {
color: rgb(255 255 255 / 88%); color: rgb(255 255 255 / 88%);
font: font-size: 16px;
400 16px/1.25 'Open Sans', font-weight: 400;
sans-serif; line-height: 1.25;
text-align: center; text-align: center;
text-shadow: 0 1px 12px rgb(0 0 0 / 60%); text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
letter-spacing: 0.01em; letter-spacing: 0.01em;
@ -56,6 +54,8 @@
} }
> .loading-progress { > .loading-progress {
--loading-progress: 0%;
position: relative; position: relative;
width: 100%; width: 100%;
height: 3px; height: 3px;
@ -64,7 +64,8 @@
background: rgb(255 255 255 / 14%); background: rgb(255 255 255 / 14%);
box-shadow: 0 1px 6px rgb(0 0 0 / 28%); box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
> .loading-progress-fill { &::before {
content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;

View file

@ -12,9 +12,5 @@
} }
} }
} }
&.is-loading aside.control-dock {
transform: translateY(0);
}
} }
} }

View file

@ -1,18 +1,21 @@
@use 'mixins' as *; @use 'mixins' as *;
html > body > aside.control-dock > .pages { html > body > aside.control-dock > .info-page {
width: min(calc(100vw - 1rem), 560px); width: min(calc(100vw - 1rem), 560px);
max-height: min(58vh, 520px); max-height: min(58vh, 520px);
max-height: min(58dvh, 520px); max-height: min(58dvh, 520px);
margin: 0 auto 10px; margin: 0 auto 10px;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
border: 1px solid rgb(255 255 255 / 54%); border: 1px solid rgb(255 255 255 / 78%);
border-radius: 8px; border-radius: 8px;
background-color: rgb(255 255 255 / 34%); background:
linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
rgb(255 255 255);
color: rgb(24 30 27);
box-shadow: box-shadow:
0 18px 48px rgb(0 0 0 / 28%), 0 20px 54px rgb(0 0 0 / 38%),
0 2px 10px rgb(0 0 0 / 16%); 0 2px 12px rgb(0 0 0 / 22%);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--main-color) transparent; scrollbar-color: var(--main-color) transparent;
@ -38,28 +41,26 @@ html > body > aside.control-dock > .pages {
outline-offset: 3px; outline-offset: 3px;
} }
&.info-page {
background:
linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
rgb(255 255 255);
border-color: rgb(255 255 255 / 78%);
color: rgb(24 30 27);
box-shadow:
0 20px 54px rgb(0 0 0 / 38%),
0 2px 12px rgb(0 0 0 / 22%);
> section { > section {
display: flex;
flex-direction: column;
gap: 0.85rem; gap: 0.85rem;
padding: var(--normal-margin);
h1 { h1 {
margin-bottom: 0; margin-bottom: 0;
color: rgb(16 24 20); color: rgb(16 24 20);
font-size: 2rem;
line-height: 1.1;
} }
p { p {
max-width: 54ch; max-width: 54ch;
margin-bottom: 0; margin-bottom: 0;
color: rgb(42 48 45); color: rgb(42 48 45);
font-size: 1.1rem;
line-height: 1.65;
hyphens: auto;
} }
a { a {
@ -72,7 +73,6 @@ html > body > aside.control-dock > .pages {
} }
} }
} }
}
&.hidden { &.hidden {
max-height: 0; max-height: 0;
@ -85,30 +85,6 @@ html > body > aside.control-dock > .pages {
visibility: hidden; visibility: hidden;
} }
> section {
display: flex;
flex-direction: column;
padding: var(--normal-margin);
h1 {
font-size: 2rem;
line-height: 1.1;
}
p {
margin-bottom: var(--small-margin);
color: var(--normal-text-color);
font:
400 1.1rem/1.65 'Open Sans',
sans-serif;
hyphens: auto;
}
a {
color: var(--accent-color);
}
}
@include on-small-screen { @include on-small-screen {
max-height: min(54vh, 500px); max-height: min(54vh, 500px);
max-height: min(54dvh, 500px); max-height: min(54dvh, 500px);

View file

@ -36,6 +36,9 @@ $toolbar-icons: (
); );
html > body > aside.control-dock > .toolbar-row { html > body > aside.control-dock > .toolbar-row {
--toolbar-background-opacity: 0%;
--toolbar-background-strength: 0;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
@ -46,9 +49,17 @@ html > body > aside.control-dock > .toolbar-row {
gap: clamp(6px, 1.8vw, 14px); gap: clamp(6px, 1.8vw, 14px);
border-radius: 12px; border-radius: 12px;
color: rgb(245 250 244 / 92%); color: rgb(245 250 244 / 92%);
font: background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
400 13px/1 'Open Sans', box-shadow:
sans-serif; inset 0 0 0 1px rgb(255 255 255 / calc(var(--toolbar-background-strength) * 16%)),
inset 0 1px 0 rgb(255 255 255 / calc(var(--toolbar-background-strength) * 7%)),
0 14px 34px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 28%));
backdrop-filter: blur(calc(var(--toolbar-background-strength) * 18px))
brightness(calc(1 - var(--toolbar-background-strength) * 0.38))
saturate(calc(1 - var(--toolbar-background-strength) * 0.18));
font-size: 13px;
font-weight: 400;
line-height: 1;
transition: transition:
backdrop-filter var(--transition-time-long), backdrop-filter var(--transition-time-long),
background-color var(--transition-time-long), background-color var(--transition-time-long),
@ -139,15 +150,6 @@ html > body > aside.control-dock > .toolbar-row {
} }
} }
&.needs-contrast-background {
background: linear-gradient(180deg, rgb(22 28 36 / 72%), rgb(5 8 13 / 82%));
box-shadow:
inset 0 0 0 1px rgb(255 255 255 / 16%),
inset 0 1px 0 rgb(255 255 255 / 7%),
0 14px 34px rgb(0 0 0 / 28%);
backdrop-filter: blur(18px) brightness(0.62) saturate(0.82);
}
> .toolbar-shell > nav.buttons { > .toolbar-shell > nav.buttons {
grid-area: nav; grid-area: nav;
display: flex; display: flex;

View file

@ -14,14 +14,6 @@
} }
} }
h1 {
font-family: 'Open Sans', sans-serif;
}
p {
font-family: 'Open Sans', sans-serif;
}
html { html {
height: 100%; height: 100%;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -29,6 +21,10 @@ html {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
body {
font-family: 'Open Sans', sans-serif;
}
.visually-hidden { .visually-hidden {
position: absolute !important; position: absolute !important;
width: 1px !important; width: 1px !important;

View file

@ -4,9 +4,5 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: src: url('../../assets/fonts/open-sans-v34-latin-regular.woff2') format('woff2');
local(''),
url('../../assets/fonts/open-sans-v34-latin-regular.woff2') format('woff2'),
/* Chrome 26+, Opera 23+, Firefox 39+ */
url('../../assets/fonts/open-sans-v34-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
} }

View file

@ -3,7 +3,6 @@
--transition-time-long: 350ms; --transition-time-long: 350ms;
--accent-color: rgb(6.39851188659668, 70.28645324707031, 102.23043060302734); --accent-color: rgb(6.39851188659668, 70.28645324707031, 102.23043060302734);
--main-color: #aaa; --main-color: #aaa;
--normal-text-color: #31343f;
--normal-margin: 2rem; --normal-margin: 2rem;
--small-margin: 1rem; --small-margin: 1rem;
} }

View file

@ -1,19 +1,14 @@
import { appConfig } from '../config'; import { appConfig } from '../config';
import { clamp } from './clamp'; import { clamp } from './clamp';
import { exponentialDecay } from './exponential-decay';
export class DeltaTimeCalculator { export class DeltaTimeCalculator {
private static FPS_EXPONENTIAL_DECAY_STRENGTH =
appConfig.deltaTime.fpsExponentialDecayStrength;
private previousTime: DOMHighResTimeStamp | null = null; private previousTime: DOMHighResTimeStamp | null = null;
private deltaTimeAccumulator: number | null = null;
constructor( constructor(
private readonly maxDeltaTimeInSeconds: number = private readonly maxDeltaTimeInSeconds: number = appConfig.deltaTime
appConfig.deltaTime.maxDeltaTimeSeconds, .maxDeltaTimeSeconds,
private readonly minDeltaTimeInSeconds: number = private readonly minDeltaTimeInSeconds: number = appConfig.deltaTime
appConfig.deltaTime.minDeltaTimeSeconds .minDeltaTimeSeconds
) { ) {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
} }
@ -27,14 +22,6 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime; const delta = currentTime - this.previousTime;
this.previousTime = currentTime; this.previousTime = currentTime;
const deltaInSeconds = delta / 1000;
this.deltaTimeAccumulator = exponentialDecay({
accumulator: this.deltaTimeAccumulator ?? deltaInSeconds,
nextValue: deltaInSeconds,
biasOfNextValue: DeltaTimeCalculator.FPS_EXPONENTIAL_DECAY_STRENGTH,
});
return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds); return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds);
} }
@ -43,8 +30,4 @@ export class DeltaTimeCalculator {
this.previousTime = null; this.previousTime = null;
} }
} }
public get fps() {
return this.deltaTimeAccumulator ? 1 / this.deltaTimeAccumulator : 0;
}
} }

View file

@ -4,10 +4,9 @@ type ElementConstructor<T extends Element> = abstract new () => T;
export const queryRequiredElement = <T extends Element>( export const queryRequiredElement = <T extends Element>(
selector: string, selector: string,
constructor: ElementConstructor<T>, constructor: ElementConstructor<T>
root: ParentNode = document
): T => { ): T => {
const element = root.querySelector(selector); const element = document.querySelector(selector);
if (!(element instanceof constructor)) { if (!(element instanceof constructor)) {
throw new RuntimeError( throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING, ErrorCode.DOM_ELEMENT_MISSING,
@ -26,10 +25,9 @@ export const queryRequiredElement = <T extends Element>(
export const queryRequiredElements = <T extends Element>( export const queryRequiredElements = <T extends Element>(
selector: string, selector: string,
constructor: ElementConstructor<T>, constructor: ElementConstructor<T>
root: ParentNode = document
): Array<T> => { ): Array<T> => {
const elements = Array.from(root.querySelectorAll(selector)); const elements = Array.from(document.querySelectorAll(selector));
if (elements.length === 0) { if (elements.length === 0) {
throw new RuntimeError( throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING, ErrorCode.DOM_ELEMENT_MISSING,

View file

@ -175,7 +175,6 @@ export const getErrorMessage = (
}; };
export class ErrorHandler { export class ErrorHandler {
private static readonly errors: Array<ErrorHandlerError> = [];
private static metadata: ErrorMetadata = {}; private static metadata: ErrorMetadata = {};
private static onErrorListeners: Array< private static onErrorListeners: Array<
(error: ErrorHandlerError, metadata: ErrorMetadata) => void (error: ErrorHandlerError, metadata: ErrorMetadata) => void
@ -213,7 +212,6 @@ export class ErrorHandler {
? {} ? {}
: { details: serializeMetadataValue(details) as ErrorMetadata }), : { details: serializeMetadataValue(details) as ErrorMetadata }),
}; };
ErrorHandler.errors.push(error);
ErrorHandler.onErrorListeners.forEach((listener) => ErrorHandler.onErrorListeners.forEach((listener) =>
listener(error, ErrorHandler.metadata) listener(error, ErrorHandler.metadata)
); );

View file

@ -1,9 +0,0 @@
export const exponentialDecay = ({
accumulator,
nextValue,
biasOfNextValue,
}: {
accumulator: number;
nextValue: number;
biasOfNextValue: number;
}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue;

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { getWorkgroupCount } from './get-workgroup-count';
describe('getWorkgroupCount', () => {
it('returns at least one workgroup for positive invocation counts', () => {
expect(getWorkgroupCount(1, 64)).toBe(1);
expect(getWorkgroupCount(65, 64)).toBe(2);
});
it('rejects zero and non-finite dispatch inputs', () => {
expect(() => getWorkgroupCount(0, 64)).toThrow(/positive finite/);
expect(() => getWorkgroupCount(-1, 64)).toThrow(/positive finite/);
expect(() => getWorkgroupCount(Number.POSITIVE_INFINITY, 64)).toThrow(
/positive finite/
);
expect(() => getWorkgroupCount(1, 0)).toThrow(/positive finite/);
});
});

View file

@ -0,0 +1,17 @@
export const getWorkgroupCount = (
invocationCount: number,
workgroupSize: number
): number => {
if (
!Number.isFinite(invocationCount) ||
!Number.isFinite(workgroupSize) ||
invocationCount <= 0 ||
workgroupSize <= 0
) {
throw new Error(
'Invocation count and workgroup size must be positive finite numbers'
);
}
return Math.ceil(invocationCount / workgroupSize);
};

View file

@ -1,34 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getWorkgroupCounts } from './get-workgroup-counts';
const makeDevice = (maxComputeWorkgroupsPerDimension: number): GPUDevice =>
({
limits: {
maxComputeWorkgroupsPerDimension,
},
}) as GPUDevice;
describe('getWorkgroupCounts', () => {
it('returns at least one workgroup for positive invocation counts', () => {
expect(getWorkgroupCounts(makeDevice(65_535), 1, 64)).toEqual([1, 1, 1]);
expect(getWorkgroupCounts(makeDevice(65_535), 65, 64)).toEqual([2, 1, 1]);
});
it('rejects zero and non-finite dispatch inputs', () => {
const device = makeDevice(65_535);
expect(() => getWorkgroupCounts(device, 0, 64)).toThrow(/positive finite/);
expect(() => getWorkgroupCounts(device, -1, 64)).toThrow(/positive finite/);
expect(() => getWorkgroupCounts(device, Number.POSITIVE_INFINITY, 64)).toThrow(
/positive finite/
);
expect(() => getWorkgroupCounts(device, 1, 0)).toThrow(/positive finite/);
});
it('rejects invocation counts that exceed device workgroup limits', () => {
expect(() => getWorkgroupCounts(makeDevice(2), 9, 1)).toThrow(
'Cannot have this many invocations'
);
});
});

View file

@ -1,39 +0,0 @@
export const getWorkgroupCounts = (
device: GPUDevice,
invocationCount: number,
workgroupSize: number
): [number, number, number] => {
if (
!Number.isFinite(invocationCount) ||
!Number.isFinite(workgroupSize) ||
invocationCount <= 0 ||
workgroupSize <= 0
) {
throw new Error(
'Invocation count and workgroup size must be positive finite numbers'
);
}
const workgroupCount = Math.ceil(invocationCount / workgroupSize);
const workgroupCountX = Math.min(
device.limits.maxComputeWorkgroupsPerDimension,
workgroupCount
);
const workgroupCountY = Math.min(
device.limits.maxComputeWorkgroupsPerDimension,
Math.ceil(workgroupCount / workgroupCountX)
);
const workgroupCountZ = Math.min(
device.limits.maxComputeWorkgroupsPerDimension,
Math.ceil(workgroupCount / workgroupCountX / workgroupCountY)
);
if (workgroupCountX * workgroupCountY * workgroupCountZ < workgroupCount) {
throw new Error('Cannot have this many invocations');
}
return [workgroupCountX, workgroupCountY, workgroupCountZ];
};

View file

@ -119,13 +119,6 @@ export const initializeGpu = async (): Promise<GPUDevice> => {
); );
} }
if (!gpuDevice) {
throw new RuntimeError(
ErrorCode.WEBGPU_DEVICE_UNAVAILABLE,
'The browser returned an empty WebGPU device.'
);
}
gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) => gpuDevice.addEventListener('uncapturederror', (event: GPUUncapturedErrorEvent) =>
ErrorHandler.addException(event.error, { ErrorHandler.addException(event.error, {
code: ErrorCode.WEBGPU_UNCAPTURED_ERROR, code: ErrorCode.WEBGPU_UNCAPTURED_ERROR,

View file

@ -6,13 +6,12 @@ export class ResizableTexture {
private texture: GPUTexture; private texture: GPUTexture;
private textureView: GPUTextureView; private textureView: GPUTextureView;
private size: vec2; private size: vec2;
private readonly copyPipeline: CopyPipeline;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly copyPipeline: CopyPipeline,
size: vec2 size: vec2
) { ) {
this.copyPipeline = new CopyPipeline(this.device);
this.size = vec2.clone(size); this.size = vec2.clone(size);
this.texture = this.createTexture(size); this.texture = this.createTexture(size);
this.textureView = this.texture.createView(); this.textureView = this.texture.createView();
@ -55,7 +54,6 @@ export class ResizableTexture {
public destroy(): void { public destroy(): void {
this.texture.destroy(); this.texture.destroy();
this.copyPipeline.destroy();
} }
private createTexture(size: vec2): GPUTexture { private createTexture(size: vec2): GPUTexture {

Some files were not shown because too many files have changed in this diff Show more