+ Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene + to paint coloured paths, then use the toolbar to change colours, erase, adjust the + config overlay, export, restart, or open more information. +
+ + + +diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 232fbf9..93748c6 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -27,18 +27,36 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + - name: Lint - run: npm run lint -- --check || true + run: npm run lint - name: Typecheck run: npm run typecheck - - name: Build - run: npm run build + - name: Typecheck browser tests + run: npm run typecheck:e2e + + - name: Test + run: npm test + + - name: Browser tests + run: npm run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 - name: Copy build to host pages mount if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | apt update && apt install -y rsync - mkdir -p /pages rsync -a --delete dist/ /pages/fleeting-garden diff --git a/.gitignore b/.gitignore index 14d1e17..916a63e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ ts-node--*/ rss.xml dist +playwright-report +test-results # Logs logs diff --git a/README.md b/README.md index a55e624..1a7466c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ -# Just a bunch of blobs +# Fleeting Garden -[](https://github.com/schmelczer/webgpu/actions/workflows/deploy.yml) +Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette, +draw persistent coloured paths, spawn agents from those strokes, erase locally, +and export the scene as a 4K wallpaper. -## todo +Check out the [agent logic](./src/pipelines/agents/agent.wgsl). -- add info page description -- add share link -- settings page - add reset link -- shareable settings -- graceful error messages when no support -- fix up generation id automatically +## Testing -Check out the [agent's logic](./src/pipelines/agents/agent.wgsl). +- `npm test` runs the Vitest unit suite. +- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium + smoke test. +- `npx playwright install chromium` installs the local browser binary when needed. diff --git a/assets/icons/download.svg b/assets/icons/download.svg new file mode 100644 index 0000000..f880e05 --- /dev/null +++ b/assets/icons/download.svg @@ -0,0 +1,10 @@ + diff --git a/assets/icons/sound.svg b/assets/icons/sound.svg new file mode 100644 index 0000000..78dbb2b --- /dev/null +++ b/assets/icons/sound.svg @@ -0,0 +1,3 @@ + diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..e42de2f --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'gpu', { + configurable: true, + value: undefined, + }); + }); + + await page.goto('/'); + + await expect(page).toHaveTitle('Fleeting Garden'); + await expect( + page.getByRole('img', { name: 'Interactive generative garden canvas' }) + ).toBeVisible(); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); + + await page.getByRole('button', { name: 'About' }).click(); + await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); +}); diff --git a/index.html b/index.html index 17b5847..1957196 100644 --- a/index.html +++ b/index.html @@ -6,16 +6,16 @@ name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" /> - + - + @@ -27,55 +27,175 @@ -
+ Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene + to paint coloured paths, then use the toolbar to change colours, erase, adjust the + config overlay, export, restart, or open more information. +
+ + + +${error.message}
- `;
- game?.destroy();
- shouldStop = true;
+ renderRuntimeMessage(elements.errorContainer, error);
+ if (error.severity === Severity.ERROR) {
+ document.body.classList.remove('is-loading');
+ game?.destroy();
+ shouldStop = true;
+ }
});
+ const syncRuntimeUi = () => {
+ renderEraserSizeUi(game);
+ renderMirrorSegmentUi();
+ renderPaletteUi(game);
+ };
+
const infoPageHandler = new CollapsiblePanelAnimator(
elements.infoButton,
elements.infoElement,
elements.aside
);
- const settingsPageHandler = new CollapsiblePanelAnimator(
- elements.settingsButton,
- elements.settingsPage,
- elements.aside
- );
- settingsPageHandler.onOpen = infoPageHandler.close.bind(infoPageHandler);
- infoPageHandler.onOpen = settingsPageHandler.close.bind(settingsPageHandler);
-
- if (isProduction) {
- infoPageHandler.open();
- }
+ const configPane = new ConfigPane({
+ settingsButton: elements.settingsButton,
+ onConfigChange: syncRuntimeUi,
+ onRuntimeChange: syncRuntimeUi,
+ onRuntimeReset: () => {
+ resetSettings();
+ syncRuntimeUi();
+ },
+ onRestart: () => game?.destroy(),
+ onVibeChange: (vibeId) => {
+ applyVibeSettings(vibeId);
+ syncRuntimeUi();
+ game?.playVibeChangeAudio(false);
+ },
+ });
+ infoPageHandler.onOpen = configPane.close.bind(configPane);
new MenuHider(
elements.aside,
() =>
FullScreenHandler.isInFullScreenMode() &&
- !settingsPageHandler.isOpen &&
- !infoPageHandler.isOpen
+ !configPane.isOpen &&
+ !infoPageHandler.isOpen,
+ { persistentElement: elements.settingsButton }
);
new FullScreenHandler(
elements.minimizeFullScreenButton,
@@ -76,31 +250,126 @@ const main = async () => {
document.body
);
+ const fontsReady = document.fonts.ready.catch(() => undefined);
+ setLoadingStage('Connecting to GPU…', 0.1);
const gpu = await initializeGpu();
+ setLoadingStage('Loading fonts…', 0.4);
+ await fontsReady;
+ setLoadingStage('Compiling shaders…', 0.7);
elements.restartButton.addEventListener('click', () => game?.destroy());
-
- const deltaTimeCalculator = new DeltaTimeCalculator();
- let sliders: Array> = [];
-
- elements.applyDefaults.addEventListener('click', () => {
- resetSettings();
- sliders.forEach((slider) => slider.updateSliderValueBasedOnSource());
+ elements.soundButton.addEventListener('click', (event) => {
+ isAudioMuted = !isAudioMuted;
+ writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
+ renderAudioUi(game);
+ if (!isAudioMuted) {
+ game?.startAudio(event.isTrusted);
+ }
});
- while (!shouldStop) {
- const gameRules = new GameRules(performance.now() / 1000);
- game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, gameRules);
+ const deltaTimeCalculator = new DeltaTimeCalculator();
- if (sliders.length === 0) {
- sliders = setUpSettingsPage(elements.settingsContent, game.maxAgentCount);
+ elements.previousVibe.addEventListener('click', (event) => {
+ const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
+ const vibe =
+ VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length];
+ applyVibeSettings(vibe.id);
+ configPane.refresh();
+ syncRuntimeUi();
+ game?.playVibeChangeAudio(event.isTrusted);
+ });
+
+ elements.nextVibe.addEventListener('click', (event) => {
+ const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
+ const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length];
+ applyVibeSettings(vibe.id);
+ configPane.refresh();
+ syncRuntimeUi();
+ game?.playVibeChangeAudio(event.isTrusted);
+ });
+
+ elements.swatches.forEach((swatch, index) => {
+ swatch.addEventListener('click', () => {
+ settings.selectedColorIndex = index;
+ elements.eraserSizeControl.dataset.active = '0';
+ game?.setEraseMode(false);
+ renderPaletteUi(game);
+ configPane.refresh();
+ });
+ });
+
+ const activateEraser = () => {
+ elements.eraserSizeControl.dataset.active = '1';
+ renderPaletteUi(game);
+ };
+
+ elements.eraserSizeControl.addEventListener('pointerdown', activateEraser);
+ elements.eraserSizeControl.addEventListener('click', activateEraser);
+ elements.eraserSizeSlider.addEventListener('focus', activateEraser);
+
+ elements.eraserSizeSlider.addEventListener('input', () => {
+ settings.eraserSize = clampEraserSize(Number(elements.eraserSizeSlider.value));
+ elements.eraserSizeControl.dataset.active = '1';
+ renderEraserSizeUi(game);
+ renderPaletteUi(game);
+ configPane.refresh();
+ });
+
+ elements.mirrorSegmentSlider.addEventListener('input', () => {
+ settings.mirrorSegmentCount = clampMirrorSegmentCount(
+ Number(elements.mirrorSegmentSlider.value)
+ );
+ elements.eraserSizeControl.dataset.active = '0';
+ renderMirrorSegmentUi();
+ renderPaletteUi(game);
+ configPane.refresh();
+ });
+
+ elements.export4k.addEventListener('click', async () => {
+ if (!game || elements.export4k.disabled) {
+ return;
}
- await game.start();
+ elements.export4k.disabled = true;
+ try {
+ await game.export4K();
+ } catch (error) {
+ ErrorHandler.addException(error, { severity: Severity.WARNING });
+ } finally {
+ elements.export4k.disabled = false;
+ }
+ });
+
+ renderPaletteUi(game);
+ renderEraserSizeUi(game);
+ renderMirrorSegmentUi();
+ renderAudioUi(game);
+
+ let isFirstStart = true;
+ while (!shouldStop) {
+ game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
+ prompt: elements.prompt,
+ eraserPreview: elements.eraserPreview,
+ exportStatus: elements.exportStatus,
+ });
+ renderPaletteUi(game);
+ renderEraserSizeUi(game);
+ renderMirrorSegmentUi();
+ renderAudioUi(game);
+
+ const startPromise = game.start();
+ if (isFirstStart) {
+ isFirstStart = false;
+ setLoadingStage('Ready', 1);
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => document.body.classList.remove('is-loading'))
+ );
+ }
+ await startPromise;
}
} catch (e) {
- const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
- ErrorHandler.addError(Severity.ERROR, message);
+ document.body.classList.remove('is-loading');
+ ErrorHandler.addException(e);
console.error(e);
}
};
diff --git a/src/page/collapsible-panel-animator.ts b/src/page/collapsible-panel-animator.ts
index d4c91fa..b5a4c6a 100644
--- a/src/page/collapsible-panel-animator.ts
+++ b/src/page/collapsible-panel-animator.ts
@@ -1,5 +1,8 @@
export class CollapsiblePanelAnimator {
+ private static nextPanelId = 0;
+
private _isOpen = false;
+ private focusBeforeOpen: HTMLElement | null = null;
public onOpen: () => unknown = () => {};
public onClose: () => unknown = () => {};
@@ -9,25 +12,64 @@ export class CollapsiblePanelAnimator {
private readonly collapsibleContent: HTMLElement,
ignoreForCloseOnClick: HTMLElement
) {
+ const panelId =
+ collapsibleContent.id ||
+ `collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`;
+ collapsibleContent.id = panelId;
+
+ toggleButton.setAttribute('aria-controls', panelId);
+ if (!collapsibleContent.hasAttribute('role')) {
+ collapsibleContent.setAttribute('role', 'region');
+ }
+ if (!collapsibleContent.hasAttribute('aria-label')) {
+ const label =
+ toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim();
+ collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`);
+ }
+ if (!collapsibleContent.hasAttribute('tabindex')) {
+ collapsibleContent.tabIndex = -1;
+ }
+
toggleButton.addEventListener('click', this.toggle.bind(this));
window.addEventListener(
'click',
(event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close()
);
+ window.addEventListener('keydown', (event) => {
+ if (this._isOpen && event.key === 'Escape') {
+ event.preventDefault();
+ this.close();
+ }
+ });
+ this.syncAccessibility();
}
public open() {
+ if (this._isOpen) {
+ return;
+ }
+
+ this.focusBeforeOpen =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
this._isOpen = true;
- this.collapsibleContent.classList.remove('hidden');
- this.toggleButton.classList.add('active');
+ this.syncAccessibility();
this.onOpen();
+ this.focusPanel();
}
public close() {
+ if (!this._isOpen) {
+ return;
+ }
+
+ const focusWasInside = this.collapsibleContent.contains(document.activeElement);
this._isOpen = false;
- this.collapsibleContent.classList.add('hidden');
- this.toggleButton.classList.remove('active');
+ this.syncAccessibility();
this.onClose();
+
+ if (focusWasInside) {
+ (this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true });
+ }
}
public toggle() {
@@ -41,4 +83,20 @@ export class CollapsiblePanelAnimator {
public get isOpen() {
return this._isOpen;
}
+
+ private syncAccessibility() {
+ this.collapsibleContent.classList.toggle('hidden', !this._isOpen);
+ this.toggleButton.classList.toggle('active', this._isOpen);
+ this.toggleButton.setAttribute('aria-expanded', String(this._isOpen));
+ this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen));
+ this.collapsibleContent.inert = !this._isOpen;
+ }
+
+ private focusPanel() {
+ requestAnimationFrame(() => {
+ if (this._isOpen) {
+ this.collapsibleContent.focus({ preventScroll: true });
+ }
+ });
+ }
}
diff --git a/src/page/config-pane.ts b/src/page/config-pane.ts
new file mode 100644
index 0000000..bf27c78
--- /dev/null
+++ b/src/page/config-pane.ts
@@ -0,0 +1,434 @@
+import { Pane, type BindingParams, type FolderApi } from 'tweakpane';
+
+import {
+ appConfig,
+ type GardenRuntimeSettings,
+ type NumberControlConfig,
+} from '../config';
+import { activeVibe, settings } from '../settings';
+import { VIBE_PRESETS } from '../vibes';
+
+type PaneContainer = Pick;
+type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
+
+const colorReactionRows = [
+ {
+ colorIndex: 0,
+ label: '1',
+ keys: ['color1ToColor1', 'color1ToColor2', 'color1ToColor3'],
+ },
+ {
+ colorIndex: 1,
+ label: '2',
+ keys: ['color2ToColor1', 'color2ToColor2', 'color2ToColor3'],
+ },
+ {
+ colorIndex: 2,
+ label: '3',
+ keys: ['color3ToColor1', 'color3ToColor2', 'color3ToColor3'],
+ },
+] as const;
+
+const colorReactionKeySet = new Set(
+ colorReactionRows.flatMap((row) => [...row.keys])
+);
+
+const isColorReactionKey = (key: string): key is ColorReactionKey =>
+ colorReactionKeySet.has(key);
+
+interface ConfigPaneOptions {
+ onConfigChange: () => void;
+ onRestart: () => void;
+ onRuntimeChange: () => void;
+ onRuntimeReset: () => void;
+ onVibeChange: (vibeId: string) => void;
+ settingsButton: HTMLButtonElement;
+}
+
+const isPlainObject = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null;
+
+const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
+ ['boolean', 'number', 'string'].includes(typeof value);
+
+const isColorString = (value: unknown): value is string =>
+ typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
+
+const toLabel = (value: string): string =>
+ value
+ .replace(/\[(\d+)\]/g, ' $1')
+ .replace(/([A-Z])/g, ' $1')
+ .replace(/[-_]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+const normalizeNumber = (value: number, config: NumberControlConfig): number => {
+ if (config.options) {
+ const optionValues = Object.values(config.options);
+ if (optionValues.includes(value)) {
+ return value;
+ }
+ return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min);
+ }
+
+ const finiteValue = Number.isFinite(value) ? value : config.min;
+ const clampedValue = Math.min(config.max, Math.max(config.min, finiteValue));
+ return config.integer ? Math.round(clampedValue) : clampedValue;
+};
+
+const getNumberBindingParams = (
+ key: keyof GardenRuntimeSettings & string,
+ config: NumberControlConfig
+): BindingParams => ({
+ label: config.label ?? toLabel(key),
+ min: config.min,
+ max: config.max,
+ options: config.options,
+ step: config.step,
+});
+
+export class ConfigPane {
+ private readonly container: HTMLDivElement;
+ private readonly pane: Pane;
+ private readonly colorReactionSelects = new Map<
+ ColorReactionKey,
+ HTMLSelectElement
+ >();
+ private readonly colorReactionSwatches: Array<{
+ colorIndex: number;
+ element: HTMLElement;
+ }> = [];
+ private readonly state = {
+ activeVibeId: activeVibe.id,
+ };
+
+ public constructor(private readonly options: ConfigPaneOptions) {
+ this.container = document.createElement('div');
+ this.container.className = 'config-pane-container';
+ Object.assign(this.container.style, {
+ boxSizing: 'border-box',
+ maxHeight: 'calc(100vh - 24px)',
+ pointerEvents: 'none',
+ position: 'fixed',
+ right: 'max(12px, env(safe-area-inset-right, 0px))',
+ top: 'max(12px, env(safe-area-inset-top, 0px))',
+ width:
+ 'min(420px, calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)))',
+ zIndex: '20',
+ });
+ document.body.appendChild(this.container);
+
+ this.pane = new Pane({
+ container: this.container,
+ title: appConfig.tuningPane.title,
+ expanded: true,
+ });
+ this.pane.hidden = appConfig.tuningPane.startHidden;
+ this.pane.element.classList.add('config-pane');
+ this.pane.element.style.boxSizing = 'border-box';
+ this.pane.element.style.maxHeight = 'calc(100vh - 24px)';
+ this.pane.element.style.overflowY = 'auto';
+ this.pane.element.style.pointerEvents = 'auto';
+ this.pane.element.style.width = '100%';
+
+ this.options.settingsButton.addEventListener('click', this.toggle);
+
+ const tabs = this.pane.addTab({
+ pages: [{ title: 'Runtime' }, { title: 'Config' }],
+ });
+
+ this.setUpRuntimeTab(tabs.pages[0]);
+ this.setUpConfigTab(tabs.pages[1]);
+ this.syncButton();
+ }
+
+ public get isOpen(): boolean {
+ return !this.pane.hidden;
+ }
+
+ public refresh(): void {
+ this.state.activeVibeId = activeVibe.id;
+ this.pane.refresh();
+ this.syncColorReactionMatrix();
+ this.syncButton();
+ }
+
+ private readonly toggle = () => {
+ this.pane.hidden = !this.pane.hidden;
+ this.syncButton();
+ };
+
+ private setHidden(isHidden: boolean): void {
+ this.pane.hidden = isHidden;
+ this.syncButton();
+ }
+
+ private setUpRuntimeTab(container: PaneContainer): void {
+ container
+ .addBinding(this.state, 'activeVibeId', {
+ label: 'active vibe',
+ options: Object.fromEntries(
+ VIBE_PRESETS.map((vibe) => [vibe.name, vibe.id])
+ ) as Record,
+ })
+ .on('change', ({ value }) => {
+ this.options.onVibeChange(value);
+ this.refresh();
+ });
+
+ container
+ .addButton({
+ title: 'Reset runtime settings',
+ })
+ .on('click', () => {
+ this.options.onRuntimeReset();
+ this.refresh();
+ });
+
+ container
+ .addButton({
+ title: 'Restart simulation',
+ })
+ .on('click', () => this.options.onRestart());
+
+ const folders = new Map();
+ let hasAddedColorReactionMatrix = false;
+ Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
+ const settingKey = key as keyof GardenRuntimeSettings & string;
+ settings[settingKey] = normalizeNumber(settings[settingKey], config);
+
+ if (isColorReactionKey(key)) {
+ if (!hasAddedColorReactionMatrix) {
+ this.addColorReactionMatrix(container);
+ hasAddedColorReactionMatrix = true;
+ }
+ return;
+ }
+
+ const folder =
+ folders.get(config.folder) ??
+ container.addFolder({
+ title: config.folder,
+ expanded: config.folder !== 'Runtime',
+ });
+ folders.set(config.folder, folder);
+
+ folder
+ .addBinding(settings, settingKey, getNumberBindingParams(settingKey, config))
+ .on('change', () => {
+ const nextValue = normalizeNumber(settings[settingKey], config);
+ if (nextValue !== settings[settingKey]) {
+ settings[settingKey] = nextValue;
+ this.pane.refresh();
+ }
+ this.options.onRuntimeChange();
+ });
+ });
+ this.syncColorReactionMatrix();
+ }
+
+ private addColorReactionMatrix(container: PaneContainer): void {
+ const folder = container.addFolder({
+ title: 'Color Reactions',
+ expanded: true,
+ });
+ folder.element.classList.add('color-reaction-folder');
+
+ const content = Array.from(folder.element.children).find((child) =>
+ child.classList.contains('tp-fldv_c')
+ );
+ if (!(content instanceof HTMLElement)) {
+ return;
+ }
+
+ const doc = folder.element.ownerDocument;
+ const matrix = doc.createElement('div');
+ matrix.className = 'color-reaction-matrix';
+
+ matrix.appendChild(this.createColorReactionCorner(doc));
+ colorReactionRows.forEach((row) => {
+ matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+ });
+
+ colorReactionRows.forEach((row) => {
+ matrix.appendChild(this.createColorReactionHeader(doc, row.colorIndex, row.label));
+ row.keys.forEach((key, columnIndex) => {
+ matrix.appendChild(
+ this.createColorReactionCell(doc, key, row.colorIndex, columnIndex)
+ );
+ });
+ });
+
+ content.appendChild(matrix);
+ this.syncColorReactionMatrix();
+ }
+
+ private createColorReactionCorner(doc: Document): HTMLDivElement {
+ const corner = doc.createElement('div');
+ corner.className = 'color-reaction-matrix__corner';
+ corner.textContent = 'agent';
+ return corner;
+ }
+
+ private createColorReactionHeader(
+ doc: Document,
+ colorIndex: number,
+ label: string
+ ): HTMLDivElement {
+ const header = doc.createElement('div');
+ header.className = 'color-reaction-matrix__header';
+
+ const swatch = doc.createElement('span');
+ swatch.className = 'color-reaction-matrix__swatch';
+ this.colorReactionSwatches.push({ colorIndex, element: swatch });
+ header.appendChild(swatch);
+
+ const text = doc.createElement('span');
+ text.textContent = label;
+ header.appendChild(text);
+
+ return header;
+ }
+
+ private createColorReactionCell(
+ doc: Document,
+ key: ColorReactionKey,
+ sourceColorIndex: number,
+ targetColorIndex: number
+ ): HTMLLabelElement {
+ const cell = doc.createElement('label');
+ cell.className = 'color-reaction-matrix__cell';
+
+ const select = doc.createElement('select');
+ select.setAttribute(
+ 'aria-label',
+ `Color ${sourceColorIndex + 1} agents reacting to color ${targetColorIndex + 1}`
+ );
+
+ const config = appConfig.runtimeSettings.controls[key];
+ Object.entries(config.options ?? {}).forEach(([label, value]) => {
+ const option = doc.createElement('option');
+ option.value = String(value);
+ option.textContent = label;
+ select.appendChild(option);
+ });
+
+ select.addEventListener('change', () => {
+ settings[key] = normalizeNumber(Number(select.value), config);
+ select.value = String(settings[key]);
+ this.options.onRuntimeChange();
+ });
+
+ this.colorReactionSelects.set(key, select);
+ cell.appendChild(select);
+
+ return cell;
+ }
+
+ private syncColorReactionMatrix(): void {
+ this.colorReactionSelects.forEach((select, key) => {
+ const config = appConfig.runtimeSettings.controls[key];
+ settings[key] = normalizeNumber(settings[key], config);
+ select.value = String(settings[key]);
+ });
+
+ this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
+ element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff';
+ });
+ }
+
+ private setUpConfigTab(container: PaneContainer): void {
+ this.addObjectBindings(
+ container,
+ appConfig as unknown as Record,
+ []
+ );
+ }
+
+ private addObjectBindings(
+ container: PaneContainer,
+ source: Record,
+ path: Array
+ ): void {
+ Object.entries(source).forEach(([key, value]) => {
+ if (isBindablePrimitive(value)) {
+ this.addPrimitiveBinding(container, source, key, value);
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ const folder = container.addFolder({
+ title: toLabel(`${key}[]`),
+ expanded: path.length < appConfig.tuningPane.expandedDepth,
+ });
+ value.forEach((item, index) => {
+ if (isBindablePrimitive(item)) {
+ this.addPrimitiveBinding(
+ folder,
+ value as unknown as Record,
+ `${index}`,
+ item
+ );
+ return;
+ }
+
+ if (isPlainObject(item)) {
+ this.addObjectBindings(
+ folder.addFolder({
+ title: `[${index}]`,
+ expanded: false,
+ }),
+ item,
+ [...path, key, String(index)]
+ );
+ }
+ });
+ return;
+ }
+
+ if (isPlainObject(value)) {
+ this.addObjectBindings(
+ container.addFolder({
+ title: toLabel(key),
+ expanded: path.length < appConfig.tuningPane.expandedDepth,
+ }),
+ value,
+ [...path, key]
+ );
+ }
+ });
+ }
+
+ private addPrimitiveBinding(
+ container: PaneContainer,
+ source: Record,
+ key: string,
+ value: boolean | number | string
+ ): void {
+ const params: BindingParams = {
+ label: toLabel(key),
+ ...(isColorString(value) ? { color: { type: 'int' } } : {}),
+ ...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
+ };
+
+ container
+ .addBinding(source, key, params)
+ .on('change', () => this.options.onConfigChange());
+ }
+
+ private syncButton(): void {
+ this.options.settingsButton.classList.toggle('active', this.isOpen);
+ this.options.settingsButton.setAttribute('aria-expanded', String(this.isOpen));
+ this.options.settingsButton.setAttribute(
+ 'aria-label',
+ this.isOpen ? 'Hide config overlay' : 'Show config overlay'
+ );
+ this.options.settingsButton.title = this.isOpen
+ ? 'Hide config overlay'
+ : 'Show config overlay';
+ }
+
+ public close(): void {
+ this.setHidden(true);
+ }
+}
diff --git a/src/page/full-screen-handler.ts b/src/page/full-screen-handler.ts
index 84c9150..6bf081e 100644
--- a/src/page/full-screen-handler.ts
+++ b/src/page/full-screen-handler.ts
@@ -32,12 +32,11 @@ export class FullScreenHandler {
return document.fullscreenElement !== null;
}
- private updateButtons() {
- this.minimizeButton.style.display = FullScreenHandler.isInFullScreenMode()
- ? 'block'
- : 'none';
- this.maximizeButton.style.display = FullScreenHandler.isInFullScreenMode()
- ? 'none'
- : 'block';
+ private updateButtons(): void {
+ const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
+ this.minimizeButton.style.display = isInFullScreenMode ? 'block' : 'none';
+ this.maximizeButton.style.display = isInFullScreenMode ? 'none' : 'block';
+ this.minimizeButton.classList.toggle('active', isInFullScreenMode);
+ this.maximizeButton.classList.toggle('active', isInFullScreenMode);
}
}
diff --git a/src/page/menu-hider.ts b/src/page/menu-hider.ts
index 173a5b6..b2ea459 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,17 +1,107 @@
-export class MenuHider {
- private static readonly DEFAULT_TIME_TO_LIVE = 3500;
- private static readonly INTERVAL = 50;
- private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+import { appConfig } from '../config';
+
+interface MenuHiderOptions {
+ persistentElement?: HTMLElement;
+}
+
+export class MenuHider {
+ private static readonly DEFAULT_TIME_TO_LIVE = appConfig.menuHider.timeToLiveMs;
+ private static readonly INTERVAL = appConfig.menuHider.intervalMs;
+ private static readonly BOTTOM_REVEAL_DISTANCE =
+ appConfig.menuHider.bottomRevealDistancePx;
+ private readonly interactiveElements: Array;
+ private timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+ private isHidden = false;
+
+ public constructor(
+ private readonly element: HTMLElement,
+ private readonly shouldBeHidden: () => boolean,
+ private readonly options: MenuHiderOptions = {}
+ ) {
+ this.interactiveElements = Array.from(
+ element.querySelectorAll(
+ 'a[href], button, input, select, textarea, [tabindex]'
+ )
+ );
+
+ if (options.persistentElement) {
+ element.classList.add('has-persistent-settings');
+ }
- public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
setInterval(() => {
this.timeToLive = Math.max(0, this.timeToLive - MenuHider.INTERVAL);
- element.style.opacity = this.timeToLive == 0 && shouldBeHidden() ? '0' : '1';
+ this.updateVisibility();
}, MenuHider.INTERVAL);
- element.addEventListener(
- 'mouseover',
- () => (this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE)
- );
+ element.addEventListener('mouseover', this.wakeUp);
+ element.addEventListener('focusin', this.wakeUp);
+ element.addEventListener('pointerdown', this.wakeUp);
+ window.addEventListener('pointermove', this.wakeUpNearViewportBottom, {
+ passive: true,
+ });
+ window.addEventListener('pointerdown', this.wakeUp, {
+ capture: true,
+ passive: true,
+ });
+ window.addEventListener('touchstart', this.wakeUp, {
+ capture: true,
+ passive: true,
+ });
+ window.addEventListener('keydown', this.wakeUp, { capture: true });
+ window.addEventListener('focusin', this.wakeUp, { capture: true });
+
+ this.updateVisibility();
+ }
+
+ private readonly wakeUp = () => {
+ this.timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+ this.updateVisibility();
+ };
+
+ private readonly wakeUpNearViewportBottom = (event: PointerEvent) => {
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+ const revealStart = viewportHeight - MenuHider.BOTTOM_REVEAL_DISTANCE;
+
+ if (event.clientY >= revealStart) {
+ this.wakeUp();
+ }
+ };
+
+ private updateVisibility() {
+ const focusWithin = this.element.contains(document.activeElement);
+ const shouldHide = this.timeToLive === 0 && this.shouldBeHidden() && !focusWithin;
+
+ if (this.isHidden === shouldHide) {
+ return;
+ }
+
+ this.isHidden = shouldHide;
+ this.element.classList.toggle('menu-hidden', shouldHide);
+ this.syncAccessibility(shouldHide);
+ }
+
+ private syncAccessibility(shouldHide: boolean): void {
+ const persistentElement = this.options.persistentElement;
+
+ if (!persistentElement) {
+ this.element.style.opacity = shouldHide ? '0' : '1';
+ this.element.setAttribute('aria-hidden', String(shouldHide));
+ this.element.inert = shouldHide;
+ return;
+ }
+
+ this.element.style.opacity = '';
+ this.element.setAttribute('aria-hidden', 'false');
+ this.element.inert = false;
+
+ this.interactiveElements.forEach((interactiveElement) => {
+ const isPersistentElement = interactiveElement === persistentElement;
+
+ interactiveElement.inert = shouldHide && !isPersistentElement;
+ interactiveElement.toggleAttribute(
+ 'aria-hidden',
+ shouldHide && !isPersistentElement
+ );
+ });
}
}
diff --git a/src/page/set-up-settings-page.ts b/src/page/set-up-settings-page.ts
deleted file mode 100644
index 238d704..0000000
--- a/src/page/set-up-settings-page.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { isProduction } from '../constants';
-import { settings } from '../settings';
-import { SettingsSlider, ValueScaling } from './settings-slider';
-
-export const setUpSettingsPage = (
- settingsPage: HTMLDivElement,
- maxAgentCount: number
-): Array> => {
- const sliders: Array> = [
- ...(isProduction
- ? []
- : [
- new SettingsSlider(settings, 'renderSpeed', {
- min: 1,
- max: 10,
- rounding: Math.round,
- }),
- ]),
-
- new SettingsSlider(settings, 'agentCount', {
- min: 1,
- max: maxAgentCount,
- scaling: ValueScaling.Quadratic,
- rounding: Math.round,
- }),
-
- new SettingsSlider(settings, 'currentGenerationAggression', {
- min: -5,
- max: 5,
- }),
-
- new SettingsSlider(settings, 'nextGenerationAggression', {
- min: -5,
- max: 5,
- }),
-
- new SettingsSlider(settings, 'moveSpeed', {
- min: 10,
- max: 500,
- scaling: ValueScaling.Quadratic,
- rounding: Math.round,
- }),
-
- new SettingsSlider(settings, 'turnSpeed', {
- min: 1,
- max: 200,
- scaling: ValueScaling.Quadratic,
- rounding: Math.round,
- }),
-
- new SettingsSlider(settings, 'sensorOffsetAngle', {
- min: 0,
- max: 90,
- step: 1,
- }),
-
- new SettingsSlider(settings, 'sensorOffsetDistance', {
- min: 0,
- max: 200,
- scaling: ValueScaling.Quadratic,
- rounding: Math.round,
- }),
-
- new SettingsSlider(settings, 'turnWhenLost', {
- min: 0,
- max: 1,
- }),
-
- new SettingsSlider(settings, 'individualTrailWeight', {
- min: 0,
- max: 1,
- }),
-
- new SettingsSlider(settings, 'diffusionRateTrails', {
- min: 0,
- max: 2,
- }),
-
- new SettingsSlider(settings, 'decayRateTrails', {
- min: 0.1,
- max: 5000,
- scaling: ValueScaling.Quadratic,
- }),
-
- new SettingsSlider(settings, 'diffusionRateBrush', {
- min: 0.001,
- max: 1,
- }),
-
- new SettingsSlider(settings, 'decayRateBrush', {
- min: 0.1,
- max: 100,
- }),
-
- new SettingsSlider(settings, 'brushSize', {
- min: 1,
- max: 30,
- }),
-
- new SettingsSlider(settings, 'clarity', {
- min: 0.00001,
- max: 1,
- }),
- ];
-
- const sliderContainerElement = document.createElement('div');
-
- sliders.forEach((slider) => {
- sliderContainerElement.appendChild(slider.element);
- });
-
- settingsPage.appendChild(sliderContainerElement);
-
- return sliders;
-};
diff --git a/src/page/settings-slider.ts b/src/page/settings-slider.ts
deleted file mode 100644
index d7ad26b..0000000
--- a/src/page/settings-slider.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { formatNumber } from '../utils/format-number';
-
-export enum ValueScaling {
- Linear,
- Quadratic,
- Logarithmic,
-}
-
-export interface SliderConfiguration {
- min: number;
- max: number;
- unit?: string;
- step?: number;
- onChangeCallback?: (value: number) => unknown;
- scaling: ValueScaling;
- rounding: (value: number) => number;
-}
-
-export class SettingsSlider> {
- private static readonly DEFAULT_STEP_COUNT = 20000;
-
- private readonly slider: HTMLInputElement;
- private readonly valueDisplay: HTMLSpanElement;
- private readonly sliderWrapper: HTMLDivElement;
- private readonly config: SliderConfiguration = {
- min: 0,
- max: 1,
- scaling: ValueScaling.Linear,
- rounding: (value) => value,
- };
-
- public constructor(
- private readonly settings: T,
- private readonly settingName: keyof T & string,
- config: Partial = {}
- ) {
- this.slider = SettingsSlider.createSlider();
- this.valueDisplay = SettingsSlider.createValueDisplay();
- this.sliderWrapper = SettingsSlider.createSliderWrapper(
- this.settingName,
- this.slider,
- this.valueDisplay
- );
-
- this.slider.addEventListener('input', this.onChange.bind(this));
-
- this.updateConfig(config);
- }
-
- private static createSlider() {
- const input = document.createElement('input');
- input.type = 'range';
- return input;
- }
-
- private static createValueDisplay() {
- return document.createElement('span');
- }
-
- private static createSliderWrapper(
- name: string,
- slider: HTMLInputElement,
- valueDisplay: HTMLSpanElement
- ) {
- const wrapper = document.createElement('div');
- wrapper.classList.add('slider');
- const label = document.createElement('label');
-
- const title = document.createElement('p');
- title.innerText = SettingsSlider.formatLabel(name);
- title.appendChild(valueDisplay);
-
- label.appendChild(title);
- label.appendChild(slider);
- wrapper.appendChild(label);
-
- return wrapper;
- }
-
- private static formatLabel(value: string): string {
- const formatted = value.replace(/([A-Z])/g, ' $1');
-
- return (
- formatted.charAt(0).toLocaleUpperCase() + formatted.slice(1).toLocaleLowerCase()
- );
- }
-
- private onChange() {
- this.settings[this.settingName] = this.config.rounding(
- this.inverseScaling(Number(this.slider.value))
- ) as any;
-
- this.config.onChangeCallback?.(this.settings[this.settingName]);
- this.valueDisplay.innerText = formatNumber(
- this.settings[this.settingName],
- this.config.unit
- );
- }
-
- public updateSliderValueBasedOnSource() {
- this.slider.value = this.scaling(this.settings[this.settingName]).toString();
- this.onChange();
- }
-
- public updateConfig(config: Partial) {
- Object.assign(this.config, config);
-
- if (this.config.step === undefined) {
- this.config.step =
- (this.scaling(this.config.max) - this.scaling(this.config.min)) /
- SettingsSlider.DEFAULT_STEP_COUNT;
- }
-
- this.slider.min = this.scaling(this.config.min).toString();
- this.slider.max = this.scaling(this.config.max).toString();
- this.slider.step = this.config.step.toString();
- this.slider.value = this.scaling(this.settings[this.settingName]).toString();
- this.onChange();
- }
-
- public get element(): HTMLElement {
- return this.sliderWrapper;
- }
-
- private get scaling(): (value: number) => number {
- switch (this.config.scaling) {
- case ValueScaling.Linear:
- return (value) => value;
- case ValueScaling.Quadratic:
- return (value) => Math.sqrt(value);
- case ValueScaling.Logarithmic:
- return (value) => Math.log10(value);
- }
- }
-
- private get inverseScaling(): (value: number) => number {
- switch (this.config.scaling) {
- case ValueScaling.Linear:
- return (value) => value;
- case ValueScaling.Quadratic:
- return (value) => Math.pow(value, 2);
- case ValueScaling.Logarithmic:
- return (value) => Math.pow(10, value);
- }
- }
-}
diff --git a/src/pipelines/agents/agent-generation/agent-compaction.wgsl b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
new file mode 100644
index 0000000..6be9e0e
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-compaction.wgsl
@@ -0,0 +1,36 @@
+struct Settings {
+ agentCount: u32,
+ padding0: u32,
+ padding1: u32,
+ padding2: u32,
+};
+
+struct Counters {
+ aliveAgentCount: atomic,
+ padding0: atomic,
+ padding1: atomic,
+};
+
+@group(1) @binding(0) var settings: Settings;
+@group(1) @binding(2) var counters: Counters;
+@group(1) @binding(3) var compactedAgents: array;
+
+@compute @workgroup_size(64)
+fn main(
+ @builtin(global_invocation_id) global_id: vec3,
+ @builtin(num_workgroups) workgroup_count: vec3
+) {
+ let id = get_id(global_id, workgroup_count);
+
+ if id >= settings.agentCount {
+ return;
+ }
+
+ let agent = agents[id];
+ if agent.colorIndex < 0.0 {
+ return;
+ }
+
+ let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1);
+ compactedAgents[compactedIndex] = agent;
+}
diff --git a/src/pipelines/agents/agent-generation/agent-counting.wgsl b/src/pipelines/agents/agent-generation/agent-counting.wgsl
index 9d4c1b6..964125a 100644
--- a/src/pipelines/agents/agent-generation/agent-counting.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-counting.wgsl
@@ -5,8 +5,8 @@ struct Settings {
@group(1) @binding(0) var settings: Settings;
struct Counters {
- evenGenerationAlive: atomic,
- oddGenerationAlive: atomic,
+ redAgentsAlive: atomic,
+ greenAgentsAlive: atomic,
};
@group(1) @binding(2) var counters: Counters;
@@ -23,9 +23,13 @@ fn main(
return;
}
- if agents[id].generation % 2 == 0 {
- atomicAdd(&counters.evenGenerationAlive, 1);
+ if agents[id].colorIndex < 0.0 {
+ return;
+ }
+
+ if agents[id].colorIndex < 0.5 {
+ atomicAdd(&counters.redAgentsAlive, 1);
} else {
- atomicAdd(&counters.oddGenerationAlive, 1);
+ atomicAdd(&counters.greenAgentsAlive, 1);
}
}
diff --git a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
index 0f2bb34..a91ae14 100644
--- a/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-first-generation.wgsl
@@ -30,5 +30,8 @@ fn main(
randomPosition.xz * state.size,
random.r * 3.14 * 2,
0,
+ vec2(-1.0, -1.0),
+ 0.0,
+ 0.0,
);
}
diff --git a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
index f347d59..3626449 100644
--- a/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
+++ b/src/pipelines/agents/agent-generation/agent-generation-pipeline.ts
@@ -1,27 +1,42 @@
+import { vec2 } from 'gl-matrix';
+
import { getWorkgroupCounts } from '../../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../../utils/graphics/smart-compile';
import { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
+import compactionShader from './agent-compaction.wgsl?raw';
import countingShader from './agent-counting.wgsl?raw';
import firstGenerationShader from './agent-first-generation.wgsl?raw';
+import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
import { GenerationCounts } from './generation-counts';
export class AgentGenerationPipeline {
private static readonly WORKGROUP_SIZE = 64;
- private static readonly UNIFORM_COUNT = 1;
+ private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 3;
private readonly bindGroupLayout: GPUBindGroupLayout;
+ private readonly compactionBindGroupLayout: GPUBindGroupLayout;
private readonly uniforms: GPUBuffer;
private readonly bindGroup: GPUBindGroup;
+ private readonly compactionBindGroup: GPUBindGroup;
private readonly firstGenerationPipeline: GPUComputePipeline;
private readonly countingPipeline: GPUComputePipeline;
+ private readonly resizePipeline: GPUComputePipeline;
+ private readonly compactionPipeline: GPUComputePipeline;
public readonly agentsBuffer: GPUBuffer;
+ private readonly compactedAgentsBuffer: GPUBuffer;
public readonly countersBuffer: GPUBuffer;
public readonly countersStagingBuffer: GPUBuffer;
+ private readonly counterClearValues = new Uint32Array(
+ AgentGenerationPipeline.COUNTER_COUNT
+ );
+ private readonly agentCountUniformValues = new Uint32Array(
+ AgentGenerationPipeline.UNIFORM_COUNT
+ );
public constructor(
private readonly device: GPUDevice,
@@ -54,9 +69,47 @@ export class AgentGenerationPipeline {
],
});
+ this.compactionBindGroupLayout = device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: {
+ type: 'uniform',
+ },
+ },
+ {
+ binding: 1,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: {
+ type: 'storage',
+ },
+ },
+ {
+ binding: 2,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: {
+ type: 'storage',
+ },
+ },
+ {
+ binding: 3,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: {
+ type: 'storage',
+ },
+ },
+ ],
+ });
+
this.agentsBuffer = this.device.createBuffer({
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
- usage: GPUBufferUsage.STORAGE,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ });
+
+ this.compactedAgentsBuffer = this.device.createBuffer({
+ size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
this.countersBuffer = this.device.createBuffer({
@@ -98,6 +151,36 @@ export class AgentGenerationPipeline {
],
});
+ this.compactionBindGroup = this.device.createBindGroup({
+ layout: this.compactionBindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: {
+ buffer: this.uniforms,
+ },
+ },
+ {
+ binding: 1,
+ resource: {
+ buffer: this.agentsBuffer,
+ },
+ },
+ {
+ binding: 2,
+ resource: {
+ buffer: this.countersBuffer,
+ },
+ },
+ {
+ binding: 3,
+ resource: {
+ buffer: this.compactedAgentsBuffer,
+ },
+ },
+ ],
+ });
+
this.firstGenerationPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
@@ -122,16 +205,79 @@ export class AgentGenerationPipeline {
entryPoint: 'main',
},
});
+
+ this.resizePipeline = device.createComputePipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+ }),
+ compute: {
+ module: smartCompile(device, CommonState.shaderCode, agentSchema, resizeShader),
+ entryPoint: 'main',
+ },
+ });
+
+ this.compactionPipeline = device.createComputePipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
+ }),
+ compute: {
+ module: smartCompile(
+ device,
+ CommonState.shaderCode,
+ agentSchema,
+ compactionShader
+ ),
+ entryPoint: 'main',
+ },
+ });
}
public get maxAgentCount(): number {
return Math.min(
- this.maxAgentCountUpperLimit,
+ Number.isFinite(this.maxAgentCountUpperLimit)
+ ? this.maxAgentCountUpperLimit
+ : Number.POSITIVE_INFINITY,
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
this.device.limits.maxComputeWorkgroupsPerDimension ** 3
);
}
+ public writeAgents(agentOffset: number, data: Float32Array): void {
+ this.device.queue.writeBuffer(
+ this.agentsBuffer,
+ agentOffset * AGENT_SIZE_IN_BYTES,
+ data
+ );
+ }
+
+ public resizeAgents(agentCount: number, scale: vec2): void {
+ if (agentCount <= 0 || vec2.equals(scale, vec2.fromValues(1, 1))) {
+ return;
+ }
+
+ this.device.queue.writeBuffer(
+ this.uniforms,
+ 0,
+ new Float32Array([scale[0], scale[1], agentCount, 0])
+ );
+
+ const commandEncoder = this.device.createCommandEncoder();
+ const passEncoder = commandEncoder.beginComputePass();
+ this.commonState.execute(passEncoder);
+ passEncoder.setPipeline(this.resizePipeline);
+ passEncoder.setBindGroup(1, this.bindGroup);
+ passEncoder.dispatchWorkgroups(
+ ...getWorkgroupCounts(
+ this.device,
+ agentCount,
+ AgentGenerationPipeline.WORKGROUP_SIZE
+ )
+ );
+ passEncoder.end();
+
+ this.device.queue.submit([commandEncoder.finish()]);
+ }
+
public spawnFirstGeneration(): void {
const commandEncoder = this.device.createCommandEncoder();
@@ -152,8 +298,11 @@ export class AgentGenerationPipeline {
}
public async countAgents(agentCount: number): Promise {
- this.device.queue.writeBuffer(this.countersBuffer, 0, new Uint32Array([0, 0]));
- this.device.queue.writeBuffer(this.uniforms, 0, new Uint32Array([agentCount]));
+ this.counterClearValues.fill(0);
+ this.agentCountUniformValues.fill(0);
+ this.agentCountUniformValues[0] = agentCount;
+ this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
+ this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
const commandEncoder = this.device.createCommandEncoder();
@@ -190,10 +339,62 @@ export class AgentGenerationPipeline {
};
}
+ public async compactAgents(agentCount: number): Promise {
+ if (agentCount <= 0) {
+ return 0;
+ }
+
+ this.counterClearValues.fill(0);
+ this.agentCountUniformValues.fill(0);
+ this.agentCountUniformValues[0] = agentCount;
+ this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
+ this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
+
+ const commandEncoder = this.device.createCommandEncoder();
+ const passEncoder = commandEncoder.beginComputePass();
+ passEncoder.setPipeline(this.compactionPipeline);
+ this.commonState.execute(passEncoder);
+ passEncoder.setBindGroup(1, this.compactionBindGroup);
+ passEncoder.dispatchWorkgroups(
+ ...getWorkgroupCounts(
+ this.device,
+ agentCount,
+ AgentGenerationPipeline.WORKGROUP_SIZE
+ )
+ );
+ passEncoder.end();
+
+ commandEncoder.copyBufferToBuffer(
+ this.compactedAgentsBuffer,
+ 0,
+ this.agentsBuffer,
+ 0,
+ agentCount * AGENT_SIZE_IN_BYTES
+ );
+ commandEncoder.copyBufferToBuffer(
+ this.countersBuffer,
+ 0,
+ this.countersStagingBuffer,
+ 0,
+ Uint32Array.BYTES_PER_ELEMENT
+ );
+
+ this.device.queue.submit([commandEncoder.finish()]);
+
+ await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
+ const compactedCount = new Uint32Array(
+ this.countersStagingBuffer.getMappedRange().slice(0, Uint32Array.BYTES_PER_ELEMENT)
+ )[0];
+ this.countersStagingBuffer.unmap();
+
+ return compactedCount;
+ }
+
public destroy() {
this.uniforms.destroy();
this.countersBuffer.destroy();
this.countersStagingBuffer.destroy();
+ this.compactedAgentsBuffer.destroy();
this.agentsBuffer.destroy();
}
}
diff --git a/src/pipelines/agents/agent-generation/agent-resize.wgsl b/src/pipelines/agents/agent-generation/agent-resize.wgsl
new file mode 100644
index 0000000..3f02c46
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-resize.wgsl
@@ -0,0 +1,28 @@
+struct ResizeSettings {
+ scale: vec2,
+ agentCount: f32,
+ padding: f32,
+};
+
+@group(1) @binding(0) var resizeSettings: ResizeSettings;
+
+@compute @workgroup_size(64)
+fn main(
+ @builtin(global_invocation_id) global_id: vec3,
+ @builtin(num_workgroups) workgroup_count: vec3
+) {
+ let id = get_id(global_id, workgroup_count);
+
+ if id >= u32(resizeSettings.agentCount) {
+ return;
+ }
+
+ var agent = agents[id];
+ agent.position *= resizeSettings.scale;
+
+ if agent.targetPosition.x >= 0.0 && agent.targetPosition.y >= 0.0 {
+ agent.targetPosition *= resizeSettings.scale;
+ }
+
+ agents[id] = agent;
+}
diff --git a/src/pipelines/agents/agent-generation/agent-schema.test.ts b/src/pipelines/agents/agent-generation/agent-schema.test.ts
new file mode 100644
index 0000000..96a419e
--- /dev/null
+++ b/src/pipelines/agents/agent-generation/agent-schema.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest';
+
+import { AGENT_FLOAT_COUNT, AGENT_SIZE_IN_BYTES } from './agent';
+import compactionShader from './agent-compaction.wgsl?raw';
+import countingShader from './agent-counting.wgsl?raw';
+import firstGenerationShader from './agent-first-generation.wgsl?raw';
+import resizeShader from './agent-resize.wgsl?raw';
+import agentSchema from './agent-schema.wgsl?raw';
+
+const wgslFloatCountByType: Record = {
+ f32: 1,
+ 'vec2': 2,
+};
+
+const getAgentStructFields = () => {
+ const match = /struct Agent\s*\{(?[\s\S]*?)\n\}/.exec(agentSchema);
+ if (!match?.groups?.body) {
+ throw new Error('Agent struct was not found in agent-schema.wgsl');
+ }
+
+ return match.groups.body
+ .split('\n')
+ .map((line) => line.trim().replace(/,$/, ''))
+ .filter(Boolean)
+ .map((line) => {
+ const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line);
+ if (!fieldMatch?.groups) {
+ throw new Error(`Unsupported Agent field syntax: ${line}`);
+ }
+
+ return {
+ name: fieldMatch.groups.name,
+ type: fieldMatch.groups.type,
+ };
+ });
+};
+
+describe('Agent TS/WGSL contract', () => {
+ it('keeps the TypeScript float count aligned with the WGSL Agent struct', () => {
+ const fields = getAgentStructFields();
+ const wgslFloatCount = fields.reduce((sum, field) => {
+ const count = wgslFloatCountByType[field.type];
+ if (!count) {
+ throw new Error(`Unsupported WGSL Agent field type: ${field.type}`);
+ }
+
+ return sum + count;
+ }, 0);
+
+ expect(fields.map((field) => field.name)).toEqual([
+ 'position',
+ 'angle',
+ 'colorIndex',
+ 'targetPosition',
+ 'targetAngle',
+ 'introDelay',
+ ]);
+ expect(wgslFloatCount).toBe(AGENT_FLOAT_COUNT);
+ expect(AGENT_SIZE_IN_BYTES).toBe(AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT);
+ });
+
+ it('keeps generation shader workgroup sizes aligned with agent indexing', () => {
+ [firstGenerationShader, countingShader, resizeShader, compactionShader].forEach(
+ (shader) => {
+ expect(shader).toMatch(/@workgroup_size\(64\)/);
+ }
+ );
+
+ expect(agentSchema).toContain('workgroup_count.x * 64');
+ expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64');
+ expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);');
+ expect(compactionShader).toContain('if id >= settings.agentCount');
+ });
+});
diff --git a/src/pipelines/agents/agent-generation/agent-schema.wgsl b/src/pipelines/agents/agent-generation/agent-schema.wgsl
index 3b37725..d40471e 100644
--- a/src/pipelines/agents/agent-generation/agent-schema.wgsl
+++ b/src/pipelines/agents/agent-generation/agent-schema.wgsl
@@ -1,7 +1,10 @@
struct Agent {
position: vec2,
angle: f32,
- generation: f32,
+ colorIndex: f32,
+ targetPosition: vec2,
+ targetAngle: f32,
+ introDelay: f32,
}
@group(1) @binding(1) var agents: array;
diff --git a/src/pipelines/agents/agent-generation/agent.ts b/src/pipelines/agents/agent-generation/agent.ts
index b950f32..630e017 100644
--- a/src/pipelines/agents/agent-generation/agent.ts
+++ b/src/pipelines/agents/agent-generation/agent.ts
@@ -1,9 +1,2 @@
-import { vec2 } from 'gl-matrix';
-
-export interface Agent {
- position: vec2;
- angle: number;
- generation: number;
-}
-
-export const AGENT_SIZE_IN_BYTES = 4 * Float32Array.BYTES_PER_ELEMENT;
+export const AGENT_FLOAT_COUNT = 8;
+export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
diff --git a/src/pipelines/agents/agent-pipeline.ts b/src/pipelines/agents/agent-pipeline.ts
index efcf9ed..4e1515f 100644
--- a/src/pipelines/agents/agent-pipeline.ts
+++ b/src/pipelines/agents/agent-pipeline.ts
@@ -1,5 +1,7 @@
-import { vec2 } from 'gl-matrix';
-
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
@@ -9,14 +11,19 @@ import shader from './agent.wgsl?raw';
export class AgentPipeline {
private static readonly WORKGROUP_SIZE = 64;
- private static readonly UNIFORM_COUNT = 19;
+ private static readonly UNIFORM_COUNT = 17;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
- private bindGroup?: GPUBindGroup;
- private previousTrailMapIn?: GPUTextureView;
- private previousTrailMapOut?: GPUTextureView;
+ private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ AgentPipeline.UNIFORM_COUNT
+ );
+ private readonly bindGroupsByTexture = new WeakMap<
+ GPUTextureView,
+ WeakMap>
+ >();
private agentCount = 0;
@@ -45,115 +52,126 @@ export class AgentPipeline {
public setParameters({
deltaTime,
- center,
- radius,
- brushTrailWeight,
moveSpeed,
turnSpeed,
sensorOffsetAngle,
sensorOffsetDistance,
- nextGenerationSensorOffsetDistance,
- currentGenerationAggression,
- nextGenerationAggression,
- nextGenerationSpeed,
- isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
- infectionProbability,
+ color1ToColor1,
+ color1ToColor2,
+ color1ToColor3,
+ color2ToColor1,
+ color2ToColor2,
+ color2ToColor3,
+ color3ToColor1,
+ color3ToColor2,
+ color3ToColor3,
agentCount,
+ introProgress,
}: AgentSettings & {
deltaTime: number;
- currentGenerationAggression: number;
- nextGenerationAggression: number;
- nextGenerationSensorOffsetDistance: number;
- nextGenerationSpeed: number;
- isNextGenerationOdd: number;
- center: vec2;
- radius: number;
- infectionProbability: number;
agentCount: number;
+ introProgress?: number;
}) {
this.agentCount = agentCount;
- this.device.queue.writeBuffer(
+ this.uniformValues[0] = moveSpeed * deltaTime;
+ this.uniformValues[1] = turnSpeed * deltaTime;
+ this.uniformValues[2] = (sensorOffsetAngle * Math.PI) / 180;
+ this.uniformValues[3] = sensorOffsetDistance;
+ this.uniformValues[4] = turnWhenLost;
+ this.uniformValues[5] = individualTrailWeight;
+ this.uniformValues[6] = agentCount;
+ this.uniformValues[7] = introProgress ?? 1;
+ this.uniformValues[8] = color1ToColor1;
+ this.uniformValues[9] = color1ToColor2;
+ this.uniformValues[10] = color1ToColor3;
+ this.uniformValues[11] = color2ToColor1;
+ this.uniformValues[12] = color2ToColor2;
+ this.uniformValues[13] = color2ToColor3;
+ this.uniformValues[14] = color3ToColor1;
+ this.uniformValues[15] = color3ToColor2;
+ this.uniformValues[16] = color3ToColor3;
+ writeFloat32BufferIfChanged(
+ this.device,
this.uniforms,
- 0,
- new Float32Array([
- ...center,
- radius,
-
- brushTrailWeight,
- moveSpeed * deltaTime,
- turnSpeed * deltaTime,
-
- (sensorOffsetAngle * Math.PI) / 180,
- sensorOffsetDistance,
-
- currentGenerationAggression,
- nextGenerationAggression,
- nextGenerationSensorOffsetDistance,
- nextGenerationSpeed * deltaTime,
- isNextGenerationOdd,
-
- turnWhenLost,
- individualTrailWeight,
- infectionProbability,
-
- agentCount,
- ])
+ this.uniformValues,
+ this.uniformCache
);
}
public execute(
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
- trailMapOut: GPUTextureView
+ trailMapOut: GPUTextureView,
+ sourceMap: GPUTextureView
) {
- this.ensureBindGroupExists(trailMapIn, trailMapOut);
+ const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
- passEncoder.setBindGroup(1, this.bindGroup);
+ passEncoder.setBindGroup(1, bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(this.device, this.agentCount, AgentPipeline.WORKGROUP_SIZE)
);
passEncoder.end();
}
- private ensureBindGroupExists(trailMapIn: GPUTextureView, trailMapOut: GPUTextureView) {
- if (
- this.previousTrailMapIn !== trailMapIn ||
- this.previousTrailMapOut !== trailMapOut
- ) {
- this.bindGroup = this.device.createBindGroup({
- layout: this.bindGroupLayout,
- entries: [
- {
- binding: 0,
- resource: {
- buffer: this.uniforms,
- },
- },
- {
- binding: 1,
- resource: {
- buffer: this.agentsBuffer,
- },
- },
- {
- binding: 2,
- resource: trailMapIn,
- },
- {
- binding: 3,
- resource: trailMapOut,
- },
- ],
- });
-
- this.previousTrailMapIn = trailMapIn;
- this.previousTrailMapOut = trailMapOut;
+ private getBindGroup(
+ trailMapIn: GPUTextureView,
+ trailMapOut: GPUTextureView,
+ sourceMap: GPUTextureView
+ ): GPUBindGroup {
+ let outputCache = this.bindGroupsByTexture.get(trailMapIn);
+ if (!outputCache) {
+ outputCache = new WeakMap>();
+ this.bindGroupsByTexture.set(trailMapIn, outputCache);
}
+
+ let sourceCache = outputCache.get(trailMapOut);
+ if (!sourceCache) {
+ sourceCache = new WeakMap();
+ outputCache.set(trailMapOut, sourceCache);
+ }
+
+ const cached = sourceCache.get(sourceMap);
+ if (cached) {
+ return cached;
+ }
+
+ const bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: {
+ buffer: this.uniforms,
+ },
+ },
+ {
+ binding: 1,
+ resource: {
+ buffer: this.agentsBuffer,
+ },
+ },
+ {
+ binding: 2,
+ resource: trailMapIn,
+ },
+ {
+ binding: 3,
+ resource: trailMapOut,
+ },
+ {
+ binding: 4,
+ resource: sourceMap,
+ },
+ ],
+ });
+
+ sourceCache.set(sourceMap, bindGroup);
+ return bindGroup;
}
public destroy() {
@@ -191,6 +209,13 @@ export class AgentPipeline {
format: 'rgba16float',
},
},
+ {
+ binding: 4,
+ visibility: GPUShaderStage.COMPUTE,
+ texture: {
+ sampleType: 'float',
+ },
+ },
],
};
}
diff --git a/src/pipelines/agents/agent-settings.ts b/src/pipelines/agents/agent-settings.ts
index 53b639c..cdd4601 100644
--- a/src/pipelines/agents/agent-settings.ts
+++ b/src/pipelines/agents/agent-settings.ts
@@ -1,11 +1,17 @@
export interface AgentSettings {
- brushTrailWeight: number;
+ color1ToColor1: number;
+ color1ToColor2: number;
+ color1ToColor3: number;
+ color2ToColor1: number;
+ color2ToColor2: number;
+ color2ToColor3: number;
+ color3ToColor1: number;
+ color3ToColor2: number;
+ color3ToColor3: number;
moveSpeed: number;
turnSpeed: number;
sensorOffsetAngle: number;
sensorOffsetDistance: number;
turnWhenLost: number;
individualTrailWeight: number;
- currentGenerationAggression: number;
- nextGenerationAggression: number;
}
diff --git a/src/pipelines/agents/agent.wgsl b/src/pipelines/agents/agent.wgsl
index 576b3cc..1516f7a 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -1,37 +1,27 @@
struct Settings {
- center: vec2,
- radius: f32,
-
- brushTrailWeight: f32,
- currentGenerationMoveRate: f32,
+ moveRate: f32,
turnRate: f32,
-
sensorAngle: f32,
sensorOffset: f32,
-
- currentGenerationAggression: f32,
- nextGenerationAggression: f32,
- nextGenerationSensorOffsetDistance: f32,
- nextGenerationMoveRate: f32,
- isNextGenerationOdd: f32,
-
turnWhenLost: f32,
individualTrailWeight: f32,
- infectionProbability: f32,
-
- agentCount: f32 // might be smaller than the length of the agents array
+ agentCount: f32,
+ introProgress: f32,
+ color1ToColor1: f32,
+ color1ToColor2: f32,
+ color1ToColor3: f32,
+ color2ToColor1: f32,
+ color2ToColor2: f32,
+ color2ToColor3: f32,
+ color3ToColor1: f32,
+ color3ToColor2: f32,
+ color3ToColor3: f32,
};
-
@group(1) @binding(0) var settings: Settings;
-
-// even generation's trail -> red channel
-// odd generation's trail -> green channel
-// unused -> blue channel
-// brush -> alpha channel
@group(1) @binding(2) var trailMapIn: texture_2d;
@group(1) @binding(3) var trailMapOut: texture_storage_2d;
-
+@group(1) @binding(4) var sourceMap: texture_2d;
@compute @workgroup_size(64)
fn main(
@@ -45,90 +35,150 @@ fn main(
}
var agent = agents[id];
+ if agent.colorIndex < 0.0 {
+ return;
+ }
+
+ let hasIntroTarget =
+ settings.introProgress < 0.999 &&
+ agent.targetPosition.x >= 0.0 &&
+ agent.targetPosition.y >= 0.0;
+
+ if hasIntroTarget && settings.introProgress < agent.introDelay {
+ return;
+ }
let random = textureSampleLevel(
noise,
noiseSampler,
- vec2(
- f32(id) % 23647 / 2000,
- state.time % 3243 / 2000
- ),
+ fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)),
0
);
- let isFromCurrentGeneration = abs(agent.generation - settings.isNextGenerationOdd);
- let isFromNextGeneration = 1.0 - isFromCurrentGeneration;
- let isFromOddGeneration = agent.generation % 2;
+ let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
+ let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
+ let rightSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, -settings.sensorAngle);
- let sensorOffset = mix(settings.sensorOffset, settings.nextGenerationSensorOffsetDistance, isFromNextGeneration);
- let moveRate = mix(settings.currentGenerationMoveRate, settings.nextGenerationMoveRate, isFromNextGeneration);
- let brushWeight = mix(settings.brushTrailWeight, 0, isFromNextGeneration);
-
- let trailForward = sense(agent.position, agent.angle, sensorOffset, 0);
- let trailLeft = sense(agent.position, agent.angle, sensorOffset, settings.sensorAngle);
- let trailRight = sense(agent.position, agent.angle, sensorOffset, -settings.sensorAngle);
+ let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
+ let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
+ let trailRight = textureLoad(trailMapIn, rightSensor, 0);
+ let sourceForwardSample = textureLoad(sourceMap, forwardSensor, 0);
+ let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
+ let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
- var weightForward = brushWeight * trailForward.a;
- var weightLeft = brushWeight * trailLeft.a;
- var weightRight = brushWeight * trailRight.a;
+ let channelMask = get_channel_mask(agent.colorIndex);
+ let reactionMask = get_reaction_mask(agent.colorIndex);
- let agression = mix(settings.currentGenerationAggression, settings.nextGenerationAggression, isFromNextGeneration) + weightForward;
+ let trailForwardWeight = dot(trailForward.rgb, reactionMask);
+ let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
+ let trailRightWeight = dot(trailRight.rgb, reactionMask);
- weightForward += mix(trailForward.r + agression * trailForward.g, trailForward.g + agression * trailForward.r, isFromOddGeneration);
- weightLeft += mix(trailLeft.r + agression * trailLeft.g, trailLeft.g + agression * trailLeft.r, isFromOddGeneration);
- weightRight += mix(trailRight.r + agression * trailRight.g, trailRight.g + agression * trailRight.r, isFromOddGeneration);
-
- var rotation: f32;
+ let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
+ let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
+ let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
+
+ let weightForward = trailForwardWeight + sourceForwardWeight * 24.0;
+ let weightLeft = trailLeftWeight + sourceLeftWeight * 24.0;
+ let weightRight = trailRightWeight + sourceRightWeight * 24.0;
+
+ var rotation = (random.r - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
- rotation = 0;
+ rotation = rotation * 0.25;
} else {
- rotation = sign(weightLeft - weightRight) * settings.turnRate;
+ rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
- let nextPosition = clamp(
- agent.position + vec2(cos(agent.angle), sin(agent.angle)) * moveRate,
- vec2(0, 0),
- state.size
- );
- if nextPosition.x == 0 || nextPosition.x == state.size.x || nextPosition.y == 0 || nextPosition.y == state.size.y {
- rotation = 3.14159265359 + random.a - 0.5;
+ let sourceAtAgent = textureLoad(sourceMap, vec2(agent.position), 0);
+ let positiveReactionMask = max(reactionMask, vec3(0.0));
+ let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
+ var moveRate = settings.moveRate * mix(1.0, 0.08, sourceAtAgentStrength);
+ var introTargetOffset = vec2(0.0, 0.0);
+ var introTargetDistance = 0.0;
+
+ if hasIntroTarget {
+ introTargetOffset = agent.targetPosition - agent.position;
+ introTargetDistance = length(introTargetOffset);
+ let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
+ let nearTitle = 1.0 - smoothstep(4.0, max(28.0, settings.sensorOffset * 0.75), introTargetDistance);
+ let desiredAngle = mix(targetAngle, agent.targetAngle, nearTitle * 0.2);
+ let introTurn = angle_delta(agent.angle, desiredAngle);
+
+ rotation = clamp(introTurn, -settings.turnRate * 3.4, settings.turnRate * 3.4)
+ + (random.g - 0.5) * settings.turnWhenLost * 0.18;
+ moveRate = min(settings.moveRate * mix(2.65, 0.01, nearTitle), introTargetDistance);
}
- var trail = vec4(settings.individualTrailWeight, 0, 0, 0);
- if isFromOddGeneration == 1.0 {
- trail = vec4(0, settings.individualTrailWeight, 0, 0);
- }
-
- var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0);
-
- agent.angle += rotation;
- trailBelow += trail;
-
- if settings.radius > 0 && length(settings.center - agent.position) < settings.radius {
- agent.generation = settings.isNextGenerationOdd;
-
- // clear trail map below so the agent won't die immediately
- // trailBelow.r = (1 - settings.isNextGenerationOdd) * (trailBelow.r + trailBelow.g);
- // trailBelow.g = settings.isNextGenerationOdd * (trailBelow.r + trailBelow.g);
- } else {
- let relativeWeight = mix(trailBelow.g - trailBelow.r, trailBelow.r - trailBelow.g, isFromOddGeneration);
- if (relativeWeight > 0 && (
- (isFromCurrentGeneration == 1.0 && trailBelow.a == 0 && random.b < settings.infectionProbability)
- || (isFromCurrentGeneration == 0.0 && trailBelow.a > 0)
- )) || (trailBelow.a > 0 && isFromCurrentGeneration == 0.0){
- // trailBelow.r = isFromOddGeneration * (trailBelow.r + trailBelow.g);
- // trailBelow.g = (1 - isFromOddGeneration) * (trailBelow.r + trailBelow.g);
- agent.generation = (agent.generation + 1) % 2;
+ var step = vec2(cos(agent.angle), sin(agent.angle)) * moveRate;
+ if hasIntroTarget {
+ step = vec2(0.0, 0.0);
+ if introTargetDistance > 0.5 {
+ step = introTargetOffset / introTargetDistance * moveRate;
}
}
- textureStore(trailMapOut, vec2(nextPosition), trailBelow);
+ let maxPosition = state.size - vec2(1.0, 1.0);
+ let nextPosition = clamp(agent.position + step, vec2(0, 0), maxPosition);
+ if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
+ rotation = 3.14159265359 + random.a - 0.5;
+ }
+
+ let sourceBelow = textureLoad(sourceMap, vec2(nextPosition), 0);
+ let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
+ let trailWeight = settings.individualTrailWeight * (1.0 + sourceBelowStrength * 16.0);
+ var trailBelow = textureLoad(trailMapIn, vec2(nextPosition), 0);
+ trailBelow = vec4(
+ trailBelow.rgb + channelMask * trailWeight,
+ max(trailBelow.a, 0.0)
+ );
+
+ agent.angle += rotation;
agent.position = nextPosition;
+
+ textureStore(trailMapOut, vec2(nextPosition), trailBelow);
agents[id] = agent;
}
-fn sense(agentPosition: vec2, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec4 {
+fn sensor_position(agentPosition: vec2, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec2 {
let sensorAngle = agentAngle + sensorOffsetAngle;
- let sensorPosition = vec2(agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset);
- return textureLoad(trailMapIn, sensorPosition, 0);
+ return vec2(clamp(
+ agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset,
+ vec2(0, 0),
+ state.size - vec2(1, 1)
+ ));
+}
+
+fn get_channel_mask(colorIndex: f32) -> vec3 {
+ if colorIndex < 0.5 {
+ return vec3(1, 0, 0);
+ }
+ if colorIndex < 1.5 {
+ return vec3(0, 1, 0);
+ }
+ return vec3(0, 0, 1);
+}
+
+fn get_reaction_mask(colorIndex: f32) -> vec3 {
+ if colorIndex < 0.5 {
+ return vec3(
+ settings.color1ToColor1,
+ settings.color1ToColor2,
+ settings.color1ToColor3
+ );
+ }
+ if colorIndex < 1.5 {
+ return vec3(
+ settings.color2ToColor1,
+ settings.color2ToColor2,
+ settings.color2ToColor3
+ );
+ }
+ return vec3(
+ settings.color3ToColor1,
+ settings.color3ToColor2,
+ settings.color3ToColor3
+ );
+}
+
+fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
+ return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
}
diff --git a/src/pipelines/brush/brush-pipeline.ts b/src/pipelines/brush/brush-pipeline.ts
index 93a0cd3..d228b79 100644
--- a/src/pipelines/brush/brush-pipeline.ts
+++ b/src/pipelines/brush/brush-pipeline.ts
@@ -1,14 +1,24 @@
import { vec2 } from 'gl-matrix';
+import { appConfig } from '../../config';
import { clamp } from '../../utils/clamp';
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import { BrushSettings } from './brush-settings';
import shader from './brush.wgsl?raw';
+interface LineSegment {
+ from: vec2;
+ to: vec2;
+}
+
export class BrushPipeline {
- private static readonly UNIFORM_COUNT = 2;
- private static readonly MAX_LINE_COUNT = 20;
+ private static readonly UNIFORM_COUNT = 8;
+ private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
@@ -16,10 +26,20 @@ export class BrushPipeline {
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
+ private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ BrushPipeline.UNIFORM_COUNT
+ );
private readonly vertexBuffer: GPUBuffer;
+ private readonly vertexUploadData = new Float32Array(
+ BrushPipeline.MAX_LINE_COUNT *
+ BrushPipeline.VERTICES_PER_LINE_SEGMENT *
+ BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT
+ );
private linePoints: Array = [];
- private actualPoints: Array = [];
+ private lineSegments: Array = [];
+ private actualSegments: Array = [];
public constructor(
private readonly device: GPUDevice,
@@ -72,18 +92,6 @@ export class BrushPipeline {
targets: [
{
format: 'rgba16float',
- blend: {
- color: {
- operation: 'add',
- srcFactor: 'zero',
- dstFactor: 'one',
- },
- alpha: {
- operation: 'max',
- srcFactor: 'one',
- dstFactor: 'one',
- },
- },
},
],
},
@@ -111,112 +119,188 @@ export class BrushPipeline {
}
public addSwipe(position: vec2) {
- this.linePoints.push(position);
+ const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
+ this.addSwipeSegment(previousPosition, position);
+ this.linePoints.push(vec2.clone(position));
+ }
+
+ public addSwipeSegment(from: vec2, to: vec2) {
+ this.lineSegments.push({
+ from: vec2.clone(from),
+ to: vec2.clone(to),
+ });
}
public clearSwipes() {
this.linePoints.length = 0;
+ this.lineSegments.length = 0;
+ this.actualSegments.length = 0;
}
- public setParameters({ brushSize, brushSizeVariation }: BrushSettings) {
- this.device.queue.writeBuffer(
+ public setParameters({
+ brushSize,
+ brushSizeVariation,
+ selectedColorIndex,
+ isErasing,
+ }: BrushSettings & { selectedColorIndex: number; isErasing: boolean }) {
+ this.uniformValues[0] = brushSize / 2;
+ this.uniformValues[1] = Math.floor((brushSize / 2) * brushSizeVariation);
+ this.uniformValues[2] = 0;
+ this.uniformValues[3] = 0;
+ this.uniformValues[4] = !isErasing && selectedColorIndex === 0 ? 1 : 0;
+ this.uniformValues[5] = !isErasing && selectedColorIndex === 1 ? 1 : 0;
+ this.uniformValues[6] = !isErasing && selectedColorIndex === 2 ? 1 : 0;
+ this.uniformValues[7] = isErasing ? 0 : 1;
+ writeFloat32BufferIfChanged(
+ this.device,
this.uniforms,
- 0,
- new Float32Array([brushSize / 2, Math.floor((brushSize / 2) * brushSizeVariation)])
+ this.uniformValues,
+ this.uniformCache
);
- this.actualPoints = this.linePoints.slice();
- this.linePoints.splice(0, this.linePoints.length - 1);
+ this.actualSegments = this.lineSegments.slice();
+ this.lineSegments.length = 0;
- if (this.actualPoints.length === 0) {
+ if (this.actualSegments.length === 0) {
return;
}
- if (this.actualPoints.length === 1) {
- this.actualPoints.push(this.actualPoints[0]); // allow single point swipes
+ if (this.actualSegments.length > BrushPipeline.MAX_LINE_COUNT) {
+ this.actualSegments = BrushPipeline.subsampleSegments(this.actualSegments);
}
- if (this.actualPoints.length > BrushPipeline.MAX_LINE_COUNT + 1) {
- this.actualPoints = BrushPipeline.subsampleLinePoints(this.actualPoints);
+ const lineCount = this.lineCount;
+ let floatOffset = 0;
+ for (let i = 0; i < lineCount; i++) {
+ const segment = this.actualSegments[i];
+ floatOffset = this.writeSegmentVertices(
+ this.vertexUploadData,
+ floatOffset,
+ segment.from,
+ segment.to,
+ brushSize / 2
+ );
}
this.device.queue.writeBuffer(
this.vertexBuffer,
0,
- new Float32Array(
- new Array(this.lineCount).fill(0).flatMap((_, i) => {
- const from = this.actualPoints[i];
- const to = this.actualPoints[i + 1];
- const [a, b, c, d] = this.getSegmentBoundingBox(from, to, brushSize / 2);
- return [a, b, c, b, c, d].flatMap((v) => [...v, ...from, ...to]);
- })
- )
+ this.vertexUploadData,
+ 0,
+ floatOffset
);
}
- private static subsampleLinePoints(points: Array): Array {
- const lines = [];
- for (let i = 0; i < points.length - 2; i++) {
- lines.push({
- from: points[i],
- to: points[i + 1],
- length: vec2.dist(points[i], points[i + 1]),
- });
+ private static subsampleSegments(segments: Array): Array {
+ if (segments.length <= BrushPipeline.MAX_LINE_COUNT) {
+ return segments;
}
- const sumLength = lines.reduce((sum, line) => sum + line.length, 0);
-
- let currentLineIndex = 0;
- let lineLengthSoFar = 0;
- const result: Array = [points[0]];
- for (let i = 1; i < BrushPipeline.MAX_LINE_COUNT; i++) {
- const t = (i * sumLength) / (BrushPipeline.MAX_LINE_COUNT + 1);
- while (lineLengthSoFar + lines[currentLineIndex].length < t) {
- lineLengthSoFar += lines[currentLineIndex].length;
- currentLineIndex++;
- }
-
- const line = lines[currentLineIndex];
- const position = vec2.lerp(
- vec2.create(),
- line.from,
- line.to,
- (t - lineLengthSoFar) / line.length
+ const result: Array = [];
+ for (let i = 0; i < BrushPipeline.MAX_LINE_COUNT; i++) {
+ const index = Math.round(
+ (i * (segments.length - 1)) / (BrushPipeline.MAX_LINE_COUNT - 1)
);
-
- result.push(position);
+ result.push(segments[index]);
}
- result.push(points[points.length - 1]);
-
return result;
}
- private getSegmentBoundingBox(from: vec2, to: vec2, width: number): Array {
- let dir = vec2.sub(vec2.create(), to, from);
- vec2.normalize(dir, dir);
+ private writeSegmentVertices(
+ target: Float32Array,
+ offset: number,
+ from: vec2,
+ to: vec2,
+ width: number
+ ): number {
+ const dx = to[0] - from[0];
+ const dy = to[1] - from[1];
+ const length = Math.hypot(dx, dy);
+ const directionX = length > 0 ? dx / length : 1;
+ const directionY = length > 0 ? dy / length : 0;
+ const scaledDirectionX = directionX * width;
+ const scaledDirectionY = directionY * width;
+ const perpendicularX = directionY * width;
+ const perpendicularY = -directionX * width;
- if (vec2.len(dir) === 0) {
- dir = vec2.fromValues(1, 0); // allow single point swipes
- }
+ const startX = from[0] - scaledDirectionX;
+ const startY = from[1] - scaledDirectionY;
+ const endX = to[0] + scaledDirectionX;
+ const endY = to[1] + scaledDirectionY;
- const perp = vec2.fromValues(dir[1], -dir[0]);
+ offset = this.writeVertex(
+ target,
+ offset,
+ startX + perpendicularX,
+ startY + perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ startX - perpendicularX,
+ startY - perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ endX + perpendicularX,
+ endY + perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ startX - perpendicularX,
+ startY - perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ endX + perpendicularX,
+ endY + perpendicularY,
+ from,
+ to
+ );
+ return this.writeVertex(
+ target,
+ offset,
+ endX - perpendicularX,
+ endY - perpendicularY,
+ from,
+ to
+ );
+ }
- vec2.scale(dir, dir, width);
- vec2.scale(perp, perp, width);
-
- const offsetStart = vec2.sub(vec2.create(), from, dir);
- const offsetEnd = vec2.add(vec2.create(), to, dir);
-
- return [
- vec2.add(vec2.create(), offsetStart, perp),
- vec2.sub(vec2.create(), offsetStart, perp),
- vec2.add(vec2.create(), offsetEnd, perp),
- vec2.sub(vec2.create(), offsetEnd, perp),
- ];
+ private writeVertex(
+ target: Float32Array,
+ offset: number,
+ screenX: number,
+ screenY: number,
+ from: vec2,
+ to: vec2
+ ): number {
+ target[offset++] = screenX;
+ target[offset++] = screenY;
+ target[offset++] = from[0];
+ target[offset++] = from[1];
+ target[offset++] = to[0];
+ target[offset++] = to[1];
+ return offset;
}
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
+ if (this.lineCount === 0) {
+ return;
+ }
+
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
@@ -256,6 +340,6 @@ export class BrushPipeline {
}
private get lineCount() {
- return clamp(this.actualPoints.length - 1, 0, BrushPipeline.MAX_LINE_COUNT);
+ return clamp(this.actualSegments.length, 0, BrushPipeline.MAX_LINE_COUNT);
}
}
diff --git a/src/pipelines/brush/brush-settings.ts b/src/pipelines/brush/brush-settings.ts
index cecb7a1..15ef872 100644
--- a/src/pipelines/brush/brush-settings.ts
+++ b/src/pipelines/brush/brush-settings.ts
@@ -1,4 +1,7 @@
export interface BrushSettings {
brushSize: number;
+ brushCurveResolution: number;
+ eraserSize: number;
+ mirrorSegmentCount: number;
brushSizeVariation: number;
}
diff --git a/src/pipelines/brush/brush.wgsl b/src/pipelines/brush/brush.wgsl
index f705ead..831927f 100644
--- a/src/pipelines/brush/brush.wgsl
+++ b/src/pipelines/brush/brush.wgsl
@@ -1,6 +1,9 @@
struct Settings {
brushSize: f32,
- brushSizeVariation: f32
+ brushSizeVariation: f32,
+ padding0: f32,
+ padding1: f32,
+ brushValue: vec4,
};
@group(1) @binding(0) var settings: Settings;
@@ -19,7 +22,7 @@ fn vertex(
@location(2) @interpolate(flat) end: vec2
) -> VertexOutput {
let uv = screenPosition / state.size;
- let position = uv * 2.0 - 1.0;
+ let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
}
@@ -29,20 +32,34 @@ fn fragment(
@location(1) start: vec2,
@location(2) end: vec2
) -> @location(0) vec4 {
- var distance = distanceFromLine(screenPosition, start, end);
- let noise = textureSample(noise, noiseSampler, screenPosition / state.size / 50);
- distance += noise.r * settings.brushSizeVariation;
+ let distance = distanceFromLine(screenPosition, start, end);
+ let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
+ let grainNoise = textureSample(
+ noise,
+ noiseSampler,
+ fract(screenPosition / 22.0 + vec2(0.31, 0.67))
+ ).r;
+ let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
+ let feather = max(1.0, settings.brushSize * 0.22);
+ let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
+ let strength = edge * mix(0.45, 1.0, grainNoise);
- if(distance > settings.brushSize) {
+ if(strength < 0.02) {
discard;
}
- return vec4(0, 0, 0, 1);
+ return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
fn distanceFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
let pa = position - start;
let direction = end - start;
- let q = clamp(dot(pa, direction) / dot(direction, direction), 0, 1);
+ let denominator = dot(direction, direction);
+
+ if denominator <= 0.0001 {
+ return length(pa);
+ }
+
+ let q = clamp(dot(pa, direction) / denominator, 0, 1);
return length(pa - direction * q);
}
diff --git a/src/pipelines/common-state/common-state.ts b/src/pipelines/common-state/common-state.ts
index 1dda653..9000a61 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -1,11 +1,20 @@
import { vec2 } from 'gl-matrix';
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
+ private static readonly NOISE_TEXTURE_SIZE = 2048;
private readonly uniforms: GPUBuffer;
+ private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ CommonState.UNIFORM_COUNT
+ );
private readonly noise: GPUTextureView;
private readonly bindGroup: GPUBindGroup;
@@ -31,8 +40,8 @@ export class CommonState {
this.noise = generateNoise({
device,
- width: 2048,
- height: 2048,
+ width: CommonState.NOISE_TEXTURE_SIZE,
+ height: CommonState.NOISE_TEXTURE_SIZE,
});
this.bindGroupLayout = device.createBindGroupLayout({
@@ -95,10 +104,15 @@ export class CommonState {
deltaTime: number;
time: number;
}) {
- this.device.queue.writeBuffer(
+ this.uniformValues[0] = canvasSize[0];
+ this.uniformValues[1] = canvasSize[1];
+ this.uniformValues[2] = deltaTime;
+ this.uniformValues[3] = time;
+ writeFloat32BufferIfChanged(
+ this.device,
this.uniforms,
- 0,
- new Float32Array([...canvasSize, deltaTime, time])
+ this.uniformValues,
+ this.uniformCache
);
}
diff --git a/src/pipelines/copy/copy-pipeline.ts b/src/pipelines/copy/copy-pipeline.ts
index b64cedf..248d9b2 100644
--- a/src/pipelines/copy/copy-pipeline.ts
+++ b/src/pipelines/copy/copy-pipeline.ts
@@ -1,19 +1,28 @@
import { vec2 } from 'gl-matrix';
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import shader from './copy.wgsl?raw';
export class CopyPipeline {
private static readonly UNIFORM_COUNT = 2;
+ private static readonly DEFAULT_SCALE = vec2.fromValues(1, 1);
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
+ private readonly uniformValues = new Float32Array(CopyPipeline.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ CopyPipeline.UNIFORM_COUNT
+ );
+ private readonly sampler: GPUSampler;
private readonly vertexBuffer: GPUBuffer;
- private bindGroup?: GPUBindGroup;
- private previousTrailMapIn?: GPUTextureView;
+ private readonly bindGroupsByInput = new WeakMap();
public constructor(private readonly device: GPUDevice) {
this.bindGroupLayout = device.createBindGroupLayout(CopyPipeline.bindGroupLayout);
@@ -23,6 +32,11 @@ export class CopyPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
+ this.sampler = this.device.createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+ });
+
this.vertexBuffer = device.createBuffer({
size: 2 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec2
usage: GPUBufferUsage.VERTEX,
@@ -79,9 +93,16 @@ export class CopyPipeline {
commandEncoder: GPUCommandEncoder,
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView,
- scale: vec2 = vec2.fromValues(1, 1)
+ scale: vec2 = CopyPipeline.DEFAULT_SCALE
) {
- this.device.queue.writeBuffer(this.uniforms, 0, new Float32Array(scale));
+ this.uniformValues[0] = scale[0];
+ this.uniformValues[1] = scale[1];
+ writeFloat32BufferIfChanged(
+ this.device,
+ this.uniforms,
+ this.uniformValues,
+ this.uniformCache
+ );
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
@@ -93,10 +114,10 @@ export class CopyPipeline {
],
};
- this.ensureBindGroupExists(trailMapIn);
+ const bindGroup = this.getBindGroup(trailMapIn);
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
- passEncoder.setBindGroup(0, this.bindGroup);
+ passEncoder.setBindGroup(0, bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
passEncoder.draw(4, 1);
passEncoder.end();
@@ -104,35 +125,37 @@ export class CopyPipeline {
public destroy() {
this.vertexBuffer.destroy();
+ this.uniforms.destroy();
}
- private ensureBindGroupExists(trailMapIn: GPUTextureView) {
- if (this.previousTrailMapIn !== trailMapIn) {
- this.bindGroup = this.device.createBindGroup({
- layout: this.bindGroupLayout,
- entries: [
- {
- binding: 0,
- resource: {
- buffer: this.uniforms,
- },
- },
- {
- binding: 1,
- resource: this.device.createSampler({
- magFilter: 'linear',
- minFilter: 'linear',
- }),
- },
- {
- binding: 2,
- resource: trailMapIn,
- },
- ],
- });
-
- this.previousTrailMapIn = trailMapIn;
+ private getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
+ const cached = this.bindGroupsByInput.get(trailMapIn);
+ if (cached) {
+ return cached;
}
+
+ const bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: {
+ buffer: this.uniforms,
+ },
+ },
+ {
+ binding: 1,
+ resource: this.sampler,
+ },
+ {
+ binding: 2,
+ resource: trailMapIn,
+ },
+ ],
+ });
+
+ this.bindGroupsByInput.set(trailMapIn, bindGroup);
+ return bindGroup;
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
diff --git a/src/pipelines/diffusion/diffusion-pipeline.test.ts b/src/pipelines/diffusion/diffusion-pipeline.test.ts
new file mode 100644
index 0000000..87c4e13
--- /dev/null
+++ b/src/pipelines/diffusion/diffusion-pipeline.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ getSafeInverseDiffusionRate,
+ setDiffusionUniformValues,
+} from './diffusion-pipeline';
+
+describe('diffusion pipeline parameters', () => {
+ it('keeps zero diffusion rates finite before writing shader uniforms', () => {
+ const uniformValues = new Float32Array(4);
+
+ setDiffusionUniformValues(uniformValues, {
+ decayRateBrush: 900,
+ decayRateTrails: 970,
+ diffusionRateBrush: 0,
+ diffusionRateTrails: 0,
+ });
+
+ expect(Number.isFinite(uniformValues[0])).toBe(true);
+ expect(Number.isFinite(uniformValues[2])).toBe(true);
+ expect(uniformValues[0]).toBeGreaterThan(0);
+ expect(uniformValues[2]).toBeGreaterThan(0);
+ });
+
+ it('passes valid diffusion rates through as inverse values', () => {
+ expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
+ expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
+ });
+});
diff --git a/src/pipelines/diffusion/diffusion-pipeline.ts b/src/pipelines/diffusion/diffusion-pipeline.ts
index 3bb3422..69875c4 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -1,19 +1,56 @@
+import { appConfig } from '../../config';
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
import shader from './diffuse.wgsl?raw';
import { DiffusionSettings } from './diffusion-settings';
+const MIN_DIFFUSION_RATE = appConfig.pipelines.diffusion.minDiffusionRate;
+
+type DiffusionUniformSettings = Pick<
+ DiffusionSettings,
+ 'diffusionRateTrails' | 'decayRateTrails' | 'diffusionRateBrush' | 'decayRateBrush'
+>;
+
+export const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
+ 1 /
+ (Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
+ ? diffusionRate
+ : MIN_DIFFUSION_RATE);
+
+export const setDiffusionUniformValues = (
+ target: Float32Array,
+ {
+ diffusionRateTrails,
+ decayRateTrails,
+ diffusionRateBrush,
+ decayRateBrush,
+ }: DiffusionUniformSettings
+): void => {
+ target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
+ target[1] = decayRateTrails / 1000;
+ target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
+ target[3] = decayRateBrush / 1000;
+};
+
export class DiffusionPipeline {
private static readonly UNIFORM_COUNT = 4;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
+ private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ DiffusionPipeline.UNIFORM_COUNT
+ );
+ private readonly sampler: GPUSampler;
private readonly vertexBuffer: GPUBuffer;
- private bindGroup?: GPUBindGroup;
- private previousTrailMapIn?: GPUTextureView;
+ private readonly bindGroupsByInput = new WeakMap();
public constructor(
private readonly device: GPUDevice,
@@ -49,6 +86,11 @@ export class DiffusionPipeline {
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
+
+ this.sampler = this.device.createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
+ });
}
public setParameters({
@@ -57,15 +99,17 @@ export class DiffusionPipeline {
diffusionRateBrush,
decayRateBrush,
}: DiffusionSettings) {
- this.device.queue.writeBuffer(
+ setDiffusionUniformValues(this.uniformValues, {
+ diffusionRateTrails,
+ decayRateTrails,
+ diffusionRateBrush,
+ decayRateBrush,
+ });
+ writeFloat32BufferIfChanged(
+ this.device,
this.uniforms,
- 0,
- new Float32Array([
- 1 / diffusionRateTrails,
- decayRateTrails / 1000,
- 1 / diffusionRateBrush,
- decayRateBrush / 1000,
- ])
+ this.uniformValues,
+ this.uniformCache
);
}
@@ -74,7 +118,7 @@ export class DiffusionPipeline {
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
) {
- this.ensureBindGroupExists(trailMapIn);
+ const bindGroup = this.getBindGroup(trailMapIn);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
@@ -91,38 +135,39 @@ export class DiffusionPipeline {
passEncoder.setPipeline(this.pipeline);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
this.commonState.execute(passEncoder);
- passEncoder.setBindGroup(1, this.bindGroup);
+ passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
}
- private ensureBindGroupExists(trailMapIn: GPUTextureView) {
- if (this.previousTrailMapIn !== trailMapIn) {
- this.bindGroup = this.device.createBindGroup({
- layout: this.bindGroupLayout,
- entries: [
- {
- binding: 0,
- resource: {
- buffer: this.uniforms,
- },
- },
- {
- binding: 1,
- resource: this.device.createSampler({
- magFilter: 'linear',
- minFilter: 'linear',
- }),
- },
- {
- binding: 2,
- resource: trailMapIn,
- },
- ],
- });
-
- this.previousTrailMapIn = trailMapIn;
+ private getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
+ const cached = this.bindGroupsByInput.get(trailMapIn);
+ if (cached) {
+ return cached;
}
+
+ const bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: {
+ buffer: this.uniforms,
+ },
+ },
+ {
+ binding: 1,
+ resource: this.sampler,
+ },
+ {
+ binding: 2,
+ resource: trailMapIn,
+ },
+ ],
+ });
+
+ this.bindGroupsByInput.set(trailMapIn, bindGroup);
+ return bindGroup;
}
public destroy() {
diff --git a/src/pipelines/diffusion/diffusion-settings.ts b/src/pipelines/diffusion/diffusion-settings.ts
index 909101b..3221bab 100644
--- a/src/pipelines/diffusion/diffusion-settings.ts
+++ b/src/pipelines/diffusion/diffusion-settings.ts
@@ -3,4 +3,5 @@ export interface DiffusionSettings {
decayRateTrails: number;
diffusionRateBrush: number;
decayRateBrush: number;
+ brushEffectDuration: number;
}
diff --git a/src/pipelines/eraser/eraser-agent-pipeline.ts b/src/pipelines/eraser/eraser-agent-pipeline.ts
new file mode 100644
index 0000000..9e2493f
--- /dev/null
+++ b/src/pipelines/eraser/eraser-agent-pipeline.ts
@@ -0,0 +1,244 @@
+import { vec2 } from 'gl-matrix';
+
+import { appConfig } from '../../config';
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
+import { getWorkgroupCounts } from '../../utils/graphics/get-workgroup-counts';
+import { smartCompile } from '../../utils/graphics/smart-compile';
+import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
+import { CommonState } from '../common-state/common-state';
+import shader from './eraser-agent.wgsl?raw';
+
+interface LineSegment {
+ from: vec2;
+ to: vec2;
+}
+
+const shaderWithConfig = shader.replace(
+ 'const MAX_SEGMENT_COUNT = 384u;',
+ `const MAX_SEGMENT_COUNT = ${Math.round(appConfig.pipelines.eraser.maxSegmentCount)}u;`
+);
+
+export class EraserAgentPipeline {
+ private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize;
+ private static readonly UNIFORM_COUNT = 4;
+ private static readonly MAX_SEGMENT_COUNT = appConfig.pipelines.eraser.maxSegmentCount;
+ private static readonly SEGMENT_FLOAT_COUNT =
+ appConfig.pipelines.eraser.segmentFloatCount;
+
+ private readonly bindGroupLayout: GPUBindGroupLayout;
+ private readonly bindGroup: GPUBindGroup;
+ private readonly pipeline: GPUComputePipeline;
+ private readonly uniforms: GPUBuffer;
+ private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ EraserAgentPipeline.UNIFORM_COUNT
+ );
+ private readonly segmentsBuffer: GPUBuffer;
+ private readonly segmentUploadData = new Float32Array(
+ EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
+ );
+
+ private linePoints: Array = [];
+ private lineSegments: Array = [];
+ private actualSegments: Array = [];
+ private segmentCount = 0;
+ private agentCount = 0;
+
+ public constructor(
+ private readonly device: GPUDevice,
+ private readonly commonState: CommonState,
+ private readonly agentsBuffer: GPUBuffer
+ ) {
+ this.bindGroupLayout = device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: {
+ type: 'uniform',
+ },
+ },
+ {
+ binding: 1,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: {
+ type: 'storage',
+ },
+ },
+ {
+ binding: 2,
+ visibility: GPUShaderStage.COMPUTE,
+ buffer: {
+ type: 'read-only-storage',
+ },
+ },
+ ],
+ });
+
+ this.uniforms = this.device.createBuffer({
+ size: EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+
+ this.segmentsBuffer = this.device.createBuffer({
+ size:
+ EraserAgentPipeline.MAX_SEGMENT_COUNT *
+ EraserAgentPipeline.SEGMENT_FLOAT_COUNT *
+ Float32Array.BYTES_PER_ELEMENT,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
+ });
+
+ this.bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: {
+ buffer: this.uniforms,
+ },
+ },
+ {
+ binding: 1,
+ resource: {
+ buffer: this.agentsBuffer,
+ },
+ },
+ {
+ binding: 2,
+ resource: {
+ buffer: this.segmentsBuffer,
+ },
+ },
+ ],
+ });
+
+ this.pipeline = device.createComputePipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+ }),
+ compute: {
+ module: smartCompile(
+ device,
+ CommonState.shaderCode,
+ agentSchema,
+ shaderWithConfig
+ ),
+ entryPoint: 'main',
+ },
+ });
+ }
+
+ public addSwipe(position: vec2): void {
+ const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
+ this.addSwipeSegment(previousPosition, position);
+ this.linePoints.push(vec2.clone(position));
+ }
+
+ public addSwipeSegment(from: vec2, to: vec2): void {
+ this.lineSegments.push({
+ from: vec2.clone(from),
+ to: vec2.clone(to),
+ });
+ }
+
+ public clearSwipes(): void {
+ this.linePoints.length = 0;
+ this.lineSegments.length = 0;
+ this.actualSegments.length = 0;
+ this.segmentCount = 0;
+ }
+
+ public setParameters({
+ agentCount,
+ eraserSize,
+ }: {
+ agentCount: number;
+ eraserSize: number;
+ }): void {
+ this.agentCount = agentCount;
+ this.actualSegments = this.lineSegments.slice();
+ this.lineSegments.length = 0;
+
+ if (this.actualSegments.length > EraserAgentPipeline.MAX_SEGMENT_COUNT) {
+ this.actualSegments = EraserAgentPipeline.subsampleSegments(this.actualSegments);
+ }
+
+ this.segmentCount = Math.max(0, this.actualSegments.length);
+
+ const eraserRadius = eraserSize / 2;
+ this.uniformValues[0] = eraserRadius;
+ this.uniformValues[1] = this.segmentCount;
+ this.uniformValues[2] = agentCount;
+ this.uniformValues[3] = eraserRadius * eraserRadius;
+ writeFloat32BufferIfChanged(
+ this.device,
+ this.uniforms,
+ this.uniformValues,
+ this.uniformCache
+ );
+
+ if (this.segmentCount === 0) {
+ return;
+ }
+
+ for (let i = 0; i < this.segmentCount; i++) {
+ const { from, to } = this.actualSegments[i];
+ const offset = i * EraserAgentPipeline.SEGMENT_FLOAT_COUNT;
+ this.segmentUploadData[offset] = from[0];
+ this.segmentUploadData[offset + 1] = from[1];
+ this.segmentUploadData[offset + 2] = to[0];
+ this.segmentUploadData[offset + 3] = to[1];
+ }
+
+ this.device.queue.writeBuffer(
+ this.segmentsBuffer,
+ 0,
+ this.segmentUploadData,
+ 0,
+ this.segmentCount * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
+ );
+ }
+
+ public execute(commandEncoder: GPUCommandEncoder): void {
+ if (this.segmentCount === 0 || this.agentCount === 0) {
+ return;
+ }
+
+ const passEncoder = commandEncoder.beginComputePass();
+ passEncoder.setPipeline(this.pipeline);
+ this.commonState.execute(passEncoder);
+ passEncoder.setBindGroup(1, this.bindGroup);
+ passEncoder.dispatchWorkgroups(
+ ...getWorkgroupCounts(
+ this.device,
+ this.agentCount,
+ EraserAgentPipeline.WORKGROUP_SIZE
+ )
+ );
+ passEncoder.end();
+ }
+
+ public destroy(): void {
+ this.uniforms.destroy();
+ this.segmentsBuffer.destroy();
+ }
+
+ private static subsampleSegments(segments: Array): Array {
+ if (segments.length <= EraserAgentPipeline.MAX_SEGMENT_COUNT) {
+ return segments;
+ }
+
+ const result: Array = [];
+ for (let i = 0; i < EraserAgentPipeline.MAX_SEGMENT_COUNT; i++) {
+ const index = Math.round(
+ (i * (segments.length - 1)) / (EraserAgentPipeline.MAX_SEGMENT_COUNT - 1)
+ );
+ result.push(segments[index]);
+ }
+
+ return result;
+ }
+}
diff --git a/src/pipelines/eraser/eraser-agent.wgsl b/src/pipelines/eraser/eraser-agent.wgsl
new file mode 100644
index 0000000..12048be
--- /dev/null
+++ b/src/pipelines/eraser/eraser-agent.wgsl
@@ -0,0 +1,63 @@
+struct Settings {
+ eraserRadius: f32,
+ segmentCount: f32,
+ agentCount: f32,
+ eraserRadiusSquared: f32,
+};
+
+const MAX_SEGMENT_COUNT = 384u;
+
+@group(1) @binding(0) var settings: Settings;
+@group(1) @binding(2) var segments: array>;
+
+@compute @workgroup_size(64)
+fn main(
+ @builtin(global_invocation_id) global_id: vec3,
+ @builtin(num_workgroups) workgroup_count: vec3
+) {
+ let id = get_id(global_id, workgroup_count);
+
+ if id >= u32(settings.agentCount) {
+ return;
+ }
+
+ var agent = agents[id];
+ if agent.colorIndex < 0.0 {
+ return;
+ }
+
+ for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) {
+ if i >= u32(settings.segmentCount) {
+ break;
+ }
+
+ let segment = segments[i];
+ let distanceSquared = distanceSquaredFromLine(
+ agent.position,
+ segment.xy,
+ segment.zw
+ );
+
+ if distanceSquared <= settings.eraserRadiusSquared {
+ agent.position = vec2(-1.0, -1.0);
+ agent.targetPosition = vec2(-1.0, -1.0);
+ agent.colorIndex = -1.0;
+ agents[id] = agent;
+ return;
+ }
+ }
+}
+
+fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
+ let pa = position - start;
+ let direction = end - start;
+ let denominator = dot(direction, direction);
+
+ if denominator <= 0.0001 {
+ return dot(pa, pa);
+ }
+
+ let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
+ let nearestOffset = pa - direction * q;
+ return dot(nearestOffset, nearestOffset);
+}
diff --git a/src/pipelines/eraser/eraser-texture-pipeline.ts b/src/pipelines/eraser/eraser-texture-pipeline.ts
new file mode 100644
index 0000000..c2db414
--- /dev/null
+++ b/src/pipelines/eraser/eraser-texture-pipeline.ts
@@ -0,0 +1,333 @@
+import { vec2 } from 'gl-matrix';
+
+import { appConfig } from '../../config';
+import { clamp } from '../../utils/clamp';
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
+import { smartCompile } from '../../utils/graphics/smart-compile';
+import { CommonState } from '../common-state/common-state';
+import shader from './eraser-texture.wgsl?raw';
+
+interface LineSegment {
+ from: vec2;
+ to: vec2;
+}
+
+export class EraserTexturePipeline {
+ private static readonly UNIFORM_COUNT = 4;
+ private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount;
+ private static readonly VERTICES_PER_LINE_SEGMENT = 6;
+ private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
+
+ private readonly bindGroupLayout: GPUBindGroupLayout;
+ private readonly bindGroup: GPUBindGroup;
+ private readonly pipeline: GPURenderPipeline;
+ private readonly uniforms: GPUBuffer;
+ private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ EraserTexturePipeline.UNIFORM_COUNT
+ );
+ private readonly vertexBuffer: GPUBuffer;
+ private readonly vertexUploadData = new Float32Array(
+ EraserTexturePipeline.MAX_LINE_COUNT *
+ EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
+ EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT
+ );
+
+ private linePoints: Array = [];
+ private lineSegments: Array = [];
+ private actualSegments: Array = [];
+
+ public constructor(
+ private readonly device: GPUDevice,
+ private readonly commonState: CommonState
+ ) {
+ this.bindGroupLayout = device.createBindGroupLayout({
+ entries: [
+ {
+ binding: 0,
+ visibility: GPUShaderStage.FRAGMENT,
+ buffer: {
+ type: 'uniform',
+ },
+ },
+ ],
+ });
+
+ this.vertexBuffer = device.createBuffer({
+ size:
+ EraserTexturePipeline.MAX_LINE_COUNT *
+ EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT *
+ EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT *
+ Float32Array.BYTES_PER_ELEMENT,
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
+ });
+
+ this.pipeline = device.createRenderPipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
+ }),
+ vertex: {
+ module: smartCompile(device, CommonState.shaderCode, shader),
+ entryPoint: 'vertex',
+ buffers: [
+ {
+ arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
+ attributes: [
+ {
+ shaderLocation: 0,
+ format: 'float32x2',
+ offset: 0,
+ },
+ {
+ shaderLocation: 1,
+ format: 'float32x2',
+ offset: Float32Array.BYTES_PER_ELEMENT * 2,
+ },
+ {
+ shaderLocation: 2,
+ format: 'float32x2',
+ offset: Float32Array.BYTES_PER_ELEMENT * 4,
+ },
+ ],
+ },
+ ],
+ },
+ fragment: {
+ module: smartCompile(device, CommonState.shaderCode, shader),
+ entryPoint: 'fragment',
+ targets: [
+ {
+ format: 'rgba16float',
+ },
+ ],
+ },
+ primitive: {
+ topology: 'triangle-list',
+ },
+ });
+
+ this.uniforms = this.device.createBuffer({
+ size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+
+ this.bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: {
+ buffer: this.uniforms,
+ },
+ },
+ ],
+ });
+ }
+
+ public addSwipe(position: vec2): void {
+ const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
+ this.addSwipeSegment(previousPosition, position);
+ this.linePoints.push(vec2.clone(position));
+ }
+
+ public addSwipeSegment(from: vec2, to: vec2): void {
+ this.lineSegments.push({
+ from: vec2.clone(from),
+ to: vec2.clone(to),
+ });
+ }
+
+ public clearSwipes(): void {
+ this.linePoints.length = 0;
+ this.lineSegments.length = 0;
+ this.actualSegments.length = 0;
+ }
+
+ public setParameters({ eraserSize }: { eraserSize: number }): void {
+ const eraserRadius = eraserSize / 2;
+
+ this.uniformValues[0] = eraserRadius;
+ this.uniformValues[1] = eraserRadius * eraserRadius;
+ this.uniformValues[2] = 0;
+ this.uniformValues[3] = 0;
+ writeFloat32BufferIfChanged(
+ this.device,
+ this.uniforms,
+ this.uniformValues,
+ this.uniformCache
+ );
+
+ this.actualSegments = this.lineSegments.slice();
+ this.lineSegments.length = 0;
+
+ if (this.actualSegments.length === 0) {
+ return;
+ }
+
+ if (this.actualSegments.length > EraserTexturePipeline.MAX_LINE_COUNT) {
+ this.actualSegments = EraserTexturePipeline.subsampleSegments(this.actualSegments);
+ }
+
+ const lineCount = this.lineCount;
+ let floatOffset = 0;
+ for (let i = 0; i < lineCount; i++) {
+ const segment = this.actualSegments[i];
+ floatOffset = this.writeSegmentVertices(
+ this.vertexUploadData,
+ floatOffset,
+ segment.from,
+ segment.to,
+ eraserRadius
+ );
+ }
+
+ this.device.queue.writeBuffer(
+ this.vertexBuffer,
+ 0,
+ this.vertexUploadData,
+ 0,
+ floatOffset
+ );
+ }
+
+ public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
+ if (this.lineCount === 0) {
+ return;
+ }
+
+ const renderPassDescriptor: GPURenderPassDescriptor = {
+ colorAttachments: [
+ {
+ view: textureOut,
+ loadOp: 'load',
+ storeOp: 'store',
+ },
+ ],
+ };
+
+ const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
+ passEncoder.setPipeline(this.pipeline);
+ this.commonState.execute(passEncoder);
+ passEncoder.setBindGroup(1, this.bindGroup);
+ passEncoder.setVertexBuffer(0, this.vertexBuffer);
+ passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
+ passEncoder.end();
+ }
+
+ public destroy(): void {
+ this.vertexBuffer.destroy();
+ this.uniforms.destroy();
+ }
+
+ private static subsampleSegments(segments: Array): Array {
+ if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
+ return segments;
+ }
+
+ const result: Array = [];
+ for (let i = 0; i < EraserTexturePipeline.MAX_LINE_COUNT; i++) {
+ const index = Math.round(
+ (i * (segments.length - 1)) / (EraserTexturePipeline.MAX_LINE_COUNT - 1)
+ );
+ result.push(segments[index]);
+ }
+
+ return result;
+ }
+
+ private writeSegmentVertices(
+ target: Float32Array,
+ offset: number,
+ from: vec2,
+ to: vec2,
+ width: number
+ ): number {
+ const dx = to[0] - from[0];
+ const dy = to[1] - from[1];
+ const length = Math.hypot(dx, dy);
+ const directionX = length > 0 ? dx / length : 1;
+ const directionY = length > 0 ? dy / length : 0;
+ const scaledDirectionX = directionX * width;
+ const scaledDirectionY = directionY * width;
+ const perpendicularX = directionY * width;
+ const perpendicularY = -directionX * width;
+
+ const startX = from[0] - scaledDirectionX;
+ const startY = from[1] - scaledDirectionY;
+ const endX = to[0] + scaledDirectionX;
+ const endY = to[1] + scaledDirectionY;
+
+ offset = this.writeVertex(
+ target,
+ offset,
+ startX + perpendicularX,
+ startY + perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ startX - perpendicularX,
+ startY - perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ endX + perpendicularX,
+ endY + perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ startX - perpendicularX,
+ startY - perpendicularY,
+ from,
+ to
+ );
+ offset = this.writeVertex(
+ target,
+ offset,
+ endX + perpendicularX,
+ endY + perpendicularY,
+ from,
+ to
+ );
+ return this.writeVertex(
+ target,
+ offset,
+ endX - perpendicularX,
+ endY - perpendicularY,
+ from,
+ to
+ );
+ }
+
+ private writeVertex(
+ target: Float32Array,
+ offset: number,
+ screenX: number,
+ screenY: number,
+ from: vec2,
+ to: vec2
+ ): number {
+ target[offset++] = screenX;
+ target[offset++] = screenY;
+ target[offset++] = from[0];
+ target[offset++] = from[1];
+ target[offset++] = to[0];
+ target[offset++] = to[1];
+ return offset;
+ }
+
+ private get lineCount(): number {
+ return clamp(this.actualSegments.length, 0, EraserTexturePipeline.MAX_LINE_COUNT);
+ }
+}
diff --git a/src/pipelines/eraser/eraser-texture.wgsl b/src/pipelines/eraser/eraser-texture.wgsl
new file mode 100644
index 0000000..c1bfe28
--- /dev/null
+++ b/src/pipelines/eraser/eraser-texture.wgsl
@@ -0,0 +1,53 @@
+struct Settings {
+ eraserRadius: f32,
+ eraserRadiusSquared: f32,
+ padding1: f32,
+ padding2: f32,
+};
+
+@group(1) @binding(0) var settings: Settings;
+
+struct VertexOutput {
+ @builtin(position) position: vec4,
+ @location(0) screenPosition: vec2,
+ @location(1) start: vec2,
+ @location(2) end: vec2
+}
+
+@vertex
+fn vertex(
+ @location(0) screenPosition: vec2,
+ @location(1) @interpolate(flat) start: vec2,
+ @location(2) @interpolate(flat) end: vec2
+) -> VertexOutput {
+ let uv = screenPosition / state.size;
+ let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
+ return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
+}
+
+@fragment
+fn fragment(
+ @location(0) screenPosition: vec2,
+ @location(1) start: vec2,
+ @location(2) end: vec2
+) -> @location(0) vec4 {
+ if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared {
+ discard;
+ }
+
+ return vec4(0.0, 0.0, 0.0, 0.0);
+}
+
+fn distanceSquaredFromLine(position: vec2, start: vec2, end: vec2) -> f32 {
+ let pa = position - start;
+ let direction = end - start;
+ let denominator = dot(direction, direction);
+
+ if denominator <= 0.0001 {
+ return dot(pa, pa);
+ }
+
+ let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
+ let nearestOffset = pa - direction * q;
+ return dot(nearestOffset, nearestOffset);
+}
diff --git a/src/pipelines/render/render-pipeline.ts b/src/pipelines/render/render-pipeline.ts
index 73ac288..21a4de4 100644
--- a/src/pipelines/render/render-pipeline.ts
+++ b/src/pipelines/render/render-pipeline.ts
@@ -1,5 +1,7 @@
-import { vec3 } from 'gl-matrix';
-
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
@@ -7,15 +9,23 @@ import { RenderSettings } from './render-settings';
import shader from './render.wgsl?raw';
export class RenderPipeline {
- private static readonly UNIFORM_COUNT = 13;
+ private static readonly UNIFORM_COUNT = 20;
private readonly bindGroupLayout: GPUBindGroupLayout;
- private readonly pipeline: GPURenderPipeline;
+ private readonly canvasPipeline: GPURenderPipeline;
+ private readonly exportPipeline: GPURenderPipeline;
+ private readonly sampler: GPUSampler;
private readonly uniforms: GPUBuffer;
+ private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
+ private readonly uniformCache = createCachedFloat32BufferWrite(
+ RenderPipeline.UNIFORM_COUNT
+ );
private readonly vertexBuffer: GPUBuffer;
- private bindGroup?: GPUBindGroup;
- private previousColorTexture?: GPUTextureView;
+ private readonly bindGroupsByTexture = new WeakMap<
+ GPUTextureView,
+ WeakMap
+ >();
public constructor(
private readonly context: GPUCanvasContext,
@@ -27,104 +37,179 @@ export class RenderPipeline {
const { buffer, vertex } = setUpFullScreenQuad(device);
this.vertexBuffer = buffer;
- this.pipeline = device.createRenderPipeline({
- layout: device.createPipelineLayout({
- bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
- }),
- vertex,
- fragment: {
- module: smartCompile(device, CommonState.shaderCode, shader),
- entryPoint: 'fragment',
- targets: [
- {
- format: navigator.gpu.getPreferredCanvasFormat(),
- },
- ],
- },
- primitive: {
- topology: 'triangle-strip',
- },
+ this.sampler = device.createSampler({
+ magFilter: 'linear',
+ minFilter: 'linear',
});
+ const format = navigator.gpu.getPreferredCanvasFormat();
+ this.canvasPipeline = this.createPipeline(format, vertex);
+ this.exportPipeline = this.createPipeline(format, vertex);
+
this.uniforms = this.device.createBuffer({
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
}
+ private createPipeline(
+ format: GPUTextureFormat,
+ vertex: GPUVertexState
+ ): GPURenderPipeline {
+ return this.device.createRenderPipeline({
+ layout: this.device.createPipelineLayout({
+ bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
+ }),
+ vertex,
+ fragment: {
+ module: smartCompile(this.device, CommonState.shaderCode, shader),
+ entryPoint: 'fragment',
+ targets: [
+ {
+ format,
+ },
+ ],
+ },
+ primitive: {
+ topology: 'triangle-strip',
+ },
+ });
+ }
+
public setParameters({
- brushColor,
- evenGenerationColor,
- oddGenerationColor,
+ channelColors,
+ backgroundColor,
+ cameraCenter,
+ cameraZoom,
clarity,
}: RenderSettings & {
- brushColor: vec3;
- evenGenerationColor: vec3;
- oddGenerationColor: vec3;
+ channelColors: Array<[number, number, number]>;
+ backgroundColor: [number, number, number];
+ cameraCenter: [number, number];
+ cameraZoom: number;
}) {
- this.device.queue.writeBuffer(
+ const [a, b, c] = channelColors;
+ this.uniformValues[0] = a[0];
+ this.uniformValues[1] = a[1];
+ this.uniformValues[2] = a[2];
+ this.uniformValues[3] = 0;
+ this.uniformValues[4] = b[0];
+ this.uniformValues[5] = b[1];
+ this.uniformValues[6] = b[2];
+ this.uniformValues[7] = 0;
+ this.uniformValues[8] = c[0];
+ this.uniformValues[9] = c[1];
+ this.uniformValues[10] = c[2];
+ this.uniformValues[11] = 0;
+ this.uniformValues[12] = backgroundColor[0];
+ this.uniformValues[13] = backgroundColor[1];
+ this.uniformValues[14] = backgroundColor[2];
+ this.uniformValues[15] = clarity;
+ this.uniformValues[16] = cameraCenter[0];
+ this.uniformValues[17] = cameraCenter[1];
+ this.uniformValues[18] = cameraZoom;
+ this.uniformValues[19] = 0;
+ writeFloat32BufferIfChanged(
+ this.device,
this.uniforms,
- 0,
- new Float32Array([
- ...brushColor,
- 0, //padding
- ...evenGenerationColor,
- 0, //padding
- ...oddGenerationColor,
- clarity,
- ])
+ this.uniformValues,
+ this.uniformCache
);
}
- public execute(commandEncoder: GPUCommandEncoder, colorTexture: GPUTextureView) {
- this.ensureBindGroupExists(colorTexture);
+ public execute(
+ commandEncoder: GPUCommandEncoder,
+ colorTexture: GPUTextureView,
+ sourceTexture: GPUTextureView
+ ) {
+ const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: this.context.getCurrentTexture().createView(),
- clearValue: { r: 0, g: 1, b: 1, a: 1 },
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
- passEncoder.setPipeline(this.pipeline);
+ passEncoder.setPipeline(this.canvasPipeline);
this.commonState.execute(passEncoder);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
- passEncoder.setBindGroup(1, this.bindGroup);
+ passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
}
- private ensureBindGroupExists(colorTexture: GPUTextureView) {
- if (this.previousColorTexture !== colorTexture) {
- this.bindGroup = this.device.createBindGroup({
- layout: this.bindGroupLayout,
- entries: [
- {
- binding: 0,
- resource: {
- buffer: this.uniforms,
- },
- },
- {
- binding: 1,
- resource: this.device.createSampler({
- magFilter: 'linear',
- minFilter: 'linear',
- }),
- },
- {
- binding: 2,
- resource: colorTexture,
- },
- ],
- });
+ public executeToView(
+ commandEncoder: GPUCommandEncoder,
+ colorTexture: GPUTextureView,
+ sourceTexture: GPUTextureView,
+ outputTexture: GPUTextureView
+ ) {
+ const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
- this.previousColorTexture = colorTexture;
+ const passEncoder = commandEncoder.beginRenderPass({
+ colorAttachments: [
+ {
+ view: outputTexture,
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
+ loadOp: 'clear',
+ storeOp: 'store',
+ },
+ ],
+ });
+ passEncoder.setPipeline(this.exportPipeline);
+ this.commonState.execute(passEncoder);
+ passEncoder.setVertexBuffer(0, this.vertexBuffer);
+ passEncoder.setBindGroup(1, bindGroup);
+ passEncoder.draw(4, 1);
+ passEncoder.end();
+ }
+
+ private getBindGroup(
+ colorTexture: GPUTextureView,
+ sourceTexture: GPUTextureView
+ ): GPUBindGroup {
+ let sourceTextureCache = this.bindGroupsByTexture.get(colorTexture);
+ if (!sourceTextureCache) {
+ sourceTextureCache = new WeakMap();
+ this.bindGroupsByTexture.set(colorTexture, sourceTextureCache);
}
+
+ const cached = sourceTextureCache.get(sourceTexture);
+ if (cached) {
+ return cached;
+ }
+
+ const bindGroup = this.device.createBindGroup({
+ layout: this.bindGroupLayout,
+ entries: [
+ {
+ binding: 0,
+ resource: {
+ buffer: this.uniforms,
+ },
+ },
+ {
+ binding: 1,
+ resource: this.sampler,
+ },
+ {
+ binding: 2,
+ resource: colorTexture,
+ },
+ {
+ binding: 3,
+ resource: sourceTexture,
+ },
+ ],
+ });
+
+ sourceTextureCache.set(sourceTexture, bindGroup);
+ return bindGroup;
}
public destroy() {
@@ -156,6 +241,13 @@ export class RenderPipeline {
sampleType: 'float',
},
},
+ {
+ binding: 3,
+ visibility: GPUShaderStage.FRAGMENT,
+ texture: {
+ sampleType: 'float',
+ },
+ },
],
};
}
diff --git a/src/pipelines/render/render.wgsl b/src/pipelines/render/render.wgsl
index 8607d7c..5864693 100644
--- a/src/pipelines/render/render.wgsl
+++ b/src/pipelines/render/render.wgsl
@@ -1,39 +1,56 @@
struct Settings {
- brushColor: vec3,
- evenGenerationColor: vec3,
- oddGenerationColor: vec3,
+ colorA: vec3,
+ backgroundColorPadding0: f32,
+ colorB: vec3,
+ backgroundColorPadding1: f32,
+ colorC: vec3,
+ backgroundColorPadding2: f32,
+ backgroundColor: vec3,
clarity: f32,
+ cameraCenter: vec2,
+ cameraZoom: f32,
+ padding0: f32,
};
@group(1) @binding(0) var settings: Settings;
@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d;
+@group(1) @binding(3) var sourceMap: texture_2d;
@fragment
fn fragment(@location(0) uv: vec2) -> @location(0) vec4 {
- let traces = textureSample(trailMap, Sampler, uv);
- let random = textureSample(noise, noiseSampler, uv);
+ let cameraUv = settings.cameraCenter / state.size;
+ let viewUv = (uv - vec2(0.5)) / settings.cameraZoom + cameraUv;
+ let traces = textureSample(trailMap, Sampler, viewUv);
+ let sources = textureSample(sourceMap, Sampler, viewUv);
- let backgroundColor = vec3(0.9) + 0.075 * random.r;
-
- let evenGenerationStrength = clarity(traces.r);
- let oddGenerationStrength = clarity(traces.g);
- let brushStrength = traces.a;
-
- let color = max(
- mix(
- evenGenerationStrength * settings.evenGenerationColor,
- oddGenerationStrength * settings.oddGenerationColor,
- oddGenerationStrength / (evenGenerationStrength + oddGenerationStrength + 0.000001)
- ),
- brushStrength * settings.brushColor
+ let traceStrengths = vec3(
+ clarity(traces.r),
+ clarity(traces.g),
+ clarity(traces.b)
);
+ let sourceStrengths = vec3(
+ clarity(sources.r),
+ clarity(sources.g),
+ clarity(sources.b)
+ );
+ let strengths = max(traceStrengths, sourceStrengths);
+ let traceColor =
+ strengths.r * settings.colorA
+ + strengths.g * settings.colorB
+ + strengths.b * settings.colorC;
+ let brushColor =
+ sourceStrengths.r * settings.colorA
+ + sourceStrengths.g * settings.colorB
+ + sourceStrengths.b * settings.colorC;
+ let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1);
+ let color = max(traceColor, brushColor * (1.2 + brushStrength * 1.6));
- let strength = max(evenGenerationStrength, max(oddGenerationStrength, brushStrength));
+ let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
- return vec4(mix(backgroundColor, color, strength), 1);
+ return vec4(mix(settings.backgroundColor, clamp(color, vec3(0), vec3(1)), strength), 1);
}
fn clarity(strength: f32) -> f32 {
- return pow(strength, settings.clarity);
+ return pow(clamp(strength, 0, 1), settings.clarity);
}
diff --git a/src/pipelines/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts
new file mode 100644
index 0000000..e611f17
--- /dev/null
+++ b/src/pipelines/wgsl-uniform-layout.test.ts
@@ -0,0 +1,202 @@
+import { describe, expect, it } from 'vitest';
+
+import compactionShader from './agents/agent-generation/agent-compaction.wgsl?raw';
+import countingShader from './agents/agent-generation/agent-counting.wgsl?raw';
+import { AgentGenerationPipeline } from './agents/agent-generation/agent-generation-pipeline';
+import resizeShader from './agents/agent-generation/agent-resize.wgsl?raw';
+import { AgentPipeline } from './agents/agent-pipeline';
+import agentShader from './agents/agent.wgsl?raw';
+import { BrushPipeline } from './brush/brush-pipeline';
+import brushShader from './brush/brush.wgsl?raw';
+import { CommonState } from './common-state/common-state';
+import { CopyPipeline } from './copy/copy-pipeline';
+import copyShader from './copy/copy.wgsl?raw';
+import diffusionShader from './diffusion/diffuse.wgsl?raw';
+import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
+import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
+import eraserAgentShader from './eraser/eraser-agent.wgsl?raw';
+import { EraserTexturePipeline } from './eraser/eraser-texture-pipeline';
+import eraserTextureShader from './eraser/eraser-texture.wgsl?raw';
+import { RenderPipeline } from './render/render-pipeline';
+import renderShader from './render/render.wgsl?raw';
+
+const wgslFloatCountsByType: Record = {
+ f32: 1,
+ u32: 1,
+ 'vec2': 2,
+ 'vec3': 3,
+ 'vec4': 4,
+};
+
+const stripComments = (source: string): string =>
+ source.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
+
+const getStructFields = (source: string, structName: string) => {
+ const match = new RegExp(
+ `struct ${structName}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`
+ ).exec(stripComments(source));
+ if (!match?.groups?.body) {
+ throw new Error(`${structName} struct was not found`);
+ }
+
+ return match.groups.body
+ .split('\n')
+ .map((line) => line.trim().replace(/,$/, ''))
+ .filter(Boolean)
+ .map((line) => {
+ const fieldMatch = /^(?\w+):\s*(?[^,]+)$/.exec(line);
+ if (!fieldMatch?.groups) {
+ throw new Error(`Unsupported WGSL struct field syntax: ${line}`);
+ }
+
+ return {
+ name: fieldMatch.groups.name,
+ type: fieldMatch.groups.type,
+ };
+ });
+};
+
+const countUniformScalars = (source: string, structName: string): number =>
+ getStructFields(source, structName).reduce((sum, field) => {
+ const count = wgslFloatCountsByType[field.type];
+ if (!count) {
+ throw new Error(`Unsupported WGSL uniform field type: ${field.type}`);
+ }
+
+ return sum + count;
+ }, 0);
+
+const getUniformCount = (pipeline: unknown): number =>
+ (pipeline as { UNIFORM_COUNT: number }).UNIFORM_COUNT;
+
+const expectStructUniformLayout = ({
+ pipeline,
+ source,
+ structName,
+ fieldNames,
+}: {
+ pipeline: unknown;
+ source: string;
+ structName: string;
+ fieldNames: Array;
+}) => {
+ const fields = getStructFields(source, structName);
+
+ expect(fields.map((field) => field.name)).toEqual(fieldNames);
+ expect(countUniformScalars(source, structName)).toBe(getUniformCount(pipeline));
+};
+
+describe('WGSL uniform layout contracts', () => {
+ it('keeps shared common-state uniforms aligned with WGSL', () => {
+ expectStructUniformLayout({
+ pipeline: CommonState,
+ source: CommonState.shaderCode,
+ structName: 'State',
+ fieldNames: ['size', 'deltaTime', 'time'],
+ });
+ });
+
+ it('keeps render and simulation uniforms aligned with WGSL', () => {
+ expectStructUniformLayout({
+ pipeline: AgentPipeline,
+ source: agentShader,
+ structName: 'Settings',
+ fieldNames: [
+ 'moveRate',
+ 'turnRate',
+ 'sensorAngle',
+ 'sensorOffset',
+ 'turnWhenLost',
+ 'individualTrailWeight',
+ 'agentCount',
+ 'introProgress',
+ 'color1ToColor1',
+ 'color1ToColor2',
+ 'color1ToColor3',
+ 'color2ToColor1',
+ 'color2ToColor2',
+ 'color2ToColor3',
+ 'color3ToColor1',
+ 'color3ToColor2',
+ 'color3ToColor3',
+ ],
+ });
+ expectStructUniformLayout({
+ pipeline: BrushPipeline,
+ source: brushShader,
+ structName: 'Settings',
+ fieldNames: [
+ 'brushSize',
+ 'brushSizeVariation',
+ 'padding0',
+ 'padding1',
+ 'brushValue',
+ ],
+ });
+ expectStructUniformLayout({
+ pipeline: DiffusionPipeline,
+ source: diffusionShader,
+ structName: 'Settings',
+ fieldNames: [
+ 'inverseDiffusionRateTrails',
+ 'decayRateTrails',
+ 'inverseDiffusionRateBrush',
+ 'decayRateBrush',
+ ],
+ });
+ expectStructUniformLayout({
+ pipeline: RenderPipeline,
+ source: renderShader,
+ structName: 'Settings',
+ fieldNames: [
+ 'colorA',
+ 'backgroundColorPadding0',
+ 'colorB',
+ 'backgroundColorPadding1',
+ 'colorC',
+ 'backgroundColorPadding2',
+ 'backgroundColor',
+ 'clarity',
+ 'cameraCenter',
+ 'cameraZoom',
+ 'padding0',
+ ],
+ });
+ });
+
+ it('keeps eraser uniforms aligned with WGSL', () => {
+ expectStructUniformLayout({
+ pipeline: EraserAgentPipeline,
+ source: eraserAgentShader,
+ structName: 'Settings',
+ fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'],
+ });
+ expectStructUniformLayout({
+ pipeline: EraserTexturePipeline,
+ source: eraserTextureShader,
+ structName: 'Settings',
+ fieldNames: ['eraserRadius', 'eraserRadiusSquared', 'padding1', 'padding2'],
+ });
+ });
+
+ it('keeps copy uniforms aligned with WGSL', () => {
+ const match = /var\s+sourceScaler:\s*(?[^;]+);/.exec(copyShader);
+
+ expect(match?.groups?.type).toBe('vec2');
+ expect(wgslFloatCountsByType[match?.groups?.type ?? '']).toBe(
+ getUniformCount(CopyPipeline)
+ );
+ });
+
+ it('keeps agent-generation uniforms large enough for every generation shader', () => {
+ const generationUniformCounts = [
+ countUniformScalars(countingShader, 'Settings'),
+ countUniformScalars(resizeShader, 'ResizeSettings'),
+ countUniformScalars(compactionShader, 'Settings'),
+ ];
+
+ expect(Math.max(...generationUniformCounts)).toBe(
+ getUniformCount(AgentGenerationPipeline)
+ );
+ });
+});
diff --git a/src/settings.ts b/src/settings.ts
index 4715928..b043fb1 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,54 +1,39 @@
-import { GameLoopSettings } from './game-loop/game-loop-settings';
-import { AgentSettings } from './pipelines/agents/agent-settings';
-import { BrushSettings } from './pipelines/brush/brush-settings';
-import { DiffusionSettings } from './pipelines/diffusion/diffusion-settings';
-import { RenderSettings } from './pipelines/render/render-settings';
-import { persist } from './utils/persist';
+import { appConfig, type GardenRuntimeSettings } from './config';
+import { writeBrowserStorage } from './utils/browser-storage';
+import { getInitialVibe, VIBE_PRESETS, type VibePreset } from './vibes';
-const initialValues: GameLoopSettings &
- AgentSettings &
- BrushSettings &
- DiffusionSettings &
- RenderSettings = {
- agentCount: 1_001_500,
+const buildInitialValues = (vibe: VibePreset): GardenRuntimeSettings => ({
+ ...appConfig.runtimeSettings.defaults,
+ ...vibe.settings,
+});
- currentGenerationAggression: -5,
- nextGenerationAggression: 0.2,
+export let activeVibe = getInitialVibe();
- moveSpeed: 74,
- turnSpeed: 45,
- sensorOffsetAngle: 31,
- sensorOffsetDistance: 43,
- turnWhenLost: 0.01,
-
- brushTrailWeight: 500,
- individualTrailWeight: 0.05,
-
- diffusionRateTrails: 0,
- decayRateTrails: 944,
- diffusionRateBrush: 0.35,
- decayRateBrush: 18,
-
- clarity: 0.7,
- brushSize: 12,
-
- brushSizeVariation: 0.5, // hidden on the UI
-
- startColorHue: 200,
-
- maxAgentCountUpperLimit: Number.POSITIVE_INFINITY, // requires restart
-
- // debug options
- renderSpeed: 1,
- simulatedDelayMs: 0,
+export const settings: { [key: string]: number } & GardenRuntimeSettings = {
+ ...buildInitialValues(activeVibe),
};
-export const settings: { [key: string]: number } & GameLoopSettings &
- AgentSettings &
- BrushSettings &
- DiffusionSettings &
- RenderSettings = persist({ ...initialValues });
-
export const resetSettings = () => {
- Object.assign(settings, initialValues);
+ Object.assign(settings, buildInitialValues(activeVibe));
+};
+
+export const applyVibeSettings = (vibeId: string) => {
+ const vibe = VIBE_PRESETS.find((candidate) => candidate.id === vibeId);
+ if (!vibe) {
+ return activeVibe;
+ }
+
+ activeVibe = vibe;
+ Object.assign(settings, {
+ ...buildInitialValues(vibe),
+ agentCount: settings.agentCount,
+ brushEffectDuration: settings.brushEffectDuration,
+ eraserSize: settings.eraserSize,
+ mirrorSegmentCount: settings.mirrorSegmentCount,
+ selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
+ });
+
+ writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
+
+ return activeVibe;
};
diff --git a/src/style/_app-shell.scss b/src/style/_app-shell.scss
new file mode 100644
index 0000000..86d78b9
--- /dev/null
+++ b/src/style/_app-shell.scss
@@ -0,0 +1,91 @@
+html > body {
+ width: 100%;
+ min-height: 100vh;
+ min-height: 100dvh;
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
+ display: flex;
+ position: relative;
+ background: var(--garden-background, #10151f);
+
+ > .canvas-container {
+ min-height: 100vh;
+ min-height: 100dvh;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ position: relative;
+ overflow: hidden;
+
+ > canvas {
+ height: 100%;
+ width: 100%;
+ touch-action: none;
+ cursor:
+ url('../../assets/icons/brush.svg') 0 24,
+ auto;
+ }
+
+ > .eraser-preview {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ width: var(--eraser-preview-size, 96px);
+ height: var(--eraser-preview-size, 96px);
+ border: 2px solid rgb(255 234 228 / 88%);
+ border-radius: 50%;
+ background: rgb(255 140 117 / 13%);
+ box-shadow:
+ 0 0 0 1px rgb(255 88 70 / 34%),
+ 0 0 26px rgb(255 118 92 / 24%);
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(-50%, -50%);
+ transition:
+ opacity var(--transition-time),
+ width var(--transition-time),
+ height var(--transition-time);
+ mix-blend-mode: screen;
+
+ &.visible {
+ opacity: 1;
+ }
+ }
+
+ > .dev-stats-overlay {
+ position: absolute;
+ top: max(10px, env(safe-area-inset-top, 0px));
+ left: max(10px, env(safe-area-inset-left, 0px));
+ z-index: 6;
+ padding: 7px 9px;
+ border: 1px solid rgb(255 255 255 / 18%);
+ border-radius: 6px;
+ background: rgb(9 12 18 / 72%);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 22%);
+ color: rgb(255 255 255 / 90%);
+ font-family:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ monospace;
+ font-size: 11px;
+ line-height: 1.45;
+ pointer-events: none;
+ user-select: none;
+ white-space: pre;
+ }
+
+ > .errors-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ margin: var(--normal-margin);
+ z-index: 5;
+
+ pre {
+ font-size: 20px;
+ color: red;
+ }
+ }
+ }
+}
diff --git a/src/style/_config-pane.scss b/src/style/_config-pane.scss
new file mode 100644
index 0000000..6fafa4b
--- /dev/null
+++ b/src/style/_config-pane.scss
@@ -0,0 +1,69 @@
+.config-pane {
+ .color-reaction-folder > .tp-fldv_c {
+ padding: 6px 8px 8px;
+ }
+}
+
+.color-reaction-matrix {
+ display: grid;
+ grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr));
+ gap: 4px;
+ align-items: stretch;
+}
+
+.color-reaction-matrix__corner,
+.color-reaction-matrix__header {
+ display: flex;
+ min-width: 0;
+ min-height: 28px;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ color: rgb(255 255 255 / 76%);
+ font-size: 11px;
+ line-height: 1;
+}
+
+.color-reaction-matrix__corner {
+ justify-content: flex-start;
+ padding-left: 2px;
+ color: rgb(255 255 255 / 62%);
+}
+
+.color-reaction-matrix__swatch {
+ flex: 0 0 auto;
+ width: 12px;
+ height: 12px;
+ border: 1px solid rgb(255 255 255 / 55%);
+ border-radius: 999px;
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 18%);
+}
+
+.color-reaction-matrix__cell {
+ display: block;
+ min-width: 0;
+}
+
+.color-reaction-matrix__cell > select {
+ width: 100%;
+ min-width: 0;
+ height: 28px;
+ border: 1px solid rgb(255 255 255 / 16%);
+ border-radius: 4px;
+ padding: 0 4px;
+ appearance: auto;
+ background: rgb(255 255 255 / 8%);
+ color: white;
+ font: inherit;
+ font-size: 11px;
+}
+
+.color-reaction-matrix__cell > select:focus-visible {
+ outline: 2px solid rgb(255 255 255 / 72%);
+ outline-offset: 1px;
+}
+
+.color-reaction-matrix__cell > select > option {
+ background: rgb(28 31 38);
+ color: white;
+}
diff --git a/src/style/_control-dock.scss b/src/style/_control-dock.scss
new file mode 100644
index 0000000..6949610
--- /dev/null
+++ b/src/style/_control-dock.scss
@@ -0,0 +1,65 @@
+html > body > aside.control-dock {
+ position: absolute;
+ left: 50%;
+ bottom: env(safe-area-inset-bottom);
+ z-index: 4;
+ width: min(calc(100vw - 1rem), 980px);
+ transform: translate(-50%, 0);
+ translate: 0 0;
+ visibility: visible;
+ pointer-events: none;
+ transition:
+ opacity var(--transition-time-long),
+ transform var(--transition-time-long),
+ translate var(--transition-time-long),
+ visibility 0s;
+
+ > .toolbar-row,
+ > .pages {
+ pointer-events: auto;
+ }
+
+ &.menu-hidden {
+ opacity: 0;
+ visibility: hidden;
+ transform: translate(-50%, 10px);
+ pointer-events: none;
+ transition:
+ opacity var(--transition-time-long),
+ transform var(--transition-time-long),
+ visibility 0s var(--transition-time-long);
+
+ > .toolbar-row,
+ > .pages {
+ pointer-events: none;
+ }
+ }
+
+ &.menu-hidden.has-persistent-settings {
+ opacity: 1;
+ visibility: visible;
+ transform: translate(-50%, 0);
+
+ > .pages,
+ > .toolbar-row > .vibe-button,
+ > .toolbar-row > .toolbar-shell > .garden-controls,
+ > .toolbar-row > .toolbar-shell > nav.buttons > button:not(.settings),
+ > .toolbar-row > .toolbar-shell > nav.buttons > .export-status {
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ }
+
+ > .toolbar-row,
+ > .toolbar-row > .toolbar-shell,
+ > .toolbar-row > .toolbar-shell > nav.buttons {
+ pointer-events: none;
+ }
+
+ > .toolbar-row > .toolbar-shell > nav.buttons > button.settings {
+ visibility: visible;
+ opacity: 1;
+ pointer-events: auto;
+ }
+ }
+}
diff --git a/src/style/_garden-prompt.scss b/src/style/_garden-prompt.scss
new file mode 100644
index 0000000..ed56320
--- /dev/null
+++ b/src/style/_garden-prompt.scss
@@ -0,0 +1,137 @@
+@use 'mixins' as *;
+
+html > body > .canvas-container > .garden-prompt {
+ position: absolute;
+ left: 50%;
+ bottom: calc(7.25rem + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ max-width: min(92vw, 780px);
+ color: white;
+ text-align: center;
+ font-size: 46px;
+ line-height: 1.15;
+ text-shadow: 0 2px 18px rgb(0 0 0 / 60%);
+ pointer-events: none;
+ z-index: 2;
+
+ &:empty {
+ display: none;
+ }
+
+ &.draw-hint {
+ display: flex;
+ align-items: center;
+ top: calc(1.25rem + env(safe-area-inset-top));
+ bottom: auto;
+ gap: 16px;
+ width: max-content;
+ min-height: 78px;
+ max-width: min(88vw, 460px);
+ padding: 12px 18px 12px 14px;
+ border: 1px solid rgb(255 255 255 / 16%);
+ border-radius: 8px;
+ background: rgb(10 12 16 / 50%);
+ box-shadow:
+ 0 14px 42px rgb(0 0 0 / 28%),
+ inset 0 0 0 1px rgb(255 255 255 / 5%);
+ backdrop-filter: blur(12px);
+ color: rgb(255 255 255 / 94%);
+ font:
+ 600 20px/1.2 'Open Sans',
+ sans-serif;
+ text-shadow: 0 1px 12px rgb(0 0 0 / 58%);
+ }
+
+ .draw-hint-mark {
+ width: 128px;
+ height: 72px;
+ flex: 0 0 128px;
+ overflow: visible;
+ }
+
+ .draw-hint-shadow,
+ .draw-hint-stroke {
+ fill: none;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+
+ .draw-hint-shadow {
+ stroke: rgb(0 0 0 / 42%);
+ stroke-width: 10px;
+ }
+
+ .draw-hint-stroke {
+ stroke: color-mix(in srgb, var(--accent-color) 74%, white);
+ stroke-width: 5px;
+ stroke-dasharray: 154;
+ animation: draw-hint-stroke 2.4s ease-in-out infinite;
+ filter: drop-shadow(
+ 0 0 12px color-mix(in srgb, var(--accent-color) 60%, transparent)
+ );
+ }
+
+ .draw-hint-start {
+ fill: rgb(255 255 255 / 64%);
+ }
+
+ .draw-hint-end {
+ fill: white;
+ stroke: color-mix(in srgb, var(--accent-color) 72%, transparent);
+ stroke-width: 5px;
+ transform-origin: 116px 42px;
+ animation: draw-hint-tap 2.4s ease-in-out infinite;
+ }
+
+ @include on-small-screen {
+ bottom: calc(10rem + env(safe-area-inset-bottom));
+ font-size: 24px;
+
+ &.draw-hint {
+ top: calc(0.75rem + env(safe-area-inset-top));
+ bottom: auto;
+ gap: 10px;
+ min-height: 58px;
+ max-width: min(92vw, 340px);
+ padding: 9px 12px 9px 10px;
+ font-size: 16px;
+ }
+
+ .draw-hint-mark {
+ width: 96px;
+ height: 54px;
+ flex-basis: 96px;
+ }
+ }
+}
+
+@keyframes draw-hint-stroke {
+ 0%,
+ 18% {
+ stroke-dashoffset: 154;
+ }
+
+ 58%,
+ 100% {
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes draw-hint-tap {
+ 0%,
+ 16% {
+ opacity: 0;
+ transform: scale(0.72);
+ }
+
+ 36%,
+ 74% {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ 100% {
+ opacity: 0.76;
+ transform: scale(0.88);
+ }
+}
diff --git a/src/style/_loading.scss b/src/style/_loading.scss
new file mode 100644
index 0000000..ff97098
--- /dev/null
+++ b/src/style/_loading.scss
@@ -0,0 +1,120 @@
+.loading-indicator {
+ --loading-progress: 0%;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ align-items: center;
+ justify-content: center;
+ z-index: 3;
+ width: min(78vw, 320px);
+ transform: translate(-50%, -50%);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--transition-time-long);
+
+ > .loading-dots {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ justify-content: center;
+
+ > .loading-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: rgb(255 255 255 / 92%);
+ box-shadow:
+ 0 0 18px rgb(255 255 255 / 38%),
+ 0 0 4px rgb(255 255 255 / 60%);
+ transform: scale(0.5);
+ opacity: 0.4;
+ animation: loading-bloom 1.4s ease-in-out infinite;
+
+ &:nth-child(2) {
+ animation-delay: 0.18s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.36s;
+ }
+ }
+ }
+
+ > .loading-status {
+ color: rgb(255 255 255 / 88%);
+ font:
+ 600 16px/1.25 'Open Sans',
+ sans-serif;
+ text-align: center;
+ text-shadow: 0 1px 12px rgb(0 0 0 / 60%);
+ letter-spacing: 0.01em;
+ min-height: 1.25em;
+ }
+
+ > .loading-progress {
+ position: relative;
+ width: 100%;
+ height: 3px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgb(255 255 255 / 14%);
+ box-shadow: 0 1px 6px rgb(0 0 0 / 28%);
+
+ > .loading-progress-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: var(--loading-progress);
+ border-radius: inherit;
+ background: linear-gradient(
+ 90deg,
+ rgb(255 255 255 / 72%),
+ rgb(255 255 255 / 96%)
+ );
+ box-shadow: 0 0 12px rgb(255 255 255 / 38%);
+ transition: width var(--transition-time-long) ease-out;
+ }
+ }
+}
+
+html > body.is-loading {
+ .loading-indicator {
+ opacity: 1;
+ }
+
+ .eraser-preview {
+ display: none;
+ }
+
+ aside.control-dock {
+ opacity: 0;
+ visibility: hidden;
+ translate: 0 36px;
+ }
+}
+
+@keyframes loading-bloom {
+ 0%,
+ 100% {
+ transform: scale(0.5);
+ opacity: 0.35;
+ }
+
+ 50% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .loading-indicator > .loading-dots > .loading-dot {
+ animation: none;
+ transform: scale(0.85);
+ opacity: 0.85;
+ }
+}
diff --git a/src/style/_motion.scss b/src/style/_motion.scss
new file mode 100644
index 0000000..005a69f
--- /dev/null
+++ b/src/style/_motion.scss
@@ -0,0 +1,34 @@
+@media (prefers-reduced-motion: reduce) {
+ html > body {
+ > .canvas-container > .garden-prompt {
+ .draw-hint-stroke {
+ stroke-dashoffset: 0;
+ }
+
+ .draw-hint-end {
+ opacity: 1;
+ transform: none;
+ }
+ }
+
+ > aside.control-dock {
+ &,
+ &.menu-hidden {
+ transform: translateX(-50%);
+ }
+
+ > .toolbar-row {
+ button:hover,
+ button:active,
+ > .toolbar-shell > .garden-controls > .swatches > .eraser-size-control:hover,
+ > .toolbar-shell > .garden-controls > .swatches > .mirror-segment-control:hover {
+ transform: none;
+ }
+
+ > .toolbar-shell > nav.buttons > button:hover::after {
+ transform: none;
+ }
+ }
+ }
+ }
+}
diff --git a/src/style/_panels.scss b/src/style/_panels.scss
new file mode 100644
index 0000000..5b3fe9a
--- /dev/null
+++ b/src/style/_panels.scss
@@ -0,0 +1,178 @@
+@use 'mixins' as *;
+@use 'range-input' as *;
+
+html > body > aside.control-dock > .pages {
+ @include blurred-background(#fff);
+ width: min(calc(100vw - 1rem), 560px);
+ max-height: min(58vh, 520px);
+ max-height: min(58dvh, 520px);
+ margin: 0 auto 10px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ border: 1px solid rgb(255 255 255 / 54%);
+ border-radius: 8px;
+ box-shadow:
+ 0 18px 48px rgb(0 0 0 / 28%),
+ 0 2px 10px rgb(0 0 0 / 16%);
+ scrollbar-width: thin;
+ scrollbar-color: var(--main-color) transparent;
+ transition:
+ max-height var(--transition-time-long),
+ opacity var(--transition-time-long),
+ transform var(--transition-time-long),
+ margin-bottom var(--transition-time-long);
+
+ &::-webkit-scrollbar-track,
+ &::-webkit-scrollbar {
+ background-color: transparent;
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--main-color);
+ border-radius: 8px;
+ }
+
+ &:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 3px;
+ }
+
+ &.info-page {
+ background:
+ linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
+ rgb(255 255 255);
+ border-color: rgb(255 255 255 / 78%);
+ color: rgb(24 30 27);
+ box-shadow:
+ 0 20px 54px rgb(0 0 0 / 38%),
+ 0 2px 12px rgb(0 0 0 / 22%);
+
+ > section {
+ gap: 0.85rem;
+
+ h1 {
+ margin-bottom: 0;
+ color: rgb(16 24 20);
+ }
+
+ p {
+ max-width: 54ch;
+ margin-bottom: 0;
+ color: rgb(42 48 45);
+ }
+
+ a {
+ color: rgb(0 84 120);
+ font-weight: 700;
+
+ &:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 3px;
+ }
+ }
+ }
+ }
+
+ &.hidden {
+ max-height: 0;
+ margin-bottom: 0;
+ border-color: transparent;
+ opacity: 0;
+ pointer-events: none;
+ box-shadow: none;
+ transform: translateY(8px);
+ visibility: hidden;
+ }
+
+ > section {
+ display: flex;
+ flex-direction: column;
+ padding: var(--normal-margin);
+
+ h1 {
+ font-size: 2rem;
+ line-height: 1.1;
+ }
+
+ p {
+ @include main-font();
+ margin-bottom: var(--small-margin);
+ line-height: 1.65;
+ }
+
+ a {
+ color: var(--accent-color);
+ }
+
+ .slider {
+ $track-height: 8px;
+ $thumb-size: 22px;
+ margin-bottom: var(--small-margin);
+ user-select: none;
+
+ p {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--small-margin);
+ margin-bottom: 0.35rem;
+ font-size: 0.95rem;
+ }
+
+ input[type='range'] {
+ @include settings-range-input();
+
+ &::-webkit-slider-runnable-track {
+ @include range-track(rgb(49 52 63 / 14%), $track-height, 1000px, null);
+ }
+
+ &::-webkit-slider-thumb {
+ @include range-thumb-base(
+ $thumb-size,
+ $thumb-size,
+ 2px solid var(--accent-color),
+ 1000px
+ );
+ margin-top: -7px;
+ appearance: none;
+ background: white;
+ box-shadow: 0 3px 10px rgb(0 0 0 / 20%);
+ transition: transform var(--transition-time);
+
+ &:hover {
+ transform: scale(1.08);
+ }
+ }
+
+ &::-moz-range-track {
+ @include range-track(rgb(49 52 63 / 14%), $track-height, 1000px, null);
+ }
+
+ &::-moz-range-thumb {
+ @include range-thumb-base(
+ $thumb-size,
+ $thumb-size,
+ 2px solid var(--accent-color),
+ 1000px
+ );
+ background: white;
+ box-shadow: 0 3px 10px rgb(0 0 0 / 20%);
+ }
+ }
+ }
+
+ .large-button {
+ margin: var(--small-margin) 0 0 auto;
+ border-radius: 8px;
+ }
+ }
+
+ @include on-small-screen {
+ max-height: min(54vh, 500px);
+ max-height: min(54dvh, 500px);
+
+ > section {
+ padding: var(--small-margin);
+ }
+ }
+}
diff --git a/src/style/_range-input.scss b/src/style/_range-input.scss
new file mode 100644
index 0000000..a55899e
--- /dev/null
+++ b/src/style/_range-input.scss
@@ -0,0 +1,49 @@
+@mixin toolbar-range-input() {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ appearance: none;
+ background: transparent;
+ cursor: ew-resize;
+ outline: none;
+ touch-action: pan-y;
+
+ &:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 2px;
+ border-radius: 8px;
+ }
+}
+
+@mixin settings-range-input() {
+ width: 100%;
+ height: 44px;
+ appearance: none;
+ background: transparent;
+ outline: none;
+}
+
+@mixin range-track(
+ $background,
+ $height: 7px,
+ $border-radius: 999px,
+ $box-shadow: inset 0 1px 2px rgb(0 0 0 / 24%)
+) {
+ height: $height;
+ cursor: pointer;
+ border-radius: $border-radius;
+ background: $background;
+
+ @if $box-shadow != null {
+ box-shadow: $box-shadow;
+ }
+}
+
+@mixin range-thumb-base($width, $height, $border, $border-radius) {
+ width: $width;
+ height: $height;
+ cursor: pointer;
+ border: $border;
+ border-radius: $border-radius;
+}
diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss
new file mode 100644
index 0000000..74a8592
--- /dev/null
+++ b/src/style/_toolbar.scss
@@ -0,0 +1,720 @@
+@use 'mixins' as *;
+@use 'range-input' as *;
+
+html > body > aside.control-dock > .toolbar-row {
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ width: fit-content;
+ max-width: 100%;
+ margin: 0 auto;
+ gap: clamp(6px, 1.8vw, 14px);
+ color: rgb(245 250 244 / 92%);
+ font:
+ 600 13px/1 'Open Sans',
+ sans-serif;
+
+ button {
+ min-width: 44px;
+ min-height: 44px;
+ border: 0;
+ font: inherit;
+ cursor: pointer;
+ transition:
+ background-color var(--transition-time),
+ border-color var(--transition-time),
+ color var(--transition-time),
+ box-shadow var(--transition-time),
+ transform var(--transition-time);
+
+ &:focus-visible {
+ outline: 2px solid white;
+ outline-offset: 2px;
+ }
+ }
+
+ > .toolbar-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-areas:
+ 'swatches'
+ 'nav';
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-width: 0;
+ min-height: 86px;
+ padding: 8px 9px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ background: transparent;
+ backdrop-filter: none;
+ box-shadow: none;
+ }
+
+ > .vibe-button {
+ display: grid;
+ place-items: center;
+ position: relative;
+ width: 52px;
+ height: auto;
+ min-height: 66px;
+ flex: 0 0 auto;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: rgb(255 255 255 / 70%);
+ font-size: 0;
+ line-height: 1;
+ text-shadow: none;
+ box-shadow: none;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 18px;
+ height: 18px;
+ border-color: currentColor;
+ border-style: solid;
+ border-width: 0 0 3px 3px;
+ transform: translate(-35%, -50%) rotate(45deg);
+ }
+
+ &.next-vibe::before {
+ border-width: 3px 3px 0 0;
+ transform: translate(-65%, -50%) rotate(45deg);
+ }
+
+ &:hover {
+ background: transparent;
+ color: color-mix(in srgb, var(--accent-color) 70%, white);
+ box-shadow: none;
+ transform: translateY(-2px);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+
+ > .toolbar-shell > nav.buttons {
+ grid-area: nav;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ min-width: 0;
+ padding-top: 7px;
+ border-top: 1px solid rgb(255 255 255 / 12%);
+
+ > button {
+ position: relative;
+ width: 44px;
+ height: 44px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ background: transparent;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ width: 20px;
+ height: 20px;
+ margin: auto;
+ background-color: rgb(245 250 244 / 76%);
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: contain;
+ transition:
+ background-color var(--transition-time),
+ transform var(--transition-time);
+ }
+
+ &:hover,
+ &.active {
+ border-color: rgb(255 255 255 / 10%);
+ background: rgb(255 255 255 / 9%);
+ }
+
+ &:hover::after {
+ transform: scale(1.08);
+ }
+
+ &.active {
+ border-color: color-mix(in srgb, var(--accent-color) 55%, white 15%);
+ background: color-mix(in srgb, var(--accent-color) 30%, transparent);
+ box-shadow: none;
+ }
+
+ &.active::after {
+ background-color: white;
+ }
+
+ &.info::after {
+ mask-image: url('../../assets/icons/info.svg');
+ }
+
+ &.maximize-full-screen::after {
+ mask-image: url('../../assets/icons/maximize.svg');
+ }
+
+ &.minimize-full-screen::after {
+ mask-image: url('../../assets/icons/minimize.svg');
+ }
+
+ &.settings::after {
+ mask-image: url('../../assets/icons/settings.svg');
+ }
+
+ &.sound::after {
+ mask-image: url('../../assets/icons/sound.svg');
+ }
+
+ &.sound.muted::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ width: 2px;
+ height: 28px;
+ margin: auto;
+ border-radius: 999px;
+ background: white;
+ transform: rotate(-45deg);
+ transform-origin: center;
+ }
+
+ &.sound.muted::after {
+ background-color: rgb(255 255 255 / 46%);
+ }
+
+ &.export-4k::after {
+ mask-image: url('../../assets/icons/download.svg');
+ }
+
+ &.restart::after {
+ mask-image: url('../../assets/icons/restart.svg');
+ }
+ }
+
+ > .export-status {
+ flex: 0 1 140px;
+ min-height: 20px;
+ max-width: 140px;
+ overflow: hidden;
+ color: rgb(255 255 255 / 82%);
+ font-size: 13px;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:empty {
+ display: none;
+ }
+ }
+ }
+
+ > .toolbar-shell > .garden-controls {
+ grid-area: swatches;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-width: 0;
+ padding: 0 4px;
+
+ > .swatches {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-height: 58px;
+ padding: 6px 10px;
+
+ > button {
+ position: relative;
+ width: 44px;
+ height: 44px;
+ border: 2px solid rgb(255 255 255 / 54%);
+ border-radius: 50%;
+ box-shadow:
+ inset 0 0 0 1px rgb(0 0 0 / 16%),
+ 0 3px 10px rgb(0 0 0 / 22%);
+
+ &:hover {
+ transform: translateY(-2px);
+ }
+
+ &.active {
+ outline: 2px solid rgb(255 255 255 / 96%);
+ outline-offset: 3px;
+ box-shadow:
+ inset 0 0 0 1px rgb(0 0 0 / 14%),
+ 0 0 0 7px color-mix(in srgb, var(--accent-color) 52%, transparent),
+ 0 7px 18px rgb(0 0 0 / 26%);
+ }
+ }
+
+ > .eraser-size-control {
+ --eraser-control-scale: 1;
+ --eraser-progress: 33%;
+
+ position: relative;
+ display: grid;
+ align-items: center;
+ width: 184px;
+ height: 46px;
+ flex: 0 0 184px;
+ padding: 0 12px;
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 14%);
+ border-radius: 8px;
+ background:
+ radial-gradient(
+ circle at 24% 78%,
+ rgb(255 226 215 / 42%) 0 1px,
+ transparent 1.5px
+ ),
+ radial-gradient(
+ circle at 47% 72%,
+ rgb(255 226 215 / 34%) 0 1px,
+ transparent 1.5px
+ ),
+ radial-gradient(
+ circle at 67% 81%,
+ rgb(255 226 215 / 38%) 0 1px,
+ transparent 1.5px
+ ),
+ linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%));
+ box-shadow:
+ inset 0 0 0 1px rgb(255 255 255 / 6%),
+ 0 3px 10px rgb(0 0 0 / 18%);
+ cursor: ew-resize;
+ transition:
+ border-color var(--transition-time),
+ background-color var(--transition-time),
+ box-shadow var(--transition-time),
+ transform var(--transition-time);
+
+ &::before {
+ content: '';
+ position: absolute;
+ right: 12px;
+ bottom: 8px;
+ left: 12px;
+ height: 2px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgb(255 140 117 / 56%) 0 var(--eraser-progress),
+ rgb(255 255 255 / 18%) var(--eraser-progress) 100%
+ );
+ box-shadow: 0 1px 4px rgb(0 0 0 / 22%);
+ }
+
+ &:hover {
+ transform: translateY(-2px);
+ border-color: rgb(255 255 255 / 24%);
+ }
+
+ &.active {
+ border-color: rgb(255 212 202 / 72%);
+ background-color: rgb(255 140 117 / 11%);
+ box-shadow:
+ inset 0 0 0 1px rgb(255 255 255 / 10%),
+ 0 0 0 5px rgb(255 140 117 / 34%),
+ 0 6px 15px rgb(0 0 0 / 22%);
+ }
+
+ input[type='range'] {
+ @include toolbar-range-input();
+
+ &::-webkit-slider-runnable-track {
+ @include range-track(
+ linear-gradient(
+ 90deg,
+ rgb(255 140 117 / 72%) 0 var(--eraser-progress),
+ rgb(255 255 255 / 24%) var(--eraser-progress) 100%
+ )
+ );
+ }
+
+ &::-webkit-slider-thumb {
+ @include range-thumb-base(
+ calc(34px * var(--eraser-control-scale)),
+ calc(21px * var(--eraser-control-scale)),
+ 2px solid rgb(255 239 233 / 94%),
+ calc(6px * var(--eraser-control-scale))
+ );
+ margin-top: calc((7px - (21px * var(--eraser-control-scale))) / 2);
+ appearance: none;
+ background:
+ linear-gradient(
+ 110deg,
+ transparent 0 12%,
+ rgb(255 255 255 / 44%) 13% 20%,
+ transparent 21% 100%
+ ),
+ linear-gradient(
+ 90deg,
+ #ff8fa3 0 52%,
+ rgb(54 46 51 / 78%) 53% 56%,
+ #f5eee5 57% 100%
+ );
+ box-shadow:
+ inset 0 -2px 3px rgb(117 46 58 / 22%),
+ inset 0 2px 3px rgb(255 255 255 / 28%),
+ 0 4px 10px rgb(0 0 0 / 28%);
+ transform: rotate(-10deg);
+ transition:
+ height var(--transition-time),
+ margin-top var(--transition-time),
+ width var(--transition-time);
+ }
+
+ &::-moz-range-track {
+ @include range-track(
+ linear-gradient(
+ 90deg,
+ rgb(255 140 117 / 72%) 0 var(--eraser-progress),
+ rgb(255 255 255 / 24%) var(--eraser-progress) 100%
+ )
+ );
+ }
+
+ &::-moz-range-thumb {
+ @include range-thumb-base(
+ calc(34px * var(--eraser-control-scale)),
+ calc(21px * var(--eraser-control-scale)),
+ 2px solid rgb(255 239 233 / 94%),
+ calc(6px * var(--eraser-control-scale))
+ );
+ background:
+ linear-gradient(
+ 110deg,
+ transparent 0 12%,
+ rgb(255 255 255 / 44%) 13% 20%,
+ transparent 21% 100%
+ ),
+ linear-gradient(
+ 90deg,
+ #ff8fa3 0 52%,
+ rgb(54 46 51 / 78%) 53% 56%,
+ #f5eee5 57% 100%
+ );
+ box-shadow:
+ inset 0 -2px 3px rgb(117 46 58 / 22%),
+ inset 0 2px 3px rgb(255 255 255 / 28%),
+ 0 4px 10px rgb(0 0 0 / 28%);
+ transform: rotate(-10deg);
+ transition:
+ height var(--transition-time),
+ width var(--transition-time);
+ }
+ }
+ }
+
+ > .mirror-segment-control {
+ --mirror-progress: 0%;
+ --mirror-angle: 360deg;
+
+ position: relative;
+ display: grid;
+ align-items: center;
+ width: 184px;
+ height: 46px;
+ flex: 0 0 184px;
+ padding: 0 12px;
+ overflow: hidden;
+ border: 1px solid rgb(255 255 255 / 14%);
+ border-radius: 8px;
+ background:
+ radial-gradient(
+ circle at 24% 78%,
+ rgb(197 255 234 / 38%) 0 1px,
+ transparent 1.5px
+ ),
+ radial-gradient(
+ circle at 47% 72%,
+ rgb(197 255 234 / 30%) 0 1px,
+ transparent 1.5px
+ ),
+ radial-gradient(
+ circle at 67% 81%,
+ rgb(197 255 234 / 34%) 0 1px,
+ transparent 1.5px
+ ),
+ linear-gradient(180deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 4%));
+ box-shadow:
+ inset 0 0 0 1px rgb(255 255 255 / 6%),
+ 0 3px 10px rgb(0 0 0 / 18%);
+ cursor: ew-resize;
+ transition:
+ border-color var(--transition-time),
+ background-color var(--transition-time),
+ box-shadow var(--transition-time),
+ transform var(--transition-time);
+
+ &::before {
+ content: '';
+ position: absolute;
+ right: 12px;
+ bottom: 8px;
+ left: 12px;
+ height: 2px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgb(148 233 203 / 56%) 0 var(--mirror-progress),
+ rgb(255 255 255 / 18%) var(--mirror-progress) 100%
+ );
+ box-shadow: 0 1px 4px rgb(0 0 0 / 22%);
+ }
+
+ &:hover {
+ transform: translateY(-2px);
+ border-color: rgb(255 255 255 / 24%);
+ }
+
+ &.active {
+ border-color: rgb(167 245 219 / 74%);
+ background-color: rgb(92 206 177 / 12%);
+ box-shadow:
+ inset 0 0 0 1px rgb(255 255 255 / 10%),
+ 0 0 0 5px rgb(92 206 177 / 28%),
+ 0 6px 15px rgb(0 0 0 / 22%);
+ }
+
+ input[type='range'] {
+ @include toolbar-range-input();
+
+ &::-webkit-slider-runnable-track {
+ @include range-track(
+ linear-gradient(
+ 90deg,
+ rgb(148 233 203 / 78%) 0 var(--mirror-progress),
+ rgb(255 255 255 / 24%) var(--mirror-progress) 100%
+ )
+ );
+ }
+
+ &::-webkit-slider-thumb {
+ @include range-thumb-base(44px, 44px, 2px solid rgb(240 255 251 / 94%), 50%);
+ margin-top: -18.5px;
+ appearance: none;
+ background:
+ radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px),
+ repeating-conic-gradient(
+ from -90deg,
+ rgb(218 255 241) 0 8deg,
+ rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
+ );
+ box-shadow:
+ inset 0 0 0 7px rgb(0 0 0 / 18%),
+ 0 0 0 5px rgb(92 206 177 / 16%),
+ 0 5px 14px rgb(0 0 0 / 30%);
+ transition:
+ box-shadow var(--transition-time),
+ transform var(--transition-time);
+ }
+
+ &::-webkit-slider-thumb:hover {
+ box-shadow:
+ inset 0 0 0 7px rgb(0 0 0 / 18%),
+ 0 0 0 7px rgb(92 206 177 / 24%),
+ 0 6px 16px rgb(0 0 0 / 34%);
+ transform: scale(1.04);
+ }
+
+ &::-moz-range-track {
+ @include range-track(
+ linear-gradient(
+ 90deg,
+ rgb(148 233 203 / 78%) 0 var(--mirror-progress),
+ rgb(255 255 255 / 24%) var(--mirror-progress) 100%
+ )
+ );
+ }
+
+ &::-moz-range-thumb {
+ @include range-thumb-base(44px, 44px, 2px solid rgb(240 255 251 / 94%), 50%);
+ background:
+ radial-gradient(circle, white 0 3px, rgb(9 20 18 / 78%) 3.5px 8px),
+ repeating-conic-gradient(
+ from -90deg,
+ rgb(218 255 241) 0 8deg,
+ rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
+ );
+ box-shadow:
+ inset 0 0 0 7px rgb(0 0 0 / 18%),
+ 0 0 0 5px rgb(92 206 177 / 16%),
+ 0 5px 14px rgb(0 0 0 / 30%);
+ }
+ }
+ }
+ }
+ }
+
+ @include on-small-screen {
+ width: 100%;
+ gap: 6px;
+
+ > .vibe-button {
+ width: 44px;
+ min-height: 44px;
+
+ &::before {
+ width: 14px;
+ height: 14px;
+ }
+ }
+
+ > .toolbar-shell {
+ flex: 1 1 auto;
+ min-width: 0;
+ gap: 8px;
+ padding: 4px 8px;
+
+ > nav.buttons {
+ grid-area: nav;
+ justify-content: center;
+ gap: 2px;
+ padding-top: 3px;
+ border-top: 1px solid rgb(255 255 255 / 12%);
+
+ > button {
+ width: 44px;
+ height: 38px;
+ min-height: 38px;
+
+ &::after {
+ width: 17px;
+ height: 17px;
+ }
+ }
+
+ > .export-status {
+ flex-basis: 100%;
+ max-width: 100%;
+ text-align: center;
+ }
+ }
+
+ > .garden-controls {
+ grid-area: swatches;
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ padding: 2px 4px;
+
+ > .swatches {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ flex: 1 1 100%;
+ align-items: center;
+ justify-items: center;
+ justify-content: stretch;
+ column-gap: 7px;
+ row-gap: 8px;
+ width: 100%;
+ min-width: 0;
+ min-height: 54px;
+ padding: 4px 6px;
+
+ > .color-swatch {
+ grid-column: span 2;
+ width: 44px;
+ height: 44px;
+ }
+
+ > .eraser-size-control {
+ grid-column: 1 / span 3;
+ justify-self: stretch;
+ width: 100%;
+ min-width: 0;
+ height: 42px;
+ flex-basis: auto;
+ padding: 0 8px;
+
+ &::before {
+ right: 8px;
+ left: 8px;
+ }
+ }
+
+ > .mirror-segment-control {
+ grid-column: 4 / span 3;
+ justify-self: stretch;
+ width: 100%;
+ min-width: 0;
+ height: 42px;
+ flex-basis: auto;
+ padding: 0 8px;
+
+ &::before {
+ right: 8px;
+ left: 8px;
+ }
+
+ input[type='range'] {
+ &::-webkit-slider-thumb {
+ @include range-thumb-base(
+ 38px,
+ 38px,
+ 2px solid rgb(240 255 251 / 94%),
+ 50%
+ );
+ margin-top: -15.5px;
+ background:
+ radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px),
+ repeating-conic-gradient(
+ from -90deg,
+ rgb(218 255 241) 0 8deg,
+ rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
+ );
+ box-shadow:
+ inset 0 0 0 6px rgb(0 0 0 / 18%),
+ 0 0 0 3px rgb(92 206 177 / 16%),
+ 0 4px 10px rgb(0 0 0 / 28%);
+ }
+
+ &::-webkit-slider-thumb:hover {
+ box-shadow:
+ inset 0 0 0 6px rgb(0 0 0 / 18%),
+ 0 0 0 4px rgb(92 206 177 / 24%),
+ 0 5px 12px rgb(0 0 0 / 32%);
+ }
+
+ &::-moz-range-thumb {
+ @include range-thumb-base(
+ 38px,
+ 38px,
+ 2px solid rgb(240 255 251 / 94%),
+ 50%
+ );
+ background:
+ radial-gradient(circle, white 0 2.5px, rgb(9 20 18 / 78%) 3px 7px),
+ repeating-conic-gradient(
+ from -90deg,
+ rgb(218 255 241) 0 8deg,
+ rgb(8 22 19 / 94%) 8deg var(--mirror-angle)
+ );
+ box-shadow:
+ inset 0 0 0 6px rgb(0 0 0 / 18%),
+ 0 0 0 3px rgb(92 206 177 / 16%),
+ 0 4px 10px rgb(0 0 0 / 28%);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/style/common.scss b/src/style/common.scss
index 8954439..f33c2e1 100644
--- a/src/style/common.scss
+++ b/src/style/common.scss
@@ -9,7 +9,7 @@
padding: 0;
box-sizing: border-box;
- @media (prefers-reduced-motion) {
+ @media (prefers-reduced-motion: reduce) {
transition: none !important;
animation: none !important;
}
@@ -36,7 +36,21 @@ html {
text-rendering: optimizeLegibility;
}
+.visually-hidden {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ margin: -1px !important;
+ padding: 0 !important;
+ overflow: hidden !important;
+ clip: rect(0 0 0 0) !important;
+ clip-path: inset(50%) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
.large-button {
+ min-height: 44px;
border: none;
background-color: var(--accent-color);
cursor: pointer;
diff --git a/src/utils/browser-storage.ts b/src/utils/browser-storage.ts
new file mode 100644
index 0000000..b02db6c
--- /dev/null
+++ b/src/utils/browser-storage.ts
@@ -0,0 +1,17 @@
+export const readBrowserStorage = (key: string): string | null => {
+ try {
+ return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+};
+
+export const writeBrowserStorage = (key: string, value: string): void => {
+ try {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem(key, value);
+ }
+ } catch {
+ // Storage can be unavailable in private browsing or embedded contexts.
+ }
+};
diff --git a/src/utils/delta-time-calculator.ts b/src/utils/delta-time-calculator.ts
index fb1ef5a..011e584 100644
--- a/src/utils/delta-time-calculator.ts
+++ b/src/utils/delta-time-calculator.ts
@@ -1,15 +1,19 @@
+import { appConfig } from '../config';
import { clamp } from './clamp';
import { exponentialDecay } from './exponential-decay';
export class DeltaTimeCalculator {
- private static FPS_EXPONENTIAL_DECAY_STRENGTH = 0.01;
+ private static FPS_EXPONENTIAL_DECAY_STRENGTH =
+ appConfig.deltaTime.fpsExponentialDecayStrength;
private previousTime: DOMHighResTimeStamp | null = null;
private deltaTimeAccumulator: number | null = null;
constructor(
- private readonly maxDeltaTimeInSeconds: number = 1 / 30,
- private readonly minDeltaTimeInSeconds: number = 1 / 240
+ private readonly maxDeltaTimeInSeconds: number =
+ appConfig.deltaTime.maxDeltaTimeSeconds,
+ private readonly minDeltaTimeInSeconds: number =
+ appConfig.deltaTime.minDeltaTimeSeconds
) {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
diff --git a/src/utils/dom.ts b/src/utils/dom.ts
new file mode 100644
index 0000000..7ba4aed
--- /dev/null
+++ b/src/utils/dom.ts
@@ -0,0 +1,63 @@
+import { ErrorCode, RuntimeError } from './error-handler';
+
+type ElementConstructor = abstract new () => T;
+
+export const queryRequiredElement = (
+ selector: string,
+ constructor: ElementConstructor,
+ root: ParentNode = document
+): T => {
+ const element = root.querySelector(selector);
+ if (!(element instanceof constructor)) {
+ throw new RuntimeError(
+ ErrorCode.DOM_ELEMENT_MISSING,
+ `Missing required DOM element: ${selector}`,
+ {
+ details: {
+ expectedType: constructor.name,
+ selector,
+ },
+ }
+ );
+ }
+
+ return element;
+};
+
+export const queryRequiredElements = (
+ selector: string,
+ constructor: ElementConstructor,
+ root: ParentNode = document
+): Array => {
+ const elements = Array.from(root.querySelectorAll(selector));
+ if (elements.length === 0) {
+ throw new RuntimeError(
+ ErrorCode.DOM_ELEMENT_MISSING,
+ `Missing required DOM elements: ${selector}`,
+ {
+ details: {
+ expectedType: constructor.name,
+ selector,
+ },
+ }
+ );
+ }
+
+ return elements.map((element) => {
+ if (!(element instanceof constructor)) {
+ throw new RuntimeError(
+ ErrorCode.DOM_ELEMENT_MISSING,
+ `DOM element has the wrong type: ${selector}`,
+ {
+ details: {
+ actualType: element.constructor.name,
+ expectedType: constructor.name,
+ selector,
+ },
+ }
+ );
+ }
+
+ return element;
+ });
+};
diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts
index ca91a8a..d969a2e 100644
--- a/src/utils/error-handler.ts
+++ b/src/utils/error-handler.ts
@@ -4,12 +4,176 @@ export enum Severity {
ERROR = 'error',
}
-export interface ErrorHandlerError {
- severity: Severity;
- message: string;
+export enum ErrorCode {
+ UNKNOWN = 'unknown',
+ WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
+ WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
+ WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',
+ WEBGPU_DEVICE_UNAVAILABLE = 'webgpu-device-unavailable',
+ WEBGPU_CONTEXT_UNAVAILABLE = 'webgpu-context-unavailable',
+ WEBGPU_CONTEXT_CONFIGURATION_FAILED = 'webgpu-context-configuration-failed',
+ WEBGPU_UNCAPTURED_ERROR = 'webgpu-uncaptured-error',
+ WEBGPU_DEVICE_LOST = 'webgpu-device-lost',
+ DOM_ELEMENT_MISSING = 'dom-element-missing',
}
-export type ErrorMetadata = { [key: string]: any };
+type ErrorMetadataPrimitive = string | number | boolean | null;
+type ErrorMetadataValue =
+ | ErrorMetadataPrimitive
+ | Array