,
This commit is contained in:
parent
70423851ba
commit
1fe5015056
55 changed files with 2077 additions and 726 deletions
2
definitions.d.ts
vendored
2
definitions.d.ts
vendored
|
|
@ -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
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
"vitest": "^4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"tweakpane": "^4.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
69
src/analytics.ts
Normal 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');
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
src/audio/piano-sampler.test.ts
Normal file
126
src/audio/piano-sampler.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export interface GameLoopSettings {
|
||||
agentBudgetMax: number;
|
||||
agentCount: number;
|
||||
renderSpeed: number;
|
||||
simulatedDelayMs: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
src/game-loop/toolbar-contrast-monitor.test.ts
Normal file
68
src/game-loop/toolbar-contrast-monitor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
284
src/game-loop/toolbar-contrast-monitor.ts
Normal file
284
src/game-loop/toolbar-contrast-monitor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/index.ts
151
src/index.ts
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
18
src/vibes.ts
18
src/vibes.ts
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue