more cleaning up

This commit is contained in:
Andras Schmelczer 2026-05-16 20:45:42 +01:00
parent 2c7d72a699
commit 560398fefb
110 changed files with 933 additions and 2647 deletions

View file

@ -1,7 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects';
const disableWebGpu = async (page: Page) => {
await page.addInitScript(() => {
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) =>
page
.locator('.color-swatch')
@ -71,6 +21,18 @@ const getGardenBackground = (page: 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 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 expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
expect(browserFailures).toEqual([]);
});
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('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 expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute(
'aria-expanded',
'true'
);
await expect(settingsButton).toHaveAttribute('aria-expanded', 'true');
await expect(settingsButton).toHaveAttribute('aria-label', 'Hide config overlay');
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');
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
@ -146,98 +117,35 @@ test('keeps fallback controls interactive and accessible', async ({ page }) => {
await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/);
});
(
[
{
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()}`);
}
});
test('keeps the fallback shell usable on mobile', async ({ page }) => {
await page.setViewportSize({ height: 844, width: 390 });
await disableWebGpu(page);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
expect(browserFailures).toEqual([]);
});
const canvasBox = await page
.getByRole('img', { name: 'Interactive generative garden canvas' })
.boundingBox();
expect(canvasBox?.width).toBeGreaterThan(0);
expect(canvasBox?.height).toBeGreaterThan(0);
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
[
{ 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);
const aboutButtonReceivesPointer = await page
.getByRole('button', { name: 'About' })
.evaluate((button) => {
const rect = button.getBoundingClientRect();
const target = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2
);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
return button === target || button.contains(target);
});
const canvasBox = await page
.getByRole('img', { name: 'Interactive generative garden canvas' })
.boundingBox();
expect(canvasBox?.width).toBeGreaterThan(0);
expect(canvasBox?.height).toBeGreaterThan(0);
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
const aboutButtonReceivesPointer = await page
.getByRole('button', { name: 'About' })
.evaluate((button) => {
const rect = button.getBoundingClientRect();
const target = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2
);
return button === target || button.contains(target);
});
expect(aboutButtonReceivesPointer).toBe(true);
});
expect(aboutButtonReceivesPointer).toBe(true);
});
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 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:height" content="1920" />
<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="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon-180x180.png" />
<link rel="manifest" href="manifest.webmanifest" />
<title>Fleeting Garden</title>
</head>
@ -41,13 +40,13 @@
</canvas>
<p id="canvas-description" class="visually-hidden">
Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene
to paint coloured paths, then use the toolbar to change colours, erase, adjust the
config overlay, export, restart, or open more information.
to paint coloured paths, then use the toolbar to change colours, erase, export,
adjust the config overlay, restart, or open more information.
</p>
<div class="eraser-preview" aria-hidden="true"></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">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
@ -61,9 +60,7 @@
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div class="loading-progress-fill"></div>
</div>
></div>
</div>
<section class="errors-container">
@ -72,7 +69,15 @@
</main>
<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>
<h1>Fleeting Garden</h1>
<p>
@ -125,22 +130,12 @@
title="Draw colour 3"
></button>
<label class="eraser-size-control" title="Erase and resize">
<input
class="eraser-size-slider"
type="range"
min="24"
max="240"
step="1"
aria-label="Eraser size"
/>
<input class="eraser-size-slider" type="range" aria-label="Eraser size" />
</label>
<label class="mirror-segment-control" title="Mirror off">
<input
class="mirror-segment-slider"
type="range"
min="1"
max="12"
step="1"
aria-label="Mirror segments"
/>
</label>

114
package-lock.json generated
View file

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

View file

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

View file

@ -17,7 +17,7 @@ export default defineConfig({
trace: 'on-first-retry',
},
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,
reuseExistingServer: false,
timeout: 120_000,

View file

@ -3,23 +3,14 @@
"short_name": "Garden",
"description": "A joyful WebGPU drawing garden where coloured paths grow into organic agent trails.",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#10151f",
"theme_color": "#10151f",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
"type": "image/svg+xml"
},
{
"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',
endpoint: 'https://stats.schmelczer.dev/status',
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
outboundLinks: true,
hashBasedRouting: true,
});
isInitialized = true;
} 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;
}
interface GardenAudioColorVoice {
interface GardenAudioStyleVoice {
scaleDegreeOffset: number;
velocityMultiplier: number;
panOffset: number;
@ -20,13 +20,14 @@ export interface GardenAudioRegister {
pan: number;
}
export interface GardenAudioColorPool extends GardenAudioRegister {
export interface GardenAudioStylePool extends GardenAudioRegister {
scaleDegrees: Array<number>;
}
interface GardenAudioGenerativePianoConfig {
colorPools: [GardenAudioColorPool, GardenAudioColorPool, GardenAudioColorPool];
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
styleRotationSeconds: number;
chordBars: number;
supportBarSpacing: number;
supportBarOffset: number;
@ -38,16 +39,14 @@ interface GardenAudioGenerativePianoConfig {
noteScorePreferenceWeight: number;
noteScoreRegisterWeight: number;
noteScoreRepeatPenalty: number;
gestureAccentSpacingSeconds: number;
gestureAccentMinIntervalSeconds: number;
strokeAccentMinIntervalSeconds: number;
strokeAccentThreshold: number;
stingerSpacingSeconds: number;
stingerDurationSeconds: number;
stingerSpacingSeconds: number;
maxBrushPhraseLayers: number;
brushLayerBaseSeconds: number;
brushLayerEnergySeconds: number;
brushLayerMirrorSeconds: number;
brushLayerMinIntensity: number;
brushStreamIdleIntervalBeats: number;
brushStreamActiveIntervalBeats: number;
@ -137,12 +136,10 @@ export interface GardenAudioConfig {
unlockTickSeconds: number;
};
input: {
activeActivityThreshold: number;
distanceWindowForFullActivityPixels: number;
distanceWindowSeconds: number;
fallbackFrameSeconds: number;
manicActivityThreshold: number;
manicModeThreshold: number;
};
muteGain: number;
muteRampSeconds: number;
@ -156,8 +153,7 @@ export interface GardenAudioConfig {
startDelaySeconds: number;
vibeChangeStingerMinIntervalSeconds: number;
generativePiano: GardenAudioGenerativePianoConfig;
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
vibes: Record<string, GardenAudioVibeProfile>;
styleVoices: [GardenAudioStyleVoice, GardenAudioStyleVoice, GardenAudioStyleVoice];
}
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 { GardenAudioStrokeMetrics } from './garden-audio-input';
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
interface GardenAudioGestureFrame {
mode: GardenAudioGestureMode;
activity: number;
maniaAmount: number;
}
@ -15,44 +12,18 @@ interface GestureDistanceSample {
distancePixels: number;
}
const DEFAULT_FRAME: GardenAudioGestureFrame = {
mode: 'calm',
activity: 0,
maniaAmount: 0,
};
export class GardenAudioGestureState {
private readonly samples: Array<GestureDistanceSample> = [];
private gestureClockSeconds = 0;
private peakActivity = 0;
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
public beginGesture(): void {
this.samples.length = 0;
this.gestureClockSeconds = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
this.reset();
}
public endGesture(): GardenAudioGestureFrame {
this.samples.length = 0;
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 endGesture(): void {
this.reset();
}
public recordStroke({
@ -77,28 +48,16 @@ export class GardenAudioGestureState {
const activity = clamp01(
windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels
);
const maniaAmount = smoothstep(this.inputConfig.manicActivityThreshold, 1, activity);
this.peakActivity = Math.max(this.peakActivity, activity);
this.lastFrame = {
...DEFAULT_FRAME,
mode: this.getMode(activity, maniaAmount),
return {
activity,
maniaAmount,
maniaAmount: smoothstep(this.inputConfig.manicActivityThreshold, 1, activity),
};
return this.lastFrame;
}
public getFrame(): GardenAudioGestureFrame {
return this.lastFrame;
}
public reset(): void {
this.samples.length = 0;
this.gestureClockSeconds = 0;
this.peakActivity = 0;
this.lastFrame = DEFAULT_FRAME;
}
private trimSamples(): void {
@ -107,14 +66,6 @@ export class GardenAudioGestureState {
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 => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { ADAPTIVE_AGENT_CAP_MAX } from './config/agent-budget';
import { runtimeSettings } from './config/runtime-settings';
import { defaultSettings } from './config/default-settings';
import { runtimeControls } from './config/runtime-controls';
import type { GardenAppConfig } from './config/types';
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
import { defaultVibeId, vibePresets } from './config/vibe-presets';
export type {
GardenAppConfig,
@ -82,12 +82,10 @@ export const appConfig = {
unlockTickSeconds: 0.035,
},
input: {
activeActivityThreshold: 0.38,
distanceWindowForFullActivityPixels: 140,
distanceWindowSeconds: 0.5,
fallbackFrameSeconds: 1 / 60,
manicActivityThreshold: 0.82,
manicModeThreshold: 0.72,
},
muteGain: 0.0001,
muteRampSeconds: 0.02,
@ -101,7 +99,7 @@ export const appConfig = {
startDelaySeconds: 0.02,
vibeChangeStingerMinIntervalSeconds: 0.45,
generativePiano: {
colorPools: [
stylePools: [
{
midiMin: 48,
midiMax: 67,
@ -144,6 +142,7 @@ export const appConfig = {
pan: 0.2,
},
],
styleRotationSeconds: 8,
chordBars: 4,
supportBarSpacing: 2,
supportBarOffset: 1,
@ -155,7 +154,6 @@ export const appConfig = {
noteScorePreferenceWeight: 1.8,
noteScoreRegisterWeight: 0.28,
noteScoreRepeatPenalty: 3.2,
gestureAccentSpacingSeconds: 0.26,
gestureAccentMinIntervalSeconds: 2.5,
strokeAccentMinIntervalSeconds: 3.2,
strokeAccentThreshold: 0.58,
@ -164,7 +162,6 @@ export const appConfig = {
maxBrushPhraseLayers: 5,
brushLayerBaseSeconds: 5.5,
brushLayerEnergySeconds: 2.5,
brushLayerMirrorSeconds: 3,
brushLayerMinIntensity: 0.08,
brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1,
@ -174,7 +171,7 @@ export const appConfig = {
brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46,
},
colorVoices: [
styleVoices: [
{
scaleDegreeOffset: 0,
velocityMultiplier: 0.92,
@ -191,10 +188,8 @@ export const appConfig = {
panOffset: 0.14,
},
],
vibes: audioVibes,
},
deltaTime: {
fpsExponentialDecayStrength: 0.01,
maxDeltaTimeSeconds: 1 / 30,
minDeltaTimeSeconds: 1 / 240,
},
@ -220,30 +215,29 @@ export const appConfig = {
minDiffusionRate: 0.000001,
},
eraser: {
maxSegmentCount: 384,
maxTextureLineCount: 384,
segmentFloatCount: 4,
workgroupSize: 64,
},
},
runtimeSettings,
defaultSettings,
runtimeSettings: {
controls: runtimeControls,
},
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: 50_000,
adaptiveCapMax: ADAPTIVE_AGENT_CAP_MAX,
adaptiveCapInitial: 1_000_000,
adaptiveCapMax: 2_000_000,
adaptiveCapMin: 500_000,
fpsHeadroom: 0.95,
fpsSmoothingNew: 0.06,
fpsSmoothingRetain: 0.94,
},
brushEffectFramesPerSecond: 60,
globalAgentCap: ADAPTIVE_AGENT_CAP_MAX,
initialAgentCount: 180_000,
intro: {
angleJitterRadians: Math.PI * 0.08,
circleMaxSideRatio: 0.46,
circleMinSideRatio: 0.32,
drawHintClass: 'draw-hint',
drawHintDelayMs: 3000,
durationSeconds: 4,
entryJitterSideRatio: 0.035,
@ -273,7 +267,6 @@ export const appConfig = {
},
introMoveSpeedBaseMultiplier: 1.8,
introMoveSpeedProgressMultiplier: 0.35,
maxMirrorSegmentCount: 12,
stroke: {
angleJitterRadians: Math.PI * 0.7,
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> = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
export const colorInteractionSettings = {
color1ToColor1: 1,
color1ToColor2: 0,
color1ToColor3: 0,
@ -18,6 +12,12 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
color3ToColor3: 1,
};
const agentInteractionOptions: Record<string, number> = {
Follow: 1,
Avoid: -1,
Ignore: 0,
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions',
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,
GardenAudioVibeProfile,
} from '../audio/garden-audio-config';
import type { GameLoopSettings } from '../game-loop/game-loop-settings';
import type { AgentSettings } from '../pipelines/agents/agent-settings';
import type { BrushSettings } from '../pipelines/brush/brush-settings';
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
import type { RenderSettings } from '../pipelines/render/render-settings';
export type GardenRuntimeSettings = GameLoopSettings &
AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
export type AgentColorInteractionSettings = Pick<
AgentSettings,
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
>;
type GardenVibeSettings = Partial<
Pick<
GardenRuntimeSettings,
| 'agentBudgetMax'
| 'brushSize'
| 'color1ToColor1'
| 'color1ToColor2'
| 'color1ToColor3'
| 'color2ToColor1'
| 'color2ToColor2'
| 'color2ToColor3'
| 'color3ToColor1'
| 'color3ToColor2'
| 'color3ToColor3'
| 'clarity'
| 'decayRateTrails'
| 'diffusionRateTrails'
| 'individualTrailWeight'
| 'moveSpeed'
| 'sensorOffsetAngle'
| 'sensorOffsetDistance'
| 'spawnPerPixel'
| 'turnSpeed'
>
>;
export interface VibePreset {
id: string;
name: string;
colors: [string, string, string];
backgroundColor: string;
settings: GardenVibeSettings;
audio: GardenAudioVibeProfile;
}
export interface NumberControlConfig {
folder: string;
integer?: boolean;
@ -72,14 +17,53 @@ export interface NumberControlConfig {
step?: number;
}
export type GardenRuntimeSettings = {
brushCurveResolution: number;
brushEffectDuration: number;
eraserSize: number;
mirrorSegmentCount: number;
selectedColorIndex: number;
spawnPerPixel: number;
} & AgentSettings &
BrushSettings &
DiffusionSettings &
RenderSettings;
type RuntimeSettingControlConfig = {
[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 {
audio: GardenAudioConfig;
deltaTime: {
fpsExponentialDecayStrength: number;
maxDeltaTimeSeconds: number;
minDeltaTimeSeconds: number;
};
@ -105,19 +89,17 @@ export interface GardenAppConfig {
minDiffusionRate: number;
};
eraser: {
maxSegmentCount: number;
maxTextureLineCount: number;
segmentFloatCount: number;
workgroupSize: number;
};
};
defaultSettings: GardenDefaultSettings;
runtimeSettings: {
controls: RuntimeSettingControlConfig;
defaults: GardenRuntimeSettings;
};
simulation: {
budget: {
adaptiveCapDecreaseAgentsPerSecond: number;
adaptiveCapInitial: number;
adaptiveCapMax: number;
adaptiveCapMin: number;
fpsHeadroom: number;
@ -125,13 +107,11 @@ export interface GardenAppConfig {
fpsSmoothingRetain: number;
};
brushEffectFramesPerSecond: number;
globalAgentCap: number;
initialAgentCount: number;
intro: {
angleJitterRadians: number;
circleMaxSideRatio: number;
circleMinSideRatio: number;
drawHintClass: string;
drawHintDelayMs: number;
durationSeconds: number;
entryJitterSideRatio: number;
@ -161,7 +141,6 @@ export interface GardenAppConfig {
};
introMoveSpeedBaseMultiplier: number;
introMoveSpeedProgressMultiplier: number;
maxMirrorSegmentCount: number;
stroke: {
angleJitterRadians: number;
densityMultiplier: number;

View file

@ -1,8 +1,4 @@
import type {
GardenAudioChord,
GardenAudioVibeProfile,
} from '../audio/garden-audio-config';
import { defaultColorInteractionSettings } from './color-interactions';
import type { GardenAudioChord } from '../audio/garden-audio-config';
import type { VibePreset } from './types';
const majorProgression: Array<GardenAudioChord> = [
@ -31,7 +27,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
backgroundColor: '#10151f',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 14,
clarity: 0.62,
decayRateTrails: 965,
@ -42,7 +37,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 38,
spawnPerPixel: 0.22,
turnSpeed: 58,
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 57,
@ -58,7 +52,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
backgroundColor: '#172016',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 16,
clarity: 0.68,
decayRateTrails: 975,
@ -69,7 +62,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 46,
spawnPerPixel: 0.18,
turnSpeed: 44,
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 53,
@ -90,7 +82,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
backgroundColor: '#0f1822',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 13,
clarity: 0.58,
decayRateTrails: 955,
@ -101,7 +92,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 35,
spawnPerPixel: 0.25,
turnSpeed: 62,
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 50,
@ -117,7 +107,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
backgroundColor: '#14121d',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 12,
clarity: 0.64,
decayRateTrails: 968,
@ -128,7 +117,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 42,
spawnPerPixel: 0.2,
turnSpeed: 52,
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 49,
@ -144,7 +132,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
backgroundColor: '#191716',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 15,
clarity: 0.55,
decayRateTrails: 948,
@ -155,7 +142,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 32,
spawnPerPixel: 0.24,
turnSpeed: 70,
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 56,
@ -171,7 +157,6 @@ export const vibePresets: Array<VibePreset> = [
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
backgroundColor: '#101820',
settings: {
agentBudgetMax: 1_000_000,
brushSize: 18,
clarity: 0.7,
decayRateTrails: 982,
@ -182,7 +167,6 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 52,
spawnPerPixel: 0.16,
turnSpeed: 40,
...defaultColorInteractionSettings,
},
audio: {
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 originalSelectedColorIndex = settings.selectedColorIndex;
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', () => {
beforeEach(() => {
settings.agentBudgetMax = 1_000_000;
settings.brushSize = 1;
settings.selectedColorIndex = 0;
settings.spawnPerPixel = 1;
});
afterEach(() => {
settings.agentBudgetMax = originalAgentBudgetMax;
settings.brushSize = originalBrushSize;
settings.selectedColorIndex = originalSelectedColorIndex;
settings.spawnPerPixel = originalSpawnPerPixel;
@ -57,12 +63,16 @@ describe('AgentPopulation adaptive budget', () => {
const population = createPopulation();
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));
expect(settings.agentBudgetMax).toBeGreaterThan(1_000_000);
expect(population.activeAgentCount).toBeGreaterThan(1_000_000);
expect(settings.agentBudgetMax).toBeLessThanOrEqual(
expect(getPopulationAdaptiveCap(population)).toBeGreaterThan(
appConfig.simulation.budget.adaptiveCapInitial
);
expect(population.activeAgentCount).toBeGreaterThan(
appConfig.simulation.budget.adaptiveCapInitial
);
expect(getPopulationAdaptiveCap(population)).toBeLessThanOrEqual(
appConfig.simulation.budget.adaptiveCapMax
);
});
@ -70,25 +80,25 @@ describe('AgentPopulation adaptive budget', () => {
it('does not grow the cap above the adaptive max agent count', () => {
const population = createPopulation();
const maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount - 1;
setPopulationAdaptiveCap(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));
expect(settings.agentBudgetMax).toBe(maxAgentCount);
expect(getPopulationAdaptiveCap(population)).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 maxAgentCount = appConfig.simulation.budget.adaptiveCapMax;
settings.agentBudgetMax = maxAgentCount + 1_000;
setPopulationAdaptiveCap(population, maxAgentCount + 1_000);
setPopulationActiveCount(population, maxAgentCount);
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);
});
@ -96,9 +106,11 @@ describe('AgentPopulation adaptive budget', () => {
const population = createPopulation();
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);
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { FramePerformance } from './frame-performance';
const INITIAL_FPS = 60;
function createScenario() {
const performance = new FramePerformance();
let time = 0;
@ -13,52 +15,31 @@ function createScenario() {
return { performance, advance };
}
describe('FramePerformance refresh target', () => {
it('uses 60 FPS as the fixed adaptive budget target', () => {
const { performance, advance } = createScenario();
describe('FramePerformance', () => {
it('starts at the adaptive budget target', () => {
const { performance } = createScenario();
[123, 126, 130, 121, 60, 30].forEach(advance);
expect(performance.refreshTargetFps).toBe(60);
expect(performance.smoothedFps).toBe(INITIAL_FPS);
});
it('keeps latest and smoothed FPS separate from the fixed target', () => {
it('smooths measured frame rates', () => {
const { performance, advance } = createScenario();
advance(120);
expect(performance.latestFps).toBe(120);
expect(performance.smoothedFps).toBeGreaterThan(60);
expect(performance.refreshTargetFps).toBe(60);
expect(performance.smoothedFps).toBeGreaterThan(INITIAL_FPS);
expect(performance.smoothedFps).toBeLessThan(120);
});
it('reports true FPS even when the simulation delta would clamp', () => {
const { performance, advance } = createScenario();
it('ignores long gaps before smoothing resumes', () => {
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', () => {
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);
expect(performance.smoothedFps).toBeLessThan(INITIAL_FPS);
});
});

View file

@ -1,22 +1,12 @@
import { appConfig } from '../config';
const COMMON_DISPLAY_REFRESH_RATES = [
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 INITIAL_FPS = 60;
const FRAME_GAP_RESET_SECONDS = 1;
export class FramePerformance {
public latestFps = 60;
public smoothedFps = 60;
public displayRefreshFps = 60;
public readonly refreshTargetFps = 60;
public smoothedFps = INITIAL_FPS;
private previousFrameTime: DOMHighResTimeStamp | null = null;
private hasConfirmedDisplayRefreshFps = false;
private pendingDisplayRefreshFps = 0;
private pendingDisplayRefreshFrameCount = 0;
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
@ -31,67 +21,8 @@ export class FramePerformance {
}
const fps = 1 / deltaSeconds;
this.latestFps = fps;
this.updateDisplayRefreshEstimate(fps);
this.smoothedFps =
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
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 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) {
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]*\);/
);
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_DST');
expect(resizableTextureSource).toContain('this.copyPipeline.execute(');
expect(simulationTexturesSource).toContain('private readonly copyPipeline');
});
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 influenceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public influenceMapB: ResizableTexture;');
expect(simulationTexturesSource).toContain('public swapBrushEffectMaps(): void');
expect(simulationTexturesSource).toContain(
'[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 { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context';
import { GLOBAL_AGENT_CAP } from './agent-population';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
@ -24,8 +23,6 @@ interface FrameParameters extends RenderInputs {
introProgress: number;
selectedColorIndex: number;
isErasing: boolean;
cameraCenter: [number, number];
cameraZoom: number;
eraserPixelSize: number;
}
@ -56,13 +53,11 @@ export class GameLoopResources {
this.commonState.setParameters({
canvasSize,
time: 0,
deltaTime: 0,
});
this.agentGenerationPipeline = new AgentGenerationPipeline(
this.device,
this.commonState,
GLOBAL_AGENT_CAP
appConfig.simulation.budget.adaptiveCapMax
);
this.agentPipeline = new AgentPipeline(
@ -73,15 +68,11 @@ export class GameLoopResources {
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
this.eraserAgentPipeline = new EraserAgentPipeline(
this.device,
this.commonState,
this.agentGenerationPipeline.agentsBuffer
);
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device, this.commonState);
this.brushEffectDiffusionPipeline = new DiffusionPipeline(
this.device,
this.commonState
);
this.diffusionPipeline = new DiffusionPipeline(this.device);
this.brushEffectDiffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
@ -106,17 +97,13 @@ export class GameLoopResources {
activeAgentCount,
introProgress,
selectedColorIndex,
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
}: FrameParameters): void {
this.commonState.setParameters({
canvasSize,
time,
deltaTime,
});
this.agentPipeline.setParameters({
...settings,
@ -133,19 +120,15 @@ export class GameLoopResources {
this.brushPipeline.setParameters({
...settings,
selectedColorIndex,
isErasing,
});
this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({
...settings,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
});
this.eraserAgentPipeline.setParameters({
agentCount: activeAgentCount,
eraserSize: eraserPixelSize,
});
this.eraserTexturePipeline.setParameters({
eraserSize: eraserPixelSize,
@ -160,10 +143,6 @@ export class GameLoopResources {
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
}
public clearSwipes(): void {
this.frameRenderer.clearSwipes();
}
public destroy(): void {
this.agentGenerationPipeline.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 { activeVibe, settings } from '../settings';
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
import { sleep } from '../utils/sleep';
import { AgentPopulation } from './agent-population';
import { EraserPreview } from './eraser-preview';
import { Export4KRenderer } from './export-4k-renderer';
@ -18,9 +17,7 @@ import { RenderInputCache } from './render-input-cache';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
export default class GameLoop {
private static readonly MAX_MIRROR_SEGMENT_COUNT =
appConfig.simulation.maxMirrorSegmentCount;
private static readonly DEV_STATS_INTERVAL_MS = 250;
private static readonly MAX_MIRROR_SEGMENT_COUNT = appConfig.toolbar.mirror.max;
private readonly resources: GameLoopResources;
private readonly audio = new GardenAudio(gardenAudioConfig);
@ -32,13 +29,10 @@ export default class GameLoop {
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly devStatsElement: HTMLDivElement | null;
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this);
private readonly keydownListener: (event: KeyboardEvent) => void;
private lastDevStatsUpdateAt = 0;
private isStatsOverlayPinned = false;
private hasFinished = false;
private readonly finished = Promise.withResolvers<void>();
@ -49,8 +43,6 @@ export default class GameLoop {
ui: GardenUi
) {
this.resize();
this.devStatsElement = this.createDevStatsElement();
this.syncDevStatsVisibility();
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
@ -102,25 +94,14 @@ export default class GameLoop {
}
public onVibeChanged(): void {
this.agentPopulation.onVibeChanged();
this.renderInputs.invalidate();
this.agentPopulation.onVibeChanged();
}
public setAudioMuted(isMuted: boolean): void {
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 {
this.audio.start(activeVibe, { userGesture });
}
@ -134,10 +115,6 @@ export default class GameLoop {
return this.finished.promise;
}
public get maxAgentCount(): number {
return this.agentPopulation.maxAgentCount;
}
public async export4K(): Promise<void> {
return this.export4KRenderer.export();
}
@ -150,7 +127,6 @@ export default class GameLoop {
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.toolbarContrastMonitor.destroy();
this.devStatsElement?.remove();
this.introPrompt.destroy();
this.resources.destroy();
await this.audio.destroy();
@ -164,29 +140,19 @@ export default class GameLoop {
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(time);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
this.framePerformance.refreshTargetFps
);
this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
this.introPrompt.update();
this.resize();
this.resizeSimulationToCanvas();
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1;
const cameraCenter: [number, number] = [
this.canvas.width / 2,
this.canvas.height / 2,
];
const eraserPixelSize = settings.eraserSize * this.devicePixelRatio;
const isErasing = this.pointerInput.isEraseMode;
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
this.renderInputs.updateAccentColor(accentColor);
this.audio.update({
vibe: activeVibe,
selectedColorIndex: settings.selectedColorIndex,
isErasing,
});
@ -200,8 +166,6 @@ export default class GameLoop {
isErasing,
channelColors,
backgroundColor,
cameraCenter,
cameraZoom,
eraserPixelSize,
});
@ -213,60 +177,9 @@ export default class GameLoop {
this.pointerInput.clearSwipesIfIdle();
await this.agentPopulation.compactAfterErase(this.pointerInput.isSwipeActive);
this.updateDevStats(time);
if (settings.simulatedDelayMs > 0) {
await sleep(settings.simulatedDelayMs);
}
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 {
const width = Math.max(
1,
@ -310,8 +223,4 @@ export default class GameLoop {
: 1;
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';
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
const DRAW_HINT_CLASS = 'draw-hint';
export class IntroPrompt {
private introComplete = false;
private introStartedAt = performance.now();
private introCompletedAt: number | null = null;
private hasStartedDrawing = false;
private isDrawHintVisible = false;
public constructor(private readonly prompt: HTMLElement) {}
@ -55,12 +55,11 @@ export class IntroPrompt {
}
private showDrawHint(): void {
if (this.isDrawHintVisible) {
if (this.prompt.classList.contains(DRAW_HINT_CLASS)) {
return;
}
this.isDrawHintVisible = true;
this.prompt.classList.add(appConfig.simulation.intro.drawHintClass);
this.prompt.classList.add(DRAW_HINT_CLASS);
this.prompt.innerHTML = `
<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" />
@ -68,13 +67,12 @@ export class IntroPrompt {
<circle class="draw-hint-start" cx="12" cy="50" r="4" />
<circle class="draw-hint-end" cx="116" cy="42" r="7" />
</svg>
<span class="draw-hint-text">Draw on the screen</span>
<span>Draw on the screen</span>
`;
}
private hideDrawHint(): void {
this.isDrawHintVisible = false;
this.prompt.classList.remove(appConfig.simulation.intro.drawHintClass);
this.prompt.classList.remove(DRAW_HINT_CLASS);
this.prompt.replaceChildren();
}
}

View file

@ -97,7 +97,6 @@ const createPointerInput = async () => {
endGesture: vi.fn(),
start: vi.fn(),
stroke: vi.fn(),
touchDown: vi.fn(),
};
const brushPipeline = makeSwipePipeline();
const eraserAgentPipeline = makeSwipePipeline();
@ -182,11 +181,6 @@ describe('GardenPointerInput drawing startup', () => {
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(audio.touchDown).toHaveBeenCalledWith(
expect.objectContaining({
colorIndex: 0,
})
);
expect(audio.stroke).not.toHaveBeenCalled();
expect(brushPipeline.addSwipeSegment).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.beginGesture();
this.options.audio.touchDown({
colorIndex: settings.selectedColorIndex,
});
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
this.canvas.setPointerCapture(event.pointerId);
@ -183,14 +180,11 @@ export class GardenPointerInput {
if (this.isErasing) {
segments.forEach((segment) => {
this.options.eraserAgentPipeline.addSwipeSegment(segment.from, segment.to);
this.options.eraserAgentPipeline.addSwipeSegment();
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
});
} else {
this.addSmoothedBrushSample(position);
}
if (!this.isErasing) {
segments.forEach((segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to);
});
@ -200,7 +194,6 @@ export class GardenPointerInput {
vibe: activeVibe,
from: previousPosition,
to: position,
colorIndex: settings.selectedColorIndex,
isErasing: this.isErasing,
elapsedSeconds,
});
@ -375,7 +368,7 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
const getBrushCurveResolution = (): number => {
const resolution = Number.isFinite(settings.brushCurveResolution)
? settings.brushCurveResolution
: appConfig.runtimeSettings.defaults.brushCurveResolution;
: appConfig.defaultSettings.brushCurveResolution;
return Math.max(1, Math.floor(resolution));
};

View file

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

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { ResizableTexture } from '../utils/graphics/resizable-texture';
export class SimulationTextures {
@ -10,18 +11,20 @@ export class SimulationTextures {
public influenceMapA: ResizableTexture;
public influenceMapB: ResizableTexture;
public eraserMask: ResizableTexture;
private readonly copyPipeline: CopyPipeline;
public constructor(
private readonly device: GPUDevice,
canvasSize: vec2
) {
this.trailMapA = new ResizableTexture(this.device, canvasSize);
this.trailMapB = new ResizableTexture(this.device, canvasSize);
this.sourceMapA = new ResizableTexture(this.device, canvasSize);
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
this.influenceMapB = new ResizableTexture(this.device, canvasSize);
this.eraserMask = new ResizableTexture(this.device, canvasSize);
this.copyPipeline = new CopyPipeline(this.device);
this.trailMapA = this.createTexture(canvasSize);
this.trailMapB = this.createTexture(canvasSize);
this.sourceMapA = this.createTexture(canvasSize);
this.sourceMapB = this.createTexture(canvasSize);
this.influenceMapA = this.createTexture(canvasSize);
this.influenceMapB = this.createTexture(canvasSize);
this.eraserMask = this.createTexture(canvasSize);
}
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];
}
public swapInfluenceMaps(): void {
[this.influenceMapA, this.influenceMapB] = [this.influenceMapB, this.influenceMapA];
}
@ -85,5 +85,10 @@ export class SimulationTextures {
this.influenceMapA.destroy();
this.influenceMapB.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 {
getToolbarContrastMetrics,
shouldDimToolbarBackground,
} from './toolbar-contrast-monitor';
import { getToolbarContrastMetrics } from './toolbar-contrast-monitor';
const makePixels = (
samples: ReadonlyArray<readonly [number, number, number]>
@ -27,12 +24,28 @@ describe('toolbar contrast monitoring', () => {
false
);
expect(metrics.dimmingStrength).toBe(0);
expect(metrics.backgroundOpacity).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 brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const);
const metrics = getToolbarContrastMetrics(
@ -42,21 +55,7 @@ describe('toolbar contrast monitoring', () => {
);
expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08);
expect(shouldDimToolbarBackground(metrics, false)).toBe(true);
});
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);
expect(metrics.backgroundOpacity).toBeGreaterThan(0);
});
it('reads bgra canvas samples in the correct channel order', () => {

View file

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

View file

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

View file

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

View file

@ -1,35 +1,13 @@
export class CollapsiblePanelAnimator {
private static nextPanelId = 0;
private _isOpen = false;
private focusBeforeOpen: HTMLElement | null = null;
public onOpen: () => unknown = () => {};
public onClose: () => unknown = () => {};
public onOpen?: () => void;
public constructor(
private readonly toggleButton: HTMLButtonElement,
private readonly collapsibleContent: HTMLElement,
ignoreForCloseOnClick: HTMLElement
) {
const panelId =
collapsibleContent.id ||
`collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`;
collapsibleContent.id = panelId;
toggleButton.setAttribute('aria-controls', panelId);
if (!collapsibleContent.hasAttribute('role')) {
collapsibleContent.setAttribute('role', 'region');
}
if (!collapsibleContent.hasAttribute('aria-label')) {
const label =
toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim();
collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`);
}
if (!collapsibleContent.hasAttribute('tabindex')) {
collapsibleContent.tabIndex = -1;
}
toggleButton.addEventListener('click', this.toggle.bind(this));
window.addEventListener(
'click',
@ -52,8 +30,8 @@ export class CollapsiblePanelAnimator {
this.focusBeforeOpen =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
this._isOpen = true;
this.onOpen?.();
this.syncAccessibility();
this.onOpen();
this.focusPanel();
}
@ -65,7 +43,6 @@ export class CollapsiblePanelAnimator {
const focusWasInside = this.collapsibleContent.contains(document.activeElement);
this._isOpen = false;
this.syncAccessibility();
this.onClose();
if (focusWasInside) {
(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 {
appConfig,
@ -151,6 +152,10 @@ export class ConfigPane {
this.syncOpenState();
}
public close(): void {
this.setHidden(true);
}
private readonly toggle = () => {
this.pane.hidden = !this.pane.hidden;
this.syncOpenState();
@ -174,14 +179,10 @@ export class ConfigPane {
this.refresh();
});
container
.addButton({
title: 'Reset runtime settings',
})
.on('click', () => {
this.options.onRuntimeReset();
this.refresh();
});
container.addButton({ title: 'Reset runtime settings' }).on('click', () => {
this.options.onRuntimeReset();
this.refresh();
});
container
.addButton({
@ -430,8 +431,4 @@ export class ConfigPane {
this.syncButton();
this.options.onOpenChange?.(this.isOpen);
}
public close(): void {
this.setHidden(true);
}
}

View file

@ -5,24 +5,13 @@ export class FullScreenHandler {
target: HTMLElement
) {
if (!document.fullscreenEnabled) {
minimizeButton.style.display = 'none';
maximizeButton.style.display = 'none';
minimizeButton.hidden = true;
maximizeButton.hidden = true;
return;
}
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));
maximizeButton.addEventListener('click', () => target.requestFullscreen());
minimizeButton.addEventListener('click', () => document.exitFullscreen());
@ -34,9 +23,7 @@ export class FullScreenHandler {
private updateButtons(): void {
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
this.minimizeButton.classList.toggle('active', isInFullScreenMode);
this.maximizeButton.classList.toggle('active', isInFullScreenMode);
this.minimizeButton.hidden = !isInFullScreenMode;
this.maximizeButton.hidden = isInFullScreenMode;
}
}

View file

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

View file

@ -7,8 +7,6 @@ struct Settings {
struct Counters {
aliveAgentCount: atomic<u32>,
padding0: atomic<u32>,
padding1: atomic<u32>,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@ -22,10 +20,9 @@ var<workgroup> workgroupCopyCount: u32;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
@builtin(local_invocation_id) local_id: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
let id = get_id(global_id);
if local_id.x == 0u {
atomicStore(&workgroupAliveCount, 0u);
@ -65,10 +62,9 @@ fn main(
@compute @workgroup_size(64)
fn copyCompactedAgents(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
@builtin(local_invocation_id) local_id: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
let id = get_id(global_id);
if local_id.x == 0u {
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 type { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
import { AgentGenerationPipeline } from './agent-generation-pipeline';
@ -52,7 +51,7 @@ class FakeBuffer {
private readonly mappedRange: ArrayBuffer;
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 unmap = vi.fn();
@ -72,9 +71,7 @@ class FakeComputePass {
public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => {
this.pipeline = pipeline as unknown as FakePipeline;
});
public readonly setBindGroup = vi.fn(
(_index: number, _bindGroup: GPUBindGroup) => undefined
);
public readonly setBindGroup = vi.fn(() => undefined);
public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => {
this.device.dispatchCalls.push({
entryPoint: this.pipeline?.entryPoint ?? 'unset',
@ -111,12 +108,8 @@ class FakeCommandEncoder {
}
class FakeQueue {
public readonly writeBuffer = vi.fn(
(_buffer: GPUBuffer, _offset: number, _data: BufferSource) => undefined
);
public readonly submit = vi.fn(
(_commandBuffers: Iterable<GPUCommandBuffer>) => undefined
);
public readonly writeBuffer = vi.fn(() => undefined);
public readonly submit = vi.fn(() => undefined);
}
class FakeShaderModule {
@ -137,9 +130,7 @@ class FakeDevice {
private bufferIndex = 0;
public readonly createBindGroupLayout = vi.fn(
(_descriptor: GPUBindGroupLayoutDescriptor) => ({}) as GPUBindGroupLayout
);
public readonly createBindGroupLayout = vi.fn(() => ({}) as GPUBindGroupLayout);
public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => {
const label =
['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][
@ -155,15 +146,10 @@ class FakeDevice {
isMappedReadBuffer ? this.compactedCount : 0
) as unknown as GPUBuffer;
});
public readonly createBindGroup = vi.fn(
(_descriptor: GPUBindGroupDescriptor) => ({}) as GPUBindGroup
);
public readonly createPipelineLayout = vi.fn(
(_descriptor: GPUPipelineLayoutDescriptor) => ({}) as GPUPipelineLayout
);
public readonly createBindGroup = vi.fn(() => ({}) as GPUBindGroup);
public readonly createPipelineLayout = vi.fn(() => ({}) as GPUPipelineLayout);
public readonly createShaderModule = vi.fn(
(_descriptor: GPUShaderModuleDescriptor) =>
new FakeShaderModule() as unknown as GPUShaderModule
() => new FakeShaderModule() as unknown as GPUShaderModule
);
public readonly createComputePipeline = vi.fn(
(descriptor: GPUComputePipelineDescriptor) => {
@ -183,18 +169,10 @@ const createPipeline = (compactedCount: number) => {
installGpuConstants();
const device = new FakeDevice(compactedCount);
const commonState = {
bindGroupLayout: {} as GPUBindGroupLayout,
execute: vi.fn(),
} as unknown as CommonState;
return {
device,
pipeline: new AgentGenerationPipeline(
device as unknown as GPUDevice,
commonState,
1024
),
pipeline: new AgentGenerationPipeline(device as unknown as GPUDevice, 1024),
};
};

View file

@ -1,37 +1,29 @@
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 { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
import compactionShader from './agent-compaction.wgsl?raw';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
import { GenerationCounts } from './generation-counts';
export class AgentGenerationPipeline {
private static readonly WORKGROUP_SIZE = 64;
private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 3;
private static readonly COUNTER_COUNT = 1;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly compactionBindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer;
private readonly bindGroup: GPUBindGroup;
private readonly compactionBindGroup: GPUBindGroup;
private readonly firstGenerationPipeline: GPUComputePipeline;
private readonly countingPipeline: GPUComputePipeline;
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
private readonly compactedAgentsCopyPipeline: GPUComputePipeline;
public readonly agentsBuffer: GPUBuffer;
private readonly compactedAgentsBuffer: GPUBuffer;
public readonly countersBuffer: GPUBuffer;
public readonly countersStagingBuffer: GPUBuffer;
private readonly countersBuffer: GPUBuffer;
private readonly countersStagingBuffer: GPUBuffer;
private readonly counterClearValues = new Uint32Array(
AgentGenerationPipeline.COUNTER_COUNT
);
@ -41,36 +33,10 @@ export class AgentGenerationPipeline {
public constructor(
private readonly device: GPUDevice,
private readonly commonState: CommonState,
private readonly maxAgentCountUpperLimit: number
) {
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
this.bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'storage',
},
},
],
});
this.compactionBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
@ -130,30 +96,6 @@ export class AgentGenerationPipeline {
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: {
buffer: this.agentsBuffer,
},
},
{
binding: 2,
resource: {
buffer: this.countersBuffer,
},
},
],
});
this.compactionBindGroup = this.device.createBindGroup({
layout: this.compactionBindGroupLayout,
entries: [
{
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({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, resizeShader),
module: smartCompile(device, agentSchema, resizeShader),
entryPoint: 'main',
},
});
const compactionModule = smartCompile(
device,
CommonState.shaderCode,
agentSchema,
compactionShader
);
const compactionModule = smartCompile(device, agentSchema, compactionShader);
this.compactionPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: compactionModule,
@ -236,7 +148,7 @@ export class AgentGenerationPipeline {
this.compactedAgentsCopyPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: compactionModule,
@ -251,7 +163,8 @@ export class AgentGenerationPipeline {
? this.maxAgentCountUpperLimit
: Number.POSITIVE_INFINITY,
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 passEncoder = commandEncoder.beginComputePass();
this.commonState.execute(passEncoder);
passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
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> {
if (agentCount <= 0) {
return 0;
@ -366,27 +213,17 @@ export class AgentGenerationPipeline {
const commandEncoder = this.device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.compactionBindGroup);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
const copyPassEncoder = commandEncoder.beginComputePass();
copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline);
this.commonState.execute(copyPassEncoder);
copyPassEncoder.setBindGroup(1, this.compactionBindGroup);
copyPassEncoder.setBindGroup(1, this.bindGroup);
copyPassEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
);
copyPassEncoder.end();

View file

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

View file

@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest';
import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
import compactionShader from './agent-compaction.wgsl?raw';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
@ -60,15 +58,12 @@ describe('Agent TS/WGSL contract', () => {
});
it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
[firstGenerationShader, countingShader, resizeShader, compactionShader].forEach(
(shader) => {
expect(shader).toMatch(/@workgroup_size\(64\)/);
}
);
[resizeShader, compactionShader].forEach((shader) => {
expect(shader).toMatch(/@workgroup_size\(64\)/);
});
expect(agentSchema).toContain('workgroup_count.x * 64');
expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64');
expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);');
expect(agentSchema).toContain('return global_id.x;');
expect(compactionShader).toContain('let id = get_id(global_id);');
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>;
fn get_id(global_id: vec3<u32>, workgroup_count: vec3<u32>) -> u32 {
return global_id.x + global_id.y * (workgroup_count.x * 64) + global_id.z * (workgroup_count.x * workgroup_count.y * 64);
}
fn get_id(global_id: vec3<u32>) -> u32 {
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,
writeFloat32BufferIfChanged,
} 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 { CommonState } from '../common-state/common-state';
import agentSchema from './agent-generation/agent-schema.wgsl?raw';
@ -117,7 +117,7 @@ export class AgentPipeline {
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE)
getWorkgroupCount(this.agentCount, AgentPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
}

View file

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

View file

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

View file

@ -31,21 +31,6 @@ fn vertex(
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
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { clamp } from '../../utils/clamp';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
@ -37,7 +36,6 @@ export class EraserTexturePipeline {
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT
);
private linePoints: Array<vec2> = [];
private lineSegments: 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 {
this.lineSegments.push({
from: vec2.clone(from),
@ -102,7 +94,6 @@ export class EraserTexturePipeline {
}
public clearSwipes(): void {
this.linePoints.length = 0;
this.lineSegments.length = 0;
this.actualSegments.length = 0;
}
@ -110,8 +101,8 @@ export class EraserTexturePipeline {
public setParameters({ eraserSize }: { eraserSize: number }): void {
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius;
this.uniformValues[1] = eraserRadius * eraserRadius;
this.uniformValues[0] = eraserRadius * eraserRadius;
this.uniformValues[1] = 0;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
writeFloat32BufferIfChanged(
@ -356,6 +347,6 @@ export class EraserTexturePipeline {
}
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 {
eraserRadius: f32,
eraserRadiusSquared: f32,
padding0: f32,
padding1: f32,
padding2: f32,
};

View file

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

View file

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

View file

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

View file

@ -1,33 +1,29 @@
import { appConfig, type GardenRuntimeSettings } from './config';
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 => ({
...appConfig.runtimeSettings.defaults,
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => ({
...appConfig.defaultSettings,
eraserSize: appConfig.toolbar.eraser.default,
mirrorSegmentCount: appConfig.toolbar.mirror.default,
...vibe.settings,
});
export let activeVibe = getInitialVibe();
export const settings: { [key: string]: number } & GardenRuntimeSettings = {
...buildInitialValues(activeVibe),
export const settings: GardenRuntimeSettings = {
...buildSettings(activeVibe),
};
export const resetSettings = () => {
Object.assign(settings, buildInitialValues(activeVibe));
export const resetSettings = (): GardenRuntimeSettings => {
Object.assign(settings, buildSettings(activeVibe));
return settings;
};
export const applyVibeSettings = (vibeId: string) => {
const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
if (!vibe) {
return activeVibe;
}
export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe = vibe;
Object.assign(settings, {
...buildInitialValues(vibe),
agentCount: settings.agentCount,
brushEffectDuration: settings.brushEffectDuration,
...buildSettings(vibe),
eraserSize: settings.eraserSize,
mirrorSegmentCount: settings.mirrorSegmentCount,
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 {
position: absolute;
top: 0;

View file

@ -17,7 +17,7 @@ html > body > aside.control-dock {
visibility 0s;
> .toolbar-row,
> .pages {
> .info-page {
pointer-events: auto;
}
@ -32,7 +32,7 @@ html > body > aside.control-dock {
visibility 0s var(--transition-time-long);
> .toolbar-row,
> .pages {
> .info-page {
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%);
backdrop-filter: blur(12px);
color: rgb(255 255 255 / 94%);
font:
400 20px/1.2 'Open Sans',
sans-serif;
font-size: 20px;
font-weight: 400;
line-height: 1.2;
text-shadow: 0 1px 12px rgb(0 0 0 / 58%);
}

View file

@ -1,6 +1,4 @@
.loading-indicator {
--loading-progress: 0%;
position: absolute;
top: 50%;
left: 50%;
@ -46,9 +44,9 @@
> .loading-status {
color: rgb(255 255 255 / 88%);
font:
400 16px/1.25 'Open Sans',
sans-serif;
font-size: 16px;
font-weight: 400;
line-height: 1.25;
text-align: center;
text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
letter-spacing: 0.01em;
@ -56,6 +54,8 @@
}
> .loading-progress {
--loading-progress: 0%;
position: relative;
width: 100%;
height: 3px;
@ -64,7 +64,8 @@
background: rgb(255 255 255 / 14%);
box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
> .loading-progress-fill {
&::before {
content: '';
position: absolute;
top: 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 *;
html > body > aside.control-dock > .pages {
html > body > aside.control-dock > .info-page {
width: min(calc(100vw - 1rem), 560px);
max-height: min(58vh, 520px);
max-height: min(58dvh, 520px);
margin: 0 auto 10px;
overflow-x: hidden;
overflow-y: auto;
border: 1px solid rgb(255 255 255 / 54%);
border: 1px solid rgb(255 255 255 / 78%);
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:
0 18px 48px rgb(0 0 0 / 28%),
0 2px 10px rgb(0 0 0 / 16%);
0 20px 54px rgb(0 0 0 / 38%),
0 2px 12px rgb(0 0 0 / 22%);
backdrop-filter: blur(12px);
scrollbar-width: thin;
scrollbar-color: var(--main-color) transparent;
@ -38,38 +41,35 @@ html > body > aside.control-dock > .pages {
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 {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: var(--normal-margin);
> section {
gap: 0.85rem;
h1 {
margin-bottom: 0;
color: rgb(16 24 20);
font-size: 2rem;
line-height: 1.1;
}
h1 {
margin-bottom: 0;
color: rgb(16 24 20);
}
p {
max-width: 54ch;
margin-bottom: 0;
color: rgb(42 48 45);
font-size: 1.1rem;
line-height: 1.65;
hyphens: auto;
}
p {
max-width: 54ch;
margin-bottom: 0;
color: rgb(42 48 45);
}
a {
color: rgb(0 84 120);
font-weight: 400;
a {
color: rgb(0 84 120);
font-weight: 400;
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
}
}
@ -85,30 +85,6 @@ html > body > aside.control-dock > .pages {
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 {
max-height: min(54vh, 500px);
max-height: min(54dvh, 500px);

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,14 @@
import { appConfig } from '../config';
import { clamp } from './clamp';
import { exponentialDecay } from './exponential-decay';
export class DeltaTimeCalculator {
private static FPS_EXPONENTIAL_DECAY_STRENGTH =
appConfig.deltaTime.fpsExponentialDecayStrength;
private previousTime: DOMHighResTimeStamp | null = null;
private deltaTimeAccumulator: number | null = null;
constructor(
private readonly maxDeltaTimeInSeconds: number =
appConfig.deltaTime.maxDeltaTimeSeconds,
private readonly minDeltaTimeInSeconds: number =
appConfig.deltaTime.minDeltaTimeSeconds
private readonly maxDeltaTimeInSeconds: number = appConfig.deltaTime
.maxDeltaTimeSeconds,
private readonly minDeltaTimeInSeconds: number = appConfig.deltaTime
.minDeltaTimeSeconds
) {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
@ -27,14 +22,6 @@ export class DeltaTimeCalculator {
const delta = currentTime - this.previousTime;
this.previousTime = currentTime;
const deltaInSeconds = delta / 1000;
this.deltaTimeAccumulator = exponentialDecay({
accumulator: this.deltaTimeAccumulator ?? deltaInSeconds,
nextValue: deltaInSeconds,
biasOfNextValue: DeltaTimeCalculator.FPS_EXPONENTIAL_DECAY_STRENGTH,
});
return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds);
}
@ -43,8 +30,4 @@ export class DeltaTimeCalculator {
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>(
selector: string,
constructor: ElementConstructor<T>,
root: ParentNode = document
constructor: ElementConstructor<T>
): T => {
const element = root.querySelector(selector);
const element = document.querySelector(selector);
if (!(element instanceof constructor)) {
throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING,
@ -26,10 +25,9 @@ export const queryRequiredElement = <T extends Element>(
export const queryRequiredElements = <T extends Element>(
selector: string,
constructor: ElementConstructor<T>,
root: ParentNode = document
constructor: ElementConstructor<T>
): Array<T> => {
const elements = Array.from(root.querySelectorAll(selector));
const elements = Array.from(document.querySelectorAll(selector));
if (elements.length === 0) {
throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING,

View file

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

View file

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

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