Compare commits
5 commits
ed5a4379db
...
c40c5d97db
| Author | SHA1 | Date | |
|---|---|---|---|
| c40c5d97db | |||
| 05c8a39bd8 | |||
| a7c04b2bd8 | |||
| 646564fc73 | |||
| f300dbd394 |
|
|
@ -32,6 +32,7 @@ jobs:
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
npm run lint:check
|
npm run lint:check
|
||||||
|
npm run format:check
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run typecheck:e2e
|
npm run typecheck:e2e
|
||||||
npm test
|
npm test
|
||||||
|
|
@ -40,6 +41,10 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
npm run test:e2e
|
npm run test:e2e
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm run build
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|
|
||||||
2
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
test-results
|
test-results
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
|
||||||
2
.nvmrc
|
|
@ -1 +1 @@
|
||||||
22
|
22.13.0
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@
|
||||||
"endOfLine": "lf",
|
"endOfLine": "lf",
|
||||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||||
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
|
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
|
||||||
"importOrderTypeScriptVersion": "5.0.0"
|
"importOrderTypeScriptVersion": "6.0.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
<path
|
<path
|
||||||
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
|
d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="white"
|
stroke="currentColor"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 239 B After Width: | Height: | Size: 248 B |
|
|
@ -1,4 +1,4 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 342 B After Width: | Height: | Size: 349 B |
|
|
@ -1,7 +1,7 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
|
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
|
||||||
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
|
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
|
||||||
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
|
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
|
||||||
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
|
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 382 B |
|
|
@ -1,7 +1,7 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
|
<path d="M15 19v-2a2 2 0 0 1 2 -2h2" />
|
||||||
<path d="M15 5v2a2 2 0 0 0 2 2h2" />
|
<path d="M15 5v2a2 2 0 0 0 2 2h2" />
|
||||||
<path d="M5 15h2a2 2 0 0 1 2 2v2" />
|
<path d="M5 15h2a2 2 0 0 1 2 2v2" />
|
||||||
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
|
<path d="M5 9h2a2 2 0 0 0 2 -2v-2" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 382 B |
|
|
@ -1,4 +1,4 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
|
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" />
|
||||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 324 B After Width: | Height: | Size: 331 B |
|
|
@ -1,4 +1,4 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
<path d="M4 6l8 0" />
|
<path d="M4 6l8 0" />
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 541 B |
|
|
@ -1,3 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
<path d="M3 9.25v5.5h4.1L13 20V4L7.1 9.25H3Zm13.3-2.8-1.4 1.4A5.1 5.1 0 0 1 16.5 12a5.1 5.1 0 0 1-1.6 4.15l1.4 1.4A7.1 7.1 0 0 0 18.5 12a7.1 7.1 0 0 0-2.2-5.55Zm2.85-2.85-1.42 1.42A9.55 9.55 0 0 1 20.5 12a9.55 9.55 0 0 1-2.77 6.98l1.42 1.42A11.55 11.55 0 0 0 22.5 12a11.55 11.55 0 0 0-3.35-8.4Z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 384 B After Width: | Height: | Size: 391 B |
8
definitions.d.ts
vendored
|
|
@ -1,8 +0,0 @@
|
||||||
declare module '*.wgsl?raw' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HTMLCanvasElement {
|
|
||||||
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { expect, test, type Page } from '@playwright/test';
|
import { test as base, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
const canvasName = 'Interactive generative garden canvas';
|
const canvasName = 'Interactive generative garden canvas';
|
||||||
|
|
||||||
|
interface BrowserDiagnostics {
|
||||||
|
browserFailures: Array<string>;
|
||||||
|
consoleErrors: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
const isLocalUrl = (url: string) => {
|
const isLocalUrl = (url: string) => {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
return hostname === '127.0.0.1' || hostname === 'localhost';
|
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||||
|
|
@ -29,6 +34,27 @@ const collectLocalBrowserFailures = (page: Page) => {
|
||||||
return failures;
|
return failures;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const test = base.extend<{ browserDiagnostics: BrowserDiagnostics }>({
|
||||||
|
browserDiagnostics: [
|
||||||
|
async ({ page }, use) => {
|
||||||
|
const browserFailures = collectLocalBrowserFailures(page);
|
||||||
|
const consoleErrors: Array<string> = [];
|
||||||
|
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') {
|
||||||
|
consoleErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await use({ browserFailures, consoleErrors });
|
||||||
|
|
||||||
|
expect(consoleErrors).toEqual([]);
|
||||||
|
expect(browserFailures).toEqual([]);
|
||||||
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const disableWebGpu = async (page: Page) => {
|
const disableWebGpu = async (page: Page) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
Object.defineProperty(navigator, 'gpu', {
|
Object.defineProperty(navigator, 'gpu', {
|
||||||
|
|
@ -39,14 +65,6 @@ const disableWebGpu = async (page: Page) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||||
const browserFailures = collectLocalBrowserFailures(page);
|
|
||||||
const consoleErrors: Array<string> = [];
|
|
||||||
page.on('console', (message) => {
|
|
||||||
if (message.type() === 'error') {
|
|
||||||
consoleErrors.push(message.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.addInitScript((expectedCanvasName) => {
|
await page.addInitScript((expectedCanvasName) => {
|
||||||
const captureState = { count: 0 };
|
const captureState = { count: 0 };
|
||||||
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
|
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
|
||||||
|
|
@ -68,10 +86,10 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||||
}, canvasName);
|
}, canvasName);
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const startButton = page.getByRole('button', { name: 'Start' });
|
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
|
||||||
await expect(startButton).toBeVisible();
|
await expect(startButton).toBeVisible();
|
||||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||||
await startButton.click();
|
await page.keyboard.press('Enter');
|
||||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
@ -117,13 +135,21 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||||
)
|
)
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(consoleErrors).toEqual([]);
|
await expect
|
||||||
expect(browserFailures).toEqual([]);
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as unknown as {
|
||||||
|
__fleetingGardenBrushPasses?: number;
|
||||||
|
}
|
||||||
|
).__fleetingGardenBrushPasses ?? 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||||
const browserFailures = collectLocalBrowserFailures(page);
|
|
||||||
|
|
||||||
await disableWebGpu(page);
|
await disableWebGpu(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
|
|
@ -135,7 +161,19 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||||
const fallback = page.getByRole('alert');
|
const fallback = page.getByRole('alert');
|
||||||
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
||||||
await expect(fallback).toContainText('webgpu-unsupported');
|
await expect(fallback).toContainText('webgpu-unsupported');
|
||||||
expect(browserFailures).toEqual([]);
|
});
|
||||||
|
|
||||||
|
test('syncs the selected vibe with the URI', async ({ page }) => {
|
||||||
|
await disableWebGpu(page);
|
||||||
|
await page.goto('/?vibe=Aurora%20Mycelium');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||||
|
await expect(page).toHaveURL(/vibe=velvet-observatory/);
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
|
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
|
||||||
|
|
@ -144,8 +182,8 @@ test('keeps audio focus outlines scoped to the active control', async ({ page })
|
||||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
|
|
||||||
const audioControl = page.locator('.audio-control');
|
const audioControl = page.locator('.audio-control');
|
||||||
const soundButton = page.locator('button.sound');
|
const soundButton = page.getByRole('button', { name: /audio/i });
|
||||||
const volumeSlider = page.locator('.volume-slider');
|
const volumeSlider = page.getByRole('slider', { name: 'Master volume' });
|
||||||
|
|
||||||
await soundButton.click();
|
await soundButton.click();
|
||||||
await expect(audioControl).toHaveCSS('outline-style', 'none');
|
await expect(audioControl).toHaveCSS('outline-style', 'none');
|
||||||
|
|
@ -178,14 +216,14 @@ test('keeps the config overlay scrollable and dismissible on mobile', async ({
|
||||||
await page.setViewportSize({ width: 390, height: 640 });
|
await page.setViewportSize({ width: 390, height: 640 });
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
const startButton = page.getByRole('button', { name: 'Start' });
|
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
|
||||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||||
await startButton.click();
|
await startButton.click();
|
||||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsButton = page.locator('button.settings');
|
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
|
||||||
await settingsButton.click();
|
await settingsButton.click();
|
||||||
|
|
||||||
const pane = page.locator('.config-pane');
|
const pane = page.locator('.config-pane');
|
||||||
|
|
|
||||||
12
package-lock.json
generated
|
|
@ -10,13 +10,13 @@
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plausible-analytics/tracker": "^0.4.5",
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"tweakpane": "^4.0.5"
|
"tweakpane": "~4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tweakpane/core": "^2.0.5",
|
"@tweakpane/core": "~2.0.5",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=22.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|
@ -2774,9 +2774,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
"generate-icons": "pwa-assets-generator"
|
"generate-icons": "pwa-assets-generator"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=22.13.0"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tweakpane/core": "^2.0.5",
|
"@tweakpane/core": "~2.0.5",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
|
|
@ -71,6 +71,6 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plausible-analytics/tracker": "^0.4.5",
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"tweakpane": "^4.0.5"
|
"tweakpane": "~4.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: isCi,
|
forbidOnly: isCi,
|
||||||
retries: isCi ? 2 : 0,
|
retries: isCi ? 2 : 0,
|
||||||
workers: isCi ? 1 : undefined,
|
workers: 1,
|
||||||
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
|
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 301 KiB |
|
|
@ -4,6 +4,7 @@ import {
|
||||||
type PlausibleEventOptions,
|
type PlausibleEventOptions,
|
||||||
} from '@plausible-analytics/tracker';
|
} from '@plausible-analytics/tracker';
|
||||||
|
|
||||||
|
import { appConfig } from './config';
|
||||||
import type { VibeId } from './vibes';
|
import type { VibeId } from './vibes';
|
||||||
|
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
|
@ -23,10 +24,10 @@ export const initAnalytics = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
plausibleInit({
|
plausibleInit({
|
||||||
domain: 'schmelczer.dev/floating',
|
domain: appConfig.analytics.domain,
|
||||||
endpoint: 'https://stats.schmelczer.dev/status',
|
endpoint: appConfig.analytics.endpoint,
|
||||||
autoCapturePageviews: true,
|
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
|
||||||
logging: true,
|
logging: appConfig.analytics.logging,
|
||||||
});
|
});
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { DEFAULT_AUDIO_VOLUME } from '../consts';
|
|
||||||
import type { PianoNoteRole } from './garden-audio-types';
|
import type { PianoNoteRole } from './garden-audio-types';
|
||||||
|
|
||||||
|
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
||||||
|
export const SILENT_AUDIO_GAIN = 0.0001;
|
||||||
|
|
||||||
|
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
|
||||||
|
|
||||||
export interface GardenAudioChord {
|
export interface GardenAudioChord {
|
||||||
rootOffset: number;
|
rootOffset: number;
|
||||||
quality: 'major' | 'minor';
|
quality: GardenAudioChordQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioVibeSettings {
|
export interface GardenAudioVibeSettings {
|
||||||
|
|
@ -14,6 +18,8 @@ export interface GardenAudioVibeSettings {
|
||||||
noteLength: number;
|
noteLength: number;
|
||||||
notePitchOffset: number;
|
notePitchOffset: number;
|
||||||
brightness: number;
|
brightness: number;
|
||||||
|
scale?: Array<number>;
|
||||||
|
progression?: Array<GardenAudioChord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||||
|
|
@ -37,7 +43,9 @@ export const createGardenAudioConfig = () => ({
|
||||||
fadeInSeconds: 0.45,
|
fadeInSeconds: 0.45,
|
||||||
updateRampSeconds: 0.08,
|
updateRampSeconds: 0.08,
|
||||||
delay: {
|
delay: {
|
||||||
timeSeconds: 0.405,
|
timeBeats: 0.5,
|
||||||
|
timeMinSeconds: 0.18,
|
||||||
|
timeMaxSeconds: 0.72,
|
||||||
feedback: 0.12,
|
feedback: 0.12,
|
||||||
wetGain: 0.044,
|
wetGain: 0.044,
|
||||||
erasingActivity: 0.12,
|
erasingActivity: 0.12,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { clamp } from '../utils/math';
|
import { clamp } from '../utils/math';
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
import { SILENT_AUDIO_GAIN, type GardenAudioConfig } from './garden-audio-config';
|
||||||
import type { PianoNoteRole } from './garden-audio-types';
|
import type { PianoNoteRole } from './garden-audio-types';
|
||||||
|
|
||||||
|
type AudioSessionType = NonNullable<NavigatorWithAudioSession['audioSession']>['type'];
|
||||||
|
|
||||||
type NavigatorWithAudioSession = Navigator & {
|
type NavigatorWithAudioSession = Navigator & {
|
||||||
audioSession?: {
|
audioSession?: {
|
||||||
type:
|
type:
|
||||||
|
|
@ -17,14 +19,14 @@ type NavigatorWithAudioSession = Navigator & {
|
||||||
const outputHighPassFrequencyHz = 45;
|
const outputHighPassFrequencyHz = 45;
|
||||||
const noiseBufferDurationSeconds = 1;
|
const noiseBufferDurationSeconds = 1;
|
||||||
const graphTuning = {
|
const graphTuning = {
|
||||||
closeGain: 0.0001,
|
closeGain: SILENT_AUDIO_GAIN,
|
||||||
closeRampSeconds: 0.015,
|
closeRampSeconds: 0.015,
|
||||||
delayMaxSeconds: 2,
|
delayMaxSeconds: 2,
|
||||||
eventBusGain: 1,
|
eventBusGain: 1,
|
||||||
noiseMax: 1,
|
noiseMax: 1,
|
||||||
noiseMin: -1,
|
noiseMin: -1,
|
||||||
latencyHint: 'interactive' as AudioContextLatencyCategory,
|
latencyHint: 'interactive',
|
||||||
outputFilterType: 'highpass' as BiquadFilterType,
|
outputFilterType: 'highpass',
|
||||||
compressor: {
|
compressor: {
|
||||||
thresholdDb: -18,
|
thresholdDb: -18,
|
||||||
kneeDb: 18,
|
kneeDb: 18,
|
||||||
|
|
@ -32,7 +34,7 @@ const graphTuning = {
|
||||||
attackSeconds: 0.018,
|
attackSeconds: 0.018,
|
||||||
releaseSeconds: 0.18,
|
releaseSeconds: 0.18,
|
||||||
},
|
},
|
||||||
};
|
} as const;
|
||||||
const delayFilterTuning = {
|
const delayFilterTuning = {
|
||||||
feedbackHighPassHz: 180,
|
feedbackHighPassHz: 180,
|
||||||
feedbackLowPassHz: 5200,
|
feedbackLowPassHz: 5200,
|
||||||
|
|
@ -54,6 +56,7 @@ export class GardenAudioGraph {
|
||||||
private pianoBusGainScale = 1;
|
private pianoBusGainScale = 1;
|
||||||
private pianoBusGainScaleAutomationUntil = 0;
|
private pianoBusGainScaleAutomationUntil = 0;
|
||||||
private pianoBusGainScaleTimeConstantSeconds = 0;
|
private pianoBusGainScaleTimeConstantSeconds = 0;
|
||||||
|
private previousAudioSessionType: AudioSessionType | null = null;
|
||||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {}
|
public constructor(private readonly config: GardenAudioConfig) {}
|
||||||
|
|
@ -77,6 +80,7 @@ export class GardenAudioGraph {
|
||||||
// Audio Session API.
|
// Audio Session API.
|
||||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||||
if (audioSession) {
|
if (audioSession) {
|
||||||
|
this.previousAudioSessionType ??= audioSession.type;
|
||||||
audioSession.type = 'playback';
|
audioSession.type = 'playback';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,19 +125,19 @@ export class GardenAudioGraph {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyDelayProfile(): void {
|
public applyDelayProfile(bpm: number): void {
|
||||||
if (!this.context || !this.delayNode) {
|
if (!this.context || !this.delayNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds,
|
this.getDelayTimeSecondsForBpm(bpm),
|
||||||
this.context.currentTime,
|
this.context.currentTime,
|
||||||
this.config.delay.timeRampSeconds
|
this.config.delay.timeRampSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateDelay(activity: number): void {
|
public updateDelay(activity: number, bpm: number): void {
|
||||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +145,7 @@ export class GardenAudioGraph {
|
||||||
const now = this.context.currentTime;
|
const now = this.context.currentTime;
|
||||||
const normalizedActivity = clamp(activity, 0, 1);
|
const normalizedActivity = clamp(activity, 0, 1);
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds,
|
this.getDelayTimeSecondsForBpm(bpm),
|
||||||
now,
|
now,
|
||||||
this.config.delay.timeRampSeconds
|
this.config.delay.timeRampSeconds
|
||||||
);
|
);
|
||||||
|
|
@ -203,6 +207,21 @@ export class GardenAudioGraph {
|
||||||
if (context.state !== 'closed') {
|
if (context.state !== 'closed') {
|
||||||
await context.close().catch(() => undefined);
|
await context.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.restoreAudioSessionType();
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreAudioSessionType(): void {
|
||||||
|
const previousType = this.previousAudioSessionType;
|
||||||
|
this.previousAudioSessionType = null;
|
||||||
|
if (previousType === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||||
|
if (audioSession) {
|
||||||
|
audioSession.type = previousType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
private createDelay(context: AudioContext, masterGain: GainNode): void {
|
||||||
|
|
@ -214,7 +233,7 @@ export class GardenAudioGraph {
|
||||||
const feedbackLowPass = context.createBiquadFilter();
|
const feedbackLowPass = context.createBiquadFilter();
|
||||||
const returnLowPass = context.createBiquadFilter();
|
const returnLowPass = context.createBiquadFilter();
|
||||||
|
|
||||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
|
||||||
delayFeedback.gain.value = this.config.delay.feedback;
|
delayFeedback.gain.value = this.config.delay.feedback;
|
||||||
delayOutput.gain.value = this.config.delay.wetGain;
|
delayOutput.gain.value = this.config.delay.wetGain;
|
||||||
feedbackHighPass.type = 'highpass';
|
feedbackHighPass.type = 'highpass';
|
||||||
|
|
@ -283,6 +302,15 @@ export class GardenAudioGraph {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDelayTimeSecondsForBpm(bpm: number): number {
|
||||||
|
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
|
||||||
|
return clamp(
|
||||||
|
(60 / safeBpm) * this.config.delay.timeBeats,
|
||||||
|
this.config.delay.timeMinSeconds,
|
||||||
|
this.config.delay.timeMaxSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||||
const buffer = context.createBuffer(
|
const buffer = context.createBuffer(
|
||||||
1,
|
1,
|
||||||
|
|
|
||||||
|
|
@ -13,22 +13,21 @@ const DEFAULT_PROGRESSION: ReadonlyArray<GardenAudioChord> = [
|
||||||
const DEFAULT_ROOT_MIDI = 57;
|
const DEFAULT_ROOT_MIDI = 57;
|
||||||
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
||||||
|
|
||||||
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
|
const getProfileScale = (vibe: VibePreset): Array<number> => {
|
||||||
|
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
|
||||||
|
return [...scale];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
|
||||||
|
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
|
||||||
|
(chord) => ({ ...chord })
|
||||||
|
);
|
||||||
|
|
||||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||||
let profile = profileCache.get(vibe);
|
return {
|
||||||
if (!profile) {
|
...vibe.audio,
|
||||||
profile = {
|
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||||
...vibe.audio,
|
scale: getProfileScale(vibe),
|
||||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
progression: getProfileProgression(vibe),
|
||||||
scale: DEFAULT_SCALE as Array<number>,
|
};
|
||||||
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
|
|
||||||
};
|
|
||||||
profileCache.set(vibe, profile);
|
|
||||||
return profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(profile, vibe.audio);
|
|
||||||
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
|
|
||||||
return profile;
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { VibePreset } from '../vibes';
|
import type { VibePreset } from '../vibes';
|
||||||
|
|
||||||
export interface GardenAudioSnapshot {
|
export interface GardenAudioSnapshot {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||||
import { clamp01 } from '../utils/math';
|
import { clamp01 } from '../utils/math';
|
||||||
import type { VibeId, VibePreset } from '../vibes';
|
import type { VibeId, VibePreset } from '../vibes';
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
import {
|
||||||
|
SILENT_AUDIO_GAIN,
|
||||||
|
type GardenAudioConfig,
|
||||||
|
type GardenAudioVibeProfile,
|
||||||
|
} from './garden-audio-config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
|
|
@ -13,8 +17,12 @@ import { NoiseBurstPlayer } from './noise-burst-player';
|
||||||
import { PianoSampler } from './piano-sampler';
|
import { PianoSampler } from './piano-sampler';
|
||||||
|
|
||||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||||
|
type PianoReleasePhase =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'awaiting-fade' }
|
||||||
|
| { kind: 'scheduled-fade'; fadeAt: number }
|
||||||
|
| { kind: 'settling'; stopAt: number };
|
||||||
|
|
||||||
const muteGain = 0.0001;
|
|
||||||
const muteRampSeconds = 0.02;
|
const muteRampSeconds = 0.02;
|
||||||
const brushUpPianoBusFadeSeconds = 2.4;
|
const brushUpPianoBusFadeSeconds = 2.4;
|
||||||
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
||||||
|
|
@ -29,16 +37,16 @@ export class GardenAudio {
|
||||||
private readonly pianoEngine: GenerativePianoEngine;
|
private readonly pianoEngine: GenerativePianoEngine;
|
||||||
|
|
||||||
private currentVibeId: VibeId | null = null;
|
private currentVibeId: VibeId | null = null;
|
||||||
|
private currentVibe: VibePreset | null = null;
|
||||||
private lifecycle: AudioLifecycle = 'idle';
|
private lifecycle: AudioLifecycle = 'idle';
|
||||||
private isReleasingPiano = false;
|
private pianoReleasePhase: PianoReleasePhase = { kind: 'idle' };
|
||||||
private isMuted = false;
|
private isMuted = false;
|
||||||
private isGestureActive = false;
|
private isGestureActive = false;
|
||||||
private fadePianoAt: number | null = null;
|
|
||||||
private masterVolume: number;
|
private masterVolume: number;
|
||||||
private stopPianoAt: number | null = null;
|
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
private startRequestId = 0;
|
private startRequestId = 0;
|
||||||
|
private hasLoadedPiano = false;
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {
|
public constructor(private readonly config: GardenAudioConfig) {
|
||||||
this.masterVolume = clamp01(config.masterVolume);
|
this.masterVolume = clamp01(config.masterVolume);
|
||||||
|
|
@ -60,7 +68,8 @@ export class GardenAudio {
|
||||||
if (
|
if (
|
||||||
this.lifecycle === 'started' &&
|
this.lifecycle === 'started' &&
|
||||||
this.currentVibeId === vibe.id &&
|
this.currentVibeId === vibe.id &&
|
||||||
this.graph.context?.state === 'running'
|
this.graph.context?.state === 'running' &&
|
||||||
|
this.hasLoadedPiano
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +83,7 @@ export class GardenAudio {
|
||||||
? muteRampSeconds
|
? muteRampSeconds
|
||||||
: this.config.fadeInSeconds;
|
: this.config.fadeInSeconds;
|
||||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||||
|
const startRequestId = ++this.startRequestId;
|
||||||
|
|
||||||
if (needsResume) {
|
if (needsResume) {
|
||||||
if (!isUserGesture) {
|
if (!isUserGesture) {
|
||||||
|
|
@ -83,7 +93,7 @@ export class GardenAudio {
|
||||||
.resume()
|
.resume()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||||
this.completeStart(vibe, { context, startupRampSeconds });
|
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -95,16 +105,18 @@ export class GardenAudio {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.completeStart(vibe, { context, startupRampSeconds });
|
this.completeStart(vibe, { context, startupRampSeconds, startRequestId });
|
||||||
}
|
}
|
||||||
|
|
||||||
private completeStart(
|
private completeStart(
|
||||||
vibe: VibePreset,
|
vibe: VibePreset,
|
||||||
{
|
{
|
||||||
context,
|
context,
|
||||||
|
startRequestId,
|
||||||
startupRampSeconds,
|
startupRampSeconds,
|
||||||
}: {
|
}: {
|
||||||
context: AudioContext;
|
context: AudioContext;
|
||||||
|
startRequestId: number;
|
||||||
startupRampSeconds: number;
|
startupRampSeconds: number;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -113,11 +125,11 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isMuted) {
|
if (this.isMuted) {
|
||||||
this.graph.setMasterGain(muteGain, muteRampSeconds);
|
this.activateMutedStart(vibe, context);
|
||||||
|
this.graph.setMasterGain(SILENT_AUDIO_GAIN, muteRampSeconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRequestId = ++this.startRequestId;
|
|
||||||
void this.piano
|
void this.piano
|
||||||
.load(context)
|
.load(context)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
@ -155,11 +167,25 @@ export class GardenAudio {
|
||||||
): void {
|
): void {
|
||||||
this.lifecycle = 'started';
|
this.lifecycle = 'started';
|
||||||
this.currentVibeId = vibe.id;
|
this.currentVibeId = vibe.id;
|
||||||
this.graph.applyDelayProfile();
|
this.currentVibe = vibe;
|
||||||
|
const profile = getVibeProfile(vibe);
|
||||||
|
this.graph.applyDelayProfile(profile.bpm);
|
||||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||||
|
|
||||||
if (cuePiano) {
|
if (cuePiano) {
|
||||||
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
|
this.hasLoadedPiano = true;
|
||||||
|
this.pianoEngine.cue(context.currentTime, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private activateMutedStart(vibe: VibePreset, context: AudioContext): void {
|
||||||
|
this.lifecycle = 'started';
|
||||||
|
this.currentVibeId = vibe.id;
|
||||||
|
this.currentVibe = vibe;
|
||||||
|
this.hasLoadedPiano = false;
|
||||||
|
this.graph.applyDelayProfile(getVibeProfile(vibe).bpm);
|
||||||
|
if (this.graph.context === context) {
|
||||||
|
this.pianoEngine.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,6 +196,7 @@ export class GardenAudio {
|
||||||
|
|
||||||
if (didChangeVibe) {
|
if (didChangeVibe) {
|
||||||
this.piano.stopAll();
|
this.piano.stopAll();
|
||||||
|
this.hasLoadedPiano = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
|
|
@ -191,9 +218,13 @@ export class GardenAudio {
|
||||||
|
|
||||||
this.isMuted = isMuted;
|
this.isMuted = isMuted;
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(
|
||||||
isMuted ? muteGain : this.masterVolume,
|
isMuted ? SILENT_AUDIO_GAIN : this.masterVolume,
|
||||||
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
isMuted ? muteRampSeconds : this.config.fadeInSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isMuted && this.currentVibe && !this.hasLoadedPiano) {
|
||||||
|
this.start(this.currentVibe);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setMasterVolume(masterVolume: number): void {
|
public setMasterVolume(masterVolume: number): void {
|
||||||
|
|
@ -210,9 +241,7 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isGestureActive = true;
|
this.isGestureActive = true;
|
||||||
this.isReleasingPiano = false;
|
this.pianoReleasePhase = { kind: 'idle' };
|
||||||
this.fadePianoAt = null;
|
|
||||||
this.stopPianoAt = null;
|
|
||||||
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
||||||
this.gestureState.reset();
|
this.gestureState.reset();
|
||||||
this.energy.beginGesture(context.currentTime);
|
this.energy.beginGesture(context.currentTime);
|
||||||
|
|
@ -222,9 +251,7 @@ export class GardenAudio {
|
||||||
public endGesture(): void {
|
public endGesture(): void {
|
||||||
this.gestureState.reset();
|
this.gestureState.reset();
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
this.isReleasingPiano = true;
|
this.pianoReleasePhase = { kind: 'awaiting-fade' };
|
||||||
this.fadePianoAt = null;
|
|
||||||
this.stopPianoAt = null;
|
|
||||||
this.energy.endGesture();
|
this.energy.endGesture();
|
||||||
this.pianoEngine.endGesture();
|
this.pianoEngine.endGesture();
|
||||||
}
|
}
|
||||||
|
|
@ -243,9 +270,9 @@ export class GardenAudio {
|
||||||
this.energy.silence();
|
this.energy.silence();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isGestureActive && this.isReleasingPiano) {
|
if (!this.isGestureActive && this.pianoReleasePhase.kind !== 'idle') {
|
||||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||||
this.updateDelay(snapshot);
|
this.updateDelay(snapshot, profile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,7 +283,7 @@ export class GardenAudio {
|
||||||
? this.config.eraser.pianoActivity
|
? this.config.eraser.pianoActivity
|
||||||
: this.energy.getLevel(),
|
: this.energy.getLevel(),
|
||||||
});
|
});
|
||||||
this.updateDelay(snapshot);
|
this.updateDelay(snapshot, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
public stroke(stroke: GardenAudioStroke): void {
|
public stroke(stroke: GardenAudioStroke): void {
|
||||||
|
|
@ -298,14 +325,14 @@ export class GardenAudio {
|
||||||
await this.graph.close();
|
await this.graph.close();
|
||||||
|
|
||||||
this.piano.reset();
|
this.piano.reset();
|
||||||
|
this.hasLoadedPiano = false;
|
||||||
this.energy.reset();
|
this.energy.reset();
|
||||||
this.gestureState.reset();
|
this.gestureState.reset();
|
||||||
this.pianoEngine.reset();
|
this.pianoEngine.reset();
|
||||||
this.currentVibeId = null;
|
this.currentVibeId = null;
|
||||||
|
this.currentVibe = null;
|
||||||
this.isGestureActive = false;
|
this.isGestureActive = false;
|
||||||
this.isReleasingPiano = false;
|
this.pianoReleasePhase = { kind: 'idle' };
|
||||||
this.fadePianoAt = null;
|
|
||||||
this.stopPianoAt = null;
|
|
||||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
}
|
}
|
||||||
|
|
@ -326,21 +353,41 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
||||||
if (this.fadePianoAt === null && this.stopPianoAt === null) {
|
if (this.pianoReleasePhase.kind === 'awaiting-fade') {
|
||||||
this.fadePianoAt = this.pianoEngine.release(vibe, now);
|
const fadeAt = this.pianoEngine.release(vibe, now);
|
||||||
}
|
if (now < fadeAt) {
|
||||||
|
this.pianoReleasePhase = { kind: 'scheduled-fade', fadeAt };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
|
|
||||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||||
this.fadePianoAt = null;
|
this.pianoReleasePhase = {
|
||||||
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
|
kind: 'settling',
|
||||||
|
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||||
|
};
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
|
if (
|
||||||
|
this.pianoReleasePhase.kind === 'scheduled-fade' &&
|
||||||
|
now >= this.pianoReleasePhase.fadeAt
|
||||||
|
) {
|
||||||
|
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||||
|
this.pianoReleasePhase = {
|
||||||
|
kind: 'settling',
|
||||||
|
stopAt: now + brushUpPianoBusFadeSettleSeconds,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.pianoReleasePhase.kind === 'settling' &&
|
||||||
|
now >= this.pianoReleasePhase.stopAt
|
||||||
|
) {
|
||||||
this.piano.stopAll();
|
this.piano.stopAll();
|
||||||
this.pianoEngine.reset();
|
this.pianoEngine.reset();
|
||||||
this.stopPianoAt = null;
|
this.hasLoadedPiano = false;
|
||||||
this.isReleasingPiano = false;
|
this.pianoReleasePhase = { kind: 'idle' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +418,10 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateDelay(snapshot: GardenAudioSnapshot): void {
|
private updateDelay(
|
||||||
|
snapshot: GardenAudioSnapshot,
|
||||||
|
profile: GardenAudioVibeProfile
|
||||||
|
): void {
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -380,7 +430,7 @@ export class GardenAudio {
|
||||||
const activity = snapshot.isErasing
|
const activity = snapshot.isErasing
|
||||||
? this.config.delay.erasingActivity
|
? this.config.delay.erasingActivity
|
||||||
: this.energy.getLevel();
|
: this.energy.getLevel();
|
||||||
this.graph.updateDelay(activity);
|
this.graph.updateDelay(activity, profile.bpm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyVibe(vibe: VibePreset): void {
|
private applyVibe(vibe: VibePreset): void {
|
||||||
|
|
@ -389,8 +439,10 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentVibeId = vibe.id;
|
this.currentVibeId = vibe.id;
|
||||||
|
this.currentVibe = vibe;
|
||||||
const profile = getVibeProfile(vibe);
|
const profile = getVibeProfile(vibe);
|
||||||
this.graph.applyDelayProfile();
|
this.graph.applyDelayProfile(profile.bpm);
|
||||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||||
|
this.hasLoadedPiano = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,12 @@ interface GenerativePianoTuning {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
};
|
};
|
||||||
|
stereoWidth: {
|
||||||
|
idle: number;
|
||||||
|
active: number;
|
||||||
|
intense: number;
|
||||||
|
intenseThreshold: number;
|
||||||
|
};
|
||||||
stylePanOffsetScale: number;
|
stylePanOffsetScale: number;
|
||||||
lowpass: {
|
lowpass: {
|
||||||
midiBase: number;
|
midiBase: number;
|
||||||
|
|
@ -371,6 +377,12 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
||||||
min: -3,
|
min: -3,
|
||||||
max: 3,
|
max: 3,
|
||||||
},
|
},
|
||||||
|
stereoWidth: {
|
||||||
|
idle: 0.46,
|
||||||
|
active: 0.9,
|
||||||
|
intense: 1.16,
|
||||||
|
intenseThreshold: 0.72,
|
||||||
|
},
|
||||||
stylePanOffsetScale: 0.35,
|
stylePanOffsetScale: 0.35,
|
||||||
lowpass: {
|
lowpass: {
|
||||||
midiBase: 48,
|
midiBase: 48,
|
||||||
|
|
|
||||||
|
|
@ -17,24 +17,37 @@ import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
|
||||||
|
|
||||||
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
|
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
|
||||||
const GENERATIVE_START_DELAY_SECONDS = 0.02;
|
const GENERATIVE_START_DELAY_SECONDS = 0.02;
|
||||||
|
const TEXTURE_ONSET_EXPRESSION = 0.15;
|
||||||
|
const SUPPORT_ONSET_EXPRESSION = 0.4;
|
||||||
|
|
||||||
const chordVoicings = {
|
const chordVoicings: Record<
|
||||||
majorOpen: [0, 7, 12, 16],
|
GardenAudioChord['quality'],
|
||||||
minorOpen: [0, 7, 12, 15],
|
{ closed: Array<number>; open: Array<number> }
|
||||||
majorClosed: [0, 4, 7, 12, 16],
|
> = {
|
||||||
minorClosed: [0, 3, 7, 12, 15],
|
major: {
|
||||||
|
closed: [0, 4, 7, 12, 16],
|
||||||
|
open: [0, 7, 12, 16],
|
||||||
|
},
|
||||||
|
minor: {
|
||||||
|
closed: [0, 3, 7, 12, 15],
|
||||||
|
open: [0, 7, 12, 15],
|
||||||
|
},
|
||||||
|
sus2: {
|
||||||
|
closed: [0, 2, 7, 12, 14],
|
||||||
|
open: [0, 7, 12, 14],
|
||||||
|
},
|
||||||
|
sus4: {
|
||||||
|
closed: [0, 5, 7, 12, 17],
|
||||||
|
open: [0, 7, 12, 17],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChordIntervals = (
|
const getChordIntervals = (
|
||||||
chord: GardenAudioChord,
|
chord: GardenAudioChord,
|
||||||
openVoicing: boolean
|
openVoicing: boolean
|
||||||
): Array<number> => {
|
): Array<number> => {
|
||||||
if (openVoicing) {
|
const voicing = chordVoicings[chord.quality];
|
||||||
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
|
return openVoicing ? voicing.open : voicing.closed;
|
||||||
}
|
|
||||||
return chord.quality === 'major'
|
|
||||||
? chordVoicings.majorClosed
|
|
||||||
: chordVoicings.minorClosed;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
|
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
|
||||||
|
|
@ -406,7 +419,7 @@ export class GenerativePianoEngine {
|
||||||
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
|
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
pan: register.pan,
|
pan: this.getActivityPan(register.pan, expression),
|
||||||
role: 'pad',
|
role: 'pad',
|
||||||
delaySend: generativePianoTuning.padChord.delaySend,
|
delaySend: generativePianoTuning.padChord.delaySend,
|
||||||
lowpassHz: this.getLowpassHz(
|
lowpassHz: this.getLowpassHz(
|
||||||
|
|
@ -446,7 +459,7 @@ export class GenerativePianoEngine {
|
||||||
velocity: release.velocities[index],
|
velocity: release.velocities[index],
|
||||||
startTime: startTime + index * release.strumSeconds,
|
startTime: startTime + index * release.strumSeconds,
|
||||||
durationSeconds: release.durationSeconds,
|
durationSeconds: release.durationSeconds,
|
||||||
pan: register.pan,
|
pan: this.getActivityPan(register.pan, 0),
|
||||||
role: 'pad',
|
role: 'pad',
|
||||||
delaySend: release.delaySend,
|
delaySend: release.delaySend,
|
||||||
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
|
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
|
||||||
|
|
@ -487,7 +500,7 @@ export class GenerativePianoEngine {
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
generativePianoTuning.supportNote.durationBaseSeconds +
|
generativePianoTuning.supportNote.durationBaseSeconds +
|
||||||
expression * generativePianoTuning.supportNote.durationExpressionSeconds,
|
expression * generativePianoTuning.supportNote.durationExpressionSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex, expression),
|
||||||
role: 'support',
|
role: 'support',
|
||||||
delaySend:
|
delaySend:
|
||||||
generativePianoTuning.supportNote.delaySendBase +
|
generativePianoTuning.supportNote.delaySendBase +
|
||||||
|
|
@ -533,7 +546,7 @@ export class GenerativePianoEngine {
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
generativePianoTuning.textureNote.durationBaseSeconds +
|
generativePianoTuning.textureNote.durationBaseSeconds +
|
||||||
expression * generativePianoTuning.textureNote.durationExpressionSeconds,
|
expression * generativePianoTuning.textureNote.durationExpressionSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex, expression),
|
||||||
role: 'texture',
|
role: 'texture',
|
||||||
delaySend:
|
delaySend:
|
||||||
generativePianoTuning.textureNote.delaySendBase +
|
generativePianoTuning.textureNote.delaySendBase +
|
||||||
|
|
@ -582,7 +595,7 @@ export class GenerativePianoEngine {
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
generativePianoTuning.gestureAccent.durationBaseSeconds +
|
generativePianoTuning.gestureAccent.durationBaseSeconds +
|
||||||
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
|
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex, strength),
|
||||||
role: 'gesture',
|
role: 'gesture',
|
||||||
delaySend: generativePianoTuning.gestureAccent.delaySend,
|
delaySend: generativePianoTuning.gestureAccent.delaySend,
|
||||||
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
||||||
|
|
@ -627,7 +640,7 @@ export class GenerativePianoEngine {
|
||||||
durationSeconds:
|
durationSeconds:
|
||||||
generativePianoTuning.touchNote.durationBaseSeconds +
|
generativePianoTuning.touchNote.durationBaseSeconds +
|
||||||
strength * generativePianoTuning.touchNote.durationStrengthSeconds,
|
strength * generativePianoTuning.touchNote.durationStrengthSeconds,
|
||||||
pan: this.getStylePan(styleIndex),
|
pan: this.getStylePan(styleIndex, strength),
|
||||||
role: 'gesture',
|
role: 'gesture',
|
||||||
delaySend: generativePianoTuning.touchNote.delaySend,
|
delaySend: generativePianoTuning.touchNote.delaySend,
|
||||||
lowpassHz: this.getLowpassHz(
|
lowpassHz: this.getLowpassHz(
|
||||||
|
|
@ -813,7 +826,7 @@ export class GenerativePianoEngine {
|
||||||
chordOffsets: this.getChordOffsets(chord, chordIntervals),
|
chordOffsets: this.getChordOffsets(chord, chordIntervals),
|
||||||
};
|
};
|
||||||
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
||||||
const pan = this.getStylePan(styleIndex);
|
const pan = this.getStylePan(styleIndex, intensity);
|
||||||
const durationSeconds = clamp(
|
const durationSeconds = clamp(
|
||||||
generativePianoTuning.brushStream.durationBaseSeconds +
|
generativePianoTuning.brushStream.durationBaseSeconds +
|
||||||
intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
|
intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
|
||||||
|
|
@ -1076,6 +1089,9 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldPlaySupport(expression: number, barIndex: number): boolean {
|
private shouldPlaySupport(expression: number, barIndex: number): boolean {
|
||||||
|
if (expression < SUPPORT_ONSET_EXPRESSION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (expression >= generativePianoTuning.supportNote.expressionThreshold) {
|
if (expression >= generativePianoTuning.supportNote.expressionThreshold) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1087,19 +1103,17 @@ export class GenerativePianoEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldPlayTexture(expression: number, barIndex: number): boolean {
|
private shouldPlayTexture(expression: number, barIndex: number): boolean {
|
||||||
|
if (expression < TEXTURE_ONSET_EXPRESSION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (expression >= generativePianoTuning.textureNote.mediumExpressionThreshold) {
|
||||||
|
return barIndex % generativePianoTuning.textureNote.intenseSpacing === 0;
|
||||||
|
}
|
||||||
const spacing =
|
const spacing =
|
||||||
expression < generativePianoTuning.textureNote.idleExpressionThreshold
|
expression < generativePianoTuning.textureNote.idleExpressionThreshold
|
||||||
? generativePianoTuning.idleTextureBarSpacing
|
? generativePianoTuning.idleTextureBarSpacing
|
||||||
: expression < generativePianoTuning.textureNote.mediumExpressionThreshold
|
: generativePianoTuning.mediumTextureBarSpacing;
|
||||||
? generativePianoTuning.mediumTextureBarSpacing
|
return barIndex % spacing === generativePianoTuning.textureNote.idlePhase % spacing;
|
||||||
: generativePianoTuning.textureNote.intenseSpacing;
|
|
||||||
|
|
||||||
return (
|
|
||||||
barIndex % spacing ===
|
|
||||||
(spacing === generativePianoTuning.textureNote.intenseSpacing
|
|
||||||
? 0
|
|
||||||
: generativePianoTuning.textureNote.idlePhase)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSupportOffsets(
|
private getSupportOffsets(
|
||||||
|
|
@ -1128,16 +1142,29 @@ export class GenerativePianoEngine {
|
||||||
styleCount) as GardenAudioStyleIndex;
|
styleCount) as GardenAudioStyleIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number {
|
||||||
const pool = generativePianoTuning.stylePools[styleIndex];
|
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||||
const styleVoice = styleVoices[styleIndex];
|
const styleVoice = styleVoices[styleIndex];
|
||||||
return clamp(
|
return this.getActivityPan(
|
||||||
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
|
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
|
||||||
-1,
|
activity
|
||||||
1
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getActivityPan(pan: number, activity: number): number {
|
||||||
|
const { active, idle, intense, intenseThreshold } = generativePianoTuning.stereoWidth;
|
||||||
|
const normalizedActivity = clamp01(activity);
|
||||||
|
const safeThreshold = clamp(intenseThreshold, 0.001, 0.999);
|
||||||
|
const width =
|
||||||
|
normalizedActivity < safeThreshold
|
||||||
|
? idle + ((active - idle) * normalizedActivity) / safeThreshold
|
||||||
|
: active +
|
||||||
|
((intense - active) * (normalizedActivity - safeThreshold)) /
|
||||||
|
(1 - safeThreshold);
|
||||||
|
|
||||||
|
return clamp(pan * width, -1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
private getLowpassHz(
|
private getLowpassHz(
|
||||||
profile: GardenAudioVibeProfile,
|
profile: GardenAudioVibeProfile,
|
||||||
midi: number,
|
midi: number,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ const noiseBurstTuning = {
|
||||||
offsetRandomSeconds: 0.4,
|
offsetRandomSeconds: 0.4,
|
||||||
scheduleAheadSeconds: 0.002,
|
scheduleAheadSeconds: 0.002,
|
||||||
silentGain: 0.0001,
|
silentGain: 0.0001,
|
||||||
filterType: 'bandpass' as BiquadFilterType,
|
filterType: 'bandpass',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export class NoiseBurstPlayer {
|
export class NoiseBurstPlayer {
|
||||||
public constructor(private readonly graph: GardenAudioGraph) {}
|
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||||
|
|
@ -45,7 +45,10 @@ export class NoiseBurstPlayer {
|
||||||
filter.connect(envelope);
|
filter.connect(envelope);
|
||||||
envelope.connect(panner);
|
envelope.connect(panner);
|
||||||
panner.connect(noiseBus);
|
panner.connect(noiseBus);
|
||||||
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
|
const maxOffsetSeconds = Math.max(0, noiseBuffer.duration - durationSeconds);
|
||||||
|
const offsetSeconds =
|
||||||
|
Math.random() * Math.min(noiseBurstTuning.offsetRandomSeconds, maxOffsetSeconds);
|
||||||
|
source.start(scheduledStart, offsetSeconds);
|
||||||
source.stop(stopAt);
|
source.stop(stopAt);
|
||||||
source.addEventListener(
|
source.addEventListener(
|
||||||
'ended',
|
'ended',
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,16 @@ interface ActivePianoVoice {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pianoSamplerTuning = {
|
const pianoSamplerTuning = {
|
||||||
filterType: 'lowpass' as BiquadFilterType,
|
filterType: 'lowpass',
|
||||||
filterQ: 0.7,
|
filterQ: 0.7,
|
||||||
minDurationSeconds: 0.08,
|
minDurationSeconds: 0.08,
|
||||||
minFadeSeconds: 0.08,
|
minFadeSeconds: 0.08,
|
||||||
minGain: 0.0001,
|
minGain: 0.0001,
|
||||||
|
releaseTimeConstantCount: 5,
|
||||||
tailStopExtraSeconds: 0.05,
|
tailStopExtraSeconds: 0.05,
|
||||||
voiceStealFadeSeconds: 0.025,
|
voiceStealFadeSeconds: 0.025,
|
||||||
voiceStealStopSeconds: 0.05,
|
voiceStealStopSeconds: 0.05,
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export class PianoSampler {
|
export class PianoSampler {
|
||||||
private samples: Array<LoadedPianoSample> = [];
|
private samples: Array<LoadedPianoSample> = [];
|
||||||
|
|
@ -84,7 +85,9 @@ export class PianoSampler {
|
||||||
const sustainAt =
|
const sustainAt =
|
||||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||||
const releaseAt = sustainAt + sustainSeconds;
|
const releaseAt = sustainAt + sustainSeconds;
|
||||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
const stopAt =
|
||||||
|
releaseAt +
|
||||||
|
this.config.piano.releaseSeconds * pianoSamplerTuning.releaseTimeConstantCount;
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
|
|
||||||
source.buffer = sample.buffer;
|
source.buffer = sample.buffer;
|
||||||
|
|
@ -178,7 +181,11 @@ export class PianoSampler {
|
||||||
|
|
||||||
this.trimActiveVoices(scheduledStart);
|
this.trimActiveVoices(scheduledStart);
|
||||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||||
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
|
const oldest = this.activeVoices.shift();
|
||||||
|
if (!oldest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.stopVoice(oldest, scheduledStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.type = pianoSamplerTuning.filterType;
|
filter.type = pianoSamplerTuning.filterType;
|
||||||
|
|
@ -220,11 +227,8 @@ export class PianoSampler {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeNoteGain(velocity: number, scale = 1): number {
|
private computeNoteGain(velocity: number): number {
|
||||||
return Math.max(
|
return Math.max(pianoSamplerTuning.minGain, this.config.piano.gain * velocity);
|
||||||
pianoSamplerTuning.minGain,
|
|
||||||
this.config.piano.gain * velocity * scale
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||||
|
|
|
||||||
|
|
@ -31,51 +31,56 @@ import fSharp6SampleUrl from './samples/Fsharp6v12.m4a?url&no-inline';
|
||||||
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
|
import fSharp7SampleUrl from './samples/Fsharp7v12.m4a?url&no-inline';
|
||||||
|
|
||||||
interface PianoSampleDefinition {
|
interface PianoSampleDefinition {
|
||||||
midi: number;
|
note: string;
|
||||||
path: string;
|
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PianoSampleLoadProgress {
|
export interface PianoSampleLoadProgress {
|
||||||
|
failedCount: number;
|
||||||
loadedCount: number;
|
loadedCount: number;
|
||||||
|
settledCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
const pianoSampleDefinitions: Array<PianoSampleDefinition> = [
|
||||||
{ url: a0SampleUrl, path: './samples/A0v12.m4a', midi: 21 },
|
{ url: a0SampleUrl, note: 'A0' },
|
||||||
{ url: c1SampleUrl, path: './samples/C1v12.m4a', midi: 24 },
|
{ url: c1SampleUrl, note: 'C1' },
|
||||||
{ url: dSharp1SampleUrl, path: './samples/Dsharp1v12.m4a', midi: 27 },
|
{ url: dSharp1SampleUrl, note: 'Dsharp1' },
|
||||||
{ url: fSharp1SampleUrl, path: './samples/Fsharp1v12.m4a', midi: 30 },
|
{ url: fSharp1SampleUrl, note: 'Fsharp1' },
|
||||||
{ url: a1SampleUrl, path: './samples/A1v12.m4a', midi: 33 },
|
{ url: a1SampleUrl, note: 'A1' },
|
||||||
{ url: c2SampleUrl, path: './samples/C2v12.m4a', midi: 36 },
|
{ url: c2SampleUrl, note: 'C2' },
|
||||||
{ url: dSharp2SampleUrl, path: './samples/Dsharp2v12.m4a', midi: 39 },
|
{ url: dSharp2SampleUrl, note: 'Dsharp2' },
|
||||||
{ url: fSharp2SampleUrl, path: './samples/Fsharp2v12.m4a', midi: 42 },
|
{ url: fSharp2SampleUrl, note: 'Fsharp2' },
|
||||||
{ url: a2SampleUrl, path: './samples/A2v12.m4a', midi: 45 },
|
{ url: a2SampleUrl, note: 'A2' },
|
||||||
{ url: c3SampleUrl, path: './samples/C3v12.m4a', midi: 48 },
|
{ url: c3SampleUrl, note: 'C3' },
|
||||||
{ url: dSharp3SampleUrl, path: './samples/Dsharp3v12.m4a', midi: 51 },
|
{ url: dSharp3SampleUrl, note: 'Dsharp3' },
|
||||||
{ url: fSharp3SampleUrl, path: './samples/Fsharp3v12.m4a', midi: 54 },
|
{ url: fSharp3SampleUrl, note: 'Fsharp3' },
|
||||||
{ url: a3SampleUrl, path: './samples/A3v12.m4a', midi: 57 },
|
{ url: a3SampleUrl, note: 'A3' },
|
||||||
{ url: c4SampleUrl, path: './samples/C4v12.m4a', midi: 60 },
|
{ url: c4SampleUrl, note: 'C4' },
|
||||||
{ url: dSharp4SampleUrl, path: './samples/Dsharp4v12.m4a', midi: 63 },
|
{ url: dSharp4SampleUrl, note: 'Dsharp4' },
|
||||||
{ url: fSharp4SampleUrl, path: './samples/Fsharp4v12.m4a', midi: 66 },
|
{ url: fSharp4SampleUrl, note: 'Fsharp4' },
|
||||||
{ url: a4SampleUrl, path: './samples/A4v12.m4a', midi: 69 },
|
{ url: a4SampleUrl, note: 'A4' },
|
||||||
{ url: c5SampleUrl, path: './samples/C5v12.m4a', midi: 72 },
|
{ url: c5SampleUrl, note: 'C5' },
|
||||||
{ url: dSharp5SampleUrl, path: './samples/Dsharp5v12.m4a', midi: 75 },
|
{ url: dSharp5SampleUrl, note: 'Dsharp5' },
|
||||||
{ url: fSharp5SampleUrl, path: './samples/Fsharp5v12.m4a', midi: 78 },
|
{ url: fSharp5SampleUrl, note: 'Fsharp5' },
|
||||||
{ url: a5SampleUrl, path: './samples/A5v12.m4a', midi: 81 },
|
{ url: a5SampleUrl, note: 'A5' },
|
||||||
{ url: c6SampleUrl, path: './samples/C6v12.m4a', midi: 84 },
|
{ url: c6SampleUrl, note: 'C6' },
|
||||||
{ url: dSharp6SampleUrl, path: './samples/Dsharp6v12.m4a', midi: 87 },
|
{ url: dSharp6SampleUrl, note: 'Dsharp6' },
|
||||||
{ url: fSharp6SampleUrl, path: './samples/Fsharp6v12.m4a', midi: 90 },
|
{ url: fSharp6SampleUrl, note: 'Fsharp6' },
|
||||||
{ url: a6SampleUrl, path: './samples/A6v12.m4a', midi: 93 },
|
{ url: a6SampleUrl, note: 'A6' },
|
||||||
{ url: c7SampleUrl, path: './samples/C7v12.m4a', midi: 96 },
|
{ url: c7SampleUrl, note: 'C7' },
|
||||||
{ url: dSharp7SampleUrl, path: './samples/Dsharp7v12.m4a', midi: 99 },
|
{ url: dSharp7SampleUrl, note: 'Dsharp7' },
|
||||||
{ url: fSharp7SampleUrl, path: './samples/Fsharp7v12.m4a', midi: 102 },
|
{ url: fSharp7SampleUrl, note: 'Fsharp7' },
|
||||||
{ url: a7SampleUrl, path: './samples/A7v12.m4a', midi: 105 },
|
{ url: a7SampleUrl, note: 'A7' },
|
||||||
{ url: c8SampleUrl, path: './samples/C8v12.m4a', midi: 108 },
|
{ url: c8SampleUrl, note: 'C8' },
|
||||||
];
|
];
|
||||||
|
|
||||||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||||
|
let lastPianoSampleProgress: PianoSampleLoadProgress | null = null;
|
||||||
|
const pianoSampleProgressListeners = new Set<
|
||||||
|
(progress: PianoSampleLoadProgress) => void
|
||||||
|
>();
|
||||||
|
|
||||||
const sampleLoadTuning = {
|
const sampleLoadTuning = {
|
||||||
concurrency: 4,
|
concurrency: 4,
|
||||||
|
|
@ -102,50 +107,65 @@ export const loadPianoSamples = (
|
||||||
decodeContext: BaseAudioContext,
|
decodeContext: BaseAudioContext,
|
||||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||||
): Promise<Array<LoadedPianoSample>> => {
|
): Promise<Array<LoadedPianoSample>> => {
|
||||||
|
const unsubscribeProgress = subscribeToPianoSampleProgress(onProgress);
|
||||||
|
|
||||||
if (loadedPianoSamples) {
|
if (loadedPianoSamples) {
|
||||||
onProgress?.({
|
emitPianoSampleProgress({
|
||||||
|
failedCount: 0,
|
||||||
loadedCount: loadedPianoSamples.length,
|
loadedCount: loadedPianoSamples.length,
|
||||||
|
settledCount: loadedPianoSamples.length,
|
||||||
totalCount: pianoSampleDefinitions.length,
|
totalCount: pianoSampleDefinitions.length,
|
||||||
});
|
});
|
||||||
|
unsubscribeProgress();
|
||||||
return Promise.resolve([...loadedPianoSamples]);
|
return Promise.resolve([...loadedPianoSamples]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pianoSampleLoadPromise) {
|
if (pianoSampleLoadPromise) {
|
||||||
return pianoSampleLoadPromise;
|
return pianoSampleLoadPromise.finally(unsubscribeProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
let loadedCount = 0;
|
let loadedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
let settledCount = 0;
|
||||||
const totalCount = pianoSampleDefinitions.length;
|
const totalCount = pianoSampleDefinitions.length;
|
||||||
onProgress?.({ loadedCount, totalCount });
|
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||||
|
|
||||||
pianoSampleLoadPromise = loadPianoSampleBatch(
|
pianoSampleLoadPromise = loadPianoSampleBatch(
|
||||||
pianoSampleDefinitions,
|
pianoSampleDefinitions,
|
||||||
async (sample) => {
|
async (sample) => {
|
||||||
try {
|
try {
|
||||||
return await withTimeout(
|
const loadedSample = await withTimeout(
|
||||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||||
sampleLoadTuning.sampleTimeoutMs
|
sampleLoadTuning.sampleTimeoutMs
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
loadedCount += 1;
|
loadedCount += 1;
|
||||||
onProgress?.({ loadedCount, totalCount });
|
return loadedSample;
|
||||||
|
} catch (error) {
|
||||||
|
failedCount += 1;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
settledCount += 1;
|
||||||
|
emitPianoSampleProgress({ failedCount, loadedCount, settledCount, totalCount });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
).then(
|
)
|
||||||
(samples) => {
|
.then(
|
||||||
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
(samples) => {
|
||||||
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
loadedPianoSamples = samples.sort((a, b) => a.midi - b.midi);
|
||||||
throw new Error(
|
if (loadedPianoSamples.length !== pianoSampleDefinitions.length) {
|
||||||
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
throw new Error(
|
||||||
);
|
`Loaded ${loadedPianoSamples.length}/${pianoSampleDefinitions.length} piano samples.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...loadedPianoSamples];
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
pianoSampleLoadPromise = null;
|
||||||
|
pianoSampleProgressListeners.clear();
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return [...loadedPianoSamples];
|
)
|
||||||
},
|
.finally(unsubscribeProgress);
|
||||||
(error: unknown) => {
|
|
||||||
pianoSampleLoadPromise = null;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return pianoSampleLoadPromise;
|
return pianoSampleLoadPromise;
|
||||||
};
|
};
|
||||||
|
|
@ -160,12 +180,12 @@ const loadPianoSample = async (
|
||||||
): Promise<LoadedPianoSample> => {
|
): Promise<LoadedPianoSample> => {
|
||||||
const response = await fetch(sample.url, { signal });
|
const response = await fetch(sample.url, { signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Unable to load piano sample ${sample.path}`);
|
throw new Error(`Unable to load piano sample ${getPianoSamplePath(sample)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioData = await response.arrayBuffer();
|
const audioData = await response.arrayBuffer();
|
||||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||||
return { midi: sample.midi, buffer };
|
return { midi: getMidiForPianoSample(sample), buffer };
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPianoSampleBatch = async (
|
const loadPianoSampleBatch = async (
|
||||||
|
|
@ -205,3 +225,47 @@ const withTimeout = <T>(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const subscribeToPianoSampleProgress = (
|
||||||
|
onProgress: ((progress: PianoSampleLoadProgress) => void) | undefined
|
||||||
|
): (() => void) => {
|
||||||
|
if (!onProgress) {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
pianoSampleProgressListeners.add(onProgress);
|
||||||
|
if (lastPianoSampleProgress) {
|
||||||
|
onProgress(lastPianoSampleProgress);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
pianoSampleProgressListeners.delete(onProgress);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitPianoSampleProgress = (progress: PianoSampleLoadProgress): void => {
|
||||||
|
lastPianoSampleProgress = progress;
|
||||||
|
pianoSampleProgressListeners.forEach((listener) => listener(progress));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPianoSamplePath = (sample: PianoSampleDefinition): string =>
|
||||||
|
`./samples/${sample.note}v12.m4a`;
|
||||||
|
|
||||||
|
const getMidiForPianoSample = (sample: PianoSampleDefinition): number => {
|
||||||
|
const match = /^(?<name>[A-G])(?<accidental>sharp)?(?<octave>\d+)$/.exec(sample.note);
|
||||||
|
if (!match?.groups) {
|
||||||
|
throw new Error(`Invalid piano sample note ${sample.note}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const semitoneByName: Record<string, number> = {
|
||||||
|
C: 0,
|
||||||
|
D: 2,
|
||||||
|
E: 4,
|
||||||
|
F: 5,
|
||||||
|
G: 7,
|
||||||
|
A: 9,
|
||||||
|
B: 11,
|
||||||
|
};
|
||||||
|
const octave = Number(match.groups.octave);
|
||||||
|
const semitone = semitoneByName[match.groups.name] + (match.groups.accidental ? 1 : 0);
|
||||||
|
return (octave + 1) * 12 + semitone;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,12 @@ under CC BY 3.0.
|
||||||
Source package: @audio-samples/piano-velocity12
|
Source package: @audio-samples/piano-velocity12
|
||||||
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
Source recording: https://archive.org/details/SalamanderGrandPianoV3
|
||||||
License: https://creativecommons.org/licenses/by/3.0/
|
License: https://creativecommons.org/licenses/by/3.0/
|
||||||
|
|
||||||
|
Checked-in subset: velocity layer `v12`, every minor-third anchor from A0
|
||||||
|
through C8: A, C, Dsharp, and Fsharp for octaves 1-7, plus A0, A7, and C8.
|
||||||
|
The app derives MIDI values from those note names in `piano-samples.ts`.
|
||||||
|
|
||||||
|
Repro notes: start from the matching `v12` OGG files in the source package and
|
||||||
|
transcode each selected sample to AAC/M4A without renaming the note/velocity
|
||||||
|
stem. The expected output filenames are `<note>v12.m4a`, for example
|
||||||
|
`C4v12.m4a`.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { createGardenAudioConfig } from './audio/garden-audio-config';
|
import {
|
||||||
|
createGardenAudioConfig,
|
||||||
|
DEFAULT_AUDIO_VOLUME,
|
||||||
|
} from './audio/garden-audio-config';
|
||||||
import { defaultSettings } from './config/default-settings';
|
import { defaultSettings } from './config/default-settings';
|
||||||
import { runtimeControls } from './config/runtime-controls';
|
import { runtimeControls } from './config/runtime-controls';
|
||||||
import type { GardenAppConfig } from './config/types';
|
import type { GardenAppConfig } from './config/types';
|
||||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
normalizeNumberControlValue,
|
normalizeNumberControlValue,
|
||||||
|
|
@ -18,6 +20,12 @@ export type {
|
||||||
|
|
||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
audio: createGardenAudioConfig(),
|
audio: createGardenAudioConfig(),
|
||||||
|
analytics: {
|
||||||
|
autoCapturePageviews: true,
|
||||||
|
domain: 'fleeting.garden',
|
||||||
|
endpoint: 'https://stats.schmelczer.dev/status',
|
||||||
|
logging: import.meta.env.DEV,
|
||||||
|
},
|
||||||
deltaTime: {
|
deltaTime: {
|
||||||
maxDeltaTimeSeconds: 1 / 30,
|
maxDeltaTimeSeconds: 1 / 30,
|
||||||
minDeltaTimeSeconds: 1 / 240,
|
minDeltaTimeSeconds: 1 / 240,
|
||||||
|
|
@ -84,13 +92,14 @@ export const appConfig = {
|
||||||
letterSpacingEm: 0.07,
|
letterSpacingEm: 0.07,
|
||||||
maskAlphaThreshold: 32,
|
maskAlphaThreshold: 32,
|
||||||
maskGradientThreshold: 8,
|
maskGradientThreshold: 8,
|
||||||
|
maskMaxPixels: 1_000_000,
|
||||||
maskSampleDensity: 540,
|
maskSampleDensity: 540,
|
||||||
maxHeightRatio: 0.25,
|
maxHeightRatio: 0.25,
|
||||||
maxWidthRatio: 0.76,
|
maxWidthRatio: 0.76,
|
||||||
minEntryJitterPx: 6,
|
minEntryJitterPx: 6,
|
||||||
minFontSizePx: 18,
|
minFontSizePx: 18,
|
||||||
minTargetJitterPx: 1,
|
minTargetJitterPx: 1,
|
||||||
pathEasing: 'easeOutQuad' as GardenAppConfig['simulation']['intro']['pathEasing'],
|
pathEasing: 'easeOutQuad',
|
||||||
pathProgressEpsilon: 0.001,
|
pathProgressEpsilon: 0.001,
|
||||||
radialJitterRatio: 0.35,
|
radialJitterRatio: 0.35,
|
||||||
radialStartEpsilon: 0.001,
|
radialStartEpsilon: 0.001,
|
||||||
|
|
@ -107,29 +116,28 @@ export const appConfig = {
|
||||||
titleStrokeWidthRatio: 0.11,
|
titleStrokeWidthRatio: 0.11,
|
||||||
verticalAnchor: 0.47,
|
verticalAnchor: 0.47,
|
||||||
},
|
},
|
||||||
introMoveSpeedBaseMultiplier: 1.8,
|
introMoveSpeed: 280,
|
||||||
introMoveSpeedProgressMultiplier: 0.35,
|
|
||||||
stroke: {
|
stroke: {
|
||||||
densityMultiplier: 110,
|
densityMultiplier: 110,
|
||||||
maxAgentCount: 2_400,
|
maxAgentCount: 2_400,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
audioMutedKey: APP_STORAGE_KEYS.audioMuted,
|
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||||
audioVolumeKey: APP_STORAGE_KEYS.audioVolume,
|
audioVolumeKey: 'fleeting-garden:audio-volume',
|
||||||
vibeKey: APP_STORAGE_KEYS.vibe,
|
vibeKey: 'fleeting-garden:vibe',
|
||||||
},
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
eraser: {
|
eraser: {
|
||||||
controlScaleMax: 1.34,
|
controlScaleMax: 1.34,
|
||||||
controlScaleMin: 0.74,
|
controlScaleMin: 0.74,
|
||||||
default: 96,
|
default: 96,
|
||||||
max: 240,
|
max: 480,
|
||||||
min: 24,
|
min: 24,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
mirror: {
|
mirror: {
|
||||||
default: 1,
|
default: 8,
|
||||||
fallbackSegmentName: 'slices',
|
fallbackSegmentName: 'slices',
|
||||||
max: 12,
|
max: 12,
|
||||||
min: 1,
|
min: 1,
|
||||||
|
|
|
||||||
27
src/config/brush-size.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
|
||||||
|
getBrushRenderQualityScale,
|
||||||
|
getRenderQualityBrushSize,
|
||||||
|
} from './brush-size';
|
||||||
|
|
||||||
|
describe('render-quality brush sizing', () => {
|
||||||
|
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
|
||||||
|
expect(
|
||||||
|
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
|
||||||
|
).toBe(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales linear brush size with the square root of render area', () => {
|
||||||
|
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
|
||||||
|
|
||||||
|
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
|
||||||
|
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to baseline scaling for invalid render areas', () => {
|
||||||
|
expect(getBrushRenderQualityScale(0)).toBe(1);
|
||||||
|
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/config/brush-size.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
|
||||||
|
|
||||||
|
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
|
||||||
|
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
|
||||||
|
? renderAreaMegapixels
|
||||||
|
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
|
||||||
|
|
||||||
|
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
|
||||||
|
Math.sqrt(
|
||||||
|
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
|
||||||
|
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getRenderQualityBrushSize = (
|
||||||
|
brushSize: number,
|
||||||
|
renderAreaMegapixels: number
|
||||||
|
): number =>
|
||||||
|
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
|
||||||
|
getBrushRenderQualityScale(renderAreaMegapixels);
|
||||||
|
|
@ -1,17 +1,5 @@
|
||||||
import type { NumberControlConfig } from './types';
|
import type { NumberControlConfig } from './types';
|
||||||
|
|
||||||
export const colorInteractionSettings = {
|
|
||||||
color1ToColor1: 1,
|
|
||||||
color1ToColor2: 0,
|
|
||||||
color1ToColor3: 0,
|
|
||||||
color2ToColor1: 0,
|
|
||||||
color2ToColor2: 1,
|
|
||||||
color2ToColor3: 0,
|
|
||||||
color3ToColor1: 0,
|
|
||||||
color3ToColor2: 0,
|
|
||||||
color3ToColor3: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||||
folder: 'Color Reactions',
|
folder: 'Color Reactions',
|
||||||
label,
|
label,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import { colorInteractionSettings } from './color-interactions';
|
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||||
import { runtimeControls } from './runtime-controls';
|
|
||||||
import type { GardenAppConfig } from './types';
|
import type { GardenAppConfig } from './types';
|
||||||
|
|
||||||
// Mirrors the historical render-scale cap so the default render area stays
|
// Mirrors the historical render-scale cap so the default render area stays
|
||||||
// roughly equivalent to native rendering on high-DPR phones without the
|
// roughly equivalent to native rendering on high-DPR phones without the
|
||||||
// pipeline applying its own clamp. The slider can override freely.
|
// pipeline applying its own clamp. The slider can override freely.
|
||||||
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
||||||
const INTERNAL_RENDER_AREA_BOUNDS = {
|
|
||||||
min: runtimeControls.internalRenderAreaMegapixels?.min ?? 0.5,
|
|
||||||
max: runtimeControls.internalRenderAreaMegapixels?.max ?? 16.6,
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||||
const rawDpr =
|
const rawDpr =
|
||||||
|
|
@ -21,18 +16,14 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||||
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||||
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||||
return Math.min(
|
return Math.min(
|
||||||
INTERNAL_RENDER_AREA_BOUNDS.max,
|
INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||||
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)
|
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
...colorInteractionSettings,
|
|
||||||
selectedColorIndex: 0,
|
selectedColorIndex: 0,
|
||||||
|
|
||||||
turnWhenLost: 0.8,
|
|
||||||
forwardRotationScale: 0.25,
|
|
||||||
sensorOffsetAngle: 32,
|
|
||||||
introNearDistanceMin: 28,
|
introNearDistanceMin: 28,
|
||||||
introNearDistanceInner: 4,
|
introNearDistanceInner: 4,
|
||||||
introNearSensorOffsetMultiplier: 0.75,
|
introNearSensorOffsetMultiplier: 0.75,
|
||||||
|
|
@ -40,8 +31,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
introProgressCutoff: 0.999,
|
introProgressCutoff: 0.999,
|
||||||
introTurnRateMultiplier: 3.4,
|
introTurnRateMultiplier: 3.4,
|
||||||
introRandomTurnMultiplier: 0.18,
|
introRandomTurnMultiplier: 0.18,
|
||||||
introFarMoveMultiplier: 2.65,
|
|
||||||
introNearMoveMultiplier: 0.01,
|
|
||||||
introStepStopDistance: 0.5,
|
introStepStopDistance: 0.5,
|
||||||
randomTimeScale: 0.34816,
|
randomTimeScale: 0.34816,
|
||||||
|
|
||||||
|
|
@ -58,7 +47,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
brushCurveMirrorResolutionExponent: 0.5,
|
brushCurveMirrorResolutionExponent: 0.5,
|
||||||
brushCurveSegmentBrushRadiusRatio: 0.65,
|
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||||
brushSmoothingMinSampleDistance: 0.5,
|
brushSmoothingMinSampleDistance: 0.5,
|
||||||
strokeAngleJitterRadians: Math.PI * 0.7,
|
|
||||||
|
|
||||||
brushAlpha: 1,
|
brushAlpha: 1,
|
||||||
brushDiscardThreshold: 0.02,
|
brushDiscardThreshold: 0.02,
|
||||||
|
|
@ -78,7 +66,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
adaptiveCapInitial: 1_000_000,
|
adaptiveCapInitial: 1_000_000,
|
||||||
adaptiveCapMin: 50_000,
|
adaptiveCapMin: 50_000,
|
||||||
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
||||||
maxAgentCount: 700_000,
|
maxAgentCount: 1_500_000,
|
||||||
|
|
||||||
renderTraceNormalizationFloor: 1,
|
renderTraceNormalizationFloor: 1,
|
||||||
renderBrushColorBase: 1.2,
|
renderBrushColorBase: 1.2,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import { colorInteractionControl } from './color-interactions';
|
import { colorInteractionControl } from './color-interactions';
|
||||||
|
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||||
import type { GardenAppConfig } from './types';
|
import type { GardenAppConfig } from './types';
|
||||||
|
|
||||||
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||||
const formatRadiansAsDegrees = (value: number): string =>
|
const formatRadiansAsDegrees = (value: number): string =>
|
||||||
`${Math.round((value * 180) / Math.PI)} deg`;
|
`${Math.round((value * 180) / Math.PI)} deg`;
|
||||||
|
const formatCompactNumber = (value: number): string => {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
const millions = value / 1_000_000;
|
||||||
|
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${Math.round(value / 1_000)}k`;
|
||||||
|
}
|
||||||
|
return `${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||||
|
|
@ -20,14 +31,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
folder: 'Brush',
|
folder: 'Brush',
|
||||||
label: 'Brush Size',
|
label: 'Brush Size',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 60,
|
max: 36,
|
||||||
step: 0.25,
|
step: 0.25,
|
||||||
},
|
},
|
||||||
spawnPerPixel: {
|
spawnPerPixel: {
|
||||||
folder: 'Brush',
|
folder: 'Brush',
|
||||||
label: 'Density',
|
label: 'Density',
|
||||||
min: 0.01,
|
min: 0.01,
|
||||||
max: 1,
|
max: 0.38,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
strokeAngleJitterRadians: {
|
strokeAngleJitterRadians: {
|
||||||
|
|
@ -35,7 +46,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
format: formatRadiansAsDegrees,
|
format: formatRadiansAsDegrees,
|
||||||
label: 'Spawn Spread',
|
label: 'Spawn Spread',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: Math.PI * 2,
|
max: Math.PI,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
},
|
},
|
||||||
sensorOffsetDistance: {
|
sensorOffsetDistance: {
|
||||||
|
|
@ -45,6 +56,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 200,
|
max: 200,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
|
sensorOffsetAngle: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Sensor Angle',
|
||||||
|
min: 0,
|
||||||
|
max: 180,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
moveSpeed: {
|
moveSpeed: {
|
||||||
folder: 'Movement',
|
folder: 'Movement',
|
||||||
label: 'Travel Speed',
|
label: 'Travel Speed',
|
||||||
|
|
@ -71,7 +89,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
folder: 'Movement',
|
folder: 'Movement',
|
||||||
label: 'Wander Turn',
|
label: 'Wander Turn',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 6.28,
|
max: Math.PI * 2,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
},
|
},
|
||||||
individualTrailWeight: {
|
individualTrailWeight: {
|
||||||
|
|
@ -81,6 +99,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
|
diffusionRateTrails: {
|
||||||
|
folder: 'Movement',
|
||||||
|
label: 'Diffusion Rate',
|
||||||
|
min: 0.01,
|
||||||
|
max: 1,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
decayRateTrails: {
|
decayRateTrails: {
|
||||||
folder: 'Movement',
|
folder: 'Movement',
|
||||||
label: 'Trail Fade',
|
label: 'Trail Fade',
|
||||||
|
|
@ -106,6 +131,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
|
|
||||||
maxAgentCount: {
|
maxAgentCount: {
|
||||||
folder: 'Performance',
|
folder: 'Performance',
|
||||||
|
format: formatCompactNumber,
|
||||||
integer: true,
|
integer: true,
|
||||||
label: 'Population Limit',
|
label: 'Population Limit',
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|
@ -114,8 +140,8 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
internalRenderAreaMegapixels: {
|
internalRenderAreaMegapixels: {
|
||||||
folder: 'Performance',
|
folder: 'Performance',
|
||||||
label: 'Render Quality (MP)',
|
label: 'Render Quality (MP)',
|
||||||
min: 0.5,
|
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
|
||||||
max: 16.6,
|
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,17 +52,30 @@ type RuntimeSettingControlConfig = Partial<
|
||||||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type GardenVibeSettings = Pick<
|
export type GardenVibeSettings = Pick<
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
| 'backgroundGrainStrength'
|
| 'backgroundGrainStrength'
|
||||||
| 'brushSize'
|
| 'brushSize'
|
||||||
| 'clarity'
|
| 'clarity'
|
||||||
|
| 'color1ToColor1'
|
||||||
|
| 'color1ToColor2'
|
||||||
|
| 'color1ToColor3'
|
||||||
|
| 'color2ToColor1'
|
||||||
|
| 'color2ToColor2'
|
||||||
|
| 'color2ToColor3'
|
||||||
|
| 'color3ToColor1'
|
||||||
|
| 'color3ToColor2'
|
||||||
|
| 'color3ToColor3'
|
||||||
| 'decayRateTrails'
|
| 'decayRateTrails'
|
||||||
|
| 'forwardRotationScale'
|
||||||
| 'individualTrailWeight'
|
| 'individualTrailWeight'
|
||||||
| 'moveSpeed'
|
| 'moveSpeed'
|
||||||
|
| 'sensorOffsetAngle'
|
||||||
| 'sensorOffsetDistance'
|
| 'sensorOffsetDistance'
|
||||||
| 'spawnPerPixel'
|
| 'spawnPerPixel'
|
||||||
|
| 'strokeAngleJitterRadians'
|
||||||
| 'turnSpeed'
|
| 'turnSpeed'
|
||||||
|
| 'turnWhenLost'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type GardenDefaultSettings = Omit<
|
type GardenDefaultSettings = Omit<
|
||||||
|
|
@ -72,10 +85,8 @@ type GardenDefaultSettings = Omit<
|
||||||
|
|
||||||
export enum VibeId {
|
export enum VibeId {
|
||||||
AuroraMycelium = 'aurora-mycelium',
|
AuroraMycelium = 'aurora-mycelium',
|
||||||
EmberCircuit = 'ember-circuit',
|
|
||||||
VelvetObservatory = 'velvet-observatory',
|
VelvetObservatory = 'velvet-observatory',
|
||||||
LichenSignal = 'lichen-signal',
|
LichenSignal = 'lichen-signal',
|
||||||
UltravioletSiren = 'ultraviolet-siren',
|
|
||||||
TidepoolLantern = 'tidepool-lantern',
|
TidepoolLantern = 'tidepool-lantern',
|
||||||
PaperLanternFog = 'paper-lantern-fog',
|
PaperLanternFog = 'paper-lantern-fog',
|
||||||
ChromePollen = 'chrome-pollen',
|
ChromePollen = 'chrome-pollen',
|
||||||
|
|
@ -92,6 +103,12 @@ export interface VibePreset {
|
||||||
|
|
||||||
export interface GardenAppConfig {
|
export interface GardenAppConfig {
|
||||||
audio: GardenAudioConfig;
|
audio: GardenAudioConfig;
|
||||||
|
analytics: {
|
||||||
|
autoCapturePageviews: boolean;
|
||||||
|
domain: string;
|
||||||
|
endpoint: string;
|
||||||
|
logging: boolean;
|
||||||
|
};
|
||||||
deltaTime: {
|
deltaTime: {
|
||||||
maxDeltaTimeSeconds: number;
|
maxDeltaTimeSeconds: number;
|
||||||
minDeltaTimeSeconds: number;
|
minDeltaTimeSeconds: number;
|
||||||
|
|
@ -156,6 +173,7 @@ export interface GardenAppConfig {
|
||||||
letterSpacingEm: number;
|
letterSpacingEm: number;
|
||||||
maskAlphaThreshold: number;
|
maskAlphaThreshold: number;
|
||||||
maskGradientThreshold: number;
|
maskGradientThreshold: number;
|
||||||
|
maskMaxPixels: number;
|
||||||
maskSampleDensity: number;
|
maskSampleDensity: number;
|
||||||
maxHeightRatio: number;
|
maxHeightRatio: number;
|
||||||
maxWidthRatio: number;
|
maxWidthRatio: number;
|
||||||
|
|
@ -179,8 +197,7 @@ export interface GardenAppConfig {
|
||||||
titleStrokeWidthRatio: number;
|
titleStrokeWidthRatio: number;
|
||||||
verticalAnchor: number;
|
verticalAnchor: number;
|
||||||
};
|
};
|
||||||
introMoveSpeedBaseMultiplier: number;
|
introMoveSpeed: number;
|
||||||
introMoveSpeedProgressMultiplier: number;
|
|
||||||
stroke: {
|
stroke: {
|
||||||
densityMultiplier: number;
|
densityMultiplier: number;
|
||||||
maxAgentCount: number;
|
maxAgentCount: number;
|
||||||
|
|
|
||||||
81
src/config/vibe-presets.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { runtimeControls } from './runtime-controls';
|
||||||
|
import { vibePresets } from './vibe-presets';
|
||||||
|
|
||||||
|
const FINAL_VIBE_NAMES = [
|
||||||
|
'Aurora Mycelium Copy',
|
||||||
|
'Velvet Observatory Copy',
|
||||||
|
'Lichen Signal',
|
||||||
|
'Tidepool Lantern',
|
||||||
|
'Paper Lantern Fog',
|
||||||
|
'Chrome Pollen',
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLENDED_BRUSH_SIZE_MIN = 17;
|
||||||
|
const BLENDED_CLARITY_MAX = 0.56;
|
||||||
|
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
|
||||||
|
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
|
||||||
|
|
||||||
|
// Performance guardrails — bumping any of these is an explicit perf trade-off.
|
||||||
|
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
|
||||||
|
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
|
||||||
|
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
|
||||||
|
const HIGH_DENSITY_DECAY_LIMIT = 940;
|
||||||
|
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
|
||||||
|
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
|
||||||
|
|
||||||
|
describe('vibePresets', () => {
|
||||||
|
it('keeps the classic preset set distinct', () => {
|
||||||
|
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
|
||||||
|
|
||||||
|
const ids = vibePresets.map((preset) => preset.id);
|
||||||
|
expect(new Set(ids).size).toBe(vibePresets.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes both blended and visibly particulate styles', () => {
|
||||||
|
const blendedNames = vibePresets
|
||||||
|
.filter(
|
||||||
|
(preset) =>
|
||||||
|
preset.settings.brushSize >= BLENDED_BRUSH_SIZE_MIN &&
|
||||||
|
preset.settings.clarity <= BLENDED_CLARITY_MAX
|
||||||
|
)
|
||||||
|
.map((preset) => preset.name);
|
||||||
|
const softParticleNames = vibePresets
|
||||||
|
.filter(
|
||||||
|
(preset) =>
|
||||||
|
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
|
||||||
|
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
|
||||||
|
)
|
||||||
|
.map((preset) => preset.name);
|
||||||
|
|
||||||
|
expect(blendedNames).toEqual(['Tidepool Lantern']);
|
||||||
|
expect(softParticleNames).toEqual(['Chrome Pollen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays inside interactive performance guardrails', () => {
|
||||||
|
const violations = vibePresets.flatMap((preset) => {
|
||||||
|
const { name, settings } = preset;
|
||||||
|
const presetViolations: Array<string> = [];
|
||||||
|
|
||||||
|
if (settings.spawnPerPixel > MAX_SPAWN_PER_PIXEL) {
|
||||||
|
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
|
||||||
|
}
|
||||||
|
if (settings.brushSize > MAX_BRUSH_SIZE) {
|
||||||
|
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
|
||||||
|
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
|
||||||
|
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
|
||||||
|
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
|
||||||
|
) {
|
||||||
|
presetViolations.push(`${name} combines high density with too much persistence`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return presetViolations;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,162 +1,252 @@
|
||||||
import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config';
|
import {
|
||||||
import { VibeId, type VibePreset } from './types';
|
defaultGardenAudioVibeSettings,
|
||||||
|
type GardenAudioChord,
|
||||||
|
} from '../audio/garden-audio-config';
|
||||||
|
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
|
||||||
|
|
||||||
|
type ColorReactionSettings = Pick<
|
||||||
|
GardenVibeSettings,
|
||||||
|
| 'color1ToColor1'
|
||||||
|
| 'color1ToColor2'
|
||||||
|
| 'color1ToColor3'
|
||||||
|
| 'color2ToColor1'
|
||||||
|
| 'color2ToColor2'
|
||||||
|
| 'color2ToColor3'
|
||||||
|
| 'color3ToColor1'
|
||||||
|
| 'color3ToColor2'
|
||||||
|
| 'color3ToColor3'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const colorReactions = {
|
||||||
|
auroraMycelium: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: 0,
|
||||||
|
color1ToColor3: 0,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: 0,
|
||||||
|
color3ToColor1: -1,
|
||||||
|
color3ToColor2: -1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
velvetObservatory: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: -1,
|
||||||
|
color1ToColor3: -1,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: -1,
|
||||||
|
color3ToColor1: -1,
|
||||||
|
color3ToColor2: -1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
lichenSignal: {
|
||||||
|
color1ToColor1: 0,
|
||||||
|
color1ToColor2: -1,
|
||||||
|
color1ToColor3: 1,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 0,
|
||||||
|
color2ToColor3: -1,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: -1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
tidepoolLantern: {
|
||||||
|
color1ToColor1: 0,
|
||||||
|
color1ToColor2: 1,
|
||||||
|
color1ToColor3: 0,
|
||||||
|
color2ToColor1: 0,
|
||||||
|
color2ToColor2: 0,
|
||||||
|
color2ToColor3: 1,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: 0,
|
||||||
|
color3ToColor3: 0,
|
||||||
|
},
|
||||||
|
paperLanternFog: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: 1,
|
||||||
|
color1ToColor3: 1,
|
||||||
|
color2ToColor1: 1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: 1,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: 1,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
chromePollen: {
|
||||||
|
color1ToColor1: 1,
|
||||||
|
color1ToColor2: 0,
|
||||||
|
color1ToColor3: 1,
|
||||||
|
color2ToColor1: -1,
|
||||||
|
color2ToColor2: 1,
|
||||||
|
color2ToColor3: 0,
|
||||||
|
color3ToColor1: 1,
|
||||||
|
color3ToColor2: 0,
|
||||||
|
color3ToColor3: 1,
|
||||||
|
},
|
||||||
|
} satisfies Record<string, ColorReactionSettings>;
|
||||||
|
|
||||||
|
const musicScales = {
|
||||||
|
dorian: [0, 2, 3, 5, 7, 9, 10],
|
||||||
|
lydian: [0, 2, 4, 6, 7, 9, 11],
|
||||||
|
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
||||||
|
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
|
||||||
|
} satisfies Record<string, Array<number>>;
|
||||||
|
|
||||||
|
const musicProgressions = {
|
||||||
|
aurora: [
|
||||||
|
{ rootOffset: 0, quality: 'sus2' },
|
||||||
|
{ rootOffset: 7, quality: 'major' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'sus4' },
|
||||||
|
],
|
||||||
|
chrome: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 2, quality: 'major' },
|
||||||
|
{ rootOffset: 7, quality: 'sus2' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
],
|
||||||
|
lichen: [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 5, quality: 'major' },
|
||||||
|
{ rootOffset: 10, quality: 'major' },
|
||||||
|
{ rootOffset: 3, quality: 'major' },
|
||||||
|
],
|
||||||
|
paperLantern: [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 8, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'minor' },
|
||||||
|
{ rootOffset: 10, quality: 'sus4' },
|
||||||
|
],
|
||||||
|
tidepool: [
|
||||||
|
{ rootOffset: 0, quality: 'major' },
|
||||||
|
{ rootOffset: 10, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'sus2' },
|
||||||
|
{ rootOffset: 9, quality: 'minor' },
|
||||||
|
],
|
||||||
|
velvet: [
|
||||||
|
{ rootOffset: 0, quality: 'minor' },
|
||||||
|
{ rootOffset: 8, quality: 'major' },
|
||||||
|
{ rootOffset: 3, quality: 'major' },
|
||||||
|
{ rootOffset: 5, quality: 'sus4' },
|
||||||
|
],
|
||||||
|
} satisfies Record<string, Array<GardenAudioChord>>;
|
||||||
|
|
||||||
export const defaultVibeId = VibeId.AuroraMycelium;
|
export const defaultVibeId = VibeId.AuroraMycelium;
|
||||||
|
|
||||||
export const vibePresets: Array<VibePreset> = [
|
export const vibePresets: Array<VibePreset> = [
|
||||||
{
|
{
|
||||||
id: VibeId.AuroraMycelium,
|
id: VibeId.AuroraMycelium,
|
||||||
name: 'Aurora Mycelium',
|
name: 'Aurora Mycelium Copy',
|
||||||
colors: [
|
colors: [
|
||||||
[78, 255, 176],
|
[251, 210, 94],
|
||||||
[154, 99, 255],
|
[154, 99, 255],
|
||||||
[169, 238, 255],
|
[255, 31, 199],
|
||||||
],
|
],
|
||||||
backgroundColor: [6, 13, 22],
|
backgroundColor: [6, 13, 22],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.016,
|
...colorReactions.auroraMycelium,
|
||||||
brushSize: 20,
|
backgroundGrainStrength: 0.003,
|
||||||
clarity: 0.52,
|
brushSize: 8.75,
|
||||||
decayRateTrails: 988,
|
clarity: 1,
|
||||||
individualTrailWeight: 0.085,
|
decayRateTrails: 973,
|
||||||
moveSpeed: 54,
|
forwardRotationScale: 0.37,
|
||||||
sensorOffsetDistance: 72,
|
individualTrailWeight: 0.053000000000000005,
|
||||||
spawnPerPixel: 0.13,
|
moveSpeed: 144,
|
||||||
turnSpeed: 35,
|
sensorOffsetAngle: 35,
|
||||||
|
sensorOffsetDistance: 52,
|
||||||
|
spawnPerPixel: 0.13999999999999999,
|
||||||
|
strokeAngleJitterRadians: 0.45,
|
||||||
|
turnSpeed: 13,
|
||||||
|
turnWhenLost: 0,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultGardenAudioVibeSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
idleIntensity: 0.12,
|
idleIntensity: 0.12000000000000002,
|
||||||
bpm: 60,
|
bpm: 60,
|
||||||
rampUpIntensity: 0.7,
|
rampUpIntensity: 0.7,
|
||||||
rampUpTime: 0.14,
|
rampUpTime: 0.14,
|
||||||
noteLength: 0.86,
|
noteLength: 0.8599999999999999,
|
||||||
notePitchOffset: -2,
|
notePitchOffset: -2,
|
||||||
brightness: 0.84,
|
brightness: 0.84,
|
||||||
},
|
scale: musicScales.lydian,
|
||||||
},
|
progression: musicProgressions.aurora,
|
||||||
{
|
|
||||||
id: VibeId.EmberCircuit,
|
|
||||||
name: 'Ember Circuit',
|
|
||||||
colors: [
|
|
||||||
[255, 95, 38],
|
|
||||||
[255, 43, 132],
|
|
||||||
[43, 219, 255],
|
|
||||||
],
|
|
||||||
backgroundColor: [17, 10, 8],
|
|
||||||
settings: {
|
|
||||||
backgroundGrainStrength: 0.03,
|
|
||||||
brushSize: 8,
|
|
||||||
clarity: 0.82,
|
|
||||||
decayRateTrails: 918,
|
|
||||||
individualTrailWeight: 0.04,
|
|
||||||
moveSpeed: 150,
|
|
||||||
sensorOffsetDistance: 24,
|
|
||||||
spawnPerPixel: 0.31,
|
|
||||||
turnSpeed: 130,
|
|
||||||
},
|
|
||||||
audio: {
|
|
||||||
...defaultGardenAudioVibeSettings,
|
|
||||||
idleIntensity: 0.03,
|
|
||||||
bpm: 124,
|
|
||||||
rampUpIntensity: 1.35,
|
|
||||||
rampUpTime: 0.04,
|
|
||||||
noteLength: 0.18,
|
|
||||||
notePitchOffset: 7,
|
|
||||||
brightness: 1.34,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.VelvetObservatory,
|
id: VibeId.VelvetObservatory,
|
||||||
name: 'Velvet Observatory',
|
name: 'Velvet Observatory Copy',
|
||||||
colors: [
|
colors: [
|
||||||
[72, 98, 255],
|
[178, 76, 62],
|
||||||
[255, 89, 176],
|
[2, 174, 255],
|
||||||
[235, 236, 255],
|
[213, 193, 9],
|
||||||
],
|
],
|
||||||
backgroundColor: [7, 8, 20],
|
backgroundColor: [7, 4, 22],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.01,
|
...colorReactions.velvetObservatory,
|
||||||
brushSize: 24,
|
backgroundGrainStrength: 0.005,
|
||||||
clarity: 0.45,
|
brushSize: 9.75,
|
||||||
decayRateTrails: 992,
|
clarity: 1,
|
||||||
individualTrailWeight: 0.095,
|
decayRateTrails: 974,
|
||||||
moveSpeed: 45,
|
forwardRotationScale: 0,
|
||||||
sensorOffsetDistance: 86,
|
individualTrailWeight: 0.232,
|
||||||
spawnPerPixel: 0.1,
|
moveSpeed: 121,
|
||||||
turnSpeed: 24,
|
sensorOffsetAngle: 24,
|
||||||
|
sensorOffsetDistance: 17,
|
||||||
|
spawnPerPixel: 0.11499999999999999,
|
||||||
|
strokeAngleJitterRadians: 0.17,
|
||||||
|
turnSpeed: 33,
|
||||||
|
turnWhenLost: 0.42,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultGardenAudioVibeSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
idleIntensity: 0.14,
|
idleIntensity: 0.24000000000000002,
|
||||||
bpm: 56,
|
bpm: 72,
|
||||||
rampUpIntensity: 0.6,
|
rampUpIntensity: 1.42,
|
||||||
rampUpTime: 0.16,
|
rampUpTime: 0.07,
|
||||||
noteLength: 1.15,
|
noteLength: 0.7,
|
||||||
notePitchOffset: -5,
|
notePitchOffset: 0,
|
||||||
brightness: 0.72,
|
brightness: 0.94,
|
||||||
|
scale: musicScales.naturalMinor,
|
||||||
|
progression: musicProgressions.velvet,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.LichenSignal,
|
id: VibeId.LichenSignal,
|
||||||
name: 'Lichen Signal',
|
name: 'Lichen Signal',
|
||||||
colors: [
|
colors: [
|
||||||
[174, 205, 91],
|
[183, 216, 92],
|
||||||
[71, 162, 126],
|
[65, 166, 128],
|
||||||
[229, 117, 71],
|
[238, 120, 76],
|
||||||
],
|
],
|
||||||
backgroundColor: [18, 24, 17],
|
backgroundColor: [0, 0, 0],
|
||||||
settings: {
|
|
||||||
backgroundGrainStrength: 0.028,
|
|
||||||
brushSize: 17,
|
|
||||||
clarity: 0.66,
|
|
||||||
decayRateTrails: 974,
|
|
||||||
individualTrailWeight: 0.065,
|
|
||||||
moveSpeed: 68,
|
|
||||||
sensorOffsetDistance: 52,
|
|
||||||
spawnPerPixel: 0.19,
|
|
||||||
turnSpeed: 38,
|
|
||||||
},
|
|
||||||
audio: {
|
|
||||||
...defaultGardenAudioVibeSettings,
|
|
||||||
idleIntensity: 0.1,
|
|
||||||
bpm: 68,
|
|
||||||
rampUpIntensity: 0.8,
|
|
||||||
rampUpTime: 0.1,
|
|
||||||
noteLength: 0.62,
|
|
||||||
notePitchOffset: -3,
|
|
||||||
brightness: 0.82,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: VibeId.UltravioletSiren,
|
|
||||||
name: 'Ultraviolet Siren',
|
|
||||||
colors: [
|
|
||||||
[184, 75, 255],
|
|
||||||
[0, 224, 255],
|
|
||||||
[214, 255, 72],
|
|
||||||
],
|
|
||||||
backgroundColor: [13, 9, 31],
|
|
||||||
settings: {
|
settings: {
|
||||||
|
...colorReactions.lichenSignal,
|
||||||
backgroundGrainStrength: 0.02,
|
backgroundGrainStrength: 0.02,
|
||||||
brushSize: 11,
|
brushSize: 6.5,
|
||||||
clarity: 0.72,
|
clarity: 0.74,
|
||||||
decayRateTrails: 946,
|
decayRateTrails: 962,
|
||||||
|
forwardRotationScale: 0.3,
|
||||||
individualTrailWeight: 0.052,
|
individualTrailWeight: 0.052,
|
||||||
moveSpeed: 118,
|
moveSpeed: 72,
|
||||||
sensorOffsetDistance: 30,
|
sensorOffsetAngle: 42,
|
||||||
spawnPerPixel: 0.28,
|
sensorOffsetDistance: 54,
|
||||||
turnSpeed: 96,
|
spawnPerPixel: 0.16,
|
||||||
|
strokeAngleJitterRadians: 3.14,
|
||||||
|
turnSpeed: 44,
|
||||||
|
turnWhenLost: 0.92,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultGardenAudioVibeSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
idleIntensity: 0.04,
|
idleIntensity: 0.13,
|
||||||
bpm: 112,
|
bpm: 68,
|
||||||
rampUpIntensity: 1.2,
|
rampUpIntensity: 1.46,
|
||||||
rampUpTime: 0.05,
|
rampUpTime: 0.1,
|
||||||
noteLength: 0.25,
|
noteLength: 0.6,
|
||||||
notePitchOffset: 5,
|
notePitchOffset: -3,
|
||||||
brightness: 1.22,
|
brightness: 1.21,
|
||||||
|
scale: musicScales.dorian,
|
||||||
|
progression: musicProgressions.lichen,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -167,89 +257,110 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
[61, 118, 255],
|
[61, 118, 255],
|
||||||
[255, 191, 91],
|
[255, 191, 91],
|
||||||
],
|
],
|
||||||
backgroundColor: [5, 20, 28],
|
backgroundColor: [4, 18, 29],
|
||||||
settings: {
|
settings: {
|
||||||
|
...colorReactions.tidepoolLantern,
|
||||||
backgroundGrainStrength: 0.018,
|
backgroundGrainStrength: 0.018,
|
||||||
brushSize: 15,
|
brushSize: 17,
|
||||||
clarity: 0.6,
|
clarity: 0.56,
|
||||||
decayRateTrails: 963,
|
decayRateTrails: 968,
|
||||||
individualTrailWeight: 0.058,
|
forwardRotationScale: 0.38,
|
||||||
|
individualTrailWeight: 0.06,
|
||||||
moveSpeed: 88,
|
moveSpeed: 88,
|
||||||
sensorOffsetDistance: 44,
|
sensorOffsetAngle: 64,
|
||||||
|
sensorOffsetDistance: 46,
|
||||||
spawnPerPixel: 0.22,
|
spawnPerPixel: 0.22,
|
||||||
turnSpeed: 60,
|
strokeAngleJitterRadians: 1.8,
|
||||||
|
turnSpeed: 66,
|
||||||
|
turnWhenLost: 1.05,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultGardenAudioVibeSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
idleIntensity: 0.08,
|
idleIntensity: 0.08,
|
||||||
bpm: 82,
|
bpm: 84,
|
||||||
rampUpIntensity: 0.95,
|
rampUpIntensity: 0.95,
|
||||||
rampUpTime: 0.08,
|
rampUpTime: 0.08,
|
||||||
noteLength: 0.48,
|
noteLength: 0.46,
|
||||||
notePitchOffset: 0,
|
notePitchOffset: 0,
|
||||||
brightness: 0.98,
|
brightness: 0.98,
|
||||||
|
scale: musicScales.mixolydian,
|
||||||
|
progression: musicProgressions.tidepool,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.PaperLanternFog,
|
id: VibeId.PaperLanternFog,
|
||||||
name: 'Paper Lantern Fog',
|
name: 'Paper Lantern Fog',
|
||||||
colors: [
|
colors: [
|
||||||
[255, 174, 104],
|
[255, 176, 108],
|
||||||
[242, 102, 107],
|
[239, 90, 108],
|
||||||
[132, 211, 185],
|
[128, 213, 184],
|
||||||
],
|
],
|
||||||
backgroundColor: [31, 23, 20],
|
backgroundColor: [30, 23, 20],
|
||||||
settings: {
|
settings: {
|
||||||
backgroundGrainStrength: 0.036,
|
...colorReactions.paperLanternFog,
|
||||||
brushSize: 22,
|
backgroundGrainStrength: 0.038,
|
||||||
clarity: 0.5,
|
brushSize: 3.5,
|
||||||
decayRateTrails: 984,
|
clarity: 1,
|
||||||
individualTrailWeight: 0.08,
|
decayRateTrails: 999,
|
||||||
moveSpeed: 56,
|
forwardRotationScale: 0.24,
|
||||||
sensorOffsetDistance: 64,
|
individualTrailWeight: 0.937,
|
||||||
spawnPerPixel: 0.14,
|
moveSpeed: 28,
|
||||||
turnSpeed: 32,
|
sensorOffsetAngle: 34,
|
||||||
|
sensorOffsetDistance: 66,
|
||||||
|
spawnPerPixel: 0.055,
|
||||||
|
strokeAngleJitterRadians: 0,
|
||||||
|
turnSpeed: 30,
|
||||||
|
turnWhenLost: 1.52,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultGardenAudioVibeSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
idleIntensity: 0.13,
|
idleIntensity: 0.33,
|
||||||
bpm: 64,
|
bpm: 127,
|
||||||
rampUpIntensity: 0.72,
|
rampUpIntensity: 0.66,
|
||||||
rampUpTime: 0.12,
|
rampUpTime: 0.03,
|
||||||
noteLength: 0.9,
|
noteLength: 0.92,
|
||||||
notePitchOffset: -4,
|
notePitchOffset: 10,
|
||||||
brightness: 0.76,
|
brightness: 1.42,
|
||||||
|
scale: musicScales.naturalMinor,
|
||||||
|
progression: musicProgressions.paperLantern,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: VibeId.ChromePollen,
|
id: VibeId.ChromePollen,
|
||||||
name: 'Chrome Pollen',
|
name: 'Chrome Pollen',
|
||||||
colors: [
|
colors: [
|
||||||
[235, 255, 238],
|
[178, 34, 34],
|
||||||
[255, 214, 48],
|
[255, 214, 48],
|
||||||
[77, 240, 157],
|
[77, 240, 157],
|
||||||
],
|
],
|
||||||
backgroundColor: [9, 13, 12],
|
backgroundColor: [7, 12, 11],
|
||||||
settings: {
|
settings: {
|
||||||
|
...colorReactions.chromePollen,
|
||||||
backgroundGrainStrength: 0.012,
|
backgroundGrainStrength: 0.012,
|
||||||
brushSize: 10,
|
brushSize: 4.5,
|
||||||
clarity: 0.9,
|
clarity: 0.1,
|
||||||
decayRateTrails: 935,
|
decayRateTrails: 922,
|
||||||
individualTrailWeight: 0.045,
|
forwardRotationScale: 0.5,
|
||||||
moveSpeed: 104,
|
individualTrailWeight: 0.026,
|
||||||
sensorOffsetDistance: 36,
|
moveSpeed: 86,
|
||||||
spawnPerPixel: 0.24,
|
sensorOffsetAngle: 46,
|
||||||
turnSpeed: 78,
|
sensorOffsetDistance: 14,
|
||||||
|
spawnPerPixel: 0.36,
|
||||||
|
strokeAngleJitterRadians: 3,
|
||||||
|
turnSpeed: 34,
|
||||||
|
turnWhenLost: 1.35,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
...defaultGardenAudioVibeSettings,
|
...defaultGardenAudioVibeSettings,
|
||||||
idleIntensity: 0.05,
|
idleIntensity: 0.11,
|
||||||
bpm: 96,
|
bpm: 150,
|
||||||
rampUpIntensity: 1.05,
|
rampUpIntensity: 2,
|
||||||
rampUpTime: 0.07,
|
rampUpTime: 0.06,
|
||||||
noteLength: 0.3,
|
noteLength: 1.8,
|
||||||
notePitchOffset: 3,
|
notePitchOffset: -12,
|
||||||
brightness: 1.18,
|
brightness: 0.5,
|
||||||
|
scale: musicScales.lydian,
|
||||||
|
progression: musicProgressions.chrome,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
export const ENABLED_FLAG_VALUE = '1';
|
|
||||||
export const DISABLED_FLAG_VALUE = '0';
|
|
||||||
|
|
||||||
export const DEFAULT_AUDIO_VOLUME = 0.5;
|
|
||||||
|
|
||||||
export const APP_STORAGE_KEYS = {
|
|
||||||
audioMuted: 'fleeting-garden:audio-muted',
|
|
||||||
audioVolume: 'fleeting-garden:audio-volume',
|
|
||||||
vibe: 'fleeting-garden:vibe',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -145,6 +145,18 @@ describe('AgentPopulation stroke spawning', () => {
|
||||||
expect(pipeline.writtenBatches[0][2]).toBe(0);
|
expect(pipeline.writtenBatches[0][2]).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears active agents when an intro replacement has no generated agents', () => {
|
||||||
|
const { population } = createPopulation();
|
||||||
|
|
||||||
|
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
|
||||||
|
expect(population.activeAgentCount).toBe(3);
|
||||||
|
|
||||||
|
settings.maxAgentCount = 0;
|
||||||
|
population.replaceIntroAgents(vec2.fromValues(100, 100), 0);
|
||||||
|
|
||||||
|
expect(population.activeAgentCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('queues stroke writes while async compaction is in flight', async () => {
|
it('queues stroke writes while async compaction is in flight', async () => {
|
||||||
const { pipeline, population } = createPopulation();
|
const { pipeline, population } = createPopulation();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
|
|
@ -58,6 +59,8 @@ export class AgentPopulation {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
|
this.activeCount = 0;
|
||||||
|
this.replacementCursor = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,7 +165,11 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseAngle = Math.atan2(deltaY, deltaX);
|
const baseAngle = Math.atan2(deltaY, deltaX);
|
||||||
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
|
const spread =
|
||||||
|
getRenderQualityBrushSize(
|
||||||
|
settings.brushSize,
|
||||||
|
settings.internalRenderAreaMegapixels
|
||||||
|
) * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||||
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
|
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
|
||||||
if (batchCapacity <= 0) {
|
if (batchCapacity <= 0) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import { type StrokeSegment } from './game-loop-types';
|
import { type StrokeSegment } from './game-loop-types';
|
||||||
|
|
@ -90,9 +91,13 @@ export class BrushStrokeSmoother {
|
||||||
): Array<StrokeSegment> {
|
): Array<StrokeSegment> {
|
||||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||||
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
|
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
|
||||||
|
const brushSize = getRenderQualityBrushSize(
|
||||||
|
settings.brushSize,
|
||||||
|
settings.internalRenderAreaMegapixels
|
||||||
|
);
|
||||||
const brushRadius = Math.max(
|
const brushRadius = Math.max(
|
||||||
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||||
(settings.brushSize * canvasPixelRatio) / 2
|
(brushSize * canvasPixelRatio) / 2
|
||||||
);
|
);
|
||||||
const segmentSpacing = Math.max(
|
const segmentSpacing = Math.max(
|
||||||
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
|
|
||||||
export class FramePerformance {
|
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
|
||||||
private readonly adaptiveRefreshTargetFps = 60;
|
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND = 200_000;
|
||||||
private readonly initialFps = this.adaptiveRefreshTargetFps;
|
const FRAME_GAP_RESET_SECONDS = 1;
|
||||||
public smoothedFps = this.initialFps;
|
const FPS_HEADROOM = 0.9;
|
||||||
|
const FPS_SMOOTHING_NEW = 0.06;
|
||||||
|
const FPS_SMOOTHING_RETAIN = 1 - FPS_SMOOTHING_NEW;
|
||||||
|
|
||||||
|
export class FramePerformance {
|
||||||
|
public smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
|
||||||
public measuredFps = 0;
|
public measuredFps = 0;
|
||||||
public frameDeltaSeconds = 0;
|
public frameDeltaSeconds = 0;
|
||||||
public measuredFrameTimeMs = 0;
|
public measuredFrameTimeMs = 0;
|
||||||
|
|
||||||
private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
|
|
||||||
private readonly frameGapResetSeconds = 1;
|
|
||||||
private readonly fpsHeadroom = 0.9;
|
|
||||||
private readonly fpsSmoothingNew = 0.06;
|
|
||||||
private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
|
|
||||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||||
|
|
||||||
public get adaptiveCapInitial(): number {
|
public get adaptiveCapInitial(): number {
|
||||||
|
|
@ -25,13 +24,13 @@ export class FramePerformance {
|
||||||
}
|
}
|
||||||
|
|
||||||
public get hasAdaptiveCapHeadroom(): boolean {
|
public get hasAdaptiveCapHeadroom(): boolean {
|
||||||
return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom;
|
return this.smoothedFps >= ADAPTIVE_REFRESH_TARGET_FPS * FPS_HEADROOM;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get adaptiveCapDecreaseAgents(): number {
|
public get adaptiveCapDecreaseAgents(): number {
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds)
|
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * this.frameDeltaSeconds)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,15 +46,16 @@ export class FramePerformance {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fps = 1 / deltaSeconds;
|
|
||||||
this.frameDeltaSeconds = deltaSeconds;
|
|
||||||
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
||||||
|
const fps = 1 / deltaSeconds;
|
||||||
this.measuredFps = fps;
|
this.measuredFps = fps;
|
||||||
if (deltaSeconds > this.frameGapResetSeconds) {
|
if (deltaSeconds > FRAME_GAP_RESET_SECONDS) {
|
||||||
|
this.frameDeltaSeconds = 0;
|
||||||
|
this.smoothedFps = ADAPTIVE_REFRESH_TARGET_FPS;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.smoothedFps =
|
this.frameDeltaSeconds = deltaSeconds;
|
||||||
this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew;
|
this.smoothedFps = this.smoothedFps * FPS_SMOOTHING_RETAIN + fps * FPS_SMOOTHING_NEW;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig, type GardenRuntimeSettings } from '../config';
|
||||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
|
|
@ -9,7 +9,6 @@ import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
import { settings } from '../settings';
|
|
||||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||||
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
|
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
|
||||||
import { GpuProfiler } from './gpu-profiler';
|
import { GpuProfiler } from './gpu-profiler';
|
||||||
|
|
@ -25,6 +24,7 @@ interface FrameParameters extends RenderInputs {
|
||||||
introProgress: number;
|
introProgress: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
eraserPixelSize: number;
|
eraserPixelSize: number;
|
||||||
|
runtimeSettings: GardenRuntimeSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameLoopResources {
|
export class GameLoopResources {
|
||||||
|
|
@ -46,7 +46,8 @@ export class GameLoopResources {
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly canvasFormat: GPUTextureFormat,
|
private readonly canvasFormat: GPUTextureFormat,
|
||||||
canvasSize: vec2,
|
canvasSize: vec2,
|
||||||
initialAgentCapacity: number
|
initialAgentCapacity: number,
|
||||||
|
initialMaxAgentCount: number
|
||||||
) {
|
) {
|
||||||
const context = initializeContext({ device, canvas, format: canvasFormat });
|
const context = initializeContext({ device, canvas, format: canvasFormat });
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ export class GameLoopResources {
|
||||||
|
|
||||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||||
this.device,
|
this.device,
|
||||||
Math.min(settings.maxAgentCount, initialAgentCapacity)
|
Math.min(initialMaxAgentCount, initialAgentCapacity)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.agentPipeline = new AgentPipeline(
|
this.agentPipeline = new AgentPipeline(
|
||||||
|
|
@ -74,12 +75,7 @@ export class GameLoopResources {
|
||||||
);
|
);
|
||||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||||
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||||
this.renderPipeline = new RenderPipeline(
|
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
|
||||||
context,
|
|
||||||
this.device,
|
|
||||||
this.commonState,
|
|
||||||
this.canvasFormat
|
|
||||||
);
|
|
||||||
this.gpuProfiler = GpuProfiler.create(
|
this.gpuProfiler = GpuProfiler.create(
|
||||||
this.device,
|
this.device,
|
||||||
() => appConfig.tuningPane.showFpsOverlay
|
() => appConfig.tuningPane.showFpsOverlay
|
||||||
|
|
@ -128,47 +124,43 @@ export class GameLoopResources {
|
||||||
channelColors,
|
channelColors,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
eraserPixelSize,
|
eraserPixelSize,
|
||||||
|
runtimeSettings,
|
||||||
}: FrameParameters): void {
|
}: FrameParameters): void {
|
||||||
this.commonState.setParameters({
|
this.commonState.setParameters({
|
||||||
canvasSize,
|
canvasSize,
|
||||||
});
|
});
|
||||||
this.agentPipeline.setParameters({
|
this.agentPipeline.setParameters({
|
||||||
...settings,
|
...runtimeSettings,
|
||||||
deltaTime,
|
deltaTime,
|
||||||
time,
|
time,
|
||||||
agentCount: activeAgentCount,
|
agentCount: activeAgentCount,
|
||||||
moveSpeed:
|
introMoveSpeed: appConfig.simulation.introMoveSpeed,
|
||||||
settings.moveSpeed *
|
|
||||||
(introProgress >= 1
|
|
||||||
? 1
|
|
||||||
: appConfig.simulation.introMoveSpeedBaseMultiplier +
|
|
||||||
introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier),
|
|
||||||
introProgress,
|
introProgress,
|
||||||
});
|
});
|
||||||
this.brushPipeline.setParameters({
|
this.brushPipeline.setParameters({
|
||||||
...settings,
|
...runtimeSettings,
|
||||||
pixelRatio: canvasPixelRatio,
|
pixelRatio: canvasPixelRatio,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
});
|
});
|
||||||
this.diffusionPipeline.setParameters(settings);
|
this.diffusionPipeline.setParameters(runtimeSettings);
|
||||||
this.renderPipeline.setParameters({
|
this.renderPipeline.setParameters({
|
||||||
...settings,
|
...runtimeSettings,
|
||||||
channelColors,
|
channelColors,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
});
|
});
|
||||||
this.eraserAgentPipeline.setParameters({
|
this.eraserAgentPipeline.setParameters({
|
||||||
agentCount: activeAgentCount,
|
agentCount: activeAgentCount,
|
||||||
eraserSize: eraserPixelSize,
|
eraserSize: eraserPixelSize,
|
||||||
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
|
eraserMaskAlphaThreshold: runtimeSettings.eraserMaskAlphaThreshold,
|
||||||
maskSize: canvasSize,
|
maskSize: canvasSize,
|
||||||
});
|
});
|
||||||
this.eraserTexturePipeline.setParameters({
|
this.eraserTexturePipeline.setParameters({
|
||||||
eraserSize: eraserPixelSize,
|
eraserSize: eraserPixelSize,
|
||||||
eraserLineDistanceEpsilon: settings.eraserLineDistanceEpsilon,
|
eraserLineDistanceEpsilon: runtimeSettings.eraserLineDistanceEpsilon,
|
||||||
eraserClearRed: settings.eraserClearRed,
|
eraserClearRed: runtimeSettings.eraserClearRed,
|
||||||
eraserClearGreen: settings.eraserClearGreen,
|
eraserClearGreen: runtimeSettings.eraserClearGreen,
|
||||||
eraserClearBlue: settings.eraserClearBlue,
|
eraserClearBlue: runtimeSettings.eraserClearBlue,
|
||||||
eraserClearAlpha: settings.eraserClearAlpha,
|
eraserClearAlpha: runtimeSettings.eraserClearAlpha,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ export default class GameLoop {
|
||||||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||||
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||||
private readonly seed = this.seedValue.toString(16);
|
private readonly seed = this.seedValue.toString(16);
|
||||||
private readonly resizeListener = this.resize.bind(this);
|
|
||||||
private readonly _canvasSize: vec2 = vec2.create();
|
private readonly _canvasSize: vec2 = vec2.create();
|
||||||
|
|
||||||
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||||
|
|
@ -55,7 +54,8 @@ export default class GameLoop {
|
||||||
device,
|
device,
|
||||||
this.canvasFormat,
|
this.canvasFormat,
|
||||||
this.canvasSize,
|
this.canvasSize,
|
||||||
this.framePerformance.adaptiveCapInitial
|
this.framePerformance.adaptiveCapInitial,
|
||||||
|
settings.maxAgentCount
|
||||||
);
|
);
|
||||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(
|
||||||
|
|
@ -112,13 +112,12 @@ export default class GameLoop {
|
||||||
getVibeId: () => activeVibe.id,
|
getVibeId: () => activeVibe.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', this.resizeListener);
|
|
||||||
this.eraserPreview.attach();
|
|
||||||
this.syncPerfStatsOverlay();
|
this.syncPerfStatsOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
public attachPointerInput(): void {
|
public attachPointerInput(): void {
|
||||||
this.pointerInput.attach();
|
this.pointerInput.attach();
|
||||||
|
this.eraserPreview.attach();
|
||||||
}
|
}
|
||||||
|
|
||||||
public setEraseMode(isErasing: boolean): void {
|
public setEraseMode(isErasing: boolean): void {
|
||||||
|
|
@ -173,9 +172,6 @@ export default class GameLoop {
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
this.animationFrameId = null;
|
this.animationFrameId = null;
|
||||||
}
|
}
|
||||||
this.finished.resolve();
|
|
||||||
|
|
||||||
window.removeEventListener('resize', this.resizeListener);
|
|
||||||
this.pointerInput.detach();
|
this.pointerInput.detach();
|
||||||
this.eraserPreview.detach();
|
this.eraserPreview.detach();
|
||||||
this.perfStatsOverlay?.destroy();
|
this.perfStatsOverlay?.destroy();
|
||||||
|
|
@ -185,6 +181,7 @@ export default class GameLoop {
|
||||||
await this.agentPopulation.waitForCompaction();
|
await this.agentPopulation.waitForCompaction();
|
||||||
this.resources.destroy();
|
this.resources.destroy();
|
||||||
await this.audio.destroy();
|
await this.audio.destroy();
|
||||||
|
this.finished.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly render = (time: DOMHighResTimeStamp) => {
|
private readonly render = (time: DOMHighResTimeStamp) => {
|
||||||
|
|
@ -204,13 +201,15 @@ export default class GameLoop {
|
||||||
|
|
||||||
const channelColors = activeVibe.colors;
|
const channelColors = activeVibe.colors;
|
||||||
const backgroundColor = activeVibe.backgroundColor;
|
const backgroundColor = activeVibe.backgroundColor;
|
||||||
|
const runtimeSettings = { ...settings };
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
const canvasPixelRatio = this.canvasPixelRatio;
|
const canvasPixelRatio = this.canvasPixelRatio;
|
||||||
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
|
const eraserPixelSize = runtimeSettings.eraserSize * canvasPixelRatio;
|
||||||
const isErasing = this.pointerInput.isEraseMode;
|
const isErasing = this.pointerInput.isEraseMode;
|
||||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
const accentColor =
|
||||||
|
channelColors[runtimeSettings.selectedColorIndex] ?? channelColors[0];
|
||||||
this.updateAccentColor(accentColor);
|
this.updateAccentColor(accentColor);
|
||||||
this.updateGrainOverlay(settings.backgroundGrainStrength);
|
this.updateGrainOverlay(runtimeSettings.backgroundGrainStrength);
|
||||||
this.audio.update({
|
this.audio.update({
|
||||||
vibe: activeVibe,
|
vibe: activeVibe,
|
||||||
isErasing,
|
isErasing,
|
||||||
|
|
@ -223,10 +222,11 @@ export default class GameLoop {
|
||||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
canvasPixelRatio,
|
canvasPixelRatio,
|
||||||
introProgress,
|
introProgress,
|
||||||
selectedColorIndex: settings.selectedColorIndex,
|
selectedColorIndex: runtimeSettings.selectedColorIndex,
|
||||||
channelColors,
|
channelColors,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
eraserPixelSize,
|
eraserPixelSize,
|
||||||
|
runtimeSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.resources.executeFrame(
|
this.resources.executeFrame(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig, type GardenAppConfig } from '../config';
|
||||||
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||||
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
||||||
|
|
||||||
|
|
@ -18,8 +18,11 @@ interface IntroTitleAgentOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RandomSource = () => number;
|
type RandomSource = () => number;
|
||||||
|
type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
|
||||||
|
|
||||||
const INTRO_TITLE = appConfig.simulation.intro.title;
|
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||||
|
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
|
||||||
|
pathEasing === 'linear';
|
||||||
|
|
||||||
export const createIntroTitleAgents = ({
|
export const createIntroTitleAgents = ({
|
||||||
count,
|
count,
|
||||||
|
|
@ -169,16 +172,22 @@ const createIntroTitlePoints = (
|
||||||
width: number,
|
width: number,
|
||||||
height: number
|
height: number
|
||||||
): Array<IntroTitlePoint> => {
|
): Array<IntroTitlePoint> => {
|
||||||
|
const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
|
||||||
|
const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height)));
|
||||||
|
const maskWidth = Math.max(1, Math.round(width * maskScale));
|
||||||
|
const maskHeight = Math.max(1, Math.round(height * maskScale));
|
||||||
|
const pointScaleX = width / maskWidth;
|
||||||
|
const pointScaleY = height / maskHeight;
|
||||||
const maskCanvas = document.createElement('canvas');
|
const maskCanvas = document.createElement('canvas');
|
||||||
maskCanvas.width = width;
|
maskCanvas.width = maskWidth;
|
||||||
maskCanvas.height = height;
|
maskCanvas.height = maskHeight;
|
||||||
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
const context = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontSize = getIntroTitleFontSize(context, width, height);
|
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
|
||||||
context.clearRect(0, 0, width, height);
|
context.clearRect(0, 0, maskWidth, maskHeight);
|
||||||
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||||
context.textAlign = 'center';
|
context.textAlign = 'center';
|
||||||
context.textBaseline = 'middle';
|
context.textBaseline = 'middle';
|
||||||
|
|
@ -192,42 +201,44 @@ const createIntroTitlePoints = (
|
||||||
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
||||||
drawIntroTitleText(
|
drawIntroTitleText(
|
||||||
context,
|
context,
|
||||||
width / 2,
|
maskWidth / 2,
|
||||||
height * appConfig.simulation.intro.verticalAnchor,
|
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||||
letterSpacing,
|
letterSpacing,
|
||||||
'stroke'
|
'stroke'
|
||||||
);
|
);
|
||||||
drawIntroTitleText(
|
drawIntroTitleText(
|
||||||
context,
|
context,
|
||||||
width / 2,
|
maskWidth / 2,
|
||||||
height * appConfig.simulation.intro.verticalAnchor,
|
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||||
letterSpacing,
|
letterSpacing,
|
||||||
'fill'
|
'fill'
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data } = context.getImageData(0, 0, width, height);
|
const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
|
||||||
const step = Math.max(
|
const step = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor(Math.min(width, height) / appConfig.simulation.intro.maskSampleDensity)
|
Math.floor(
|
||||||
|
Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const points: Array<IntroTitlePoint> = [];
|
const points: Array<IntroTitlePoint> = [];
|
||||||
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
||||||
context,
|
context,
|
||||||
width,
|
maskWidth,
|
||||||
letterSpacing
|
letterSpacing
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let y = 0; y < height; y += step) {
|
for (let y = 0; y < maskHeight; y += step) {
|
||||||
for (let x = 0; x < width; x += step) {
|
for (let x = 0; x < maskWidth; x += step) {
|
||||||
const alpha = getMaskAlpha(data, width, height, x, y);
|
const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y);
|
||||||
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
points.push({
|
points.push({
|
||||||
x,
|
x: x * pointScaleX,
|
||||||
y,
|
y: y * pointScaleY,
|
||||||
tangent: estimateMaskTangent(data, width, height, x, y),
|
tangent: estimateMaskTangent(data, maskWidth, maskHeight, x, y),
|
||||||
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
colorIndex: getIntroTitleColorIndex(x, characterColorBoundaries),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -244,8 +255,10 @@ const getIntroTitleColorBoundaries = (
|
||||||
const letters = Array.from(INTRO_TITLE);
|
const letters = Array.from(INTRO_TITLE);
|
||||||
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||||
let x = width / 2 - totalWidth / 2;
|
let x = width / 2 - totalWidth / 2;
|
||||||
const [firstCutLetter, secondCutLetter] =
|
const cutLetters = appConfig.simulation.intro.titleColorCutLetters
|
||||||
appConfig.simulation.intro.titleColorCutLetters;
|
.map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter))))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
const [firstCutLetter, secondCutLetter] = cutLetters;
|
||||||
const letterBoxes = letters.map((letter, index) => {
|
const letterBoxes = letters.map((letter, index) => {
|
||||||
const letterWidth = context.measureText(letter).width;
|
const letterWidth = context.measureText(letter).width;
|
||||||
const box = {
|
const box = {
|
||||||
|
|
@ -401,7 +414,7 @@ const createSeededRandom = (seed: number): RandomSource => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const easePathProgress = (amount: number): number => {
|
const easePathProgress = (amount: number): number => {
|
||||||
if (appConfig.simulation.intro.pathEasing === 'linear') {
|
if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
|
||||||
return amount;
|
return amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const PERF_STATS_REFRESH_MS = 200;
|
const PERF_STATS_REFRESH_MS = 200;
|
||||||
|
const UNAVAILABLE_STAT_TEXT = 'n/a';
|
||||||
const ZERO_STAT_TEXT = '0';
|
const ZERO_STAT_TEXT = '0';
|
||||||
const ZERO_FRAME_TIME_TEXT = '0ms';
|
const ZERO_FRAME_TIME_TEXT = '0ms';
|
||||||
const ZERO_RESOLUTION_TEXT = '0x0';
|
const ZERO_RESOLUTION_TEXT = '0x0';
|
||||||
|
|
@ -39,7 +40,7 @@ export class PerfStatsOverlay {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.previousUpdateTime = time;
|
this.previousUpdateTime = time;
|
||||||
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
|
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatOptionalFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
|
||||||
if (text !== this.previousText) {
|
if (text !== this.previousText) {
|
||||||
this.element.textContent = text;
|
this.element.textContent = text;
|
||||||
this.previousText = text;
|
this.previousText = text;
|
||||||
|
|
@ -68,6 +69,11 @@ const formatFrameTime = (frameTimeMs: number | undefined): string => {
|
||||||
return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`;
|
return `${safeFrameTimeMs.toFixed(safeFrameTimeMs < 10 ? 1 : 0)}ms`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatOptionalFrameTime = (frameTimeMs: number | undefined): string =>
|
||||||
|
typeof frameTimeMs === 'number' && Number.isFinite(frameTimeMs)
|
||||||
|
? formatFrameTime(frameTimeMs)
|
||||||
|
: UNAVAILABLE_STAT_TEXT;
|
||||||
|
|
||||||
const formatResolution = (width: number, height: number): string =>
|
const formatResolution = (width: number, height: number): string =>
|
||||||
Number.isFinite(width) && Number.isFinite(height)
|
Number.isFinite(width) && Number.isFinite(height)
|
||||||
? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}`
|
? `${Math.max(0, Math.round(width))}x${Math.max(0, Math.round(height))}`
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@ export class GardenPointerInput {
|
||||||
this.options.onStartDrawing();
|
this.options.onStartDrawing();
|
||||||
this.activePointerId = event.pointerId;
|
this.activePointerId = event.pointerId;
|
||||||
this.canvas.setPointerCapture(event.pointerId);
|
this.canvas.setPointerCapture(event.pointerId);
|
||||||
this.options.strokeOutput.clearSwipes();
|
|
||||||
this.lastPointerPosition = null;
|
this.lastPointerPosition = null;
|
||||||
this.lastPointerEventTimeMs = null;
|
this.lastPointerEventTimeMs = null;
|
||||||
this.brushSmoother.clear();
|
this.brushSmoother.clear();
|
||||||
|
|
@ -122,11 +121,16 @@ export class GardenPointerInput {
|
||||||
if (this.isErasing) {
|
if (this.isErasing) {
|
||||||
this.options.onEraseGestureEnded();
|
this.options.onEraseGestureEnded();
|
||||||
}
|
}
|
||||||
this.canvas.releasePointerCapture(event.pointerId);
|
try {
|
||||||
this.activePointerId = null;
|
if (this.canvas.hasPointerCapture(event.pointerId)) {
|
||||||
this.lastPointerPosition = null;
|
this.canvas.releasePointerCapture(event.pointerId);
|
||||||
this.lastPointerEventTimeMs = null;
|
}
|
||||||
this.brushSmoother.clear();
|
} finally {
|
||||||
|
this.activePointerId = null;
|
||||||
|
this.lastPointerPosition = null;
|
||||||
|
this.lastPointerEventTimeMs = null;
|
||||||
|
this.brushSmoother.clear();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||||
|
|
|
||||||
|
|
@ -46,16 +46,19 @@ export class SimulationFrameRenderer {
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
this.gpuProfiler?.beginFrame();
|
this.gpuProfiler?.beginFrame();
|
||||||
|
|
||||||
this.textures.copyTrailMapAToB(commandEncoder);
|
// Clear the deposit map up-front so agents write fresh deposits each frame
|
||||||
|
// and diffuse sees only this frame's contributions added to trailMapA.
|
||||||
|
this.textures.clearDepositMap(commandEncoder);
|
||||||
let wroteSourceMap = false;
|
let wroteSourceMap = false;
|
||||||
if (isErasing) {
|
if (isErasing) {
|
||||||
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
||||||
const eraserMask = this.textures.eraserMask.getTextureView();
|
const eraserMask = this.textures.eraserMask.getTextureView();
|
||||||
|
// Erase trailMapA directly — it's what agent and diffuse will read.
|
||||||
this.pipelines.eraserTexturePipeline.executeCombined(
|
this.pipelines.eraserTexturePipeline.executeCombined(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
eraserMask,
|
eraserMask,
|
||||||
this.textures.sourceMapA.getTextureView(),
|
this.textures.sourceMapA.getTextureView(),
|
||||||
this.textures.trailMapB.getTextureView(),
|
this.textures.trailMapA.getTextureView(),
|
||||||
this.gpuProfiler?.timestampWrites('eraserTexture')
|
this.gpuProfiler?.timestampWrites('eraserTexture')
|
||||||
);
|
);
|
||||||
this.pipelines.eraserAgentPipeline.execute(
|
this.pipelines.eraserAgentPipeline.execute(
|
||||||
|
|
@ -65,7 +68,7 @@ export class SimulationFrameRenderer {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
wroteSourceMap = this.pipelines.brushPipeline.executeMultiTarget(
|
wroteSourceMap = this.pipelines.brushPipeline.executeSource(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.sourceMapA.getTextureView(),
|
this.textures.sourceMapA.getTextureView(),
|
||||||
this.gpuProfiler?.timestampWrites('brush')
|
this.gpuProfiler?.timestampWrites('brush')
|
||||||
|
|
@ -86,19 +89,20 @@ export class SimulationFrameRenderer {
|
||||||
this.pipelines.agentPipeline.execute(
|
this.pipelines.agentPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.trailMapA.getTextureView(),
|
this.textures.trailMapA.getTextureView(),
|
||||||
this.textures.trailMapB.getTextureView(),
|
this.textures.depositMap.getTextureView(),
|
||||||
this.gpuProfiler?.timestampWrites('agent')
|
this.gpuProfiler?.timestampWrites('agent')
|
||||||
);
|
);
|
||||||
this.pipelines.diffusionPipeline.execute(
|
this.pipelines.diffusionPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.trailMapB.getTextureView(),
|
|
||||||
this.textures.trailMapA.getTextureView(),
|
this.textures.trailMapA.getTextureView(),
|
||||||
|
this.textures.trailMapB.getTextureView(),
|
||||||
this.textures.trailMapA.getSize(),
|
this.textures.trailMapA.getSize(),
|
||||||
|
this.textures.depositMap.getTextureView(),
|
||||||
this.gpuProfiler?.timestampWrites('trailDiffusion')
|
this.gpuProfiler?.timestampWrites('trailDiffusion')
|
||||||
);
|
);
|
||||||
const canvasTexture = this.pipelines.renderPipeline.execute(
|
const canvasTexture = this.pipelines.renderPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.trailMapA.getTextureView(),
|
this.textures.trailMapB.getTextureView(),
|
||||||
this.textures.sourceMapA.getTextureView(),
|
this.textures.sourceMapA.getTextureView(),
|
||||||
useSourceMap,
|
useSourceMap,
|
||||||
this.gpuProfiler?.timestampWrites('render')
|
this.gpuProfiler?.timestampWrites('render')
|
||||||
|
|
@ -111,6 +115,7 @@ export class SimulationFrameRenderer {
|
||||||
this.textures.sourceMapA.getTextureView(),
|
this.textures.sourceMapA.getTextureView(),
|
||||||
this.textures.sourceMapB.getTextureView(),
|
this.textures.sourceMapB.getTextureView(),
|
||||||
this.textures.sourceMapB.getSize(),
|
this.textures.sourceMapB.getSize(),
|
||||||
|
null,
|
||||||
this.gpuProfiler?.timestampWrites('sourceDiffusion')
|
this.gpuProfiler?.timestampWrites('sourceDiffusion')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +123,10 @@ export class SimulationFrameRenderer {
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
afterGpuProfileSubmit?.();
|
afterGpuProfileSubmit?.();
|
||||||
canvasReadbackRequest?.afterSubmit();
|
canvasReadbackRequest?.afterSubmit();
|
||||||
|
// After this frame's diffuse, trailMapB holds the fresh trail; swap so
|
||||||
|
// trailMapA is "current trail" again for the next frame and any external
|
||||||
|
// readers (e.g. export snapshot).
|
||||||
|
this.textures.swapTrailMaps();
|
||||||
if (useSourceMap) {
|
if (useSourceMap) {
|
||||||
this.textures.swapSourceMaps();
|
this.textures.swapSourceMaps();
|
||||||
this.sourceActiveFramesRemaining -= 1;
|
this.sourceActiveFramesRemaining -= 1;
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,15 @@ import {
|
||||||
} from '../utils/graphics/resizable-texture';
|
} from '../utils/graphics/resizable-texture';
|
||||||
|
|
||||||
export class SimulationTextures {
|
export class SimulationTextures {
|
||||||
public readonly trailMapA: ResizableTexture;
|
// trailMapA holds the current trail (read by agent and diffuse). trailMapB
|
||||||
public readonly trailMapB: ResizableTexture;
|
// receives the diffuse output; the two swap each frame so the freshly
|
||||||
|
// diffused texture becomes trailMapA for the next frame.
|
||||||
|
public trailMapA: ResizableTexture;
|
||||||
|
public trailMapB: ResizableTexture;
|
||||||
|
// Per-frame last-writer deposit map: cleared each frame, written sparsely by
|
||||||
|
// agents, then read by diffuse alongside trailMapA.
|
||||||
|
public readonly depositMap: ResizableTexture;
|
||||||
public readonly eraserMask: ResizableTexture;
|
public readonly eraserMask: ResizableTexture;
|
||||||
// A/B are swapped each frame to ping-pong the diffusion pass.
|
|
||||||
public sourceMapA: ResizableTexture;
|
public sourceMapA: ResizableTexture;
|
||||||
public sourceMapB: ResizableTexture;
|
public sourceMapB: ResizableTexture;
|
||||||
|
|
||||||
|
|
@ -21,6 +26,7 @@ export class SimulationTextures {
|
||||||
) {
|
) {
|
||||||
this.trailMapA = this.createTexture(canvasSize);
|
this.trailMapA = this.createTexture(canvasSize);
|
||||||
this.trailMapB = this.createTexture(canvasSize);
|
this.trailMapB = this.createTexture(canvasSize);
|
||||||
|
this.depositMap = this.createTexture(canvasSize);
|
||||||
this.sourceMapA = this.createTexture(canvasSize);
|
this.sourceMapA = this.createTexture(canvasSize);
|
||||||
this.sourceMapB = this.createTexture(canvasSize);
|
this.sourceMapB = this.createTexture(canvasSize);
|
||||||
this.eraserMask = this.createEraserMask(canvasSize);
|
this.eraserMask = this.createEraserMask(canvasSize);
|
||||||
|
|
@ -36,6 +42,7 @@ export class SimulationTextures {
|
||||||
const resizes = [
|
const resizes = [
|
||||||
this.trailMapA,
|
this.trailMapA,
|
||||||
this.trailMapB,
|
this.trailMapB,
|
||||||
|
this.depositMap,
|
||||||
this.sourceMapA,
|
this.sourceMapA,
|
||||||
this.sourceMapB,
|
this.sourceMapB,
|
||||||
this.eraserMask,
|
this.eraserMask,
|
||||||
|
|
@ -67,6 +74,7 @@ export class SimulationTextures {
|
||||||
[
|
[
|
||||||
this.trailMapA,
|
this.trailMapA,
|
||||||
this.trailMapB,
|
this.trailMapB,
|
||||||
|
this.depositMap,
|
||||||
this.sourceMapA,
|
this.sourceMapA,
|
||||||
this.sourceMapB,
|
this.sourceMapB,
|
||||||
this.eraserMask,
|
this.eraserMask,
|
||||||
|
|
@ -86,14 +94,25 @@ export class SimulationTextures {
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void {
|
public clearDepositMap(commandEncoder: GPUCommandEncoder): void {
|
||||||
const size = this.trailMapA.getSize();
|
// Hardware fast-clear via a render pass with loadOp 'clear' and an empty
|
||||||
|
// body. Cheaper than copyTextureToTexture and writes no actual color data
|
||||||
|
// on tile-based GPUs.
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: this.depositMap.getTextureView(),
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
passEncoder.end();
|
||||||
|
}
|
||||||
|
|
||||||
commandEncoder.copyTextureToTexture(
|
public swapTrailMaps(): void {
|
||||||
{ texture: this.trailMapA.getTexture() },
|
[this.trailMapA, this.trailMapB] = [this.trailMapB, this.trailMapA];
|
||||||
{ texture: this.trailMapB.getTexture() },
|
|
||||||
{ width: size[0], height: size[1] }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
|
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
|
||||||
|
|
@ -119,6 +138,7 @@ export class SimulationTextures {
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.trailMapA.destroy();
|
this.trailMapA.destroy();
|
||||||
this.trailMapB.destroy();
|
this.trailMapB.destroy();
|
||||||
|
this.depositMap.destroy();
|
||||||
this.sourceMapA.destroy();
|
this.sourceMapA.destroy();
|
||||||
this.sourceMapB.destroy();
|
this.sourceMapB.destroy();
|
||||||
this.eraserMask.destroy();
|
this.eraserMask.destroy();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ interface CanvasSamplePoint {
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CanvasSampleRegion {
|
||||||
|
bytesPerRow: number;
|
||||||
|
height: number;
|
||||||
|
origin: CanvasSamplePoint;
|
||||||
|
sampleOffsets: Array<number>;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToolbarContrastMetrics {
|
interface ToolbarContrastMetrics {
|
||||||
averageLuminance: number;
|
averageLuminance: number;
|
||||||
backgroundOpacity: number;
|
backgroundOpacity: number;
|
||||||
|
|
@ -16,6 +24,7 @@ interface ToolbarContrastMetrics {
|
||||||
|
|
||||||
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
||||||
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
||||||
|
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
|
||||||
|
|
||||||
const getLinearChannel = (channel: number): number => {
|
const getLinearChannel = (channel: number): number => {
|
||||||
const normalized = channel / 255;
|
const normalized = channel / 255;
|
||||||
|
|
@ -33,16 +42,13 @@ const getRelativeLuminance = (red: number, green: number, blue: number): number
|
||||||
|
|
||||||
const getToolbarContrastMetrics = (
|
const getToolbarContrastMetrics = (
|
||||||
pixels: Uint8Array,
|
pixels: Uint8Array,
|
||||||
sampleCount: number,
|
sampleOffsets: ReadonlyArray<number>,
|
||||||
isBgra: boolean
|
isBgra: boolean
|
||||||
): ToolbarContrastMetrics => {
|
): ToolbarContrastMetrics => {
|
||||||
const count = Math.max(
|
const count = sampleOffsets.filter(
|
||||||
0,
|
(offset) =>
|
||||||
Math.min(
|
offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
|
||||||
sampleCount,
|
).length;
|
||||||
Math.floor(pixels.length / appConfig.toolbar.contrast.bytesPerSample)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
return {
|
return {
|
||||||
averageLuminance: 0,
|
averageLuminance: 0,
|
||||||
|
|
@ -56,8 +62,14 @@ const getToolbarContrastMetrics = (
|
||||||
let brightCount = 0;
|
let brightCount = 0;
|
||||||
let lowContrastCount = 0;
|
let lowContrastCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
sampleOffsets.forEach((offset) => {
|
||||||
const offset = i * appConfig.toolbar.contrast.bytesPerSample;
|
if (
|
||||||
|
offset < 0 ||
|
||||||
|
offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const red = pixels[offset + (isBgra ? 2 : 0)];
|
const red = pixels[offset + (isBgra ? 2 : 0)];
|
||||||
const green = pixels[offset + 1];
|
const green = pixels[offset + 1];
|
||||||
const blue = pixels[offset + (isBgra ? 0 : 2)];
|
const blue = pixels[offset + (isBgra ? 0 : 2)];
|
||||||
|
|
@ -73,7 +85,7 @@ const getToolbarContrastMetrics = (
|
||||||
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
|
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
|
||||||
lowContrastCount++;
|
lowContrastCount++;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
const averageLuminance = luminanceTotal / count;
|
const averageLuminance = luminanceTotal / count;
|
||||||
const brightRatio = brightCount / count;
|
const brightRatio = brightCount / count;
|
||||||
|
|
@ -100,6 +112,8 @@ export class ToolbarContrastMonitor {
|
||||||
private isDestroyed = false;
|
private isDestroyed = false;
|
||||||
private isReadbackPending = false;
|
private isReadbackPending = false;
|
||||||
private lastSampleAt = Number.NEGATIVE_INFINITY;
|
private lastSampleAt = Number.NEGATIVE_INFINITY;
|
||||||
|
private readbackBuffer: GPUBuffer | null = null;
|
||||||
|
private readbackBufferSize = 0;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly canvas: HTMLCanvasElement,
|
private readonly canvas: HTMLCanvasElement,
|
||||||
|
|
@ -119,45 +133,29 @@ export class ToolbarContrastMonitor {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const samplePoints = this.getSamplePoints();
|
const sampleRegion = this.getSampleRegion();
|
||||||
if (samplePoints.length === 0) {
|
if (sampleRegion.sampleOffsets.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let buffer: GPUBuffer;
|
const bufferSize = sampleRegion.bytesPerRow * sampleRegion.height;
|
||||||
try {
|
const buffer = this.getReadbackBuffer(bufferSize);
|
||||||
buffer = this.device.createBuffer({
|
if (!buffer) {
|
||||||
size: samplePoints.length * appConfig.toolbar.contrast.bytesPerSample,
|
|
||||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isReadbackPending = true;
|
this.isReadbackPending = true;
|
||||||
this.lastSampleAt = time;
|
this.lastSampleAt = time;
|
||||||
|
|
||||||
let isBufferDestroyed = false;
|
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
let isEncoded = false;
|
let isEncoded = false;
|
||||||
const destroyBuffer = () => {
|
const cancel = () => {
|
||||||
if (isBufferDestroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isBufferDestroyed = true;
|
|
||||||
buffer.destroy();
|
|
||||||
};
|
|
||||||
const cancel = (destroyNow = true) => {
|
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
this.isReadbackPending = false;
|
this.isReadbackPending = false;
|
||||||
if (destroyNow) {
|
|
||||||
destroyBuffer();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -167,31 +165,28 @@ export class ToolbarContrastMonitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
samplePoints.forEach((point, index) => {
|
commandEncoder.copyTextureToBuffer(
|
||||||
commandEncoder.copyTextureToBuffer(
|
{
|
||||||
{
|
origin: sampleRegion.origin,
|
||||||
origin: point,
|
texture,
|
||||||
texture,
|
},
|
||||||
},
|
{
|
||||||
{
|
buffer,
|
||||||
buffer,
|
bytesPerRow: sampleRegion.bytesPerRow,
|
||||||
offset: index * appConfig.toolbar.contrast.bytesPerSample,
|
},
|
||||||
},
|
{
|
||||||
{
|
depthOrArrayLayers: 1,
|
||||||
depthOrArrayLayers: 1,
|
height: sampleRegion.height,
|
||||||
height: 1,
|
width: sampleRegion.width,
|
||||||
width: 1,
|
}
|
||||||
}
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
isEncoded = true;
|
isEncoded = true;
|
||||||
} catch {
|
} catch {
|
||||||
cancel(false);
|
cancel();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
afterSubmit: () => {
|
afterSubmit: () => {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
destroyBuffer();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,13 +195,16 @@ export class ToolbarContrastMonitor {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.readBuffer(buffer, samplePoints.length);
|
void this.readBuffer(buffer, sampleRegion.sampleOffsets);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.isDestroyed = true;
|
this.isDestroyed = true;
|
||||||
|
this.readbackBuffer?.destroy();
|
||||||
|
this.readbackBuffer = null;
|
||||||
|
this.readbackBufferSize = 0;
|
||||||
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
|
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_OPACITY_PROPERTY);
|
||||||
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
|
this.toolbar.style.removeProperty(TOOLBAR_BACKGROUND_STRENGTH_PROPERTY);
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +229,14 @@ export class ToolbarContrastMonitor {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSamplePoints(): Array<CanvasSamplePoint> {
|
private getSampleRegion(): CanvasSampleRegion {
|
||||||
|
const emptyRegion = {
|
||||||
|
bytesPerRow: 0,
|
||||||
|
height: 0,
|
||||||
|
origin: { x: 0, y: 0 },
|
||||||
|
sampleOffsets: [],
|
||||||
|
width: 0,
|
||||||
|
};
|
||||||
const canvasRect = this.canvas.getBoundingClientRect();
|
const canvasRect = this.canvas.getBoundingClientRect();
|
||||||
const toolbarRect = this.toolbar.getBoundingClientRect();
|
const toolbarRect = this.toolbar.getBoundingClientRect();
|
||||||
if (
|
if (
|
||||||
|
|
@ -240,7 +245,7 @@ export class ToolbarContrastMonitor {
|
||||||
toolbarRect.width <= 0 ||
|
toolbarRect.width <= 0 ||
|
||||||
toolbarRect.height <= 0
|
toolbarRect.height <= 0
|
||||||
) {
|
) {
|
||||||
return [];
|
return emptyRegion;
|
||||||
}
|
}
|
||||||
|
|
||||||
const left = Math.max(canvasRect.left, toolbarRect.left);
|
const left = Math.max(canvasRect.left, toolbarRect.left);
|
||||||
|
|
@ -248,17 +253,40 @@ export class ToolbarContrastMonitor {
|
||||||
const top = Math.max(canvasRect.top, toolbarRect.top);
|
const top = Math.max(canvasRect.top, toolbarRect.top);
|
||||||
const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom);
|
const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom);
|
||||||
if (left >= right || top >= bottom) {
|
if (left >= right || top >= bottom) {
|
||||||
return [];
|
return emptyRegion;
|
||||||
}
|
}
|
||||||
|
|
||||||
const xScale = this.canvas.width / canvasRect.width;
|
const xScale = this.canvas.width / canvasRect.width;
|
||||||
const yScale = this.canvas.height / canvasRect.height;
|
const yScale = this.canvas.height / canvasRect.height;
|
||||||
const width = right - left;
|
const cssWidth = right - left;
|
||||||
const height = bottom - top;
|
const cssHeight = bottom - top;
|
||||||
|
const origin = {
|
||||||
|
x: Math.max(0, Math.floor((left - canvasRect.left) * xScale)),
|
||||||
|
y: Math.max(0, Math.floor((top - canvasRect.top) * yScale)),
|
||||||
|
};
|
||||||
|
const regionRight = Math.min(
|
||||||
|
this.canvas.width,
|
||||||
|
Math.ceil((right - canvasRect.left) * xScale)
|
||||||
|
);
|
||||||
|
const regionBottom = Math.min(
|
||||||
|
this.canvas.height,
|
||||||
|
Math.ceil((bottom - canvasRect.top) * yScale)
|
||||||
|
);
|
||||||
|
const width = Math.max(0, regionRight - origin.x);
|
||||||
|
const height = Math.max(0, regionBottom - origin.y);
|
||||||
|
if (width === 0 || height === 0) {
|
||||||
|
return emptyRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesPerRow = alignTo(
|
||||||
|
width * appConfig.toolbar.contrast.bytesPerSample,
|
||||||
|
GPU_COPY_BYTES_PER_ROW_ALIGNMENT
|
||||||
|
);
|
||||||
const points = new Map<string, CanvasSamplePoint>();
|
const points = new Map<string, CanvasSamplePoint>();
|
||||||
|
|
||||||
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
|
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
|
||||||
const cssY = top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * height;
|
const cssY =
|
||||||
|
top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
|
||||||
const y = Math.min(
|
const y = Math.min(
|
||||||
this.canvas.height - 1,
|
this.canvas.height - 1,
|
||||||
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
|
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
|
||||||
|
|
@ -266,7 +294,7 @@ export class ToolbarContrastMonitor {
|
||||||
|
|
||||||
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
|
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
|
||||||
const cssX =
|
const cssX =
|
||||||
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * width;
|
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
|
||||||
const x = Math.min(
|
const x = Math.min(
|
||||||
this.canvas.width - 1,
|
this.canvas.width - 1,
|
||||||
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
|
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
|
||||||
|
|
@ -275,10 +303,43 @@ export class ToolbarContrastMonitor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...points.values()];
|
return {
|
||||||
|
bytesPerRow,
|
||||||
|
height,
|
||||||
|
origin,
|
||||||
|
sampleOffsets: [...points.values()].map(
|
||||||
|
(point) =>
|
||||||
|
(point.y - origin.y) * bytesPerRow +
|
||||||
|
(point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readBuffer(buffer: GPUBuffer, sampleCount: number): Promise<void> {
|
private getReadbackBuffer(size: number): GPUBuffer | null {
|
||||||
|
if (this.readbackBuffer && this.readbackBufferSize >= size) {
|
||||||
|
return this.readbackBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readbackBuffer?.destroy();
|
||||||
|
try {
|
||||||
|
this.readbackBuffer = this.device.createBuffer({
|
||||||
|
size,
|
||||||
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||||
|
});
|
||||||
|
this.readbackBufferSize = size;
|
||||||
|
return this.readbackBuffer;
|
||||||
|
} catch {
|
||||||
|
this.readbackBuffer = null;
|
||||||
|
this.readbackBufferSize = 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readBuffer(
|
||||||
|
buffer: GPUBuffer,
|
||||||
|
sampleOffsets: Array<number>
|
||||||
|
): Promise<void> {
|
||||||
let isMapped = false;
|
let isMapped = false;
|
||||||
try {
|
try {
|
||||||
await buffer.mapAsync(GPUMapMode.READ);
|
await buffer.mapAsync(GPUMapMode.READ);
|
||||||
|
|
@ -286,7 +347,7 @@ export class ToolbarContrastMonitor {
|
||||||
|
|
||||||
if (!this.isDestroyed) {
|
if (!this.isDestroyed) {
|
||||||
const pixels = new Uint8Array(buffer.getMappedRange());
|
const pixels = new Uint8Array(buffer.getMappedRange());
|
||||||
const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra);
|
const metrics = getToolbarContrastMetrics(pixels, sampleOffsets, this.isBgra);
|
||||||
this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
|
this.setToolbarBackgroundOpacity(metrics.backgroundOpacity);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -295,8 +356,10 @@ export class ToolbarContrastMonitor {
|
||||||
if (isMapped) {
|
if (isMapped) {
|
||||||
buffer.unmap();
|
buffer.unmap();
|
||||||
}
|
}
|
||||||
buffer.destroy();
|
|
||||||
this.isReadbackPending = false;
|
this.isReadbackPending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alignTo = (value: number, alignment: number): number =>
|
||||||
|
Math.ceil(value / alignment) * alignment;
|
||||||
|
|
|
||||||
12
src/index.ts
|
|
@ -82,11 +82,13 @@ const main = async () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const splash = new SplashScreen();
|
const splash = new SplashScreen();
|
||||||
|
let eraserSizeControl: EraserSizeControl | null = null;
|
||||||
const paletteControl = new PaletteControl({
|
const paletteControl = new PaletteControl({
|
||||||
getGame,
|
getGame,
|
||||||
onChange: () => configPane?.refresh(),
|
onChange: () => configPane?.refresh(),
|
||||||
|
onModeChange: (isEraserActive) => eraserSizeControl?.setActive(isEraserActive),
|
||||||
});
|
});
|
||||||
const eraserSizeControl = new EraserSizeControl({
|
eraserSizeControl = new EraserSizeControl({
|
||||||
getGame,
|
getGame,
|
||||||
onActivate: () => paletteControl.setEraserActive(true),
|
onActivate: () => paletteControl.setEraserActive(true),
|
||||||
onChange: () => configPane?.refresh(),
|
onChange: () => configPane?.refresh(),
|
||||||
|
|
@ -104,7 +106,8 @@ const main = async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const syncRuntimeUi = () => {
|
const syncRuntimeUi = () => {
|
||||||
eraserSizeControl.render();
|
eraserSizeControl?.render();
|
||||||
|
eraserSizeControl?.setActive(paletteControl.isEraserActive);
|
||||||
mirrorSegmentControl.render();
|
mirrorSegmentControl.render();
|
||||||
paletteControl.render();
|
paletteControl.render();
|
||||||
};
|
};
|
||||||
|
|
@ -120,12 +123,12 @@ const main = async () => {
|
||||||
new FullScreenHandler(fullScreenButton, document.documentElement);
|
new FullScreenHandler(fullScreenButton, document.documentElement);
|
||||||
|
|
||||||
new VibeNavigator({
|
new VibeNavigator({
|
||||||
onChange: ({ vibeId, vibeName, source }) => {
|
onChange: ({ vibeId, vibeName, source, userGesture }) => {
|
||||||
trackVibeChange({ vibeId, vibeName, source });
|
trackVibeChange({ vibeId, vibeName, source });
|
||||||
game?.onVibeChanged();
|
game?.onVibeChanged();
|
||||||
syncRuntimeUi();
|
syncRuntimeUi();
|
||||||
configPane?.refresh();
|
configPane?.refresh();
|
||||||
game?.playVibeChangeAudio(true);
|
game?.playVibeChangeAudio(userGesture);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -243,7 +246,6 @@ const main = async () => {
|
||||||
ErrorPresenter.renderStartup(e);
|
ErrorPresenter.renderStartup(e);
|
||||||
ErrorHandler.addException(e);
|
ErrorHandler.addException(e);
|
||||||
}
|
}
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { DISABLED_FLAG_VALUE, ENABLED_FLAG_VALUE } from '../consts';
|
|
||||||
import type GameLoop from '../game-loop/game-loop';
|
import type GameLoop from '../game-loop/game-loop';
|
||||||
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
import { queryRequiredElement } from '../utils/dom';
|
||||||
|
|
@ -21,6 +20,9 @@ const readInitialAudioVolume = (): number => {
|
||||||
const formatStoredAudioVolume = (volume: number): string =>
|
const formatStoredAudioVolume = (volume: number): string =>
|
||||||
clampAudioVolume(volume).toFixed(2);
|
clampAudioVolume(volume).toFixed(2);
|
||||||
|
|
||||||
|
const STORED_MUTED_TRUE = '1';
|
||||||
|
const STORED_MUTED_FALSE = '0';
|
||||||
|
|
||||||
interface AudioControlOptions {
|
interface AudioControlOptions {
|
||||||
getGame: () => GameLoop | null;
|
getGame: () => GameLoop | null;
|
||||||
hasStarted: () => boolean;
|
hasStarted: () => boolean;
|
||||||
|
|
@ -43,7 +45,7 @@ export class AudioControl {
|
||||||
|
|
||||||
private audioVolume = readInitialAudioVolume();
|
private audioVolume = readInitialAudioVolume();
|
||||||
private isMutedState =
|
private isMutedState =
|
||||||
readBrowserStorage(appConfig.storage.audioMutedKey) === ENABLED_FLAG_VALUE ||
|
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
|
||||||
this.audioVolume <= 0;
|
this.audioVolume <= 0;
|
||||||
|
|
||||||
public constructor(private readonly options: AudioControlOptions) {
|
public constructor(private readonly options: AudioControlOptions) {
|
||||||
|
|
@ -140,7 +142,7 @@ export class AudioControl {
|
||||||
private persist(): void {
|
private persist(): void {
|
||||||
writeBrowserStorage(
|
writeBrowserStorage(
|
||||||
appConfig.storage.audioMutedKey,
|
appConfig.storage.audioMutedKey,
|
||||||
this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
|
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
|
||||||
);
|
);
|
||||||
writeBrowserStorage(
|
writeBrowserStorage(
|
||||||
appConfig.storage.audioVolumeKey,
|
appConfig.storage.audioVolumeKey,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export class CollapsiblePanelAnimator {
|
export class CollapsiblePanelAnimator {
|
||||||
private _isOpen = false;
|
private _isOpen = false;
|
||||||
private focusBeforeOpen: HTMLElement | null = null;
|
private focusBeforeOpen: HTMLElement | null = null;
|
||||||
|
private readonly abortController = new AbortController();
|
||||||
public onOpen?: () => void;
|
public onOpen?: () => void;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|
@ -8,17 +9,23 @@ export class CollapsiblePanelAnimator {
|
||||||
private readonly collapsibleContent: HTMLElement,
|
private readonly collapsibleContent: HTMLElement,
|
||||||
ignoreForCloseOnClick: HTMLElement
|
ignoreForCloseOnClick: HTMLElement
|
||||||
) {
|
) {
|
||||||
toggleButton.addEventListener('click', this.toggle.bind(this));
|
const { signal } = this.abortController;
|
||||||
|
toggleButton.addEventListener('click', this.toggle, { signal });
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
'click',
|
'click',
|
||||||
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
|
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close(),
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
'keydown',
|
||||||
|
(event) => {
|
||||||
|
if (this._isOpen && event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
);
|
);
|
||||||
window.addEventListener('keydown', (event) => {
|
|
||||||
if (this._isOpen && event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.syncAccessibility();
|
this.syncAccessibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,12 +56,16 @@ export class CollapsiblePanelAnimator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggle() {
|
public readonly toggle = () => {
|
||||||
if (this._isOpen) {
|
if (this._isOpen) {
|
||||||
this.close();
|
this.close();
|
||||||
} else {
|
} else {
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.abortController.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isOpen() {
|
public get isOpen() {
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,17 @@ import {
|
||||||
type NumberControlConfig,
|
type NumberControlConfig,
|
||||||
} from '../config';
|
} from '../config';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
import {
|
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
|
||||||
hexColorToRgbColor,
|
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
|
||||||
rgbColorToCss,
|
|
||||||
rgbColorToHex,
|
|
||||||
type RgbColor,
|
|
||||||
} from '../utils/rgb-color';
|
|
||||||
|
|
||||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
|
||||||
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
|
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
|
||||||
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
|
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
|
||||||
type VibeNumberKey = keyof GardenAudioVibeSettings;
|
type NumberPropertyKey<T> = {
|
||||||
|
[Key in keyof T]-?: T[Key] extends number ? Key : never;
|
||||||
|
}[keyof T] &
|
||||||
|
string;
|
||||||
|
type VibeNumberKey = NumberPropertyKey<GardenAudioVibeSettings>;
|
||||||
|
|
||||||
interface PaneState extends GardenAudioVibeSettings {
|
interface PaneState extends GardenAudioVibeSettings {
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
|
|
@ -29,31 +28,6 @@ interface PaneState extends GardenAudioVibeSettings {
|
||||||
color3: string;
|
color3: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
|
|
||||||
const COLOR_REACTION_STATES = [
|
|
||||||
{ id: 'follow', label: 'Move Toward', value: 1 },
|
|
||||||
{ id: 'ignore', label: 'Ignore', value: 0 },
|
|
||||||
{ id: 'avoid', label: 'Move Away', value: -1 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const colorReactionRows = [
|
|
||||||
{
|
|
||||||
colorIndex: 0,
|
|
||||||
label: COLOR_REACTION_LABELS[0],
|
|
||||||
keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
colorIndex: 1,
|
|
||||||
label: COLOR_REACTION_LABELS[1],
|
|
||||||
keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
colorIndex: 2,
|
|
||||||
label: COLOR_REACTION_LABELS[2],
|
|
||||||
keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
|
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
|
||||||
|
|
||||||
const MUSIC_CONTROLS: ReadonlyArray<{
|
const MUSIC_CONTROLS: ReadonlyArray<{
|
||||||
|
|
@ -107,37 +81,11 @@ const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
|
||||||
return params;
|
return params;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getColorReactionStateIndex = (value: number): number =>
|
|
||||||
COLOR_REACTION_STATES.findIndex((state) => state.value === value);
|
|
||||||
|
|
||||||
const getColorReactionState = (value: number): (typeof COLOR_REACTION_STATES)[number] =>
|
|
||||||
COLOR_REACTION_STATES[getColorReactionStateIndex(value)] ?? COLOR_REACTION_STATES[1];
|
|
||||||
|
|
||||||
const getNextColorReactionState = (
|
|
||||||
value: number
|
|
||||||
): (typeof COLOR_REACTION_STATES)[number] => {
|
|
||||||
const index = getColorReactionStateIndex(value);
|
|
||||||
return COLOR_REACTION_STATES[
|
|
||||||
((index < 0 ? 1 : index) + 1) % COLOR_REACTION_STATES.length
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ConfigPane {
|
export class ConfigPane {
|
||||||
private readonly container: HTMLDivElement;
|
private readonly container: HTMLDivElement;
|
||||||
private readonly closeButton: HTMLButtonElement;
|
private readonly closeButton: HTMLButtonElement;
|
||||||
|
private readonly colorReactionMatrix: ColorReactionMatrixControl;
|
||||||
private readonly pane: Pane;
|
private readonly pane: Pane;
|
||||||
private readonly colorReactionButtons = new Map<
|
|
||||||
ColorReactionKey,
|
|
||||||
{
|
|
||||||
element: HTMLButtonElement;
|
|
||||||
sourceColorIndex: number;
|
|
||||||
targetColorIndex: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
private readonly colorReactionSwatches: Array<{
|
|
||||||
colorIndex: number;
|
|
||||||
element: HTMLElement;
|
|
||||||
}> = [];
|
|
||||||
private readonly state: PaneState = {
|
private readonly state: PaneState = {
|
||||||
backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
|
backgroundColor: rgbColorToHex(activeVibe.backgroundColor),
|
||||||
color1: rgbColorToHex(activeVibe.colors[0]),
|
color1: rgbColorToHex(activeVibe.colors[0]),
|
||||||
|
|
@ -147,6 +95,9 @@ export class ConfigPane {
|
||||||
};
|
};
|
||||||
|
|
||||||
public constructor(private readonly options: ConfigPaneOptions) {
|
public constructor(private readonly options: ConfigPaneOptions) {
|
||||||
|
this.colorReactionMatrix = new ColorReactionMatrixControl(
|
||||||
|
this.options.onRuntimeChange
|
||||||
|
);
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement('div');
|
||||||
this.container.className = 'config-pane-container';
|
this.container.className = 'config-pane-container';
|
||||||
|
|
||||||
|
|
@ -187,7 +138,7 @@ export class ConfigPane {
|
||||||
public refresh(): void {
|
public refresh(): void {
|
||||||
this.syncVibeState();
|
this.syncVibeState();
|
||||||
this.pane.refresh();
|
this.pane.refresh();
|
||||||
this.syncColorReactionMatrix();
|
this.colorReactionMatrix.sync();
|
||||||
this.syncOpenState();
|
this.syncOpenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,7 +184,7 @@ export class ConfigPane {
|
||||||
this.setUpVibeSection(container);
|
this.setUpVibeSection(container);
|
||||||
this.addRuntimeSection(container, runtimeFolderOrder[0], true);
|
this.addRuntimeSection(container, runtimeFolderOrder[0], true);
|
||||||
this.addRuntimeSection(container, runtimeFolderOrder[1], true);
|
this.addRuntimeSection(container, runtimeFolderOrder[1], true);
|
||||||
this.addColorReactionMatrix(container);
|
this.colorReactionMatrix.addTo(container);
|
||||||
this.addRuntimeSection(container, runtimeFolderOrder[2], true);
|
this.addRuntimeSection(container, runtimeFolderOrder[2], true);
|
||||||
const performanceFolder = this.addRuntimeSection(
|
const performanceFolder = this.addRuntimeSection(
|
||||||
container,
|
container,
|
||||||
|
|
@ -242,7 +193,7 @@ export class ConfigPane {
|
||||||
);
|
);
|
||||||
this.addFpsOverlayBinding(performanceFolder);
|
this.addFpsOverlayBinding(performanceFolder);
|
||||||
this.setUpMusicSection(container);
|
this.setUpMusicSection(container);
|
||||||
this.syncColorReactionMatrix();
|
this.colorReactionMatrix.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private setUpVibeSection(container: PaneContainer): void {
|
private setUpVibeSection(container: PaneContainer): void {
|
||||||
|
|
@ -291,7 +242,7 @@ export class ConfigPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateColor(color);
|
updateColor(color);
|
||||||
this.syncColorReactionMatrix();
|
this.colorReactionMatrix.sync();
|
||||||
this.options.onConfigChange();
|
this.options.onConfigChange();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -348,132 +299,6 @@ export class ConfigPane {
|
||||||
.on('change', () => this.options.onConfigChange());
|
.on('change', () => this.options.onConfigChange());
|
||||||
}
|
}
|
||||||
|
|
||||||
private addColorReactionMatrix(container: PaneContainer): void {
|
|
||||||
const folder = container.addFolder({
|
|
||||||
title: 'Color Behavior',
|
|
||||||
expanded: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const matrix = document.createElement('div');
|
|
||||||
matrix.className = 'color-reaction-matrix';
|
|
||||||
|
|
||||||
matrix.appendChild(this.createColorReactionCorner());
|
|
||||||
colorReactionRows.forEach((row) => {
|
|
||||||
matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
|
|
||||||
});
|
|
||||||
|
|
||||||
colorReactionRows.forEach((row) => {
|
|
||||||
matrix.appendChild(this.createColorReactionHeader(row.colorIndex, row.label));
|
|
||||||
row.keys.forEach((key, columnIndex) => {
|
|
||||||
matrix.appendChild(
|
|
||||||
this.createColorReactionCell(key, row.colorIndex, columnIndex)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const matrixBlade = folder.addBlade({ view: 'separator' });
|
|
||||||
matrixBlade.element.classList.add('color-reaction-matrix-blade');
|
|
||||||
matrixBlade.element.replaceChildren(matrix);
|
|
||||||
this.syncColorReactionMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
private createColorReactionCorner(): HTMLDivElement {
|
|
||||||
const corner = document.createElement('div');
|
|
||||||
corner.className = 'color-reaction-matrix__corner';
|
|
||||||
return corner;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'color-reaction-matrix__header';
|
|
||||||
header.setAttribute('aria-label', label);
|
|
||||||
header.title = label;
|
|
||||||
|
|
||||||
const swatch = document.createElement('span');
|
|
||||||
swatch.className = 'color-reaction-matrix__swatch';
|
|
||||||
this.colorReactionSwatches.push({ colorIndex, element: swatch });
|
|
||||||
header.appendChild(swatch);
|
|
||||||
|
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createColorReactionCell(
|
|
||||||
key: ColorReactionKey,
|
|
||||||
sourceColorIndex: number,
|
|
||||||
targetColorIndex: number
|
|
||||||
): HTMLDivElement {
|
|
||||||
const cell = document.createElement('div');
|
|
||||||
cell.className = 'color-reaction-matrix__cell';
|
|
||||||
|
|
||||||
const config = appConfig.runtimeSettings.controls[key];
|
|
||||||
if (!config) {
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.className = 'color-reaction-matrix__button';
|
|
||||||
button.type = 'button';
|
|
||||||
|
|
||||||
const icon = document.createElement('span');
|
|
||||||
icon.className = 'color-reaction-matrix__icon';
|
|
||||||
button.appendChild(icon);
|
|
||||||
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
const currentValue = normalizeNumberControlValue(settings[key], config);
|
|
||||||
const nextState = getNextColorReactionState(currentValue);
|
|
||||||
settings[key] = nextState.value;
|
|
||||||
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
|
|
||||||
this.options.onRuntimeChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.colorReactionButtons.set(key, {
|
|
||||||
element: button,
|
|
||||||
sourceColorIndex,
|
|
||||||
targetColorIndex,
|
|
||||||
});
|
|
||||||
cell.appendChild(button);
|
|
||||||
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncColorReactionMatrix(): void {
|
|
||||||
this.colorReactionButtons.forEach(
|
|
||||||
({ element, sourceColorIndex, targetColorIndex }, key) => {
|
|
||||||
this.syncColorReactionButton(element, key, sourceColorIndex, targetColorIndex);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
|
|
||||||
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncColorReactionButton(
|
|
||||||
button: HTMLButtonElement,
|
|
||||||
key: ColorReactionKey,
|
|
||||||
sourceColorIndex: number,
|
|
||||||
targetColorIndex: number
|
|
||||||
): void {
|
|
||||||
const config = appConfig.runtimeSettings.controls[key];
|
|
||||||
if (!config) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings[key] = normalizeNumberControlValue(settings[key], config);
|
|
||||||
|
|
||||||
const state = getColorReactionState(settings[key]);
|
|
||||||
const nextState = getNextColorReactionState(settings[key]);
|
|
||||||
const sourceLabel = COLOR_REACTION_LABELS[sourceColorIndex];
|
|
||||||
const targetLabel = COLOR_REACTION_LABELS[targetColorIndex];
|
|
||||||
|
|
||||||
button.dataset.reaction = state.id;
|
|
||||||
button.setAttribute(
|
|
||||||
'aria-label',
|
|
||||||
`${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
|
|
||||||
);
|
|
||||||
button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setUpMusicSection(container: PaneContainer): void {
|
private setUpMusicSection(container: PaneContainer): void {
|
||||||
const folder = container.addFolder({ title: 'Music', expanded: true });
|
const folder = container.addFolder({ title: 'Music', expanded: true });
|
||||||
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
|
MUSIC_CONTROLS.forEach(({ key, label, min, max, step }) => {
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,15 @@ export class EraserSizeControl {
|
||||||
HTMLLabelElement
|
HTMLLabelElement
|
||||||
);
|
);
|
||||||
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
|
||||||
|
private isActive = false;
|
||||||
|
|
||||||
public constructor(private readonly options: EraserSizeControlOptions) {
|
public constructor(private readonly options: EraserSizeControlOptions) {
|
||||||
this.control.addEventListener('pointerdown', this.options.onActivate);
|
this.control.addEventListener('pointerdown', this.activate);
|
||||||
this.control.addEventListener('click', this.options.onActivate);
|
this.control.addEventListener('click', this.activate);
|
||||||
this.slider.addEventListener('focus', this.options.onActivate);
|
this.slider.addEventListener('focus', this.activate);
|
||||||
this.slider.addEventListener('input', () => {
|
this.slider.addEventListener('input', () => {
|
||||||
settings.eraserSize = clampEraserSize(Number(this.slider.value));
|
settings.eraserSize = clampEraserSize(Number(this.slider.value));
|
||||||
this.options.onActivate();
|
this.activate();
|
||||||
this.render();
|
this.render();
|
||||||
this.options.onChange();
|
this.options.onChange();
|
||||||
});
|
});
|
||||||
|
|
@ -59,6 +60,25 @@ export class EraserSizeControl {
|
||||||
ratio;
|
ratio;
|
||||||
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
|
||||||
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
||||||
|
this.syncActiveState();
|
||||||
this.options.getGame()?.updateEraserPreview();
|
this.options.getGame()?.updateEraserPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setActive(isActive: boolean): void {
|
||||||
|
this.isActive = isActive;
|
||||||
|
this.syncActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly activate = () => {
|
||||||
|
this.setActive(true);
|
||||||
|
this.options.onActivate();
|
||||||
|
};
|
||||||
|
|
||||||
|
private syncActiveState(): void {
|
||||||
|
this.control.classList.toggle('active', this.isActive);
|
||||||
|
this.slider.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
this.isActive ? 'Eraser size, active' : 'Eraser size'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export class FullScreenHandler {
|
export class FullScreenHandler {
|
||||||
|
private readonly abortController = new AbortController();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly toggleButton: HTMLElement,
|
private readonly toggleButton: HTMLElement,
|
||||||
target: HTMLElement
|
target: HTMLElement
|
||||||
|
|
@ -10,26 +12,35 @@ export class FullScreenHandler {
|
||||||
|
|
||||||
this.updateButtons();
|
this.updateButtons();
|
||||||
|
|
||||||
addEventListener('fullscreenchange', this.updateButtons.bind(this));
|
const { signal } = this.abortController;
|
||||||
toggleButton.addEventListener('click', () => {
|
addEventListener('fullscreenchange', this.updateButtons, { signal });
|
||||||
if (FullScreenHandler.isInFullScreenMode()) {
|
toggleButton.addEventListener(
|
||||||
void document.exitFullscreen();
|
'click',
|
||||||
return;
|
() => {
|
||||||
}
|
if (FullScreenHandler.isInFullScreenMode()) {
|
||||||
|
void document.exitFullscreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void target.requestFullscreen().catch(() => undefined);
|
void target.requestFullscreen().catch(() => undefined);
|
||||||
});
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static isInFullScreenMode(): boolean {
|
public static isInFullScreenMode(): boolean {
|
||||||
return document.fullscreenElement !== null;
|
return document.fullscreenElement !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateButtons(): void {
|
public destroy(): void {
|
||||||
|
this.abortController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly updateButtons = (): void => {
|
||||||
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
|
||||||
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
|
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
|
||||||
this.toggleButton.classList.toggle('active', isInFullScreenMode);
|
this.toggleButton.classList.toggle('active', isInFullScreenMode);
|
||||||
this.toggleButton.setAttribute('aria-label', label);
|
this.toggleButton.setAttribute('aria-label', label);
|
||||||
this.toggleButton.title = label;
|
this.toggleButton.title = label;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,18 +99,18 @@ export class MenuHider {
|
||||||
}
|
}
|
||||||
|
|
||||||
private reveal(): void {
|
private reveal(): void {
|
||||||
|
if (!this.isHidden && this.hideTimeout === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.clearHideTimeout();
|
this.clearHideTimeout();
|
||||||
this.isHidden = false;
|
this.isHidden = false;
|
||||||
this.element.classList.remove('menu-hidden');
|
this.element.classList.remove('menu-hidden');
|
||||||
this.element.setAttribute('aria-hidden', 'false');
|
|
||||||
this.element.inert = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private hide(): void {
|
private hide(): void {
|
||||||
this.isHidden = true;
|
this.isHidden = true;
|
||||||
this.element.classList.add('menu-hidden');
|
this.element.classList.add('menu-hidden');
|
||||||
this.element.setAttribute('aria-hidden', 'true');
|
|
||||||
this.element.inert = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearHideTimeout(): void {
|
private clearHideTimeout(): void {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const getMirrorSegmentRatio = (count: number): number => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMirrorSegmentCount = (count: number): string =>
|
const formatMirrorSegmentCount = (count: number): string =>
|
||||||
count === appConfig.toolbar.mirror.default
|
count <= 1
|
||||||
? appConfig.toolbar.mirror.offLabel
|
? appConfig.toolbar.mirror.offLabel
|
||||||
: `${count} ${
|
: `${count} ${
|
||||||
appConfig.toolbar.mirror.names[
|
appConfig.toolbar.mirror.names[
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,16 @@
|
||||||
import type GameLoop from '../game-loop/game-loop';
|
import type GameLoop from '../game-loop/game-loop';
|
||||||
import { activeVibe, settings } from '../settings';
|
import { activeVibe, settings } from '../settings';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
|
||||||
import { ErrorCode, RuntimeError } from '../utils/error-handler';
|
import { ErrorCode, RuntimeError } from '../utils/error-handler';
|
||||||
import { rgbColorToCss } from '../utils/rgb-color';
|
import { rgbColorToCss } from '../utils/rgb-color';
|
||||||
|
|
||||||
interface PaletteControlOptions {
|
interface PaletteControlOptions {
|
||||||
getGame: () => GameLoop | null;
|
getGame: () => GameLoop | null;
|
||||||
onChange: () => void;
|
onChange: () => void;
|
||||||
|
onModeChange?: (isEraserActive: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PaletteControl {
|
export class PaletteControl {
|
||||||
private readonly swatches = queryRequiredColorSwatches();
|
private readonly swatches = queryRequiredColorSwatches();
|
||||||
private readonly eraserControl = queryRequiredElement(
|
|
||||||
'.eraser-size-control',
|
|
||||||
HTMLLabelElement
|
|
||||||
);
|
|
||||||
private isEraserActiveState = false;
|
private isEraserActiveState = false;
|
||||||
|
|
||||||
public constructor(private readonly options: PaletteControlOptions) {
|
public constructor(private readonly options: PaletteControlOptions) {
|
||||||
|
|
@ -23,6 +19,7 @@ export class PaletteControl {
|
||||||
settings.selectedColorIndex = index;
|
settings.selectedColorIndex = index;
|
||||||
this.isEraserActiveState = false;
|
this.isEraserActiveState = false;
|
||||||
this.render();
|
this.render();
|
||||||
|
this.options.onModeChange?.(false);
|
||||||
this.options.onChange();
|
this.options.onChange();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -35,17 +32,16 @@ export class PaletteControl {
|
||||||
public setEraserActive(active: boolean): void {
|
public setEraserActive(active: boolean): void {
|
||||||
this.isEraserActiveState = active;
|
this.isEraserActiveState = active;
|
||||||
this.render();
|
this.render();
|
||||||
|
this.options.onModeChange?.(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): void {
|
public render(): void {
|
||||||
this.swatches.forEach((swatch, index) => {
|
this.swatches.forEach((swatch, index) => {
|
||||||
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
||||||
swatch.classList.toggle(
|
const isActive = settings.selectedColorIndex === index && !this.isEraserActiveState;
|
||||||
'active',
|
swatch.classList.toggle('active', isActive);
|
||||||
settings.selectedColorIndex === index && !this.isEraserActiveState
|
swatch.setAttribute('aria-pressed', String(isActive));
|
||||||
);
|
|
||||||
});
|
});
|
||||||
this.eraserControl.classList.toggle('active', this.isEraserActiveState);
|
|
||||||
this.options.getGame()?.setEraseMode(this.isEraserActiveState);
|
this.options.getGame()?.setEraseMode(this.isEraserActiveState);
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
'--garden-background',
|
'--garden-background',
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,24 @@ export class SplashScreen {
|
||||||
public awaitStart(onStart: () => void): Promise<void> {
|
public awaitStart(onStart: () => void): Promise<void> {
|
||||||
this.startButton.disabled = false;
|
this.startButton.disabled = false;
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Enter' || event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this.startButton.click();
|
||||||
|
};
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
this.startButton.removeEventListener('click', onClick);
|
this.startButton.removeEventListener('click', onClick);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
onStart();
|
onStart();
|
||||||
this.setVisible(this.splash, false);
|
this.setVisible(this.splash, false);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.startButton.addEventListener('click', onClick);
|
this.startButton.addEventListener('click', onClick);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { activeVibe, applyVibeSettings } from '../settings';
|
import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings';
|
||||||
import { queryRequiredElement } from '../utils/dom';
|
import { queryRequiredElement } from '../utils/dom';
|
||||||
import { VIBE_PRESETS, type VibeId } from '../vibes';
|
import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri';
|
||||||
|
import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||||
|
|
||||||
interface VibeSelection {
|
interface VibeSelection {
|
||||||
source: string;
|
source: string;
|
||||||
|
userGesture: boolean;
|
||||||
vibeId: VibeId;
|
vibeId: VibeId;
|
||||||
vibeName: string;
|
vibeName: string;
|
||||||
}
|
}
|
||||||
|
|
@ -13,6 +15,7 @@ interface VibeNavigatorOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VibeNavigator {
|
export class VibeNavigator {
|
||||||
|
private readonly abortController = new AbortController();
|
||||||
private readonly previousButton = queryRequiredElement(
|
private readonly previousButton = queryRequiredElement(
|
||||||
'.previous-vibe',
|
'.previous-vibe',
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
|
|
@ -20,18 +23,60 @@ export class VibeNavigator {
|
||||||
private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
|
private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
|
||||||
|
|
||||||
public constructor(private readonly options: VibeNavigatorOptions) {
|
public constructor(private readonly options: VibeNavigatorOptions) {
|
||||||
this.previousButton.addEventListener('click', () =>
|
rememberActiveVibeSelection();
|
||||||
this.select(-1, 'previous-button')
|
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||||
|
|
||||||
|
const { signal } = this.abortController;
|
||||||
|
this.previousButton.addEventListener(
|
||||||
|
'click',
|
||||||
|
() => this.select(-1, 'previous-button'),
|
||||||
|
{ signal }
|
||||||
);
|
);
|
||||||
this.nextButton.addEventListener('click', () => this.select(1, 'next-button'));
|
this.nextButton.addEventListener('click', () => this.select(1, 'next-button'), {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
window.addEventListener('popstate', this.selectFromCurrentUri, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.abortController.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
private select(offset: number, source: string): void {
|
private select(offset: number, source: string): void {
|
||||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||||
|
const currentIndex = current >= 0 ? current : 0;
|
||||||
const vibe =
|
const vibe =
|
||||||
VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
|
VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
|
||||||
const activePreset = applyVibeSettings(vibe);
|
const activePreset = applyVibeSettings(vibe);
|
||||||
|
writeCurrentVibeUri(activePreset.id, 'push');
|
||||||
|
this.notifyChange(activePreset, source, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly selectFromCurrentUri = (): void => {
|
||||||
|
const vibeId = getCurrentUriVibeId();
|
||||||
|
if (!vibeId || vibeId === activeVibe.id) {
|
||||||
|
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vibe = getVibeById(vibeId);
|
||||||
|
if (!vibe) {
|
||||||
|
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePreset = applyVibeSettings(vibe);
|
||||||
|
writeCurrentVibeUri(activePreset.id, 'replace');
|
||||||
|
this.notifyChange(activePreset, 'uri-popstate', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
private notifyChange(
|
||||||
|
activePreset: typeof activeVibe,
|
||||||
|
source: string,
|
||||||
|
userGesture: boolean
|
||||||
|
): void {
|
||||||
this.options.onChange({
|
this.options.onChange({
|
||||||
|
userGesture,
|
||||||
vibeId: activePreset.id,
|
vibeId: activePreset.id,
|
||||||
vibeName: activePreset.name,
|
vibeName: activePreset.name,
|
||||||
source,
|
source,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,43 @@
|
||||||
// Use the device's max workgroup size so we get full SIMD/wave occupancy on
|
const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const;
|
||||||
// hardware that supports more than the WebGPU minimum of 256.
|
|
||||||
export const getAgentWorkgroupSize = (device: GPUDevice): number =>
|
export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number];
|
||||||
device.limits.maxComputeInvocationsPerWorkgroup;
|
|
||||||
|
const AGENT_WORKGROUP_SIZE_TARGETS = {
|
||||||
|
// Keep shader-specific targets conservative. Using the device maximum can
|
||||||
|
// hurt occupancy and makes compaction's workgroup scan more expensive.
|
||||||
|
simulation: 256,
|
||||||
|
eraser: 256,
|
||||||
|
resize: 256,
|
||||||
|
compaction: 256,
|
||||||
|
} satisfies Record<AgentWorkgroupKind, number>;
|
||||||
|
|
||||||
|
export const getAgentWorkgroupSize = (
|
||||||
|
device: GPUDevice,
|
||||||
|
kind: AgentWorkgroupKind = 'simulation'
|
||||||
|
): number => {
|
||||||
|
const deviceLimit = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
Math.min(
|
||||||
|
device.limits.maxComputeInvocationsPerWorkgroup,
|
||||||
|
device.limits.maxComputeWorkgroupSizeX
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinAgentWorkgroupSize = (device: GPUDevice): number =>
|
||||||
|
Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind)));
|
||||||
|
|
||||||
export const substituteAgentWorkgroupSize = (
|
export const substituteAgentWorkgroupSize = (
|
||||||
device: GPUDevice,
|
device: GPUDevice,
|
||||||
shaderCode: string
|
shaderCode: string,
|
||||||
|
kind: AgentWorkgroupKind = 'simulation'
|
||||||
): string =>
|
): string =>
|
||||||
shaderCode.replaceAll(
|
shaderCode.replaceAll(
|
||||||
'__AGENT_WORKGROUP_SIZE__',
|
'__AGENT_WORKGROUP_SIZE__',
|
||||||
String(getAgentWorkgroupSize(device))
|
String(getAgentWorkgroupSize(device, kind))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const dispatchAgentWorkgroups = (
|
export const dispatchAgentWorkgroups = (
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ struct Counters {
|
||||||
aliveAgentCount: atomic<u32>,
|
aliveAgentCount: atomic<u32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearCompactedTailStride = 4u;
|
const clearCompactedTailStride = __CLEAR_COMPACTED_TAIL_STRIDE__u;
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
||||||
|
|
@ -30,7 +30,7 @@ fn main(
|
||||||
var isAlive = false;
|
var isAlive = false;
|
||||||
var agent: Agent;
|
var agent: Agent;
|
||||||
if id < settings.agentCount {
|
if id < settings.agentCount {
|
||||||
isAlive = agents[id].colorIndex >= 0.0;
|
isAlive = agents[id].colorIndex >= 0.0 && agents[id].colorIndex < 2.5;
|
||||||
if isAlive {
|
if isAlive {
|
||||||
agent = agents[id];
|
agent = agents[id];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUBuffer>(
|
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUBuffer]>(
|
||||||
(active, inactive) =>
|
(active, inactive) =>
|
||||||
this.device.createBindGroup({
|
this.device.createBindGroup({
|
||||||
layout: this.bindGroupLayout,
|
layout: this.bindGroupLayout,
|
||||||
|
|
@ -36,7 +36,8 @@ export class AgentGenerationPipeline {
|
||||||
private readonly resizePipeline: GPUComputePipeline;
|
private readonly resizePipeline: GPUComputePipeline;
|
||||||
private readonly compactionPipeline: GPUComputePipeline;
|
private readonly compactionPipeline: GPUComputePipeline;
|
||||||
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
||||||
private readonly workgroupSize: number;
|
private readonly resizeWorkgroupSize: number;
|
||||||
|
private readonly compactionWorkgroupSize: number;
|
||||||
|
|
||||||
private activeAgentsBuffer: GPUBuffer;
|
private activeAgentsBuffer: GPUBuffer;
|
||||||
private inactiveAgentsBuffer: GPUBuffer;
|
private inactiveAgentsBuffer: GPUBuffer;
|
||||||
|
|
@ -110,20 +111,33 @@ export class AgentGenerationPipeline {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.workgroupSize = getAgentWorkgroupSize(device);
|
this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize');
|
||||||
const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema);
|
this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction');
|
||||||
|
const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize');
|
||||||
|
const compactionSchema = substituteAgentWorkgroupSize(
|
||||||
|
device,
|
||||||
|
agentSchema,
|
||||||
|
'compaction'
|
||||||
|
);
|
||||||
|
|
||||||
this.resizePipeline = device.createComputePipeline({
|
this.resizePipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(device, sizedSchema, resizeShader),
|
module: smartCompile(device, resizeSchema, resizeShader),
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const compactionModule = smartCompile(device, sizedSchema, compactionShader);
|
const compactionModule = smartCompile(
|
||||||
|
device,
|
||||||
|
compactionSchema,
|
||||||
|
compactionShader.replaceAll(
|
||||||
|
'__CLEAR_COMPACTED_TAIL_STRIDE__',
|
||||||
|
AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE.toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
this.compactionPipeline = device.createComputePipeline({
|
this.compactionPipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
|
|
@ -248,7 +262,7 @@ export class AgentGenerationPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.resizePipeline);
|
passEncoder.setPipeline(this.resizePipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
|
dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
@ -267,11 +281,11 @@ export class AgentGenerationPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.compactionPipeline);
|
passEncoder.setPipeline(this.compactionPipeline);
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
|
dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount);
|
||||||
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
||||||
dispatchAgentWorkgroups(
|
dispatchAgentWorkgroups(
|
||||||
passEncoder,
|
passEncoder,
|
||||||
this.workgroupSize,
|
this.compactionWorkgroupSize,
|
||||||
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
|
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
|
||||||
);
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAgentWorkgroupSize } from './agent-dispatch';
|
import { getMinAgentWorkgroupSize } from './agent-dispatch';
|
||||||
|
|
||||||
export const AGENT_FLOAT_COUNT = 8;
|
export const AGENT_FLOAT_COUNT = 8;
|
||||||
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
|
@ -58,7 +58,7 @@ export const getMaxSupportedAgentCount = (
|
||||||
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
||||||
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
|
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
|
||||||
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
|
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
|
||||||
getAgentWorkgroupSize(device)
|
getMinAgentWorkgroupSize(device)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
|
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||||
import {
|
import {
|
||||||
createCachedBufferWrite,
|
createCachedBufferWrite,
|
||||||
writeBufferIfChanged,
|
writeBufferIfChanged,
|
||||||
|
|
@ -38,28 +38,34 @@ export interface AgentSettings {
|
||||||
introNearDistanceInner: number;
|
introNearDistanceInner: number;
|
||||||
introTurnRateMultiplier: number;
|
introTurnRateMultiplier: number;
|
||||||
introRandomTurnMultiplier: number;
|
introRandomTurnMultiplier: number;
|
||||||
introFarMoveMultiplier: number;
|
|
||||||
introNearMoveMultiplier: number;
|
|
||||||
introStepStopDistance: number;
|
introStepStopDistance: number;
|
||||||
randomTimeScale: number;
|
randomTimeScale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNIFORM_COUNT = 30;
|
// The Settings struct in WGSL starts with a mat3x3<f32> reactionMatrix.
|
||||||
|
// In uniform layout each of its 3 columns is stored as a vec3<f32> padded to
|
||||||
|
// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused
|
||||||
|
// padding). Remaining scalars pack tightly from float 12 onward.
|
||||||
|
const UNIFORM_COUNT = 32;
|
||||||
|
const REACTION_MATRIX_COL0 = 0;
|
||||||
|
const REACTION_MATRIX_COL1 = 4;
|
||||||
|
const REACTION_MATRIX_COL2 = 8;
|
||||||
|
const SCALAR_BASE = 12;
|
||||||
|
|
||||||
export class AgentPipeline {
|
export class AgentPipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipelineFull: GPUComputePipeline;
|
||||||
|
private readonly pipelineSteady: GPUComputePipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly workgroupSize: number;
|
private readonly workgroupSize: number;
|
||||||
|
private useSteadyPipeline = false;
|
||||||
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||||
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||||
private readonly uniformCache = createCachedBufferWrite(
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
private readonly bindGroupCache = createBindGroupCache3<
|
private readonly bindGroupCache = createBindGroupCache<
|
||||||
GPUBuffer,
|
[GPUBuffer, GPUTextureView, GPUTextureView]
|
||||||
GPUTextureView,
|
|
||||||
GPUTextureView
|
|
||||||
>((agentsBuffer, trailMapIn, trailMapOut) =>
|
>((agentsBuffer, trailMapIn, trailMapOut) =>
|
||||||
this.device.createBindGroup({
|
this.device.createBindGroup({
|
||||||
layout: this.bindGroupLayout,
|
layout: this.bindGroupLayout,
|
||||||
|
|
@ -104,23 +110,30 @@ export class AgentPipeline {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.workgroupSize = getAgentWorkgroupSize(device);
|
this.workgroupSize = getAgentWorkgroupSize(device, 'simulation');
|
||||||
const shaderModule = smartCompile(
|
const shaderModule = smartCompile(
|
||||||
device,
|
device,
|
||||||
CommonState.shaderCode,
|
CommonState.shaderCode,
|
||||||
substituteAgentWorkgroupSize(device, agentSchema),
|
substituteAgentWorkgroupSize(device, agentSchema, 'simulation'),
|
||||||
shader
|
shader
|
||||||
);
|
);
|
||||||
const pipelineLayout = device.createPipelineLayout({
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||||
});
|
});
|
||||||
this.pipeline = device.createComputePipeline({
|
this.pipelineFull = device.createComputePipeline({
|
||||||
layout: pipelineLayout,
|
layout: pipelineLayout,
|
||||||
compute: {
|
compute: {
|
||||||
module: shaderModule,
|
module: shaderModule,
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.pipelineSteady = device.createComputePipeline({
|
||||||
|
layout: pipelineLayout,
|
||||||
|
compute: {
|
||||||
|
module: shaderModule,
|
||||||
|
entryPoint: 'mainSteady',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
this.uniforms = device.createBuffer({
|
this.uniforms = device.createBuffer({
|
||||||
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -153,8 +166,7 @@ export class AgentPipeline {
|
||||||
introProgressCutoff,
|
introProgressCutoff,
|
||||||
introTurnRateMultiplier,
|
introTurnRateMultiplier,
|
||||||
introRandomTurnMultiplier,
|
introRandomTurnMultiplier,
|
||||||
introFarMoveMultiplier,
|
introMoveSpeed,
|
||||||
introNearMoveMultiplier,
|
|
||||||
introStepStopDistance,
|
introStepStopDistance,
|
||||||
randomTimeScale,
|
randomTimeScale,
|
||||||
time,
|
time,
|
||||||
|
|
@ -164,40 +176,46 @@ export class AgentPipeline {
|
||||||
deltaTime: number;
|
deltaTime: number;
|
||||||
time: number;
|
time: number;
|
||||||
agentCount: number;
|
agentCount: number;
|
||||||
|
introMoveSpeed: number;
|
||||||
introProgress?: number;
|
introProgress?: number;
|
||||||
}) {
|
}) {
|
||||||
this.agentCount = agentCount;
|
this.agentCount = agentCount;
|
||||||
this.uniformValues[0] = moveSpeed * deltaTime;
|
const resolvedIntroProgress = introProgress ?? 1;
|
||||||
this.uniformValues[1] = turnSpeed * deltaTime;
|
// Once the intro target phase ends nothing reads intro fields again, so the
|
||||||
|
// steady-only pipeline can replace the full one for the rest of the session.
|
||||||
|
this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff;
|
||||||
|
// Reaction matrix: column N holds the weights for source colorIndex == N.
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2;
|
||||||
|
this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3;
|
||||||
|
this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime;
|
||||||
|
this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime;
|
||||||
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
|
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
|
||||||
this.uniformValues[2] = Math.sin(sensorAngle);
|
this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle);
|
||||||
this.uniformValues[3] = Math.cos(sensorAngle);
|
this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle);
|
||||||
this.uniformValues[4] = sensorOffsetDistance;
|
this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance;
|
||||||
this.uniformValues[5] = turnWhenLost;
|
this.uniformValues[SCALAR_BASE + 5] = turnWhenLost;
|
||||||
this.uniformValues[6] = individualTrailWeight;
|
this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight;
|
||||||
this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount));
|
this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount));
|
||||||
this.uniformValues[8] = introProgress ?? 1;
|
this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress;
|
||||||
this.uniformValues[9] = color1ToColor1;
|
this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale;
|
||||||
this.uniformValues[10] = color1ToColor2;
|
this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner;
|
||||||
this.uniformValues[11] = color1ToColor3;
|
this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin;
|
||||||
this.uniformValues[12] = color2ToColor1;
|
this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier;
|
||||||
this.uniformValues[13] = color2ToColor2;
|
this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend;
|
||||||
this.uniformValues[14] = color2ToColor3;
|
this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff;
|
||||||
this.uniformValues[15] = color3ToColor1;
|
this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier;
|
||||||
this.uniformValues[16] = color3ToColor2;
|
this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier;
|
||||||
this.uniformValues[17] = color3ToColor3;
|
this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime;
|
||||||
this.uniformValues[18] = forwardRotationScale;
|
this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance;
|
||||||
this.uniformValues[19] = introNearDistanceInner;
|
this.uniformUintValues[SCALAR_BASE + 19] =
|
||||||
this.uniformValues[20] = introNearDistanceMin;
|
Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
||||||
this.uniformValues[21] = introNearSensorOffsetMultiplier;
|
|
||||||
this.uniformValues[22] = introTargetAngleBlend;
|
|
||||||
this.uniformValues[23] = introProgressCutoff;
|
|
||||||
this.uniformValues[24] = introTurnRateMultiplier;
|
|
||||||
this.uniformValues[25] = introRandomTurnMultiplier;
|
|
||||||
this.uniformValues[26] = introFarMoveMultiplier;
|
|
||||||
this.uniformValues[27] = introNearMoveMultiplier;
|
|
||||||
this.uniformValues[28] = introStepStopDistance;
|
|
||||||
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
|
||||||
writeBufferIfChanged(
|
writeBufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
@ -219,7 +237,9 @@ export class AgentPipeline {
|
||||||
const passEncoder = commandEncoder.beginComputePass(
|
const passEncoder = commandEncoder.beginComputePass(
|
||||||
timestampWrites ? { timestampWrites } : undefined
|
timestampWrites ? { timestampWrites } : undefined
|
||||||
);
|
);
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(
|
||||||
|
this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull
|
||||||
|
);
|
||||||
this.commonState.execute(passEncoder);
|
this.commonState.execute(passEncoder);
|
||||||
passEncoder.setBindGroup(
|
passEncoder.setBindGroup(
|
||||||
1,
|
1,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,16 @@ const PI: f32 = 3.14159265359;
|
||||||
const TAU: f32 = 6.28318530718;
|
const TAU: f32 = 6.28318530718;
|
||||||
const INV_TAU: f32 = 0.15915494309;
|
const INV_TAU: f32 = 0.15915494309;
|
||||||
|
|
||||||
|
const CHANNEL_MASKS = array<vec3<f32>, 3>(
|
||||||
|
vec3<f32>(1.0, 0.0, 0.0),
|
||||||
|
vec3<f32>(0.0, 1.0, 0.0),
|
||||||
|
vec3<f32>(0.0, 0.0, 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
struct Settings {
|
struct Settings {
|
||||||
|
// Columns are indexed by source colorIndex; each column holds the per-target
|
||||||
|
// weights (colorXToColor1, colorXToColor2, colorXToColor3).
|
||||||
|
reactionMatrix: mat3x3<f32>,
|
||||||
moveRate: f32,
|
moveRate: f32,
|
||||||
turnRate: f32,
|
turnRate: f32,
|
||||||
sensorAngleSin: f32,
|
sensorAngleSin: f32,
|
||||||
|
|
@ -12,15 +21,6 @@ struct Settings {
|
||||||
individualTrailWeight: f32,
|
individualTrailWeight: f32,
|
||||||
agentCount: u32,
|
agentCount: u32,
|
||||||
introProgress: f32,
|
introProgress: f32,
|
||||||
color1ToColor1: f32,
|
|
||||||
color1ToColor2: f32,
|
|
||||||
color1ToColor3: f32,
|
|
||||||
color2ToColor1: f32,
|
|
||||||
color2ToColor2: f32,
|
|
||||||
color2ToColor3: f32,
|
|
||||||
color3ToColor1: f32,
|
|
||||||
color3ToColor2: f32,
|
|
||||||
color3ToColor3: f32,
|
|
||||||
forwardRotationScale: f32,
|
forwardRotationScale: f32,
|
||||||
introNearDistanceInner: f32,
|
introNearDistanceInner: f32,
|
||||||
introNearDistanceMin: f32,
|
introNearDistanceMin: f32,
|
||||||
|
|
@ -29,8 +29,7 @@ struct Settings {
|
||||||
introProgressCutoff: f32,
|
introProgressCutoff: f32,
|
||||||
introTurnRateMultiplier: f32,
|
introTurnRateMultiplier: f32,
|
||||||
introRandomTurnMultiplier: f32,
|
introRandomTurnMultiplier: f32,
|
||||||
introFarMoveMultiplier: f32,
|
introMoveRate: f32,
|
||||||
introNearMoveMultiplier: f32,
|
|
||||||
introStepStopDistance: f32,
|
introStepStopDistance: f32,
|
||||||
randomTimeSeed: u32,
|
randomTimeSeed: u32,
|
||||||
};
|
};
|
||||||
|
|
@ -39,6 +38,11 @@ struct Settings {
|
||||||
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
||||||
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
|
||||||
|
struct AgentMovement {
|
||||||
|
rotation: f32,
|
||||||
|
step: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
@compute @workgroup_size(agentWorkgroupSize)
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
|
|
@ -54,8 +58,8 @@ fn main(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var position = agents[id].position;
|
let position = agents[id].position;
|
||||||
var angle = agents[id].angle;
|
let angle = agents[id].angle;
|
||||||
var targetPosition = vec2<f32>(-1.0, -1.0);
|
var targetPosition = vec2<f32>(-1.0, -1.0);
|
||||||
var hasIntroTarget = false;
|
var hasIntroTarget = false;
|
||||||
if settings.introProgress < settings.introProgressCutoff {
|
if settings.introProgress < settings.introProgressCutoff {
|
||||||
|
|
@ -70,92 +74,153 @@ fn main(
|
||||||
let reactionMask = get_reaction_mask(colorIndex);
|
let reactionMask = get_reaction_mask(colorIndex);
|
||||||
let randomSeed = random_seed(id);
|
let randomSeed = random_seed(id);
|
||||||
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||||
var rotation = 0.0;
|
|
||||||
var step = vec2<f32>(0.0, 0.0);
|
|
||||||
|
|
||||||
|
var movement = AgentMovement(0.0, vec2<f32>(0.0, 0.0));
|
||||||
if hasIntroTarget {
|
if hasIntroTarget {
|
||||||
let introTargetOffset = targetPosition - position;
|
movement = intro_decide(id, position, angle, targetPosition, randomSeed);
|
||||||
let introTargetDistance = length(introTargetOffset);
|
|
||||||
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
|
||||||
let nearTitle = 1.0 - smoothstep(
|
|
||||||
settings.introNearDistanceInner,
|
|
||||||
max(
|
|
||||||
settings.introNearDistanceMin,
|
|
||||||
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
|
|
||||||
),
|
|
||||||
introTargetDistance
|
|
||||||
);
|
|
||||||
let desiredAngle = mix(
|
|
||||||
targetAngle,
|
|
||||||
agents[id].targetAngle,
|
|
||||||
nearTitle * settings.introTargetAngleBlend
|
|
||||||
);
|
|
||||||
let introTurn = angle_delta(angle, desiredAngle);
|
|
||||||
|
|
||||||
rotation = clamp(
|
|
||||||
introTurn,
|
|
||||||
-settings.turnRate * settings.introTurnRateMultiplier,
|
|
||||||
settings.turnRate * settings.introTurnRateMultiplier
|
|
||||||
)
|
|
||||||
+ (random_float(randomSeed + 1013904223u) - 0.5) *
|
|
||||||
settings.turnWhenLost *
|
|
||||||
settings.introRandomTurnMultiplier;
|
|
||||||
let moveRate = min(
|
|
||||||
settings.moveRate *
|
|
||||||
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
|
|
||||||
introTargetDistance
|
|
||||||
);
|
|
||||||
if introTargetDistance > settings.introStepStopDistance {
|
|
||||||
step = introTargetOffset / introTargetDistance * moveRate;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let randomTurn = random_float(randomSeed);
|
movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
|
||||||
let direction = vec2(cos(angle), sin(angle));
|
|
||||||
|
|
||||||
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
|
|
||||||
let leftSensor = sensor_position(
|
|
||||||
position,
|
|
||||||
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
|
|
||||||
settings.sensorOffset,
|
|
||||||
maxPosition
|
|
||||||
);
|
|
||||||
let rightSensor = sensor_position(
|
|
||||||
position,
|
|
||||||
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
|
|
||||||
settings.sensorOffset,
|
|
||||||
maxPosition
|
|
||||||
);
|
|
||||||
|
|
||||||
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
|
|
||||||
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
|
|
||||||
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
|
|
||||||
|
|
||||||
let weightForward = dot(trailForward.rgb, reactionMask);
|
|
||||||
let weightLeft = dot(trailLeft.rgb, reactionMask);
|
|
||||||
let weightRight = dot(trailRight.rgb, reactionMask);
|
|
||||||
|
|
||||||
rotation = (randomTurn - 0.5) * settings.turnWhenLost;
|
|
||||||
if weightForward >= weightLeft && weightForward >= weightRight {
|
|
||||||
rotation = rotation * settings.forwardRotationScale;
|
|
||||||
} else {
|
|
||||||
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
step = direction * settings.moveRate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextPosition = clamp(position + step, vec2<f32>(0, 0), maxPosition);
|
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steady-state-only entry point used after introProgress >= introProgressCutoff.
|
||||||
|
// Drops the intro target reads, atan2/smoothstep math, and introDelay check —
|
||||||
|
// once intro completes those paths are dead for the rest of the session.
|
||||||
|
@compute @workgroup_size(agentWorkgroupSize)
|
||||||
|
fn mainSteady(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||||
|
) {
|
||||||
|
let id = get_id(global_id);
|
||||||
|
|
||||||
|
if id >= settings.agentCount {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colorIndex = agents[id].colorIndex;
|
||||||
|
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = agents[id].position;
|
||||||
|
let angle = agents[id].angle;
|
||||||
|
let channelMask = get_channel_mask(colorIndex);
|
||||||
|
let reactionMask = get_reaction_mask(colorIndex);
|
||||||
|
let randomSeed = random_seed(id);
|
||||||
|
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||||
|
|
||||||
|
let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
|
||||||
|
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn steady_decide(
|
||||||
|
position: vec2<f32>,
|
||||||
|
angle: f32,
|
||||||
|
reactionMask: vec3<f32>,
|
||||||
|
randomSeed: u32,
|
||||||
|
maxPosition: vec2<f32>
|
||||||
|
) -> AgentMovement {
|
||||||
|
let randomTurn = random_float(randomSeed);
|
||||||
|
let direction = vec2(cos(angle), sin(angle));
|
||||||
|
|
||||||
|
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
|
||||||
|
let leftSensor = sensor_position(
|
||||||
|
position,
|
||||||
|
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
|
||||||
|
settings.sensorOffset,
|
||||||
|
maxPosition
|
||||||
|
);
|
||||||
|
let rightSensor = sensor_position(
|
||||||
|
position,
|
||||||
|
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
|
||||||
|
settings.sensorOffset,
|
||||||
|
maxPosition
|
||||||
|
);
|
||||||
|
|
||||||
|
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
|
||||||
|
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
|
||||||
|
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
|
||||||
|
|
||||||
|
let weightForward = dot(trailForward.rgb, reactionMask);
|
||||||
|
let weightLeft = dot(trailLeft.rgb, reactionMask);
|
||||||
|
let weightRight = dot(trailRight.rgb, reactionMask);
|
||||||
|
|
||||||
|
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
|
||||||
|
if weightForward >= weightLeft && weightForward >= weightRight {
|
||||||
|
rotation = rotation * settings.forwardRotationScale;
|
||||||
|
} else {
|
||||||
|
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentMovement(rotation, direction * settings.moveRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intro_decide(
|
||||||
|
id: u32,
|
||||||
|
position: vec2<f32>,
|
||||||
|
angle: f32,
|
||||||
|
targetPosition: vec2<f32>,
|
||||||
|
randomSeed: u32
|
||||||
|
) -> AgentMovement {
|
||||||
|
let introTargetOffset = targetPosition - position;
|
||||||
|
let introTargetDistance = length(introTargetOffset);
|
||||||
|
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
||||||
|
let nearTitle = 1.0 - smoothstep(
|
||||||
|
settings.introNearDistanceInner,
|
||||||
|
max(
|
||||||
|
settings.introNearDistanceMin,
|
||||||
|
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
|
||||||
|
),
|
||||||
|
introTargetDistance
|
||||||
|
);
|
||||||
|
let desiredAngle = mix(
|
||||||
|
targetAngle,
|
||||||
|
agents[id].targetAngle,
|
||||||
|
nearTitle * settings.introTargetAngleBlend
|
||||||
|
);
|
||||||
|
let introTurn = angle_delta(angle, desiredAngle);
|
||||||
|
|
||||||
|
let rotation = clamp(
|
||||||
|
introTurn,
|
||||||
|
-settings.turnRate * settings.introTurnRateMultiplier,
|
||||||
|
settings.turnRate * settings.introTurnRateMultiplier
|
||||||
|
)
|
||||||
|
+ (random_float(randomSeed + 1013904223u) - 0.5) *
|
||||||
|
settings.turnWhenLost *
|
||||||
|
settings.introRandomTurnMultiplier;
|
||||||
|
let moveRate = min(settings.introMoveRate, introTargetDistance);
|
||||||
|
var step = vec2<f32>(0.0, 0.0);
|
||||||
|
if introTargetDistance > settings.introStepStopDistance {
|
||||||
|
step = introTargetOffset / introTargetDistance * moveRate;
|
||||||
|
}
|
||||||
|
return AgentMovement(rotation, step);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_finalize(
|
||||||
|
id: u32,
|
||||||
|
position: vec2<f32>,
|
||||||
|
angle: f32,
|
||||||
|
channelMask: vec3<f32>,
|
||||||
|
randomSeed: u32,
|
||||||
|
maxPosition: vec2<f32>,
|
||||||
|
movement: AgentMovement
|
||||||
|
) {
|
||||||
|
let nextPosition = clamp(position + movement.step, vec2<f32>(0, 0), maxPosition);
|
||||||
|
var rotation = movement.rotation;
|
||||||
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
|
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
|
||||||
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
|
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
// Writes only this agent's last-writer-wins deposit into a per-frame-cleared
|
||||||
trailBelow = vec4<f32>(
|
// depositMap. Storage textures do not blend concurrent compute writes, so
|
||||||
trailBelow.rgb + channelMask * settings.individualTrailWeight,
|
// overlapping agents intentionally collapse to whichever write wins. The
|
||||||
max(trailBelow.a, 0.0)
|
// diffusion pass then sums trailMap + depositMap at tile-load time.
|
||||||
|
textureStore(
|
||||||
|
trailMapOut,
|
||||||
|
vec2<i32>(nextPosition),
|
||||||
|
vec4<f32>(channelMask * settings.individualTrailWeight, 0.0)
|
||||||
);
|
);
|
||||||
|
|
||||||
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
|
|
||||||
agents[id].angle = angle + rotation;
|
agents[id].angle = angle + rotation;
|
||||||
agents[id].position = nextPosition;
|
agents[id].position = nextPosition;
|
||||||
}
|
}
|
||||||
|
|
@ -181,41 +246,11 @@ fn rotate_direction(direction: vec2<f32>, angleSin: f32, angleCos: f32) -> vec2<
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
if colorIndex < 0.5 {
|
return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))];
|
||||||
return vec3<f32>(1, 0, 0);
|
|
||||||
}
|
|
||||||
if colorIndex < 1.5 {
|
|
||||||
return vec3<f32>(0, 1, 0);
|
|
||||||
}
|
|
||||||
if colorIndex < 2.5 {
|
|
||||||
return vec3<f32>(0, 0, 1);
|
|
||||||
}
|
|
||||||
return vec3<f32>(0.0, 0.0, 0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
if colorIndex < 0.5 {
|
return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))];
|
||||||
return vec3<f32>(
|
|
||||||
settings.color1ToColor1,
|
|
||||||
settings.color1ToColor2,
|
|
||||||
settings.color1ToColor3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if colorIndex < 1.5 {
|
|
||||||
return vec3<f32>(
|
|
||||||
settings.color2ToColor1,
|
|
||||||
settings.color2ToColor2,
|
|
||||||
settings.color2ToColor3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if colorIndex < 2.5 {
|
|
||||||
return vec3<f32>(
|
|
||||||
settings.color3ToColor1,
|
|
||||||
settings.color3ToColor2,
|
|
||||||
settings.color3ToColor3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return vec3<f32>(0.0, 0.0, 0.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../../config';
|
import { appConfig } from '../../config';
|
||||||
|
import { getRenderQualityBrushSize } from '../../config/brush-size';
|
||||||
import {
|
import {
|
||||||
createCachedBufferWrite,
|
createCachedBufferWrite,
|
||||||
writeBufferIfChanged,
|
writeBufferIfChanged,
|
||||||
|
|
@ -28,6 +29,7 @@ export interface BrushSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrushParameters extends BrushSettings {
|
interface BrushParameters extends BrushSettings {
|
||||||
|
internalRenderAreaMegapixels: number;
|
||||||
pixelRatio?: number;
|
pixelRatio?: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
}
|
}
|
||||||
|
|
@ -50,12 +52,16 @@ const setBrushUniformValues = (
|
||||||
brushGrainNoiseOffsetY,
|
brushGrainNoiseOffsetY,
|
||||||
brushGrainMinStrength,
|
brushGrainMinStrength,
|
||||||
brushGrainMaxStrength,
|
brushGrainMaxStrength,
|
||||||
|
internalRenderAreaMegapixels,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
pixelRatio,
|
pixelRatio,
|
||||||
}: BrushParameters
|
}: BrushParameters
|
||||||
): void => {
|
): void => {
|
||||||
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
||||||
const brushRadius = (brushSize * safePixelRatio) / 2;
|
const brushRadius =
|
||||||
|
(getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) *
|
||||||
|
safePixelRatio) /
|
||||||
|
2;
|
||||||
|
|
||||||
target[0] = brushRadius;
|
target[0] = brushRadius;
|
||||||
target[1] = brushRadius * brushRadius;
|
target[1] = brushRadius * brushRadius;
|
||||||
|
|
@ -116,7 +122,7 @@ export class BrushPipeline {
|
||||||
},
|
},
|
||||||
fragment: {
|
fragment: {
|
||||||
module: shaderModule,
|
module: shaderModule,
|
||||||
entryPoint: 'fragmentMrt',
|
entryPoint: 'fragment',
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
|
@ -160,7 +166,7 @@ export class BrushPipeline {
|
||||||
this.segments.flush();
|
this.segments.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
public executeMultiTarget(
|
public executeSource(
|
||||||
commandEncoder: GPUCommandEncoder,
|
commandEncoder: GPUCommandEncoder,
|
||||||
sourceMapOut: GPUTextureView,
|
sourceMapOut: GPUTextureView,
|
||||||
timestampWrites?: GPURenderPassTimestampWrites
|
timestampWrites?: GPURenderPassTimestampWrites
|
||||||
|
|
@ -170,6 +176,7 @@ export class BrushPipeline {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordBrushPassForE2e();
|
||||||
const passEncoder = commandEncoder.beginRenderPass({
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
|
colorAttachments: [{ view: sourceMapOut, loadOp: 'load', storeOp: 'store' }],
|
||||||
timestampWrites,
|
timestampWrites,
|
||||||
|
|
@ -188,3 +195,12 @@ export class BrushPipeline {
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recordBrushPassForE2e = (): void => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = window as Window & { __fleetingGardenBrushPasses?: number };
|
||||||
|
state.__fleetingGardenBrushPasses = (state.__fleetingGardenBrushPasses ?? 0) + 1;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ fn vertex(
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragmentMrt(
|
fn fragment(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,13 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
|
||||||
@group(0) @binding(0) var<uniform> settings: Settings;
|
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(0) @binding(1) var trailMap: texture_2d<f32>;
|
@group(0) @binding(1) var trailMap: texture_2d<f32>;
|
||||||
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||||
|
// Per-frame deposit accumulator written sparsely by agents. Summed with
|
||||||
|
// trailMap at tile-load so deposits propagate through the diffusion kernel
|
||||||
|
// in the same frame.
|
||||||
|
@group(0) @binding(3) var depositMap: texture_2d<f32>;
|
||||||
|
|
||||||
var<workgroup> tile: array<vec4<f32>, 324>;
|
var<workgroup> tile: array<vec4<f32>, TILE_TEXEL_COUNT>;
|
||||||
var<workgroup> tileTrailStrength: array<f32, 324>;
|
var<workgroup> tileTrailStrength: array<f32, TILE_TEXEL_COUNT>;
|
||||||
|
|
||||||
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
|
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
|
||||||
fn main(
|
fn main(
|
||||||
|
|
@ -49,7 +53,8 @@ fn main(
|
||||||
vec2<i32>(0, 0),
|
vec2<i32>(0, 0),
|
||||||
textureBound
|
textureBound
|
||||||
);
|
);
|
||||||
let texel = textureLoad(trailMap, sourcePixel, 0);
|
let texel = textureLoad(trailMap, sourcePixel, 0)
|
||||||
|
+ textureLoad(depositMap, sourcePixel, 0);
|
||||||
tile[tileIndex] = texel;
|
tile[tileIndex] = texel;
|
||||||
tileTrailStrength[tileIndex] = length(texel.rgb);
|
tileTrailStrength[tileIndex] = length(texel.rgb);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,20 +69,27 @@ export class DiffusionPipeline {
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
|
// 1x1 zero texture used as the depositMap binding when callers don't supply
|
||||||
|
// one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for
|
||||||
|
// out-of-bounds coordinates, so the diffusion shader sums in zeros.
|
||||||
|
private readonly emptyDepositTexture: GPUTexture;
|
||||||
|
private readonly emptyDepositTextureView: GPUTextureView;
|
||||||
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedBufferWrite(
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
private readonly getBindGroup = createBindGroupCache<
|
||||||
(trailMapIn, trailMapOut) =>
|
[GPUTextureView, GPUTextureView, GPUTextureView]
|
||||||
this.device.createBindGroup({
|
>((trailMapIn, trailMapOut, depositMap) =>
|
||||||
layout: this.bindGroupLayout,
|
this.device.createBindGroup({
|
||||||
entries: [
|
layout: this.bindGroupLayout,
|
||||||
{ binding: 0, resource: { buffer: this.uniforms } },
|
entries: [
|
||||||
{ binding: 1, resource: trailMapIn },
|
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||||
{ binding: 2, resource: trailMapOut },
|
{ binding: 1, resource: trailMapIn },
|
||||||
],
|
{ binding: 2, resource: trailMapOut },
|
||||||
})
|
{ binding: 3, resource: depositMap },
|
||||||
|
],
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
public constructor(private readonly device: GPUDevice) {
|
public constructor(private readonly device: GPUDevice) {
|
||||||
|
|
@ -104,6 +111,26 @@ export class DiffusionPipeline {
|
||||||
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.emptyDepositTexture = device.createTexture({
|
||||||
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
|
size: { width: 1, height: 1 },
|
||||||
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
|
});
|
||||||
|
this.emptyDepositTextureView = this.emptyDepositTexture.createView();
|
||||||
|
const clearEncoder = device.createCommandEncoder();
|
||||||
|
const clearPass = clearEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: this.emptyDepositTextureView,
|
||||||
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
clearPass.end();
|
||||||
|
device.queue.submit([clearEncoder.finish()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({
|
public setParameters({
|
||||||
|
|
@ -135,9 +162,14 @@ export class DiffusionPipeline {
|
||||||
trailMapIn: GPUTextureView,
|
trailMapIn: GPUTextureView,
|
||||||
trailMapOut: GPUTextureView,
|
trailMapOut: GPUTextureView,
|
||||||
size: vec2,
|
size: vec2,
|
||||||
|
depositMap: GPUTextureView | null,
|
||||||
timestampWrites?: GPUComputePassTimestampWrites
|
timestampWrites?: GPUComputePassTimestampWrites
|
||||||
) {
|
) {
|
||||||
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
|
const bindGroup = this.getBindGroup(
|
||||||
|
trailMapIn,
|
||||||
|
trailMapOut,
|
||||||
|
depositMap ?? this.emptyDepositTextureView
|
||||||
|
);
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginComputePass(
|
const passEncoder = commandEncoder.beginComputePass(
|
||||||
timestampWrites ? { timestampWrites } : undefined
|
timestampWrites ? { timestampWrites } : undefined
|
||||||
|
|
@ -153,6 +185,7 @@ export class DiffusionPipeline {
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
|
this.emptyDepositTexture.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
||||||
|
|
@ -180,6 +213,13 @@ export class DiffusionPipeline {
|
||||||
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
binding: 3,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
texture: {
|
||||||
|
sampleType: 'float',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export class EraserAgentPipeline {
|
||||||
private readonly uniformCache = createCachedBufferWrite(
|
private readonly uniformCache = createCachedBufferWrite(
|
||||||
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
|
private readonly bindGroupCache = createBindGroupCache<[GPUBuffer, GPUTextureView]>(
|
||||||
(agentsBuffer, eraserMask) =>
|
(agentsBuffer, eraserMask) =>
|
||||||
this.device.createBindGroup({
|
this.device.createBindGroup({
|
||||||
layout: this.bindGroupLayout,
|
layout: this.bindGroupLayout,
|
||||||
|
|
@ -86,7 +86,7 @@ export class EraserAgentPipeline {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.workgroupSize = getAgentWorkgroupSize(device);
|
this.workgroupSize = getAgentWorkgroupSize(device, 'eraser');
|
||||||
this.pipeline = device.createComputePipeline({
|
this.pipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
|
|
@ -94,7 +94,7 @@ export class EraserAgentPipeline {
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(
|
module: smartCompile(
|
||||||
device,
|
device,
|
||||||
substituteAgentWorkgroupSize(device, agentSchema),
|
substituteAgentWorkgroupSize(device, agentSchema, 'eraser'),
|
||||||
shader
|
shader
|
||||||
),
|
),
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ fn main(
|
||||||
}
|
}
|
||||||
|
|
||||||
let colorIndex = agents[id].colorIndex;
|
let colorIndex = agents[id].colorIndex;
|
||||||
if colorIndex < 0.0 {
|
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
|
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
|
||||||
import { CommonState } from '../common-state/common-state';
|
|
||||||
import shader from './render.wgsl?raw';
|
import shader from './render.wgsl?raw';
|
||||||
|
|
||||||
export interface RenderSettings {
|
export interface RenderSettings {
|
||||||
|
|
@ -30,7 +29,7 @@ export class RenderPipeline {
|
||||||
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
private readonly getBindGroup = createBindGroupCache<[GPUTextureView, GPUTextureView]>(
|
||||||
(colorTexture, sourceTexture) =>
|
(colorTexture, sourceTexture) =>
|
||||||
this.device.createBindGroup({
|
this.device.createBindGroup({
|
||||||
layout: this.bindGroupLayout,
|
layout: this.bindGroupLayout,
|
||||||
|
|
@ -45,7 +44,6 @@ export class RenderPipeline {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly context: GPUCanvasContext,
|
private readonly context: GPUCanvasContext,
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly commonState: CommonState,
|
|
||||||
private readonly canvasFormat: GPUTextureFormat
|
private readonly canvasFormat: GPUTextureFormat
|
||||||
) {
|
) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
|
@ -68,10 +66,10 @@ export class RenderPipeline {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
const shaderModule = smartCompile(device, shader);
|
||||||
const vertex = setUpFullScreenQuad(device);
|
const vertex = setUpFullScreenQuad(device);
|
||||||
const pipelineLayout = device.createPipelineLayout({
|
const pipelineLayout = device.createPipelineLayout({
|
||||||
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [this.bindGroupLayout],
|
||||||
});
|
});
|
||||||
this.pipeline = this.createPipeline(
|
this.pipeline = this.createPipeline(
|
||||||
pipelineLayout,
|
pipelineLayout,
|
||||||
|
|
@ -207,8 +205,7 @@ export class RenderPipeline {
|
||||||
timestampWrites,
|
timestampWrites,
|
||||||
});
|
});
|
||||||
passEncoder.setPipeline(this.getPipeline(useSourceTexture));
|
passEncoder.setPipeline(this.getPipeline(useSourceTexture));
|
||||||
this.commonState.execute(passEncoder);
|
passEncoder.setBindGroup(0, this.getBindGroup(colorTexture, sourceTexture));
|
||||||
passEncoder.setBindGroup(1, this.getBindGroup(colorTexture, sourceTexture));
|
|
||||||
passEncoder.draw(3, 1);
|
passEncoder.draw(3, 1);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,17 @@ struct Settings {
|
||||||
brushColorStrengthMultiplier: f32,
|
brushColorStrengthMultiplier: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
const COMMON_CHANNEL_REDUCTION: f32 = 0.75;
|
||||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
const OVERLAP_SATURATION_BOOST: f32 = 1.35;
|
||||||
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
|
const LOW_SATURATION_RESCUE_AMOUNT: f32 = 0.65;
|
||||||
|
const LOW_SATURATION_RESCUE_MIN: f32 = 0.08;
|
||||||
|
const LOW_SATURATION_RESCUE_MAX: f32 = 0.22;
|
||||||
|
const COLOR_WEIGHT_EPSILON: f32 = 0.0001;
|
||||||
|
const LUMA_WEIGHTS: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||||
|
@group(0) @binding(2) var trailMap: texture_2d<f32>;
|
||||||
|
@group(0) @binding(3) var sourceMap: texture_2d<f32>;
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
|
|
@ -41,25 +49,13 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) ->
|
||||||
}
|
}
|
||||||
|
|
||||||
if brushStrength <= 0.0 {
|
if brushStrength <= 0.0 {
|
||||||
let traceColor =
|
let traceColor = colorFromChannelStrengths(traceStrengths);
|
||||||
traceStrengths.r * settings.colorA
|
return vec4(mix(background, clamp(traceColor, vec3(0), vec3(1)), traceStrength), 1);
|
||||||
+ traceStrengths.g * settings.colorB
|
|
||||||
+ traceStrengths.b * settings.colorC;
|
|
||||||
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
|
||||||
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let strengths = max(traceStrengths, sourceStrengths);
|
let strengths = max(traceStrengths, sourceStrengths);
|
||||||
let traceColor =
|
let traceColor = colorFromChannelStrengths(strengths);
|
||||||
strengths.r * settings.colorA
|
let brushColor = colorFromChannelStrengths(sourceStrengths);
|
||||||
+ strengths.g * settings.colorB
|
|
||||||
+ strengths.b * settings.colorC;
|
|
||||||
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
|
||||||
let brushColor =
|
|
||||||
sourceStrengths.r * settings.colorA
|
|
||||||
+ sourceStrengths.g * settings.colorB
|
|
||||||
+ sourceStrengths.b * settings.colorC;
|
|
||||||
let normalizedBrushColor = normalizeColorIntensity(brushColor);
|
|
||||||
let brushVisibility = clamp(
|
let brushVisibility = clamp(
|
||||||
brushStrength * (
|
brushStrength * (
|
||||||
settings.brushColorBase +
|
settings.brushColorBase +
|
||||||
|
|
@ -68,7 +64,7 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) ->
|
||||||
0,
|
0,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
let color = max(normalizedTraceColor, normalizedBrushColor);
|
let color = mix(traceColor, brushColor, brushVisibility);
|
||||||
|
|
||||||
let strength = max(maxComponent(strengths), brushVisibility);
|
let strength = max(maxComponent(strengths), brushVisibility);
|
||||||
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
||||||
|
|
@ -78,10 +74,80 @@ fn maxComponent(v: vec3<f32>) -> f32 {
|
||||||
return max(max(v.r, v.g), v.b);
|
return max(max(v.r, v.g), v.b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn minComponent(v: vec3<f32>) -> f32 {
|
||||||
|
return min(min(v.r, v.g), v.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn componentSum(v: vec3<f32>) -> f32 {
|
||||||
|
return v.r + v.g + v.b;
|
||||||
|
}
|
||||||
|
|
||||||
fn clarity(strength: vec3<f32>) -> vec3<f32> {
|
fn clarity(strength: vec3<f32>) -> vec3<f32> {
|
||||||
return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
|
return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn colorFromChannelStrengths(strengths: vec3<f32>) -> vec3<f32> {
|
||||||
|
if maxComponent(strengths) <= 0.0 {
|
||||||
|
return vec3<f32>(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let weights = colorWeights(strengths);
|
||||||
|
let color =
|
||||||
|
weights.r * settings.colorA
|
||||||
|
+ weights.g * settings.colorB
|
||||||
|
+ weights.b * settings.colorC;
|
||||||
|
return preserveOverlapVibrancy(normalizeColorIntensity(color), strengths);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn colorWeights(strengths: vec3<f32>) -> vec3<f32> {
|
||||||
|
let commonStrength = minComponent(strengths);
|
||||||
|
var weightBase = max(
|
||||||
|
strengths - vec3<f32>(commonStrength * COMMON_CHANNEL_REDUCTION),
|
||||||
|
vec3<f32>(0.0)
|
||||||
|
);
|
||||||
|
if componentSum(weightBase) <= COLOR_WEIGHT_EPSILON {
|
||||||
|
weightBase = strengths;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharpenedWeights = weightBase * weightBase;
|
||||||
|
return sharpenedWeights / max(COLOR_WEIGHT_EPSILON, componentSum(sharpenedWeights));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preserveOverlapVibrancy(color: vec3<f32>, strengths: vec3<f32>) -> vec3<f32> {
|
||||||
|
let strongest = maxComponent(strengths);
|
||||||
|
let overlapAmount = clamp(
|
||||||
|
(componentSum(strengths) - strongest) / max(COLOR_WEIGHT_EPSILON, strongest),
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
|
||||||
|
let luminance = dot(color, LUMA_WEIGHTS);
|
||||||
|
var vibrantColor = clamp(
|
||||||
|
vec3<f32>(luminance) +
|
||||||
|
(color - vec3<f32>(luminance)) *
|
||||||
|
mix(1.0, OVERLAP_SATURATION_BOOST, overlapAmount),
|
||||||
|
vec3<f32>(0.0),
|
||||||
|
vec3<f32>(1.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let saturation = maxComponent(vibrantColor) - minComponent(vibrantColor);
|
||||||
|
let rescueAmount =
|
||||||
|
overlapAmount *
|
||||||
|
(1.0 - smoothstep(LOW_SATURATION_RESCUE_MIN, LOW_SATURATION_RESCUE_MAX, saturation)) *
|
||||||
|
LOW_SATURATION_RESCUE_AMOUNT;
|
||||||
|
return mix(vibrantColor, dominantColor(strengths), rescueAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dominantColor(strengths: vec3<f32>) -> vec3<f32> {
|
||||||
|
if strengths.r >= strengths.g && strengths.r >= strengths.b {
|
||||||
|
return normalizeColorIntensity(settings.colorA);
|
||||||
|
}
|
||||||
|
if strengths.g >= strengths.b {
|
||||||
|
return normalizeColorIntensity(settings.colorB);
|
||||||
|
}
|
||||||
|
return normalizeColorIntensity(settings.colorC);
|
||||||
|
}
|
||||||
|
|
||||||
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
||||||
let brightestChannel = maxComponent(color);
|
let brightestChannel = maxComponent(color);
|
||||||
return color / max(settings.traceNormalizationFloor, brightestChannel);
|
return color / max(settings.traceNormalizationFloor, brightestChannel);
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,20 @@ const preservedRuntimeSettingKeys = [
|
||||||
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
|
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
|
||||||
[...color] as T;
|
[...color] as T;
|
||||||
|
|
||||||
|
const cloneVibeAudio = (audio: VibePreset['audio']): VibePreset['audio'] => ({
|
||||||
|
...audio,
|
||||||
|
...(audio.scale ? { scale: [...audio.scale] } : {}),
|
||||||
|
...(audio.progression
|
||||||
|
? { progression: audio.progression.map((chord) => ({ ...chord })) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
|
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
|
||||||
...vibe,
|
...vibe,
|
||||||
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
|
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
|
||||||
backgroundColor: cloneRgbColor(vibe.backgroundColor),
|
backgroundColor: cloneRgbColor(vibe.backgroundColor),
|
||||||
settings: { ...vibe.settings },
|
settings: { ...vibe.settings },
|
||||||
audio: { ...vibe.audio },
|
audio: cloneVibeAudio(vibe.audio),
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
|
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
|
||||||
|
|
@ -43,6 +51,10 @@ export const settings: GardenRuntimeSettings = {
|
||||||
...buildSettings(activeVibe),
|
...buildSettings(activeVibe),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const rememberActiveVibeSelection = (): void => {
|
||||||
|
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
|
||||||
|
};
|
||||||
|
|
||||||
export const applyVibeSettings = (vibe: VibePreset) => {
|
export const applyVibeSettings = (vibe: VibePreset) => {
|
||||||
activeVibe = cloneVibePreset(vibe);
|
activeVibe = cloneVibePreset(vibe);
|
||||||
const nextSettings = buildSettings(activeVibe);
|
const nextSettings = buildSettings(activeVibe);
|
||||||
|
|
@ -59,7 +71,7 @@ export const applyVibeSettings = (vibe: VibePreset) => {
|
||||||
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
|
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
|
||||||
);
|
);
|
||||||
|
|
||||||
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
|
rememberActiveVibeSelection();
|
||||||
|
|
||||||
return activeVibe;
|
return activeVibe;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
@use 'mixins' as *;
|
||||||
|
|
||||||
.config-pane-container {
|
.config-pane-container {
|
||||||
--config-pane-available-height: calc(
|
--config-pane-available-height: calc(
|
||||||
100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
||||||
|
|
@ -41,6 +43,9 @@
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
// Tweakpane v4 internal classes — re-verify on upgrade.
|
||||||
|
// No public theming hook exists for label padding or the slider/number
|
||||||
|
// flex ratio; if a fourth override appears here, switch to a custom plugin.
|
||||||
.tp-lblv_l {
|
.tp-lblv_l {
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +144,7 @@
|
||||||
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
||||||
|
// Tweakpane v4 internal class — re-verify on upgrade.
|
||||||
.tp-sldtxtv_t {
|
.tp-sldtxtv_t {
|
||||||
flex-basis: 48px;
|
flex-basis: 48px;
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +156,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 599px), (hover: none) and (pointer: coarse) {
|
@include on-mobile-input {
|
||||||
@include mobile-config-pane;
|
@include mobile-config-pane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
touch-action: manipulation;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
|
@ -23,6 +24,7 @@ html {
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Open Sans', sans-serif;
|
font-family: 'Open Sans', sans-serif;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,9 @@ $breakpoint-width: 600px !default;
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin on-mobile-input() {
|
||||||
|
@media (max-width: ($breakpoint-width - 1px)), (hover: none) and (pointer: coarse) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,20 @@
|
||||||
--toolbar-background-strength: 0;
|
--toolbar-background-strength: 0;
|
||||||
--toolbar-divider-space: clamp(6px, 1.8vw, 14px);
|
--toolbar-divider-space: clamp(6px, 1.8vw, 14px);
|
||||||
--toolbar-top-max-width: 594px;
|
--toolbar-top-max-width: 594px;
|
||||||
|
--vibe-button-hit-size: 64px;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'previous controls next'
|
'previous controls next'
|
||||||
'previous divider next'
|
'previous divider next'
|
||||||
'previous buttons next';
|
'previous buttons next';
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns:
|
||||||
|
var(--vibe-button-hit-size)
|
||||||
|
minmax(0, var(--toolbar-top-max-width))
|
||||||
|
var(--vibe-button-hit-size);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding-inline: clamp(8px, 1.4vw, 14px);
|
padding-inline: clamp(8px, 1.4vw, 14px);
|
||||||
|
|
@ -82,40 +86,72 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
> .vibe-button {
|
> .vibe-button {
|
||||||
|
--vibe-button-surface-inset-block: 10px;
|
||||||
|
--vibe-button-surface-inset-inline: 8px;
|
||||||
|
--vibe-chevron-size: 22px;
|
||||||
|
--vibe-chevron-stroke: 4px;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 52px;
|
width: var(--vibe-button-hit-size);
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 66px;
|
min-height: 72px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgb(255 255 255 / 70%);
|
color: rgb(255 255 255 / 88%);
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
inset: var(--vibe-button-surface-inset-block)
|
||||||
|
var(--vibe-button-surface-inset-inline);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(255 255 255 / calc(9% + var(--toolbar-background-strength) * 10%));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(255 255 255 / 18%),
|
||||||
|
0 8px 18px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 22%));
|
||||||
|
transition:
|
||||||
|
background var(--transition-time),
|
||||||
|
box-shadow var(--transition-time),
|
||||||
|
opacity var(--transition-time);
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 18px;
|
width: var(--vibe-chevron-size);
|
||||||
height: 18px;
|
height: var(--vibe-chevron-size);
|
||||||
border-color: currentColor;
|
border-color: currentColor;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 0 0 3px 3px;
|
border-width: 0 0 var(--vibe-chevron-stroke) var(--vibe-chevron-stroke);
|
||||||
|
filter: drop-shadow(0 1px 3px rgb(0 0 0 / 70%));
|
||||||
transform: translate(-35%, -50%) rotate(45deg);
|
transform: translate(-35%, -50%) rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.next-vibe::before {
|
&.next-vibe::before {
|
||||||
border-width: 3px 3px 0 0;
|
border-width: var(--vibe-chevron-stroke) var(--vibe-chevron-stroke) 0 0;
|
||||||
transform: translate(-65%, -50%) rotate(45deg);
|
transform: translate(-65%, -50%) rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: color-mix(in srgb, var(--accent-color) 70%, white);
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::after {
|
||||||
|
background: color-mix(in srgb, var(--accent-color) 34%, rgb(255 255 255 / 18%));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(255 255 255 / 28%),
|
||||||
|
0 10px 22px rgb(0 0 0 / calc(var(--toolbar-background-strength) * 30%));
|
||||||
}
|
}
|
||||||
|
|
||||||
&.previous-vibe:hover {
|
&.previous-vibe:hover {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
@include on-small-screen {
|
@include on-small-screen {
|
||||||
--toolbar-divider-space: 4px;
|
--toolbar-divider-space: 4px;
|
||||||
--toolbar-top-max-width: 329px;
|
--toolbar-top-max-width: 329px;
|
||||||
|
--vibe-button-hit-size: 44px;
|
||||||
|
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'previous controls next'
|
'previous controls next'
|
||||||
|
|
@ -15,13 +16,13 @@
|
||||||
row-gap: 0;
|
row-gap: 0;
|
||||||
|
|
||||||
> .vibe-button {
|
> .vibe-button {
|
||||||
width: 36px;
|
--vibe-button-surface-inset-block: 5px;
|
||||||
min-height: 44px;
|
--vibe-button-surface-inset-inline: 3px;
|
||||||
|
--vibe-chevron-size: 17px;
|
||||||
|
--vibe-chevron-stroke: 3px;
|
||||||
|
|
||||||
&::before {
|
width: var(--vibe-button-hit-size);
|
||||||
width: 14px;
|
min-height: 44px;
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
> .toolbar-shell {
|
> .toolbar-shell {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
:root {
|
:root {
|
||||||
--transition-time: 200ms;
|
--transition-time: 200ms;
|
||||||
--transition-time-long: 350ms;
|
--transition-time-long: 350ms;
|
||||||
--accent-color: rgb(255, 93, 162);
|
--accent-color: rgb(255 93 162);
|
||||||
--main-color: #aaa;
|
--main-color: #aaa;
|
||||||
--normal-margin: 2rem;
|
--normal-margin: 2rem;
|
||||||
--small-margin: 1rem;
|
--small-margin: 1rem;
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,12 @@ export class ErrorHandler {
|
||||||
|
|
||||||
public static addOnErrorListener(
|
public static addOnErrorListener(
|
||||||
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
listener: (error: ErrorHandlerError, metadata: ErrorMetadata) => void
|
||||||
) {
|
): () => void {
|
||||||
ErrorHandler.onErrorListeners.push(listener);
|
ErrorHandler.onErrorListeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
ErrorHandler.onErrorListeners = ErrorHandler.onErrorListeners.filter(
|
||||||
|
(registeredListener) => registeredListener !== listener
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,38 @@
|
||||||
export const createBindGroupCache = <K1 extends object, K2 extends object>(
|
type BindGroupCacheKeys = readonly [object, ...object[]];
|
||||||
factory: (key1: K1, key2: K2) => GPUBindGroup
|
|
||||||
): ((key1: K1, key2: K2) => GPUBindGroup) => {
|
interface BindGroupCacheNode {
|
||||||
const outer = new WeakMap<K1, WeakMap<K2, GPUBindGroup>>();
|
bindGroup?: GPUBindGroup;
|
||||||
return (key1, key2) => {
|
children: WeakMap<object, BindGroupCacheNode>;
|
||||||
let inner = outer.get(key1);
|
}
|
||||||
if (!inner) {
|
|
||||||
inner = new WeakMap();
|
const createNode = (): BindGroupCacheNode => ({
|
||||||
outer.set(key1, inner);
|
children: new WeakMap(),
|
||||||
}
|
});
|
||||||
const cached = inner.get(key2);
|
|
||||||
if (cached) {
|
const getOrCreateNode = (
|
||||||
return cached;
|
children: WeakMap<object, BindGroupCacheNode>,
|
||||||
}
|
key: object
|
||||||
const bindGroup = factory(key1, key2);
|
): BindGroupCacheNode => {
|
||||||
inner.set(key2, bindGroup);
|
let node = children.get(key);
|
||||||
return bindGroup;
|
if (!node) {
|
||||||
};
|
node = createNode();
|
||||||
|
children.set(key, node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createBindGroupCache3 = <
|
export const createBindGroupCache = <Keys extends BindGroupCacheKeys>(
|
||||||
K1 extends object,
|
factory: (...keys: Keys) => GPUBindGroup
|
||||||
K2 extends object,
|
): ((...keys: Keys) => GPUBindGroup) => {
|
||||||
K3 extends object,
|
const root = new WeakMap<object, BindGroupCacheNode>();
|
||||||
>(
|
|
||||||
factory: (key1: K1, key2: K2, key3: K3) => GPUBindGroup
|
return (...keys) => {
|
||||||
): ((key1: K1, key2: K2, key3: K3) => GPUBindGroup) => {
|
let node = getOrCreateNode(root, keys[0]);
|
||||||
const outer = new WeakMap<K1, WeakMap<K2, WeakMap<K3, GPUBindGroup>>>();
|
for (const key of keys.slice(1)) {
|
||||||
return (key1, key2, key3) => {
|
node = getOrCreateNode(node.children, key);
|
||||||
let mid = outer.get(key1);
|
|
||||||
if (!mid) {
|
|
||||||
mid = new WeakMap();
|
|
||||||
outer.set(key1, mid);
|
|
||||||
}
|
}
|
||||||
let inner = mid.get(key2);
|
|
||||||
if (!inner) {
|
node.bindGroup ??= factory(...keys);
|
||||||
inner = new WeakMap();
|
return node.bindGroup;
|
||||||
mid.set(key2, inner);
|
|
||||||
}
|
|
||||||
const cached = inner.get(key3);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
const bindGroup = factory(key1, key2, key3);
|
|
||||||
inner.set(key3, bindGroup);
|
|
||||||
return bindGroup;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
54
src/vibe-uri.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { VibeId } from './config/types';
|
||||||
|
import { createVibeUri, getVibeIdFromUri } from './vibe-uri';
|
||||||
|
|
||||||
|
describe('vibe URI handling', () => {
|
||||||
|
it('loads vibes from slug IDs and display names', () => {
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe(
|
||||||
|
VibeId.AuroraMycelium
|
||||||
|
);
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
||||||
|
VibeId.AuroraMycelium
|
||||||
|
);
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=Velvet%20Observatory%20Copy')).toBe(
|
||||||
|
VibeId.VelvetObservatory
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses query values before path or hash fallbacks', () => {
|
||||||
|
expect(
|
||||||
|
getVibeIdFromUri(
|
||||||
|
'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium'
|
||||||
|
)
|
||||||
|
).toBe(VibeId.LichenSignal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts explicit path segments and hash fallbacks', () => {
|
||||||
|
expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe(
|
||||||
|
VibeId.TidepoolLantern
|
||||||
|
);
|
||||||
|
expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe(
|
||||||
|
VibeId.PaperLanternFog
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unknown or malformed vibe values', () => {
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull();
|
||||||
|
expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull();
|
||||||
|
expect(getVibeIdFromUri('not a url')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a canonical query URI without dropping other URL parts', () => {
|
||||||
|
expect(
|
||||||
|
createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen)
|
||||||
|
).toBe('/garden?debug=1&vibe=chrome-pollen#panel');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createVibeUri(
|
||||||
|
'https://example.test/garden?vibe=aurora-mycelium&debug=1',
|
||||||
|
VibeId.LichenSignal
|
||||||
|
)
|
||||||
|
).toBe('/garden?vibe=lichen-signal&debug=1');
|
||||||
|
});
|
||||||
|
});
|
||||||
148
src/vibe-uri.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import type { VibeId } from './config/types';
|
||||||
|
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||||
|
|
||||||
|
const VIBE_URI_QUERY_PARAM = 'vibe';
|
||||||
|
const FALLBACK_URL_ORIGIN = 'https://fleeting.garden';
|
||||||
|
|
||||||
|
const slugifyVibeName = (value: string): string =>
|
||||||
|
value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
const safeDecodeURIComponent = (value: string): string => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeVibeIdentifier = (value: string): string =>
|
||||||
|
slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, ''));
|
||||||
|
|
||||||
|
const vibeIdByIdentifier = new Map<string, VibeId>();
|
||||||
|
|
||||||
|
for (const vibe of VIBE_PRESETS) {
|
||||||
|
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id);
|
||||||
|
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toUrl = (url: string | URL): URL | null => {
|
||||||
|
try {
|
||||||
|
return new URL(url, FALLBACK_URL_ORIGIN);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveVibeId = (value: string | null | undefined): VibeId | null => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHashSearchParam = (hash: string): string | null => {
|
||||||
|
const hashValue = hash.replace(/^#/, '');
|
||||||
|
if (!hashValue.includes('=')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue;
|
||||||
|
try {
|
||||||
|
return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPathVibeCandidates = (pathname: string): Array<string> => {
|
||||||
|
const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean);
|
||||||
|
const explicitVibeIndex = segments.findIndex((segment) =>
|
||||||
|
['vibe', 'vibes'].includes(segment.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined,
|
||||||
|
segments.at(-1),
|
||||||
|
].filter((candidate): candidate is string => typeof candidate === 'string');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVibeIdFromUri = (url: string | URL): VibeId | null => {
|
||||||
|
const parsedUrl = toUrl(url);
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM),
|
||||||
|
getHashSearchParam(parsedUrl.hash),
|
||||||
|
...getPathVibeCandidates(parsedUrl.pathname),
|
||||||
|
parsedUrl.hash.replace(/^#/, ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const vibeId = resolveVibeId(candidate);
|
||||||
|
if (vibeId) {
|
||||||
|
return vibeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentUriVibeId = (): VibeId | null => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVibeIdFromUri(window.location.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVibeSlug = (vibeId: VibeId): string => {
|
||||||
|
const vibe = getVibeById(vibeId);
|
||||||
|
return vibe ? vibe.id : vibeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createVibeUri = (url: string | URL, vibeId: VibeId): string => {
|
||||||
|
const parsedUrl = toUrl(url);
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId));
|
||||||
|
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeCurrentVibeUri = (
|
||||||
|
vibeId: VibeId,
|
||||||
|
mode: 'push' | 'replace' = 'replace'
|
||||||
|
): void => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUri = createVibeUri(window.location.href, vibeId);
|
||||||
|
const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
if (nextUri === currentUri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState =
|
||||||
|
typeof window.history.state === 'object' && window.history.state !== null
|
||||||
|
? { ...window.history.state, vibeId }
|
||||||
|
: { vibeId };
|
||||||
|
|
||||||
|
if (mode === 'push') {
|
||||||
|
window.history.pushState(nextState, '', nextUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState(nextState, '', nextUri);
|
||||||
|
};
|
||||||
13
src/vibes.ts
|
|
@ -1,21 +1,26 @@
|
||||||
import { appConfig } from './config';
|
import { appConfig } from './config';
|
||||||
import { VibeId, type VibePreset } from './config/types';
|
import { VibeId, type VibePreset } from './config/types';
|
||||||
import { readBrowserStorage } from './utils/browser-storage';
|
import { readBrowserStorage } from './utils/browser-storage';
|
||||||
|
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||||
|
import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri';
|
||||||
|
|
||||||
export { VibeId };
|
export { VibeId };
|
||||||
|
export { getVibeById, VIBE_PRESETS };
|
||||||
export type { VibePreset };
|
export type { VibePreset };
|
||||||
|
|
||||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
|
||||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||||
|
|
||||||
const isVibeId = (value: unknown): value is VibeId =>
|
const isVibeId = (value: unknown): value is VibeId =>
|
||||||
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||||
|
|
||||||
export const getInitialVibe = (): VibePreset => {
|
export const getInitialVibe = (): VibePreset => {
|
||||||
|
const uriVibeId = getCurrentUriVibeId();
|
||||||
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||||
const initialVibeId = isVibeId(storedVibeId)
|
const storedOrLegacyVibeId = isVibeId(storedVibeId)
|
||||||
? storedVibeId
|
? storedVibeId
|
||||||
: appConfig.vibes.defaultVibeId;
|
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
|
||||||
|
const initialVibeId =
|
||||||
|
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
|
||||||
|
|
||||||
return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0];
|
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,5 @@
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true
|
"noUnusedParameters": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
|
"include": ["src/**/*", "pwa-assets.config.ts", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@ export default defineConfig(({ command }) => ({
|
||||||
cssMinify: 'lightningcss',
|
cssMinify: 'lightningcss',
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
|
||||||
host: true,
|
host: true,
|
||||||
hmr: false,
|
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
|
|
||||||