Optimise
All checks were successful
Check & deploy / build (pull_request) Successful in 1m51s

This commit is contained in:
Andras Schmelczer 2026-05-21 20:33:49 +01:00
parent 6bc125be1c
commit ed5a4379db
76 changed files with 1418 additions and 988 deletions

View file

@ -29,18 +29,15 @@ jobs:
npm ci npm ci
npx playwright install --with-deps chromium npx playwright install --with-deps chromium
- name: Lint
run: npm run lint:check
- name: Typecheck
run: |
npm run unused:check
npm run typecheck
npm run typecheck:e2e
- name: Test - name: Test
run: | run: |
npm run lint:check
npm run typecheck
npm run typecheck:e2e
npm test npm test
- name: Test E2E
run: |
npm run test:e2e npm run test:e2e
- name: Upload Playwright report - name: Upload Playwright report

View file

@ -9,6 +9,6 @@ Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
## Testing ## Testing
- `npm test` runs the Vitest unit suite. - `npm test` runs the Vitest unit suite.
- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium - `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright
smoke test. config builds the production bundle before serving it.
- `npx playwright install chromium` installs the local browser binary when needed. - `npx playwright install chromium` installs the local browser binary when needed.

View file

@ -1,7 +1,7 @@
import js from '@eslint/js'; import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier'; import prettierConfig from 'eslint-config-prettier';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ {

View file

@ -88,14 +88,14 @@
<div class="garden-prompt" aria-live="polite"></div> <div class="garden-prompt" aria-live="polite"></div>
<div class="loading-indicator" role="status"> <div class="loading-indicator" role="status">
<div class="splash"> <div class="splash" data-visible="true">
<h1 class="splash-title">Fleeting Garden</h1> <h1 class="splash-title">Fleeting Garden</h1>
<p class="splash-description"> <p class="splash-description">
Tend it while you can. The garden returns to weather either way. Tend it while you can. The garden returns to weather either way.
</p> </p>
<button class="start-button" type="button" disabled>Start</button> <button class="start-button" type="button" disabled>Start</button>
</div> </div>
<div class="loading-bar" hidden> <div class="loading-bar" data-visible="false" aria-hidden="true" inert>
<div class="loading-status">Starting up&hellip;</div> <div class="loading-status">Starting up&hellip;</div>
<div <div
class="loading-progress" class="loading-progress"
@ -160,7 +160,7 @@
<div class="toolbar-shell"> <div class="toolbar-shell">
<section class="garden-controls" aria-label="Garden controls"> <section class="garden-controls" aria-label="Garden controls">
<div class="swatches" aria-label="Drawing colours"> <div class="swatches" role="group" aria-label="Drawing colours">
<button <button
class="color-swatch" class="color-swatch"
aria-label="Draw colour 1" aria-label="Draw colour 1"
@ -193,24 +193,21 @@
<nav class="buttons" aria-label="App controls"> <nav class="buttons" aria-label="App controls">
<button <button
class="info" class="info"
data-control="info"
aria-label="About" aria-label="About"
aria-controls="info-panel" aria-controls="info-panel"
aria-expanded="false" aria-expanded="false"
title="About" title="About"
></button> ></button>
<button <button
class="maximize-full-screen" class="full-screen-toggle"
data-control="full-screen"
aria-label="Enter fullscreen" aria-label="Enter fullscreen"
title="Enter fullscreen" title="Enter fullscreen"
></button> ></button>
<button
class="minimize-full-screen"
aria-label="Exit fullscreen"
hidden
title="Exit fullscreen"
></button>
<button <button
class="settings" class="settings"
data-control="settings"
aria-label="Show config overlay" aria-label="Show config overlay"
aria-expanded="false" aria-expanded="false"
title="Show config overlay" title="Show config overlay"
@ -218,6 +215,7 @@
<div class="audio-control"> <div class="audio-control">
<button <button
class="sound" class="sound"
data-control="sound"
aria-label="Mute audio" aria-label="Mute audio"
aria-pressed="false" aria-pressed="false"
title="Mute audio" title="Mute audio"
@ -228,12 +226,14 @@
</div> </div>
<button <button
class="export-4k" class="export-4k"
data-control="export"
aria-label="Download internal buffer snapshot" aria-label="Download internal buffer snapshot"
title="Download internal buffer snapshot" title="Download internal buffer snapshot"
></button> ></button>
<span class="export-status" aria-live="polite"></span> <span class="export-status" aria-live="polite"></span>
<button <button
class="restart" class="restart"
data-control="restart"
aria-label="Restart simulation" aria-label="Restart simulation"
title="Restart simulation" title="Restart simulation"
></button> ></button>

View file

@ -8,15 +8,15 @@
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint:check": "eslint \"src/**/*.ts\" && npm run unused:check", "lint:check": "eslint . && npm run unused:check",
"lint:fix": "eslint --fix \"src/**/*.ts\"", "lint:fix": "eslint . --fix",
"format": "prettier --write \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", "format": "prettier --write \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"format:check": "prettier --check \"index.html\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"", "format:check": "prettier --check \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json", "typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
"test": "vitest run", "test": "vitest run",
"test:e2e": "npm run build && playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "npm run build && playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:watch": "vitest", "test:watch": "vitest",
"unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports", "unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
"generate-icons": "pwa-assets-generator" "generate-icons": "pwa-assets-generator"

View file

@ -13,6 +13,11 @@
"sizes": "any", "sizes": "any",
"type": "image/svg+xml" "type": "image/svg+xml"
}, },
{
"src": "pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{ {
"src": "pwa-192x192.png", "src": "pwa-192x192.png",
"sizes": "192x192", "sizes": "192x192",

View file

@ -22,12 +22,22 @@ export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
progression: Array<GardenAudioChord>; progression: Array<GardenAudioChord>;
} }
export const defaultGardenAudioVibeSettings: GardenAudioVibeSettings = {
idleIntensity: 0.08,
bpm: 74,
rampUpIntensity: 0.85,
rampUpTime: 0.08,
noteLength: 0.42,
notePitchOffset: 0,
brightness: 1,
};
export const createGardenAudioConfig = () => ({ export const createGardenAudioConfig = () => ({
masterVolume: DEFAULT_AUDIO_VOLUME, masterVolume: DEFAULT_AUDIO_VOLUME,
fadeInSeconds: 0.45, fadeInSeconds: 0.45,
updateRampSeconds: 0.08, updateRampSeconds: 0.08,
delay: { delay: {
timeSeconds: 0.46, timeSeconds: 0.405,
feedback: 0.12, feedback: 0.12,
wetGain: 0.044, wetGain: 0.044,
erasingActivity: 0.12, erasingActivity: 0.12,
@ -43,9 +53,9 @@ export const createGardenAudioConfig = () => ({
maxVoices: 24, maxVoices: 24,
gain: 0.48, gain: 0.48,
sustainSeconds: 0.42, sustainSeconds: 0.42,
sustainLevel: 0.32, sustainLevel: 0.26,
releaseSeconds: 0.24, releaseSeconds: 0.34,
lowpassHz: 7600, lowpassHz: 7000,
gainAttackSeconds: 0.006, gainAttackSeconds: 0.006,
lowpassMaxHz: 12000, lowpassMaxHz: 12000,
lowpassMinHz: 1400, lowpassMinHz: 1400,
@ -53,8 +63,8 @@ export const createGardenAudioConfig = () => ({
sustainVelocityRange: 0.55, sustainVelocityRange: 0.55,
}, },
rhythm: { rhythm: {
idleIntensity: 0.08, idleIntensity: defaultGardenAudioVibeSettings.idleIntensity,
bpm: 74, bpm: defaultGardenAudioVibeSettings.bpm,
stepsPerBeat: 4, stepsPerBeat: 4,
stepsPerBar: 16, stepsPerBar: 16,
sparseActivity: 0.055, sparseActivity: 0.055,
@ -69,9 +79,7 @@ export const createGardenAudioConfig = () => ({
pianoActivity: 0, pianoActivity: 0,
}, },
energy: { energy: {
attackSeconds: 0.08,
decaySeconds: 0.9, decaySeconds: 0.9,
immediateActivityScale: 0.85,
releaseSeconds: 1.15, releaseSeconds: 1.15,
strokeDecaySeconds: 0.32, strokeDecaySeconds: 0.32,
}, },

View file

@ -13,7 +13,10 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
const dy = stroke.to[1] - stroke.from[1]; const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy); const distancePixels = Math.hypot(dx, dy);
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0); const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1])); const normalizationPixels = Math.max(
1,
Math.min(stroke.canvasSize[0], stroke.canvasSize[1])
);
const normalizedDistance = distancePixels / normalizationPixels; const normalizedDistance = distancePixels / normalizationPixels;
return { return {

View file

@ -260,11 +260,10 @@ export class GardenAudio {
} }
public stroke(stroke: GardenAudioStroke): void { public stroke(stroke: GardenAudioStroke): void {
if (this.lifecycle === 'destroyed' || this.isMuted) { if (this.lifecycle !== 'started' || this.isMuted) {
return; return;
} }
this.start(stroke.vibe);
const context = this.graph.context; const context = this.graph.context;
if (!context) { if (!context) {
return; return;

View file

@ -207,8 +207,8 @@ export const generativePianoTuning: GenerativePianoTuning = {
}, },
{ {
midiMin: 62, midiMin: 62,
midiMax: 81, midiMax: 78,
preferredMidi: 72, preferredMidi: 70,
pan: 0.18, pan: 0.18,
scaleDegrees: [2, 3, 4, 6], scaleDegrees: [2, 3, 4, 6],
}, },
@ -254,10 +254,10 @@ export const generativePianoTuning: GenerativePianoTuning = {
expressionMultiplier: 0.9, expressionMultiplier: 0.9,
}, },
padChord: { padChord: {
velocities: [0.052, 0.041, 0.033], velocities: [0.046, 0.036, 0.029],
expressionVelocityWeight: 0.02, expressionVelocityWeight: 0.018,
delaySend: 0.008, delaySend: 0.008,
lowpassExpressionWeight: 0.28, lowpassExpressionWeight: 0.24,
}, },
supportNote: { supportNote: {
velocityBase: 0.105, velocityBase: 0.105,
@ -316,7 +316,7 @@ export const generativePianoTuning: GenerativePianoTuning = {
brushStream: { brushStream: {
inferredManiaThreshold: 0.82, inferredManiaThreshold: 0.82,
inferredManiaRange: 0.18, inferredManiaRange: 0.18,
registerManiaShift: 0.45, registerManiaShift: 0.3,
chordToneEverySteps: 4, chordToneEverySteps: 4,
durationBaseSeconds: 0.48, durationBaseSeconds: 0.48,
durationIntensitySeconds: 0.08, durationIntensitySeconds: 0.08,
@ -329,22 +329,22 @@ export const generativePianoTuning: GenerativePianoTuning = {
delaySendMin: 0.006, delaySendMin: 0.006,
delaySendMax: 0.032, delaySendMax: 0.032,
velocityBase: 0.1, velocityBase: 0.1,
velocityIntensityWeight: 0.13, velocityIntensityWeight: 0.1,
lowpassBaseExpression: 0.39, lowpassBaseExpression: 0.39,
lowpassIntensityWeight: 0.48, lowpassIntensityWeight: 0.48,
lowpassManiaWeight: 0.18, lowpassManiaWeight: 0.18,
intenseThreshold: 0.62, intenseThreshold: 0.68,
activeThreshold: 0.34, activeThreshold: 0.34,
}, },
brushStreamEcho: { brushStreamEcho: {
maniaThreshold: 0.86, maniaThreshold: 0.92,
stepModulo: 2, stepModulo: 3,
stepRemainder: 1, stepRemainder: 1,
intensityThreshold: 0.95, intensityThreshold: 0.95,
octaveSemitones: 12, octaveSemitones: 12,
maxMidi: 88, maxMidi: 84,
velocityBase: 0.045, velocityBase: 0.035,
velocityIntensityWeight: 0.05, velocityIntensityWeight: 0.04,
durationMinSeconds: 0.11, durationMinSeconds: 0.11,
durationScale: 0.68, durationScale: 0.68,
panScale: -0.75, panScale: -0.75,
@ -361,7 +361,7 @@ export const generativePianoTuning: GenerativePianoTuning = {
lowOffset: -1, lowOffset: -1,
}, },
registerBias: { registerBias: {
maniaShiftSemitones: 4, maniaShiftSemitones: 2,
midiMin: 36, midiMin: 36,
midiMaxForMin: 86, midiMaxForMin: 86,
minimumSpan: 4, minimumSpan: 4,
@ -375,7 +375,7 @@ export const generativePianoTuning: GenerativePianoTuning = {
lowpass: { lowpass: {
midiBase: 48, midiBase: 48,
midiRange: 33, midiRange: 33,
midiLiftHz: 720, midiLiftHz: 500,
expressionBase: 0.58, expressionBase: 0.58,
expressionWeight: 0.32, expressionWeight: 0.32,
}, },
@ -396,16 +396,16 @@ export const generativePianoTuning: GenerativePianoTuning = {
strokeAccentMinSteps: 12, strokeAccentMinSteps: 12,
strokeAccentThreshold: 0.58, strokeAccentThreshold: 0.58,
maxBrushPhraseLayers: 3, maxBrushPhraseLayers: 3,
maxBrushStreamNotesPerBar: 9, maxBrushStreamNotesPerBar: 7,
brushLayerBaseSeconds: 5.5, brushLayerBaseSeconds: 5.5,
brushLayerEnergySeconds: 2.5, brushLayerEnergySeconds: 2.5,
brushLayerMinIntensity: 0.12, brushLayerMinIntensity: 0.12,
brushStreamIdleIntervalBeats: 2, brushStreamIdleIntervalBeats: 2,
brushStreamActiveIntervalBeats: 1, brushStreamActiveIntervalBeats: 1,
brushStreamIntenseIntervalBeats: 0.5, brushStreamIntenseIntervalBeats: 0.75,
brushMotifMaxSteps: 8, brushMotifMaxSteps: 8,
brushMotifCanonDelaySeconds: 0.055, brushMotifCanonDelaySeconds: 0.055,
padDurationBarScale: 0.46, padDurationBarScale: 0.82,
}; };
export const styleVoices: [ export const styleVoices: [

View file

@ -196,7 +196,8 @@ export class GenerativePianoEngine {
if ( if (
this.isWaitingForGestureAccent && this.isWaitingForGestureAccent &&
now - this.lastGestureAccentAt >= generativePianoTuning.gestureAccentMinIntervalSeconds now - this.lastGestureAccentAt >=
generativePianoTuning.gestureAccentMinIntervalSeconds
) { ) {
this.recordTouchDown({ this.recordTouchDown({
vibe, vibe,
@ -554,7 +555,9 @@ export class GenerativePianoEngine {
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const degrees = this.rotate( const degrees = this.rotate(
pool.scaleDegrees, pool.scaleDegrees,
Math.round(strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier) Math.round(
strength * generativePianoTuning.gestureAccent.rotationStrengthMultiplier
)
); );
const midi = this.chooseMidi( const midi = this.chooseMidi(
@ -705,7 +708,9 @@ export class GenerativePianoEngine {
); );
layer.motifOffsets.push(this.getMotifOffset(strength)); layer.motifOffsets.push(this.getMotifOffset(strength));
if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) { if (layer.motifOffsets.length > generativePianoTuning.brushMotifMaxSteps) {
layer.motifOffsets = layer.motifOffsets.slice(-generativePianoTuning.brushMotifMaxSteps); layer.motifOffsets = layer.motifOffsets.slice(
-generativePianoTuning.brushMotifMaxSteps
);
} }
} }
@ -789,7 +794,9 @@ export class GenerativePianoEngine {
const chordIntervals = getChordIntervals(chord, false); const chordIntervals = getChordIntervals(chord, false);
const rootMidi = profile.rootMidi + chord.rootOffset; const rootMidi = profile.rootMidi + chord.rootOffset;
const useChordTone = const useChordTone =
this.brushStreamNoteIndex % generativePianoTuning.brushStream.chordToneEverySteps === 0; this.brushStreamNoteIndex %
generativePianoTuning.brushStream.chordToneEverySteps ===
0;
const source = useChordTone const source = useChordTone
? { ? {
baseMidi: rootMidi, baseMidi: rootMidi,
@ -897,7 +904,8 @@ export class GenerativePianoEngine {
layer.energy * layer.energy *
this.getBrushPhraseFade(layer, startTime) * this.getBrushPhraseFade(layer, startTime) *
(generativePianoTuning.brushPhrase.layerIntensityBase + (generativePianoTuning.brushPhrase.layerIntensityBase +
layer.maniaAmount * generativePianoTuning.brushPhrase.layerIntensityManiaWeight), layer.maniaAmount *
generativePianoTuning.brushPhrase.layerIntensityManiaWeight),
})); }));
const dominant = layerStates.reduce<{ const dominant = layerStates.reduce<{
layer: BrushPhraseLayer; layer: BrushPhraseLayer;
@ -1073,7 +1081,8 @@ export class GenerativePianoEngine {
} }
return ( return (
barIndex % generativePianoTuning.supportBarSpacing === generativePianoTuning.supportBarOffset barIndex % generativePianoTuning.supportBarSpacing ===
generativePianoTuning.supportBarOffset
); );
} }
@ -1136,7 +1145,8 @@ export class GenerativePianoEngine {
): number { ): number {
const midiLift = const midiLift =
clamp01( clamp01(
(midi - generativePianoTuning.lowpass.midiBase) / generativePianoTuning.lowpass.midiRange (midi - generativePianoTuning.lowpass.midiBase) /
generativePianoTuning.lowpass.midiRange
) * generativePianoTuning.lowpass.midiLiftHz; ) * generativePianoTuning.lowpass.midiLiftHz;
return clamp( return clamp(
this.config.piano.lowpassHz * this.config.piano.lowpassHz *

View file

@ -7,8 +7,6 @@ import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002; export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
type PianoLoadState = 'idle' | 'loading' | 'loaded';
interface ActivePianoVoice { interface ActivePianoVoice {
gain: GainNode; gain: GainNode;
source: AudioScheduledSourceNode; source: AudioScheduledSourceNode;
@ -27,7 +25,6 @@ const pianoSamplerTuning = {
}; };
export class PianoSampler { export class PianoSampler {
private loadState: PianoLoadState = 'idle';
private samples: Array<LoadedPianoSample> = []; private samples: Array<LoadedPianoSample> = [];
private activeVoices: Array<ActivePianoVoice> = []; private activeVoices: Array<ActivePianoVoice> = [];
@ -37,27 +34,19 @@ export class PianoSampler {
) {} ) {}
public load(context: BaseAudioContext): Promise<void> { public load(context: BaseAudioContext): Promise<void> {
if (this.loadState === 'loaded') { if (this.samples.length > 0) {
return Promise.resolve(); return Promise.resolve();
} }
const loadedSamples = getLoadedPianoSamples(); const loadedSamples = getLoadedPianoSamples();
if (loadedSamples) { if (loadedSamples) {
this.setSamples(loadedSamples); this.setSamples(loadedSamples);
this.loadState = 'loaded';
return Promise.resolve(); return Promise.resolve();
} }
this.loadState = 'loading'; return loadPianoSamples(context).then((samples) => {
return loadPianoSamples(context) this.setSamples(samples);
.then((samples) => { });
this.setSamples(samples);
this.loadState = 'loaded';
})
.catch((error) => {
this.loadState = 'idle';
throw error;
});
} }
public play({ public play({
@ -154,7 +143,6 @@ export class PianoSampler {
} }
public reset(): void { public reset(): void {
this.loadState = 'idle';
this.samples = []; this.samples = [];
this.activeVoices = []; this.activeVoices = [];
} }

View file

@ -5,6 +5,11 @@ import type { GardenAppConfig } from './config/types';
import { defaultVibeId, vibePresets } from './config/vibe-presets'; import { defaultVibeId, vibePresets } from './config/vibe-presets';
import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts'; import { APP_STORAGE_KEYS, DEFAULT_AUDIO_VOLUME } from './consts';
export {
normalizeNumberControlValue,
normalizeRuntimeSettings,
} from './config/normalize-runtime-settings';
export type { export type {
GardenAppConfig, GardenAppConfig,
GardenRuntimeSettings, GardenRuntimeSettings,
@ -107,8 +112,6 @@ export const appConfig = {
stroke: { stroke: {
densityMultiplier: 110, densityMultiplier: 110,
maxAgentCount: 2_400, maxAgentCount: 2_400,
minAgentCount: 140,
minSegmentLengthPx: 1,
}, },
}, },
storage: { storage: {

View file

@ -19,7 +19,7 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP); const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920; const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080; const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
const cssMegapixels = Math.max(cssWidth, 1) * Math.max(cssHeight, 1) / 1_000_000; const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
return Math.min( return Math.min(
INTERNAL_RENDER_AREA_BOUNDS.max, INTERNAL_RENDER_AREA_BOUNDS.max,
Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels) Math.max(INTERNAL_RENDER_AREA_BOUNDS.min, dpr * dpr * cssMegapixels)

View file

@ -0,0 +1,46 @@
import type {
GardenAppConfig,
GardenRuntimeSettings,
NumberControlConfig,
} from './types';
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
export const normalizeNumberControlValue = (
value: number,
config: NumberControlConfig
): number => {
if (config.options) {
const optionValues = Object.values(config.options);
if (optionValues.includes(value)) {
return value;
}
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
}
const min = config.min ?? Number.NEGATIVE_INFINITY;
const max = config.max ?? Number.POSITIVE_INFINITY;
const fallbackValue = config.min ?? 0;
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
const clampedValue = Math.min(max, Math.max(min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
};
export const normalizeRuntimeSettings = (
settings: GardenRuntimeSettings,
controls: RuntimeSettingControls
): GardenRuntimeSettings => {
const normalized = { ...settings };
(
Object.entries(controls) as Array<
[keyof GardenRuntimeSettings, NumberControlConfig | undefined]
>
).forEach(([key, config]) => {
if (config) {
normalized[key] = normalizeNumberControlValue(normalized[key], config);
}
});
return normalized;
};

View file

@ -6,15 +6,15 @@ const formatRadiansAsDegrees = (value: number): string =>
`${Math.round((value * 180) / Math.PI)} deg`; `${Math.round((value * 180) / Math.PI)} deg`;
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = { export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('Primary Follows Primary'), color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Primary Follows Secondary'), color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Primary Follows Accent'), color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
color2ToColor1: colorInteractionControl('Secondary Follows Primary'), color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
color2ToColor2: colorInteractionControl('Secondary Follows Secondary'), color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
color2ToColor3: colorInteractionControl('Secondary Follows Accent'), color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
color3ToColor1: colorInteractionControl('Accent Follows Primary'), color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
color3ToColor2: colorInteractionControl('Accent Follows Secondary'), color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
color3ToColor3: colorInteractionControl('Accent Follows Accent'), color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
brushSize: { brushSize: {
folder: 'Brush', folder: 'Brush',
@ -25,7 +25,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
}, },
spawnPerPixel: { spawnPerPixel: {
folder: 'Brush', folder: 'Brush',
label: 'Agent Density', label: 'Density',
min: 0.01, min: 0.01,
max: 1, max: 1,
step: 0.001, step: 0.001,
@ -39,28 +39,28 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
step: 0.01, step: 0.01,
}, },
sensorOffsetDistance: { sensorOffsetDistance: {
folder: 'Agents', folder: 'Movement',
label: 'Sensor Reach', label: 'Sensor Reach',
min: 0, min: 0,
max: 200, max: 200,
step: 1, step: 1,
}, },
moveSpeed: { moveSpeed: {
folder: 'Agents', folder: 'Movement',
label: 'Travel Speed', label: 'Travel Speed',
min: 10, min: 10,
max: 500, max: 500,
step: 1, step: 1,
}, },
turnSpeed: { turnSpeed: {
folder: 'Agents', folder: 'Movement',
label: 'Turning Speed', label: 'Turning Speed',
min: 1, min: 1,
max: 200, max: 200,
step: 1, step: 1,
}, },
forwardRotationScale: { forwardRotationScale: {
folder: 'Agents', folder: 'Movement',
format: formatPercent, format: formatPercent,
label: 'Forward Focus', label: 'Forward Focus',
min: 0, min: 0,
@ -68,21 +68,21 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
step: 0.01, step: 0.01,
}, },
turnWhenLost: { turnWhenLost: {
folder: 'Agents', folder: 'Movement',
label: 'Wander Turn', label: 'Wander Turn',
min: 0, min: 0,
max: 6.28, max: 6.28,
step: 0.01, step: 0.01,
}, },
individualTrailWeight: { individualTrailWeight: {
folder: 'Agents', folder: 'Movement',
label: 'Trail Strength', label: 'Trail Strength',
min: 0, min: 0,
max: 1, max: 1,
step: 0.001, step: 0.001,
}, },
decayRateTrails: { decayRateTrails: {
folder: 'Agents', folder: 'Movement',
label: 'Trail Fade', label: 'Trail Fade',
min: 800, min: 800,
max: 1000, max: 1000,
@ -107,7 +107,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
maxAgentCount: { maxAgentCount: {
folder: 'Performance', folder: 'Performance',
integer: true, integer: true,
label: 'Agent Limit', label: 'Population Limit',
min: 0, min: 0,
step: 10_000, step: 10_000,
}, },

View file

@ -22,6 +22,7 @@ export interface NumberControlConfig {
export type GardenRuntimeSettings = { export type GardenRuntimeSettings = {
adaptiveCapInitial: number; adaptiveCapInitial: number;
adaptiveCapMin: number; adaptiveCapMin: number;
backgroundGrainStrength: number;
brushCurveResolution: number; brushCurveResolution: number;
brushCurveMinBrushRadius: number; brushCurveMinBrushRadius: number;
brushCurveMinSegmentSpacing: number; brushCurveMinSegmentSpacing: number;
@ -70,12 +71,14 @@ type GardenDefaultSettings = Omit<
>; >;
export enum VibeId { export enum VibeId {
CandyRain = 'candy-rain', AuroraMycelium = 'aurora-mycelium',
SunlitMoss = 'sunlit-moss', EmberCircuit = 'ember-circuit',
CoralTide = 'coral-tide', VelvetObservatory = 'velvet-observatory',
MoonOrchid = 'moon-orchid', LichenSignal = 'lichen-signal',
PeachNeon = 'peach-neon', UltravioletSiren = 'ultraviolet-siren',
FrostBloom = 'frost-bloom', TidepoolLantern = 'tidepool-lantern',
PaperLanternFog = 'paper-lantern-fog',
ChromePollen = 'chrome-pollen',
} }
export interface VibePreset { export interface VibePreset {
@ -181,8 +184,6 @@ export interface GardenAppConfig {
stroke: { stroke: {
densityMultiplier: number; densityMultiplier: number;
maxAgentCount: number; maxAgentCount: number;
minAgentCount: number;
minSegmentLengthPx: number;
}; };
}; };
storage: { storage: {

View file

@ -1,166 +1,255 @@
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config';
import { VibeId, type VibePreset } from './types'; import { VibeId, type VibePreset } from './types';
const defaultAudioSettings = { export const defaultVibeId = VibeId.AuroraMycelium;
idleIntensity: 0.08,
bpm: 74,
rampUpIntensity: 0.85,
rampUpTime: 0.08,
noteLength: 0.42,
notePitchOffset: 0,
} satisfies Omit<GardenAudioVibeSettings, 'brightness'>;
export const defaultVibeId = VibeId.CandyRain;
export const vibePresets: Array<VibePreset> = [ export const vibePresets: Array<VibePreset> = [
{ {
id: VibeId.CandyRain, id: VibeId.AuroraMycelium,
name: 'Candy Rain', name: 'Aurora Mycelium',
colors: [ colors: [
[255, 93, 162], [78, 255, 176],
[54, 215, 208], [154, 99, 255],
[255, 216, 77], [169, 238, 255],
], ],
backgroundColor: [16, 21, 31], backgroundColor: [6, 13, 22],
settings: { settings: {
backgroundGrainStrength: 0.018, backgroundGrainStrength: 0.016,
brushSize: 14, brushSize: 20,
clarity: 0.62, clarity: 0.52,
decayRateTrails: 965, decayRateTrails: 988,
individualTrailWeight: 0.07, individualTrailWeight: 0.085,
moveSpeed: 82, moveSpeed: 54,
sensorOffsetDistance: 38, sensorOffsetDistance: 72,
spawnPerPixel: 0.22, spawnPerPixel: 0.13,
turnSpeed: 58, turnSpeed: 35,
}, },
audio: { audio: {
...defaultAudioSettings, ...defaultGardenAudioVibeSettings,
brightness: 1.04, idleIntensity: 0.12,
bpm: 60,
rampUpIntensity: 0.7,
rampUpTime: 0.14,
noteLength: 0.86,
notePitchOffset: -2,
brightness: 0.84,
}, },
}, },
{ {
id: VibeId.SunlitMoss, id: VibeId.EmberCircuit,
name: 'Sunlit Moss', name: 'Ember Circuit',
colors: [ colors: [
[131, 212, 131], [255, 95, 38],
[246, 215, 107], [255, 43, 132],
[94, 193, 161], [43, 219, 255],
], ],
backgroundColor: [23, 32, 22], backgroundColor: [17, 10, 8],
settings: { settings: {
backgroundGrainStrength: 0.014, backgroundGrainStrength: 0.03,
brushSize: 16, brushSize: 8,
clarity: 0.68, clarity: 0.82,
decayRateTrails: 975, decayRateTrails: 918,
individualTrailWeight: 0.06, individualTrailWeight: 0.04,
moveSpeed: 70, moveSpeed: 150,
sensorOffsetDistance: 46, sensorOffsetDistance: 24,
spawnPerPixel: 0.18, spawnPerPixel: 0.31,
turnSpeed: 44, turnSpeed: 130,
}, },
audio: { audio: {
...defaultAudioSettings, ...defaultGardenAudioVibeSettings,
brightness: 0.92, idleIntensity: 0.03,
bpm: 124,
rampUpIntensity: 1.35,
rampUpTime: 0.04,
noteLength: 0.18,
notePitchOffset: 7,
brightness: 1.34,
}, },
}, },
{ {
id: VibeId.CoralTide, id: VibeId.VelvetObservatory,
name: 'Coral Tide', name: 'Velvet Observatory',
colors: [ colors: [
[255, 127, 110], [72, 98, 255],
[64, 184, 255], [255, 89, 176],
[244, 240, 166], [235, 236, 255],
], ],
backgroundColor: [15, 24, 34], backgroundColor: [7, 8, 20],
settings: { settings: {
backgroundGrainStrength: 0.022, backgroundGrainStrength: 0.01,
brushSize: 13, brushSize: 24,
clarity: 0.58, clarity: 0.45,
decayRateTrails: 955, decayRateTrails: 992,
individualTrailWeight: 0.055, individualTrailWeight: 0.095,
moveSpeed: 90, moveSpeed: 45,
sensorOffsetDistance: 35, sensorOffsetDistance: 86,
spawnPerPixel: 0.25, spawnPerPixel: 0.1,
turnSpeed: 62, turnSpeed: 24,
}, },
audio: { audio: {
...defaultAudioSettings, ...defaultGardenAudioVibeSettings,
brightness: 1, idleIntensity: 0.14,
bpm: 56,
rampUpIntensity: 0.6,
rampUpTime: 0.16,
noteLength: 1.15,
notePitchOffset: -5,
brightness: 0.72,
}, },
}, },
{ {
id: VibeId.MoonOrchid, id: VibeId.LichenSignal,
name: 'Moon Orchid', name: 'Lichen Signal',
colors: [ colors: [
[201, 147, 255], [174, 205, 91],
[125, 216, 255], [71, 162, 126],
[240, 244, 255], [229, 117, 71],
], ],
backgroundColor: [20, 18, 29], backgroundColor: [18, 24, 17],
settings: { settings: {
backgroundGrainStrength: 0.018, backgroundGrainStrength: 0.028,
brushSize: 12, brushSize: 17,
clarity: 0.64, clarity: 0.66,
decayRateTrails: 968, decayRateTrails: 974,
individualTrailWeight: 0.065, individualTrailWeight: 0.065,
moveSpeed: 76, moveSpeed: 68,
sensorOffsetDistance: 42, sensorOffsetDistance: 52,
spawnPerPixel: 0.2, spawnPerPixel: 0.19,
turnSpeed: 52, turnSpeed: 38,
}, },
audio: { audio: {
...defaultAudioSettings, ...defaultGardenAudioVibeSettings,
brightness: 0.9, idleIntensity: 0.1,
bpm: 68,
rampUpIntensity: 0.8,
rampUpTime: 0.1,
noteLength: 0.62,
notePitchOffset: -3,
brightness: 0.82,
}, },
}, },
{ {
id: VibeId.PeachNeon, id: VibeId.UltravioletSiren,
name: 'Peach Neon', name: 'Ultraviolet Siren',
colors: [ colors: [
[255, 155, 115], [184, 75, 255],
[91, 240, 169], [0, 224, 255],
[110, 168, 255], [214, 255, 72],
], ],
backgroundColor: [25, 23, 22], backgroundColor: [13, 9, 31],
settings: { settings: {
backgroundGrainStrength: 0.024, backgroundGrainStrength: 0.02,
brushSize: 15, brushSize: 11,
clarity: 0.55, clarity: 0.72,
decayRateTrails: 948, decayRateTrails: 946,
individualTrailWeight: 0.05, individualTrailWeight: 0.052,
moveSpeed: 96, moveSpeed: 118,
sensorOffsetDistance: 32, sensorOffsetDistance: 30,
spawnPerPixel: 0.24, spawnPerPixel: 0.28,
turnSpeed: 70, turnSpeed: 96,
}, },
audio: { audio: {
...defaultAudioSettings, ...defaultGardenAudioVibeSettings,
brightness: 1.08, idleIntensity: 0.04,
bpm: 112,
rampUpIntensity: 1.2,
rampUpTime: 0.05,
noteLength: 0.25,
notePitchOffset: 5,
brightness: 1.22,
}, },
}, },
{ {
id: VibeId.FrostBloom, id: VibeId.TidepoolLantern,
name: 'Frost Bloom', name: 'Tidepool Lantern',
colors: [ colors: [
[180, 247, 255], [30, 219, 194],
[158, 200, 255], [61, 118, 255],
[255, 184, 210], [255, 191, 91],
], ],
backgroundColor: [16, 24, 32], backgroundColor: [5, 20, 28],
settings: {
backgroundGrainStrength: 0.018,
brushSize: 15,
clarity: 0.6,
decayRateTrails: 963,
individualTrailWeight: 0.058,
moveSpeed: 88,
sensorOffsetDistance: 44,
spawnPerPixel: 0.22,
turnSpeed: 60,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.08,
bpm: 82,
rampUpIntensity: 0.95,
rampUpTime: 0.08,
noteLength: 0.48,
notePitchOffset: 0,
brightness: 0.98,
},
},
{
id: VibeId.PaperLanternFog,
name: 'Paper Lantern Fog',
colors: [
[255, 174, 104],
[242, 102, 107],
[132, 211, 185],
],
backgroundColor: [31, 23, 20],
settings: {
backgroundGrainStrength: 0.036,
brushSize: 22,
clarity: 0.5,
decayRateTrails: 984,
individualTrailWeight: 0.08,
moveSpeed: 56,
sensorOffsetDistance: 64,
spawnPerPixel: 0.14,
turnSpeed: 32,
},
audio: {
...defaultGardenAudioVibeSettings,
idleIntensity: 0.13,
bpm: 64,
rampUpIntensity: 0.72,
rampUpTime: 0.12,
noteLength: 0.9,
notePitchOffset: -4,
brightness: 0.76,
},
},
{
id: VibeId.ChromePollen,
name: 'Chrome Pollen',
colors: [
[235, 255, 238],
[255, 214, 48],
[77, 240, 157],
],
backgroundColor: [9, 13, 12],
settings: { settings: {
backgroundGrainStrength: 0.012, backgroundGrainStrength: 0.012,
brushSize: 18, brushSize: 10,
clarity: 0.7, clarity: 0.9,
decayRateTrails: 982, decayRateTrails: 935,
individualTrailWeight: 0.075, individualTrailWeight: 0.045,
moveSpeed: 62, moveSpeed: 104,
sensorOffsetDistance: 52, sensorOffsetDistance: 36,
spawnPerPixel: 0.16, spawnPerPixel: 0.24,
turnSpeed: 40, turnSpeed: 78,
}, },
audio: { audio: {
...defaultAudioSettings, ...defaultGardenAudioVibeSettings,
brightness: 0.88, idleIntensity: 0.05,
bpm: 96,
rampUpIntensity: 1.05,
rampUpTime: 0.07,
noteLength: 0.3,
notePitchOffset: 3,
brightness: 1.18,
}, },
}, },
]; ];

View file

@ -1,9 +1,6 @@
export const ENABLED_FLAG_VALUE = '1'; export const ENABLED_FLAG_VALUE = '1';
export const DISABLED_FLAG_VALUE = '0'; export const DISABLED_FLAG_VALUE = '0';
export const UNIT_INTERVAL_INPUT_MIN = '0';
export const UNIT_INTERVAL_INPUT_MAX = '1';
export const DEFAULT_AUDIO_VOLUME = 0.5; export const DEFAULT_AUDIO_VOLUME = 0.5;
export const APP_STORAGE_KEYS = { export const APP_STORAGE_KEYS = {

View file

@ -0,0 +1,166 @@
import { vec2 } from 'gl-matrix';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appConfig } from '../config';
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
import { settings } from '../settings';
import { AgentPopulation } from './agent-population';
import { type FramePerformance } from './frame-performance';
const originalSettings = {
brushSize: settings.brushSize,
maxAgentCount: settings.maxAgentCount,
selectedColorIndex: settings.selectedColorIndex,
spawnPerPixel: settings.spawnPerPixel,
strokeAngleJitterRadians: settings.strokeAngleJitterRadians,
};
class RecordingAgentGenerationPipeline {
public readonly writtenAgentCounts: Array<number> = [];
public readonly writtenAgentOffsets: Array<number> = [];
public readonly writtenBatches: Array<Float32Array> = [];
public readonly maxSupportedAgentCount = 1_000_000;
public maxAgentCount = 1_000_000;
private compactResolver: ((compactedAgentCount: number) => void) | null = null;
public ensureMaxAgentCount(requestedMaxAgentCount: number): number {
this.maxAgentCount = Math.max(this.maxAgentCount, requestedMaxAgentCount);
return this.maxAgentCount;
}
public writeAgents(agentOffset: number, data: Float32Array): void {
this.writtenAgentOffsets.push(agentOffset);
this.writtenAgentCounts.push(data.length / AGENT_FLOAT_COUNT);
this.writtenBatches.push(data.slice());
}
public compactAgents(): Promise<number> {
return new Promise((resolve) => {
this.compactResolver = resolve;
});
}
public finishCompaction(compactedAgentCount: number): void {
this.compactResolver?.(compactedAgentCount);
this.compactResolver = null;
}
}
const framePerformance = {
adaptiveCapDecreaseAgents: 0,
adaptiveCapInitial: 1_000_000,
adaptiveCapMin: 0,
hasAdaptiveCapHeadroom: true,
} as FramePerformance;
const createPopulation = (): {
pipeline: RecordingAgentGenerationPipeline;
population: AgentPopulation;
} => {
const pipeline = new RecordingAgentGenerationPipeline();
const population = new AgentPopulation(
pipeline as unknown as AgentGenerationPipeline,
0,
() => 1,
framePerformance
);
population.beginStroke();
return { pipeline, population };
};
const setSpawnRate = (agentsPerPixel: number): void => {
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
};
describe('AgentPopulation stroke spawning', () => {
beforeEach(() => {
settings.brushSize = 0;
settings.maxAgentCount = 1_000_000;
settings.selectedColorIndex = 0;
settings.strokeAngleJitterRadians = 0;
setSpawnRate(1);
});
afterEach(() => {
Object.assign(settings, originalSettings);
});
it('spawns the same count for the same stroke length regardless of segmentation', () => {
const segmented = createPopulation();
for (let x = 0; x < 10; x++) {
segmented.population.spawnStrokeAgents(
vec2.fromValues(x, 0),
vec2.fromValues(x + 1, 0)
);
}
const singleSegment = createPopulation();
singleSegment.population.spawnStrokeAgents(
vec2.fromValues(0, 0),
vec2.fromValues(10, 0)
);
expect(segmented.population.activeAgentCount).toBe(10);
expect(singleSegment.population.activeAgentCount).toBe(10);
});
it('carries fractional spawn budget within a stroke', () => {
setSpawnRate(0.5);
const { population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(1, 0));
expect(population.activeAgentCount).toBe(0);
population.spawnStrokeAgents(vec2.fromValues(1, 0), vec2.fromValues(2, 0));
expect(population.activeAgentCount).toBe(1);
population.spawnStrokeAgents(vec2.fromValues(2, 0), vec2.fromValues(3, 0));
expect(population.activeAgentCount).toBe(1);
population.spawnStrokeAgents(vec2.fromValues(3, 0), vec2.fromValues(4, 0));
expect(population.activeAgentCount).toBe(2);
});
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
const { pipeline, population } = createPopulation();
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
const expectedAgentCount = batchCapacity + 10;
population.spawnStrokeAgents(
vec2.fromValues(0, 0),
vec2.fromValues(expectedAgentCount, 0)
);
expect(population.activeAgentCount).toBe(expectedAgentCount);
expect(pipeline.writtenAgentCounts).toEqual([batchCapacity, 10]);
});
it('spawns agents in the movement direction', () => {
const { pipeline, population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(3, 0));
expect(population.activeAgentCount).toBe(3);
expect(pipeline.writtenBatches[0][2]).toBe(0);
});
it('queues stroke writes while async compaction is in flight', async () => {
const { pipeline, population } = createPopulation();
population.spawnStrokeAgents(vec2.fromValues(0, 0), vec2.fromValues(10, 0));
population.requestCompactionAfterErase();
population.compactAfterErase(false);
population.spawnStrokeAgents(vec2.fromValues(10, 0), vec2.fromValues(15, 0));
expect(population.activeAgentCount).toBe(10);
expect(pipeline.writtenAgentCounts).toEqual([10]);
pipeline.finishCompaction(6);
await population.waitForCompaction();
expect(population.activeAgentCount).toBe(11);
expect(pipeline.writtenAgentOffsets).toEqual([0, 6]);
expect(pipeline.writtenAgentCounts).toEqual([10, 5]);
});
});

View file

@ -1,10 +1,8 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
AGENT_FLOAT_COUNT, import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
AgentGenerationPipeline,
} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline'; import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings'; import { settings } from '../settings';
import type { FramePerformance } from './frame-performance'; import type { FramePerformance } from './frame-performance';
@ -20,8 +18,8 @@ export class AgentPopulation {
private shouldCompactAfterErase = false; private shouldCompactAfterErase = false;
private isCompacting = false; private isCompacting = false;
private pendingCompaction: Promise<void> | null = null; private pendingCompaction: Promise<void> | null = null;
// Highest active slot written while async compaction is running. private readonly queuedAgentBatches: Array<Float32Array> = [];
private postCompactionWriteEnd = 0; private pendingStrokeAgentCount = 0;
private readonly strokeAgentData = new Float32Array( private readonly strokeAgentData = new Float32Array(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
); );
@ -64,16 +62,20 @@ export class AgentPopulation {
} }
this.pipeline.writeAgents(0, data); this.pipeline.writeAgents(0, data);
this.markPostCompactionWrite(0, data.length / AGENT_FLOAT_COUNT);
this.activeCount = data.length / AGENT_FLOAT_COUNT; this.activeCount = data.length / AGENT_FLOAT_COUNT;
this.replacementCursor = 0; this.replacementCursor = 0;
} }
public onVibeChanged(): void { public onVibeChanged(): void {
this.pendingStrokeAgentCount = 0;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget(); this.trimActiveCountToBudget();
} }
public beginStroke(): void {
this.pendingStrokeAgentCount = 0;
}
public resizeAgents(scale: vec2): void { public resizeAgents(scale: vec2): void {
this.pipeline.resizeAgents(this.activeCount, scale); this.pipeline.resizeAgents(this.activeCount, scale);
} }
@ -93,17 +95,13 @@ export class AgentPopulation {
} }
this.isCompacting = true; this.isCompacting = true;
this.postCompactionWriteEnd = 0;
this.pendingCompaction = this.pipeline this.pendingCompaction = this.pipeline
.compactAgents(this.activeCount) .compactAgents(this.activeCount)
.then((compactedAgentCount) => { .then((compactedAgentCount) => {
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount) const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
? Math.max(0, Math.floor(compactedAgentCount)) ? Math.max(0, Math.floor(compactedAgentCount))
: 0; : 0;
this.activeCount = Math.min( this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount);
this.activeCount,
Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
);
this.clampReplacementCursor(); this.clampReplacementCursor();
this.trimActiveCountToBudget(); this.trimActiveCountToBudget();
}) })
@ -113,7 +111,7 @@ export class AgentPopulation {
.finally(() => { .finally(() => {
this.isCompacting = false; this.isCompacting = false;
this.pendingCompaction = null; this.pendingCompaction = null;
this.postCompactionWriteEnd = 0; this.flushQueuedAgentBatches();
}); });
} }
@ -144,40 +142,86 @@ export class AgentPopulation {
public spawnStrokeAgents(from: vec2, to: vec2): void { public spawnStrokeAgents(from: vec2, to: vec2): void {
const deltaX = to[0] - from[0]; const deltaX = to[0] - from[0];
const deltaY = to[1] - from[1]; const deltaY = to[1] - from[1];
const length = Math.max( const length = Math.hypot(deltaX, deltaY);
appConfig.simulation.stroke.minSegmentLengthPx, const spawnRate = getStrokeSpawnRate();
Math.hypot(deltaX, deltaY) if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) {
); return;
const count = Math.max( }
appConfig.simulation.stroke.minAgentCount,
Math.min( const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount;
appConfig.simulation.stroke.maxAgentCount, if (!Number.isFinite(expectedAgentCount)) {
this.strokeAgentData.length / AGENT_FLOAT_COUNT, this.pendingStrokeAgentCount = 0;
Math.ceil( return;
length * settings.spawnPerPixel * appConfig.simulation.stroke.densityMultiplier }
)
) const count = Math.floor(expectedAgentCount);
); this.pendingStrokeAgentCount = expectedAgentCount - count;
if (count <= 0) {
return;
}
const baseAngle = Math.atan2(deltaY, deltaX); const baseAngle = Math.atan2(deltaY, deltaX);
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio()); const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
if (batchCapacity <= 0) {
return;
}
for (let i = 0; i < count; i++) { for (let written = 0; written < count; written += batchCapacity) {
const t = count === 1 ? 1 : i / (count - 1); const batchCount = Math.min(batchCapacity, count - written);
this.populateStrokeAgentBatch({
baseAngle,
batchCount,
from,
spread,
to,
totalCount: count,
written,
});
this.writeAgentBatch(
this.strokeAgentData.subarray(0, batchCount * AGENT_FLOAT_COUNT)
);
}
}
private populateStrokeAgentBatch({
baseAngle,
batchCount,
from,
spread,
to,
totalCount,
written,
}: {
baseAngle: number;
batchCount: number;
from: vec2;
spread: number;
to: vec2;
totalCount: number;
written: number;
}): void {
for (let i = 0; i < batchCount; i++) {
const agentIndex = written + i;
const t = totalCount === 1 ? 0.5 : agentIndex / (totalCount - 1);
const x = from[0] + (to[0] - from[0]) * t; const x = from[0] + (to[0] - from[0]) * t;
const y = from[1] + (to[1] - from[1]) * t; const y = from[1] + (to[1] - from[1]) * t;
const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians; const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
const base = i * AGENT_FLOAT_COUNT; const positionX = x + (Math.random() - 0.5) * spread;
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread; const positionY = y + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
this.strokeAgentData[base + 2] = angle;
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
this.strokeAgentData[base + 4] = -1;
this.strokeAgentData[base + 5] = -1;
this.strokeAgentData[base + 6] = angle;
this.strokeAgentData[base + 7] = 0;
}
this.writeAgentBatch(this.strokeAgentData.subarray(0, count * AGENT_FLOAT_COUNT)); writeAgentValues(this.strokeAgentData, i, {
positionX,
positionY,
angle,
colorIndex: settings.selectedColorIndex,
targetPositionX: -1,
targetPositionY: -1,
targetAngle: angle,
introDelay: 0,
});
}
} }
private writeAgentBatch(data: Float32Array): void { private writeAgentBatch(data: Float32Array): void {
@ -185,6 +229,11 @@ export class AgentPopulation {
return; return;
} }
if (this.isCompacting) {
this.queuedAgentBatches.push(data.slice());
return;
}
const count = data.length / AGENT_FLOAT_COUNT; const count = data.length / AGENT_FLOAT_COUNT;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap); this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count); this.expandAdaptiveCapForPendingAgents(count);
@ -197,7 +246,6 @@ export class AgentPopulation {
this.activeCount, this.activeCount,
data.subarray(0, appendCount * AGENT_FLOAT_COUNT) data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
); );
this.markPostCompactionWrite(this.activeCount, appendCount);
this.activeCount += appendCount; this.activeCount += appendCount;
} }
@ -216,22 +264,15 @@ export class AgentPopulation {
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT (sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
) )
); );
this.markPostCompactionWrite(targetAgentOffset, chunkAgentCount);
sourceAgentOffset += chunkAgentCount; sourceAgentOffset += chunkAgentCount;
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount; this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
} }
} }
private markPostCompactionWrite(agentOffset: number, agentCount: number): void { private flushQueuedAgentBatches(): void {
if (!this.isCompacting || agentCount <= 0) { const batches = this.queuedAgentBatches.splice(0);
return; batches.forEach((batch) => this.writeAgentBatch(batch));
}
this.postCompactionWriteEnd = Math.max(
this.postCompactionWriteEnd,
Math.ceil(agentOffset + agentCount)
);
} }
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void { private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
@ -279,3 +320,13 @@ export class AgentPopulation {
); );
} }
} }
const getStrokeSpawnRate = (): number => {
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
? settings.spawnPerPixel
: 0;
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
? appConfig.simulation.stroke.densityMultiplier
: 0;
return Math.max(0, spawnPerPixel * densityMultiplier);
};

View file

@ -5,6 +5,7 @@ import type { VibeId } from '../vibes';
interface ExportSnapshotRendererOptions { interface ExportSnapshotRendererOptions {
device: GPUDevice; device: GPUDevice;
renderPipeline: RenderPipeline; renderPipeline: RenderPipeline;
canvasFormat: GPUTextureFormat;
statusElement: HTMLElement; statusElement: HTMLElement;
seed: string; seed: string;
getSourceSize: () => { width: number; height: number }; getSourceSize: () => { width: number; height: number };
@ -50,7 +51,6 @@ export class ExportSnapshotRenderer {
private async renderSnapshot(layout: SnapshotLayout): Promise<void> { private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout; const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
const format = navigator.gpu.getPreferredCanvasFormat();
let texture: GPUTexture | null = null; let texture: GPUTexture | null = null;
let output: GPUBuffer | null = null; let output: GPUBuffer | null = null;
let isOutputMapped = false; let isOutputMapped = false;
@ -58,7 +58,7 @@ export class ExportSnapshotRenderer {
try { try {
texture = this.device.createTexture({ texture = this.device.createTexture({
size: { width, height }, size: { width, height },
format, format: this.options.canvasFormat,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
}); });
output = this.device.createBuffer({ output = this.device.createBuffer({
@ -89,7 +89,7 @@ export class ExportSnapshotRenderer {
height, height,
unpaddedBytesPerRow, unpaddedBytesPerRow,
bytesPerRow, bytesPerRow,
isBgra: format === 'bgra8unorm', isBgra: this.options.canvasFormat === 'bgra8unorm',
}); });
output.unmap(); output.unmap();
isOutputMapped = false; isOutputMapped = false;

View file

@ -44,10 +44,11 @@ export class GameLoopResources {
public constructor( public constructor(
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly canvasFormat: GPUTextureFormat,
canvasSize: vec2, canvasSize: vec2,
initialAgentCapacity: number initialAgentCapacity: number
) { ) {
const context = initializeContext({ device, canvas }); const context = initializeContext({ device, canvas, format: canvasFormat });
this.textures = new SimulationTextures(this.device, canvasSize); this.textures = new SimulationTextures(this.device, canvasSize);
@ -73,8 +74,16 @@ export class GameLoopResources {
); );
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState); this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
this.diffusionPipeline = new DiffusionPipeline(this.device); this.diffusionPipeline = new DiffusionPipeline(this.device);
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState); this.renderPipeline = new RenderPipeline(
this.gpuProfiler = GpuProfiler.create(this.device); context,
this.device,
this.commonState,
this.canvasFormat
);
this.gpuProfiler = GpuProfiler.create(
this.device,
() => appConfig.tuningPane.showFpsOverlay
);
this.frameRenderer = new SimulationFrameRenderer( this.frameRenderer = new SimulationFrameRenderer(
this.device, this.device,
@ -104,6 +113,10 @@ export class GameLoopResources {
return this.frameRenderer.isSourceMapActive; return this.frameRenderer.isSourceMapActive;
} }
public get gpuPassTimeMs(): number | undefined {
return this.gpuProfiler?.latestTotalPassMs;
}
public setFrameParameters({ public setFrameParameters({
time, time,
deltaTime, deltaTime,
@ -140,7 +153,6 @@ export class GameLoopResources {
this.diffusionPipeline.setParameters(settings); this.diffusionPipeline.setParameters(settings);
this.renderPipeline.setParameters({ this.renderPipeline.setParameters({
...settings, ...settings,
backgroundGrainStrength: 0,
channelColors, channelColors,
backgroundColor, backgroundColor,
}); });

View file

@ -38,11 +38,14 @@ export default class GameLoop {
private previousAccentColor = ''; private previousAccentColor = '';
private previousGrainStrength = Number.NaN; private previousGrainStrength = Number.NaN;
private hasFinished = false; private hasFinished = false;
private animationFrameId: number | null = null;
private destroyPromise: Promise<void> | null = null;
private readonly finished = Promise.withResolvers<void>(); private readonly finished = Promise.withResolvers<void>();
public constructor( public constructor(
private readonly canvas: HTMLCanvasElement, private readonly canvas: HTMLCanvasElement,
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly canvasFormat: GPUTextureFormat,
private readonly deltaTimeCalculator: DeltaTimeCalculator, private readonly deltaTimeCalculator: DeltaTimeCalculator,
private readonly ui: GardenUi private readonly ui: GardenUi
) { ) {
@ -50,11 +53,17 @@ export default class GameLoop {
this.resources = new GameLoopResources( this.resources = new GameLoopResources(
canvas, canvas,
device, device,
this.canvasFormat,
this.canvasSize, this.canvasSize,
this.framePerformance.adaptiveCapInitial this.framePerformance.adaptiveCapInitial
); );
this.introPrompt = new IntroPrompt(ui.prompt); this.introPrompt = new IntroPrompt(ui.prompt);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device); this.toolbarContrastMonitor = new ToolbarContrastMonitor(
canvas,
ui.toolbar,
device,
this.canvasFormat
);
this.agentPopulation = new AgentPopulation( this.agentPopulation = new AgentPopulation(
this.resources.agentGenerationPipeline, this.resources.agentGenerationPipeline,
this.seedValue, this.seedValue,
@ -72,7 +81,10 @@ export default class GameLoop {
), ),
getCanvasPixelRatio: () => this.canvasPixelRatio, getCanvasPixelRatio: () => this.canvasPixelRatio,
getMirrorSegmentCount: () => this.mirrorSegmentCount, getMirrorSegmentCount: () => this.mirrorSegmentCount,
onStartDrawing: () => this.introPrompt.markStartedDrawing(), onStartDrawing: () => {
this.introPrompt.markStartedDrawing();
this.agentPopulation.beginStroke();
},
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(), onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to), spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
}); });
@ -84,6 +96,7 @@ export default class GameLoop {
this.exportSnapshotRenderer = new ExportSnapshotRenderer({ this.exportSnapshotRenderer = new ExportSnapshotRenderer({
device, device,
renderPipeline: this.resources.renderPipeline, renderPipeline: this.resources.renderPipeline,
canvasFormat: this.canvasFormat,
statusElement: ui.exportStatus, statusElement: ui.exportStatus,
seed: this.seed, seed: this.seed,
getSourceSize: () => { getSourceSize: () => {
@ -139,7 +152,9 @@ export default class GameLoop {
} }
public async start(): Promise<void> { public async start(): Promise<void> {
requestAnimationFrame(this.render); if (this.animationFrameId === null && !this.hasFinished) {
this.animationFrameId = requestAnimationFrame(this.render);
}
return this.finished.promise; return this.finished.promise;
} }
@ -148,8 +163,17 @@ export default class GameLoop {
} }
public async destroy(): Promise<void> { public async destroy(): Promise<void> {
this.destroyPromise ??= this.dispose();
return this.destroyPromise;
}
private async dispose(): Promise<void> {
this.hasFinished = true; this.hasFinished = true;
await this.finished.promise; if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.finished.resolve();
window.removeEventListener('resize', this.resizeListener); window.removeEventListener('resize', this.resizeListener);
this.pointerInput.detach(); this.pointerInput.detach();
@ -164,6 +188,7 @@ export default class GameLoop {
} }
private readonly render = (time: DOMHighResTimeStamp) => { private readonly render = (time: DOMHighResTimeStamp) => {
this.animationFrameId = null;
if (this.hasFinished) { if (this.hasFinished) {
this.finished.resolve(); this.finished.resolve();
return; return;
@ -216,11 +241,12 @@ export default class GameLoop {
fps: this.framePerformance.measuredFps, fps: this.framePerformance.measuredFps,
agentCount: this.agentPopulation.activeAgentCount, agentCount: this.agentPopulation.activeAgentCount,
frameTimeMs: this.framePerformance.measuredFrameTimeMs, frameTimeMs: this.framePerformance.measuredFrameTimeMs,
gpuPassTimeMs: this.resources.gpuPassTimeMs,
renderWidth: this.canvas.width, renderWidth: this.canvas.width,
renderHeight: this.canvas.height, renderHeight: this.canvas.height,
}); });
requestAnimationFrame(this.render); this.animationFrameId = requestAnimationFrame(this.render);
}; };
private syncPerfStatsOverlay(): void { private syncPerfStatsOverlay(): void {

View file

@ -16,11 +16,6 @@ interface GpuProfilerSample {
totalPassMs: number; totalPassMs: number;
} }
interface FleetingGardenPerf {
latest?: GpuProfilerSample;
samples: Array<GpuProfilerSample>;
}
interface ActivePass { interface ActivePass {
endQueryIndex: number; endQueryIndex: number;
name: GpuPassName; name: GpuPassName;
@ -32,33 +27,29 @@ interface ReadbackSlot {
state: 'idle' | 'encoding' | 'mapping'; state: 'idle' | 'encoding' | 'mapping';
} }
declare global {
interface Window {
__fleetingGardenPerf?: FleetingGardenPerf;
}
}
const MAX_QUERY_COUNT = PASS_NAMES.length * 2; const MAX_QUERY_COUNT = PASS_NAMES.length * 2;
const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT; const QUERY_BYTES = BigUint64Array.BYTES_PER_ELEMENT;
const READBACK_SLOT_COUNT = 4; const READBACK_SLOT_COUNT = 4;
const MAX_SAMPLE_COUNT = 600;
export class GpuProfiler { export class GpuProfiler {
private readonly querySet: GPUQuerySet; private readonly querySet: GPUQuerySet;
private readonly resolveBuffer: GPUBuffer; private readonly resolveBuffer: GPUBuffer;
private readonly readbackSlots: Array<ReadbackSlot>; private readonly readbackSlots: Array<ReadbackSlot>;
private readonly isEnabled: () => boolean;
private activePasses: Array<ActivePass> = []; private activePasses: Array<ActivePass> = [];
private nextQueryIndex = 0; private nextQueryIndex = 0;
private frame = 0; private frame = 0;
private latestSample: GpuProfilerSample | null = null;
public static create(device: GPUDevice): GpuProfiler | null { public static create(device: GPUDevice, isEnabled: () => boolean): GpuProfiler | null {
if (!device.features.has('timestamp-query')) { if (!device.features.has('timestamp-query')) {
return null; return null;
} }
return new GpuProfiler(device); return new GpuProfiler(device, isEnabled);
} }
private constructor(device: GPUDevice) { private constructor(device: GPUDevice, isEnabled: () => boolean) {
this.isEnabled = isEnabled;
this.querySet = device.createQuerySet({ this.querySet = device.createQuerySet({
type: 'timestamp', type: 'timestamp',
count: MAX_QUERY_COUNT, count: MAX_QUERY_COUNT,
@ -85,6 +76,9 @@ export class GpuProfiler {
public timestampWrites( public timestampWrites(
name: GpuPassName name: GpuPassName
): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined { ): (GPUComputePassTimestampWrites & GPURenderPassTimestampWrites) | undefined {
if (!this.isEnabled()) {
return undefined;
}
if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) { if (this.nextQueryIndex + 1 >= MAX_QUERY_COUNT) {
return undefined; return undefined;
} }
@ -146,6 +140,10 @@ export class GpuProfiler {
}); });
} }
public get latestTotalPassMs(): number | undefined {
return this.latestSample?.totalPassMs;
}
private publishSample( private publishSample(
frame: number, frame: number,
passes: Array<ActivePass>, passes: Array<ActivePass>,
@ -170,11 +168,6 @@ export class GpuProfiler {
sample.totalPassMs += elapsedMs; sample.totalPassMs += elapsedMs;
}); });
const perf = (window.__fleetingGardenPerf ??= { samples: [] }); this.latestSample = sample;
perf.latest = sample;
perf.samples.push(sample);
if (perf.samples.length > MAX_SAMPLE_COUNT) {
perf.samples.splice(0, perf.samples.length - MAX_SAMPLE_COUNT);
}
} }
} }

View file

@ -1,5 +1,5 @@
import { appConfig } from '../config'; import { appConfig } from '../config';
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent-generation-pipeline'; import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math'; import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
interface IntroTitlePoint { interface IntroTitlePoint {
@ -114,15 +114,16 @@ export const createIntroTitleAgents = ({
pathProgress pathProgress
) )
); );
const base = i * AGENT_FLOAT_COUNT; writeAgentValues(data, i, {
data[base] = mix(startX, targetX, pathProgress); positionX: mix(startX, targetX, pathProgress),
data[base + 1] = mix(startY, targetY, pathProgress); positionY: mix(startY, targetY, pathProgress),
data[base + 2] = currentAngle; angle: currentAngle,
data[base + 3] = point.colorIndex; colorIndex: point.colorIndex,
data[base + 4] = targetX; targetPositionX: targetX,
data[base + 5] = targetY; targetPositionY: targetY,
data[base + 6] = targetAngle; targetAngle,
data[base + 7] = introDelay; introDelay,
});
} }
return data; return data;

View file

@ -8,6 +8,7 @@ interface PerfStatsSnapshot {
fps: number; fps: number;
agentCount: number; agentCount: number;
frameTimeMs: number; frameTimeMs: number;
gpuPassTimeMs?: number;
renderWidth: number; renderWidth: number;
renderHeight: number; renderHeight: number;
} }
@ -29,6 +30,7 @@ export class PerfStatsOverlay {
fps, fps,
agentCount, agentCount,
frameTimeMs, frameTimeMs,
gpuPassTimeMs,
renderWidth, renderWidth,
renderHeight, renderHeight,
}: PerfStatsSnapshot): void { }: PerfStatsSnapshot): void {
@ -37,7 +39,6 @@ export class PerfStatsOverlay {
} }
this.previousUpdateTime = time; this.previousUpdateTime = time;
const gpuPassTimeMs = window.__fleetingGardenPerf?.latest?.totalPassMs;
const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`; const text = `FPS ${formatFps(fps)}\nAgents ${formatAgentCount(agentCount)}\nFrame ${formatFrameTime(frameTimeMs)}\nGPU passes ${formatFrameTime(gpuPassTimeMs)}\nResolution ${formatResolution(renderWidth, renderHeight)}`;
if (text !== this.previousText) { if (text !== this.previousText) {
this.element.textContent = text; this.element.textContent = text;

View file

@ -92,7 +92,6 @@ export class GardenPointerInput {
return; return;
} }
this.options.audio.start(activeVibe, { userGesture: true });
this.options.audio.beginGesture(); this.options.audio.beginGesture();
this.options.onStartDrawing(); this.options.onStartDrawing();
this.activePointerId = event.pointerId; this.activePointerId = event.pointerId;
@ -117,7 +116,6 @@ export class GardenPointerInput {
if (event.pointerId !== this.activePointerId) { if (event.pointerId !== this.activePointerId) {
return; return;
} }
this.options.audio.start(activeVibe, { userGesture: true });
this.addSwipeAt(event, { emitAudio: false }); this.addSwipeAt(event, { emitAudio: false });
this.finishBrushStroke(); this.finishBrushStroke();
this.options.audio.endGesture(); this.options.audio.endGesture();
@ -166,12 +164,7 @@ export class GardenPointerInput {
} }
private addBrushSample(sample: PointerSample): void { private addBrushSample(sample: PointerSample): void {
this.addBrushSegments(this.brushSmoother.addSample(sample.position)); this.emitBrushSegments(this.brushSmoother.addSample(sample.position));
this.getMirroredSegments(sample.previousPosition, sample.position).forEach(
(segment) => {
this.options.spawnStrokeAgents(segment.from, segment.to);
}
);
} }
private addEraseSample(sample: PointerSample): void { private addEraseSample(sample: PointerSample): void {
@ -199,13 +192,14 @@ export class GardenPointerInput {
); );
} }
private addBrushSegments(segments: Array<StrokeSegment>): void { private emitBrushSegments(segments: Array<StrokeSegment>): void {
segments.forEach((segment) => { segments.forEach((segment) => {
this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => { this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
this.options.strokeOutput.addBrushSegment( this.options.strokeOutput.addBrushSegment(
mirroredSegment.from, mirroredSegment.from,
mirroredSegment.to mirroredSegment.to
); );
this.options.spawnStrokeAgents(mirroredSegment.from, mirroredSegment.to);
}); });
}); });
} }
@ -215,7 +209,7 @@ export class GardenPointerInput {
return; return;
} }
this.addBrushSegments(this.brushSmoother.finish()); this.emitBrushSegments(this.brushSmoother.finish());
} }
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> { private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {

View file

@ -1,6 +1,7 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { appConfig } from '../config'; import { appConfig } from '../config';
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
import { import {
ResizableTexture, ResizableTexture,
type PendingTextureResize, type PendingTextureResize,
@ -96,13 +97,17 @@ export class SimulationTextures {
} }
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void { public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
// Only sourceMapA needs clearing — sourceMapB gets fully overwritten by
// the diffusion pass on the next active frame before it's ever sampled.
const passEncoder = commandEncoder.beginRenderPass({ const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [this.sourceMapA, this.sourceMapB].map((texture) => ({ colorAttachments: [
view: texture.getTextureView(), {
clearValue: appConfig.simulation.clearColor, view: this.sourceMapA.getTextureView(),
loadOp: 'clear', clearValue: appConfig.simulation.clearColor,
storeOp: 'store', loadOp: 'clear',
})), storeOp: 'store',
},
],
}); });
passEncoder.end(); passEncoder.end();
} }
@ -126,7 +131,7 @@ export class SimulationTextures {
private createEraserMask(size: vec2): ResizableTexture { private createEraserMask(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, size, { return new ResizableTexture(this.device, size, {
clearValue: { r: 1, g: 1, b: 1, a: 1 }, clearValue: { r: 1, g: 1, b: 1, a: 1 },
format: 'r8unorm', format: ERASER_MASK_TEXTURE_FORMAT,
usage: usage:
GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.RENDER_ATTACHMENT |

View file

@ -104,9 +104,10 @@ export class ToolbarContrastMonitor {
public constructor( public constructor(
private readonly canvas: HTMLCanvasElement, private readonly canvas: HTMLCanvasElement,
private readonly toolbar: HTMLElement, private readonly toolbar: HTMLElement,
private readonly device: GPUDevice private readonly device: GPUDevice,
canvasFormat: GPUTextureFormat
) { ) {
this.isBgra = navigator.gpu?.getPreferredCanvasFormat() === 'bgra8unorm'; this.isBgra = canvasFormat === 'bgra8unorm';
} }
public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null { public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null {

View file

@ -32,6 +32,15 @@ const main = async () => {
let game: GameLoop | null = null; let game: GameLoop | null = null;
let configPane: ConfigPane | null = null; let configPane: ConfigPane | null = null;
const getGame = () => game; const getGame = () => game;
const destroyCurrentGame = async () => {
const currentGame = game;
if (!currentGame) {
return;
}
game = null;
await currentGame.destroy();
};
const errorPresenter = new ErrorPresenter( const errorPresenter = new ErrorPresenter(
queryRequiredElement('.errors-container', HTMLElement) queryRequiredElement('.errors-container', HTMLElement)
@ -40,7 +49,7 @@ const main = async () => {
errorPresenter.render(error); errorPresenter.render(error);
if (error.severity === Severity.ERROR) { if (error.severity === Severity.ERROR) {
document.body.classList.remove('is-loading'); document.body.classList.remove('is-loading');
game?.destroy(); void destroyCurrentGame();
shouldStop = true; shouldStop = true;
} }
}); });
@ -53,19 +62,24 @@ const main = async () => {
const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement); const grainOverlay = queryRequiredElement('.garden-grain', HTMLDivElement);
const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement); const promptElement = queryRequiredElement('.garden-prompt', HTMLDivElement);
const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement); const exportStatus = queryRequiredElement('.export-status', HTMLSpanElement);
const settingsButton = queryRequiredElement('button.settings', HTMLButtonElement); const settingsButton = queryRequiredElement(
const restartButton = queryRequiredElement('button.restart', HTMLButtonElement); '[data-control="settings"]',
const infoButton = queryRequiredElement('button.info', HTMLButtonElement); HTMLButtonElement
);
const restartButton = queryRequiredElement(
'[data-control="restart"]',
HTMLButtonElement
);
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
const infoElement = queryRequiredElement('.info-page', HTMLElement); const infoElement = queryRequiredElement('.info-page', HTMLElement);
const minimizeFullScreenButton = queryRequiredElement( const fullScreenButton = queryRequiredElement(
'button.minimize-full-screen', '[data-control="full-screen"]',
HTMLButtonElement HTMLButtonElement
); );
const maximizeFullScreenButton = queryRequiredElement( const export4kButton = queryRequiredElement(
'button.maximize-full-screen', '[data-control="export"]',
HTMLButtonElement HTMLButtonElement
); );
const export4kButton = queryRequiredElement('.export-4k', HTMLButtonElement);
const splash = new SplashScreen(); const splash = new SplashScreen();
const paletteControl = new PaletteControl({ const paletteControl = new PaletteControl({
@ -103,11 +117,7 @@ const main = async () => {
!configPane?.isOpen && !configPane?.isOpen &&
!infoPageHandler.isOpen !infoPageHandler.isOpen
); );
new FullScreenHandler( new FullScreenHandler(fullScreenButton, document.documentElement);
minimizeFullScreenButton,
maximizeFullScreenButton,
document.documentElement
);
new VibeNavigator({ new VibeNavigator({
onChange: ({ vibeId, vibeName, source }) => { onChange: ({ vibeId, vibeName, source }) => {
@ -119,16 +129,17 @@ const main = async () => {
}, },
}); });
restartButton.addEventListener('click', () => game?.destroy()); restartButton.addEventListener('click', () => void destroyCurrentGame());
export4kButton.addEventListener('click', async () => { export4kButton.addEventListener('click', async () => {
if (!game || export4kButton.disabled) { const currentGame = game;
if (!currentGame || export4kButton.disabled) {
return; return;
} }
export4kButton.disabled = true; export4kButton.disabled = true;
try { try {
await game.exportSnapshot(); await currentGame.exportSnapshot();
trackExport({ vibeId: activeVibe.id }); trackExport({ vibeId: activeVibe.id });
} catch (error) { } catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING }); ErrorHandler.addException(error, { severity: Severity.WARNING });
@ -168,9 +179,15 @@ const main = async () => {
); );
const gpu = await gpuPromise; const gpu = await gpuPromise;
const gpuNavigator = navigator.gpu;
if (!gpuNavigator) {
throw new Error('WebGPU is no longer available after initialization.');
}
const canvasFormat = gpuNavigator.getPreferredCanvasFormat();
configPane = new ConfigPane({ configPane = new ConfigPane({
maxSupportedAgentCount: getMaxSupportedAgentCount(gpu), maxSupportedAgentCount: getMaxSupportedAgentCount(gpu),
settingsButton, settingsButton,
onOpen: () => infoPageHandler.close(),
onConfigChange: () => { onConfigChange: () => {
game?.onVibeChanged(); game?.onVibeChanged();
syncRuntimeUi(); syncRuntimeUi();
@ -186,13 +203,14 @@ const main = async () => {
let isFirstStart = true; let isFirstStart = true;
while (!shouldStop) { while (!shouldStop) {
game = new GameLoop(canvas, gpu, deltaTimeCalculator, { const loop = new GameLoop(canvas, gpu, canvasFormat, deltaTimeCalculator, {
toolbar: toolbarRow, toolbar: toolbarRow,
prompt: promptElement, prompt: promptElement,
eraserPreview, eraserPreview,
grainOverlay, grainOverlay,
exportStatus, exportStatus,
}); });
game = loop;
syncRuntimeUi(); syncRuntimeUi();
audioControl.render(); audioControl.render();
@ -211,8 +229,11 @@ const main = async () => {
requestAnimationFrame(() => document.body.classList.remove('is-loading')) requestAnimationFrame(() => document.body.classList.remove('is-loading'))
); );
} }
game.attachPointerInput(); loop.attachPointerInput();
await game.start(); await loop.start();
if (game === loop) {
game = null;
}
} }
} catch (e) { } catch (e) {
document.body.classList.remove('is-loading'); document.body.classList.remove('is-loading');

View file

@ -1,27 +1,20 @@
import { import { appConfig } from '../config';
APP_STORAGE_KEYS, import { DISABLED_FLAG_VALUE, ENABLED_FLAG_VALUE } from '../consts';
DEFAULT_AUDIO_VOLUME,
DISABLED_FLAG_VALUE,
ENABLED_FLAG_VALUE,
UNIT_INTERVAL_INPUT_MAX,
UNIT_INTERVAL_INPUT_MIN,
} from '../consts';
import type GameLoop from '../game-loop/game-loop'; import type GameLoop from '../game-loop/game-loop';
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage'; import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math'; import { clamp01 } from '../utils/math';
const AUDIO_VOLUME_STEP = 0.01;
const clampAudioVolume = (value: number): number => { const clampAudioVolume = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME; const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
return clamp01(safeValue); const safeValue = Number.isFinite(value) ? value : defaultVolume;
return Math.min(max, Math.max(min, clamp01(safeValue)));
}; };
const readInitialAudioVolume = (): number => { const readInitialAudioVolume = (): number => {
const storedVolume = readBrowserStorage(APP_STORAGE_KEYS.audioVolume); const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
return storedVolume === null return storedVolume === null
? DEFAULT_AUDIO_VOLUME ? appConfig.toolbar.volume.default
: clampAudioVolume(Number(storedVolume)); : clampAudioVolume(Number(storedVolume));
}; };
@ -36,7 +29,7 @@ interface AudioControlOptions {
export class AudioControl { export class AudioControl {
private readonly soundButton = queryRequiredElement( private readonly soundButton = queryRequiredElement(
'button.sound', '[data-control="sound"]',
HTMLButtonElement HTMLButtonElement
); );
private readonly volumeControl = queryRequiredElement( private readonly volumeControl = queryRequiredElement(
@ -50,7 +43,7 @@ export class AudioControl {
private audioVolume = readInitialAudioVolume(); private audioVolume = readInitialAudioVolume();
private isMutedState = private isMutedState =
readBrowserStorage(APP_STORAGE_KEYS.audioMuted) === ENABLED_FLAG_VALUE || readBrowserStorage(appConfig.storage.audioMutedKey) === ENABLED_FLAG_VALUE ||
this.audioVolume <= 0; this.audioVolume <= 0;
public constructor(private readonly options: AudioControlOptions) { public constructor(private readonly options: AudioControlOptions) {
@ -90,9 +83,9 @@ export class AudioControl {
this.soundButton.setAttribute('aria-label', muteLabel); this.soundButton.setAttribute('aria-label', muteLabel);
this.soundButton.title = muteLabel; this.soundButton.title = muteLabel;
this.volumeSlider.min = UNIT_INTERVAL_INPUT_MIN; this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
this.volumeSlider.max = UNIT_INTERVAL_INPUT_MAX; this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
this.volumeSlider.step = AUDIO_VOLUME_STEP.toString(); this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume); this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
this.volumeSlider.setAttribute( this.volumeSlider.setAttribute(
'aria-valuetext', 'aria-valuetext',
@ -112,7 +105,7 @@ export class AudioControl {
private readonly onToggleMute = () => { private readonly onToggleMute = () => {
const shouldUnmute = this.isMutedState || this.audioVolume <= 0; const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
if (shouldUnmute && this.audioVolume <= 0) { if (shouldUnmute && this.audioVolume <= 0) {
this.audioVolume = DEFAULT_AUDIO_VOLUME; this.audioVolume = appConfig.toolbar.volume.default;
} }
this.isMutedState = !shouldUnmute; this.isMutedState = !shouldUnmute;
this.persist(); this.persist();
@ -136,8 +129,7 @@ export class AudioControl {
if ( if (
!this.options.hasStarted() || !this.options.hasStarted() ||
this.isMutedState || this.isMutedState ||
(event.target instanceof Node && (event.target instanceof Node && this.options.startButton.contains(event.target)) ||
this.options.startButton.contains(event.target)) ||
(event.target instanceof Node && this.soundButton.contains(event.target)) (event.target instanceof Node && this.soundButton.contains(event.target))
) { ) {
return; return;
@ -147,11 +139,11 @@ export class AudioControl {
private persist(): void { private persist(): void {
writeBrowserStorage( writeBrowserStorage(
APP_STORAGE_KEYS.audioMuted, appConfig.storage.audioMutedKey,
this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE this.isMutedState ? ENABLED_FLAG_VALUE : DISABLED_FLAG_VALUE
); );
writeBrowserStorage( writeBrowserStorage(
APP_STORAGE_KEYS.audioVolume, appConfig.storage.audioVolumeKey,
formatStoredAudioVolume(this.audioVolume) formatStoredAudioVolume(this.audioVolume)
); );
} }

View file

@ -4,6 +4,7 @@ import { Pane } from 'tweakpane';
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config'; import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
import { import {
appConfig, appConfig,
normalizeNumberControlValue,
type GardenRuntimeSettings, type GardenRuntimeSettings,
type NumberControlConfig, type NumberControlConfig,
} from '../config'; } from '../config';
@ -28,7 +29,7 @@ interface PaneState extends GardenAudioVibeSettings {
color3: string; color3: string;
} }
const COLOR_REACTION_LABELS = ['Primary', 'Secondary', 'Accent'] as const; const COLOR_REACTION_LABELS = ['Color 1', 'Color 2', 'Color 3'] as const;
const COLOR_REACTION_STATES = [ const COLOR_REACTION_STATES = [
{ id: 'follow', label: 'Move Toward', value: 1 }, { id: 'follow', label: 'Move Toward', value: 1 },
{ id: 'ignore', label: 'Ignore', value: 0 }, { id: 'ignore', label: 'Ignore', value: 0 },
@ -53,31 +54,7 @@ const colorReactionRows = [
}, },
] as const; ] as const;
const brushControlKeys = [ const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
'brushSize',
'spawnPerPixel',
'strokeAngleJitterRadians',
] satisfies Array<RuntimeControlKey>;
const agentControlKeys = [
'sensorOffsetDistance',
'moveSpeed',
'turnSpeed',
'forwardRotationScale',
'turnWhenLost',
'individualTrailWeight',
'decayRateTrails',
] satisfies Array<RuntimeControlKey>;
const lookControlKeys = [
'clarity',
'backgroundGrainStrength',
] satisfies Array<RuntimeControlKey>;
const performanceControlKeys = [
'maxAgentCount',
'internalRenderAreaMegapixels',
] satisfies Array<RuntimeControlKey>;
const MUSIC_CONTROLS: ReadonlyArray<{ const MUSIC_CONTROLS: ReadonlyArray<{
key: VibeNumberKey; key: VibeNumberKey;
@ -98,26 +75,19 @@ const MUSIC_CONTROLS: ReadonlyArray<{
interface ConfigPaneOptions { interface ConfigPaneOptions {
maxSupportedAgentCount: number; maxSupportedAgentCount: number;
onConfigChange: () => void; onConfigChange: () => void;
onOpen?: () => void;
onRuntimeChange: () => void; onRuntimeChange: () => void;
settingsButton: HTMLButtonElement; settingsButton: HTMLButtonElement;
} }
const normalizeNumber = (value: number, config: NumberControlConfig): number => { const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> =>
if (config.options) { (
const optionValues = Object.values(config.options); Object.entries(appConfig.runtimeSettings.controls) as Array<
if (optionValues.includes(value)) { [RuntimeControlKey, NumberControlConfig | undefined]
return value; >
} )
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0); .filter(([, config]) => config?.folder === folder)
} .map(([key]) => key);
const min = config.min ?? Number.NEGATIVE_INFINITY;
const max = config.max ?? Number.POSITIVE_INFINITY;
const fallbackValue = config.min ?? 0;
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
const clampedValue = Math.min(max, Math.max(min, finiteValue));
return config.integer ? Math.round(clampedValue) : clampedValue;
};
const getNumberBindingParams = (config: NumberControlConfig): BindingParams => { const getNumberBindingParams = (config: NumberControlConfig): BindingParams => {
const params: BindingParams = { const params: BindingParams = {
@ -226,8 +196,7 @@ export class ConfigPane {
} }
private readonly toggle = () => { private readonly toggle = () => {
this.pane.hidden = !this.pane.hidden; this.setHidden(!this.pane.hidden);
this.syncOpenState();
}; };
private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => { private readonly dismissOnOutsidePointerDown = (event: PointerEvent) => {
@ -252,20 +221,23 @@ export class ConfigPane {
}; };
private setHidden(isHidden: boolean): void { private setHidden(isHidden: boolean): void {
const wasOpen = this.isOpen;
this.pane.hidden = isHidden; this.pane.hidden = isHidden;
this.syncOpenState(); this.syncOpenState();
if (!wasOpen && this.isOpen) {
this.options.onOpen?.();
}
} }
private setUpTuningPane(container: PaneContainer): void { private setUpTuningPane(container: PaneContainer): void {
this.setUpVibeSection(container); this.setUpVibeSection(container);
this.addRuntimeSection(container, 'Brush', brushControlKeys, true); this.addRuntimeSection(container, runtimeFolderOrder[0], true);
this.addRuntimeSection(container, 'Agents', agentControlKeys, true); this.addRuntimeSection(container, runtimeFolderOrder[1], true);
this.addColorReactionMatrix(container); this.addColorReactionMatrix(container);
this.addRuntimeSection(container, 'Look', lookControlKeys, true); this.addRuntimeSection(container, runtimeFolderOrder[2], true);
const performanceFolder = this.addRuntimeSection( const performanceFolder = this.addRuntimeSection(
container, container,
'Performance', runtimeFolderOrder[3],
performanceControlKeys,
true true
); );
this.addFpsOverlayBinding(performanceFolder); this.addFpsOverlayBinding(performanceFolder);
@ -279,13 +251,13 @@ export class ConfigPane {
expanded: true, expanded: true,
}); });
this.addColorBinding(folder, 'color1', 'Primary Color', (color) => { this.addColorBinding(folder, 'color1', '', (color) => {
activeVibe.colors[0] = color; activeVibe.colors[0] = color;
}); });
this.addColorBinding(folder, 'color2', 'Secondary Color', (color) => { this.addColorBinding(folder, 'color2', '', (color) => {
activeVibe.colors[1] = color; activeVibe.colors[1] = color;
}); });
this.addColorBinding(folder, 'color3', 'Accent Color', (color) => { this.addColorBinding(folder, 'color3', '', (color) => {
activeVibe.colors[2] = color; activeVibe.colors[2] = color;
}); });
this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => { this.addColorBinding(folder, 'backgroundColor', 'Background Color', (color) => {
@ -327,11 +299,10 @@ export class ConfigPane {
private addRuntimeSection( private addRuntimeSection(
container: PaneContainer, container: PaneContainer,
title: string, title: string,
keys: ReadonlyArray<RuntimeControlKey>,
expanded: boolean expanded: boolean
): PaneContainer { ): PaneContainer {
const folder = container.addFolder({ title, expanded }); const folder = container.addFolder({ title, expanded });
keys.forEach((key) => this.addRuntimeBinding(folder, key)); getRuntimeControlKeys(title).forEach((key) => this.addRuntimeBinding(folder, key));
return folder; return folder;
} }
@ -341,12 +312,12 @@ export class ConfigPane {
return; return;
} }
settings[key] = normalizeNumber(settings[key], config); settings[key] = normalizeNumberControlValue(settings[key], config);
container container
.addBinding(settings, key, getNumberBindingParams(config)) .addBinding(settings, key, getNumberBindingParams(config))
.on('change', () => { .on('change', () => {
const nextValue = normalizeNumber(settings[key], config); const nextValue = normalizeNumberControlValue(settings[key], config);
if (nextValue !== settings[key]) { if (nextValue !== settings[key]) {
settings[key] = nextValue; settings[key] = nextValue;
this.pane.refresh(); this.pane.refresh();
@ -382,7 +353,6 @@ export class ConfigPane {
title: 'Color Behavior', title: 'Color Behavior',
expanded: true, expanded: true,
}); });
folder.element.classList.add('color-reaction-folder');
const matrix = document.createElement('div'); const matrix = document.createElement('div');
matrix.className = 'color-reaction-matrix'; matrix.className = 'color-reaction-matrix';
@ -410,23 +380,20 @@ export class ConfigPane {
private createColorReactionCorner(): HTMLDivElement { private createColorReactionCorner(): HTMLDivElement {
const corner = document.createElement('div'); const corner = document.createElement('div');
corner.className = 'color-reaction-matrix__corner'; corner.className = 'color-reaction-matrix__corner';
corner.textContent = 'agents';
return corner; return corner;
} }
private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement { private createColorReactionHeader(colorIndex: number, label: string): HTMLDivElement {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'color-reaction-matrix__header'; header.className = 'color-reaction-matrix__header';
header.setAttribute('aria-label', label);
header.title = label;
const swatch = document.createElement('span'); const swatch = document.createElement('span');
swatch.className = 'color-reaction-matrix__swatch'; swatch.className = 'color-reaction-matrix__swatch';
this.colorReactionSwatches.push({ colorIndex, element: swatch }); this.colorReactionSwatches.push({ colorIndex, element: swatch });
header.appendChild(swatch); header.appendChild(swatch);
const text = document.createElement('span');
text.textContent = label;
header.appendChild(text);
return header; return header;
} }
@ -452,7 +419,7 @@ export class ConfigPane {
button.appendChild(icon); button.appendChild(icon);
button.addEventListener('click', () => { button.addEventListener('click', () => {
const currentValue = normalizeNumber(settings[key], config); const currentValue = normalizeNumberControlValue(settings[key], config);
const nextState = getNextColorReactionState(currentValue); const nextState = getNextColorReactionState(currentValue);
settings[key] = nextState.value; settings[key] = nextState.value;
this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex); this.syncColorReactionButton(button, key, sourceColorIndex, targetColorIndex);
@ -492,7 +459,7 @@ export class ConfigPane {
return; return;
} }
settings[key] = normalizeNumber(settings[key], config); settings[key] = normalizeNumberControlValue(settings[key], config);
const state = getColorReactionState(settings[key]); const state = getColorReactionState(settings[key]);
const nextState = getNextColorReactionState(settings[key]); const nextState = getNextColorReactionState(settings[key]);
@ -502,9 +469,9 @@ export class ConfigPane {
button.dataset.reaction = state.id; button.dataset.reaction = state.id;
button.setAttribute( button.setAttribute(
'aria-label', 'aria-label',
`${sourceLabel} agents ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}` `${sourceLabel} ${state.label.toLowerCase()} ${targetLabel.toLowerCase()} trails; click to switch to ${nextState.label.toLowerCase()}`
); );
button.title = `${sourceLabel} agents: ${state.label} ${targetLabel} trails`; button.title = `${sourceLabel}: ${state.label} ${targetLabel} trails`;
} }
private setUpMusicSection(container: PaneContainer): void { private setUpMusicSection(container: PaneContainer): void {
@ -519,12 +486,12 @@ export class ConfigPane {
key: VibeNumberKey, key: VibeNumberKey,
config: NumberControlConfig config: NumberControlConfig
): void { ): void {
this.state[key] = normalizeNumber(this.state[key], config); this.state[key] = normalizeNumberControlValue(this.state[key], config);
container container
.addBinding(this.state, key, getNumberBindingParams(config)) .addBinding(this.state, key, getNumberBindingParams(config))
.on('change', () => { .on('change', () => {
const nextValue = normalizeNumber(this.state[key], config); const nextValue = normalizeNumberControlValue(this.state[key], config);
if (nextValue !== this.state[key]) { if (nextValue !== this.state[key]) {
this.state[key] = nextValue; this.state[key] = nextValue;
this.pane.refresh(); this.pane.refresh();

View file

@ -1,21 +1,18 @@
import { appConfig } from '../config';
import type GameLoop from '../game-loop/game-loop'; import type GameLoop from '../game-loop/game-loop';
import { settings } from '../settings'; import { settings } from '../settings';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
const ERASER_CONTROL_SCALE_MAX = 1.33;
const ERASER_CONTROL_SCALE_MIN = 0.75;
const ERASER_SIZE_DEFAULT = 96;
const ERASER_SIZE_MAX = 240;
const ERASER_SIZE_MIN = 24;
const ERASER_SIZE_STEP = 1;
const clampEraserSize = (value: number): number => { const clampEraserSize = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : ERASER_SIZE_DEFAULT; const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue))); const safeValue = Number.isFinite(value) ? value : defaultSize;
return Math.min(max, Math.max(min, Math.round(safeValue)));
}; };
const getEraserSizeRatio = (size: number): number => const getEraserSizeRatio = (size: number): number => {
(size - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN); const { max, min } = appConfig.toolbar.eraser;
return (size - min) / (max - min);
};
interface EraserSizeControlOptions { interface EraserSizeControlOptions {
getGame: () => GameLoop | null; getGame: () => GameLoop | null;
@ -28,10 +25,7 @@ export class EraserSizeControl {
'.eraser-size-control', '.eraser-size-control',
HTMLLabelElement HTMLLabelElement
); );
private readonly slider = queryRequiredElement( private readonly slider = queryRequiredElement('.eraser-size-slider', HTMLInputElement);
'.eraser-size-slider',
HTMLInputElement
);
public constructor(private readonly options: EraserSizeControlOptions) { public constructor(private readonly options: EraserSizeControlOptions) {
this.control.addEventListener('pointerdown', this.options.onActivate); this.control.addEventListener('pointerdown', this.options.onActivate);
@ -51,16 +45,18 @@ export class EraserSizeControl {
settings.eraserSize = size; settings.eraserSize = size;
} }
this.slider.min = ERASER_SIZE_MIN.toString(); this.slider.min = appConfig.toolbar.eraser.min.toString();
this.slider.max = ERASER_SIZE_MAX.toString(); this.slider.max = appConfig.toolbar.eraser.max.toString();
this.slider.step = ERASER_SIZE_STEP.toString(); this.slider.step = appConfig.toolbar.eraser.step.toString();
this.slider.value = size.toString(); this.slider.value = size.toString();
this.slider.setAttribute('aria-valuetext', `${size}px`); this.slider.setAttribute('aria-valuetext', `${size}px`);
const ratio = getEraserSizeRatio(size); const ratio = getEraserSizeRatio(size);
const scale = const scale =
ERASER_CONTROL_SCALE_MIN + appConfig.toolbar.eraser.controlScaleMin +
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * ratio; (appConfig.toolbar.eraser.controlScaleMax -
appConfig.toolbar.eraser.controlScaleMin) *
ratio;
this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`); this.control.style.setProperty('--eraser-progress', `${ratio * 100}%`);
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3)); this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
this.options.getGame()?.updateEraserPreview(); this.options.getGame()?.updateEraserPreview();

View file

@ -1,22 +1,24 @@
export class FullScreenHandler { export class FullScreenHandler {
public constructor( public constructor(
private readonly minimizeButton: HTMLElement, private readonly toggleButton: HTMLElement,
private readonly maximizeButton: HTMLElement,
target: HTMLElement target: HTMLElement
) { ) {
if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') { if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
minimizeButton.hidden = true; toggleButton.hidden = true;
maximizeButton.hidden = true;
return; return;
} }
this.updateButtons(); this.updateButtons();
addEventListener('fullscreenchange', this.updateButtons.bind(this)); addEventListener('fullscreenchange', this.updateButtons.bind(this));
maximizeButton.addEventListener('click', () => { toggleButton.addEventListener('click', () => {
if (FullScreenHandler.isInFullScreenMode()) {
void document.exitFullscreen();
return;
}
void target.requestFullscreen().catch(() => undefined); void target.requestFullscreen().catch(() => undefined);
}); });
minimizeButton.addEventListener('click', () => document.exitFullscreen());
} }
public static isInFullScreenMode(): boolean { public static isInFullScreenMode(): boolean {
@ -25,7 +27,9 @@ export class FullScreenHandler {
private updateButtons(): void { private updateButtons(): void {
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode(); const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
this.minimizeButton.hidden = !isInFullScreenMode; const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
this.maximizeButton.hidden = isInFullScreenMode; this.toggleButton.classList.toggle('active', isInFullScreenMode);
this.toggleButton.setAttribute('aria-label', label);
this.toggleButton.title = label;
} }
} }

View file

@ -1,28 +1,26 @@
import { appConfig } from '../config';
import { settings } from '../settings'; import { settings } from '../settings';
import { queryRequiredElement } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
const MIRROR_SEGMENT_DEFAULT = 1;
const MIRROR_SEGMENT_MAX = 12;
const MIRROR_SEGMENT_MIN = 1;
const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
const MIRROR_SEGMENT_STEP = 1;
const MIRROR_SEGMENT_LABEL_SUFFIX = 'slices';
const clampMirrorSegmentCount = (value: number): number => { const clampMirrorSegmentCount = (value: number): number => {
const safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT; const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
return Math.min( const safeValue = Number.isFinite(value) ? value : defaultCount;
MIRROR_SEGMENT_MAX, return Math.min(max, Math.max(min, Math.round(safeValue)));
Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
);
}; };
const getMirrorSegmentRatio = (count: number): number => const getMirrorSegmentRatio = (count: number): number => {
(count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN); const { max, min } = appConfig.toolbar.mirror;
return (count - min) / (max - min);
};
const formatMirrorSegmentCount = (count: number): string => const formatMirrorSegmentCount = (count: number): string =>
count === MIRROR_SEGMENT_DEFAULT count === appConfig.toolbar.mirror.default
? MIRROR_SEGMENT_OFF_LABEL ? appConfig.toolbar.mirror.offLabel
: `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`; : `${count} ${
appConfig.toolbar.mirror.names[
count as keyof typeof appConfig.toolbar.mirror.names
] ?? appConfig.toolbar.mirror.fallbackSegmentName
}`;
interface MirrorSegmentControlOptions { interface MirrorSegmentControlOptions {
onChange: () => void; onChange: () => void;
@ -52,9 +50,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count; settings.mirrorSegmentCount = count;
} }
this.slider.min = MIRROR_SEGMENT_MIN.toString(); this.slider.min = appConfig.toolbar.mirror.min.toString();
this.slider.max = MIRROR_SEGMENT_MAX.toString(); this.slider.max = appConfig.toolbar.mirror.max.toString();
this.slider.step = MIRROR_SEGMENT_STEP.toString(); this.slider.step = appConfig.toolbar.mirror.step.toString();
this.slider.value = count.toString(); this.slider.value = count.toString();
const label = formatMirrorSegmentCount(count); const label = formatMirrorSegmentCount(count);

View file

@ -1,6 +1,7 @@
import type GameLoop from '../game-loop/game-loop'; import type GameLoop from '../game-loop/game-loop';
import { activeVibe, settings } from '../settings'; import { activeVibe, settings } from '../settings';
import { queryRequiredElement, queryRequiredElements } from '../utils/dom'; import { queryRequiredElement } from '../utils/dom';
import { ErrorCode, RuntimeError } from '../utils/error-handler';
import { rgbColorToCss } from '../utils/rgb-color'; import { rgbColorToCss } from '../utils/rgb-color';
interface PaletteControlOptions { interface PaletteControlOptions {
@ -9,7 +10,7 @@ interface PaletteControlOptions {
} }
export class PaletteControl { export class PaletteControl {
private readonly swatches = queryRequiredElements('.color-swatch', HTMLButtonElement); private readonly swatches = queryRequiredColorSwatches();
private readonly eraserControl = queryRequiredElement( private readonly eraserControl = queryRequiredElement(
'.eraser-size-control', '.eraser-size-control',
HTMLLabelElement HTMLLabelElement
@ -52,3 +53,28 @@ export class PaletteControl {
); );
} }
} }
const queryRequiredColorSwatches = (): Array<HTMLButtonElement> => {
const selector = '.color-swatch';
const swatches = Array.from(document.querySelectorAll(selector));
const expectedCount = activeVibe.colors.length;
const hasExpectedSwatches =
swatches.length === expectedCount &&
swatches.every((swatch) => swatch instanceof HTMLButtonElement);
if (!hasExpectedSwatches) {
throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING,
`Expected ${expectedCount} color swatches.`,
{
details: {
actualCount: swatches.length,
expectedCount,
selector,
},
}
);
}
return swatches as Array<HTMLButtonElement>;
};

View file

@ -2,10 +2,7 @@ import { queryRequiredElement } from '../utils/dom';
import { clamp01 } from '../utils/math'; import { clamp01 } from '../utils/math';
export class SplashScreen { export class SplashScreen {
public readonly startButton = queryRequiredElement( public readonly startButton = queryRequiredElement('.start-button', HTMLButtonElement);
'.start-button',
HTMLButtonElement
);
private readonly splash = queryRequiredElement('.splash', HTMLDivElement); private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement); private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
private readonly loadingStatus = queryRequiredElement( private readonly loadingStatus = queryRequiredElement(
@ -17,6 +14,12 @@ export class SplashScreen {
HTMLDivElement HTMLDivElement
); );
private setVisible(element: HTMLElement, isVisible: boolean): void {
element.dataset.visible = String(isVisible);
element.setAttribute('aria-hidden', String(!isVisible));
element.inert = !isVisible;
}
public setLoadingStage(label: string, ratio: number): void { public setLoadingStage(label: string, ratio: number): void {
const percent = Math.round(clamp01(ratio) * 100); const percent = Math.round(clamp01(ratio) * 100);
this.loadingStatus.textContent = label; this.loadingStatus.textContent = label;
@ -30,7 +33,7 @@ export class SplashScreen {
const onClick = () => { const onClick = () => {
this.startButton.removeEventListener('click', onClick); this.startButton.removeEventListener('click', onClick);
onStart(); onStart();
this.splash.hidden = true; this.setVisible(this.splash, false);
resolve(); resolve();
}; };
this.startButton.addEventListener('click', onClick); this.startButton.addEventListener('click', onClick);
@ -38,10 +41,10 @@ export class SplashScreen {
} }
public showLoadingBar(): void { public showLoadingBar(): void {
this.loadingBar.hidden = false; this.setVisible(this.loadingBar, true);
} }
public hideLoadingBar(): void { public hideLoadingBar(): void {
this.loadingBar.hidden = true; this.setVisible(this.loadingBar, false);
} }
} }

View file

@ -1,8 +1,21 @@
export const AGENT_WORKGROUP_SIZE = 64; // Use the device's max workgroup size so we get full SIMD/wave occupancy on
// hardware that supports more than the WebGPU minimum of 256.
export const getAgentWorkgroupSize = (device: GPUDevice): number =>
device.limits.maxComputeInvocationsPerWorkgroup;
export const substituteAgentWorkgroupSize = (
device: GPUDevice,
shaderCode: string
): string =>
shaderCode.replaceAll(
'__AGENT_WORKGROUP_SIZE__',
String(getAgentWorkgroupSize(device))
);
export const dispatchAgentWorkgroups = ( export const dispatchAgentWorkgroups = (
passEncoder: GPUComputePassEncoder, passEncoder: GPUComputePassEncoder,
workgroupSize: number,
agentCount: number agentCount: number
): void => { ): void => {
passEncoder.dispatchWorkgroups(Math.ceil(agentCount / AGENT_WORKGROUP_SIZE), 1); passEncoder.dispatchWorkgroups(Math.ceil(agentCount / workgroupSize), 1);
}; };

View file

@ -15,40 +15,53 @@ const clearCompactedTailStride = 4u;
@group(1) @binding(2) var<storage, read_write> counters: Counters; @group(1) @binding(2) var<storage, read_write> counters: Counters;
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>; @group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
var<workgroup> workgroupAliveCount: atomic<u32>;
var<workgroup> workgroupCompactedOffset: u32; var<workgroup> workgroupCompactedOffset: u32;
var<workgroup> scanData: array<u32, agentWorkgroupSize>;
var<workgroup> clearAliveAgentCount: u32; var<workgroup> clearAliveAgentCount: u32;
@compute @workgroup_size(64) @compute @workgroup_size(agentWorkgroupSize)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32> @builtin(local_invocation_id) local_id: vec3<u32>
) { ) {
let id = get_id(global_id); let id = get_id(global_id);
let lid = local_id.x;
if local_id.x == 0u {
atomicStore(&workgroupAliveCount, 0u);
}
workgroupBarrier();
var localCompactedIndex = 0u;
var agent: Agent;
var isAlive = false; var isAlive = false;
var agent: Agent;
if id < settings.agentCount { if id < settings.agentCount {
isAlive = agents[id].colorIndex >= 0.0; isAlive = agents[id].colorIndex >= 0.0;
if isAlive { if isAlive {
agent = agents[id]; agent = agents[id];
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
} }
} }
// Hillis-Steele inclusive prefix sum across the workgroup. Replaces a
// per-thread atomicAdd to a workgroup counter, eliminating serialization
// on dense workgroups.
scanData[lid] = select(0u, 1u, isAlive);
workgroupBarrier(); workgroupBarrier();
if local_id.x == 0u { var offset: u32 = 1u;
let groupAliveCount = atomicLoad(&workgroupAliveCount); while offset < agentWorkgroupSize {
if groupAliveCount > 0u { let own = scanData[lid];
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, groupAliveCount); var contribution: u32 = 0u;
if lid >= offset {
contribution = scanData[lid - offset];
}
workgroupBarrier();
scanData[lid] = own + contribution;
workgroupBarrier();
offset = offset * 2u;
}
let inclusivePrefix = scanData[lid];
let workgroupAliveTotal = scanData[agentWorkgroupSize - 1u];
let exclusivePrefix = inclusivePrefix - select(0u, 1u, isAlive);
if lid == 0u {
if workgroupAliveTotal > 0u {
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, workgroupAliveTotal);
} else { } else {
workgroupCompactedOffset = 0u; workgroupCompactedOffset = 0u;
} }
@ -57,11 +70,11 @@ fn main(
workgroupBarrier(); workgroupBarrier();
if isAlive { if isAlive {
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent; compactedAgents[workgroupCompactedOffset + exclusivePrefix] = agent;
} }
} }
@compute @workgroup_size(64) @compute @workgroup_size(agentWorkgroupSize)
fn clearCompactedTail( fn clearCompactedTail(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32> @builtin(local_invocation_id) local_id: vec3<u32>

View file

@ -2,14 +2,16 @@ import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache'; import { createBindGroupCache } from '../../../utils/graphics/bind-group-cache';
import { smartCompile } from '../../../utils/graphics/smart-compile'; import { smartCompile } from '../../../utils/graphics/smart-compile';
import { dispatchAgentWorkgroups } from '../agent-dispatch'; import {
dispatchAgentWorkgroups,
getAgentWorkgroupSize,
substituteAgentWorkgroupSize,
} from '../agent-dispatch';
import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits'; import { AGENT_SIZE_IN_BYTES, getMaxSupportedAgentCount } from '../agent-limits';
import compactionShader from './agent-compaction.wgsl?raw'; import compactionShader from './agent-compaction.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw'; import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw'; import agentSchema from './agent-schema.wgsl?raw';
export { AGENT_FLOAT_COUNT } from '../agent-limits';
export class AgentGenerationPipeline { export class AgentGenerationPipeline {
private static readonly UNIFORM_COUNT = 4; private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 1; private static readonly COUNTER_COUNT = 1;
@ -34,6 +36,7 @@ export class AgentGenerationPipeline {
private readonly resizePipeline: GPUComputePipeline; private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline; private readonly compactionPipeline: GPUComputePipeline;
private readonly clearCompactedTailPipeline: GPUComputePipeline; private readonly clearCompactedTailPipeline: GPUComputePipeline;
private readonly workgroupSize: number;
private activeAgentsBuffer: GPUBuffer; private activeAgentsBuffer: GPUBuffer;
private inactiveAgentsBuffer: GPUBuffer; private inactiveAgentsBuffer: GPUBuffer;
@ -90,7 +93,7 @@ export class AgentGenerationPipeline {
}); });
this.activeAgentsBuffer = this.createAgentsBuffer(); this.activeAgentsBuffer = this.createAgentsBuffer();
this.inactiveAgentsBuffer = this.createInactivePlaceholderBuffer(); this.inactiveAgentsBuffer = this.createAgentsBuffer();
this.countersBuffer = this.device.createBuffer({ this.countersBuffer = this.device.createBuffer({
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT, size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
@ -107,17 +110,20 @@ export class AgentGenerationPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
this.workgroupSize = getAgentWorkgroupSize(device);
const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema);
this.resizePipeline = device.createComputePipeline({ this.resizePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: smartCompile(device, agentSchema, resizeShader), module: smartCompile(device, sizedSchema, resizeShader),
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
const compactionModule = smartCompile(device, agentSchema, compactionShader); const compactionModule = smartCompile(device, sizedSchema, compactionShader);
this.compactionPipeline = device.createComputePipeline({ this.compactionPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
@ -151,16 +157,6 @@ export class AgentGenerationPipeline {
}); });
} }
// The inactive slot only needs a real allocation during compaction. The rest of
// the time we keep a one-agent placeholder so the bind group at binding 3 stays
// valid for resize without holding a second N-agent buffer in GPU memory.
private createInactivePlaceholderBuffer(): GPUBuffer {
return this.device.createBuffer({
size: AGENT_SIZE_IN_BYTES,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
}
public get maxAgentCount(): number { public get maxAgentCount(): number {
return this.allocatedMaxAgentCount; return this.allocatedMaxAgentCount;
} }
@ -187,9 +183,11 @@ export class AgentGenerationPipeline {
) )
); );
const previousActiveAgentsBuffer = this.activeAgentsBuffer; const previousActiveAgentsBuffer = this.activeAgentsBuffer;
const previousInactiveAgentsBuffer = this.inactiveAgentsBuffer;
const previousMaxAgentCount = this.allocatedMaxAgentCount; const previousMaxAgentCount = this.allocatedMaxAgentCount;
this.allocatedMaxAgentCount = nextMaxAgentCount; this.allocatedMaxAgentCount = nextMaxAgentCount;
this.activeAgentsBuffer = this.createAgentsBuffer(); this.activeAgentsBuffer = this.createAgentsBuffer();
this.inactiveAgentsBuffer = this.createAgentsBuffer();
const copyAgentCount = Math.min( const copyAgentCount = Math.min(
Math.max(0, Math.floor(activeAgentCount)), Math.max(0, Math.floor(activeAgentCount)),
@ -209,10 +207,9 @@ export class AgentGenerationPipeline {
} }
// GPUBuffer.destroy() defers actual freeing until pending submissions // GPUBuffer.destroy() defers actual freeing until pending submissions
// finish, so calling it synchronously after submit is safe and avoids the // finish, so calling it synchronously after submit is safe.
// transient 4-buffers-live spike that pushes iOS Safari past its per-tab
// memory ceiling.
previousActiveAgentsBuffer.destroy(); previousActiveAgentsBuffer.destroy();
previousInactiveAgentsBuffer.destroy();
return this.allocatedMaxAgentCount; return this.allocatedMaxAgentCount;
} }
@ -251,7 +248,7 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.resizePipeline); passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.getBindGroup()); passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, agentCount); dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
passEncoder.end(); passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
@ -262,12 +259,6 @@ export class AgentGenerationPipeline {
return 0; return 0;
} }
// Stash the placeholder, swap in a real N-agent destination buffer just
// for this compaction so the rest of the time we only carry one full
// agent buffer in memory.
const placeholder = this.inactiveAgentsBuffer;
this.inactiveAgentsBuffer = this.createAgentsBuffer();
this.agentCountUniformValues[0] = agentCount; this.agentCountUniformValues[0] = agentCount;
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues); this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
@ -276,10 +267,11 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass(); const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline); passEncoder.setPipeline(this.compactionPipeline);
passEncoder.setBindGroup(1, this.getBindGroup()); passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, agentCount); dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
passEncoder.setPipeline(this.clearCompactedTailPipeline); passEncoder.setPipeline(this.clearCompactedTailPipeline);
dispatchAgentWorkgroups( dispatchAgentWorkgroups(
passEncoder, passEncoder,
this.workgroupSize,
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE) Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
); );
passEncoder.end(); passEncoder.end();
@ -295,13 +287,6 @@ export class AgentGenerationPipeline {
this.device.queue.submit([commandEncoder.finish()]); this.device.queue.submit([commandEncoder.finish()]);
this.swapAgentBuffers(); this.swapAgentBuffers();
// After swap, inactive is the previous active (full size). Destroy it and
// restore the placeholder; the destroy is deferred by WebGPU until the
// submitted compaction work has finished.
const previousActiveAgentsBuffer = this.inactiveAgentsBuffer;
this.inactiveAgentsBuffer = placeholder;
previousActiveAgentsBuffer.destroy();
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ); await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
const compactedCount = new Uint32Array( const compactedCount = new Uint32Array(
this.countersStagingBuffer.getMappedRange(), this.countersStagingBuffer.getMappedRange(),

View file

@ -5,7 +5,7 @@ struct ResizeSettings {
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings; @group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
@compute @workgroup_size(64) @compute @workgroup_size(agentWorkgroupSize)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32> @builtin(global_invocation_id) global_id: vec3<u32>
) { ) {

View file

@ -9,7 +9,7 @@ struct Agent {
@group(1) @binding(1) var<storage, read_write> agents: array<Agent>; @group(1) @binding(1) var<storage, read_write> agents: array<Agent>;
const agentWorkgroupSize = 64u; const agentWorkgroupSize = __AGENT_WORKGROUP_SIZE__u;
fn get_id(global_id: vec3<u32>) -> u32 { fn get_id(global_id: vec3<u32>) -> u32 {
return global_id.x; return global_id.x;

View file

@ -1,8 +1,46 @@
import { AGENT_WORKGROUP_SIZE } from './agent-dispatch'; import { getAgentWorkgroupSize } from './agent-dispatch';
export const AGENT_FLOAT_COUNT = 8; export const AGENT_FLOAT_COUNT = 8;
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT; export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
const AGENT_LAYOUT = {
positionX: 0,
positionY: 1,
angle: 2,
colorIndex: 3,
targetPositionX: 4,
targetPositionY: 5,
targetAngle: 6,
introDelay: 7,
} as const;
export interface AgentLayoutValues {
angle: number;
colorIndex: number;
introDelay: number;
positionX: number;
positionY: number;
targetAngle: number;
targetPositionX: number;
targetPositionY: number;
}
export const writeAgentValues = (
target: Float32Array,
agentIndex: number,
values: AgentLayoutValues
): void => {
const base = agentIndex * AGENT_FLOAT_COUNT;
target[base + AGENT_LAYOUT.positionX] = values.positionX;
target[base + AGENT_LAYOUT.positionY] = values.positionY;
target[base + AGENT_LAYOUT.angle] = values.angle;
target[base + AGENT_LAYOUT.colorIndex] = values.colorIndex;
target[base + AGENT_LAYOUT.targetPositionX] = values.targetPositionX;
target[base + AGENT_LAYOUT.targetPositionY] = values.targetPositionY;
target[base + AGENT_LAYOUT.targetAngle] = values.targetAngle;
target[base + AGENT_LAYOUT.introDelay] = values.introDelay;
};
export const getMaxSupportedAgentCount = ( export const getMaxSupportedAgentCount = (
device: GPUDevice, device: GPUDevice,
maxAgentCountUpperLimit = Number.POSITIVE_INFINITY maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
@ -19,7 +57,8 @@ export const getMaxSupportedAgentCount = (
upperLimit, upperLimit,
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES), Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES), Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) * AGENT_WORKGROUP_SIZE Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
getAgentWorkgroupSize(device)
) )
); );
}; };

View file

@ -1,11 +1,16 @@
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache'; import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedFloat32BufferWrite, createCachedBufferWrite,
writeFloat32BufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; import { CommonState } from '../common-state/common-state';
import { dispatchAgentWorkgroups } from './agent-dispatch'; import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import {
dispatchAgentWorkgroups,
getAgentWorkgroupSize,
substituteAgentWorkgroupSize,
} from './agent-dispatch';
import agentSchema from './agent-generation/agent-schema.wgsl?raw'; import agentSchema from './agent-generation/agent-schema.wgsl?raw';
import shader from './agent.wgsl?raw'; import shader from './agent.wgsl?raw';
@ -44,11 +49,13 @@ const UNIFORM_COUNT = 30;
export class AgentPipeline { export class AgentPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline; private readonly pipeline: GPUComputePipeline;
private readonly normalPipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly workgroupSize: number;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer); private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly bindGroupCache = createBindGroupCache3< private readonly bindGroupCache = createBindGroupCache3<
GPUBuffer, GPUBuffer,
GPUTextureView, GPUTextureView,
@ -66,7 +73,6 @@ export class AgentPipeline {
); );
private agentCount = 0; private agentCount = 0;
private useIntroPipeline = true;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
@ -93,15 +99,16 @@ export class AgentPipeline {
{ {
binding: 3, binding: 3,
visibility: GPUShaderStage.COMPUTE, visibility: GPUShaderStage.COMPUTE,
storageTexture: { format: 'rgba16float' }, storageTexture: { format: TRAIL_SOURCE_TEXTURE_FORMAT },
}, },
], ],
}); });
this.workgroupSize = getAgentWorkgroupSize(device);
const shaderModule = smartCompile( const shaderModule = smartCompile(
device, device,
CommonState.shaderCode, CommonState.shaderCode,
agentSchema, substituteAgentWorkgroupSize(device, agentSchema),
shader shader
); );
const pipelineLayout = device.createPipelineLayout({ const pipelineLayout = device.createPipelineLayout({
@ -114,13 +121,6 @@ export class AgentPipeline {
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
this.normalPipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: shaderModule,
entryPoint: 'mainNormal',
},
});
this.uniforms = device.createBuffer({ this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -167,7 +167,6 @@ export class AgentPipeline {
introProgress?: number; introProgress?: number;
}) { }) {
this.agentCount = agentCount; this.agentCount = agentCount;
this.useIntroPipeline = (introProgress ?? 1) < introProgressCutoff;
this.uniformValues[0] = moveSpeed * deltaTime; this.uniformValues[0] = moveSpeed * deltaTime;
this.uniformValues[1] = turnSpeed * deltaTime; this.uniformValues[1] = turnSpeed * deltaTime;
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180; const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
@ -199,7 +198,7 @@ export class AgentPipeline {
this.uniformValues[27] = introNearMoveMultiplier; this.uniformValues[27] = introNearMoveMultiplier;
this.uniformValues[28] = introStepStopDistance; this.uniformValues[28] = introStepStopDistance;
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0; this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
writeFloat32BufferIfChanged( writeBufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
this.uniformValues, this.uniformValues,
@ -220,13 +219,13 @@ export class AgentPipeline {
const passEncoder = commandEncoder.beginComputePass( const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined timestampWrites ? { timestampWrites } : undefined
); );
passEncoder.setPipeline(this.useIntroPipeline ? this.pipeline : this.normalPipeline); passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder); this.commonState.execute(passEncoder);
passEncoder.setBindGroup( passEncoder.setBindGroup(
1, 1,
this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut) this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
); );
dispatchAgentWorkgroups(passEncoder, this.agentCount); dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
passEncoder.end(); passEncoder.end();
} }

View file

@ -1,4 +1,6 @@
const PI: f32 = 3.14159265359; const PI: f32 = 3.14159265359;
const TAU: f32 = 6.28318530718;
const INV_TAU: f32 = 0.15915494309;
struct Settings { struct Settings {
moveRate: f32, moveRate: f32,
@ -35,9 +37,9 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var trailMapIn: texture_2d<f32>; @group(1) @binding(2) var trailMapIn: texture_2d<f32>;
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>; @group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
@compute @workgroup_size(64) @compute @workgroup_size(agentWorkgroupSize)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32> @builtin(global_invocation_id) global_id: vec3<u32>
) { ) {
@ -158,79 +160,6 @@ fn main(
agents[id].position = nextPosition; agents[id].position = nextPosition;
} }
@compute @workgroup_size(64)
fn mainNormal(
@builtin(global_invocation_id) global_id: vec3<u32>
) {
let id = get_id(global_id);
if id >= settings.agentCount {
return;
}
let colorIndex = agents[id].colorIndex;
if colorIndex < 0.0 || colorIndex >= 2.5 {
return;
}
var position = agents[id].position;
var angle = agents[id].angle;
let channelMask = get_channel_mask(colorIndex);
let reactionMask = get_reaction_mask(colorIndex);
let randomSeed = random_seed(id);
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
let randomTurn = random_float(randomSeed);
let direction = vec2(cos(angle), sin(angle));
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
let leftSensor = sensor_position(
position,
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let rightSensor = sensor_position(
position,
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
settings.sensorOffset,
maxPosition
);
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
let weightForward = dot(trailForward.rgb, reactionMask);
let weightLeft = dot(trailLeft.rgb, reactionMask);
let weightRight = dot(trailRight.rgb, reactionMask);
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
if weightForward >= weightLeft && weightForward >= weightRight {
rotation = rotation * settings.forwardRotationScale;
} else {
rotation += sign(weightLeft - weightRight) * settings.turnRate;
}
let nextPosition = clamp(
position + direction * settings.moveRate,
vec2<f32>(0, 0),
maxPosition
);
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
}
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
trailBelow = vec4<f32>(
trailBelow.rgb + channelMask * settings.individualTrailWeight,
max(trailBelow.a, 0.0)
);
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
agents[id].angle = angle + rotation;
agents[id].position = nextPosition;
}
fn sensor_position( fn sensor_position(
agentPosition: vec2<f32>, agentPosition: vec2<f32>,
direction: vec2<f32>, direction: vec2<f32>,
@ -290,7 +219,8 @@ fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
} }
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 { fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle)); // Wraps to (-π, π] via fract(); replaces atan2(sin(d), cos(d)).
return (fract((targetAngle - sourceAngle) * INV_TAU + 0.5) - 0.5) * TAU;
} }
fn random_seed(id: u32) -> u32 { fn random_seed(id: u32) -> u32 {

View file

@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { import {
createCachedFloat32BufferWrite, createCachedBufferWrite,
writeFloat32BufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; import { CommonState } from '../common-state/common-state';
@ -13,6 +13,7 @@ import {
LineSegmentBuffer, LineSegmentBuffer,
} from '../common/line-segment-buffer'; } from '../common/line-segment-buffer';
import lineSegmentShader from '../common/line-segment.wgsl?raw'; import lineSegmentShader from '../common/line-segment.wgsl?raw';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import shader from './brush.wgsl?raw'; import shader from './brush.wgsl?raw';
export interface BrushSettings { export interface BrushSettings {
@ -77,7 +78,9 @@ export class BrushPipeline {
private readonly renderPipeline: GPURenderPipeline; private readonly renderPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly segments: LineSegmentBuffer; private readonly segments: LineSegmentBuffer;
public constructor( public constructor(
@ -116,7 +119,7 @@ export class BrushPipeline {
entryPoint: 'fragmentMrt', entryPoint: 'fragmentMrt',
targets: [ targets: [
{ {
format: 'rgba16float', format: TRAIL_SOURCE_TEXTURE_FORMAT,
blend: { blend: {
color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' }, color: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' }, alpha: { operation: 'max', srcFactor: 'one', dstFactor: 'one' },
@ -148,7 +151,7 @@ export class BrushPipeline {
public setParameters(parameters: BrushParameters): void { public setParameters(parameters: BrushParameters): void {
setBrushUniformValues(this.uniformValues, parameters); setBrushUniformValues(this.uniformValues, parameters);
writeFloat32BufferIfChanged( writeBufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
this.uniformValues, this.uniformValues,

View file

@ -38,10 +38,12 @@ fn vertex(
let direction = end - start; let direction = end - start;
let denominator = dot(direction, direction); let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0; var inverseLengthSquared = 0.0;
var normalizedDirection = vec2<f32>(1.0, 0.0);
if denominator > SEGMENT_LENGTH_EPSILON { if denominator > SEGMENT_LENGTH_EPSILON {
inverseLengthSquared = 1.0 / denominator; inverseLengthSquared = 1.0 / denominator;
normalizedDirection = direction * inverseSqrt(denominator);
} }
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushRadius); let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.brushRadius);
let uv = screenPosition / state.size; let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.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, direction, inverseLengthSquared); return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
@ -85,8 +87,16 @@ fn brushStrength(
return 0.0; return 0.0;
} }
// smoothstep(0.35, 1.0, sqrt(d²/r²)) reparameterized to squared distance:
// squaring the edges gives smoothstep(0.1225·r², r², d²), avoiding the sqrt.
let safeRadiusSquared = max(settings.brushRadiusSquared, 0.0001);
let feather = 1.0 - smoothstep(0.1225 * safeRadiusSquared, safeRadiusSquared, distanceSquared);
if feather <= 0.0 {
return 0.0;
}
if settings.brushGrainMinStrength == settings.brushGrainMaxStrength { if settings.brushGrainMinStrength == settings.brushGrainMaxStrength {
return settings.brushGrainMinStrength; return settings.brushGrainMinStrength * feather;
} }
let grainNoise = textureSampleLevel( let grainNoise = textureSampleLevel(
@ -96,7 +106,12 @@ fn brushStrength(
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY), vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
0.0 0.0
).r; ).r;
return mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise); let grainStrength = mix(
settings.brushGrainMinStrength,
settings.brushGrainMaxStrength,
grainNoise
);
return grainStrength * feather;
} }
fn brushOutput(strength: f32) -> vec4<f32> { fn brushOutput(strength: f32) -> vec4<f32> {

View file

@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { import {
createCachedFloat32BufferWrite, createCachedBufferWrite,
writeFloat32BufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { generateNoise } from '../../utils/graphics/noise'; import { generateNoise } from '../../utils/graphics/noise';
@ -12,10 +12,10 @@ export class CommonState {
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedBufferWrite(
CommonState.UNIFORM_COUNT CommonState.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
); );
private readonly noise: GPUTextureView; private readonly noise: GPUTexture;
private readonly bindGroup: GPUBindGroup; private readonly bindGroup: GPUBindGroup;
public readonly bindGroupLayout: GPUBindGroupLayout; public readonly bindGroupLayout: GPUBindGroupLayout;
@ -37,11 +37,12 @@ export class CommonState {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
this.noise = generateNoise({ const noise = generateNoise({
device, device,
width: appConfig.pipelines.common.noiseTextureSize, width: appConfig.pipelines.common.noiseTextureSize,
height: appConfig.pipelines.common.noiseTextureSize, height: appConfig.pipelines.common.noiseTextureSize,
}); });
this.noise = noise.texture;
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
entries: [ entries: [
@ -90,7 +91,7 @@ export class CommonState {
}, },
{ {
binding: 2, binding: 2,
resource: this.noise, resource: noise.view,
}, },
], ],
}); });
@ -99,7 +100,7 @@ export class CommonState {
public setParameters({ canvasSize }: { canvasSize: vec2 }) { public setParameters({ canvasSize }: { canvasSize: vec2 }) {
this.uniformValues[0] = canvasSize[0]; this.uniformValues[0] = canvasSize[0];
this.uniformValues[1] = canvasSize[1]; this.uniformValues[1] = canvasSize[1];
writeFloat32BufferIfChanged( writeBufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
this.uniformValues, this.uniformValues,
@ -113,5 +114,6 @@ export class CommonState {
public destroy() { public destroy() {
this.uniforms.destroy(); this.uniforms.destroy();
this.noise.destroy();
} }
} }

View file

@ -13,14 +13,9 @@ fn segment_vertex_position(
vertexIndex: u32, vertexIndex: u32,
start: vec2<f32>, start: vec2<f32>,
end: vec2<f32>, end: vec2<f32>,
direction: vec2<f32>,
radius: f32 radius: f32
) -> vec2<f32> { ) -> vec2<f32> {
let directionVector = end - start;
let segmentLength = length(directionVector);
var direction = vec2<f32>(1.0, 0.0);
if segmentLength > 0.0 {
direction = directionVector / segmentLength;
}
let perpendicular = vec2<f32>(direction.y, -direction.x); let perpendicular = vec2<f32>(direction.y, -direction.x);
let corner = segment_vertex_corner(vertexIndex % 6u); let corner = segment_vertex_corner(vertexIndex % 6u);
let center = mix(start, end, (corner.x + 1.0) * 0.5); let center = mix(start, end, (corner.x + 1.0) * 0.5);

View file

@ -9,8 +9,13 @@ struct Settings {
padding2: f32, padding2: f32,
}; };
const WORKGROUP_SIZE_X = 16u; const WORKGROUP_SIZE_X = __WORKGROUP_SIZE__u;
const WORKGROUP_SIZE_Y = 16u; const WORKGROUP_SIZE_Y = __WORKGROUP_SIZE__u;
// Half a quantization step of rgba8unorm (1/255 0.00392). Subtracted from
// RGB each frame so multiplicative decay can fall through the unorm
// quantization floor; without it, the smallest nonzero level (1/255) is a
// fixed point and trails never reach pure black.
const TRAIL_RGB_DECAY_SUBTRACT: f32 = 0.00196;
// One-pixel halo on each side so the 3x3 neighbourhood read in the main pass // One-pixel halo on each side so the 3x3 neighbourhood read in the main pass
// can be served from workgroup memory without bounds checks for interior tiles. // can be served from workgroup memory without bounds checks for interior tiles.
const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u; const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
@ -21,34 +26,29 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
@group(0) @binding(0) var<uniform> settings: Settings; @group(0) @binding(0) var<uniform> settings: Settings;
@group(0) @binding(1) var trailMap: texture_2d<f32>; @group(0) @binding(1) var trailMap: texture_2d<f32>;
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba16float, write>; @group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
var<workgroup> tile: array<vec4<f32>, 324>; var<workgroup> tile: array<vec4<f32>, 324>;
var<workgroup> tileTrailStrength: array<f32, 324>; var<workgroup> tileTrailStrength: array<f32, 324>;
@compute @workgroup_size(16, 16) @compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32>, @builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>, @builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(workgroup_id) workgroup_id: vec3<u32> @builtin(workgroup_id) workgroup_id: vec3<u32>
) { ) {
let textureSize = vec2<i32>(textureDimensions(trailMap, 0)); let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
let textureSizeU32 = vec2<u32>(textureSize); let textureBound = textureSize - vec2<i32>(1, 1);
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x; let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y); let workgroupOrigin = workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y);
let isInteriorTile =
workgroupOrigin.x > 0u &&
workgroupOrigin.y > 0u &&
workgroupOrigin.x + WORKGROUP_SIZE_X < textureSizeU32.x &&
workgroupOrigin.y + WORKGROUP_SIZE_Y < textureSizeU32.y;
for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) { for (var tileIndex = localLinearIndex; tileIndex < TILE_TEXEL_COUNT; tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y) {
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X); let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
let unclampedSourcePixel = vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1); let sourcePixel = clamp(
var sourcePixel = unclampedSourcePixel; vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1),
if !isInteriorTile { vec2<i32>(0, 0),
sourcePixel = clamp(unclampedSourcePixel, vec2<i32>(0, 0), textureSize - vec2<i32>(1, 1)); textureBound
} );
let texel = textureLoad(trailMap, sourcePixel, 0); let texel = textureLoad(trailMap, sourcePixel, 0);
tile[tileIndex] = texel; tile[tileIndex] = texel;
tileTrailStrength[tileIndex] = length(texel.rgb); tileTrailStrength[tileIndex] = length(texel.rgb);
@ -57,53 +57,67 @@ fn main(
workgroupBarrier(); workgroupBarrier();
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y)); let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
let inBounds = pixel.x < textureSize.x && pixel.y < textureSize.y; if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
if !inBounds {
return; return;
} }
let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u); let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u);
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x; let c = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
var current = tile[centerTileIndex]; let rowNorth = c - TILE_SIZE_X;
let rowSouth = c + TILE_SIZE_X;
// Batch-load all 8 neighbour texels and strengths into registers up front
// so the compiler can schedule LDS reads in parallel.
let current = tile[c];
let nTL = tile[rowNorth - 1u];
let nT = tile[rowNorth];
let nTR = tile[rowNorth + 1u];
let nL = tile[c - 1u];
let nR = tile[c + 1u];
let nBL = tile[rowSouth - 1u];
let nB = tile[rowSouth];
let nBR = tile[rowSouth + 1u];
let sTL = tileTrailStrength[rowNorth - 1u];
let sT = tileTrailStrength[rowNorth];
let sTR = tileTrailStrength[rowNorth + 1u];
let sL = tileTrailStrength[c - 1u];
let sR = tileTrailStrength[c + 1u];
let sBL = tileTrailStrength[rowSouth - 1u];
let sB = tileTrailStrength[rowSouth];
let sBR = tileTrailStrength[rowSouth + 1u];
let random = random_from_pixel(pixel); let random = random_from_pixel(pixel);
let trailWeight = diffusion_weight( let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
random,
settings.inverseDiffusionRateTrails
);
current += (
propagate(centerTileIndex, -1, -1, current, trailWeight)
+ propagate(centerTileIndex, -1, 1, current, trailWeight)
+ propagate(centerTileIndex, 1, -1, current, trailWeight)
+ propagate(centerTileIndex, 1, 1, current, trailWeight)
+ propagate(centerTileIndex, -1, 0, current, trailWeight) let propagated =
+ propagate(centerTileIndex, 0, -1, current, trailWeight) propagate_value(nTL, sTL, current, trailWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight) + propagate_value(nT, sT, current, trailWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight) + propagate_value(nTR, sTR, current, trailWeight)
) * settings.diffusionNeighborScale; + propagate_value(nL, sL, current, trailWeight)
+ propagate_value(nR, sR, current, trailWeight)
+ propagate_value(nBL, sBL, current, trailWeight)
+ propagate_value(nB, sB, current, trailWeight)
+ propagate_value(nBR, sBR, current, trailWeight);
let updated = current + propagated * settings.diffusionNeighborScale;
let decayed = clamp(vec4( let decayed = clamp(vec4(
current.rgb * settings.decayRateTrails, updated.rgb * settings.decayRateTrails - vec3(TRAIL_RGB_DECAY_SUBTRACT),
max(0, current.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract) updated.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract
), vec4(0), vec4(1)); ), vec4(0), vec4(1));
textureStore(trailMapOut, pixel, decayed); textureStore(trailMapOut, pixel, decayed);
} }
fn propagate( fn propagate_value(
centerTileIndex: u32, neighbour: vec4<f32>,
offsetX: i32, neighbourStrength: f32,
offsetY: i32, current: vec4<f32>,
currentColor: vec4<f32>,
trailWeight: f32 trailWeight: f32
) -> vec4<f32> { ) -> vec4<f32> {
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX; let difference = clamp(neighbour - current, vec4(0), vec4(1));
let neighbourTileIndex = u32(neighbourIndex);
let neighbour = tile[neighbourTileIndex];
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
return vec4( return vec4(
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight), vec3(neighbourStrength * trailWeight),
neighbour.a * trailWeight neighbour.a * trailWeight
) * difference; ) * difference;
} }

View file

@ -3,10 +3,11 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedFloat32BufferWrite, createCachedBufferWrite,
writeFloat32BufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import shader from './diffuse.wgsl?raw'; import shader from './diffuse.wgsl?raw';
export interface DiffusionSettings { export interface DiffusionSettings {
@ -69,8 +70,8 @@ export class DiffusionPipeline {
private readonly pipeline: GPUComputePipeline; private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedBufferWrite(
DiffusionPipeline.UNIFORM_COUNT DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
); );
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>( private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
(trailMapIn, trailMapOut) => (trailMapIn, trailMapOut) =>
@ -94,7 +95,7 @@ export class DiffusionPipeline {
bindGroupLayouts: [this.bindGroupLayout], bindGroupLayouts: [this.bindGroupLayout],
}), }),
compute: { compute: {
module: smartCompile(device, shader), module: smartCompile(device, this.shaderCode),
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
@ -121,7 +122,7 @@ export class DiffusionPipeline {
diffusionNeighborDivisor, diffusionNeighborDivisor,
brushDecayAlphaOffset, brushDecayAlphaOffset,
}); });
writeFloat32BufferIfChanged( writeBufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
this.uniformValues, this.uniformValues,
@ -176,10 +177,17 @@ export class DiffusionPipeline {
visibility: GPUShaderStage.COMPUTE, visibility: GPUShaderStage.COMPUTE,
storageTexture: { storageTexture: {
access: 'write-only', access: 'write-only',
format: 'rgba16float', format: TRAIL_SOURCE_TEXTURE_FORMAT,
}, },
}, },
], ],
}; };
} }
private get shaderCode(): string {
return shader.replaceAll(
'__WORKGROUP_SIZE__',
DiffusionPipeline.WORKGROUP_SIZE.toString()
);
}
} }

View file

@ -2,11 +2,15 @@ import { vec2 } from 'gl-matrix';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedFloat32BufferWrite, createCachedBufferWrite,
writeFloat32BufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { dispatchAgentWorkgroups } from '../agents/agent-dispatch'; import {
dispatchAgentWorkgroups,
getAgentWorkgroupSize,
substituteAgentWorkgroupSize,
} from '../agents/agent-dispatch';
import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw'; import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import shader from './eraser-agent.wgsl?raw'; import shader from './eraser-agent.wgsl?raw';
@ -25,8 +29,8 @@ export class EraserAgentPipeline {
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT); private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer); private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite( private readonly uniformCache = createCachedBufferWrite(
EraserAgentPipeline.UNIFORM_COUNT EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
); );
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>( private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
(agentsBuffer, eraserMask) => (agentsBuffer, eraserMask) =>
@ -44,6 +48,7 @@ export class EraserAgentPipeline {
private activeSegmentCount = 0; private activeSegmentCount = 0;
private pendingBounds: Bounds | null = null; private pendingBounds: Bounds | null = null;
private agentCount = 0; private agentCount = 0;
private readonly workgroupSize: number;
public constructor( public constructor(
private readonly device: GPUDevice, private readonly device: GPUDevice,
@ -81,12 +86,17 @@ export class EraserAgentPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
}); });
this.workgroupSize = getAgentWorkgroupSize(device);
this.pipeline = device.createComputePipeline({ this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}), }),
compute: { compute: {
module: smartCompile(device, agentSchema, shader), module: smartCompile(
device,
substituteAgentWorkgroupSize(device, agentSchema),
shader
),
entryPoint: 'main', entryPoint: 'main',
}, },
}); });
@ -128,7 +138,7 @@ export class EraserAgentPipeline {
this.uniformValues[5] = activeBounds.minY; this.uniformValues[5] = activeBounds.minY;
this.uniformValues[6] = activeBounds.maxX; this.uniformValues[6] = activeBounds.maxX;
this.uniformValues[7] = activeBounds.maxY; this.uniformValues[7] = activeBounds.maxY;
writeFloat32BufferIfChanged( writeBufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
this.uniformValues, this.uniformValues,
@ -154,7 +164,7 @@ export class EraserAgentPipeline {
); );
passEncoder.setPipeline(this.pipeline); passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask)); passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
dispatchAgentWorkgroups(passEncoder, this.agentCount); dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
passEncoder.end(); passEncoder.end();
} }

View file

@ -10,7 +10,7 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var eraserMask: texture_2d<f32>; @group(1) @binding(2) var eraserMask: texture_2d<f32>;
@compute @workgroup_size(64) @compute @workgroup_size(agentWorkgroupSize)
fn main( fn main(
@builtin(global_invocation_id) global_id: vec3<u32> @builtin(global_invocation_id) global_id: vec3<u32>
) { ) {
@ -26,11 +26,7 @@ fn main(
} }
let position = agents[id].position; let position = agents[id].position;
let outsideBounds = position.x < settings.boundsMin.x || if any(position < settings.boundsMin) || any(position > settings.boundsMax) {
position.y < settings.boundsMin.y ||
position.x > settings.boundsMax.x ||
position.y > settings.boundsMax.y;
if outsideBounds {
return; return;
} }

View file

@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config'; import { appConfig } from '../../config';
import { import {
createCachedFloat32BufferWrite, createCachedBufferWrite,
writeFloat32BufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state'; import { CommonState } from '../common-state/common-state';
@ -13,6 +13,10 @@ import {
LineSegmentBuffer, LineSegmentBuffer,
} from '../common/line-segment-buffer'; } from '../common/line-segment-buffer';
import lineSegmentShader from '../common/line-segment.wgsl?raw'; import lineSegmentShader from '../common/line-segment.wgsl?raw';
import {
ERASER_MASK_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
} from '../texture-formats';
import shader from './eraser-texture.wgsl?raw'; import shader from './eraser-texture.wgsl?raw';
interface EraserTextureParameters { interface EraserTextureParameters {
@ -25,7 +29,11 @@ interface EraserTextureParameters {
} }
const UNIFORM_COUNT = 8; const UNIFORM_COUNT = 8;
const TARGET_FORMATS: Array<GPUTextureFormat> = ['r8unorm', 'rgba16float', 'rgba16float']; const TARGET_FORMATS: Array<GPUTextureFormat> = [
ERASER_MASK_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
TRAIL_SOURCE_TEXTURE_FORMAT,
];
export class EraserTexturePipeline { export class EraserTexturePipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
@ -33,7 +41,9 @@ export class EraserTexturePipeline {
private readonly combinedPipeline: GPURenderPipeline; private readonly combinedPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); private readonly uniformCache = createCachedBufferWrite(
UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly segments: LineSegmentBuffer; private readonly segments: LineSegmentBuffer;
public constructor( public constructor(
@ -114,7 +124,7 @@ export class EraserTexturePipeline {
this.uniformValues[4] = eraserClearBlue; this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha; this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = eraserRadius; this.uniformValues[6] = eraserRadius;
writeFloat32BufferIfChanged( writeBufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
this.uniformValues, this.uniformValues,

View file

@ -33,10 +33,12 @@ fn vertex(
let direction = end - start; let direction = end - start;
let denominator = dot(direction, direction); let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0; var inverseLengthSquared = 0.0;
var normalizedDirection = vec2<f32>(1.0, 0.0);
if denominator > settings.lineDistanceEpsilon { if denominator > settings.lineDistanceEpsilon {
inverseLengthSquared = 1.0 / denominator; inverseLengthSquared = 1.0 / denominator;
normalizedDirection = direction * inverseSqrt(denominator);
} }
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.eraserRadius); let screenPosition = segment_vertex_position(vertexIndex, start, end, normalizedDirection, settings.eraserRadius);
let uv = screenPosition / state.size; let uv = screenPosition / state.size;
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.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, direction, inverseLengthSquared); return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);

View file

@ -1,7 +1,7 @@
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache'; import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import { import {
createCachedFloat32BufferWrite, createCachedBufferWrite,
writeFloat32BufferIfChanged, writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write'; } from '../../utils/graphics/cached-buffer-write';
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad'; import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
import { smartCompile } from '../../utils/graphics/smart-compile'; import { smartCompile } from '../../utils/graphics/smart-compile';
@ -14,22 +14,21 @@ export interface RenderSettings {
renderTraceNormalizationFloor: number; renderTraceNormalizationFloor: number;
renderBrushColorBase: number; renderBrushColorBase: number;
renderBrushColorStrengthMultiplier: number; renderBrushColorStrengthMultiplier: number;
backgroundGrainStrength: number;
} }
// 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 5 scalars = 20 floats. // 3 channel colors (vec3 + f32 padding) + bg color (vec3) + 4 scalars,
// rounded up to 20 floats for 16-byte uniform alignment.
const UNIFORM_COUNT = 20; const UNIFORM_COUNT = 20;
export class RenderPipeline { export class RenderPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout; private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPURenderPipeline; private readonly pipeline: GPURenderPipeline;
private readonly noSourcePipeline: GPURenderPipeline; private readonly noSourcePipeline: GPURenderPipeline;
private readonly noGrainPipeline: GPURenderPipeline;
private readonly noSourceNoGrainPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer; private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT); private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(UNIFORM_COUNT); private readonly uniformCache = createCachedBufferWrite(
private useBackgroundGrain = true; UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>( private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
(colorTexture, sourceTexture) => (colorTexture, sourceTexture) =>
@ -46,7 +45,8 @@ export class RenderPipeline {
public constructor( public constructor(
private readonly context: GPUCanvasContext, private readonly context: GPUCanvasContext,
private readonly device: GPUDevice, private readonly device: GPUDevice,
private readonly commonState: CommonState private readonly commonState: CommonState,
private readonly canvasFormat: GPUTextureFormat
) { ) {
this.bindGroupLayout = device.createBindGroupLayout({ this.bindGroupLayout = device.createBindGroupLayout({
entries: [ entries: [
@ -70,7 +70,6 @@ export class RenderPipeline {
const shaderModule = smartCompile(device, CommonState.shaderCode, shader); const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
const vertex = setUpFullScreenQuad(device); const vertex = setUpFullScreenQuad(device);
const format = navigator.gpu.getPreferredCanvasFormat();
const pipelineLayout = device.createPipelineLayout({ const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout], bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}); });
@ -78,30 +77,16 @@ export class RenderPipeline {
pipelineLayout, pipelineLayout,
vertex, vertex,
shaderModule, shaderModule,
format, this.canvasFormat,
'fragment' 'fragment'
); );
this.noSourcePipeline = this.createPipeline( this.noSourcePipeline = this.createPipeline(
pipelineLayout, pipelineLayout,
vertex, vertex,
shaderModule, shaderModule,
format, this.canvasFormat,
'fragmentNoSource' 'fragmentNoSource'
); );
this.noGrainPipeline = this.createPipeline(
pipelineLayout,
vertex,
shaderModule,
format,
'fragmentNoGrain'
);
this.noSourceNoGrainPipeline = this.createPipeline(
pipelineLayout,
vertex,
shaderModule,
format,
'fragmentNoSourceNoGrain'
);
this.uniforms = device.createBuffer({ this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT, size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -135,7 +120,6 @@ export class RenderPipeline {
renderTraceNormalizationFloor, renderTraceNormalizationFloor,
renderBrushColorBase, renderBrushColorBase,
renderBrushColorStrengthMultiplier, renderBrushColorStrengthMultiplier,
backgroundGrainStrength,
}: RenderSettings & { }: RenderSettings & {
channelColors: [RgbColor, RgbColor, RgbColor]; channelColors: [RgbColor, RgbColor, RgbColor];
backgroundColor: RgbColor; backgroundColor: RgbColor;
@ -158,9 +142,7 @@ export class RenderPipeline {
this.uniformValues[16] = renderTraceNormalizationFloor; this.uniformValues[16] = renderTraceNormalizationFloor;
this.uniformValues[17] = renderBrushColorBase; this.uniformValues[17] = renderBrushColorBase;
this.uniformValues[18] = renderBrushColorStrengthMultiplier; this.uniformValues[18] = renderBrushColorStrengthMultiplier;
this.uniformValues[19] = backgroundGrainStrength; writeBufferIfChanged(
this.useBackgroundGrain = backgroundGrainStrength !== 0;
writeFloat32BufferIfChanged(
this.device, this.device,
this.uniforms, this.uniforms,
this.uniformValues, this.uniformValues,
@ -232,10 +214,7 @@ export class RenderPipeline {
} }
private getPipeline(useSourceTexture: boolean): GPURenderPipeline { private getPipeline(useSourceTexture: boolean): GPURenderPipeline {
if (useSourceTexture) { return useSourceTexture ? this.pipeline : this.noSourcePipeline;
return this.useBackgroundGrain ? this.pipeline : this.noGrainPipeline;
}
return this.useBackgroundGrain ? this.noSourcePipeline : this.noSourceNoGrainPipeline;
} }
public destroy() { public destroy() {

View file

@ -10,32 +10,14 @@ struct Settings {
traceNormalizationFloor: f32, traceNormalizationFloor: f32,
brushColorBase: f32, brushColorBase: f32,
brushColorStrengthMultiplier: f32, brushColorStrengthMultiplier: f32,
backgroundGrainStrength: f32,
}; };
@group(1) @binding(0) var<uniform> settings: Settings; @group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var trailMap: texture_2d<f32>; @group(1) @binding(2) var trailMap: texture_2d<f32>;
@group(1) @binding(3) var sourceMap: texture_2d<f32>; @group(1) @binding(3) var sourceMap: texture_2d<f32>;
const NOISE_TEXTURE_MASK = 2047u;
@fragment @fragment
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> { fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
return renderColor(traces, sources, getTexturedBackground(pixel));
}
@fragment
fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0);
return renderColor(traces, vec4<f32>(0.0), getTexturedBackground(pixel));
}
@fragment
fn fragmentNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy); let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0); let traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0); let sources = textureLoad(sourceMap, pixel, 0);
@ -43,39 +25,30 @@ fn fragmentNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<
} }
@fragment @fragment
fn fragmentNoSourceNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> { fn fragmentNoSource(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(position.xy); let pixel = vec2<i32>(position.xy);
let traces = textureLoad(trailMap, pixel, 0); let traces = textureLoad(trailMap, pixel, 0);
return renderColor(traces, vec4<f32>(0.0), getFlatBackground()); return renderColor(traces, vec4<f32>(0.0), getFlatBackground());
} }
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> { fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
let tracesMax = maxComponent(traces.rgb); let traceStrengths = clarity(traces.rgb);
let sourcesMax = maxComponent(sources.rgb); let sourceStrengths = clarity(sources.rgb);
if max(tracesMax, sourcesMax) <= 0.0 { let traceStrength = maxComponent(traceStrengths);
let brushStrength = maxComponent(sourceStrengths);
if max(traceStrength, brushStrength) <= 0.0 {
return vec4(background, 1); return vec4(background, 1);
} }
let traceStrengths = vec3( if brushStrength <= 0.0 {
clarity(traces.r),
clarity(traces.g),
clarity(traces.b)
);
if sourcesMax <= 0.0 {
let traceColor = let traceColor =
traceStrengths.r * settings.colorA traceStrengths.r * settings.colorA
+ traceStrengths.g * settings.colorB + traceStrengths.g * settings.colorB
+ traceStrengths.b * settings.colorC; + traceStrengths.b * settings.colorC;
let normalizedTraceColor = normalizeColorIntensity(traceColor); let normalizedTraceColor = normalizeColorIntensity(traceColor);
let traceStrength = maxComponent(traceStrengths);
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1); return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
} }
let sourceStrengths = vec3(
clarity(sources.r),
clarity(sources.g),
clarity(sources.b)
);
let strengths = max(traceStrengths, sourceStrengths); let strengths = max(traceStrengths, sourceStrengths);
let traceColor = let traceColor =
strengths.r * settings.colorA strengths.r * settings.colorA
@ -87,7 +60,6 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) ->
+ sourceStrengths.g * settings.colorB + sourceStrengths.g * settings.colorB
+ sourceStrengths.b * settings.colorC; + sourceStrengths.b * settings.colorC;
let normalizedBrushColor = normalizeColorIntensity(brushColor); let normalizedBrushColor = normalizeColorIntensity(brushColor);
let brushStrength = maxComponent(sourceStrengths);
let brushVisibility = clamp( let brushVisibility = clamp(
brushStrength * ( brushStrength * (
settings.brushColorBase + settings.brushColorBase +
@ -106,12 +78,8 @@ fn maxComponent(v: vec3<f32>) -> f32 {
return max(max(v.r, v.g), v.b); return max(max(v.r, v.g), v.b);
} }
fn clarity(strength: f32) -> f32 { fn clarity(strength: vec3<f32>) -> vec3<f32> {
let clamped = clamp(strength, 0, 1); return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
if settings.clarity == 1.0 {
return clamped;
}
return pow(clamped, settings.clarity);
} }
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> { fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
@ -122,14 +90,3 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
fn getFlatBackground() -> vec3<f32> { fn getFlatBackground() -> vec3<f32> {
return clamp(settings.backgroundColor, vec3(0), vec3(1)); return clamp(settings.backgroundColor, vec3(0), vec3(1));
} }
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
return clamp(
settings.backgroundColor + vec3(grain * settings.backgroundGrainStrength),
vec3(0),
vec3(1)
);
}

View file

@ -0,0 +1,2 @@
export const TRAIL_SOURCE_TEXTURE_FORMAT = 'rgba8unorm' satisfies GPUTextureFormat;
export const ERASER_MASK_TEXTURE_FORMAT = 'r8unorm' satisfies GPUTextureFormat;

View file

@ -1,32 +1,63 @@
import { appConfig, type GardenRuntimeSettings } from './config'; import {
appConfig,
normalizeRuntimeSettings,
type GardenRuntimeSettings,
} from './config';
import { writeBrowserStorage } from './utils/browser-storage'; import { writeBrowserStorage } from './utils/browser-storage';
import { getInitialVibe, type VibePreset } from './vibes'; import { getInitialVibe, type VibePreset } from './vibes';
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => ({ const preservedRuntimeSettingKeys = [
...appConfig.defaultSettings, 'eraserSize',
eraserSize: appConfig.toolbar.eraser.default, 'adaptiveCapInitial',
mirrorSegmentCount: appConfig.toolbar.mirror.default, 'adaptiveCapMin',
...vibe.settings, 'internalRenderAreaMegapixels',
'maxAgentCount',
'mirrorSegmentCount',
] satisfies ReadonlyArray<keyof GardenRuntimeSettings>;
const cloneRgbColor = <T extends [number, number, number]>(color: T): T =>
[...color] as T;
const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
...vibe,
colors: vibe.colors.map(cloneRgbColor) as VibePreset['colors'],
backgroundColor: cloneRgbColor(vibe.backgroundColor),
settings: { ...vibe.settings },
audio: { ...vibe.audio },
}); });
export let activeVibe = getInitialVibe(); const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
normalizeRuntimeSettings(
{
...appConfig.defaultSettings,
eraserSize: appConfig.toolbar.eraser.default,
mirrorSegmentCount: appConfig.toolbar.mirror.default,
...vibe.settings,
},
appConfig.runtimeSettings.controls
);
export let activeVibe = cloneVibePreset(getInitialVibe());
export const settings: GardenRuntimeSettings = { export const settings: GardenRuntimeSettings = {
...buildSettings(activeVibe), ...buildSettings(activeVibe),
}; };
export const applyVibeSettings = (vibe: VibePreset) => { export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe = vibe; activeVibe = cloneVibePreset(vibe);
Object.assign(settings, { const nextSettings = buildSettings(activeVibe);
...buildSettings(vibe), preservedRuntimeSettingKeys.forEach((key) => {
eraserSize: settings.eraserSize, nextSettings[key] = settings[key];
adaptiveCapInitial: settings.adaptiveCapInitial,
adaptiveCapMin: settings.adaptiveCapMin,
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
maxAgentCount: settings.maxAgentCount,
mirrorSegmentCount: settings.mirrorSegmentCount,
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
}); });
nextSettings.selectedColorIndex = Math.min(
settings.selectedColorIndex,
activeVibe.colors.length - 1
);
Object.assign(
settings,
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
);
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id); writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);

View file

@ -46,19 +46,19 @@ html > body {
} }
&::before { &::before {
opacity: clamp(0, calc(var(--garden-grain-strength) * 12), 0.44); opacity: clamp(0, calc(var(--garden-grain-strength) * 4.25), 0.24);
background-image: $grain-noise-a; background-image: $grain-noise-a;
background-size: 257px 257px; background-size: 257px 257px;
filter: contrast(190%) brightness(0.66); filter: contrast(145%) brightness(0.82);
mix-blend-mode: multiply; mix-blend-mode: multiply;
} }
&::after { &::after {
opacity: clamp(0, calc(var(--garden-grain-strength) * 7), 0.24); opacity: clamp(0, calc(var(--garden-grain-strength) * 2.5), 0.12);
background-image: $grain-noise-b; background-image: $grain-noise-b;
background-position: 73px 41px; background-position: 73px 41px;
background-size: 389px 389px; background-size: 389px 389px;
filter: contrast(170%) brightness(1.02); filter: contrast(135%) brightness(1);
mix-blend-mode: screen; mix-blend-mode: screen;
transform: rotate(0.01deg); transform: rotate(0.01deg);
} }

View file

@ -1,5 +1,3 @@
@use 'mixins' as *;
.config-pane-container { .config-pane-container {
--config-pane-available-height: calc( --config-pane-available-height: calc(
100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) 100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
@ -31,6 +29,8 @@
} }
.config-pane { .config-pane {
--tp-blade-value-width: min(260px, 64%);
width: 100%; width: 100%;
max-height: calc(var(--config-pane-available-height) - 36px); max-height: calc(var(--config-pane-available-height) - 36px);
overflow-x: hidden; overflow-x: hidden;
@ -40,6 +40,19 @@
scrollbar-width: thin; scrollbar-width: thin;
touch-action: pan-y; touch-action: pan-y;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
.tp-lblv_l {
padding-right: 10px;
}
.tp-sldtxtv_s {
flex: 1 1 auto;
min-width: 0;
}
.tp-sldtxtv_t {
flex: 0 0 54px;
}
} }
.config-pane-close { .config-pane-close {
@ -121,10 +134,14 @@
} }
.config-pane { .config-pane {
--tp-blade-value-width: min(128px, 38vw); --tp-blade-value-width: min(210px, 62%);
--tp-container-unit-size: 18px; --tp-container-unit-size: 18px;
font-size: 11px; font-size: 11px;
.tp-sldtxtv_t {
flex-basis: 48px;
}
} }
.config-pane-close { .config-pane-close {
@ -133,11 +150,7 @@
} }
} }
@include on-small-screen { @media (max-width: 599px), (hover: none) and (pointer: coarse) {
@include mobile-config-pane;
}
@media (hover: none) and (pointer: coarse) {
@include mobile-config-pane; @include mobile-config-pane;
} }
@ -147,7 +160,7 @@
.color-reaction-matrix { .color-reaction-matrix {
display: grid; display: grid;
grid-template-columns: minmax(42px, max-content) repeat(3, minmax(0, 1fr)); grid-template-columns: 28px repeat(3, minmax(0, 1fr));
gap: 4px; gap: 4px;
align-items: stretch; align-items: stretch;
} }
@ -165,7 +178,7 @@
} }
.color-reaction-matrix__header { .color-reaction-matrix__header {
gap: 5px; gap: 0;
} }
.color-reaction-matrix__corner { .color-reaction-matrix__corner {

View file

@ -1,10 +1,11 @@
.loading-indicator { .loading-indicator {
--loading-gap: 22px;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 22px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 3; z-index: 3;
@ -13,6 +14,7 @@
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity var(--transition-time-long); transition: opacity var(--transition-time-long);
contain: layout;
> .splash { > .splash {
display: flex; display: flex;
@ -20,9 +22,19 @@
gap: 16px; gap: 16px;
align-items: center; align-items: center;
pointer-events: auto; pointer-events: auto;
opacity: 1;
visibility: visible;
transition:
opacity var(--transition-time),
visibility 0s linear 0s;
&[hidden] { &[data-visible='false'] {
display: none; opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--transition-time),
visibility 0s linear var(--transition-time);
} }
> .splash-title { > .splash-title {
@ -89,14 +101,28 @@
} }
> .loading-bar { > .loading-bar {
position: absolute;
top: calc(100% + var(--loading-gap));
left: 0;
right: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
align-items: center; align-items: center;
width: 100%; width: 100%;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity var(--transition-time),
visibility 0s linear var(--transition-time);
&[hidden] { &[data-visible='true'] {
display: none; opacity: 1;
visibility: visible;
transition:
opacity var(--transition-time),
visibility 0s linear 0s;
} }
> .loading-status { > .loading-status {
@ -147,7 +173,7 @@ html > body.is-loading {
} }
.eraser-preview { .eraser-preview {
display: none; visibility: hidden;
} }
aside.control-dock { aside.control-dock {

View file

@ -1,6 +1,6 @@
@use 'shared' as *; @use 'shared' as *;
html > body > aside.control-dock > .toolbar-row > nav.buttons { .buttons {
grid-area: buttons; grid-area: buttons;
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -58,6 +58,10 @@ html > body > aside.control-dock > .toolbar-row > nav.buttons {
} }
} }
&.full-screen-toggle.active::after {
mask-image: url('../../../assets/icons/minimize.svg');
}
&.sound.muted::before { &.sound.muted::before {
content: ''; content: '';
position: absolute; position: absolute;

View file

@ -1,6 +1,6 @@
@use 'shared' as *; @use 'shared' as *;
html > body > aside.control-dock > .toolbar-row > .toolbar-shell > .garden-controls { .garden-controls {
grid-area: swatches; grid-area: swatches;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -1,6 +1,6 @@
@use 'shared' as *; @use 'shared' as *;
html > body > aside.control-dock > .toolbar-row { .toolbar-row {
--toolbar-background-opacity: 0%; --toolbar-background-opacity: 0%;
--toolbar-background-strength: 0; --toolbar-background-strength: 0;
--toolbar-divider-space: clamp(6px, 1.8vw, 14px); --toolbar-divider-space: clamp(6px, 1.8vw, 14px);

View file

@ -1,6 +1,6 @@
@use '../mixins' as *; @use '../mixins' as *;
html > body > aside.control-dock > .toolbar-row { .toolbar-row {
@include on-small-screen { @include on-small-screen {
--toolbar-divider-space: 4px; --toolbar-divider-space: 4px;
--toolbar-top-max-width: 329px; --toolbar-top-max-width: 329px;
@ -124,7 +124,7 @@ html > body > aside.control-dock > .toolbar-row {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html > body > aside.control-dock > .toolbar-row { .toolbar-row {
> .vibe-button.previous-vibe:hover, > .vibe-button.previous-vibe:hover,
> .vibe-button.next-vibe:hover, > .vibe-button.next-vibe:hover,
> .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover, > .toolbar-shell > .garden-controls > .swatches > .color-swatch:hover,

View file

@ -1,7 +1,6 @@
$toolbar-icons: ( $toolbar-icons: (
info: 'info', info: 'info',
maximize-full-screen: 'maximize', full-screen-toggle: 'maximize',
minimize-full-screen: 'minimize',
settings: 'settings', settings: 'settings',
sound: 'sound', sound: 'sound',
export-4k: 'download', export-4k: 'download',

View file

@ -3,14 +3,13 @@ import { clamp } from './math';
export class DeltaTimeCalculator { export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null; private previousTime: DOMHighResTimeStamp | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
constructor() { constructor() {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); document.addEventListener('visibilitychange', this.visibilityChangeListener);
} }
public calculateDeltaTimeInSeconds( public calculateDeltaTimeInSeconds(currentTime: DOMHighResTimeStamp): number {
currentTime: DOMHighResTimeStamp
): DOMHighResTimeStamp {
if (this.previousTime === null) { if (this.previousTime === null) {
this.previousTime = currentTime; this.previousTime = currentTime;
} }
@ -29,4 +28,8 @@ export class DeltaTimeCalculator {
this.previousTime = null; this.previousTime = null;
} }
} }
public destroy(): void {
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
}
} }

View file

@ -22,40 +22,3 @@ export const queryRequiredElement = <T extends Element>(
return element; return element;
}; };
export const queryRequiredElements = <T extends Element>(
selector: string,
constructor: ElementConstructor<T>
): Array<T> => {
const elements = Array.from(document.querySelectorAll(selector));
if (elements.length === 0) {
throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING,
`Missing required DOM elements: ${selector}`,
{
details: {
expectedType: constructor.name,
selector,
},
}
);
}
return elements.map((element) => {
if (!(element instanceof constructor)) {
throw new RuntimeError(
ErrorCode.DOM_ELEMENT_MISSING,
`DOM element has the wrong type: ${selector}`,
{
details: {
actualType: element.constructor.name,
expectedType: constructor.name,
selector,
},
}
);
}
return element;
});
};

View file

@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import {
createCachedBufferWrite,
updateCachedBufferWrite,
writeBufferIfChanged,
} from './cached-buffer-write';
describe('cached buffer writes', () => {
it('compares raw bytes so aliased uint changes are detected', () => {
const values = new Float32Array(1);
const uintValues = new Uint32Array(values.buffer);
const cache = createCachedBufferWrite(values.byteLength);
uintValues[0] = 0x7fc00001;
expect(updateCachedBufferWrite(values, cache)).toBe(true);
expect(updateCachedBufferWrite(values, cache)).toBe(false);
uintValues[0] = 0x7fc00002;
expect(Number.isNaN(values[0])).toBe(true);
expect(updateCachedBufferWrite(values, cache)).toBe(true);
});
it('writes to the GPU queue only when the raw buffer changed', () => {
const values = new Uint32Array([1, 2, 3, 4]);
const writeBuffer = vi.fn();
const device = { queue: { writeBuffer } } as unknown as GPUDevice;
const buffer = {} as GPUBuffer;
const cache = createCachedBufferWrite(values.byteLength);
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(false);
values[2] = 5;
expect(writeBufferIfChanged(device, buffer, values, cache)).toBe(true);
expect(writeBuffer).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,36 +1,46 @@
interface CachedFloat32BufferWrite { interface CachedBufferWrite {
hasValue: boolean; hasValue: boolean;
previous: Float32Array; previous: Uint8Array;
} }
export const createCachedFloat32BufferWrite = ( export const createCachedBufferWrite = (byteLength: number): CachedBufferWrite => ({
length: number
): CachedFloat32BufferWrite => ({
hasValue: false, hasValue: false,
previous: new Float32Array(length), previous: new Uint8Array(byteLength),
}); });
export const writeFloat32BufferIfChanged = ( export const updateCachedBufferWrite = (
device: GPUDevice, values: ArrayBufferView,
buffer: GPUBuffer, cache: CachedBufferWrite
values: Float32Array,
cache: CachedFloat32BufferWrite
): boolean => { ): boolean => {
if (values.length !== cache.previous.length) { const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
if (bytes.length !== cache.previous.length) {
throw new Error('Cached buffer write length mismatch'); throw new Error('Cached buffer write length mismatch');
} }
let hasChanged = !cache.hasValue; let hasChanged = !cache.hasValue;
for (let i = 0; i < values.length && !hasChanged; i++) { for (let i = 0; i < bytes.length && !hasChanged; i++) {
hasChanged = !Object.is(values[i], cache.previous[i]); hasChanged = bytes[i] !== cache.previous[i];
} }
if (!hasChanged) { if (!hasChanged) {
return false; return false;
} }
cache.previous.set(values); cache.previous.set(bytes);
cache.hasValue = true; cache.hasValue = true;
return true;
};
export const writeBufferIfChanged = (
device: GPUDevice,
buffer: GPUBuffer,
values: ArrayBufferView,
cache: CachedBufferWrite
): boolean => {
if (!updateCachedBufferWrite(values, cache)) {
return false;
}
device.queue.writeBuffer(buffer, 0, values); device.queue.writeBuffer(buffer, 0, values);
return true; return true;
}; };

View file

@ -3,9 +3,11 @@ import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
export const initializeContext = ({ export const initializeContext = ({
device, device,
canvas, canvas,
format,
}: { }: {
device: GPUDevice; device: GPUDevice;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
format: GPUTextureFormat;
}): GPUCanvasContext => { }): GPUCanvasContext => {
const context = canvas.getContext('webgpu'); const context = canvas.getContext('webgpu');
@ -22,18 +24,10 @@ export const initializeContext = ({
); );
} }
const gpu = navigator.gpu;
if (!gpu) {
throw new RuntimeError(
ErrorCode.WEBGPU_UNSUPPORTED,
'WebGPU is no longer available while configuring the canvas context.'
);
}
try { try {
context.configure({ context.configure({
device: device, device: device,
format: gpu.getPreferredCanvasFormat(), format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
alphaMode: 'opaque', alphaMode: 'opaque',
}); });

View file

@ -2,7 +2,10 @@ import { appConfig } from '../../config';
import { setUpFullScreenQuad } from './full-screen-quad'; import { setUpFullScreenQuad } from './full-screen-quad';
import { smartCompile } from './smart-compile'; import { smartCompile } from './smart-compile';
const textureCache = new WeakMap<GPUDevice, Map<string, GPUTexture>>(); export interface GeneratedNoiseTexture {
texture: GPUTexture;
view: GPUTextureView;
}
export const generateNoise = ({ export const generateNoise = ({
device, device,
@ -12,19 +15,7 @@ export const generateNoise = ({
device: GPUDevice; device: GPUDevice;
width: number; width: number;
height: number; height: number;
}): GPUTextureView => { }): GeneratedNoiseTexture => {
const cacheKey = `${width}x${height}:${appConfig.pipelines.common.noiseTextureFormat}`;
let deviceCache = textureCache.get(device);
if (!deviceCache) {
deviceCache = new Map<string, GPUTexture>();
textureCache.set(device, deviceCache);
}
const cached = deviceCache.get(cacheKey);
if (cached) {
return cached.createView();
}
const vertex = setUpFullScreenQuad(device); const vertex = setUpFullScreenQuad(device);
const pipeline = device.createRenderPipeline({ const pipeline = device.createRenderPipeline({
@ -98,6 +89,8 @@ export const generateNoise = ({
passEncoder.end(); passEncoder.end();
device.queue.submit([commandEncoder.finish()]); device.queue.submit([commandEncoder.finish()]);
deviceCache.set(cacheKey, colorTexture); return {
return colorTexture.createView(); texture: colorTexture,
view: colorTexture.createView(),
};
}; };

View file

@ -1,5 +1,7 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../../pipelines/texture-formats';
interface ResizableTextureOptions { interface ResizableTextureOptions {
clearValue?: GPUColor; clearValue?: GPUColor;
format?: GPUTextureFormat; format?: GPUTextureFormat;
@ -27,7 +29,7 @@ export class ResizableTexture {
size: vec2, size: vec2,
{ {
clearValue = { r: 0, g: 0, b: 0, a: 0 }, clearValue = { r: 0, g: 0, b: 0, a: 0 },
format = 'rgba16float', format = TRAIL_SOURCE_TEXTURE_FORMAT,
usage = defaultTextureUsage, usage = defaultTextureUsage,
}: ResizableTextureOptions = {} }: ResizableTextureOptions = {}
) { ) {
@ -39,18 +41,6 @@ export class ResizableTexture {
this.textureView = this.texture.createView(); this.textureView = this.texture.createView();
} }
public resize(size: vec2): void {
const resize = this.prepareResize(size);
if (!resize) {
return;
}
const commandEncoder = this.device.createCommandEncoder();
this.encodeResize(commandEncoder, resize);
this.device.queue.submit([commandEncoder.finish()]);
this.commitResize(resize);
}
public prepareResize(size: vec2): PendingTextureResize | null { public prepareResize(size: vec2): PendingTextureResize | null {
if (vec2.equals(this.size, size)) { if (vec2.equals(this.size, size)) {
return null; return null;

View file

@ -9,14 +9,7 @@ const cssTargets = browserslistToTargets(browserslist());
const esbuildTargets = browserslistToEsbuild(); const esbuildTargets = browserslistToEsbuild();
export default defineConfig(({ command }) => ({ export default defineConfig(({ command }) => ({
base: './', plugins: [viteSingleFile(), ...(command === 'serve' ? [basicSsl()] : [])],
plugins: [
viteSingleFile({
inlinePattern: ['index-*.js', 'style-*.css'],
useRecommendedBuildConfig: false,
}),
...(command === 'serve' ? [basicSsl()] : []),
],
css: { css: {
transformer: 'lightningcss', transformer: 'lightningcss',
lightningcss: { lightningcss: {
@ -25,14 +18,12 @@ export default defineConfig(({ command }) => ({
}, },
build: { build: {
target: esbuildTargets, target: esbuildTargets,
cssCodeSplit: false,
cssMinify: 'lightningcss', cssMinify: 'lightningcss',
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
assetsDir: '',
}, },
server: { server: {
open: true, open: true,
host: true, host: true,
hmr: false,
}, },
test: { test: {
environment: 'node', environment: 'node',