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
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
run: |
npm run lint:check
npm run typecheck
npm run typecheck:e2e
npm test
- name: Test E2E
run: |
npm run test:e2e
- name: Upload Playwright report

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,10 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
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;
return {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
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(
INTERNAL_RENDER_AREA_BOUNDS.max,
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`;
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
color1ToColor1: colorInteractionControl('Primary Follows Primary'),
color1ToColor2: colorInteractionControl('Primary Follows Secondary'),
color1ToColor3: colorInteractionControl('Primary Follows Accent'),
color2ToColor1: colorInteractionControl('Secondary Follows Primary'),
color2ToColor2: colorInteractionControl('Secondary Follows Secondary'),
color2ToColor3: colorInteractionControl('Secondary Follows Accent'),
color3ToColor1: colorInteractionControl('Accent Follows Primary'),
color3ToColor2: colorInteractionControl('Accent Follows Secondary'),
color3ToColor3: colorInteractionControl('Accent Follows Accent'),
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
brushSize: {
folder: 'Brush',
@ -25,7 +25,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
},
spawnPerPixel: {
folder: 'Brush',
label: 'Agent Density',
label: 'Density',
min: 0.01,
max: 1,
step: 0.001,
@ -39,28 +39,28 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
step: 0.01,
},
sensorOffsetDistance: {
folder: 'Agents',
folder: 'Movement',
label: 'Sensor Reach',
min: 0,
max: 200,
step: 1,
},
moveSpeed: {
folder: 'Agents',
folder: 'Movement',
label: 'Travel Speed',
min: 10,
max: 500,
step: 1,
},
turnSpeed: {
folder: 'Agents',
folder: 'Movement',
label: 'Turning Speed',
min: 1,
max: 200,
step: 1,
},
forwardRotationScale: {
folder: 'Agents',
folder: 'Movement',
format: formatPercent,
label: 'Forward Focus',
min: 0,
@ -68,21 +68,21 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
step: 0.01,
},
turnWhenLost: {
folder: 'Agents',
folder: 'Movement',
label: 'Wander Turn',
min: 0,
max: 6.28,
step: 0.01,
},
individualTrailWeight: {
folder: 'Agents',
folder: 'Movement',
label: 'Trail Strength',
min: 0,
max: 1,
step: 0.001,
},
decayRateTrails: {
folder: 'Agents',
folder: 'Movement',
label: 'Trail Fade',
min: 800,
max: 1000,
@ -107,7 +107,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
maxAgentCount: {
folder: 'Performance',
integer: true,
label: 'Agent Limit',
label: 'Population Limit',
min: 0,
step: 10_000,
},

View file

@ -22,6 +22,7 @@ export interface NumberControlConfig {
export type GardenRuntimeSettings = {
adaptiveCapInitial: number;
adaptiveCapMin: number;
backgroundGrainStrength: number;
brushCurveResolution: number;
brushCurveMinBrushRadius: number;
brushCurveMinSegmentSpacing: number;
@ -70,12 +71,14 @@ type GardenDefaultSettings = Omit<
>;
export enum VibeId {
CandyRain = 'candy-rain',
SunlitMoss = 'sunlit-moss',
CoralTide = 'coral-tide',
MoonOrchid = 'moon-orchid',
PeachNeon = 'peach-neon',
FrostBloom = 'frost-bloom',
AuroraMycelium = 'aurora-mycelium',
EmberCircuit = 'ember-circuit',
VelvetObservatory = 'velvet-observatory',
LichenSignal = 'lichen-signal',
UltravioletSiren = 'ultraviolet-siren',
TidepoolLantern = 'tidepool-lantern',
PaperLanternFog = 'paper-lantern-fog',
ChromePollen = 'chrome-pollen',
}
export interface VibePreset {
@ -181,8 +184,6 @@ export interface GardenAppConfig {
stroke: {
densityMultiplier: number;
maxAgentCount: number;
minAgentCount: number;
minSegmentLengthPx: number;
};
};
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';
const defaultAudioSettings = {
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 defaultVibeId = VibeId.AuroraMycelium;
export const vibePresets: Array<VibePreset> = [
{
id: VibeId.CandyRain,
name: 'Candy Rain',
id: VibeId.AuroraMycelium,
name: 'Aurora Mycelium',
colors: [
[255, 93, 162],
[54, 215, 208],
[255, 216, 77],
[78, 255, 176],
[154, 99, 255],
[169, 238, 255],
],
backgroundColor: [16, 21, 31],
backgroundColor: [6, 13, 22],
settings: {
backgroundGrainStrength: 0.018,
brushSize: 14,
clarity: 0.62,
decayRateTrails: 965,
individualTrailWeight: 0.07,
moveSpeed: 82,
sensorOffsetDistance: 38,
spawnPerPixel: 0.22,
turnSpeed: 58,
backgroundGrainStrength: 0.016,
brushSize: 20,
clarity: 0.52,
decayRateTrails: 988,
individualTrailWeight: 0.085,
moveSpeed: 54,
sensorOffsetDistance: 72,
spawnPerPixel: 0.13,
turnSpeed: 35,
},
audio: {
...defaultAudioSettings,
brightness: 1.04,
...defaultGardenAudioVibeSettings,
idleIntensity: 0.12,
bpm: 60,
rampUpIntensity: 0.7,
rampUpTime: 0.14,
noteLength: 0.86,
notePitchOffset: -2,
brightness: 0.84,
},
},
{
id: VibeId.SunlitMoss,
name: 'Sunlit Moss',
id: VibeId.EmberCircuit,
name: 'Ember Circuit',
colors: [
[131, 212, 131],
[246, 215, 107],
[94, 193, 161],
[255, 95, 38],
[255, 43, 132],
[43, 219, 255],
],
backgroundColor: [23, 32, 22],
backgroundColor: [17, 10, 8],
settings: {
backgroundGrainStrength: 0.014,
brushSize: 16,
clarity: 0.68,
decayRateTrails: 975,
individualTrailWeight: 0.06,
moveSpeed: 70,
sensorOffsetDistance: 46,
spawnPerPixel: 0.18,
turnSpeed: 44,
backgroundGrainStrength: 0.03,
brushSize: 8,
clarity: 0.82,
decayRateTrails: 918,
individualTrailWeight: 0.04,
moveSpeed: 150,
sensorOffsetDistance: 24,
spawnPerPixel: 0.31,
turnSpeed: 130,
},
audio: {
...defaultAudioSettings,
brightness: 0.92,
...defaultGardenAudioVibeSettings,
idleIntensity: 0.03,
bpm: 124,
rampUpIntensity: 1.35,
rampUpTime: 0.04,
noteLength: 0.18,
notePitchOffset: 7,
brightness: 1.34,
},
},
{
id: VibeId.CoralTide,
name: 'Coral Tide',
id: VibeId.VelvetObservatory,
name: 'Velvet Observatory',
colors: [
[255, 127, 110],
[64, 184, 255],
[244, 240, 166],
[72, 98, 255],
[255, 89, 176],
[235, 236, 255],
],
backgroundColor: [15, 24, 34],
backgroundColor: [7, 8, 20],
settings: {
backgroundGrainStrength: 0.022,
brushSize: 13,
clarity: 0.58,
decayRateTrails: 955,
individualTrailWeight: 0.055,
moveSpeed: 90,
sensorOffsetDistance: 35,
spawnPerPixel: 0.25,
turnSpeed: 62,
backgroundGrainStrength: 0.01,
brushSize: 24,
clarity: 0.45,
decayRateTrails: 992,
individualTrailWeight: 0.095,
moveSpeed: 45,
sensorOffsetDistance: 86,
spawnPerPixel: 0.1,
turnSpeed: 24,
},
audio: {
...defaultAudioSettings,
brightness: 1,
...defaultGardenAudioVibeSettings,
idleIntensity: 0.14,
bpm: 56,
rampUpIntensity: 0.6,
rampUpTime: 0.16,
noteLength: 1.15,
notePitchOffset: -5,
brightness: 0.72,
},
},
{
id: VibeId.MoonOrchid,
name: 'Moon Orchid',
id: VibeId.LichenSignal,
name: 'Lichen Signal',
colors: [
[201, 147, 255],
[125, 216, 255],
[240, 244, 255],
[174, 205, 91],
[71, 162, 126],
[229, 117, 71],
],
backgroundColor: [20, 18, 29],
backgroundColor: [18, 24, 17],
settings: {
backgroundGrainStrength: 0.018,
brushSize: 12,
clarity: 0.64,
decayRateTrails: 968,
backgroundGrainStrength: 0.028,
brushSize: 17,
clarity: 0.66,
decayRateTrails: 974,
individualTrailWeight: 0.065,
moveSpeed: 76,
sensorOffsetDistance: 42,
spawnPerPixel: 0.2,
turnSpeed: 52,
moveSpeed: 68,
sensorOffsetDistance: 52,
spawnPerPixel: 0.19,
turnSpeed: 38,
},
audio: {
...defaultAudioSettings,
brightness: 0.9,
...defaultGardenAudioVibeSettings,
idleIntensity: 0.1,
bpm: 68,
rampUpIntensity: 0.8,
rampUpTime: 0.1,
noteLength: 0.62,
notePitchOffset: -3,
brightness: 0.82,
},
},
{
id: VibeId.PeachNeon,
name: 'Peach Neon',
id: VibeId.UltravioletSiren,
name: 'Ultraviolet Siren',
colors: [
[255, 155, 115],
[91, 240, 169],
[110, 168, 255],
[184, 75, 255],
[0, 224, 255],
[214, 255, 72],
],
backgroundColor: [25, 23, 22],
backgroundColor: [13, 9, 31],
settings: {
backgroundGrainStrength: 0.024,
brushSize: 15,
clarity: 0.55,
decayRateTrails: 948,
individualTrailWeight: 0.05,
moveSpeed: 96,
sensorOffsetDistance: 32,
spawnPerPixel: 0.24,
turnSpeed: 70,
backgroundGrainStrength: 0.02,
brushSize: 11,
clarity: 0.72,
decayRateTrails: 946,
individualTrailWeight: 0.052,
moveSpeed: 118,
sensorOffsetDistance: 30,
spawnPerPixel: 0.28,
turnSpeed: 96,
},
audio: {
...defaultAudioSettings,
brightness: 1.08,
...defaultGardenAudioVibeSettings,
idleIntensity: 0.04,
bpm: 112,
rampUpIntensity: 1.2,
rampUpTime: 0.05,
noteLength: 0.25,
notePitchOffset: 5,
brightness: 1.22,
},
},
{
id: VibeId.FrostBloom,
name: 'Frost Bloom',
id: VibeId.TidepoolLantern,
name: 'Tidepool Lantern',
colors: [
[180, 247, 255],
[158, 200, 255],
[255, 184, 210],
[30, 219, 194],
[61, 118, 255],
[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: {
backgroundGrainStrength: 0.012,
brushSize: 18,
clarity: 0.7,
decayRateTrails: 982,
individualTrailWeight: 0.075,
moveSpeed: 62,
sensorOffsetDistance: 52,
spawnPerPixel: 0.16,
turnSpeed: 40,
brushSize: 10,
clarity: 0.9,
decayRateTrails: 935,
individualTrailWeight: 0.045,
moveSpeed: 104,
sensorOffsetDistance: 36,
spawnPerPixel: 0.24,
turnSpeed: 78,
},
audio: {
...defaultAudioSettings,
brightness: 0.88,
...defaultGardenAudioVibeSettings,
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 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 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 { appConfig } from '../config';
import {
AGENT_FLOAT_COUNT,
AgentGenerationPipeline,
} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
import { settings } from '../settings';
import type { FramePerformance } from './frame-performance';
@ -20,8 +18,8 @@ export class AgentPopulation {
private shouldCompactAfterErase = false;
private isCompacting = false;
private pendingCompaction: Promise<void> | null = null;
// Highest active slot written while async compaction is running.
private postCompactionWriteEnd = 0;
private readonly queuedAgentBatches: Array<Float32Array> = [];
private pendingStrokeAgentCount = 0;
private readonly strokeAgentData = new Float32Array(
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
);
@ -64,16 +62,20 @@ export class AgentPopulation {
}
this.pipeline.writeAgents(0, data);
this.markPostCompactionWrite(0, data.length / AGENT_FLOAT_COUNT);
this.activeCount = data.length / AGENT_FLOAT_COUNT;
this.replacementCursor = 0;
}
public onVibeChanged(): void {
this.pendingStrokeAgentCount = 0;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.trimActiveCountToBudget();
}
public beginStroke(): void {
this.pendingStrokeAgentCount = 0;
}
public resizeAgents(scale: vec2): void {
this.pipeline.resizeAgents(this.activeCount, scale);
}
@ -93,17 +95,13 @@ export class AgentPopulation {
}
this.isCompacting = true;
this.postCompactionWriteEnd = 0;
this.pendingCompaction = this.pipeline
.compactAgents(this.activeCount)
.then((compactedAgentCount) => {
const finiteCompactedAgentCount = Number.isFinite(compactedAgentCount)
? Math.max(0, Math.floor(compactedAgentCount))
: 0;
this.activeCount = Math.min(
this.activeCount,
Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
);
this.activeCount = Math.min(this.activeCount, finiteCompactedAgentCount);
this.clampReplacementCursor();
this.trimActiveCountToBudget();
})
@ -113,7 +111,7 @@ export class AgentPopulation {
.finally(() => {
this.isCompacting = false;
this.pendingCompaction = null;
this.postCompactionWriteEnd = 0;
this.flushQueuedAgentBatches();
});
}
@ -144,40 +142,86 @@ export class AgentPopulation {
public spawnStrokeAgents(from: vec2, to: vec2): void {
const deltaX = to[0] - from[0];
const deltaY = to[1] - from[1];
const length = Math.max(
appConfig.simulation.stroke.minSegmentLengthPx,
Math.hypot(deltaX, deltaY)
);
const count = Math.max(
appConfig.simulation.stroke.minAgentCount,
Math.min(
appConfig.simulation.stroke.maxAgentCount,
this.strokeAgentData.length / AGENT_FLOAT_COUNT,
Math.ceil(
length * settings.spawnPerPixel * appConfig.simulation.stroke.densityMultiplier
)
)
);
const length = Math.hypot(deltaX, deltaY);
const spawnRate = getStrokeSpawnRate();
if (!Number.isFinite(length) || length <= 0 || spawnRate <= 0) {
return;
}
const expectedAgentCount = length * spawnRate + this.pendingStrokeAgentCount;
if (!Number.isFinite(expectedAgentCount)) {
this.pendingStrokeAgentCount = 0;
return;
}
const count = Math.floor(expectedAgentCount);
this.pendingStrokeAgentCount = expectedAgentCount - count;
if (count <= 0) {
return;
}
const baseAngle = Math.atan2(deltaY, deltaX);
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++) {
const t = count === 1 ? 1 : i / (count - 1);
for (let written = 0; written < count; written += batchCapacity) {
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 y = from[1] + (to[1] - from[1]) * t;
const angle = baseAngle + (Math.random() - 0.5) * settings.strokeAngleJitterRadians;
const base = i * AGENT_FLOAT_COUNT;
this.strokeAgentData[base] = x + (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;
}
const positionX = x + (Math.random() - 0.5) * spread;
const positionY = y + (Math.random() - 0.5) * spread;
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 {
@ -185,6 +229,11 @@ export class AgentPopulation {
return;
}
if (this.isCompacting) {
this.queuedAgentBatches.push(data.slice());
return;
}
const count = data.length / AGENT_FLOAT_COUNT;
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
this.expandAdaptiveCapForPendingAgents(count);
@ -197,7 +246,6 @@ export class AgentPopulation {
this.activeCount,
data.subarray(0, appendCount * AGENT_FLOAT_COUNT)
);
this.markPostCompactionWrite(this.activeCount, appendCount);
this.activeCount += appendCount;
}
@ -216,22 +264,15 @@ export class AgentPopulation {
(sourceAgentOffset + chunkAgentCount) * AGENT_FLOAT_COUNT
)
);
this.markPostCompactionWrite(targetAgentOffset, chunkAgentCount);
sourceAgentOffset += chunkAgentCount;
this.replacementCursor = (targetAgentOffset + chunkAgentCount) % this.activeCount;
}
}
private markPostCompactionWrite(agentOffset: number, agentCount: number): void {
if (!this.isCompacting || agentCount <= 0) {
return;
}
this.postCompactionWriteEnd = Math.max(
this.postCompactionWriteEnd,
Math.ceil(agentOffset + agentCount)
);
private flushQueuedAgentBatches(): void {
const batches = this.queuedAgentBatches.splice(0);
batches.forEach((batch) => this.writeAgentBatch(batch));
}
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 {
device: GPUDevice;
renderPipeline: RenderPipeline;
canvasFormat: GPUTextureFormat;
statusElement: HTMLElement;
seed: string;
getSourceSize: () => { width: number; height: number };
@ -50,7 +51,6 @@ export class ExportSnapshotRenderer {
private async renderSnapshot(layout: SnapshotLayout): Promise<void> {
const { width, height, unpaddedBytesPerRow, bytesPerRow } = layout;
const format = navigator.gpu.getPreferredCanvasFormat();
let texture: GPUTexture | null = null;
let output: GPUBuffer | null = null;
let isOutputMapped = false;
@ -58,7 +58,7 @@ export class ExportSnapshotRenderer {
try {
texture = this.device.createTexture({
size: { width, height },
format,
format: this.options.canvasFormat,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
output = this.device.createBuffer({
@ -89,7 +89,7 @@ export class ExportSnapshotRenderer {
height,
unpaddedBytesPerRow,
bytesPerRow,
isBgra: format === 'bgra8unorm',
isBgra: this.options.canvasFormat === 'bgra8unorm',
});
output.unmap();
isOutputMapped = false;

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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';
interface IntroTitlePoint {
@ -114,15 +114,16 @@ export const createIntroTitleAgents = ({
pathProgress
)
);
const base = i * AGENT_FLOAT_COUNT;
data[base] = mix(startX, targetX, pathProgress);
data[base + 1] = mix(startY, targetY, pathProgress);
data[base + 2] = currentAngle;
data[base + 3] = point.colorIndex;
data[base + 4] = targetX;
data[base + 5] = targetY;
data[base + 6] = targetAngle;
data[base + 7] = introDelay;
writeAgentValues(data, i, {
positionX: mix(startX, targetX, pathProgress),
positionY: mix(startY, targetY, pathProgress),
angle: currentAngle,
colorIndex: point.colorIndex,
targetPositionX: targetX,
targetPositionY: targetY,
targetAngle,
introDelay,
});
}
return data;

View file

@ -8,6 +8,7 @@ interface PerfStatsSnapshot {
fps: number;
agentCount: number;
frameTimeMs: number;
gpuPassTimeMs?: number;
renderWidth: number;
renderHeight: number;
}
@ -29,6 +30,7 @@ export class PerfStatsOverlay {
fps,
agentCount,
frameTimeMs,
gpuPassTimeMs,
renderWidth,
renderHeight,
}: PerfStatsSnapshot): void {
@ -37,7 +39,6 @@ export class PerfStatsOverlay {
}
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)}`;
if (text !== this.previousText) {
this.element.textContent = text;

View file

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

View file

@ -1,6 +1,7 @@
import { vec2 } from 'gl-matrix';
import { appConfig } from '../config';
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
import {
ResizableTexture,
type PendingTextureResize,
@ -96,13 +97,17 @@ export class SimulationTextures {
}
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({
colorAttachments: [this.sourceMapA, this.sourceMapB].map((texture) => ({
view: texture.getTextureView(),
clearValue: appConfig.simulation.clearColor,
loadOp: 'clear',
storeOp: 'store',
})),
colorAttachments: [
{
view: this.sourceMapA.getTextureView(),
clearValue: appConfig.simulation.clearColor,
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.end();
}
@ -126,7 +131,7 @@ export class SimulationTextures {
private createEraserMask(size: vec2): ResizableTexture {
return new ResizableTexture(this.device, size, {
clearValue: { r: 1, g: 1, b: 1, a: 1 },
format: 'r8unorm',
format: ERASER_MASK_TEXTURE_FORMAT,
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT |

View file

@ -104,9 +104,10 @@ export class ToolbarContrastMonitor {
public constructor(
private readonly canvas: HTMLCanvasElement,
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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,24 @@
export class FullScreenHandler {
public constructor(
private readonly minimizeButton: HTMLElement,
private readonly maximizeButton: HTMLElement,
private readonly toggleButton: HTMLElement,
target: HTMLElement
) {
if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
minimizeButton.hidden = true;
maximizeButton.hidden = true;
toggleButton.hidden = true;
return;
}
this.updateButtons();
addEventListener('fullscreenchange', this.updateButtons.bind(this));
maximizeButton.addEventListener('click', () => {
toggleButton.addEventListener('click', () => {
if (FullScreenHandler.isInFullScreenMode()) {
void document.exitFullscreen();
return;
}
void target.requestFullscreen().catch(() => undefined);
});
minimizeButton.addEventListener('click', () => document.exitFullscreen());
}
public static isInFullScreenMode(): boolean {
@ -25,7 +27,9 @@ export class FullScreenHandler {
private updateButtons(): void {
const isInFullScreenMode = FullScreenHandler.isInFullScreenMode();
this.minimizeButton.hidden = !isInFullScreenMode;
this.maximizeButton.hidden = isInFullScreenMode;
const label = isInFullScreenMode ? 'Exit fullscreen' : 'Enter fullscreen';
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 { 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 safeValue = Number.isFinite(value) ? value : MIRROR_SEGMENT_DEFAULT;
return Math.min(
MIRROR_SEGMENT_MAX,
Math.max(MIRROR_SEGMENT_MIN, Math.round(safeValue))
);
const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
const safeValue = Number.isFinite(value) ? value : defaultCount;
return Math.min(max, Math.max(min, Math.round(safeValue)));
};
const getMirrorSegmentRatio = (count: number): number =>
(count - MIRROR_SEGMENT_MIN) / (MIRROR_SEGMENT_MAX - MIRROR_SEGMENT_MIN);
const getMirrorSegmentRatio = (count: number): number => {
const { max, min } = appConfig.toolbar.mirror;
return (count - min) / (max - min);
};
const formatMirrorSegmentCount = (count: number): string =>
count === MIRROR_SEGMENT_DEFAULT
? MIRROR_SEGMENT_OFF_LABEL
: `${count} ${MIRROR_SEGMENT_LABEL_SUFFIX}`;
count === appConfig.toolbar.mirror.default
? appConfig.toolbar.mirror.offLabel
: `${count} ${
appConfig.toolbar.mirror.names[
count as keyof typeof appConfig.toolbar.mirror.names
] ?? appConfig.toolbar.mirror.fallbackSegmentName
}`;
interface MirrorSegmentControlOptions {
onChange: () => void;
@ -52,9 +50,9 @@ export class MirrorSegmentControl {
settings.mirrorSegmentCount = count;
}
this.slider.min = MIRROR_SEGMENT_MIN.toString();
this.slider.max = MIRROR_SEGMENT_MAX.toString();
this.slider.step = MIRROR_SEGMENT_STEP.toString();
this.slider.min = appConfig.toolbar.mirror.min.toString();
this.slider.max = appConfig.toolbar.mirror.max.toString();
this.slider.step = appConfig.toolbar.mirror.step.toString();
this.slider.value = count.toString();
const label = formatMirrorSegmentCount(count);

View file

@ -1,6 +1,7 @@
import type GameLoop from '../game-loop/game-loop';
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';
interface PaletteControlOptions {
@ -9,7 +10,7 @@ interface PaletteControlOptions {
}
export class PaletteControl {
private readonly swatches = queryRequiredElements('.color-swatch', HTMLButtonElement);
private readonly swatches = queryRequiredColorSwatches();
private readonly eraserControl = queryRequiredElement(
'.eraser-size-control',
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';
export class SplashScreen {
public readonly startButton = queryRequiredElement(
'.start-button',
HTMLButtonElement
);
public readonly startButton = queryRequiredElement('.start-button', HTMLButtonElement);
private readonly splash = queryRequiredElement('.splash', HTMLDivElement);
private readonly loadingBar = queryRequiredElement('.loading-bar', HTMLDivElement);
private readonly loadingStatus = queryRequiredElement(
@ -17,6 +14,12 @@ export class SplashScreen {
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 {
const percent = Math.round(clamp01(ratio) * 100);
this.loadingStatus.textContent = label;
@ -30,7 +33,7 @@ export class SplashScreen {
const onClick = () => {
this.startButton.removeEventListener('click', onClick);
onStart();
this.splash.hidden = true;
this.setVisible(this.splash, false);
resolve();
};
this.startButton.addEventListener('click', onClick);
@ -38,10 +41,10 @@ export class SplashScreen {
}
public showLoadingBar(): void {
this.loadingBar.hidden = false;
this.setVisible(this.loadingBar, true);
}
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 = (
passEncoder: GPUComputePassEncoder,
workgroupSize: number,
agentCount: number
): 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(3) var<storage, read_write> compactedAgents: array<Agent>;
var<workgroup> workgroupAliveCount: atomic<u32>;
var<workgroup> workgroupCompactedOffset: u32;
var<workgroup> scanData: array<u32, agentWorkgroupSize>;
var<workgroup> clearAliveAgentCount: u32;
@compute @workgroup_size(64)
@compute @workgroup_size(agentWorkgroupSize)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>
) {
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 agent: Agent;
if id < settings.agentCount {
isAlive = agents[id].colorIndex >= 0.0;
if isAlive {
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();
if local_id.x == 0u {
let groupAliveCount = atomicLoad(&workgroupAliveCount);
if groupAliveCount > 0u {
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, groupAliveCount);
var offset: u32 = 1u;
while offset < agentWorkgroupSize {
let own = scanData[lid];
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 {
workgroupCompactedOffset = 0u;
}
@ -57,11 +70,11 @@ fn main(
workgroupBarrier();
if isAlive {
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
compactedAgents[workgroupCompactedOffset + exclusivePrefix] = agent;
}
}
@compute @workgroup_size(64)
@compute @workgroup_size(agentWorkgroupSize)
fn clearCompactedTail(
@builtin(global_invocation_id) global_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 { 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 compactionShader from './agent-compaction.wgsl?raw';
import resizeShader from './agent-resize.wgsl?raw';
import agentSchema from './agent-schema.wgsl?raw';
export { AGENT_FLOAT_COUNT } from '../agent-limits';
export class AgentGenerationPipeline {
private static readonly UNIFORM_COUNT = 4;
private static readonly COUNTER_COUNT = 1;
@ -34,6 +36,7 @@ export class AgentGenerationPipeline {
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
private readonly clearCompactedTailPipeline: GPUComputePipeline;
private readonly workgroupSize: number;
private activeAgentsBuffer: GPUBuffer;
private inactiveAgentsBuffer: GPUBuffer;
@ -90,7 +93,7 @@ export class AgentGenerationPipeline {
});
this.activeAgentsBuffer = this.createAgentsBuffer();
this.inactiveAgentsBuffer = this.createInactivePlaceholderBuffer();
this.inactiveAgentsBuffer = this.createAgentsBuffer();
this.countersBuffer = this.device.createBuffer({
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
@ -107,17 +110,20 @@ export class AgentGenerationPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.workgroupSize = getAgentWorkgroupSize(device);
const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema);
this.resizePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, agentSchema, resizeShader),
module: smartCompile(device, sizedSchema, resizeShader),
entryPoint: 'main',
},
});
const compactionModule = smartCompile(device, agentSchema, compactionShader);
const compactionModule = smartCompile(device, sizedSchema, compactionShader);
this.compactionPipeline = device.createComputePipeline({
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 {
return this.allocatedMaxAgentCount;
}
@ -187,9 +183,11 @@ export class AgentGenerationPipeline {
)
);
const previousActiveAgentsBuffer = this.activeAgentsBuffer;
const previousInactiveAgentsBuffer = this.inactiveAgentsBuffer;
const previousMaxAgentCount = this.allocatedMaxAgentCount;
this.allocatedMaxAgentCount = nextMaxAgentCount;
this.activeAgentsBuffer = this.createAgentsBuffer();
this.inactiveAgentsBuffer = this.createAgentsBuffer();
const copyAgentCount = Math.min(
Math.max(0, Math.floor(activeAgentCount)),
@ -209,10 +207,9 @@ export class AgentGenerationPipeline {
}
// GPUBuffer.destroy() defers actual freeing until pending submissions
// finish, so calling it synchronously after submit is safe and avoids the
// transient 4-buffers-live spike that pushes iOS Safari past its per-tab
// memory ceiling.
// finish, so calling it synchronously after submit is safe.
previousActiveAgentsBuffer.destroy();
previousInactiveAgentsBuffer.destroy();
return this.allocatedMaxAgentCount;
}
@ -251,7 +248,7 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.resizePipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, agentCount);
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
passEncoder.end();
this.device.queue.submit([commandEncoder.finish()]);
@ -262,12 +259,6 @@ export class AgentGenerationPipeline {
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.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
@ -276,10 +267,11 @@ export class AgentGenerationPipeline {
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.compactionPipeline);
passEncoder.setBindGroup(1, this.getBindGroup());
dispatchAgentWorkgroups(passEncoder, agentCount);
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
passEncoder.setPipeline(this.clearCompactedTailPipeline);
dispatchAgentWorkgroups(
passEncoder,
this.workgroupSize,
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
);
passEncoder.end();
@ -295,13 +287,6 @@ export class AgentGenerationPipeline {
this.device.queue.submit([commandEncoder.finish()]);
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);
const compactedCount = new Uint32Array(
this.countersStagingBuffer.getMappedRange(),

View file

@ -5,7 +5,7 @@ struct ResizeSettings {
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
@compute @workgroup_size(64)
@compute @workgroup_size(agentWorkgroupSize)
fn main(
@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>;
const agentWorkgroupSize = 64u;
const agentWorkgroupSize = __AGENT_WORKGROUP_SIZE__u;
fn get_id(global_id: vec3<u32>) -> u32 {
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_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 = (
device: GPUDevice,
maxAgentCountUpperLimit = Number.POSITIVE_INFINITY
@ -19,7 +57,8 @@ export const getMaxSupportedAgentCount = (
upperLimit,
Math.floor(device.limits.maxBufferSize / 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 {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
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 shader from './agent.wgsl?raw';
@ -44,11 +49,13 @@ const UNIFORM_COUNT = 30;
export class AgentPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly pipeline: GPUComputePipeline;
private readonly normalPipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly workgroupSize: number;
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
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<
GPUBuffer,
GPUTextureView,
@ -66,7 +73,6 @@ export class AgentPipeline {
);
private agentCount = 0;
private useIntroPipeline = true;
public constructor(
private readonly device: GPUDevice,
@ -93,15 +99,16 @@ export class AgentPipeline {
{
binding: 3,
visibility: GPUShaderStage.COMPUTE,
storageTexture: { format: 'rgba16float' },
storageTexture: { format: TRAIL_SOURCE_TEXTURE_FORMAT },
},
],
});
this.workgroupSize = getAgentWorkgroupSize(device);
const shaderModule = smartCompile(
device,
CommonState.shaderCode,
agentSchema,
substituteAgentWorkgroupSize(device, agentSchema),
shader
);
const pipelineLayout = device.createPipelineLayout({
@ -114,13 +121,6 @@ export class AgentPipeline {
entryPoint: 'main',
},
});
this.normalPipeline = device.createComputePipeline({
layout: pipelineLayout,
compute: {
module: shaderModule,
entryPoint: 'mainNormal',
},
});
this.uniforms = device.createBuffer({
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -167,7 +167,6 @@ export class AgentPipeline {
introProgress?: number;
}) {
this.agentCount = agentCount;
this.useIntroPipeline = (introProgress ?? 1) < introProgressCutoff;
this.uniformValues[0] = moveSpeed * deltaTime;
this.uniformValues[1] = turnSpeed * deltaTime;
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
@ -199,7 +198,7 @@ export class AgentPipeline {
this.uniformValues[27] = introNearMoveMultiplier;
this.uniformValues[28] = introStepStopDistance;
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
writeFloat32BufferIfChanged(
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
@ -220,13 +219,13 @@ export class AgentPipeline {
const passEncoder = commandEncoder.beginComputePass(
timestampWrites ? { timestampWrites } : undefined
);
passEncoder.setPipeline(this.useIntroPipeline ? this.pipeline : this.normalPipeline);
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(
1,
this.bindGroupCache(this.getAgentsBuffer(), trailMapIn, trailMapOut)
);
dispatchAgentWorkgroups(passEncoder, this.agentCount);
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
passEncoder.end();
}

View file

@ -1,4 +1,6 @@
const PI: f32 = 3.14159265359;
const TAU: f32 = 6.28318530718;
const INV_TAU: f32 = 0.15915494309;
struct Settings {
moveRate: f32,
@ -35,9 +37,9 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings;
@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(
@builtin(global_invocation_id) global_id: vec3<u32>
) {
@ -158,79 +160,6 @@ fn main(
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(
agentPosition: 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 {
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 {

View file

@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
@ -13,6 +13,7 @@ import {
LineSegmentBuffer,
} from '../common/line-segment-buffer';
import lineSegmentShader from '../common/line-segment.wgsl?raw';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import shader from './brush.wgsl?raw';
export interface BrushSettings {
@ -77,7 +78,9 @@ export class BrushPipeline {
private readonly renderPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
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;
public constructor(
@ -116,7 +119,7 @@ export class BrushPipeline {
entryPoint: 'fragmentMrt',
targets: [
{
format: 'rgba16float',
format: TRAIL_SOURCE_TEXTURE_FORMAT,
blend: {
color: { 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 {
setBrushUniformValues(this.uniformValues, parameters);
writeFloat32BufferIfChanged(
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,

View file

@ -38,10 +38,12 @@ fn vertex(
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
var normalizedDirection = vec2<f32>(1.0, 0.0);
if denominator > SEGMENT_LENGTH_EPSILON {
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 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);
@ -85,8 +87,16 @@ fn brushStrength(
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 {
return settings.brushGrainMinStrength;
return settings.brushGrainMinStrength * feather;
}
let grainNoise = textureSampleLevel(
@ -96,7 +106,12 @@ fn brushStrength(
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
0.0
).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> {

View file

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

View file

@ -13,14 +13,9 @@ fn segment_vertex_position(
vertexIndex: u32,
start: vec2<f32>,
end: vec2<f32>,
direction: vec2<f32>,
radius: 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 corner = segment_vertex_corner(vertexIndex % 6u);
let center = mix(start, end, (corner.x + 1.0) * 0.5);

View file

@ -9,8 +9,13 @@ struct Settings {
padding2: f32,
};
const WORKGROUP_SIZE_X = 16u;
const WORKGROUP_SIZE_Y = 16u;
const WORKGROUP_SIZE_X = __WORKGROUP_SIZE__u;
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
// can be served from workgroup memory without bounds checks for interior tiles.
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(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> tileTrailStrength: array<f32, 324>;
@compute @workgroup_size(16, 16)
@compute @workgroup_size(__WORKGROUP_SIZE__, __WORKGROUP_SIZE__)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(workgroup_id) workgroup_id: vec3<u32>
) {
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 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) {
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
let unclampedSourcePixel = vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1);
var sourcePixel = unclampedSourcePixel;
if !isInteriorTile {
sourcePixel = clamp(unclampedSourcePixel, vec2<i32>(0, 0), textureSize - vec2<i32>(1, 1));
}
let sourcePixel = clamp(
vec2<i32>(workgroupOrigin + tilePosition) - vec2<i32>(1, 1),
vec2<i32>(0, 0),
textureBound
);
let texel = textureLoad(trailMap, sourcePixel, 0);
tile[tileIndex] = texel;
tileTrailStrength[tileIndex] = length(texel.rgb);
@ -57,53 +57,67 @@ fn main(
workgroupBarrier();
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
let inBounds = pixel.x < textureSize.x && pixel.y < textureSize.y;
if !inBounds {
if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
return;
}
let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u);
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
var current = tile[centerTileIndex];
let c = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
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 trailWeight = diffusion_weight(
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)
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
+ propagate(centerTileIndex, -1, 0, current, trailWeight)
+ propagate(centerTileIndex, 0, -1, current, trailWeight)
+ propagate(centerTileIndex, 1, 0, current, trailWeight)
+ propagate(centerTileIndex, 0, 1, current, trailWeight)
) * settings.diffusionNeighborScale;
let propagated =
propagate_value(nTL, sTL, current, trailWeight)
+ propagate_value(nT, sT, current, trailWeight)
+ propagate_value(nTR, sTR, current, trailWeight)
+ 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(
current.rgb * settings.decayRateTrails,
max(0, current.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract)
updated.rgb * settings.decayRateTrails - vec3(TRAIL_RGB_DECAY_SUBTRACT),
updated.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract
), vec4(0), vec4(1));
textureStore(trailMapOut, pixel, decayed);
}
fn propagate(
centerTileIndex: u32,
offsetX: i32,
offsetY: i32,
currentColor: vec4<f32>,
fn propagate_value(
neighbour: vec4<f32>,
neighbourStrength: f32,
current: vec4<f32>,
trailWeight: f32
) -> vec4<f32> {
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
let neighbourTileIndex = u32(neighbourIndex);
let neighbour = tile[neighbourTileIndex];
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
let difference = clamp(neighbour - current, vec4(0), vec4(1));
return vec4(
vec3(tileTrailStrength[neighbourTileIndex] * trailWeight),
vec3(neighbourStrength * trailWeight),
neighbour.a * trailWeight
) * difference;
}

View file

@ -3,10 +3,11 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../texture-formats';
import shader from './diffuse.wgsl?raw';
export interface DiffusionSettings {
@ -69,8 +70,8 @@ export class DiffusionPipeline {
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
DiffusionPipeline.UNIFORM_COUNT
private readonly uniformCache = createCachedBufferWrite(
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
(trailMapIn, trailMapOut) =>
@ -94,7 +95,7 @@ export class DiffusionPipeline {
bindGroupLayouts: [this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, shader),
module: smartCompile(device, this.shaderCode),
entryPoint: 'main',
},
});
@ -121,7 +122,7 @@ export class DiffusionPipeline {
diffusionNeighborDivisor,
brushDecayAlphaOffset,
});
writeFloat32BufferIfChanged(
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
@ -176,10 +177,17 @@ export class DiffusionPipeline {
visibility: GPUShaderStage.COMPUTE,
storageTexture: {
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 {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
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 shader from './eraser-agent.wgsl?raw';
@ -25,8 +29,8 @@ export class EraserAgentPipeline {
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
private readonly uniformCache = createCachedBufferWrite(
EraserAgentPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
);
private readonly bindGroupCache = createBindGroupCache<GPUBuffer, GPUTextureView>(
(agentsBuffer, eraserMask) =>
@ -44,6 +48,7 @@ export class EraserAgentPipeline {
private activeSegmentCount = 0;
private pendingBounds: Bounds | null = null;
private agentCount = 0;
private readonly workgroupSize: number;
public constructor(
private readonly device: GPUDevice,
@ -81,12 +86,17 @@ export class EraserAgentPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.workgroupSize = getAgentWorkgroupSize(device);
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, agentSchema, shader),
module: smartCompile(
device,
substituteAgentWorkgroupSize(device, agentSchema),
shader
),
entryPoint: 'main',
},
});
@ -128,7 +138,7 @@ export class EraserAgentPipeline {
this.uniformValues[5] = activeBounds.minY;
this.uniformValues[6] = activeBounds.maxX;
this.uniformValues[7] = activeBounds.maxY;
writeFloat32BufferIfChanged(
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
@ -154,7 +164,7 @@ export class EraserAgentPipeline {
);
passEncoder.setPipeline(this.pipeline);
passEncoder.setBindGroup(1, this.bindGroupCache(this.getAgentsBuffer(), eraserMask));
dispatchAgentWorkgroups(passEncoder, this.agentCount);
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, this.agentCount);
passEncoder.end();
}

View file

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

View file

@ -2,8 +2,8 @@ import { vec2 } from 'gl-matrix';
import { appConfig } from '../../config';
import {
createCachedFloat32BufferWrite,
writeFloat32BufferIfChanged,
createCachedBufferWrite,
writeBufferIfChanged,
} from '../../utils/graphics/cached-buffer-write';
import { smartCompile } from '../../utils/graphics/smart-compile';
import { CommonState } from '../common-state/common-state';
@ -13,6 +13,10 @@ import {
LineSegmentBuffer,
} from '../common/line-segment-buffer';
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';
interface EraserTextureParameters {
@ -25,7 +29,11 @@ interface EraserTextureParameters {
}
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 {
private readonly bindGroupLayout: GPUBindGroupLayout;
@ -33,7 +41,9 @@ export class EraserTexturePipeline {
private readonly combinedPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
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;
public constructor(
@ -114,7 +124,7 @@ export class EraserTexturePipeline {
this.uniformValues[4] = eraserClearBlue;
this.uniformValues[5] = eraserClearAlpha;
this.uniformValues[6] = eraserRadius;
writeFloat32BufferIfChanged(
writeBufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,

View file

@ -33,10 +33,12 @@ fn vertex(
let direction = end - start;
let denominator = dot(direction, direction);
var inverseLengthSquared = 0.0;
var normalizedDirection = vec2<f32>(1.0, 0.0);
if denominator > settings.lineDistanceEpsilon {
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 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);

View file

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

View file

@ -10,32 +10,14 @@ struct Settings {
traceNormalizationFloor: f32,
brushColorBase: f32,
brushColorStrengthMultiplier: f32,
backgroundGrainStrength: f32,
};
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
const NOISE_TEXTURE_MASK = 2047u;
@fragment
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 traces = textureLoad(trailMap, pixel, 0);
let sources = textureLoad(sourceMap, pixel, 0);
@ -43,39 +25,30 @@ fn fragmentNoGrain(@builtin(position) position: vec4<f32>) -> @location(0) vec4<
}
@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 traces = textureLoad(trailMap, pixel, 0);
return renderColor(traces, vec4<f32>(0.0), getFlatBackground());
}
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) -> vec4<f32> {
let tracesMax = maxComponent(traces.rgb);
let sourcesMax = maxComponent(sources.rgb);
if max(tracesMax, sourcesMax) <= 0.0 {
let traceStrengths = clarity(traces.rgb);
let sourceStrengths = clarity(sources.rgb);
let traceStrength = maxComponent(traceStrengths);
let brushStrength = maxComponent(sourceStrengths);
if max(traceStrength, brushStrength) <= 0.0 {
return vec4(background, 1);
}
let traceStrengths = vec3(
clarity(traces.r),
clarity(traces.g),
clarity(traces.b)
);
if sourcesMax <= 0.0 {
if brushStrength <= 0.0 {
let traceColor =
traceStrengths.r * settings.colorA
+ traceStrengths.g * settings.colorB
+ traceStrengths.b * settings.colorC;
let normalizedTraceColor = normalizeColorIntensity(traceColor);
let traceStrength = maxComponent(traceStrengths);
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 traceColor =
strengths.r * settings.colorA
@ -87,7 +60,6 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, background: vec3<f32>) ->
+ sourceStrengths.g * settings.colorB
+ sourceStrengths.b * settings.colorC;
let normalizedBrushColor = normalizeColorIntensity(brushColor);
let brushStrength = maxComponent(sourceStrengths);
let brushVisibility = clamp(
brushStrength * (
settings.brushColorBase +
@ -106,12 +78,8 @@ fn maxComponent(v: vec3<f32>) -> f32 {
return max(max(v.r, v.g), v.b);
}
fn clarity(strength: f32) -> f32 {
let clamped = clamp(strength, 0, 1);
if settings.clarity == 1.0 {
return clamped;
}
return pow(clamped, settings.clarity);
fn clarity(strength: vec3<f32>) -> vec3<f32> {
return pow(clamp(strength, vec3(0), vec3(1)), vec3(settings.clarity));
}
fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
@ -122,14 +90,3 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
fn getFlatBackground() -> vec3<f32> {
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 { getInitialVibe, type VibePreset } from './vibes';
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings => ({
...appConfig.defaultSettings,
eraserSize: appConfig.toolbar.eraser.default,
mirrorSegmentCount: appConfig.toolbar.mirror.default,
...vibe.settings,
const preservedRuntimeSettingKeys = [
'eraserSize',
'adaptiveCapInitial',
'adaptiveCapMin',
'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 = {
...buildSettings(activeVibe),
};
export const applyVibeSettings = (vibe: VibePreset) => {
activeVibe = vibe;
Object.assign(settings, {
...buildSettings(vibe),
eraserSize: settings.eraserSize,
adaptiveCapInitial: settings.adaptiveCapInitial,
adaptiveCapMin: settings.adaptiveCapMin,
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
maxAgentCount: settings.maxAgentCount,
mirrorSegmentCount: settings.mirrorSegmentCount,
selectedColorIndex: Math.min(settings.selectedColorIndex, vibe.colors.length - 1),
activeVibe = cloneVibePreset(vibe);
const nextSettings = buildSettings(activeVibe);
preservedRuntimeSettingKeys.forEach((key) => {
nextSettings[key] = settings[key];
});
nextSettings.selectedColorIndex = Math.min(
settings.selectedColorIndex,
activeVibe.colors.length - 1
);
Object.assign(
settings,
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
);
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);

View file

@ -46,19 +46,19 @@ html > body {
}
&::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-size: 257px 257px;
filter: contrast(190%) brightness(0.66);
filter: contrast(145%) brightness(0.82);
mix-blend-mode: multiply;
}
&::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-position: 73px 41px;
background-size: 389px 389px;
filter: contrast(170%) brightness(1.02);
filter: contrast(135%) brightness(1);
mix-blend-mode: screen;
transform: rotate(0.01deg);
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
@use 'shared' as *;
html > body > aside.control-dock > .toolbar-row > nav.buttons {
.buttons {
grid-area: buttons;
display: flex;
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 {
content: '';
position: absolute;

View file

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

View file

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

View file

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

View file

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

View file

@ -3,14 +3,13 @@ import { clamp } from './math';
export class DeltaTimeCalculator {
private previousTime: DOMHighResTimeStamp | null = null;
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
constructor() {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
document.addEventListener('visibilitychange', this.visibilityChangeListener);
}
public calculateDeltaTimeInSeconds(
currentTime: DOMHighResTimeStamp
): DOMHighResTimeStamp {
public calculateDeltaTimeInSeconds(currentTime: DOMHighResTimeStamp): number {
if (this.previousTime === null) {
this.previousTime = currentTime;
}
@ -29,4 +28,8 @@ export class DeltaTimeCalculator {
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;
};
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;
previous: Float32Array;
previous: Uint8Array;
}
export const createCachedFloat32BufferWrite = (
length: number
): CachedFloat32BufferWrite => ({
export const createCachedBufferWrite = (byteLength: number): CachedBufferWrite => ({
hasValue: false,
previous: new Float32Array(length),
previous: new Uint8Array(byteLength),
});
export const writeFloat32BufferIfChanged = (
device: GPUDevice,
buffer: GPUBuffer,
values: Float32Array,
cache: CachedFloat32BufferWrite
export const updateCachedBufferWrite = (
values: ArrayBufferView,
cache: CachedBufferWrite
): 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');
}
let hasChanged = !cache.hasValue;
for (let i = 0; i < values.length && !hasChanged; i++) {
hasChanged = !Object.is(values[i], cache.previous[i]);
for (let i = 0; i < bytes.length && !hasChanged; i++) {
hasChanged = bytes[i] !== cache.previous[i];
}
if (!hasChanged) {
return false;
}
cache.previous.set(values);
cache.previous.set(bytes);
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);
return true;
};

View file

@ -3,9 +3,11 @@ import { ErrorCode, getErrorMessage, RuntimeError } from '../error-handler';
export const initializeContext = ({
device,
canvas,
format,
}: {
device: GPUDevice;
canvas: HTMLCanvasElement;
format: GPUTextureFormat;
}): GPUCanvasContext => {
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 {
context.configure({
device: device,
format: gpu.getPreferredCanvasFormat(),
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
alphaMode: 'opaque',
});

View file

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

View file

@ -1,5 +1,7 @@
import { vec2 } from 'gl-matrix';
import { TRAIL_SOURCE_TEXTURE_FORMAT } from '../../pipelines/texture-formats';
interface ResizableTextureOptions {
clearValue?: GPUColor;
format?: GPUTextureFormat;
@ -27,7 +29,7 @@ export class ResizableTexture {
size: vec2,
{
clearValue = { r: 0, g: 0, b: 0, a: 0 },
format = 'rgba16float',
format = TRAIL_SOURCE_TEXTURE_FORMAT,
usage = defaultTextureUsage,
}: ResizableTextureOptions = {}
) {
@ -39,18 +41,6 @@ export class ResizableTexture {
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 {
if (vec2.equals(this.size, size)) {
return null;

View file

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