This commit is contained in:
Andras Schmelczer 2026-05-16 15:05:35 +01:00
parent 70423851ba
commit 1fe5015056
55 changed files with 2077 additions and 726 deletions

2
definitions.d.ts vendored
View file

@ -6,3 +6,5 @@ declare module '*.wgsl?raw' {
interface HTMLCanvasElement {
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
}
declare var webkitOfflineAudioContext: typeof OfflineAudioContext | undefined;

7
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.2.0",
"license": "Unlicense",
"dependencies": {
"@plausible-analytics/tracker": "^0.4.5",
"tweakpane": "^4.0.5"
},
"devDependencies": {
@ -1936,6 +1937,12 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@plausible-analytics/tracker": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz",
"integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",

View file

@ -68,6 +68,7 @@
"vitest": "^4.1.5"
},
"dependencies": {
"@plausible-analytics/tracker": "^0.4.5",
"tweakpane": "^4.0.5"
}
}

View file

@ -19,7 +19,7 @@ export default defineConfig({
webServer: {
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
ignoreHTTPSErrors: true,
reuseExistingServer: !isCi,
reuseExistingServer: false,
timeout: 120_000,
url: baseURL,
},

69
src/analytics.ts Normal file
View file

@ -0,0 +1,69 @@
import {
init as plausibleInit,
track as plausibleTrack,
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
let isInitialized = false;
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
try {
plausibleTrack(eventName, options);
} catch (error) {
console.warn(`Could not track analytics event "${eventName}".`, error);
}
};
export const initAnalytics = () => {
if (isInitialized) {
return;
}
try {
plausibleInit({
domain: 'schmelczer.dev/floating',
endpoint: 'https://stats.schmelczer.dev/status',
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
outboundLinks: true,
hashBasedRouting: true,
});
isInitialized = true;
} catch (error) {
console.warn('Could not initialize analytics.', error);
}
};
export const trackVibeChange = ({
vibeId,
vibeName,
source,
}: {
vibeId: string;
vibeName: string;
source: string;
}) => {
track('Vibe Change', {
props: {
vibeId,
vibeName,
source,
},
});
};
export const trackExport = ({ vibeId }: { vibeId: string }) => {
track('Export', {
props: {
format: 'png',
resolution: '4k',
vibeId,
},
});
};
export const trackSettingsOpen = () => {
track('Settings Open');
};

View file

@ -26,14 +26,6 @@ export interface GardenAudioConfig {
fadeInSeconds: number;
updateRampSeconds: number;
highPassFrequencyHz: number;
fallbackVibeId: string;
compressor: {
thresholdDb: number;
kneeDb: number;
ratio: number;
attackSeconds: number;
releaseSeconds: number;
};
delay: {
timeSeconds: number;
feedback: number;
@ -47,9 +39,6 @@ export interface GardenAudioConfig {
releaseSeconds: number;
lowpassHz: number;
};
input: {
pressureFallback: number;
};
rhythm: {
bpm: number;
stepsPerBeat: number;

View file

@ -2,6 +2,9 @@ import type { GardenAudioEngineConfig } from '../config';
import { clamp } from '../utils/clamp';
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
const UNLOCK_TICK_SECONDS = 0.035;
const UNLOCK_TICK_FREQUENCY_HZ = 440;
export class GardenAudioGraph {
public context: AudioContext | null = null;
public eventBus: GainNode | null = null;
@ -12,7 +15,6 @@ export class GardenAudioGraph {
private delayNode: DelayNode | null = null;
private delayFeedback: GainNode | null = null;
private delayOutput: GainNode | null = null;
private hasUnlocked = false;
public constructor(
private readonly config: GardenAudioConfig,
@ -28,23 +30,26 @@ export class GardenAudioGraph {
return null;
}
const context = new AudioContext({ latencyHint: 'interactive' });
const AudioContextConstructor = globalThis.AudioContext;
if (!AudioContextConstructor) {
return null;
}
let context: AudioContext;
try {
context = new AudioContextConstructor({ latencyHint: 'interactive' });
} catch {
context = new AudioContextConstructor();
}
const masterGain = context.createGain();
const highPass = context.createBiquadFilter();
const compressor = context.createDynamicsCompressor();
masterGain.gain.value = 0;
highPass.type = 'highpass';
highPass.frequency.value = this.config.highPassFrequencyHz;
compressor.threshold.value = this.config.compressor.thresholdDb;
compressor.knee.value = this.config.compressor.kneeDb;
compressor.ratio.value = this.config.compressor.ratio;
compressor.attack.value = this.config.compressor.attackSeconds;
compressor.release.value = this.config.compressor.releaseSeconds;
masterGain.connect(highPass);
highPass.connect(compressor);
compressor.connect(context.destination);
highPass.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
@ -55,24 +60,37 @@ export class GardenAudioGraph {
return context;
}
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
// a buffer source has been started inside a user-gesture handler. Calling
// resume() alone leaves the context "running" but silent.
// iOS WebKit can report "running" while the hardware output is still silent.
// A very short, nearly inaudible oscillator in the gesture stack is more
// reliable than a fully silent buffer on recent Safari versions.
public unlock(): void {
if (!this.context || this.hasUnlocked) {
if (!this.context) {
return;
}
const buffer = this.context.createBuffer(
1,
this.engineConfig.graph.unlockBufferLength,
this.engineConfig.graph.unlockSampleRate
const now = this.context.currentTime;
const source = this.context.createOscillator();
const gain = this.context.createGain();
source.type = 'sine';
source.frequency.setValueAtTime(UNLOCK_TICK_FREQUENCY_HZ, now);
gain.gain.setValueAtTime(this.engineConfig.piano.minGain, now);
gain.gain.exponentialRampToValueAtTime(
this.engineConfig.piano.minGain,
now + UNLOCK_TICK_SECONDS
);
source.connect(gain);
gain.connect(this.context.destination);
source.start(now);
source.stop(now + UNLOCK_TICK_SECONDS);
source.addEventListener(
'ended',
() => {
source.disconnect();
gain.disconnect();
},
{ once: true }
);
const source = this.context.createBufferSource();
source.buffer = buffer;
source.connect(this.context.destination);
source.start(0);
this.hasUnlocked = true;
}
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
@ -201,6 +219,5 @@ export class GardenAudioGraph {
this.delayNode = null;
this.delayFeedback = null;
this.delayOutput = null;
this.hasUnlocked = false;
}
}

View file

@ -13,14 +13,13 @@ export interface GardenAudioStrokeMetrics {
export const getStrokeMetrics = (
stroke: GardenAudioStroke,
speedForFullEnergyPixelsPerSecond: number,
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): GardenAudioStrokeMetrics => {
const dx = stroke.to[0] - stroke.from[0];
const dy = stroke.to[1] - stroke.from[1];
const distancePixels = Math.hypot(dx, dy);
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
const pressure = getPressureAmount(stroke);
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
const strokeEnergy = clamp01(
inputConfig.strokeEnergyBase +
@ -58,11 +57,7 @@ const getStrokeVelocity = (
return distancePixels / inputConfig.fallbackFrameSeconds;
};
const getPressureAmount = (
stroke: GardenAudioStroke,
fallbackPressure: number,
inputConfig: GardenAudioEngineConfig['input']
): number => {
const getPressureAmount = (stroke: GardenAudioStroke): number => {
if (
stroke.pressure !== undefined &&
Number.isFinite(stroke.pressure) &&
@ -71,7 +66,5 @@ const getPressureAmount = (
return clamp01(stroke.pressure);
}
return stroke.pointerType === 'pen'
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
: clamp01(fallbackPressure);
return 0;
};

View file

@ -12,10 +12,14 @@ export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
export const getVibeProfile = (
config: GardenAudioConfig,
vibe: VibePreset
): GardenAudioVibeProfile =>
config.vibes[vibe.id] ??
config.vibes[config.fallbackVibeId] ??
Object.values(config.vibes)[0];
): GardenAudioVibeProfile => {
const profile = config.vibes[vibe.id];
if (!profile) {
throw new Error(`Missing audio profile for vibe "${vibe.id}"`);
}
return profile;
};
export const getChordIntervals = (
chord: GardenAudioChord,

View file

@ -21,7 +21,6 @@ export interface GardenAudioStroke {
elapsedSeconds?: number;
eraserSizePixels?: number;
mirrorSegmentCount?: number;
pointerType?: string;
}
export interface GardenAudioTouchDown {
@ -31,7 +30,6 @@ export interface GardenAudioTouchDown {
canvasSize?: ArrayLike<number>;
mirrorSegmentCount?: number;
pressure?: number;
pointerType?: string;
}
export interface GardenAudioStartOptions {
@ -45,7 +43,7 @@ export interface LoadedPianoSample {
export interface ActivePianoVoice {
gain: GainNode;
source: AudioBufferSourceNode;
source: AudioScheduledSourceNode;
startAt: number;
stopAt: number;
}

View file

@ -1,17 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { VIBE_PRESETS } from '../vibes';
import { GardenAudio } from './garden-audio';
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
import { loadPianoSamples, resetPianoSampleCacheForTest } from './piano-samples';
type FakeScheduledSourceNode = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
const calls = {
constructed: 0,
resumed: 0,
sourcesStarted: 0,
sources: [] as Array<FakeScheduledSourceNode>,
};
let contextState: AudioContextState = 'suspended';
let resumeError: Error | null = null;
class FakeAudioParam {
public value = 0;
@ -24,6 +33,7 @@ class FakeAudioParam {
class FakeAudioNode {
public readonly gain = new FakeAudioParam();
public readonly frequency = new FakeAudioParam();
public readonly playbackRate = new FakeAudioParam();
public readonly Q = new FakeAudioParam();
public readonly threshold = new FakeAudioParam();
public readonly knee = new FakeAudioParam();
@ -54,6 +64,7 @@ class FakeAudioContext {
public readonly currentTime = 1;
public readonly sampleRate = 16;
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
public constructor() {
calls.constructed += 1;
@ -75,10 +86,6 @@ class FakeAudioContext {
return new FakeAudioNode() as unknown as BiquadFilterNode;
}
public createDynamicsCompressor(): DynamicsCompressorNode {
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
}
public createDelay(): DelayNode {
return new FakeAudioNode() as unknown as DelayNode;
}
@ -100,13 +107,30 @@ class FakeAudioContext {
node.buffer = null;
node.start = vi.fn(() => {
calls.sourcesStarted += 1;
});
node.stop = vi.fn();
}) as unknown as typeof node.start;
node.stop = vi.fn() as unknown as typeof node.stop;
calls.sources.push(node as unknown as FakeScheduledSourceNode);
return node;
}
public createOscillator(): OscillatorNode {
const node = new FakeAudioNode() as unknown as OscillatorNode & {
start: () => void;
stop: () => void;
};
node.start = vi.fn(() => {
calls.sourcesStarted += 1;
}) as unknown as typeof node.start;
node.stop = vi.fn() as unknown as typeof node.stop;
calls.sources.push(node as unknown as FakeScheduledSourceNode);
return node;
}
public async resume(): Promise<void> {
calls.resumed += 1;
if (resumeError) {
throw resumeError;
}
contextState = 'running';
}
}
@ -120,13 +144,18 @@ describe('GardenAudio startup policy', () => {
calls.constructed = 0;
calls.resumed = 0;
calls.sourcesStarted = 0;
calls.sources = [];
contextState = 'suspended';
resumeError = null;
resetPianoSampleCacheForTest();
vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resetPianoSampleCacheForTest();
});
it('does not create an AudioContext from passive audio paths', () => {
@ -171,7 +200,27 @@ describe('GardenAudio startup policy', () => {
expect(calls.resumed).toBe(1);
});
it('skips cold piano fallback while preserving eraser noise', () => {
it('reports AudioContext resume failures as warnings', async () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const vibe = VIBE_PRESETS[0];
resumeError = new Error('resume rejected');
const addException = vi.spyOn(ErrorHandler, 'addException');
audio.start(vibe, { userGesture: true });
await Promise.resolve();
await Promise.resolve();
expect(addException).toHaveBeenCalledWith(resumeError, {
fallbackMessage: 'Could not resume audio playback.',
severity: Severity.WARNING,
});
});
it('stays silent without piano samples while preserving eraser noise', () => {
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
@ -217,4 +266,51 @@ describe('GardenAudio startup policy', () => {
expect(calls.sourcesStarted).toBe(2);
});
it('quickly stops active piano voices when the vibe changes', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
arrayBuffer: async () => new ArrayBuffer(8),
ok: true,
}))
);
await loadPianoSamples(new FakeAudioContext() as unknown as AudioContext);
const audio = new GardenAudio(
makeConfig(),
appConfig.audioEngine,
appConfig.simulation.maxMirrorSegmentCount
);
const vibe = VIBE_PRESETS[0];
audio.start(vibe, { userGesture: true });
audio.beginGesture();
audio.touchDown({
vibe,
colorIndex: 1,
position: [30, 40],
canvasSize: [100, 100],
pressure: 0.7,
});
const activePianoSources = calls.sources.filter(
(source) => source.stop.mock.calls.length === 1
);
expect(activePianoSources.length).toBeGreaterThan(0);
const stopCounts = activePianoSources.map((source) => source.stop.mock.calls.length);
audio.changeVibe(VIBE_PRESETS[1], { userGesture: true });
const stoppedVoices = activePianoSources.filter(
(source, index) => source.stop.mock.calls.length === stopCounts[index] + 1
);
expect(stoppedVoices.length).toBeGreaterThan(0);
stoppedVoices.forEach((source) => {
expect(source.stop.mock.calls.at(-1)?.[0]).toBeCloseTo(
1 + appConfig.audioEngine.piano.voiceStealStopSeconds,
3
);
});
});
});

View file

@ -1,5 +1,6 @@
import type { GardenAudioEngineConfig } from '../config';
import { clamp, clamp01 } from '../utils/clamp';
import { ErrorHandler, Severity } from '../utils/error-handler';
import { VibePreset } from '../vibes';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioEnergy } from './garden-audio-energy';
@ -71,40 +72,66 @@ export class GardenAudio {
return;
}
const startupRampSeconds =
options.userGesture === true
? this.engineConfig.muteRampSeconds
: this.config.fadeInSeconds;
const needsResume = context.state !== 'running' && context.state !== 'closed';
let resumePromise: Promise<void> | null = null;
if (needsResume) {
if (options.userGesture !== true) {
return;
}
resumePromise = context.resume();
}
if (options.userGesture === true) {
this.graph.unlock();
}
if (context.state === 'suspended') {
if (options.userGesture !== true) {
return;
}
void context.resume().catch(() => undefined);
if (resumePromise) {
void resumePromise
.then(() => {
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
this.graph.unlock();
this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds);
}
})
.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not resume audio playback.',
severity: Severity.WARNING,
});
});
}
this.hasStarted = true;
this.applyVibe(vibe);
this.pianoEngine.prime(context.currentTime);
this.graph.setMasterGain(
this.config.masterVolume,
options.userGesture === true
? this.engineConfig.muteRampSeconds
: this.config.fadeInSeconds
);
this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds);
if (!this.hasQueuedPianoLoad) {
this.hasQueuedPianoLoad = true;
void this.piano.load(context).then(() => {
if (this.graph.context === context && !this.isDestroyed) {
this.pianoEngine.cue(context.currentTime);
}
});
void this.piano
.load(context)
.then(() => {
if (this.graph.context === context && !this.isDestroyed) {
this.pianoEngine.cue(context.currentTime);
}
})
.catch(() => undefined);
}
}
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
const previousVibeId = this.currentVibeId;
this.start(vibe, options);
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
if (didChangeVibe) {
this.piano.stopAll();
}
const context = this.graph.context;
if (
@ -112,8 +139,7 @@ export class GardenAudio {
(context.state === 'running' || options.userGesture === true) &&
!this.isMuted &&
!this.isDestroyed &&
previousVibeId !== null &&
previousVibeId !== vibe.id
didChangeVibe
) {
this.playVibeChangeStinger(vibe);
}
@ -158,7 +184,7 @@ export class GardenAudio {
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
const pressure = this.getTouchPressure(touch.pressure, touch.pointerType);
const pressure = this.getPressure(touch.pressure);
const strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
const frame = this.gestureState.recordTouchDown({
touch,
@ -225,7 +251,6 @@ export class GardenAudio {
const metrics = getStrokeMetrics(
stroke,
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
this.config.input.pressureFallback,
this.engineConfig.input
);
const now = context.currentTime;
@ -375,16 +400,11 @@ export class GardenAudio {
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
}
private getTouchPressure(pressure: number | undefined, pointerType?: string): number {
private getPressure(pressure: number | undefined): number {
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
return clamp01(pressure);
}
return pointerType === 'pen'
? Math.max(
this.engineConfig.input.penMinPressure,
this.config.input.pressureFallback
)
: this.config.input.pressureFallback;
return 0;
}
}

View file

@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appConfig } from '../config';
import { gardenAudioConfig } from './garden-audio-config';
import type { GardenAudioGraph } from './garden-audio-graph';
import { PianoSampler } from './piano-sampler';
import { pianoSampleDefinitions, resetPianoSampleCacheForTest } from './piano-samples';
const calls = {
bufferSourcesStarted: 0,
};
class FakeAudioParam {
public value = 0;
public setTargetAtTime = vi.fn();
public setValueAtTime = vi.fn();
public exponentialRampToValueAtTime = vi.fn();
public cancelScheduledValues = vi.fn();
}
class FakeAudioNode {
public readonly gain = new FakeAudioParam();
public readonly frequency = new FakeAudioParam();
public readonly playbackRate = new FakeAudioParam();
public readonly Q = new FakeAudioParam();
public readonly pan = new FakeAudioParam();
public buffer: AudioBuffer | null = null;
public type = '';
public addEventListener = vi.fn();
public connect = vi.fn();
public disconnect = vi.fn();
public start = vi.fn();
public stop = vi.fn();
}
class FakeAudioContext {
public readonly currentTime = 1;
public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
public createGain(): GainNode {
return new FakeAudioNode() as unknown as GainNode;
}
public createBiquadFilter(): BiquadFilterNode {
return new FakeAudioNode() as unknown as BiquadFilterNode;
}
public createStereoPanner(): StereoPannerNode {
return new FakeAudioNode() as unknown as StereoPannerNode;
}
public createBufferSource(): AudioBufferSourceNode {
const node = new FakeAudioNode() as unknown as AudioBufferSourceNode & {
start: () => void;
stop: () => void;
};
node.start = vi.fn(() => {
calls.bufferSourcesStarted += 1;
});
node.stop = vi.fn();
return node;
}
}
const makeSampler = (context: AudioContext): PianoSampler => {
const eventBus = new FakeAudioNode() as unknown as GainNode;
const graph = {
context,
delayInput: null,
eventBus,
} as unknown as GardenAudioGraph;
return new PianoSampler(gardenAudioConfig, appConfig.audioEngine, graph);
};
describe('PianoSampler', () => {
beforeEach(() => {
calls.bufferSourcesStarted = 0;
resetPianoSampleCacheForTest();
});
afterEach(() => {
vi.unstubAllGlobals();
resetPianoSampleCacheForTest();
});
it('loads every piano sample before playback', async () => {
const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = makeSampler(context);
const fetch = vi.fn(async () => {
return {
arrayBuffer: async () => new ArrayBuffer(8),
ok: true,
} as Response;
});
vi.stubGlobal('fetch', fetch);
await sampler.load(context);
sampler.play({
durationSeconds: 0.2,
midi: 60,
pan: 0,
startTime: context.currentTime,
velocity: 0.5,
});
expect(fetch).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
expect(context.decodeAudioData).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
expect(calls.bufferSourcesStarted).toBe(1);
});
it('stays silent when no decoded sample is available', () => {
const context = new FakeAudioContext() as unknown as AudioContext;
const sampler = makeSampler(context);
sampler.play({
durationSeconds: 0.2,
midi: 60,
pan: 0,
startTime: context.currentTime,
velocity: 0.5,
});
expect(calls.bufferSourcesStarted).toBe(0);
});
});

View file

@ -3,7 +3,7 @@ import { clamp, clamp01 } from '../utils/clamp';
import { GardenAudioConfig } from './garden-audio-config';
import { GardenAudioGraph } from './garden-audio-graph';
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
import { pianoSampleDefinitions } from './piano-samples';
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
export class PianoSampler {
private sampleLoadPromise: Promise<void> | null = null;
@ -16,28 +16,20 @@ export class PianoSampler {
private readonly graph: GardenAudioGraph
) {}
public async load(context: AudioContext): Promise<void> {
public load(context: BaseAudioContext): Promise<void> {
const loadedSamples = getLoadedPianoSamples();
if (loadedSamples) {
this.setSamples(loadedSamples);
return Promise.resolve();
}
if (this.sampleLoadPromise) {
return this.sampleLoadPromise;
}
this.sampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const response = await fetch(sample.url);
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await context.decodeAudioData(audioData);
return { midi: sample.midi, buffer };
})
)
.then((samples) => {
this.samples = samples.sort((a, b) => a.midi - b.midi);
})
.catch(() => {
this.samples = [];
});
this.sampleLoadPromise = loadPianoSamples(context).then((samples) => {
this.setSamples(samples);
});
return this.sampleLoadPromise;
}
@ -89,13 +81,9 @@ export class PianoSampler {
this.trimActiveVoices(scheduledStart);
while (this.activeVoices.length >= this.config.piano.maxVoices) {
const oldest = this.activeVoices.shift();
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
oldest?.gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
scheduledStart,
this.engineConfig.piano.voiceStealFadeSeconds
);
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
if (oldest) {
this.stopVoice(oldest, scheduledStart);
}
}
source.buffer = sample.buffer;
@ -162,6 +150,21 @@ export class PianoSampler {
);
}
public stopAll(): void {
const context = this.graph.context;
if (!context) {
this.activeVoices = [];
return;
}
const now = context.currentTime;
this.activeVoices.forEach((voice) => {
this.stopVoice(voice, now);
});
this.activeVoices = [];
}
public reset(): void {
this.sampleLoadPromise = null;
this.samples = [];
@ -181,4 +184,25 @@ export class PianoSampler {
private trimActiveVoices(now: number): void {
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
}
private stopVoice(voice: ActivePianoVoice, now: number): void {
const stopAt = now + this.engineConfig.piano.voiceStealStopSeconds;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setTargetAtTime(
this.engineConfig.piano.minGain,
now,
this.engineConfig.piano.voiceStealFadeSeconds
);
voice.stopAt = stopAt;
try {
voice.source.stop(stopAt);
} catch {
// The voice may already have ended; either way it is no longer part of the mix.
}
}
private setSamples(samples: Array<LoadedPianoSample>): void {
this.samples = samples.slice().sort((a, b) => a.midi - b.midi);
}
}

View file

@ -1,8 +1,16 @@
interface PianoSampleDefinition {
import type { LoadedPianoSample } from './garden-audio-types';
export interface PianoSampleDefinition {
midi: number;
url: string;
}
export interface PianoSampleLoadProgress {
loadedCount: number;
totalCount: number;
sample?: PianoSampleDefinition;
}
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
const sampleFiles: Array<[fileName: string, midi: number]> = [
@ -44,3 +52,94 @@ export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
url: `${sampleBaseUrl}${fileName}`,
}))
.sort((a, b) => a.midi - b.midi);
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
export const preloadPianoSamples = (
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
const OfflineAudioContextConstructor =
globalThis.OfflineAudioContext ?? globalThis.webkitOfflineAudioContext;
if (!OfflineAudioContextConstructor) {
return Promise.reject(
new Error('OfflineAudioContext is required to preload piano samples.')
);
}
const decodeContext = new OfflineAudioContextConstructor(1, 1, 44_100);
return loadPianoSamples(decodeContext, onProgress);
};
export const loadPianoSamples = (
decodeContext: BaseAudioContext,
onProgress?: (progress: PianoSampleLoadProgress) => void
): Promise<Array<LoadedPianoSample>> => {
if (loadedPianoSamples) {
onProgress?.({
loadedCount: loadedPianoSamples.length,
totalCount: pianoSampleDefinitions.length,
});
return Promise.resolve([...loadedPianoSamples]);
}
if (pianoSampleLoadPromise) {
return pianoSampleLoadPromise;
}
let loadedCount = 0;
const totalCount = pianoSampleDefinitions.length;
onProgress?.({ loadedCount, totalCount });
pianoSampleLoadPromise = Promise.all(
pianoSampleDefinitions.map(async (sample) => {
const loadedSample = await loadPianoSample(decodeContext, sample);
loadedCount += 1;
onProgress?.({ loadedCount, totalCount, sample });
return loadedSample;
})
).then(
(samples) => {
loadedPianoSamples = samples.slice().sort((a, b) => a.midi - b.midi);
return [...loadedPianoSamples];
},
(error: unknown) => {
pianoSampleLoadPromise = null;
throw error;
}
);
return pianoSampleLoadPromise;
};
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
loadedPianoSamples ? [...loadedPianoSamples] : null;
export const resetPianoSampleCacheForTest = (): void => {
loadedPianoSamples = null;
pianoSampleLoadPromise = null;
};
const loadPianoSample = async (
decodeContext: BaseAudioContext,
sample: PianoSampleDefinition
): Promise<LoadedPianoSample> => {
const response = await fetch(sample.url);
if (!response.ok) {
throw new Error(`Unable to load piano sample ${sample.url}`);
}
const audioData = await response.arrayBuffer();
const buffer = await decodeAudioData(decodeContext, audioData);
return { midi: sample.midi, buffer };
};
const decodeAudioData = (
decodeContext: BaseAudioContext,
audioData: ArrayBuffer
): Promise<AudioBuffer> =>
new Promise((resolve, reject) => {
const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
decodePromise?.then(resolve, reject);
});

View file

@ -3,15 +3,10 @@ import type { GardenAppConfig } from './config/types';
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
export type {
AgentColorInteractionSettings,
GardenAppConfig,
GardenAudioEngineConfig,
GardenRuntimeSettings,
GardenSimulationConfig,
GardenStorageConfig,
GardenVibeSettings,
NumberControlConfig,
RuntimeSettingControlConfig,
VibePreset,
} from './config/types';
@ -21,14 +16,6 @@ export const appConfig = {
fadeInSeconds: 0.45,
updateRampSeconds: 0.08,
highPassFrequencyHz: 45,
fallbackVibeId: defaultVibeId,
compressor: {
thresholdDb: -18,
kneeDb: 18,
ratio: 2.4,
attackSeconds: 0.006,
releaseSeconds: 0.18,
},
delay: {
timeSeconds: 0.46,
feedback: 0.12,
@ -42,9 +29,6 @@ export const appConfig = {
releaseSeconds: 0.24,
lowpassHz: 7600,
},
input: {
pressureFallback: 0.48,
},
rhythm: {
bpm: 74,
stepsPerBeat: 4,
@ -120,7 +104,6 @@ export const appConfig = {
distanceEnergyScale: 0.66,
distanceForFullEnergyPixels: 140,
fallbackFrameSeconds: 1 / 60,
penMinPressure: 0.56,
strokeEnergyBase: 0.18,
strokeEnergyPressureWeight: 0.22,
strokeEnergySpeedWeight: 0.62,

View file

@ -18,47 +18,6 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
color3ToColor3: 1,
};
const hashString = (value: string): number => {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
};
const createSeededRandom = (seed: number): (() => number) => {
let state = seed;
return () => {
let value = (state += 0x6d2b79f5);
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
};
export const createColorInteractionSettings = (
seedSource: string
): AgentColorInteractionSettings => {
const random = createSeededRandom(hashString(seedSource));
const values = Object.values(agentInteractionOptions);
const randomInteraction = () =>
values[Math.floor(random() * values.length)] ??
defaultColorInteractionSettings.color1ToColor2;
return {
color1ToColor1: 1,
color1ToColor2: randomInteraction(),
color1ToColor3: randomInteraction(),
color2ToColor1: randomInteraction(),
color2ToColor2: 1,
color2ToColor3: randomInteraction(),
color3ToColor1: randomInteraction(),
color3ToColor2: randomInteraction(),
color3ToColor3: 1,
};
};
export const colorInteractionControl = (label: string): NumberControlConfig => ({
folder: 'Color Reactions',
label,

View file

@ -36,7 +36,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
startColorHue: 200,
renderSpeed: 1,
simulatedDelayMs: 0,
},
controls: {
@ -145,13 +144,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
max: 500,
step: 1,
},
renderSpeed: {
folder: 'Runtime',
integer: true,
min: 1,
max: 10,
step: 1,
},
selectedColorIndex: {
folder: 'Brush',
integer: true,

View file

@ -27,7 +27,7 @@ export type AgentColorInteractionSettings = Pick<
| 'color3ToColor3'
>;
export type GardenVibeSettings = Partial<
type GardenVibeSettings = Partial<
Pick<
GardenRuntimeSettings,
| 'agentBudgetMax'
@ -72,7 +72,7 @@ export interface NumberControlConfig {
step?: number;
}
export type RuntimeSettingControlConfig = {
type RuntimeSettingControlConfig = {
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
};
@ -120,7 +120,6 @@ export interface GardenAppConfig {
distanceEnergyScale: number;
distanceForFullEnergyPixels: number;
fallbackFrameSeconds: number;
penMinPressure: number;
strokeEnergyBase: number;
strokeEnergyPressureWeight: number;
strokeEnergySpeedWeight: number;
@ -280,5 +279,3 @@ export interface GardenAppConfig {
}
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
export type GardenSimulationConfig = GardenAppConfig['simulation'];
export type GardenStorageConfig = GardenAppConfig['storage'];

View file

@ -2,7 +2,7 @@ import type {
GardenAudioChord,
GardenAudioVibeProfile,
} from '../audio/garden-audio-config';
import { createColorInteractionSettings } from './color-interactions';
import { defaultColorInteractionSettings } from './color-interactions';
import type { VibePreset } from './types';
const majorProgression: Array<GardenAudioChord> = [
@ -42,7 +42,7 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 38,
spawnPerPixel: 0.22,
turnSpeed: 58,
...createColorInteractionSettings('candy-rain'),
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 57,
@ -69,7 +69,7 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 46,
spawnPerPixel: 0.18,
turnSpeed: 44,
...createColorInteractionSettings('sunlit-moss'),
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 53,
@ -101,7 +101,7 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 35,
spawnPerPixel: 0.25,
turnSpeed: 62,
...createColorInteractionSettings('coral-tide'),
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 50,
@ -128,7 +128,7 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 42,
spawnPerPixel: 0.2,
turnSpeed: 52,
...createColorInteractionSettings('moon-orchid'),
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 49,
@ -155,7 +155,7 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 32,
spawnPerPixel: 0.24,
turnSpeed: 70,
...createColorInteractionSettings('peach-neon'),
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 56,
@ -182,7 +182,7 @@ export const vibePresets: Array<VibePreset> = [
sensorOffsetDistance: 52,
spawnPerPixel: 0.16,
turnSpeed: 40,
...createColorInteractionSettings('frost-bloom'),
...defaultColorInteractionSettings,
},
audio: {
rootMidi: 62,

View file

@ -2,44 +2,61 @@ import { describe, expect, it } from 'vitest';
import { FramePerformance } from './frame-performance';
function createScenario() {
const performance = new FramePerformance();
let time = 0;
performance.update(time);
const advance = (fps: number): void => {
time += 1000 / fps;
performance.update(time);
};
return { performance, advance };
}
describe('FramePerformance refresh target', () => {
it('uses 60 FPS as the fixed adaptive budget target', () => {
const performance = new FramePerformance();
const { performance, advance } = createScenario();
[123, 126, 130, 121, 60, 30].forEach((fps) => performance.update(1 / fps));
[123, 126, 130, 121, 60, 30].forEach(advance);
expect(performance.refreshTargetFps).toBe(60);
});
it('keeps latest and smoothed FPS separate from the fixed target', () => {
const performance = new FramePerformance();
const { performance, advance } = createScenario();
performance.update(1 / 120);
advance(120);
expect(performance.latestFps).toBe(120);
expect(performance.smoothedFps).toBeGreaterThan(60);
expect(performance.refreshTargetFps).toBe(60);
});
it('snaps the display refresh estimate to a stable screen frequency', () => {
const performance = new FramePerformance();
it('reports true FPS even when the simulation delta would clamp', () => {
const { performance, advance } = createScenario();
[123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) =>
performance.update(1 / fps)
);
[5, 5, 5, 5, 5].forEach(advance);
expect(performance.latestFps).toBeCloseTo(5, 5);
});
it('snaps the display refresh estimate to a stable screen frequency', () => {
const { performance, advance } = createScenario();
[123, 126, 130, 121, 124, 127, 125, 122].forEach(advance);
expect(performance.refreshTargetFps).toBe(60);
expect(performance.displayRefreshFps).toBe(120);
});
it('ignores a single startup spike before settling the display refresh estimate', () => {
const performance = new FramePerformance();
const { performance, advance } = createScenario();
performance.update(1 / 240);
advance(240);
expect(performance.displayRefreshFps).toBe(60);
Array.from({ length: 8 }).forEach(() => performance.update(1 / 120));
Array.from({ length: 8 }).forEach(() => advance(120));
expect(performance.refreshTargetFps).toBe(60);
expect(performance.displayRefreshFps).toBe(120);

View file

@ -7,7 +7,6 @@ interface TelemetrySnapshot {
agentBudgetMax: number;
canvas: HTMLCanvasElement;
devicePixelRatio: number;
renderSpeed: number;
}
const COMMON_DISPLAY_REFRESH_RATES = [
@ -15,6 +14,7 @@ const COMMON_DISPLAY_REFRESH_RATES = [
] as const;
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
const FRAME_GAP_RESET_SECONDS = 1;
export class FramePerformance {
public latestFps = 60;
@ -23,6 +23,7 @@ export class FramePerformance {
public readonly refreshTargetFps = 60;
private lastTelemetryAt = 0;
private previousFrameTime: DOMHighResTimeStamp | null = null;
private hasConfirmedDisplayRefreshFps = false;
private pendingDisplayRefreshFps = 0;
private pendingDisplayRefreshFrameCount = 0;
@ -35,8 +36,19 @@ export class FramePerformance {
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
}
public update(deltaTime: number): void {
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
public update(time: DOMHighResTimeStamp): void {
const previous = this.previousFrameTime;
this.previousFrameTime = time;
if (previous === null) {
return;
}
const deltaSeconds = (time - previous) / 1000;
if (deltaSeconds <= 0 || deltaSeconds > FRAME_GAP_RESET_SECONDS) {
return;
}
const fps = 1 / deltaSeconds;
this.latestFps = fps;
this.updateDisplayRefreshEstimate(fps);
this.smoothedFps =
@ -51,7 +63,6 @@ export class FramePerformance {
agentBudgetMax,
canvas,
devicePixelRatio,
renderSpeed,
}: TelemetrySnapshot): void {
if (!appConfig.telemetry.enabled) {
return;
@ -73,7 +84,6 @@ export class FramePerformance {
canvasWidth: canvas.width,
canvasHeight: canvas.height,
dpr: devicePixelRatio,
renderSpeed,
frameCpuMs: now - frameCpuStartedAt,
encodeCpuMs,
});

View file

@ -10,31 +10,45 @@ const simulationTexturesSource = readFileSync(
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
'utf8'
);
const resizableTextureSource = readFileSync(
join(process.cwd(), 'src/utils/graphics/resizable-texture.ts'),
'utf8'
);
const getRenderStepSource = () => {
const start = simulationFrameSource.indexOf('for (let i = 0; i < renderSpeed; i++)');
const start = simulationFrameSource.indexOf(
'const commandEncoder = this.device.createCommandEncoder();'
);
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
if (start < 0 || end < 0) {
throw new Error('Could not find the render-speed simulation loop');
throw new Error('Could not find the simulation frame execution body');
}
return simulationFrameSource.slice(start, end);
};
describe('GameLoop ping-pong texture flow', () => {
it('copies only the trail map and swaps source/influence references after diffusion', () => {
it('copies only the trail map with a GPU texture copy and swaps source/influence references after diffusion', () => {
const renderStepSource = getRenderStepSource();
expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1);
expect(renderStepSource).toMatch(
/this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/
expect(renderStepSource).not.toContain('copyPipeline.execute');
expect(renderStepSource).toContain('this.textures.copyTrailMapAToB(commandEncoder);');
expect(simulationTexturesSource).toMatch(
/commandEncoder\.copyTextureToTexture\([\s\S]*this\.trailMapA\.getTexture\(\)[\s\S]*this\.trailMapB\.getTexture\(\)[\s\S]*width: size\[0\][\s\S]*height: size\[1\][\s\S]*\);/
);
expect(renderStepSource).toMatch(
/this\.pipelines\.diffusionPipeline\.execute\([\s\S]*this\.textures\.sourceMapA\.getTextureView\(\)[\s\S]*this\.textures\.sourceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.pipelines\.brushEffectDiffusionPipeline\.execute\([\s\S]*this\.textures\.influenceMapA\.getTextureView\(\)[\s\S]*this\.textures\.influenceMapB\.getTextureView\(\)[\s\S]*\);[\s\S]*this\.device\.queue\.submit\(\[commandEncoder\.finish\(\)\]\);[\s\S]*this\.textures\.swapSourceMaps\(\);[\s\S]*this\.textures\.swapInfluenceMaps\(\);/
);
});
it('keeps resizable textures usable for render, shader, and GPU copy paths', () => {
expect(resizableTextureSource).toContain('public getTexture(): GPUTexture');
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
expect(resizableTextureSource).toContain('this.copyPipeline.execute(');
});
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
expect(simulationTexturesSource).toContain('public sourceMapB: ResizableTexture;');

View file

@ -5,7 +5,6 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CommonState } from '../pipelines/common-state/common-state';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
@ -13,7 +12,7 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { settings } from '../settings';
import { initializeContext } from '../utils/graphics/initialize-context';
import { GLOBAL_AGENT_CAP } from './agent-population';
import { RenderInputs } from './game-loop-types';
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
import { SimulationFrameRenderer } from './simulation-frame';
import { SimulationTextures } from './simulation-textures';
@ -33,7 +32,6 @@ interface FrameParameters extends RenderInputs {
export class GameLoopResources {
public readonly textures: SimulationTextures;
public readonly commonState: CommonState;
public readonly copyPipeline: CopyPipeline;
public readonly agentGenerationPipeline: AgentGenerationPipeline;
public readonly agentPipeline: AgentPipeline;
public readonly brushPipeline: BrushPipeline;
@ -53,7 +51,6 @@ export class GameLoopResources {
const context = initializeContext({ device, canvas });
this.textures = new SimulationTextures(this.device, canvasSize);
this.copyPipeline = new CopyPipeline(this.device);
this.commonState = new CommonState(this.device);
this.commonState.setParameters({
@ -88,7 +85,6 @@ export class GameLoopResources {
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
copyPipeline: this.copyPipeline,
agentPipeline: this.agentPipeline,
brushPipeline: this.brushPipeline,
eraserAgentPipeline: this.eraserAgentPipeline,
@ -157,8 +153,11 @@ export class GameLoopResources {
this.setBrushEffectDiffusionParameters();
}
public executeFrame(renderSpeed: number, isErasing: boolean): void {
this.frameRenderer.execute(renderSpeed, isErasing);
public executeFrame(
isErasing: boolean,
canvasReadbackRequest?: CanvasReadbackRequest | null
): void {
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
}
public clearSwipes(): void {
@ -166,7 +165,6 @@ export class GameLoopResources {
}
public destroy(): void {
this.copyPipeline.destroy();
this.agentGenerationPipeline.destroy();
this.agentPipeline.destroy();
this.brushPipeline.destroy();

View file

@ -1,7 +1,6 @@
export interface GameLoopSettings {
agentBudgetMax: number;
agentCount: number;
renderSpeed: number;
simulatedDelayMs: number;
selectedColorIndex: number;
spawnPerPixel: number;

View file

@ -4,6 +4,7 @@ export interface GardenUi {
prompt: HTMLElement;
eraserPreview: HTMLElement;
exportStatus: HTMLElement;
toolbar: HTMLElement;
}
export interface RenderInputs {
@ -15,3 +16,8 @@ export interface StrokeSegment {
from: vec2;
to: vec2;
}
export interface CanvasReadbackRequest {
encode(commandEncoder: GPUCommandEncoder, texture: GPUTexture): void;
afterSubmit(): void;
}

View file

@ -15,6 +15,7 @@ import { GardenUi } from './game-loop-types';
import { IntroPrompt } from './intro-prompt';
import { GardenPointerInput } from './pointer-input';
import { RenderInputCache } from './render-input-cache';
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
export default class GameLoop {
private static readonly MAX_MIRROR_SEGMENT_COUNT =
@ -34,6 +35,7 @@ export default class GameLoop {
private readonly agentPopulation: AgentPopulation;
private readonly export4KRenderer: Export4KRenderer;
private readonly framePerformance = new FramePerformance();
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
private readonly devStatsElement: HTMLDivElement | null;
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
private readonly resizeListener = this.resize.bind(this);
@ -56,6 +58,7 @@ export default class GameLoop {
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
this.introPrompt = new IntroPrompt(ui.prompt);
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
this.agentPopulation.initializeIntroAgents(this.canvasSize);
this.pointerInput = new GardenPointerInput({
@ -85,8 +88,8 @@ export default class GameLoop {
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
getVibeId: () => activeVibe.id,
});
this.keydownListener = (event: KeyboardEvent) => {
this.audio.start(activeVibe, { userGesture: event.isTrusted });
this.keydownListener = () => {
this.audio.start(activeVibe, { userGesture: true });
this.introPrompt.complete();
};
@ -151,6 +154,7 @@ export default class GameLoop {
window.removeEventListener('resize', this.resizeListener);
window.removeEventListener('keydown', this.keydownListener);
this.pointerInput.detach();
this.toolbarContrastMonitor.destroy();
this.devStatsElement?.remove();
this.introPrompt.destroy();
this.resources.destroy();
@ -165,7 +169,7 @@ export default class GameLoop {
const frameCpuStartedAt = this.framePerformance.markCpuStart();
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
this.framePerformance.update(deltaTime);
this.framePerformance.update(time);
this.agentPopulation.growBudget(
deltaTime,
this.framePerformance.smoothedFps,
@ -175,7 +179,6 @@ export default class GameLoop {
this.resize();
this.resizeSimulationToCanvas();
const scaledTime = time * settings.renderSpeed;
const { channelColors, backgroundColor } = this.renderInputs.get();
const introProgress = this.introPrompt.progress;
const cameraZoom = 1;
@ -195,7 +198,7 @@ export default class GameLoop {
});
this.resources.setFrameParameters({
time: scaledTime,
time,
deltaTime,
canvasSize: this.canvasSize,
activeAgentCount: this.agentPopulation.activeAgentCount,
@ -210,7 +213,10 @@ export default class GameLoop {
});
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
this.resources.executeFrame(settings.renderSpeed, isErasing);
this.resources.executeFrame(
isErasing,
this.toolbarContrastMonitor.takeReadbackRequest(time)
);
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
this.pointerInput.clearSwipesIfIdle();
@ -223,7 +229,6 @@ export default class GameLoop {
agentBudgetMax: settings.agentBudgetMax,
canvas: this.canvas,
devicePixelRatio: this.devicePixelRatio,
renderSpeed: settings.renderSpeed,
});
this.updateDevStats(time);

View file

@ -154,7 +154,7 @@ const createIntroTitlePoints = (
const fontSize = getIntroTitleFontSize(context, width, height);
context.clearRect(0, 0, width, height);
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
context.font = `${fontSize}px "Open Sans", sans-serif`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillStyle = '#fff';
@ -302,7 +302,7 @@ const getIntroTitleFontSize = (
);
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
context.font = `${fontSize}px Comfortaa, "Open Sans", sans-serif`;
context.font = `${fontSize}px "Open Sans", sans-serif`;
const metrics = context.measureText(INTRO_TITLE);
const measuredHeight =
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;

View file

@ -1,7 +1,6 @@
import { vec2 } from 'gl-matrix';
import { GardenAudio } from '../audio/garden-audio';
import { gardenAudioConfig } from '../audio/garden-audio-config';
import { appConfig } from '../config';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
@ -111,7 +110,9 @@ export class GardenPointerInput {
}
const position = this.getCanvasPointerPosition(event);
this.options.audio.start(activeVibe, { userGesture: event.isTrusted });
if (event.pointerType !== 'touch') {
this.options.audio.start(activeVibe, { userGesture: true });
}
this.options.audio.beginGesture();
this.options.audio.touchDown({
vibe: activeVibe,
@ -120,7 +121,6 @@ export class GardenPointerInput {
canvasSize: this.options.getCanvasSize(),
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pressure: this.getPointerPressure(event),
pointerType: event.pointerType,
});
this.options.onStartDrawing();
this.activePointerId = event.pointerId;
@ -149,6 +149,7 @@ export class GardenPointerInput {
if (event.pointerId !== this.activePointerId) {
return;
}
this.options.audio.start(activeVibe, { userGesture: true });
this.addSwipeAt(event, { emitAudio: false });
this.finishSmoothedStroke();
this.options.audio.endGesture();
@ -221,7 +222,6 @@ export class GardenPointerInput {
elapsedSeconds,
eraserSizePixels: settings.eraserSize * devicePixelRatio,
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
pointerType: event.pointerType,
});
}
this.lastPointerPosition = position;
@ -369,9 +369,7 @@ export class GardenPointerInput {
return Math.min(1, Math.max(0, event.pressure));
}
return event.buttons > 0 || event.type === 'pointerdown'
? gardenAudioConfig.input.pressureFallback
: 0;
return 0;
}
}

View file

@ -1,14 +1,13 @@
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
import { RenderPipeline } from '../pipelines/render/render-pipeline';
import { CanvasReadbackRequest } from './game-loop-types';
import { SimulationTextures } from './simulation-textures';
interface SimulationFramePipelines {
copyPipeline: CopyPipeline;
agentPipeline: AgentPipeline;
brushPipeline: BrushPipeline;
eraserAgentPipeline: EraserAgentPipeline;
@ -25,70 +24,64 @@ export class SimulationFrameRenderer {
private readonly pipelines: SimulationFramePipelines
) {}
public execute(renderSpeed: number, isErasing: boolean): void {
for (let i = 0; i < renderSpeed; i++) {
const commandEncoder = this.device.createCommandEncoder();
public execute(
isErasing: boolean,
canvasReadbackRequest?: CanvasReadbackRequest | null
): void {
const commandEncoder = this.device.createCommandEncoder();
this.pipelines.copyPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView()
);
if (isErasing) {
this.pipelines.eraserTexturePipeline.execute(
commandEncoder,
this.textures.sourceMapA.getTextureView()
);
this.pipelines.eraserTexturePipeline.execute(
commandEncoder,
this.textures.influenceMapA.getTextureView()
);
this.pipelines.eraserTexturePipeline.execute(
this.textures.copyTrailMapAToB(commandEncoder);
if (isErasing) {
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
const eraserMask = this.textures.clearEraserMask(commandEncoder);
this.pipelines.eraserTexturePipeline.execute(commandEncoder, eraserMask);
this.pipelines.eraserTexturePipeline.executeMultiTarget(
commandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.influenceMapA.getTextureView(),
this.textures.trailMapB.getTextureView()
);
this.pipelines.eraserAgentPipeline.execute(commandEncoder);
} else {
this.pipelines.brushPipeline.execute(
commandEncoder,
this.textures.sourceMapA.getTextureView()
);
this.pipelines.brushPipeline.execute(
commandEncoder,
this.textures.influenceMapA.getTextureView()
);
this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask);
}
this.pipelines.agentPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView(),
this.textures.influenceMapA.getTextureView()
);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
this.textures.trailMapB.getTextureView(),
this.textures.trailMapA.getTextureView()
);
this.pipelines.renderPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.sourceMapA.getTextureView()
);
this.pipelines.diffusionPipeline.execute(
} else {
this.pipelines.brushPipeline.executeMultiTarget(
commandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView()
this.textures.influenceMapA.getTextureView()
);
this.pipelines.brushEffectDiffusionPipeline.execute(
commandEncoder,
this.textures.influenceMapA.getTextureView(),
this.textures.influenceMapB.getTextureView()
);
this.device.queue.submit([commandEncoder.finish()]);
this.textures.swapSourceMaps();
this.textures.swapInfluenceMaps();
}
this.pipelines.agentPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.trailMapB.getTextureView(),
this.textures.influenceMapA.getTextureView()
);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
this.textures.trailMapB.getTextureView(),
this.textures.trailMapA.getTextureView()
);
const canvasTexture = this.pipelines.renderPipeline.execute(
commandEncoder,
this.textures.trailMapA.getTextureView(),
this.textures.sourceMapA.getTextureView()
);
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
this.pipelines.diffusionPipeline.execute(
commandEncoder,
this.textures.sourceMapA.getTextureView(),
this.textures.sourceMapB.getTextureView()
);
this.pipelines.brushEffectDiffusionPipeline.execute(
commandEncoder,
this.textures.influenceMapA.getTextureView(),
this.textures.influenceMapB.getTextureView()
);
this.device.queue.submit([commandEncoder.finish()]);
canvasReadbackRequest?.afterSubmit();
this.textures.swapSourceMaps();
this.textures.swapInfluenceMaps();
}
public clearSwipes(): void {

View file

@ -9,6 +9,7 @@ export class SimulationTextures {
public sourceMapB: ResizableTexture;
public influenceMapA: ResizableTexture;
public influenceMapB: ResizableTexture;
public eraserMask: ResizableTexture;
public constructor(
private readonly device: GPUDevice,
@ -20,6 +21,7 @@ export class SimulationTextures {
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
this.influenceMapB = new ResizableTexture(this.device, canvasSize);
this.eraserMask = new ResizableTexture(this.device, canvasSize);
}
public resizeTo(nextSize: vec2): vec2 | null {
@ -35,10 +37,38 @@ export class SimulationTextures {
this.sourceMapB.resize(nextSize);
this.influenceMapA.resize(nextSize);
this.influenceMapB.resize(nextSize);
this.eraserMask.resize(nextSize);
return scale;
}
public clearEraserMask(commandEncoder: GPUCommandEncoder): GPUTextureView {
const eraserMaskView = this.eraserMask.getTextureView();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: eraserMaskView,
clearValue: { r: 1, g: 1, b: 1, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
passEncoder.end();
return eraserMaskView;
}
public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void {
const size = this.trailMapA.getSize();
commandEncoder.copyTextureToTexture(
{ texture: this.trailMapA.getTexture() },
{ texture: this.trailMapB.getTexture() },
{ width: size[0], height: size[1] }
);
}
public swapSourceMaps(): void {
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
}
@ -54,5 +84,6 @@ export class SimulationTextures {
this.sourceMapB.destroy();
this.influenceMapA.destroy();
this.influenceMapB.destroy();
this.eraserMask.destroy();
}
}

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
getToolbarContrastMetrics,
shouldDimToolbarBackground,
} from './toolbar-contrast-monitor';
const makePixels = (
samples: ReadonlyArray<readonly [number, number, number]>
): Uint8Array => {
const pixels = new Uint8Array(samples.length * 4);
samples.forEach(([red, green, blue], index) => {
const offset = index * 4;
pixels[offset] = red;
pixels[offset + 1] = green;
pixels[offset + 2] = blue;
pixels[offset + 3] = 255;
});
return pixels;
};
describe('toolbar contrast monitoring', () => {
it('leaves the toolbar transparent over dark canvas samples', () => {
const metrics = getToolbarContrastMetrics(
makePixels(Array.from({ length: 91 }, () => [8, 12, 18])),
91,
false
);
expect(metrics.dimmingStrength).toBe(0);
expect(metrics.lowContrastRatio).toBe(0);
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
});
it('dims the toolbar when enough samples have poor contrast with white controls', () => {
const darkSamples = Array.from({ length: 82 }, () => [8, 12, 18] as const);
const brightSamples = Array.from({ length: 9 }, () => [245, 240, 218] as const);
const metrics = getToolbarContrastMetrics(
makePixels([...darkSamples, ...brightSamples]),
91,
false
);
expect(metrics.lowContrastRatio).toBeGreaterThanOrEqual(0.08);
expect(shouldDimToolbarBackground(metrics, false)).toBe(true);
});
it('keeps the dimmed state until contrast has clearly recovered', () => {
const metrics = getToolbarContrastMetrics(
makePixels([
...Array.from({ length: 86 }, () => [8, 12, 18] as const),
...Array.from({ length: 5 }, () => [245, 240, 218] as const),
]),
91,
false
);
expect(shouldDimToolbarBackground(metrics, false)).toBe(false);
expect(shouldDimToolbarBackground(metrics, true)).toBe(true);
});
it('reads bgra canvas samples in the correct channel order', () => {
const bgraPixels = new Uint8Array([0, 0, 255, 255]);
const metrics = getToolbarContrastMetrics(bgraPixels, 1, true);
expect(metrics.averageLuminance).toBeCloseTo(0.2126);
});
});

View file

@ -0,0 +1,284 @@
import type { CanvasReadbackRequest } from './game-loop-types';
interface CanvasSamplePoint {
x: number;
y: number;
}
interface ToolbarContrastMetrics {
averageLuminance: number;
brightRatio: number;
dimmingStrength: number;
lowContrastRatio: number;
}
const BYTES_PER_SAMPLE = 4;
const SAMPLE_COLUMNS = 13;
const SAMPLE_ROWS = 7;
const SAMPLE_INTERVAL_MS = 300;
const LOW_CONTRAST_RATIO_TO_DIM = 0.08;
const LOW_CONTRAST_RATIO_TO_CLEAR = 0.04;
const DIMMING_STRENGTH_TO_DIM = 0.18;
const DIMMING_STRENGTH_TO_CLEAR = 0.1;
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const getLinearChannel = (channel: number): number => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
0.2126 * getLinearChannel(red) +
0.7152 * getLinearChannel(green) +
0.0722 * getLinearChannel(blue);
export const getToolbarContrastMetrics = (
pixels: Uint8Array,
sampleCount: number,
isBgra: boolean
): ToolbarContrastMetrics => {
const count = Math.max(0, Math.min(sampleCount, Math.floor(pixels.length / 4)));
if (count === 0) {
return {
averageLuminance: 0,
brightRatio: 0,
dimmingStrength: 0,
lowContrastRatio: 0,
};
}
let luminanceTotal = 0;
let brightCount = 0;
let lowContrastCount = 0;
for (let i = 0; i < count; i++) {
const offset = i * BYTES_PER_SAMPLE;
const red = pixels[offset + (isBgra ? 2 : 0)];
const green = pixels[offset + 1];
const blue = pixels[offset + (isBgra ? 0 : 2)];
const luminance = getRelativeLuminance(red, green, blue);
const contrastWithWhite = 1.05 / (luminance + 0.05);
luminanceTotal += luminance;
if (luminance > 0.32) {
brightCount++;
}
if (contrastWithWhite < 3) {
lowContrastCount++;
}
}
const averageLuminance = luminanceTotal / count;
const brightRatio = brightCount / count;
const lowContrastRatio = lowContrastCount / count;
const dimmingStrength = clamp01(
Math.max(0, averageLuminance - 0.11) / 0.28 +
brightRatio * 0.65 +
lowContrastRatio * 1.8
);
return {
averageLuminance,
brightRatio,
dimmingStrength,
lowContrastRatio,
};
};
export const shouldDimToolbarBackground = (
metrics: ToolbarContrastMetrics,
wasDimmed: boolean
): boolean =>
wasDimmed
? metrics.dimmingStrength > DIMMING_STRENGTH_TO_CLEAR ||
metrics.lowContrastRatio > LOW_CONTRAST_RATIO_TO_CLEAR
: metrics.dimmingStrength > DIMMING_STRENGTH_TO_DIM ||
metrics.lowContrastRatio >= LOW_CONTRAST_RATIO_TO_DIM;
export class ToolbarContrastMonitor {
private readonly isBgra: boolean;
private isDestroyed = false;
private isDimmed = false;
private isReadbackPending = false;
private lastSampleAt = Number.NEGATIVE_INFINITY;
public constructor(
private readonly canvas: HTMLCanvasElement,
private readonly toolbar: HTMLElement,
private readonly device: GPUDevice
) {
this.isBgra = navigator.gpu?.getPreferredCanvasFormat() === 'bgra8unorm';
}
public takeReadbackRequest(time: DOMHighResTimeStamp): CanvasReadbackRequest | null {
if (
this.isDestroyed ||
this.isReadbackPending ||
time - this.lastSampleAt < SAMPLE_INTERVAL_MS
) {
return null;
}
const samplePoints = this.getSamplePoints();
if (samplePoints.length === 0) {
return null;
}
let buffer: GPUBuffer;
try {
buffer = this.device.createBuffer({
size: samplePoints.length * BYTES_PER_SAMPLE,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
} catch {
return null;
}
this.isReadbackPending = true;
this.lastSampleAt = time;
let isBufferDestroyed = false;
let isCancelled = false;
let isEncoded = false;
const destroyBuffer = () => {
if (isBufferDestroyed) {
return;
}
isBufferDestroyed = true;
buffer.destroy();
};
const cancel = (destroyNow = true) => {
if (isCancelled) {
return;
}
isCancelled = true;
this.isReadbackPending = false;
if (destroyNow) {
destroyBuffer();
}
};
return {
encode: (commandEncoder, texture) => {
if (isCancelled) {
return;
}
try {
samplePoints.forEach((point, index) => {
commandEncoder.copyTextureToBuffer(
{
origin: point,
texture,
},
{
buffer,
offset: index * BYTES_PER_SAMPLE,
},
{
depthOrArrayLayers: 1,
height: 1,
width: 1,
}
);
});
isEncoded = true;
} catch {
cancel(false);
}
},
afterSubmit: () => {
if (isCancelled) {
destroyBuffer();
return;
}
if (!isEncoded) {
cancel();
return;
}
void this.readBuffer(buffer, samplePoints.length);
},
};
}
public destroy(): void {
this.isDestroyed = true;
this.toolbar.classList.remove('needs-contrast-background');
}
private getSamplePoints(): Array<CanvasSamplePoint> {
const canvasRect = this.canvas.getBoundingClientRect();
const toolbarRect = this.toolbar.getBoundingClientRect();
if (
canvasRect.width <= 0 ||
canvasRect.height <= 0 ||
toolbarRect.width <= 0 ||
toolbarRect.height <= 0
) {
return [];
}
const left = Math.max(canvasRect.left, toolbarRect.left);
const right = Math.min(canvasRect.right, toolbarRect.right);
const top = Math.max(canvasRect.top, toolbarRect.top);
const bottom = Math.min(canvasRect.bottom, toolbarRect.bottom);
if (left >= right || top >= bottom) {
return [];
}
const xScale = this.canvas.width / canvasRect.width;
const yScale = this.canvas.height / canvasRect.height;
const width = right - left;
const height = bottom - top;
const points = new Map<string, CanvasSamplePoint>();
for (let row = 0; row < SAMPLE_ROWS; row++) {
const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * height;
const y = Math.min(
this.canvas.height - 1,
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
);
for (let column = 0; column < SAMPLE_COLUMNS; column++) {
const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * width;
const x = Math.min(
this.canvas.width - 1,
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
);
points.set(`${x}:${y}`, { x, y });
}
}
return [...points.values()];
}
private async readBuffer(buffer: GPUBuffer, sampleCount: number): Promise<void> {
let isMapped = false;
try {
await buffer.mapAsync(GPUMapMode.READ);
isMapped = true;
if (!this.isDestroyed) {
const pixels = new Uint8Array(buffer.getMappedRange());
const metrics = getToolbarContrastMetrics(pixels, sampleCount, this.isBgra);
this.isDimmed = shouldDimToolbarBackground(metrics, this.isDimmed);
this.toolbar.classList.toggle('needs-contrast-background', this.isDimmed);
}
} catch {
// Readback is an enhancement; leave rendering alone if the GPU rejects it.
} finally {
if (isMapped) {
buffer.unmap();
}
buffer.destroy();
this.isReadbackPending = false;
}
}
}

View file

@ -2,6 +2,13 @@ import GameLoop from './game-loop/game-loop';
import './index.scss';
import {
initAnalytics,
trackExport,
trackSettingsOpen,
trackVibeChange,
} from './analytics';
import { preloadPianoSamples } from './audio/piano-samples';
import { appConfig } from './config';
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
import { ConfigPane } from './page/config-pane';
@ -11,7 +18,12 @@ import { activeVibe, applyVibeSettings, resetSettings, settings } from './settin
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
import { ErrorHandler, Severity } from './utils/error-handler';
import {
ErrorHandler,
getErrorMessage,
RuntimeError,
Severity,
} from './utils/error-handler';
import { initializeGpu } from './utils/graphics/initialize-gpu';
import { VIBE_PRESETS } from './vibes';
@ -47,10 +59,11 @@ const formatMirrorSegmentCount = (count: number): string =>
? 'Mirror off'
: `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
const renderRuntimeMessage = (
container: HTMLElement,
error: Parameters<Parameters<typeof ErrorHandler.addOnErrorListener>[0]>[0]
) => {
type RuntimeUiError = Parameters<
Parameters<typeof ErrorHandler.addOnErrorListener>[0]
>[0];
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
const message = document.createElement('pre');
message.className = error.severity;
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
@ -67,8 +80,31 @@ const renderRuntimeMessage = (
}
};
const elements = {
const getRuntimeUiError = (exception: unknown): RuntimeUiError => ({
severity: Severity.ERROR,
message: getErrorMessage(exception),
...(exception instanceof RuntimeError ? { code: exception.code } : {}),
});
const renderStartupException = (exception: unknown) => {
const existingContainer = document.querySelector('.errors-container');
const container =
existingContainer instanceof HTMLElement
? existingContainer
: document.createElement('div');
if (!(existingContainer instanceof HTMLElement)) {
container.className = 'errors-container';
document.body.append(container);
}
container.setAttribute('aria-live', 'assertive');
renderRuntimeMessage(container, getRuntimeUiError(exception));
};
const queryAppElements = () => ({
aside: queryRequiredElement('aside', HTMLElement),
toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement),
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
infoElement: queryRequiredElement('.info-page', HTMLElement),
minimizeFullScreenButton: queryRequiredElement(
@ -98,7 +134,11 @@ const elements = {
loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement),
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
};
});
type AppElements = ReturnType<typeof queryAppElements>;
let elements: AppElements;
const setLoadingStage = (label: string, ratio: number) => {
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
@ -188,10 +228,15 @@ const renderMirrorSegmentUi = () => {
};
const main = async () => {
let hasRuntimeErrorListener = false;
try {
initAnalytics();
let shouldStop = false;
let game: GameLoop | null = null;
let wasConfigPaneOpen = false;
elements = queryAppElements();
elements.errorContainer.setAttribute('aria-live', 'assertive');
ErrorHandler.addOnErrorListener((error, _metadata) => {
renderRuntimeMessage(elements.errorContainer, error);
@ -201,6 +246,7 @@ const main = async () => {
shouldStop = true;
}
});
hasRuntimeErrorListener = true;
const syncRuntimeUi = () => {
renderEraserSizeUi(game);
@ -216,7 +262,13 @@ const main = async () => {
const configPane = new ConfigPane({
settingsButton: elements.settingsButton,
onConfigChange: syncRuntimeUi,
onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen),
onOpenChange: (isOpen) => {
game?.setStatsOverlayPinned(isOpen);
if (isOpen && !wasConfigPaneOpen) {
trackSettingsOpen();
}
wasConfigPaneOpen = isOpen;
},
onRuntimeChange: syncRuntimeUi,
onRuntimeReset: () => {
resetSettings();
@ -224,7 +276,12 @@ const main = async () => {
},
onRestart: () => game?.destroy(),
onVibeChange: (vibeId) => {
applyVibeSettings(vibeId);
const vibe = applyVibeSettings(vibeId);
trackVibeChange({
vibeId: vibe.id,
vibeName: vibe.name,
source: 'settings',
});
syncRuntimeUi();
game?.playVibeChangeAudio(false);
},
@ -244,33 +301,65 @@ const main = async () => {
document.body
);
const startAudioFromUserGesture = (event: Event) => {
if (
isAudioMuted ||
(event.target instanceof Node && elements.soundButton.contains(event.target))
) {
return;
}
game?.startAudio(true);
};
window.addEventListener('touchend', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('pointerup', startAudioFromUserGesture, {
capture: true,
passive: true,
});
window.addEventListener('click', startAudioFromUserGesture, { capture: true });
window.addEventListener('keydown', startAudioFromUserGesture, { capture: true });
elements.restartButton.addEventListener('click', () => game?.destroy());
elements.soundButton.addEventListener('click', (event) => {
elements.soundButton.addEventListener('click', () => {
isAudioMuted = !isAudioMuted;
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
renderAudioUi(game);
if (!isAudioMuted) {
game?.startAudio(event.isTrusted);
game?.startAudio(true);
}
});
elements.previousVibe.addEventListener('click', (event) => {
elements.previousVibe.addEventListener('click', () => {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const vibe =
VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length];
applyVibeSettings(vibe.id);
const activePreset = applyVibeSettings(vibe.id);
trackVibeChange({
vibeId: activePreset.id,
vibeName: activePreset.name,
source: 'previous-button',
});
configPane.refresh();
syncRuntimeUi();
game?.playVibeChangeAudio(event.isTrusted);
game?.playVibeChangeAudio(true);
});
elements.nextVibe.addEventListener('click', (event) => {
elements.nextVibe.addEventListener('click', () => {
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length];
applyVibeSettings(vibe.id);
const activePreset = applyVibeSettings(vibe.id);
trackVibeChange({
vibeId: activePreset.id,
vibeName: activePreset.name,
source: 'next-button',
});
configPane.refresh();
syncRuntimeUi();
game?.playVibeChangeAudio(event.isTrusted);
game?.playVibeChangeAudio(true);
});
elements.swatches.forEach((swatch, index) => {
@ -318,6 +407,7 @@ const main = async () => {
elements.export4k.disabled = true;
try {
await game.export4K();
trackExport({ vibeId: activeVibe.id });
} catch (error) {
ErrorHandler.addException(error, { severity: Severity.WARNING });
} finally {
@ -330,18 +420,32 @@ const main = async () => {
renderMirrorSegmentUi();
renderAudioUi(game);
const fontsReady = document.fonts.ready.catch(() => undefined);
const fontsReady = document.fonts.ready.catch((error) => {
ErrorHandler.addException(error, {
fallbackMessage: 'Could not load fonts.',
severity: Severity.WARNING,
});
});
setLoadingStage('Connecting to GPU…', 0.1);
const gpu = await initializeGpu();
setLoadingStage('Loading fonts…', 0.4);
setLoadingStage('Loading fonts…', 0.3);
await fontsReady;
setLoadingStage('Compiling shaders…', 0.7);
setLoadingStage('Loading piano samples…', 0.45);
await preloadPianoSamples(({ loadedCount, totalCount }) => {
const sampleRatio = totalCount > 0 ? loadedCount / totalCount : 1;
setLoadingStage(
`Loading piano samples ${loadedCount}/${totalCount}`,
0.45 + sampleRatio * 0.3
);
});
setLoadingStage('Compiling shaders…', 0.8);
const deltaTimeCalculator = new DeltaTimeCalculator();
let isFirstStart = true;
while (!shouldStop) {
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
toolbar: elements.toolbarRow,
prompt: elements.prompt,
eraserPreview: elements.eraserPreview,
exportStatus: elements.exportStatus,
@ -364,7 +468,12 @@ const main = async () => {
}
} catch (e) {
document.body.classList.remove('is-loading');
ErrorHandler.addException(e);
if (hasRuntimeErrorListener) {
ErrorHandler.addException(e);
} else {
renderStartupException(e);
ErrorHandler.addException(e);
}
console.error(e);
}
};

View file

@ -15,22 +15,70 @@ struct Counters {
@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> workgroupCopyCount: u32;
@compute @workgroup_size(64)
fn main(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
if id >= settings.agentCount {
return;
if local_id.x == 0u {
atomicStore(&workgroupAliveCount, 0u);
}
let agent = agents[id];
if agent.colorIndex < 0.0 {
return;
workgroupBarrier();
var localCompactedIndex = 0u;
if id < settings.agentCount {
let agent = agents[id];
if agent.colorIndex >= 0.0 {
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
}
}
let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1);
compactedAgents[compactedIndex] = agent;
workgroupBarrier();
if local_id.x == 0u {
let groupAliveCount = atomicLoad(&workgroupAliveCount);
if groupAliveCount > 0u {
workgroupCompactedOffset = atomicAdd(&counters.aliveAgentCount, groupAliveCount);
} else {
workgroupCompactedOffset = 0u;
}
}
workgroupBarrier();
if id < settings.agentCount {
let agent = agents[id];
if agent.colorIndex >= 0.0 {
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
}
}
}
@compute @workgroup_size(64)
fn copyCompactedAgents(
@builtin(global_invocation_id) global_id: vec3<u32>,
@builtin(local_invocation_id) local_id: vec3<u32>,
@builtin(num_workgroups) workgroup_count: vec3<u32>
) {
let id = get_id(global_id, workgroup_count);
if local_id.x == 0u {
workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);
}
workgroupBarrier();
if id >= workgroupCopyCount {
return;
}
agents[id] = compactedAgents[id];
}

View file

@ -0,0 +1,235 @@
import { describe, expect, it, vi } from 'vitest';
import type { CommonState } from '../../common-state/common-state';
import { AGENT_SIZE_IN_BYTES } from './agent';
import { AgentGenerationPipeline } from './agent-generation-pipeline';
const installGpuConstants = () => {
Object.defineProperties(globalThis, {
GPUBufferUsage: {
configurable: true,
value: {
MAP_READ: 1,
COPY_DST: 2,
COPY_SRC: 4,
STORAGE: 8,
UNIFORM: 16,
},
},
GPUMapMode: {
configurable: true,
value: {
READ: 1,
},
},
GPUShaderStage: {
configurable: true,
value: {
COMPUTE: 1,
},
},
});
};
type CopyCall = {
source: GPUBuffer;
sourceOffset: number;
destination: GPUBuffer;
destinationOffset: number;
size: number;
};
type DispatchCall = {
entryPoint: string;
workgroups: [number, number, number];
};
type FakePipeline = {
entryPoint: string;
};
class FakeBuffer {
private readonly mappedRange: ArrayBuffer;
public readonly destroy = vi.fn();
public readonly mapAsync = vi.fn(async (_mode: number) => undefined);
public readonly getMappedRange = vi.fn(() => this.mappedRange);
public readonly unmap = vi.fn();
public constructor(
public readonly label: string,
size: number,
mappedValue = 0
) {
this.mappedRange = new ArrayBuffer(Math.max(size, Uint32Array.BYTES_PER_ELEMENT));
new Uint32Array(this.mappedRange)[0] = mappedValue;
}
}
class FakeComputePass {
private pipeline: FakePipeline | null = null;
public readonly setPipeline = vi.fn((pipeline: GPUComputePipeline) => {
this.pipeline = pipeline as unknown as FakePipeline;
});
public readonly setBindGroup = vi.fn(
(_index: number, _bindGroup: GPUBindGroup) => undefined
);
public readonly dispatchWorkgroups = vi.fn((x: number, y = 1, z = 1) => {
this.device.dispatchCalls.push({
entryPoint: this.pipeline?.entryPoint ?? 'unset',
workgroups: [x, y, z],
});
});
public readonly end = vi.fn();
public constructor(private readonly device: FakeDevice) {}
}
class FakeCommandEncoder {
public readonly beginComputePass = vi.fn(() => new FakeComputePass(this.device));
public readonly copyBufferToBuffer = vi.fn(
(
source: GPUBuffer,
sourceOffset: number,
destination: GPUBuffer,
destinationOffset: number,
size: number
) => {
this.device.copyCalls.push({
source,
sourceOffset,
destination,
destinationOffset,
size,
});
}
);
public readonly finish = vi.fn(() => ({}) as GPUCommandBuffer);
public constructor(private readonly device: FakeDevice) {}
}
class FakeQueue {
public readonly writeBuffer = vi.fn(
(_buffer: GPUBuffer, _offset: number, _data: BufferSource) => undefined
);
public readonly submit = vi.fn(
(_commandBuffers: Iterable<GPUCommandBuffer>) => undefined
);
}
class FakeShaderModule {
public readonly getCompilationInfo = vi.fn(async () => ({
messages: [],
}));
}
class FakeDevice {
public readonly copyCalls: Array<CopyCall> = [];
public readonly dispatchCalls: Array<DispatchCall> = [];
public readonly createdComputeEntryPoints: Array<string> = [];
public readonly limits = {
maxBufferSize: 1024 * 1024 * 1024,
maxComputeWorkgroupsPerDimension: 65_535,
};
public readonly queue = new FakeQueue();
private bufferIndex = 0;
public readonly createBindGroupLayout = vi.fn(
(_descriptor: GPUBindGroupLayoutDescriptor) => ({}) as GPUBindGroupLayout
);
public readonly createBuffer = vi.fn((descriptor: GPUBufferDescriptor) => {
const label =
['agents', 'compactedAgents', 'counters', 'countersStaging', 'uniforms'][
this.bufferIndex
] ?? `buffer${this.bufferIndex}`;
this.bufferIndex += 1;
const isMappedReadBuffer = (Number(descriptor.usage) & GPUBufferUsage.MAP_READ) !== 0;
return new FakeBuffer(
label,
Number(descriptor.size),
isMappedReadBuffer ? this.compactedCount : 0
) as unknown as GPUBuffer;
});
public readonly createBindGroup = vi.fn(
(_descriptor: GPUBindGroupDescriptor) => ({}) as GPUBindGroup
);
public readonly createPipelineLayout = vi.fn(
(_descriptor: GPUPipelineLayoutDescriptor) => ({}) as GPUPipelineLayout
);
public readonly createShaderModule = vi.fn(
(_descriptor: GPUShaderModuleDescriptor) =>
new FakeShaderModule() as unknown as GPUShaderModule
);
public readonly createComputePipeline = vi.fn(
(descriptor: GPUComputePipelineDescriptor) => {
const pipeline = {
entryPoint: descriptor.compute.entryPoint ?? 'main',
};
this.createdComputeEntryPoints.push(pipeline.entryPoint);
return pipeline as unknown as GPUComputePipeline;
}
);
public readonly createCommandEncoder = vi.fn(() => new FakeCommandEncoder(this));
public constructor(private readonly compactedCount: number) {}
}
const createPipeline = (compactedCount: number) => {
installGpuConstants();
const device = new FakeDevice(compactedCount);
const commonState = {
bindGroupLayout: {} as GPUBindGroupLayout,
execute: vi.fn(),
} as unknown as CommonState;
return {
device,
pipeline: new AgentGenerationPipeline(
device as unknown as GPUDevice,
commonState,
1024
),
};
};
describe('AgentGenerationPipeline compaction', () => {
it('copies compacted agents back with compute instead of a full agent buffer copy', async () => {
const agentCount = 10;
const { device, pipeline } = createPipeline(3);
await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3);
expect(device.createdComputeEntryPoints).toContain('copyCompactedAgents');
expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual([
'main',
'copyCompactedAgents',
]);
expect(device.copyCalls.map((call) => call.size)).toEqual([
Uint32Array.BYTES_PER_ELEMENT,
]);
expect(
device.copyCalls.some((call) => call.size === agentCount * AGENT_SIZE_IN_BYTES)
).toBe(false);
expect(device.queue.submit).toHaveBeenCalledTimes(1);
pipeline.destroy();
});
it('does not encode work for empty compaction requests', async () => {
const { device, pipeline } = createPipeline(0);
await expect(pipeline.compactAgents(0)).resolves.toBe(0);
expect(device.dispatchCalls).toEqual([]);
expect(device.copyCalls).toEqual([]);
expect(device.queue.submit).not.toHaveBeenCalled();
pipeline.destroy();
});
});

View file

@ -26,6 +26,7 @@ export class AgentGenerationPipeline {
private readonly countingPipeline: GPUComputePipeline;
private readonly resizePipeline: GPUComputePipeline;
private readonly compactionPipeline: GPUComputePipeline;
private readonly compactedAgentsCopyPipeline: GPUComputePipeline;
public readonly agentsBuffer: GPUBuffer;
private readonly compactedAgentsBuffer: GPUBuffer;
@ -109,7 +110,7 @@ export class AgentGenerationPipeline {
this.compactedAgentsBuffer = this.device.createBuffer({
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
usage: GPUBufferUsage.STORAGE,
});
this.countersBuffer = this.device.createBuffer({
@ -216,20 +217,32 @@ export class AgentGenerationPipeline {
},
});
const compactionModule = smartCompile(
device,
CommonState.shaderCode,
agentSchema,
compactionShader
);
this.compactionPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
}),
compute: {
module: smartCompile(
device,
CommonState.shaderCode,
agentSchema,
compactionShader
),
module: compactionModule,
entryPoint: 'main',
},
});
this.compactedAgentsCopyPipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
}),
compute: {
module: compactionModule,
entryPoint: 'copyCompactedAgents',
},
});
}
public get maxAgentCount(): number {
@ -364,13 +377,19 @@ export class AgentGenerationPipeline {
);
passEncoder.end();
commandEncoder.copyBufferToBuffer(
this.compactedAgentsBuffer,
0,
this.agentsBuffer,
0,
agentCount * AGENT_SIZE_IN_BYTES
const copyPassEncoder = commandEncoder.beginComputePass();
copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline);
this.commonState.execute(copyPassEncoder);
copyPassEncoder.setBindGroup(1, this.compactionBindGroup);
copyPassEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
agentCount,
AgentGenerationPipeline.WORKGROUP_SIZE
)
);
copyPassEncoder.end();
commandEncoder.copyBufferToBuffer(
this.countersBuffer,
0,

View file

@ -69,6 +69,27 @@ describe('Agent TS/WGSL contract', () => {
expect(agentSchema).toContain('workgroup_count.x * 64');
expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 64');
expect(compactionShader).toContain('let id = get_id(global_id, workgroup_count);');
expect(compactionShader).toContain('if id >= settings.agentCount');
expect(compactionShader).toContain('if id < settings.agentCount');
});
it('keeps compaction copy-back bounded by the compacted count', () => {
expect(compactionShader).toContain('fn copyCompactedAgents');
expect(compactionShader).toContain(
'workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);'
);
expect(compactionShader).toContain('if id >= workgroupCopyCount');
expect(compactionShader).toContain('agents[id] = compactedAgents[id];');
});
it('uses workgroup-local counting before allocating global compacted ranges', () => {
expect(compactionShader).toContain(
'var<workgroup> workgroupAliveCount: atomic<u32>;'
);
expect(compactionShader).toContain(
'localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);'
);
expect(
compactionShader.match(/atomicAdd\(&counters\.aliveAgentCount/g) ?? []
).toHaveLength(1);
});
});

View file

@ -35,7 +35,7 @@ fn main(
}
var agent = agents[id];
if agent.colorIndex < 0.0 {
if agent.colorIndex < 0.0 || agent.colorIndex >= 2.5 {
return;
}
@ -48,12 +48,7 @@ fn main(
return;
}
let random = textureSampleLevel(
noise,
noiseSampler,
fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)),
0
);
let random = random_vec4(id, state.time);
let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
@ -154,7 +149,10 @@ fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
if colorIndex < 1.5 {
return vec3<f32>(0, 1, 0);
}
return vec3<f32>(0, 0, 1);
if colorIndex < 2.5 {
return vec3<f32>(0, 0, 1);
}
return vec3<f32>(0.0, 0.0, 0.0);
}
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
@ -172,13 +170,37 @@ fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
settings.color2ToColor3
);
}
return vec3<f32>(
settings.color3ToColor1,
settings.color3ToColor2,
settings.color3ToColor3
);
if colorIndex < 2.5 {
return vec3<f32>(
settings.color3ToColor1,
settings.color3ToColor2,
settings.color3ToColor3
);
}
return vec3<f32>(0.0, 0.0, 0.0);
}
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
}
fn random_vec4(id: u32, time: f32) -> vec4<f32> {
let timeSeed = u32(time * 0.34816);
let seed = id * 747796405u + timeSeed * 2891336453u;
return vec4<f32>(
random_float(seed),
random_float(seed + 1013904223u),
random_float(seed + 1664525u),
random_float(seed + 22695477u)
);
}
fn random_float(seed: u32) -> f32 {
return f32(hash_u32(seed) >> 8u) * (1.0 / 16777216.0);
}
fn hash_u32(seed: u32) -> u32 {
let value = seed * 747796405u + 2891336453u;
let word = ((value >> ((value >> 28u) + 4u)) ^ value) * 277803737u;
return (word >> 22u) ^ word;
}

View file

@ -26,6 +26,7 @@ export class BrushPipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly multiTargetPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
@ -57,68 +58,16 @@ export class BrushPipeline {
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'vertex',
buffers: [
{
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
attributes: [
{
shaderLocation: 0,
format: 'float32x2',
offset: 0,
},
{
shaderLocation: 1,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
{
shaderLocation: 2,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 4,
},
],
},
],
},
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
blend: {
color: {
operation: 'max',
srcFactor: 'one',
dstFactor: 'one',
},
alpha: {
operation: 'max',
srcFactor: 'one',
dstFactor: 'one',
},
},
},
],
},
primitive: {
topology: 'triangle-list',
},
});
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2);
this.uniforms = this.device.createBuffer({
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.bindGroup = this.bindGroup = this.device.createBindGroup({
this.bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
@ -315,23 +264,40 @@ export class BrushPipeline {
return offset;
}
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView) {
public execute(commandEncoder: GPUCommandEncoder, trailMapOut: GPUTextureView): void {
this.executeWithPipeline(commandEncoder, this.pipeline, [trailMapOut]);
}
public executeMultiTarget(
commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView,
influenceMapOut: GPUTextureView
): void {
this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
sourceMapOut,
influenceMapOut,
]);
}
private executeWithPipeline(
commandEncoder: GPUCommandEncoder,
pipeline: GPURenderPipeline,
textureViews: Array<GPUTextureView>
): void {
if (this.lineCount === 0) {
return;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: trailMapOut,
loadOp: 'load',
storeOp: 'store',
},
],
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
view,
loadOp: 'load',
storeOp: 'store',
})),
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
passEncoder.setPipeline(pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
@ -344,6 +310,73 @@ export class BrushPipeline {
this.uniforms.destroy();
}
private createPipeline(
shaderModule: GPUShaderModule,
fragmentEntryPoint: string,
colorTargetCount: number
): GPURenderPipeline {
return this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: shaderModule,
entryPoint: 'vertex',
buffers: [
{
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
attributes: [
{
shaderLocation: 0,
format: 'float32x2',
offset: 0,
},
{
shaderLocation: 1,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
{
shaderLocation: 2,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 4,
},
],
},
],
},
fragment: {
module: shaderModule,
entryPoint: fragmentEntryPoint,
targets: Array.from(
{ length: colorTargetCount },
() => BrushPipeline.colorTarget
),
},
primitive: {
topology: 'triangle-list',
},
});
}
private static get colorTarget(): GPUColorTargetState {
return {
format: 'rgba16float',
blend: {
color: {
operation: 'max',
srcFactor: 'one',
dstFactor: 'one',
},
alpha: {
operation: 'max',
srcFactor: 'one',
dstFactor: 'one',
},
},
};
}
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
return {
entries: [

View file

@ -15,6 +15,11 @@ struct VertexOutput {
@location(2) end: vec2<f32>
}
struct BrushTargets {
@location(0) source: vec4<f32>,
@location(1) influence: vec4<f32>,
}
@vertex
fn vertex(
@location(0) screenPosition: vec2<f32>,
@ -32,23 +37,47 @@ fn fragment(
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
let distance = distanceFromLine(screenPosition, start, end);
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
let grainNoise = textureSample(
noise,
noiseSampler,
fract(screenPosition / 22.0 + vec2(0.31, 0.67))
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
let feather = max(1.0, settings.brushSize * 0.22);
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
let strength = edge * mix(0.45, 1.0, grainNoise);
let strength = brushStrength(screenPosition, start, end);
if(strength < 0.02) {
discard;
}
if(strength < 0.02) {
discard;
}
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
return brushOutput(strength);
}
@fragment
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> BrushTargets {
let strength = brushStrength(screenPosition, start, end);
if(strength < 0.02) {
discard;
}
let color = brushOutput(strength);
return BrushTargets(color, color);
}
fn brushStrength(screenPosition: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
let distance = distanceFromLine(screenPosition, start, end);
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
let grainNoise = textureSample(
noise,
noiseSampler,
fract(screenPosition / 22.0 + vec2(0.31, 0.67))
).r;
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
let feather = max(1.0, settings.brushSize * 0.22);
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
return edge * mix(0.45, 1.0, grainNoise);
}
fn brushOutput(strength: f32) -> vec4<f32> {
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
}
fn distanceFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {

View file

@ -7,24 +7,28 @@ struct Settings {
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(1) var Sampler: sampler;
@group(1) @binding(2) var trailMap: texture_2d<f32>;
@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
var current = textureSample(trailMap, Sampler, uv);
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
let pixel = clamp(vec2<i32>(position.xy), vec2<i32>(0), textureSize - vec2<i32>(1));
var current = textureLoad(trailMap, pixel, 0);
let random = random_from_pixel(pixel);
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
current += (
propagate(uv, vec2(-1.0, -1.0), current)
+ propagate(uv, vec2(-1.0, 1.0), current)
+ propagate(uv, vec2(1.0, -1.0), current)
+ propagate(uv, vec2(1.0, 1.0), current)
propagate(pixel, textureSize, vec2<i32>(-1, -1), current, trailWeight, brushWeight)
+ propagate(pixel, textureSize, vec2<i32>(-1, 1), current, trailWeight, brushWeight)
+ propagate(pixel, textureSize, vec2<i32>(1, -1), current, trailWeight, brushWeight)
+ propagate(pixel, textureSize, vec2<i32>(1, 1), current, trailWeight, brushWeight)
+ propagate(uv, vec2(-1.0, 0.0), current)
+ propagate(uv, vec2(0.0, -1.0), current)
+ propagate(uv, vec2(1.0, 0.0), current)
+ propagate(uv, vec2(0.0, 1.0), current)
+ propagate(pixel, textureSize, vec2<i32>(-1, 0), current, trailWeight, brushWeight)
+ propagate(pixel, textureSize, vec2<i32>(0, -1), current, trailWeight, brushWeight)
+ propagate(pixel, textureSize, vec2<i32>(1, 0), current, trailWeight, brushWeight)
+ propagate(pixel, textureSize, vec2<i32>(0, 1), current, trailWeight, brushWeight)
) / 8;
let decayed = clamp(vec4(
@ -36,13 +40,64 @@ fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
}
fn propagate(uv: vec2<f32>, offset: vec2<f32>, currentColor: vec4<f32>) -> vec4<f32> {
let neighbour = textureSample(trailMap, Sampler, uv + offset / state.size);
var random = textureSample(noise, noiseSampler, uv + offset / state.size * 0.5).r;
fn propagate(
pixel: vec2<i32>,
textureSize: vec2<i32>,
offset: vec2<i32>,
currentColor: vec4<f32>,
trailWeight: f32,
brushWeight: f32
) -> vec4<f32> {
let neighbour = textureLoad(
trailMap,
clamp(pixel + offset, vec2<i32>(0), textureSize - vec2<i32>(1)),
0
);
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
return vec4(
vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)),
length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush)
vec3(length(neighbour.rgb) * trailWeight),
neighbour.a * brushWeight
) * difference;
}
fn random_from_pixel(pixel: vec2<i32>) -> f32 {
let p = vec2<u32>(pixel);
var hash = p.x * 1664525u + p.y * 1013904223u + 374761393u;
hash = (hash ^ (hash >> 16u)) * 2246822519u;
hash = (hash ^ (hash >> 13u)) * 3266489917u;
hash = hash ^ (hash >> 16u);
return f32(hash) * 2.3283064365386963e-10;
}
fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
let r = clamp(random, 0.0, 1.0);
let r2 = r * r;
let r4 = r2 * r2;
let r8 = r4 * r4;
if inverseRate < 1.0 {
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
return mix(
rootApproximation,
r,
clamp((inverseRate - 0.5) * 2.0, 0.0, 1.0)
);
}
if inverseRate < 2.0 {
return mix(r, r2, inverseRate - 1.0);
}
if inverseRate < 4.0 {
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
}
if inverseRate < 8.0 {
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
}
let r16 = r8 * r8;
return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
* min(1.0, 16.0 / inverseRate);
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import shader from './diffuse.wgsl?raw';
import {
getSafeInverseDiffusionRate,
setDiffusionUniformValues,
@ -26,4 +27,11 @@ describe('diffusion pipeline parameters', () => {
expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
});
it('keeps the diffusion shader on the low-cost trail sampling path', () => {
expect(shader).toContain('textureLoad');
expect(shader).not.toContain('textureSample');
expect(shader).not.toContain('pow(');
expect(shader).not.toContain('noise');
});
});

View file

@ -47,7 +47,6 @@ export class DiffusionPipeline {
private readonly uniformCache = createCachedFloat32BufferWrite(
DiffusionPipeline.UNIFORM_COUNT
);
private readonly sampler: GPUSampler;
private readonly vertexBuffer: GPUBuffer;
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
@ -86,11 +85,6 @@ export class DiffusionPipeline {
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.sampler = this.device.createSampler({
magFilter: 'linear',
minFilter: 'linear',
});
}
public setParameters({
@ -155,10 +149,6 @@ export class DiffusionPipeline {
buffer: this.uniforms,
},
},
{
binding: 1,
resource: this.sampler,
},
{
binding: 2,
resource: trailMapIn,
@ -185,13 +175,6 @@ export class DiffusionPipeline {
type: 'uniform',
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: 'filtering',
},
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,

View file

@ -11,40 +11,22 @@ import agentSchema from '../agents/agent-generation/agent-schema.wgsl?raw';
import { CommonState } from '../common-state/common-state';
import shader from './eraser-agent.wgsl?raw';
interface LineSegment {
from: vec2;
to: vec2;
}
const shaderWithConfig = shader.replace(
'const MAX_SEGMENT_COUNT = 384u;',
`const MAX_SEGMENT_COUNT = ${Math.round(appConfig.pipelines.eraser.maxSegmentCount)}u;`
);
export class EraserAgentPipeline {
private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize;
private static readonly UNIFORM_COUNT = 4;
private static readonly MAX_SEGMENT_COUNT = appConfig.pipelines.eraser.maxSegmentCount;
private static readonly SEGMENT_FLOAT_COUNT =
appConfig.pipelines.eraser.segmentFloatCount;
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPUComputePipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
EraserAgentPipeline.UNIFORM_COUNT
);
private readonly segmentsBuffer: GPUBuffer;
private readonly segmentUploadData = new Float32Array(
EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
);
private readonly bindGroupsByMask = new WeakMap<GPUTextureView, GPUBindGroup>();
private linePoints: Array<vec2> = [];
private lineSegments: Array<LineSegment> = [];
private actualSegments: Array<LineSegment> = [];
private segmentCount = 0;
private pendingSegmentCount = 0;
private activeSegmentCount = 0;
private agentCount = 0;
public constructor(
@ -71,8 +53,8 @@ export class EraserAgentPipeline {
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: {
type: 'read-only-storage',
texture: {
sampleType: 'float',
},
},
],
@ -83,15 +65,93 @@ export class EraserAgentPipeline {
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
this.segmentsBuffer = this.device.createBuffer({
size:
EraserAgentPipeline.MAX_SEGMENT_COUNT *
EraserAgentPipeline.SEGMENT_FLOAT_COUNT *
Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(device, CommonState.shaderCode, agentSchema, shader),
entryPoint: 'main',
},
});
}
this.bindGroup = this.device.createBindGroup({
public addSwipe(position: vec2): void {
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2): void {
void from;
void to;
this.pendingSegmentCount += 1;
}
public clearSwipes(): void {
this.linePoints.length = 0;
this.pendingSegmentCount = 0;
this.activeSegmentCount = 0;
}
public setParameters({
agentCount,
eraserSize: _eraserSize,
}: {
agentCount: number;
eraserSize: number;
}): void {
void _eraserSize;
this.agentCount = agentCount;
this.activeSegmentCount = this.pendingSegmentCount;
this.pendingSegmentCount = 0;
this.uniformValues[0] = agentCount;
this.uniformValues[1] = 0;
this.uniformValues[2] = 0;
this.uniformValues[3] = 0;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
}
public hasActiveMask(): boolean {
return this.activeSegmentCount > 0;
}
public execute(commandEncoder: GPUCommandEncoder, eraserMask: GPUTextureView): void {
if (!this.hasActiveMask() || this.agentCount === 0) {
return;
}
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.getBindGroup(eraserMask));
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
this.agentCount,
EraserAgentPipeline.WORKGROUP_SIZE
)
);
passEncoder.end();
}
public destroy(): void {
this.uniforms.destroy();
}
private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup {
const cached = this.bindGroupsByMask.get(eraserMask);
if (cached) {
return cached;
}
const bindGroup = this.device.createBindGroup({
layout: this.bindGroupLayout,
entries: [
{
@ -108,137 +168,12 @@ export class EraserAgentPipeline {
},
{
binding: 2,
resource: {
buffer: this.segmentsBuffer,
},
resource: eraserMask,
},
],
});
this.pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
compute: {
module: smartCompile(
device,
CommonState.shaderCode,
agentSchema,
shaderWithConfig
),
entryPoint: 'main',
},
});
}
public addSwipe(position: vec2): void {
const previousPosition = this.linePoints[this.linePoints.length - 1] ?? position;
this.addSwipeSegment(previousPosition, position);
this.linePoints.push(vec2.clone(position));
}
public addSwipeSegment(from: vec2, to: vec2): void {
this.lineSegments.push({
from: vec2.clone(from),
to: vec2.clone(to),
});
}
public clearSwipes(): void {
this.linePoints.length = 0;
this.lineSegments.length = 0;
this.actualSegments.length = 0;
this.segmentCount = 0;
}
public setParameters({
agentCount,
eraserSize,
}: {
agentCount: number;
eraserSize: number;
}): void {
this.agentCount = agentCount;
this.actualSegments = this.lineSegments.slice();
this.lineSegments.length = 0;
if (this.actualSegments.length > EraserAgentPipeline.MAX_SEGMENT_COUNT) {
this.actualSegments = EraserAgentPipeline.subsampleSegments(this.actualSegments);
}
this.segmentCount = Math.max(0, this.actualSegments.length);
const eraserRadius = eraserSize / 2;
this.uniformValues[0] = eraserRadius;
this.uniformValues[1] = this.segmentCount;
this.uniformValues[2] = agentCount;
this.uniformValues[3] = eraserRadius * eraserRadius;
writeFloat32BufferIfChanged(
this.device,
this.uniforms,
this.uniformValues,
this.uniformCache
);
if (this.segmentCount === 0) {
return;
}
for (let i = 0; i < this.segmentCount; i++) {
const { from, to } = this.actualSegments[i];
const offset = i * EraserAgentPipeline.SEGMENT_FLOAT_COUNT;
this.segmentUploadData[offset] = from[0];
this.segmentUploadData[offset + 1] = from[1];
this.segmentUploadData[offset + 2] = to[0];
this.segmentUploadData[offset + 3] = to[1];
}
this.device.queue.writeBuffer(
this.segmentsBuffer,
0,
this.segmentUploadData,
0,
this.segmentCount * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
);
}
public execute(commandEncoder: GPUCommandEncoder): void {
if (this.segmentCount === 0 || this.agentCount === 0) {
return;
}
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(this.pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.dispatchWorkgroups(
...getWorkgroupCounts(
this.device,
this.agentCount,
EraserAgentPipeline.WORKGROUP_SIZE
)
);
passEncoder.end();
}
public destroy(): void {
this.uniforms.destroy();
this.segmentsBuffer.destroy();
}
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
if (segments.length <= EraserAgentPipeline.MAX_SEGMENT_COUNT) {
return segments;
}
const result: Array<LineSegment> = [];
for (let i = 0; i < EraserAgentPipeline.MAX_SEGMENT_COUNT; i++) {
const index = Math.round(
(i * (segments.length - 1)) / (EraserAgentPipeline.MAX_SEGMENT_COUNT - 1)
);
result.push(segments[index]);
}
return result;
this.bindGroupsByMask.set(eraserMask, bindGroup);
return bindGroup;
}
}

View file

@ -1,14 +1,12 @@
struct Settings {
eraserRadius: f32,
segmentCount: f32,
agentCount: f32,
eraserRadiusSquared: f32,
padding0: f32,
padding1: f32,
padding2: f32,
};
const MAX_SEGMENT_COUNT = 384u;
@group(1) @binding(0) var<uniform> settings: Settings;
@group(1) @binding(2) var<storage, read> segments: array<vec4<f32>>;
@group(1) @binding(2) var eraserMask: texture_2d<f32>;
@compute @workgroup_size(64)
fn main(
@ -26,38 +24,16 @@ fn main(
return;
}
for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) {
if i >= u32(settings.segmentCount) {
break;
}
let maskSize = vec2<i32>(textureDimensions(eraserMask));
let maskPosition = clamp(
vec2<i32>(agent.position),
vec2<i32>(0, 0),
maskSize - vec2<i32>(1, 1)
);
let maskSample = textureLoad(eraserMask, maskPosition, 0);
let segment = segments[i];
let distanceSquared = distanceSquaredFromLine(
agent.position,
segment.xy,
segment.zw
);
if distanceSquared <= settings.eraserRadiusSquared {
agent.position = vec2<f32>(-1.0, -1.0);
agent.targetPosition = vec2<f32>(-1.0, -1.0);
agent.colorIndex = -1.0;
agents[id] = agent;
return;
}
if maskSample.a < 0.5 {
agent.colorIndex = -1.0;
agents[id] = agent;
}
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
let pa = position - start;
let direction = end - start;
let denominator = dot(direction, direction);
if denominator <= 0.0001 {
return dot(pa, pa);
}
let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
let nearestOffset = pa - direction * q;
return dot(nearestOffset, nearestOffset);
}

View file

@ -24,6 +24,7 @@ export class EraserTexturePipeline {
private readonly bindGroupLayout: GPUBindGroupLayout;
private readonly bindGroup: GPUBindGroup;
private readonly pipeline: GPURenderPipeline;
private readonly multiTargetPipeline: GPURenderPipeline;
private readonly uniforms: GPUBuffer;
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
private readonly uniformCache = createCachedFloat32BufferWrite(
@ -65,49 +66,9 @@ export class EraserTexturePipeline {
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
this.pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'vertex',
buffers: [
{
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
attributes: [
{
shaderLocation: 0,
format: 'float32x2',
offset: 0,
},
{
shaderLocation: 1,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
{
shaderLocation: 2,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 4,
},
],
},
],
},
fragment: {
module: smartCompile(device, CommonState.shaderCode, shader),
entryPoint: 'fragment',
targets: [
{
format: 'rgba16float',
},
],
},
primitive: {
topology: 'triangle-list',
},
});
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 3);
this.uniforms = this.device.createBuffer({
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
@ -194,22 +155,41 @@ export class EraserTexturePipeline {
}
public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
this.executeWithPipeline(commandEncoder, this.pipeline, [textureOut]);
}
public executeMultiTarget(
commandEncoder: GPUCommandEncoder,
sourceMapOut: GPUTextureView,
influenceMapOut: GPUTextureView,
trailMapOut: GPUTextureView
): void {
this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
sourceMapOut,
influenceMapOut,
trailMapOut,
]);
}
private executeWithPipeline(
commandEncoder: GPUCommandEncoder,
pipeline: GPURenderPipeline,
textureViews: Array<GPUTextureView>
): void {
if (this.lineCount === 0) {
return;
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: textureOut,
loadOp: 'load',
storeOp: 'store',
},
],
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
view,
loadOp: 'load',
storeOp: 'store',
})),
};
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
passEncoder.setPipeline(this.pipeline);
passEncoder.setPipeline(pipeline);
this.commonState.execute(passEncoder);
passEncoder.setBindGroup(1, this.bindGroup);
passEncoder.setVertexBuffer(0, this.vertexBuffer);
@ -222,6 +202,54 @@ export class EraserTexturePipeline {
this.uniforms.destroy();
}
private createPipeline(
shaderModule: GPUShaderModule,
fragmentEntryPoint: string,
colorTargetCount: number
): GPURenderPipeline {
return this.device.createRenderPipeline({
layout: this.device.createPipelineLayout({
bindGroupLayouts: [this.commonState.bindGroupLayout, this.bindGroupLayout],
}),
vertex: {
module: shaderModule,
entryPoint: 'vertex',
buffers: [
{
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
attributes: [
{
shaderLocation: 0,
format: 'float32x2',
offset: 0,
},
{
shaderLocation: 1,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 2,
},
{
shaderLocation: 2,
format: 'float32x2',
offset: Float32Array.BYTES_PER_ELEMENT * 4,
},
],
},
],
},
fragment: {
module: shaderModule,
entryPoint: fragmentEntryPoint,
targets: Array.from({ length: colorTargetCount }, () => ({
format: 'rgba16float' as const,
})),
},
primitive: {
topology: 'triangle-list',
},
});
}
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
return segments;

View file

@ -14,6 +14,12 @@ struct VertexOutput {
@location(2) end: vec2<f32>
}
struct EraserTextureTargets {
@location(0) source: vec4<f32>,
@location(1) influence: vec4<f32>,
@location(2) trail: vec4<f32>,
}
@vertex
fn vertex(
@location(0) screenPosition: vec2<f32>,
@ -31,13 +37,35 @@ fn fragment(
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> @location(0) vec4<f32> {
if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared {
if shouldDiscardEraserFragment(screenPosition, start, end) {
discard;
}
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
@fragment
fn fragmentMrt(
@location(0) screenPosition: vec2<f32>,
@location(1) start: vec2<f32>,
@location(2) end: vec2<f32>
) -> EraserTextureTargets {
if shouldDiscardEraserFragment(screenPosition, start, end) {
discard;
}
let cleared = vec4<f32>(0.0, 0.0, 0.0, 0.0);
return EraserTextureTargets(cleared, cleared, cleared);
}
fn shouldDiscardEraserFragment(
screenPosition: vec2<f32>,
start: vec2<f32>,
end: vec2<f32>
) -> bool {
return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared;
}
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
let pa = position - start;
let direction = end - start;

View file

@ -121,13 +121,14 @@ export class RenderPipeline {
commandEncoder: GPUCommandEncoder,
colorTexture: GPUTextureView,
sourceTexture: GPUTextureView
) {
): GPUTexture {
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
const canvasTexture = this.context.getCurrentTexture();
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
{
view: this.context.getCurrentTexture().createView(),
view: canvasTexture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store',
@ -141,6 +142,8 @@ export class RenderPipeline {
passEncoder.setBindGroup(1, bindGroup);
passEncoder.draw(4, 1);
passEncoder.end();
return canvasTexture;
}
public executeToView(

View file

@ -169,7 +169,7 @@ describe('WGSL uniform layout contracts', () => {
pipeline: EraserAgentPipeline,
source: eraserAgentShader,
structName: 'Settings',
fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'],
fieldNames: ['agentCount', 'padding0', 'padding1', 'padding2'],
});
expectStructUniformLayout({
pipeline: EraserTexturePipeline,
@ -199,4 +199,10 @@ describe('WGSL uniform layout contracts', () => {
getUniformCount(AgentGenerationPipeline)
);
});
it('guards invalid high agent color indexes instead of treating them as color 3', () => {
expect(agentShader).toContain('agent.colorIndex < 0.0 || agent.colorIndex >= 2.5');
expect(agentShader).toContain('if colorIndex < 2.5');
expect(agentShader).toContain('return vec3<f32>(0.0, 0.0, 0.0);');
});
});

View file

@ -5,7 +5,6 @@ export enum Severity {
}
export enum ErrorCode {
UNKNOWN = 'unknown',
WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',

View file

@ -34,6 +34,7 @@ export const initializeContext = ({
context.configure({
device: device,
format: gpu.getPreferredCanvasFormat(),
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
alphaMode: 'premultiplied',
});
} catch (error) {

View file

@ -49,6 +49,10 @@ export class ResizableTexture {
return this.textureView;
}
public getTexture(): GPUTexture {
return this.texture;
}
public destroy(): void {
this.texture.destroy();
this.copyPipeline.destroy();
@ -61,7 +65,9 @@ export class ResizableTexture {
usage:
GPUTextureUsage.STORAGE_BINDING |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.RENDER_ATTACHMENT,
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.COPY_DST,
});
}
}

View file

@ -60,7 +60,6 @@ describe('vibe and audio config contract', () => {
expect(new Set(vibeIds).size).toBe(vibeIds.length);
expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true);
expect(audioIds.slice().sort()).toEqual(vibeIds.slice().sort());
expect(vibeIds).toContain(gardenAudioConfig.fallbackVibeId);
});
it('keeps each vibe palette and audio profile complete', () => {
@ -97,6 +96,11 @@ describe('vibe and audio config contract', () => {
});
});
it('falls back to finite RGB channels for malformed hex colors', () => {
expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]);
expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]);
});
it('uses discrete color interaction matrices for every vibe', () => {
VIBE_PRESETS.forEach((vibe) => {
colorInteractionKeys.forEach((key) => {

View file

@ -1,17 +1,21 @@
import { appConfig, type VibePreset } from './config';
import { readBrowserStorage } from './utils/browser-storage';
export type { GardenVibeSettings, VibePreset } from './config';
export type { VibePreset } from './config';
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
const HEX_COLOR_PATTERN =
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
export const hexToRgb = (hex: string): [number, number, number] => {
const value = hex.replace('#', '');
return [
parseInt(value.slice(0, 2), 16) / 255,
parseInt(value.slice(2, 4), 16) / 255,
parseInt(value.slice(4, 6), 16) / 255,
];
const match = HEX_COLOR_PATTERN.exec(hex);
if (!match?.groups) {
return [0, 0, 0];
}
const { red, green, blue } = match.groups;
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
};
export const getInitialVibe = (): VibePreset => {