diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml
index b6e190b..b50603f 100644
--- a/.forgejo/workflows/deploy.yml
+++ b/.forgejo/workflows/deploy.yml
@@ -43,5 +43,4 @@ jobs:
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/index.html b/index.html
index 5bcb619..8db16b3 100644
--- a/index.html
+++ b/index.html
@@ -41,8 +41,8 @@
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
- settings, export, restart, or open more information.
+ to paint coloured paths, then use the toolbar to change colours, erase, adjust the
+ config overlay, export, restart, or open more information.
@@ -74,18 +74,6 @@
-
-
- `;
- game?.destroy();
- shouldStop = true;
+ renderRuntimeMessage(elements.errorContainer, error);
+ if (error.severity === Severity.ERROR) {
+ 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 &&
+ !configPane.isOpen &&
!infoPageHandler.isOpen
);
new FullScreenHandler(
@@ -76,31 +231,113 @@ const main = async () => {
document.body
);
+ const fontsReady = document.fonts.ready.catch(() => undefined);
const gpu = await initializeGpu();
+ await fontsReady;
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;
+ localStorage.setItem(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;
}
+ 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);
+
+ 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);
+
await game.start();
}
} catch (e) {
- const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
- ErrorHandler.addError(Severity.ERROR, message);
+ 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..0a78d3d
--- /dev/null
+++ b/src/page/config-pane.ts
@@ -0,0 +1,268 @@
+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;
+
+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 => {
+ 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,
+ step: config.step,
+});
+
+export class ConfigPane {
+ private readonly container: HTMLDivElement;
+ private readonly pane: Pane;
+ 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.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();
+ Object.entries(appConfig.runtimeSettings.controls).forEach(([key, config]) => {
+ const folder =
+ folders.get(config.folder) ??
+ container.addFolder({
+ title: config.folder,
+ expanded: config.folder !== 'Runtime',
+ });
+ folders.set(config.folder, folder);
+
+ const settingKey = key as keyof GardenRuntimeSettings & string;
+ settings[settingKey] = normalizeNumber(settings[settingKey], config);
+ 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();
+ });
+ });
+ }
+
+ 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.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/menu-hider.ts b/src/page/menu-hider.ts
index 173a5b6..c4ba2c2 100644
--- a/src/page/menu-hider.ts
+++ b/src/page/menu-hider.ts
@@ -1,17 +1,68 @@
-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';
- public constructor(element: HTMLElement, shouldBeHidden: () => boolean) {
+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 timeToLive = MenuHider.DEFAULT_TIME_TO_LIVE;
+ private isHidden = false;
+
+ public constructor(
+ private readonly element: HTMLElement,
+ private readonly 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.element.style.opacity = shouldHide ? '0' : '1';
+ this.element.setAttribute('aria-hidden', String(shouldHide));
+ this.element.inert = shouldHide;
}
}
diff --git a/src/page/set-up-settings-page.ts b/src/page/set-up-settings-page.ts
deleted file mode 100644
index 8d63f3a..0000000
--- a/src/page/set-up-settings-page.ts
+++ /dev/null
@@ -1,129 +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 params = new URLSearchParams(window.location.search);
- const shouldShowAdvancedSettings = !isProduction && params.get('dev') !== '0';
-
- const sliders: Array> = [
- new SettingsSlider(settings, 'brushEffectDuration', {
- min: 0.5,
- max: 20,
- unit: 's',
- scaling: ValueScaling.Quadratic,
- }),
-
- ...(shouldShowAdvancedSettings
- ? [
- new SettingsSlider(settings, 'agentBudgetMax', {
- min: 1_000,
- max: maxAgentCount,
- scaling: ValueScaling.Quadratic,
- rounding: Math.round,
- }),
-
- new SettingsSlider(settings, 'spawnPerPixel', {
- min: 0.01,
- max: 1,
- }),
-
- 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, 'anisotropy', {
- min: 0,
- max: 1,
- }),
-
- new SettingsSlider(settings, 'brushSize', {
- min: 1,
- max: 60,
- }),
-
- new SettingsSlider(settings, 'clarity', {
- min: 0.00001,
- max: 1,
- }),
- ]
- : []),
-
- ...(shouldShowAdvancedSettings
- ? [
- new SettingsSlider(settings, 'renderSpeed', {
- min: 1,
- max: 10,
- rounding: Math.round,
- }),
- ]
- : []),
- ];
-
- 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-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.ts b/src/pipelines/agents/agent-generation/agent.ts
index b950f32..0a81207 100644
--- a/src/pipelines/agents/agent-generation/agent.ts
+++ b/src/pipelines/agents/agent-generation/agent.ts
@@ -3,7 +3,11 @@ import { vec2 } from 'gl-matrix';
export interface Agent {
position: vec2;
angle: number;
- generation: number;
+ colorIndex: number;
+ targetPosition: vec2;
+ targetAngle: number;
+ introDelay: 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..4f80aff 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 = 8;
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,108 @@ export class AgentPipeline {
public setParameters({
deltaTime,
- center,
- radius,
- brushTrailWeight,
moveSpeed,
turnSpeed,
sensorOffsetAngle,
sensorOffsetDistance,
- nextGenerationSensorOffsetDistance,
- currentGenerationAggression,
- nextGenerationAggression,
- nextGenerationSpeed,
- isNextGenerationOdd,
turnWhenLost,
individualTrailWeight,
- infectionProbability,
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;
+ 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 +191,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..55d4e70 100644
--- a/src/pipelines/agents/agent-settings.ts
+++ b/src/pipelines/agents/agent-settings.ts
@@ -1,11 +1,8 @@
export interface AgentSettings {
- brushTrailWeight: 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..527af63 100644
--- a/src/pipelines/agents/agent.wgsl
+++ b/src/pipelines/agents/agent.wgsl
@@ -1,37 +1,18 @@
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,
};
-
@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 +26,125 @@ 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 friendForward = dot(trailForward.rgb, channelMask);
+ let friendLeft = dot(trailLeft.rgb, channelMask);
+ let friendRight = dot(trailRight.rgb, channelMask);
- let agression = mix(settings.currentGenerationAggression, settings.nextGenerationAggression, isFromNextGeneration) + weightForward;
+ let sourceForward = dot(sourceForwardSample.rgb, channelMask);
+ let sourceLeft = dot(sourceLeftSample.rgb, channelMask);
+ let sourceRight = dot(sourceRightSample.rgb, channelMask);
- 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 weightForward = friendForward + sourceForward * 24.0;
+ let weightLeft = friendLeft + sourceLeft * 24.0;
+ let weightRight = friendRight + sourceRight * 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 sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, channelMask), 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 = dot(sourceBelow.rgb, channelMask);
+ 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 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..32c4539 100644
--- a/src/pipelines/brush/brush-settings.ts
+++ b/src/pipelines/brush/brush-settings.ts
@@ -1,4 +1,6 @@
export interface BrushSettings {
brushSize: 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 4f53890..9000a61 100644
--- a/src/pipelines/common-state/common-state.ts
+++ b/src/pipelines/common-state/common-state.ts
@@ -8,7 +8,7 @@ import { generateNoise } from '../../utils/graphics/noise';
export class CommonState {
private static readonly UNIFORM_COUNT = 4;
- private static readonly NOISE_TEXTURE_SIZE = 1024;
+ private static readonly NOISE_TEXTURE_SIZE = 2048;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
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 b1a924f..69875c4 100644
--- a/src/pipelines/diffusion/diffusion-pipeline.ts
+++ b/src/pipelines/diffusion/diffusion-pipeline.ts
@@ -1,3 +1,4 @@
+import { appConfig } from '../../config';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
@@ -8,8 +9,36 @@ 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 = 5;
+ private static readonly UNIFORM_COUNT = 4;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline;
@@ -18,10 +47,10 @@ export class DiffusionPipeline {
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,
@@ -57,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({
@@ -64,13 +98,13 @@ export class DiffusionPipeline {
decayRateTrails,
diffusionRateBrush,
decayRateBrush,
- anisotropy,
}: DiffusionSettings) {
- this.uniformValues[0] = 1 / diffusionRateTrails;
- this.uniformValues[1] = decayRateTrails / 1000;
- this.uniformValues[2] = 1 / diffusionRateBrush;
- this.uniformValues[3] = decayRateBrush / 1000;
- this.uniformValues[4] = anisotropy;
+ setDiffusionUniformValues(this.uniformValues, {
+ diffusionRateTrails,
+ decayRateTrails,
+ diffusionRateBrush,
+ decayRateBrush,
+ });
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
@@ -84,7 +118,7 @@ export class DiffusionPipeline {
trailMapIn: GPUTextureView,
trailMapOut: GPUTextureView
) {
- this.ensureBindGroupExists(trailMapIn);
+ const bindGroup = this.getBindGroup(trailMapIn);
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
@@ -101,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/wgsl-uniform-layout.test.ts b/src/pipelines/wgsl-uniform-layout.test.ts
new file mode 100644
index 0000000..8f6444d
--- /dev/null
+++ b/src/pipelines/wgsl-uniform-layout.test.ts
@@ -0,0 +1,193 @@
+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',
+ ],
+ });
+ 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..128bc4e 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,54 +1,38 @@
-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 { 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),
+ });
+
+ localStorage.setItem(appConfig.storage.vibeKey, vibe.id);
+
+ return activeVibe;
};
diff --git a/src/style/_control-dock.scss b/src/style/_control-dock.scss
index 6267c25..56213d9 100644
--- a/src/style/_control-dock.scss
+++ b/src/style/_control-dock.scss
@@ -1,7 +1,7 @@
html > body > aside.control-dock {
position: absolute;
left: 50%;
- bottom: calc(0.75rem + env(safe-area-inset-bottom));
+ bottom: env(safe-area-inset-bottom);
z-index: 4;
width: min(calc(100vw - 1rem), 980px);
transform: translateX(-50%);
diff --git a/src/style/_toolbar.scss b/src/style/_toolbar.scss
index 28ac9e2..74a8592 100644
--- a/src/style/_toolbar.scss
+++ b/src/style/_toolbar.scss
@@ -45,13 +45,11 @@ html > body > aside.control-dock > .toolbar-row {
min-width: 0;
min-height: 86px;
padding: 8px 9px;
- border: 1px solid rgb(255 255 255 / 10%);
+ border: 1px solid transparent;
border-radius: 10px;
- background: rgb(12 14 16 / 74%);
- backdrop-filter: blur(16px);
- box-shadow:
- 0 10px 30px rgb(0 0 0 / 22%),
- 0 1px 3px rgb(0 0 0 / 18%);
+ background: transparent;
+ backdrop-filter: none;
+ box-shadow: none;
}
> .vibe-button {
@@ -582,18 +580,19 @@ html > body > aside.control-dock > .toolbar-row {
flex: 1 1 auto;
min-width: 0;
gap: 8px;
- padding: 8px;
+ padding: 4px 8px;
> nav.buttons {
grid-area: nav;
justify-content: center;
gap: 2px;
- padding-top: 7px;
+ padding-top: 3px;
border-top: 1px solid rgb(255 255 255 / 12%);
> button {
width: 44px;
- height: 44px;
+ height: 38px;
+ min-height: 38px;
&::after {
width: 17px;
@@ -616,34 +615,102 @@ html > body > aside.control-dock > .toolbar-row {
padding: 2px 4px;
> .swatches {
- flex-wrap: wrap;
- justify-content: center;
- gap: 9px;
+ 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: 7px 8px;
+ padding: 4px 6px;
> .color-swatch {
+ grid-column: span 2;
width: 44px;
height: 44px;
}
> .eraser-size-control {
- width: clamp(104px, 31vw, 138px);
- height: 44px;
- flex-basis: clamp(104px, 31vw, 138px);
- padding: 0 9px;
+ 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 {
- width: clamp(104px, 31vw, 138px);
- height: 44px;
- flex-basis: clamp(104px, 31vw, 138px);
- padding: 0 9px;
+ grid-column: 4 / span 3;
+ justify-self: stretch;
+ width: 100%;
+ min-width: 0;
+ height: 42px;
+ flex-basis: auto;
+ padding: 0 8px;
&::before {
- right: 9px;
- left: 9px;
+ 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/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/graphics/cached-buffer-write.test.ts b/src/utils/graphics/cached-buffer-write.test.ts
new file mode 100644
index 0000000..3fb15b3
--- /dev/null
+++ b/src/utils/graphics/cached-buffer-write.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ createCachedFloat32BufferWrite,
+ writeFloat32BufferIfChanged,
+} from './cached-buffer-write';
+
+const createGpuWriteStub = () => {
+ const writeBuffer = vi.fn();
+ const device = {
+ queue: {
+ writeBuffer,
+ },
+ } as unknown as GPUDevice;
+
+ return { device, writeBuffer };
+};
+
+describe('cached float32 buffer writes', () => {
+ it('writes the first value set and skips unchanged values', () => {
+ const { device, writeBuffer } = createGpuWriteStub();
+ const buffer = {} as GPUBuffer;
+ const cache = createCachedFloat32BufferWrite(3);
+ const values = new Float32Array([1, 2, 3]);
+
+ expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(true);
+ expect(writeBuffer).toHaveBeenCalledTimes(1);
+ expect(writeBuffer).toHaveBeenLastCalledWith(buffer, 0, values);
+
+ expect(writeFloat32BufferIfChanged(device, buffer, values, cache)).toBe(false);
+ expect(writeBuffer).toHaveBeenCalledTimes(1);
+ });
+
+ it('writes again when any float changes', () => {
+ const { device, writeBuffer } = createGpuWriteStub();
+ const buffer = {} as GPUBuffer;
+ const cache = createCachedFloat32BufferWrite(3);
+
+ expect(
+ writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 3]), cache)
+ ).toBe(true);
+ expect(
+ writeFloat32BufferIfChanged(device, buffer, new Float32Array([1, 2, 4]), cache)
+ ).toBe(true);
+ expect(writeBuffer).toHaveBeenCalledTimes(2);
+ });
+
+ it('rejects cache length mismatches before writing', () => {
+ const { device, writeBuffer } = createGpuWriteStub();
+ const buffer = {} as GPUBuffer;
+ const cache = createCachedFloat32BufferWrite(2);
+
+ expect(() =>
+ writeFloat32BufferIfChanged(device, buffer, new Float32Array([1]), cache)
+ ).toThrow('Cached buffer write length mismatch');
+ expect(writeBuffer).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/utils/graphics/cached-buffer-write.ts b/src/utils/graphics/cached-buffer-write.ts
new file mode 100644
index 0000000..6216548
--- /dev/null
+++ b/src/utils/graphics/cached-buffer-write.ts
@@ -0,0 +1,36 @@
+export interface CachedFloat32BufferWrite {
+ hasValue: boolean;
+ previous: Float32Array;
+}
+
+export const createCachedFloat32BufferWrite = (
+ length: number
+): CachedFloat32BufferWrite => ({
+ hasValue: false,
+ previous: new Float32Array(length),
+});
+
+export const writeFloat32BufferIfChanged = (
+ device: GPUDevice,
+ buffer: GPUBuffer,
+ values: Float32Array,
+ cache: CachedFloat32BufferWrite
+): boolean => {
+ if (values.length !== cache.previous.length) {
+ throw new Error('Cached buffer write length mismatch');
+ }
+
+ let hasChanged = !cache.hasValue;
+ for (let i = 0; i < values.length && !hasChanged; i++) {
+ hasChanged = !Object.is(values[i], cache.previous[i]);
+ }
+
+ if (!hasChanged) {
+ return false;
+ }
+
+ cache.previous.set(values);
+ cache.hasValue = true;
+ device.queue.writeBuffer(buffer, 0, values);
+ return true;
+};
diff --git a/src/utils/persist.ts b/src/utils/persist.ts
deleted file mode 100644
index 4489458..0000000
--- a/src/utils/persist.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-export const persist = >(wrapee: T): T => {
- const keys = Object.keys(wrapee);
- keys.sort();
-
- const keysToShortKeys = Object.fromEntries(keys.map((key) => [key, key]));
-
- const params = new URLSearchParams(window.location.search);
- const newParams = new URLSearchParams();
- keys.forEach((key) => {
- if (params.has(keysToShortKeys[key])) {
- (wrapee as any)[key] = Number(params.get(keysToShortKeys[key]));
- newParams.set(keysToShortKeys[key], params.get(keysToShortKeys[key])!);
- }
- });
-
- window.history.replaceState(
- {},
- '',
- `${window.location.pathname}?${newParams.toString()}`
- );
-
- return new Proxy(wrapee, {
- set: (target, key: string, value: number) => {
- const params = new URLSearchParams(window.location.search);
-
- params.set(keysToShortKeys[key], value.toString());
-
- (target as any)[key] = value;
-
- window.history.replaceState(
- {},
- '',
- `${window.location.pathname}?${params.toString()}`
- );
-
- return true;
- },
- });
-};
diff --git a/src/vibes.test.ts b/src/vibes.test.ts
index 99b28cc..4d3d2a4 100644
--- a/src/vibes.test.ts
+++ b/src/vibes.test.ts
@@ -4,22 +4,12 @@ import { gardenAudioConfig } from './audio/garden-audio-config';
import { getInitialVibe, hexToRgb, VIBE_PRESETS } from './vibes';
const originalLocalStorage = globalThis.localStorage;
-const originalWindow = globalThis.window;
const setBrowserVibeState = ({
- search = '',
storedVibeId = null,
}: {
- search?: string;
storedVibeId?: string | null;
}) => {
- Object.defineProperty(globalThis, 'window', {
- configurable: true,
- value: {
- location: new URL(`https://garden.test/${search}`),
- },
- });
-
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
@@ -30,38 +20,22 @@ const setBrowserVibeState = ({
});
};
-describe('vibe URL selection', () => {
+describe('vibe selection', () => {
afterEach(() => {
- Object.defineProperty(globalThis, 'window', {
- configurable: true,
- value: originalWindow,
- });
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: originalLocalStorage,
});
});
- it('uses a valid vibe id from the URL before local storage', () => {
- setBrowserVibeState({
- search: '?vibe=moon-orchid',
- storedVibeId: 'candy-rain',
- });
-
- expect(getInitialVibe().id).toBe('moon-orchid');
- });
-
- it('uses a valid stored vibe id when the URL does not provide one', () => {
+ it('uses a valid stored vibe id', () => {
setBrowserVibeState({ storedVibeId: 'sunlit-moss' });
expect(getInitialVibe().id).toBe('sunlit-moss');
});
- it('falls back to the default preset for an unknown URL vibe id', () => {
- setBrowserVibeState({
- search: '?vibe=unknown',
- storedVibeId: 'sunlit-moss',
- });
+ it('falls back to the default preset for an unknown stored vibe id', () => {
+ setBrowserVibeState({ storedVibeId: 'unknown' });
expect(getInitialVibe()).toBe(VIBE_PRESETS[0]);
});
@@ -107,7 +81,6 @@ describe('vibe and audio config contract', () => {
expect(gardenAudioConfig.colorVoices).toHaveLength(3);
gardenAudioConfig.colorVoices.forEach((voice) => {
expect(Number.isFinite(voice.scaleDegreeOffset)).toBe(true);
- expect(Number.isFinite(voice.octaveOffset)).toBe(true);
expect(voice.velocityMultiplier).toBeGreaterThan(0);
expect(Math.abs(voice.panOffset)).toBeLessThanOrEqual(1);
});
diff --git a/src/vibes.ts b/src/vibes.ts
new file mode 100644
index 0000000..2c035cc
--- /dev/null
+++ b/src/vibes.ts
@@ -0,0 +1,23 @@
+import { appConfig, type VibePreset } from './config';
+
+export type { GardenVibeSettings, VibePreset } from './config';
+
+export const VIBE_PRESETS: Array = appConfig.vibes.presets;
+
+export const hexToRgb = (hex: string): [number, number, number] => {
+ const value = hex.replace('#', '');
+ return [
+ parseInt(value.slice(0, 2), 16) / 255,
+ parseInt(value.slice(2, 4), 16) / 255,
+ parseInt(value.slice(4, 6), 16) / 255,
+ ];
+};
+
+export const getInitialVibe = (): VibePreset => {
+ const id = localStorage.getItem(appConfig.storage.vibeKey);
+ return (
+ VIBE_PRESETS.find((vibe) => vibe.id === id) ??
+ VIBE_PRESETS.find((vibe) => vibe.id === appConfig.vibes.defaultVibeId) ??
+ VIBE_PRESETS[0]
+ );
+};
diff --git a/vite.config.ts b/vite.config.ts
index 56f50e8..795e630 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,3 +1,4 @@
+import basicSsl from '@vitejs/plugin-basic-ssl';
import browserslist from 'browserslist';
import { browserslistToTargets } from 'lightningcss';
import { viteSingleFile } from 'vite-plugin-singlefile';
@@ -5,8 +6,12 @@ import { defineConfig } from 'vitest/config';
const cssTargets = browserslistToTargets(browserslist());
-export default defineConfig({
- plugins: [viteSingleFile()],
+export default defineConfig(({ command }) => ({
+ base: command === 'build' ? './' : '/',
+ plugins: [
+ viteSingleFile({ useRecommendedBuildConfig: false }),
+ ...(command === 'serve' ? [basicSsl()] : []),
+ ],
css: {
transformer: 'lightningcss',
lightningcss: {
@@ -18,12 +23,14 @@ export default defineConfig({
cssCodeSplit: false,
cssMinify: 'lightningcss',
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
+ assetsDir: '',
},
server: {
open: true,
+ host: true,
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
-});
+}));