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'], }, -}); +}));