more cleaning up
This commit is contained in:
parent
2c7d72a699
commit
560398fefb
110 changed files with 933 additions and 2647 deletions
Binary file not shown.
190
e2e/app.spec.ts
190
e2e/app.spec.ts
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
39
index.html
39
index.html
|
|
@ -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
114
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
|
@ -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');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const ADAPTIVE_AGENT_CAP_MAX = 2_000_000;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
17
src/config/default-settings.ts
Normal file
17
src/config/default-settings.ts
Normal 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,
|
||||
};
|
||||
133
src/config/runtime-controls.ts
Normal file
133
src/config/runtime-controls.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export interface GameLoopSettings {
|
||||
agentBudgetMax: number;
|
||||
agentCount: number;
|
||||
simulatedDelayMs: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
104
src/index.ts
104
src/index.ts
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,11 +76,6 @@ export class MenuHider {
|
|||
};
|
||||
|
||||
private readonly onVisibilityContextChange = () => {
|
||||
if (!this.desktopMediaQuery.matches || !this.shouldBeHidden()) {
|
||||
this.reveal();
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleHide();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export interface GenerationCounts {
|
||||
evenGenerationCount: number;
|
||||
oddGenerationCount: number;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
export interface BrushSettings {
|
||||
brushSize: number;
|
||||
brushCurveResolution: number;
|
||||
eraserSize: number;
|
||||
mirrorSegmentCount: number;
|
||||
brushSizeVariation: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,4 @@ export interface DiffusionSettings {
|
|||
decayRateTrails: number;
|
||||
diffusionRateBrush: number;
|
||||
decayRateBrush: number;
|
||||
brushEffectDuration: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
struct Settings {
|
||||
eraserRadius: f32,
|
||||
eraserRadiusSquared: f32,
|
||||
padding0: f32,
|
||||
padding1: f32,
|
||||
padding2: f32,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -12,9 +12,5 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-loading aside.control-dock {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
export const exponentialDecay = ({
|
||||
accumulator,
|
||||
nextValue,
|
||||
biasOfNextValue,
|
||||
}: {
|
||||
accumulator: number;
|
||||
nextValue: number;
|
||||
biasOfNextValue: number;
|
||||
}) => accumulator * (1 - biasOfNextValue) + nextValue * biasOfNextValue;
|
||||
19
src/utils/graphics/get-workgroup-count.test.ts
Normal file
19
src/utils/graphics/get-workgroup-count.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
17
src/utils/graphics/get-workgroup-count.ts
Normal file
17
src/utils/graphics/get-workgroup-count.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue