,
This commit is contained in:
parent
70423851ba
commit
1fe5015056
55 changed files with 2036 additions and 685 deletions
2
definitions.d.ts
vendored
2
definitions.d.ts
vendored
|
|
@ -6,3 +6,5 @@ declare module '*.wgsl?raw' {
|
||||||
interface HTMLCanvasElement {
|
interface HTMLCanvasElement {
|
||||||
getContext(contextId: 'webgpu'): GPUCanvasContext | null;
|
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",
|
"version": "0.2.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"tweakpane": "^4.0.5"
|
"tweakpane": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1936,6 +1937,12 @@
|
||||||
"url": "https://opencollective.com/pkgr"
|
"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": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.60.0",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
"tweakpane": "^4.0.5"
|
"tweakpane": "^4.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default defineConfig({
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
reuseExistingServer: !isCi,
|
reuseExistingServer: false,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
url: baseURL,
|
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;
|
fadeInSeconds: number;
|
||||||
updateRampSeconds: number;
|
updateRampSeconds: number;
|
||||||
highPassFrequencyHz: number;
|
highPassFrequencyHz: number;
|
||||||
fallbackVibeId: string;
|
|
||||||
compressor: {
|
|
||||||
thresholdDb: number;
|
|
||||||
kneeDb: number;
|
|
||||||
ratio: number;
|
|
||||||
attackSeconds: number;
|
|
||||||
releaseSeconds: number;
|
|
||||||
};
|
|
||||||
delay: {
|
delay: {
|
||||||
timeSeconds: number;
|
timeSeconds: number;
|
||||||
feedback: number;
|
feedback: number;
|
||||||
|
|
@ -47,9 +39,6 @@ export interface GardenAudioConfig {
|
||||||
releaseSeconds: number;
|
releaseSeconds: number;
|
||||||
lowpassHz: number;
|
lowpassHz: number;
|
||||||
};
|
};
|
||||||
input: {
|
|
||||||
pressureFallback: number;
|
|
||||||
};
|
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: number;
|
bpm: number;
|
||||||
stepsPerBeat: number;
|
stepsPerBeat: number;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp } from '../utils/clamp';
|
import { clamp } from '../utils/clamp';
|
||||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||||
|
|
||||||
|
const UNLOCK_TICK_SECONDS = 0.035;
|
||||||
|
const UNLOCK_TICK_FREQUENCY_HZ = 440;
|
||||||
|
|
||||||
export class GardenAudioGraph {
|
export class GardenAudioGraph {
|
||||||
public context: AudioContext | null = null;
|
public context: AudioContext | null = null;
|
||||||
public eventBus: GainNode | null = null;
|
public eventBus: GainNode | null = null;
|
||||||
|
|
@ -12,7 +15,6 @@ export class GardenAudioGraph {
|
||||||
private delayNode: DelayNode | null = null;
|
private delayNode: DelayNode | null = null;
|
||||||
private delayFeedback: GainNode | null = null;
|
private delayFeedback: GainNode | null = null;
|
||||||
private delayOutput: GainNode | null = null;
|
private delayOutput: GainNode | null = null;
|
||||||
private hasUnlocked = false;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly config: GardenAudioConfig,
|
private readonly config: GardenAudioConfig,
|
||||||
|
|
@ -28,23 +30,26 @@ export class GardenAudioGraph {
|
||||||
return null;
|
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 masterGain = context.createGain();
|
||||||
const highPass = context.createBiquadFilter();
|
const highPass = context.createBiquadFilter();
|
||||||
const compressor = context.createDynamicsCompressor();
|
|
||||||
|
|
||||||
masterGain.gain.value = 0;
|
masterGain.gain.value = 0;
|
||||||
highPass.type = 'highpass';
|
highPass.type = 'highpass';
|
||||||
highPass.frequency.value = this.config.highPassFrequencyHz;
|
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);
|
masterGain.connect(highPass);
|
||||||
highPass.connect(compressor);
|
highPass.connect(context.destination);
|
||||||
compressor.connect(context.destination);
|
|
||||||
|
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.masterGain = masterGain;
|
this.masterGain = masterGain;
|
||||||
|
|
@ -55,24 +60,37 @@ export class GardenAudioGraph {
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS WebKit (Safari + Chrome iOS) only fully unlocks audio output once
|
// iOS WebKit can report "running" while the hardware output is still silent.
|
||||||
// a buffer source has been started inside a user-gesture handler. Calling
|
// A very short, nearly inaudible oscillator in the gesture stack is more
|
||||||
// resume() alone leaves the context "running" but silent.
|
// reliable than a fully silent buffer on recent Safari versions.
|
||||||
public unlock(): void {
|
public unlock(): void {
|
||||||
if (!this.context || this.hasUnlocked) {
|
if (!this.context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = this.context.createBuffer(
|
const now = this.context.currentTime;
|
||||||
1,
|
const source = this.context.createOscillator();
|
||||||
this.engineConfig.graph.unlockBufferLength,
|
const gain = this.context.createGain();
|
||||||
this.engineConfig.graph.unlockSampleRate
|
|
||||||
|
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 {
|
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||||
|
|
@ -201,6 +219,5 @@ export class GardenAudioGraph {
|
||||||
this.delayNode = null;
|
this.delayNode = null;
|
||||||
this.delayFeedback = null;
|
this.delayFeedback = null;
|
||||||
this.delayOutput = null;
|
this.delayOutput = null;
|
||||||
this.hasUnlocked = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,13 @@ export interface GardenAudioStrokeMetrics {
|
||||||
export const getStrokeMetrics = (
|
export const getStrokeMetrics = (
|
||||||
stroke: GardenAudioStroke,
|
stroke: GardenAudioStroke,
|
||||||
speedForFullEnergyPixelsPerSecond: number,
|
speedForFullEnergyPixelsPerSecond: number,
|
||||||
fallbackPressure: number,
|
|
||||||
inputConfig: GardenAudioEngineConfig['input']
|
inputConfig: GardenAudioEngineConfig['input']
|
||||||
): GardenAudioStrokeMetrics => {
|
): GardenAudioStrokeMetrics => {
|
||||||
const dx = stroke.to[0] - stroke.from[0];
|
const dx = stroke.to[0] - stroke.from[0];
|
||||||
const dy = stroke.to[1] - stroke.from[1];
|
const dy = stroke.to[1] - stroke.from[1];
|
||||||
const distancePixels = Math.hypot(dx, dy);
|
const distancePixels = Math.hypot(dx, dy);
|
||||||
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
const speedPixelsPerSecond = getStrokeVelocity(stroke, distancePixels, inputConfig);
|
||||||
const pressure = getPressureAmount(stroke, fallbackPressure, inputConfig);
|
const pressure = getPressureAmount(stroke);
|
||||||
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
const speedAmount = clamp01(speedPixelsPerSecond / speedForFullEnergyPixelsPerSecond);
|
||||||
const strokeEnergy = clamp01(
|
const strokeEnergy = clamp01(
|
||||||
inputConfig.strokeEnergyBase +
|
inputConfig.strokeEnergyBase +
|
||||||
|
|
@ -58,11 +57,7 @@ const getStrokeVelocity = (
|
||||||
return distancePixels / inputConfig.fallbackFrameSeconds;
|
return distancePixels / inputConfig.fallbackFrameSeconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPressureAmount = (
|
const getPressureAmount = (stroke: GardenAudioStroke): number => {
|
||||||
stroke: GardenAudioStroke,
|
|
||||||
fallbackPressure: number,
|
|
||||||
inputConfig: GardenAudioEngineConfig['input']
|
|
||||||
): number => {
|
|
||||||
if (
|
if (
|
||||||
stroke.pressure !== undefined &&
|
stroke.pressure !== undefined &&
|
||||||
Number.isFinite(stroke.pressure) &&
|
Number.isFinite(stroke.pressure) &&
|
||||||
|
|
@ -71,7 +66,5 @@ const getPressureAmount = (
|
||||||
return clamp01(stroke.pressure);
|
return clamp01(stroke.pressure);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stroke.pointerType === 'pen'
|
return 0;
|
||||||
? Math.max(inputConfig.penMinPressure, clamp01(fallbackPressure))
|
|
||||||
: clamp01(fallbackPressure);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,14 @@ export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
||||||
export const getVibeProfile = (
|
export const getVibeProfile = (
|
||||||
config: GardenAudioConfig,
|
config: GardenAudioConfig,
|
||||||
vibe: VibePreset
|
vibe: VibePreset
|
||||||
): GardenAudioVibeProfile =>
|
): GardenAudioVibeProfile => {
|
||||||
config.vibes[vibe.id] ??
|
const profile = config.vibes[vibe.id];
|
||||||
config.vibes[config.fallbackVibeId] ??
|
if (!profile) {
|
||||||
Object.values(config.vibes)[0];
|
throw new Error(`Missing audio profile for vibe "${vibe.id}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
};
|
||||||
|
|
||||||
export const getChordIntervals = (
|
export const getChordIntervals = (
|
||||||
chord: GardenAudioChord,
|
chord: GardenAudioChord,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ export interface GardenAudioStroke {
|
||||||
elapsedSeconds?: number;
|
elapsedSeconds?: number;
|
||||||
eraserSizePixels?: number;
|
eraserSizePixels?: number;
|
||||||
mirrorSegmentCount?: number;
|
mirrorSegmentCount?: number;
|
||||||
pointerType?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioTouchDown {
|
export interface GardenAudioTouchDown {
|
||||||
|
|
@ -31,7 +30,6 @@ export interface GardenAudioTouchDown {
|
||||||
canvasSize?: ArrayLike<number>;
|
canvasSize?: ArrayLike<number>;
|
||||||
mirrorSegmentCount?: number;
|
mirrorSegmentCount?: number;
|
||||||
pressure?: number;
|
pressure?: number;
|
||||||
pointerType?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GardenAudioStartOptions {
|
export interface GardenAudioStartOptions {
|
||||||
|
|
@ -45,7 +43,7 @@ export interface LoadedPianoSample {
|
||||||
|
|
||||||
export interface ActivePianoVoice {
|
export interface ActivePianoVoice {
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
source: AudioBufferSourceNode;
|
source: AudioScheduledSourceNode;
|
||||||
startAt: number;
|
startAt: number;
|
||||||
stopAt: number;
|
stopAt: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||||
import { VIBE_PRESETS } from '../vibes';
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
import { GardenAudio } from './garden-audio';
|
import { GardenAudio } from './garden-audio';
|
||||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
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 = {
|
const calls = {
|
||||||
constructed: 0,
|
constructed: 0,
|
||||||
resumed: 0,
|
resumed: 0,
|
||||||
sourcesStarted: 0,
|
sourcesStarted: 0,
|
||||||
|
sources: [] as Array<FakeScheduledSourceNode>,
|
||||||
};
|
};
|
||||||
|
|
||||||
let contextState: AudioContextState = 'suspended';
|
let contextState: AudioContextState = 'suspended';
|
||||||
|
let resumeError: Error | null = null;
|
||||||
|
|
||||||
class FakeAudioParam {
|
class FakeAudioParam {
|
||||||
public value = 0;
|
public value = 0;
|
||||||
|
|
@ -24,6 +33,7 @@ class FakeAudioParam {
|
||||||
class FakeAudioNode {
|
class FakeAudioNode {
|
||||||
public readonly gain = new FakeAudioParam();
|
public readonly gain = new FakeAudioParam();
|
||||||
public readonly frequency = new FakeAudioParam();
|
public readonly frequency = new FakeAudioParam();
|
||||||
|
public readonly playbackRate = new FakeAudioParam();
|
||||||
public readonly Q = new FakeAudioParam();
|
public readonly Q = new FakeAudioParam();
|
||||||
public readonly threshold = new FakeAudioParam();
|
public readonly threshold = new FakeAudioParam();
|
||||||
public readonly knee = new FakeAudioParam();
|
public readonly knee = new FakeAudioParam();
|
||||||
|
|
@ -54,6 +64,7 @@ class FakeAudioContext {
|
||||||
public readonly currentTime = 1;
|
public readonly currentTime = 1;
|
||||||
public readonly sampleRate = 16;
|
public readonly sampleRate = 16;
|
||||||
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
||||||
|
public readonly decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
calls.constructed += 1;
|
calls.constructed += 1;
|
||||||
|
|
@ -75,10 +86,6 @@ class FakeAudioContext {
|
||||||
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createDynamicsCompressor(): DynamicsCompressorNode {
|
|
||||||
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createDelay(): DelayNode {
|
public createDelay(): DelayNode {
|
||||||
return new FakeAudioNode() as unknown as DelayNode;
|
return new FakeAudioNode() as unknown as DelayNode;
|
||||||
}
|
}
|
||||||
|
|
@ -100,13 +107,30 @@ class FakeAudioContext {
|
||||||
node.buffer = null;
|
node.buffer = null;
|
||||||
node.start = vi.fn(() => {
|
node.start = vi.fn(() => {
|
||||||
calls.sourcesStarted += 1;
|
calls.sourcesStarted += 1;
|
||||||
});
|
}) as unknown as typeof node.start;
|
||||||
node.stop = vi.fn();
|
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;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resume(): Promise<void> {
|
public async resume(): Promise<void> {
|
||||||
calls.resumed += 1;
|
calls.resumed += 1;
|
||||||
|
if (resumeError) {
|
||||||
|
throw resumeError;
|
||||||
|
}
|
||||||
contextState = 'running';
|
contextState = 'running';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,13 +144,18 @@ describe('GardenAudio startup policy', () => {
|
||||||
calls.constructed = 0;
|
calls.constructed = 0;
|
||||||
calls.resumed = 0;
|
calls.resumed = 0;
|
||||||
calls.sourcesStarted = 0;
|
calls.sourcesStarted = 0;
|
||||||
|
calls.sources = [];
|
||||||
contextState = 'suspended';
|
contextState = 'suspended';
|
||||||
|
resumeError = null;
|
||||||
|
resetPianoSampleCacheForTest();
|
||||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
resetPianoSampleCacheForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not create an AudioContext from passive audio paths', () => {
|
it('does not create an AudioContext from passive audio paths', () => {
|
||||||
|
|
@ -171,7 +200,27 @@ describe('GardenAudio startup policy', () => {
|
||||||
expect(calls.resumed).toBe(1);
|
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(
|
const audio = new GardenAudio(
|
||||||
makeConfig(),
|
makeConfig(),
|
||||||
appConfig.audioEngine,
|
appConfig.audioEngine,
|
||||||
|
|
@ -217,4 +266,51 @@ describe('GardenAudio startup policy', () => {
|
||||||
|
|
||||||
expect(calls.sourcesStarted).toBe(2);
|
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 type { GardenAudioEngineConfig } from '../config';
|
||||||
import { clamp, clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
|
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import { GardenAudioConfig } from './garden-audio-config';
|
import { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||||
|
|
@ -71,40 +72,66 @@ export class GardenAudio {
|
||||||
return;
|
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) {
|
if (options.userGesture === true) {
|
||||||
this.graph.unlock();
|
this.graph.unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.state === 'suspended') {
|
if (resumePromise) {
|
||||||
if (options.userGesture !== true) {
|
void resumePromise
|
||||||
return;
|
.then(() => {
|
||||||
|
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
|
||||||
|
this.graph.unlock();
|
||||||
|
this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds);
|
||||||
}
|
}
|
||||||
void context.resume().catch(() => undefined);
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
ErrorHandler.addException(error, {
|
||||||
|
fallbackMessage: 'Could not resume audio playback.',
|
||||||
|
severity: Severity.WARNING,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hasStarted = true;
|
this.hasStarted = true;
|
||||||
this.applyVibe(vibe);
|
this.applyVibe(vibe);
|
||||||
this.pianoEngine.prime(context.currentTime);
|
this.pianoEngine.prime(context.currentTime);
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds);
|
||||||
this.config.masterVolume,
|
|
||||||
options.userGesture === true
|
|
||||||
? this.engineConfig.muteRampSeconds
|
|
||||||
: this.config.fadeInSeconds
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!this.hasQueuedPianoLoad) {
|
if (!this.hasQueuedPianoLoad) {
|
||||||
this.hasQueuedPianoLoad = true;
|
this.hasQueuedPianoLoad = true;
|
||||||
void this.piano.load(context).then(() => {
|
void this.piano
|
||||||
|
.load(context)
|
||||||
|
.then(() => {
|
||||||
if (this.graph.context === context && !this.isDestroyed) {
|
if (this.graph.context === context && !this.isDestroyed) {
|
||||||
this.pianoEngine.cue(context.currentTime);
|
this.pianoEngine.cue(context.currentTime);
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
public changeVibe(vibe: VibePreset, options: GardenAudioStartOptions = {}): void {
|
||||||
const previousVibeId = this.currentVibeId;
|
const previousVibeId = this.currentVibeId;
|
||||||
this.start(vibe, options);
|
this.start(vibe, options);
|
||||||
|
const didChangeVibe = previousVibeId !== null && previousVibeId !== vibe.id;
|
||||||
|
|
||||||
|
if (didChangeVibe) {
|
||||||
|
this.piano.stopAll();
|
||||||
|
}
|
||||||
|
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
if (
|
if (
|
||||||
|
|
@ -112,8 +139,7 @@ export class GardenAudio {
|
||||||
(context.state === 'running' || options.userGesture === true) &&
|
(context.state === 'running' || options.userGesture === true) &&
|
||||||
!this.isMuted &&
|
!this.isMuted &&
|
||||||
!this.isDestroyed &&
|
!this.isDestroyed &&
|
||||||
previousVibeId !== null &&
|
didChangeVibe
|
||||||
previousVibeId !== vibe.id
|
|
||||||
) {
|
) {
|
||||||
this.playVibeChangeStinger(vibe);
|
this.playVibeChangeStinger(vibe);
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +184,7 @@ export class GardenAudio {
|
||||||
|
|
||||||
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
||||||
const mirrorAmount = this.getMirrorAmount(touch.mirrorSegmentCount ?? 1);
|
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 strength = clamp01(0.36 + pressure * 0.34 + mirrorAmount * 0.22);
|
||||||
const frame = this.gestureState.recordTouchDown({
|
const frame = this.gestureState.recordTouchDown({
|
||||||
touch,
|
touch,
|
||||||
|
|
@ -225,7 +251,6 @@ export class GardenAudio {
|
||||||
const metrics = getStrokeMetrics(
|
const metrics = getStrokeMetrics(
|
||||||
stroke,
|
stroke,
|
||||||
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
this.config.rhythm.speedForFullEnergyPixelsPerSecond,
|
||||||
this.config.input.pressureFallback,
|
|
||||||
this.engineConfig.input
|
this.engineConfig.input
|
||||||
);
|
);
|
||||||
const now = context.currentTime;
|
const now = context.currentTime;
|
||||||
|
|
@ -375,16 +400,11 @@ export class GardenAudio {
|
||||||
return clamp01((segmentCount - 1) / (maxMirrorSegmentCount - 1));
|
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) {
|
if (pressure !== undefined && Number.isFinite(pressure) && pressure > 0) {
|
||||||
return clamp01(pressure);
|
return clamp01(pressure);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pointerType === 'pen'
|
return 0;
|
||||||
? Math.max(
|
|
||||||
this.engineConfig.input.penMinPressure,
|
|
||||||
this.config.input.pressureFallback
|
|
||||||
)
|
|
||||||
: this.config.input.pressureFallback;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { GardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioGraph } from './garden-audio-graph';
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
import { ActivePianoVoice, LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||||
import { pianoSampleDefinitions } from './piano-samples';
|
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||||
|
|
||||||
export class PianoSampler {
|
export class PianoSampler {
|
||||||
private sampleLoadPromise: Promise<void> | null = null;
|
private sampleLoadPromise: Promise<void> | null = null;
|
||||||
|
|
@ -16,27 +16,19 @@ export class PianoSampler {
|
||||||
private readonly graph: GardenAudioGraph
|
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) {
|
if (this.sampleLoadPromise) {
|
||||||
return this.sampleLoadPromise;
|
return this.sampleLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sampleLoadPromise = Promise.all(
|
this.sampleLoadPromise = loadPianoSamples(context).then((samples) => {
|
||||||
pianoSampleDefinitions.map(async (sample) => {
|
this.setSamples(samples);
|
||||||
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 = [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.sampleLoadPromise;
|
return this.sampleLoadPromise;
|
||||||
|
|
@ -89,13 +81,9 @@ export class PianoSampler {
|
||||||
this.trimActiveVoices(scheduledStart);
|
this.trimActiveVoices(scheduledStart);
|
||||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||||
const oldest = this.activeVoices.shift();
|
const oldest = this.activeVoices.shift();
|
||||||
oldest?.gain.gain.cancelScheduledValues(scheduledStart);
|
if (oldest) {
|
||||||
oldest?.gain.gain.setTargetAtTime(
|
this.stopVoice(oldest, scheduledStart);
|
||||||
this.engineConfig.piano.minGain,
|
}
|
||||||
scheduledStart,
|
|
||||||
this.engineConfig.piano.voiceStealFadeSeconds
|
|
||||||
);
|
|
||||||
oldest?.source.stop(scheduledStart + this.engineConfig.piano.voiceStealStopSeconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
source.buffer = sample.buffer;
|
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 {
|
public reset(): void {
|
||||||
this.sampleLoadPromise = null;
|
this.sampleLoadPromise = null;
|
||||||
this.samples = [];
|
this.samples = [];
|
||||||
|
|
@ -181,4 +184,25 @@ export class PianoSampler {
|
||||||
private trimActiveVoices(now: number): void {
|
private trimActiveVoices(now: number): void {
|
||||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
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;
|
midi: number;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PianoSampleLoadProgress {
|
||||||
|
loadedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
sample?: PianoSampleDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
||||||
|
|
||||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||||
|
|
@ -44,3 +52,94 @@ export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||||
url: `${sampleBaseUrl}${fileName}`,
|
url: `${sampleBaseUrl}${fileName}`,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.midi - b.midi);
|
.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';
|
import { audioVibes, defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AgentColorInteractionSettings,
|
|
||||||
GardenAppConfig,
|
GardenAppConfig,
|
||||||
GardenAudioEngineConfig,
|
GardenAudioEngineConfig,
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
GardenSimulationConfig,
|
|
||||||
GardenStorageConfig,
|
|
||||||
GardenVibeSettings,
|
|
||||||
NumberControlConfig,
|
NumberControlConfig,
|
||||||
RuntimeSettingControlConfig,
|
|
||||||
VibePreset,
|
VibePreset,
|
||||||
} from './config/types';
|
} from './config/types';
|
||||||
|
|
||||||
|
|
@ -21,14 +16,6 @@ export const appConfig = {
|
||||||
fadeInSeconds: 0.45,
|
fadeInSeconds: 0.45,
|
||||||
updateRampSeconds: 0.08,
|
updateRampSeconds: 0.08,
|
||||||
highPassFrequencyHz: 45,
|
highPassFrequencyHz: 45,
|
||||||
fallbackVibeId: defaultVibeId,
|
|
||||||
compressor: {
|
|
||||||
thresholdDb: -18,
|
|
||||||
kneeDb: 18,
|
|
||||||
ratio: 2.4,
|
|
||||||
attackSeconds: 0.006,
|
|
||||||
releaseSeconds: 0.18,
|
|
||||||
},
|
|
||||||
delay: {
|
delay: {
|
||||||
timeSeconds: 0.46,
|
timeSeconds: 0.46,
|
||||||
feedback: 0.12,
|
feedback: 0.12,
|
||||||
|
|
@ -42,9 +29,6 @@ export const appConfig = {
|
||||||
releaseSeconds: 0.24,
|
releaseSeconds: 0.24,
|
||||||
lowpassHz: 7600,
|
lowpassHz: 7600,
|
||||||
},
|
},
|
||||||
input: {
|
|
||||||
pressureFallback: 0.48,
|
|
||||||
},
|
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: 74,
|
bpm: 74,
|
||||||
stepsPerBeat: 4,
|
stepsPerBeat: 4,
|
||||||
|
|
@ -120,7 +104,6 @@ export const appConfig = {
|
||||||
distanceEnergyScale: 0.66,
|
distanceEnergyScale: 0.66,
|
||||||
distanceForFullEnergyPixels: 140,
|
distanceForFullEnergyPixels: 140,
|
||||||
fallbackFrameSeconds: 1 / 60,
|
fallbackFrameSeconds: 1 / 60,
|
||||||
penMinPressure: 0.56,
|
|
||||||
strokeEnergyBase: 0.18,
|
strokeEnergyBase: 0.18,
|
||||||
strokeEnergyPressureWeight: 0.22,
|
strokeEnergyPressureWeight: 0.22,
|
||||||
strokeEnergySpeedWeight: 0.62,
|
strokeEnergySpeedWeight: 0.62,
|
||||||
|
|
|
||||||
|
|
@ -18,47 +18,6 @@ export const defaultColorInteractionSettings: AgentColorInteractionSettings = {
|
||||||
color3ToColor3: 1,
|
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 => ({
|
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||||
folder: 'Color Reactions',
|
folder: 'Color Reactions',
|
||||||
label,
|
label,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||||
|
|
||||||
startColorHue: 200,
|
startColorHue: 200,
|
||||||
|
|
||||||
renderSpeed: 1,
|
|
||||||
simulatedDelayMs: 0,
|
simulatedDelayMs: 0,
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
|
|
@ -145,13 +144,6 @@ export const runtimeSettings: GardenAppConfig['runtimeSettings'] = {
|
||||||
max: 500,
|
max: 500,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
renderSpeed: {
|
|
||||||
folder: 'Runtime',
|
|
||||||
integer: true,
|
|
||||||
min: 1,
|
|
||||||
max: 10,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
selectedColorIndex: {
|
selectedColorIndex: {
|
||||||
folder: 'Brush',
|
folder: 'Brush',
|
||||||
integer: true,
|
integer: true,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export type AgentColorInteractionSettings = Pick<
|
||||||
| 'color3ToColor3'
|
| 'color3ToColor3'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type GardenVibeSettings = Partial<
|
type GardenVibeSettings = Partial<
|
||||||
Pick<
|
Pick<
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
| 'agentBudgetMax'
|
| 'agentBudgetMax'
|
||||||
|
|
@ -72,7 +72,7 @@ export interface NumberControlConfig {
|
||||||
step?: number;
|
step?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeSettingControlConfig = {
|
type RuntimeSettingControlConfig = {
|
||||||
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
[Key in keyof GardenRuntimeSettings]: NumberControlConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -120,7 +120,6 @@ export interface GardenAppConfig {
|
||||||
distanceEnergyScale: number;
|
distanceEnergyScale: number;
|
||||||
distanceForFullEnergyPixels: number;
|
distanceForFullEnergyPixels: number;
|
||||||
fallbackFrameSeconds: number;
|
fallbackFrameSeconds: number;
|
||||||
penMinPressure: number;
|
|
||||||
strokeEnergyBase: number;
|
strokeEnergyBase: number;
|
||||||
strokeEnergyPressureWeight: number;
|
strokeEnergyPressureWeight: number;
|
||||||
strokeEnergySpeedWeight: number;
|
strokeEnergySpeedWeight: number;
|
||||||
|
|
@ -280,5 +279,3 @@ export interface GardenAppConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
|
export type GardenAudioEngineConfig = GardenAppConfig['audioEngine'];
|
||||||
export type GardenSimulationConfig = GardenAppConfig['simulation'];
|
|
||||||
export type GardenStorageConfig = GardenAppConfig['storage'];
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type {
|
||||||
GardenAudioChord,
|
GardenAudioChord,
|
||||||
GardenAudioVibeProfile,
|
GardenAudioVibeProfile,
|
||||||
} from '../audio/garden-audio-config';
|
} from '../audio/garden-audio-config';
|
||||||
import { createColorInteractionSettings } from './color-interactions';
|
import { defaultColorInteractionSettings } from './color-interactions';
|
||||||
import type { VibePreset } from './types';
|
import type { VibePreset } from './types';
|
||||||
|
|
||||||
const majorProgression: Array<GardenAudioChord> = [
|
const majorProgression: Array<GardenAudioChord> = [
|
||||||
|
|
@ -42,7 +42,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
sensorOffsetDistance: 38,
|
sensorOffsetDistance: 38,
|
||||||
spawnPerPixel: 0.22,
|
spawnPerPixel: 0.22,
|
||||||
turnSpeed: 58,
|
turnSpeed: 58,
|
||||||
...createColorInteractionSettings('candy-rain'),
|
...defaultColorInteractionSettings,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 57,
|
rootMidi: 57,
|
||||||
|
|
@ -69,7 +69,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
sensorOffsetDistance: 46,
|
sensorOffsetDistance: 46,
|
||||||
spawnPerPixel: 0.18,
|
spawnPerPixel: 0.18,
|
||||||
turnSpeed: 44,
|
turnSpeed: 44,
|
||||||
...createColorInteractionSettings('sunlit-moss'),
|
...defaultColorInteractionSettings,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 53,
|
rootMidi: 53,
|
||||||
|
|
@ -101,7 +101,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
sensorOffsetDistance: 35,
|
sensorOffsetDistance: 35,
|
||||||
spawnPerPixel: 0.25,
|
spawnPerPixel: 0.25,
|
||||||
turnSpeed: 62,
|
turnSpeed: 62,
|
||||||
...createColorInteractionSettings('coral-tide'),
|
...defaultColorInteractionSettings,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 50,
|
rootMidi: 50,
|
||||||
|
|
@ -128,7 +128,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
sensorOffsetDistance: 42,
|
sensorOffsetDistance: 42,
|
||||||
spawnPerPixel: 0.2,
|
spawnPerPixel: 0.2,
|
||||||
turnSpeed: 52,
|
turnSpeed: 52,
|
||||||
...createColorInteractionSettings('moon-orchid'),
|
...defaultColorInteractionSettings,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 49,
|
rootMidi: 49,
|
||||||
|
|
@ -155,7 +155,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
sensorOffsetDistance: 32,
|
sensorOffsetDistance: 32,
|
||||||
spawnPerPixel: 0.24,
|
spawnPerPixel: 0.24,
|
||||||
turnSpeed: 70,
|
turnSpeed: 70,
|
||||||
...createColorInteractionSettings('peach-neon'),
|
...defaultColorInteractionSettings,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 56,
|
rootMidi: 56,
|
||||||
|
|
@ -182,7 +182,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
sensorOffsetDistance: 52,
|
sensorOffsetDistance: 52,
|
||||||
spawnPerPixel: 0.16,
|
spawnPerPixel: 0.16,
|
||||||
turnSpeed: 40,
|
turnSpeed: 40,
|
||||||
...createColorInteractionSettings('frost-bloom'),
|
...defaultColorInteractionSettings,
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 62,
|
rootMidi: 62,
|
||||||
|
|
|
||||||
|
|
@ -2,44 +2,61 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { FramePerformance } from './frame-performance';
|
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', () => {
|
describe('FramePerformance refresh target', () => {
|
||||||
it('uses 60 FPS as the fixed adaptive budget 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);
|
expect(performance.refreshTargetFps).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps latest and smoothed FPS separate from the fixed target', () => {
|
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.latestFps).toBe(120);
|
||||||
expect(performance.smoothedFps).toBeGreaterThan(60);
|
expect(performance.smoothedFps).toBeGreaterThan(60);
|
||||||
expect(performance.refreshTargetFps).toBe(60);
|
expect(performance.refreshTargetFps).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('snaps the display refresh estimate to a stable screen frequency', () => {
|
it('reports true FPS even when the simulation delta would clamp', () => {
|
||||||
const performance = new FramePerformance();
|
const { performance, advance } = createScenario();
|
||||||
|
|
||||||
[123, 126, 130, 121, 124, 127, 125, 122].forEach((fps) =>
|
[5, 5, 5, 5, 5].forEach(advance);
|
||||||
performance.update(1 / fps)
|
|
||||||
);
|
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.refreshTargetFps).toBe(60);
|
||||||
expect(performance.displayRefreshFps).toBe(120);
|
expect(performance.displayRefreshFps).toBe(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores a single startup spike before settling the display refresh estimate', () => {
|
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);
|
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.refreshTargetFps).toBe(60);
|
||||||
expect(performance.displayRefreshFps).toBe(120);
|
expect(performance.displayRefreshFps).toBe(120);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ interface TelemetrySnapshot {
|
||||||
agentBudgetMax: number;
|
agentBudgetMax: number;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
devicePixelRatio: number;
|
devicePixelRatio: number;
|
||||||
renderSpeed: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_DISPLAY_REFRESH_RATES = [
|
const COMMON_DISPLAY_REFRESH_RATES = [
|
||||||
|
|
@ -15,6 +14,7 @@ const COMMON_DISPLAY_REFRESH_RATES = [
|
||||||
] as const;
|
] as const;
|
||||||
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
|
const DISPLAY_REFRESH_CONFIRMATION_FRAMES = 8;
|
||||||
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
|
const DISPLAY_REFRESH_SNAP_TOLERANCE = 0.15;
|
||||||
|
const FRAME_GAP_RESET_SECONDS = 1;
|
||||||
|
|
||||||
export class FramePerformance {
|
export class FramePerformance {
|
||||||
public latestFps = 60;
|
public latestFps = 60;
|
||||||
|
|
@ -23,6 +23,7 @@ export class FramePerformance {
|
||||||
public readonly refreshTargetFps = 60;
|
public readonly refreshTargetFps = 60;
|
||||||
|
|
||||||
private lastTelemetryAt = 0;
|
private lastTelemetryAt = 0;
|
||||||
|
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||||
private hasConfirmedDisplayRefreshFps = false;
|
private hasConfirmedDisplayRefreshFps = false;
|
||||||
private pendingDisplayRefreshFps = 0;
|
private pendingDisplayRefreshFps = 0;
|
||||||
private pendingDisplayRefreshFrameCount = 0;
|
private pendingDisplayRefreshFrameCount = 0;
|
||||||
|
|
@ -35,8 +36,19 @@ export class FramePerformance {
|
||||||
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
|
return appConfig.telemetry.enabled ? performance.now() - startedAt : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(deltaTime: number): void {
|
public update(time: DOMHighResTimeStamp): void {
|
||||||
const fps = 1 / Math.max(deltaTime, appConfig.deltaTime.minDeltaTimeSeconds);
|
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.latestFps = fps;
|
||||||
this.updateDisplayRefreshEstimate(fps);
|
this.updateDisplayRefreshEstimate(fps);
|
||||||
this.smoothedFps =
|
this.smoothedFps =
|
||||||
|
|
@ -51,7 +63,6 @@ export class FramePerformance {
|
||||||
agentBudgetMax,
|
agentBudgetMax,
|
||||||
canvas,
|
canvas,
|
||||||
devicePixelRatio,
|
devicePixelRatio,
|
||||||
renderSpeed,
|
|
||||||
}: TelemetrySnapshot): void {
|
}: TelemetrySnapshot): void {
|
||||||
if (!appConfig.telemetry.enabled) {
|
if (!appConfig.telemetry.enabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -73,7 +84,6 @@ export class FramePerformance {
|
||||||
canvasWidth: canvas.width,
|
canvasWidth: canvas.width,
|
||||||
canvasHeight: canvas.height,
|
canvasHeight: canvas.height,
|
||||||
dpr: devicePixelRatio,
|
dpr: devicePixelRatio,
|
||||||
renderSpeed,
|
|
||||||
frameCpuMs: now - frameCpuStartedAt,
|
frameCpuMs: now - frameCpuStartedAt,
|
||||||
encodeCpuMs,
|
encodeCpuMs,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,31 +10,45 @@ const simulationTexturesSource = readFileSync(
|
||||||
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
|
join(process.cwd(), 'src/game-loop/simulation-textures.ts'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
const resizableTextureSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src/utils/graphics/resizable-texture.ts'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
const getRenderStepSource = () => {
|
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);
|
const end = simulationFrameSource.indexOf(' public clearSwipes', start);
|
||||||
|
|
||||||
if (start < 0 || end < 0) {
|
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);
|
return simulationFrameSource.slice(start, end);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('GameLoop ping-pong texture flow', () => {
|
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();
|
const renderStepSource = getRenderStepSource();
|
||||||
|
|
||||||
expect(renderStepSource.match(/copyPipeline\.execute/g)).toHaveLength(1);
|
expect(renderStepSource).not.toContain('copyPipeline.execute');
|
||||||
expect(renderStepSource).toMatch(
|
expect(renderStepSource).toContain('this.textures.copyTrailMapAToB(commandEncoder);');
|
||||||
/this\.pipelines\.copyPipeline\.execute\([\s\S]*this\.textures\.trailMapA\.getTextureView\(\)[\s\S]*this\.textures\.trailMapB\.getTextureView\(\)[\s\S]*\);/
|
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(
|
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\(\);/
|
/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', () => {
|
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
||||||
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
|
expect(simulationTexturesSource).toContain('public sourceMapA: ResizableTexture;');
|
||||||
expect(simulationTexturesSource).toContain('public sourceMapB: 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 { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
import { CommonState } from '../pipelines/common-state/common-state';
|
import { CommonState } from '../pipelines/common-state/common-state';
|
||||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
|
||||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-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 { settings } from '../settings';
|
||||||
import { initializeContext } from '../utils/graphics/initialize-context';
|
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||||
import { GLOBAL_AGENT_CAP } from './agent-population';
|
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 { SimulationFrameRenderer } from './simulation-frame';
|
||||||
import { SimulationTextures } from './simulation-textures';
|
import { SimulationTextures } from './simulation-textures';
|
||||||
|
|
||||||
|
|
@ -33,7 +32,6 @@ interface FrameParameters extends RenderInputs {
|
||||||
export class GameLoopResources {
|
export class GameLoopResources {
|
||||||
public readonly textures: SimulationTextures;
|
public readonly textures: SimulationTextures;
|
||||||
public readonly commonState: CommonState;
|
public readonly commonState: CommonState;
|
||||||
public readonly copyPipeline: CopyPipeline;
|
|
||||||
public readonly agentGenerationPipeline: AgentGenerationPipeline;
|
public readonly agentGenerationPipeline: AgentGenerationPipeline;
|
||||||
public readonly agentPipeline: AgentPipeline;
|
public readonly agentPipeline: AgentPipeline;
|
||||||
public readonly brushPipeline: BrushPipeline;
|
public readonly brushPipeline: BrushPipeline;
|
||||||
|
|
@ -53,7 +51,6 @@ export class GameLoopResources {
|
||||||
const context = initializeContext({ device, canvas });
|
const context = initializeContext({ device, canvas });
|
||||||
|
|
||||||
this.textures = new SimulationTextures(this.device, canvasSize);
|
this.textures = new SimulationTextures(this.device, canvasSize);
|
||||||
this.copyPipeline = new CopyPipeline(this.device);
|
|
||||||
|
|
||||||
this.commonState = new CommonState(this.device);
|
this.commonState = new CommonState(this.device);
|
||||||
this.commonState.setParameters({
|
this.commonState.setParameters({
|
||||||
|
|
@ -88,7 +85,6 @@ export class GameLoopResources {
|
||||||
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
this.renderPipeline = new RenderPipeline(context, this.device, this.commonState);
|
||||||
|
|
||||||
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
|
this.frameRenderer = new SimulationFrameRenderer(this.device, this.textures, {
|
||||||
copyPipeline: this.copyPipeline,
|
|
||||||
agentPipeline: this.agentPipeline,
|
agentPipeline: this.agentPipeline,
|
||||||
brushPipeline: this.brushPipeline,
|
brushPipeline: this.brushPipeline,
|
||||||
eraserAgentPipeline: this.eraserAgentPipeline,
|
eraserAgentPipeline: this.eraserAgentPipeline,
|
||||||
|
|
@ -157,8 +153,11 @@ export class GameLoopResources {
|
||||||
this.setBrushEffectDiffusionParameters();
|
this.setBrushEffectDiffusionParameters();
|
||||||
}
|
}
|
||||||
|
|
||||||
public executeFrame(renderSpeed: number, isErasing: boolean): void {
|
public executeFrame(
|
||||||
this.frameRenderer.execute(renderSpeed, isErasing);
|
isErasing: boolean,
|
||||||
|
canvasReadbackRequest?: CanvasReadbackRequest | null
|
||||||
|
): void {
|
||||||
|
this.frameRenderer.execute(isErasing, canvasReadbackRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearSwipes(): void {
|
public clearSwipes(): void {
|
||||||
|
|
@ -166,7 +165,6 @@ export class GameLoopResources {
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.copyPipeline.destroy();
|
|
||||||
this.agentGenerationPipeline.destroy();
|
this.agentGenerationPipeline.destroy();
|
||||||
this.agentPipeline.destroy();
|
this.agentPipeline.destroy();
|
||||||
this.brushPipeline.destroy();
|
this.brushPipeline.destroy();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export interface GameLoopSettings {
|
export interface GameLoopSettings {
|
||||||
agentBudgetMax: number;
|
agentBudgetMax: number;
|
||||||
agentCount: number;
|
agentCount: number;
|
||||||
renderSpeed: number;
|
|
||||||
simulatedDelayMs: number;
|
simulatedDelayMs: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
spawnPerPixel: number;
|
spawnPerPixel: number;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export interface GardenUi {
|
||||||
prompt: HTMLElement;
|
prompt: HTMLElement;
|
||||||
eraserPreview: HTMLElement;
|
eraserPreview: HTMLElement;
|
||||||
exportStatus: HTMLElement;
|
exportStatus: HTMLElement;
|
||||||
|
toolbar: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderInputs {
|
export interface RenderInputs {
|
||||||
|
|
@ -15,3 +16,8 @@ export interface StrokeSegment {
|
||||||
from: vec2;
|
from: vec2;
|
||||||
to: 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 { IntroPrompt } from './intro-prompt';
|
||||||
import { GardenPointerInput } from './pointer-input';
|
import { GardenPointerInput } from './pointer-input';
|
||||||
import { RenderInputCache } from './render-input-cache';
|
import { RenderInputCache } from './render-input-cache';
|
||||||
|
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
||||||
|
|
||||||
export default class GameLoop {
|
export default class GameLoop {
|
||||||
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
private static readonly MAX_MIRROR_SEGMENT_COUNT =
|
||||||
|
|
@ -34,6 +35,7 @@ export default class GameLoop {
|
||||||
private readonly agentPopulation: AgentPopulation;
|
private readonly agentPopulation: AgentPopulation;
|
||||||
private readonly export4KRenderer: Export4KRenderer;
|
private readonly export4KRenderer: Export4KRenderer;
|
||||||
private readonly framePerformance = new FramePerformance();
|
private readonly framePerformance = new FramePerformance();
|
||||||
|
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||||
private readonly devStatsElement: HTMLDivElement | null;
|
private readonly devStatsElement: HTMLDivElement | null;
|
||||||
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
||||||
private readonly resizeListener = this.resize.bind(this);
|
private readonly resizeListener = this.resize.bind(this);
|
||||||
|
|
@ -56,6 +58,7 @@ export default class GameLoop {
|
||||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||||
|
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
||||||
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
||||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||||
this.pointerInput = new GardenPointerInput({
|
this.pointerInput = new GardenPointerInput({
|
||||||
|
|
@ -85,8 +88,8 @@ export default class GameLoop {
|
||||||
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||||
getVibeId: () => activeVibe.id,
|
getVibeId: () => activeVibe.id,
|
||||||
});
|
});
|
||||||
this.keydownListener = (event: KeyboardEvent) => {
|
this.keydownListener = () => {
|
||||||
this.audio.start(activeVibe, { userGesture: event.isTrusted });
|
this.audio.start(activeVibe, { userGesture: true });
|
||||||
this.introPrompt.complete();
|
this.introPrompt.complete();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -151,6 +154,7 @@ export default class GameLoop {
|
||||||
window.removeEventListener('resize', this.resizeListener);
|
window.removeEventListener('resize', this.resizeListener);
|
||||||
window.removeEventListener('keydown', this.keydownListener);
|
window.removeEventListener('keydown', this.keydownListener);
|
||||||
this.pointerInput.detach();
|
this.pointerInput.detach();
|
||||||
|
this.toolbarContrastMonitor.destroy();
|
||||||
this.devStatsElement?.remove();
|
this.devStatsElement?.remove();
|
||||||
this.introPrompt.destroy();
|
this.introPrompt.destroy();
|
||||||
this.resources.destroy();
|
this.resources.destroy();
|
||||||
|
|
@ -165,7 +169,7 @@ export default class GameLoop {
|
||||||
|
|
||||||
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
const frameCpuStartedAt = this.framePerformance.markCpuStart();
|
||||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||||
this.framePerformance.update(deltaTime);
|
this.framePerformance.update(time);
|
||||||
this.agentPopulation.growBudget(
|
this.agentPopulation.growBudget(
|
||||||
deltaTime,
|
deltaTime,
|
||||||
this.framePerformance.smoothedFps,
|
this.framePerformance.smoothedFps,
|
||||||
|
|
@ -175,7 +179,6 @@ export default class GameLoop {
|
||||||
this.resize();
|
this.resize();
|
||||||
this.resizeSimulationToCanvas();
|
this.resizeSimulationToCanvas();
|
||||||
|
|
||||||
const scaledTime = time * settings.renderSpeed;
|
|
||||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
const cameraZoom = 1;
|
const cameraZoom = 1;
|
||||||
|
|
@ -195,7 +198,7 @@ export default class GameLoop {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.resources.setFrameParameters({
|
this.resources.setFrameParameters({
|
||||||
time: scaledTime,
|
time,
|
||||||
deltaTime,
|
deltaTime,
|
||||||
canvasSize: this.canvasSize,
|
canvasSize: this.canvasSize,
|
||||||
activeAgentCount: this.agentPopulation.activeAgentCount,
|
activeAgentCount: this.agentPopulation.activeAgentCount,
|
||||||
|
|
@ -210,7 +213,10 @@ export default class GameLoop {
|
||||||
});
|
});
|
||||||
|
|
||||||
const encodeCpuStartedAt = this.framePerformance.markCpuStart();
|
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);
|
const encodeCpuMs = this.framePerformance.measureSince(encodeCpuStartedAt);
|
||||||
|
|
||||||
this.pointerInput.clearSwipesIfIdle();
|
this.pointerInput.clearSwipesIfIdle();
|
||||||
|
|
@ -223,7 +229,6 @@ export default class GameLoop {
|
||||||
agentBudgetMax: settings.agentBudgetMax,
|
agentBudgetMax: settings.agentBudgetMax,
|
||||||
canvas: this.canvas,
|
canvas: this.canvas,
|
||||||
devicePixelRatio: this.devicePixelRatio,
|
devicePixelRatio: this.devicePixelRatio,
|
||||||
renderSpeed: settings.renderSpeed,
|
|
||||||
});
|
});
|
||||||
this.updateDevStats(time);
|
this.updateDevStats(time);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ const createIntroTitlePoints = (
|
||||||
|
|
||||||
const fontSize = getIntroTitleFontSize(context, width, height);
|
const fontSize = getIntroTitleFontSize(context, width, height);
|
||||||
context.clearRect(0, 0, 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.textAlign = 'center';
|
||||||
context.textBaseline = 'middle';
|
context.textBaseline = 'middle';
|
||||||
context.fillStyle = '#fff';
|
context.fillStyle = '#fff';
|
||||||
|
|
@ -302,7 +302,7 @@ const getIntroTitleFontSize = (
|
||||||
);
|
);
|
||||||
|
|
||||||
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
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 metrics = context.measureText(INTRO_TITLE);
|
||||||
const measuredHeight =
|
const measuredHeight =
|
||||||
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { GardenAudio } from '../audio/garden-audio';
|
import { GardenAudio } from '../audio/garden-audio';
|
||||||
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
|
|
@ -111,7 +110,9 @@ export class GardenPointerInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = this.getCanvasPointerPosition(event);
|
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.beginGesture();
|
||||||
this.options.audio.touchDown({
|
this.options.audio.touchDown({
|
||||||
vibe: activeVibe,
|
vibe: activeVibe,
|
||||||
|
|
@ -120,7 +121,6 @@ export class GardenPointerInput {
|
||||||
canvasSize: this.options.getCanvasSize(),
|
canvasSize: this.options.getCanvasSize(),
|
||||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||||
pressure: this.getPointerPressure(event),
|
pressure: this.getPointerPressure(event),
|
||||||
pointerType: event.pointerType,
|
|
||||||
});
|
});
|
||||||
this.options.onStartDrawing();
|
this.options.onStartDrawing();
|
||||||
this.activePointerId = event.pointerId;
|
this.activePointerId = event.pointerId;
|
||||||
|
|
@ -149,6 +149,7 @@ export class GardenPointerInput {
|
||||||
if (event.pointerId !== this.activePointerId) {
|
if (event.pointerId !== this.activePointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.options.audio.start(activeVibe, { userGesture: true });
|
||||||
this.addSwipeAt(event, { emitAudio: false });
|
this.addSwipeAt(event, { emitAudio: false });
|
||||||
this.finishSmoothedStroke();
|
this.finishSmoothedStroke();
|
||||||
this.options.audio.endGesture();
|
this.options.audio.endGesture();
|
||||||
|
|
@ -221,7 +222,6 @@ export class GardenPointerInput {
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
eraserSizePixels: settings.eraserSize * devicePixelRatio,
|
||||||
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
mirrorSegmentCount: this.options.getMirrorSegmentCount(),
|
||||||
pointerType: event.pointerType,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.lastPointerPosition = position;
|
this.lastPointerPosition = position;
|
||||||
|
|
@ -369,9 +369,7 @@ export class GardenPointerInput {
|
||||||
return Math.min(1, Math.max(0, event.pressure));
|
return Math.min(1, Math.max(0, event.pressure));
|
||||||
}
|
}
|
||||||
|
|
||||||
return event.buttons > 0 || event.type === 'pointerdown'
|
return 0;
|
||||||
? gardenAudioConfig.input.pressureFallback
|
|
||||||
: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
|
||||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
|
import { CanvasReadbackRequest } from './game-loop-types';
|
||||||
import { SimulationTextures } from './simulation-textures';
|
import { SimulationTextures } from './simulation-textures';
|
||||||
|
|
||||||
interface SimulationFramePipelines {
|
interface SimulationFramePipelines {
|
||||||
copyPipeline: CopyPipeline;
|
|
||||||
agentPipeline: AgentPipeline;
|
agentPipeline: AgentPipeline;
|
||||||
brushPipeline: BrushPipeline;
|
brushPipeline: BrushPipeline;
|
||||||
eraserAgentPipeline: EraserAgentPipeline;
|
eraserAgentPipeline: EraserAgentPipeline;
|
||||||
|
|
@ -25,36 +24,29 @@ export class SimulationFrameRenderer {
|
||||||
private readonly pipelines: SimulationFramePipelines
|
private readonly pipelines: SimulationFramePipelines
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public execute(renderSpeed: number, isErasing: boolean): void {
|
public execute(
|
||||||
for (let i = 0; i < renderSpeed; i++) {
|
isErasing: boolean,
|
||||||
|
canvasReadbackRequest?: CanvasReadbackRequest | null
|
||||||
|
): void {
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
|
||||||
this.pipelines.copyPipeline.execute(
|
this.textures.copyTrailMapAToB(commandEncoder);
|
||||||
commandEncoder,
|
|
||||||
this.textures.trailMapA.getTextureView(),
|
|
||||||
this.textures.trailMapB.getTextureView()
|
|
||||||
);
|
|
||||||
if (isErasing) {
|
if (isErasing) {
|
||||||
this.pipelines.eraserTexturePipeline.execute(
|
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
||||||
commandEncoder,
|
const eraserMask = this.textures.clearEraserMask(commandEncoder);
|
||||||
this.textures.sourceMapA.getTextureView()
|
this.pipelines.eraserTexturePipeline.execute(commandEncoder, eraserMask);
|
||||||
);
|
this.pipelines.eraserTexturePipeline.executeMultiTarget(
|
||||||
this.pipelines.eraserTexturePipeline.execute(
|
|
||||||
commandEncoder,
|
|
||||||
this.textures.influenceMapA.getTextureView()
|
|
||||||
);
|
|
||||||
this.pipelines.eraserTexturePipeline.execute(
|
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
|
this.textures.sourceMapA.getTextureView(),
|
||||||
|
this.textures.influenceMapA.getTextureView(),
|
||||||
this.textures.trailMapB.getTextureView()
|
this.textures.trailMapB.getTextureView()
|
||||||
);
|
);
|
||||||
this.pipelines.eraserAgentPipeline.execute(commandEncoder);
|
this.pipelines.eraserAgentPipeline.execute(commandEncoder, eraserMask);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.pipelines.brushPipeline.execute(
|
this.pipelines.brushPipeline.executeMultiTarget(
|
||||||
commandEncoder,
|
|
||||||
this.textures.sourceMapA.getTextureView()
|
|
||||||
);
|
|
||||||
this.pipelines.brushPipeline.execute(
|
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
|
this.textures.sourceMapA.getTextureView(),
|
||||||
this.textures.influenceMapA.getTextureView()
|
this.textures.influenceMapA.getTextureView()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -69,11 +61,12 @@ export class SimulationFrameRenderer {
|
||||||
this.textures.trailMapB.getTextureView(),
|
this.textures.trailMapB.getTextureView(),
|
||||||
this.textures.trailMapA.getTextureView()
|
this.textures.trailMapA.getTextureView()
|
||||||
);
|
);
|
||||||
this.pipelines.renderPipeline.execute(
|
const canvasTexture = this.pipelines.renderPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.trailMapA.getTextureView(),
|
this.textures.trailMapA.getTextureView(),
|
||||||
this.textures.sourceMapA.getTextureView()
|
this.textures.sourceMapA.getTextureView()
|
||||||
);
|
);
|
||||||
|
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
|
||||||
this.pipelines.diffusionPipeline.execute(
|
this.pipelines.diffusionPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.sourceMapA.getTextureView(),
|
this.textures.sourceMapA.getTextureView(),
|
||||||
|
|
@ -86,10 +79,10 @@ export class SimulationFrameRenderer {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
canvasReadbackRequest?.afterSubmit();
|
||||||
this.textures.swapSourceMaps();
|
this.textures.swapSourceMaps();
|
||||||
this.textures.swapInfluenceMaps();
|
this.textures.swapInfluenceMaps();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public clearSwipes(): void {
|
public clearSwipes(): void {
|
||||||
this.pipelines.brushPipeline.clearSwipes();
|
this.pipelines.brushPipeline.clearSwipes();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export class SimulationTextures {
|
||||||
public sourceMapB: ResizableTexture;
|
public sourceMapB: ResizableTexture;
|
||||||
public influenceMapA: ResizableTexture;
|
public influenceMapA: ResizableTexture;
|
||||||
public influenceMapB: ResizableTexture;
|
public influenceMapB: ResizableTexture;
|
||||||
|
public eraserMask: ResizableTexture;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
|
|
@ -20,6 +21,7 @@ export class SimulationTextures {
|
||||||
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
|
this.sourceMapB = new ResizableTexture(this.device, canvasSize);
|
||||||
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
|
this.influenceMapA = new ResizableTexture(this.device, canvasSize);
|
||||||
this.influenceMapB = 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 {
|
public resizeTo(nextSize: vec2): vec2 | null {
|
||||||
|
|
@ -35,10 +37,38 @@ export class SimulationTextures {
|
||||||
this.sourceMapB.resize(nextSize);
|
this.sourceMapB.resize(nextSize);
|
||||||
this.influenceMapA.resize(nextSize);
|
this.influenceMapA.resize(nextSize);
|
||||||
this.influenceMapB.resize(nextSize);
|
this.influenceMapB.resize(nextSize);
|
||||||
|
this.eraserMask.resize(nextSize);
|
||||||
|
|
||||||
return scale;
|
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 {
|
public swapSourceMaps(): void {
|
||||||
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
[this.sourceMapA, this.sourceMapB] = [this.sourceMapB, this.sourceMapA];
|
||||||
}
|
}
|
||||||
|
|
@ -54,5 +84,6 @@ export class SimulationTextures {
|
||||||
this.sourceMapB.destroy();
|
this.sourceMapB.destroy();
|
||||||
this.influenceMapA.destroy();
|
this.influenceMapA.destroy();
|
||||||
this.influenceMapB.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/index.ts
149
src/index.ts
|
|
@ -2,6 +2,13 @@ import GameLoop from './game-loop/game-loop';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
|
import {
|
||||||
|
initAnalytics,
|
||||||
|
trackExport,
|
||||||
|
trackSettingsOpen,
|
||||||
|
trackVibeChange,
|
||||||
|
} from './analytics';
|
||||||
|
import { preloadPianoSamples } from './audio/piano-samples';
|
||||||
import { appConfig } from './config';
|
import { appConfig } from './config';
|
||||||
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
import { CollapsiblePanelAnimator } from './page/collapsible-panel-animator';
|
||||||
import { ConfigPane } from './page/config-pane';
|
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 { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
||||||
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
import { DeltaTimeCalculator } from './utils/delta-time-calculator';
|
||||||
import { queryRequiredElement, queryRequiredElements } from './utils/dom';
|
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 { initializeGpu } from './utils/graphics/initialize-gpu';
|
||||||
import { VIBE_PRESETS } from './vibes';
|
import { VIBE_PRESETS } from './vibes';
|
||||||
|
|
||||||
|
|
@ -47,10 +59,11 @@ const formatMirrorSegmentCount = (count: number): string =>
|
||||||
? 'Mirror off'
|
? 'Mirror off'
|
||||||
: `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
|
: `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
|
||||||
|
|
||||||
const renderRuntimeMessage = (
|
type RuntimeUiError = Parameters<
|
||||||
container: HTMLElement,
|
Parameters<typeof ErrorHandler.addOnErrorListener>[0]
|
||||||
error: Parameters<Parameters<typeof ErrorHandler.addOnErrorListener>[0]>[0]
|
>[0];
|
||||||
) => {
|
|
||||||
|
const renderRuntimeMessage = (container: HTMLElement, error: RuntimeUiError) => {
|
||||||
const message = document.createElement('pre');
|
const message = document.createElement('pre');
|
||||||
message.className = error.severity;
|
message.className = error.severity;
|
||||||
message.textContent = error.code ? `${error.message}\n${error.code}` : error.message;
|
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),
|
aside: queryRequiredElement('aside', HTMLElement),
|
||||||
|
toolbarRow: queryRequiredElement('.toolbar-row', HTMLElement),
|
||||||
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
infoButton: queryRequiredElement('button.info', HTMLButtonElement),
|
||||||
infoElement: queryRequiredElement('.info-page', HTMLElement),
|
infoElement: queryRequiredElement('.info-page', HTMLElement),
|
||||||
minimizeFullScreenButton: queryRequiredElement(
|
minimizeFullScreenButton: queryRequiredElement(
|
||||||
|
|
@ -98,7 +134,11 @@ const elements = {
|
||||||
loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement),
|
loadingIndicator: queryRequiredElement('.loading-indicator', HTMLDivElement),
|
||||||
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
|
loadingStatus: queryRequiredElement('.loading-status', HTMLDivElement),
|
||||||
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
|
loadingProgress: queryRequiredElement('.loading-progress', HTMLDivElement),
|
||||||
};
|
});
|
||||||
|
|
||||||
|
type AppElements = ReturnType<typeof queryAppElements>;
|
||||||
|
|
||||||
|
let elements: AppElements;
|
||||||
|
|
||||||
const setLoadingStage = (label: string, ratio: number) => {
|
const setLoadingStage = (label: string, ratio: number) => {
|
||||||
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
|
const percent = Math.round(Math.max(0, Math.min(1, ratio)) * 100);
|
||||||
|
|
@ -188,10 +228,15 @@ const renderMirrorSegmentUi = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
|
let hasRuntimeErrorListener = false;
|
||||||
try {
|
try {
|
||||||
|
initAnalytics();
|
||||||
|
|
||||||
let shouldStop = false;
|
let shouldStop = false;
|
||||||
let game: GameLoop | null = null;
|
let game: GameLoop | null = null;
|
||||||
|
let wasConfigPaneOpen = false;
|
||||||
|
|
||||||
|
elements = queryAppElements();
|
||||||
elements.errorContainer.setAttribute('aria-live', 'assertive');
|
elements.errorContainer.setAttribute('aria-live', 'assertive');
|
||||||
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
ErrorHandler.addOnErrorListener((error, _metadata) => {
|
||||||
renderRuntimeMessage(elements.errorContainer, error);
|
renderRuntimeMessage(elements.errorContainer, error);
|
||||||
|
|
@ -201,6 +246,7 @@ const main = async () => {
|
||||||
shouldStop = true;
|
shouldStop = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
hasRuntimeErrorListener = true;
|
||||||
|
|
||||||
const syncRuntimeUi = () => {
|
const syncRuntimeUi = () => {
|
||||||
renderEraserSizeUi(game);
|
renderEraserSizeUi(game);
|
||||||
|
|
@ -216,7 +262,13 @@ const main = async () => {
|
||||||
const configPane = new ConfigPane({
|
const configPane = new ConfigPane({
|
||||||
settingsButton: elements.settingsButton,
|
settingsButton: elements.settingsButton,
|
||||||
onConfigChange: syncRuntimeUi,
|
onConfigChange: syncRuntimeUi,
|
||||||
onOpenChange: (isOpen) => game?.setStatsOverlayPinned(isOpen),
|
onOpenChange: (isOpen) => {
|
||||||
|
game?.setStatsOverlayPinned(isOpen);
|
||||||
|
if (isOpen && !wasConfigPaneOpen) {
|
||||||
|
trackSettingsOpen();
|
||||||
|
}
|
||||||
|
wasConfigPaneOpen = isOpen;
|
||||||
|
},
|
||||||
onRuntimeChange: syncRuntimeUi,
|
onRuntimeChange: syncRuntimeUi,
|
||||||
onRuntimeReset: () => {
|
onRuntimeReset: () => {
|
||||||
resetSettings();
|
resetSettings();
|
||||||
|
|
@ -224,7 +276,12 @@ const main = async () => {
|
||||||
},
|
},
|
||||||
onRestart: () => game?.destroy(),
|
onRestart: () => game?.destroy(),
|
||||||
onVibeChange: (vibeId) => {
|
onVibeChange: (vibeId) => {
|
||||||
applyVibeSettings(vibeId);
|
const vibe = applyVibeSettings(vibeId);
|
||||||
|
trackVibeChange({
|
||||||
|
vibeId: vibe.id,
|
||||||
|
vibeName: vibe.name,
|
||||||
|
source: 'settings',
|
||||||
|
});
|
||||||
syncRuntimeUi();
|
syncRuntimeUi();
|
||||||
game?.playVibeChangeAudio(false);
|
game?.playVibeChangeAudio(false);
|
||||||
},
|
},
|
||||||
|
|
@ -244,33 +301,65 @@ const main = async () => {
|
||||||
document.body
|
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.restartButton.addEventListener('click', () => game?.destroy());
|
||||||
elements.soundButton.addEventListener('click', (event) => {
|
elements.soundButton.addEventListener('click', () => {
|
||||||
isAudioMuted = !isAudioMuted;
|
isAudioMuted = !isAudioMuted;
|
||||||
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
if (!isAudioMuted) {
|
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 current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||||
const vibe =
|
const vibe =
|
||||||
VIBE_PRESETS[(current + VIBE_PRESETS.length - 1) % VIBE_PRESETS.length];
|
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();
|
configPane.refresh();
|
||||||
syncRuntimeUi();
|
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 current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||||
const vibe = VIBE_PRESETS[(current + 1) % VIBE_PRESETS.length];
|
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();
|
configPane.refresh();
|
||||||
syncRuntimeUi();
|
syncRuntimeUi();
|
||||||
game?.playVibeChangeAudio(event.isTrusted);
|
game?.playVibeChangeAudio(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.swatches.forEach((swatch, index) => {
|
elements.swatches.forEach((swatch, index) => {
|
||||||
|
|
@ -318,6 +407,7 @@ const main = async () => {
|
||||||
elements.export4k.disabled = true;
|
elements.export4k.disabled = true;
|
||||||
try {
|
try {
|
||||||
await game.export4K();
|
await game.export4K();
|
||||||
|
trackExport({ vibeId: activeVibe.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -330,18 +420,32 @@ const main = async () => {
|
||||||
renderMirrorSegmentUi();
|
renderMirrorSegmentUi();
|
||||||
renderAudioUi(game);
|
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);
|
setLoadingStage('Connecting to GPU…', 0.1);
|
||||||
const gpu = await initializeGpu();
|
const gpu = await initializeGpu();
|
||||||
setLoadingStage('Loading fonts…', 0.4);
|
setLoadingStage('Loading fonts…', 0.3);
|
||||||
await fontsReady;
|
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();
|
const deltaTimeCalculator = new DeltaTimeCalculator();
|
||||||
|
|
||||||
let isFirstStart = true;
|
let isFirstStart = true;
|
||||||
while (!shouldStop) {
|
while (!shouldStop) {
|
||||||
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
game = new GameLoop(elements.canvas, gpu, deltaTimeCalculator, {
|
||||||
|
toolbar: elements.toolbarRow,
|
||||||
prompt: elements.prompt,
|
prompt: elements.prompt,
|
||||||
eraserPreview: elements.eraserPreview,
|
eraserPreview: elements.eraserPreview,
|
||||||
exportStatus: elements.exportStatus,
|
exportStatus: elements.exportStatus,
|
||||||
|
|
@ -364,7 +468,12 @@ const main = async () => {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.classList.remove('is-loading');
|
document.body.classList.remove('is-loading');
|
||||||
|
if (hasRuntimeErrorListener) {
|
||||||
ErrorHandler.addException(e);
|
ErrorHandler.addException(e);
|
||||||
|
} else {
|
||||||
|
renderStartupException(e);
|
||||||
|
ErrorHandler.addException(e);
|
||||||
|
}
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,70 @@ struct Counters {
|
||||||
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
@group(1) @binding(2) var<storage, read_write> counters: Counters;
|
||||||
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
|
@group(1) @binding(3) var<storage, read_write> compactedAgents: array<Agent>;
|
||||||
|
|
||||||
|
var<workgroup> workgroupAliveCount: atomic<u32>;
|
||||||
|
var<workgroup> workgroupCompactedOffset: u32;
|
||||||
|
var<workgroup> workgroupCopyCount: u32;
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(
|
fn main(
|
||||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||||
@builtin(num_workgroups) workgroup_count: vec3<u32>
|
@builtin(num_workgroups) workgroup_count: vec3<u32>
|
||||||
) {
|
) {
|
||||||
let id = get_id(global_id, workgroup_count);
|
let id = get_id(global_id, workgroup_count);
|
||||||
|
|
||||||
if id >= settings.agentCount {
|
if local_id.x == 0u {
|
||||||
return;
|
atomicStore(&workgroupAliveCount, 0u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workgroupBarrier();
|
||||||
|
|
||||||
|
var localCompactedIndex = 0u;
|
||||||
|
if id < settings.agentCount {
|
||||||
let agent = agents[id];
|
let agent = agents[id];
|
||||||
if agent.colorIndex < 0.0 {
|
if agent.colorIndex >= 0.0 {
|
||||||
|
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let compactedIndex = atomicAdd(&counters.aliveAgentCount, 1);
|
agents[id] = compactedAgents[id];
|
||||||
compactedAgents[compactedIndex] = agent;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 countingPipeline: GPUComputePipeline;
|
||||||
private readonly resizePipeline: GPUComputePipeline;
|
private readonly resizePipeline: GPUComputePipeline;
|
||||||
private readonly compactionPipeline: GPUComputePipeline;
|
private readonly compactionPipeline: GPUComputePipeline;
|
||||||
|
private readonly compactedAgentsCopyPipeline: GPUComputePipeline;
|
||||||
|
|
||||||
public readonly agentsBuffer: GPUBuffer;
|
public readonly agentsBuffer: GPUBuffer;
|
||||||
private readonly compactedAgentsBuffer: GPUBuffer;
|
private readonly compactedAgentsBuffer: GPUBuffer;
|
||||||
|
|
@ -109,7 +110,7 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
this.compactedAgentsBuffer = this.device.createBuffer({
|
this.compactedAgentsBuffer = this.device.createBuffer({
|
||||||
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
||||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
usage: GPUBufferUsage.STORAGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.countersBuffer = this.device.createBuffer({
|
this.countersBuffer = this.device.createBuffer({
|
||||||
|
|
@ -216,20 +217,32 @@ export class AgentGenerationPipeline {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const compactionModule = smartCompile(
|
||||||
|
device,
|
||||||
|
CommonState.shaderCode,
|
||||||
|
agentSchema,
|
||||||
|
compactionShader
|
||||||
|
);
|
||||||
|
|
||||||
this.compactionPipeline = device.createComputePipeline({
|
this.compactionPipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
|
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
|
||||||
}),
|
}),
|
||||||
compute: {
|
compute: {
|
||||||
module: smartCompile(
|
module: compactionModule,
|
||||||
device,
|
|
||||||
CommonState.shaderCode,
|
|
||||||
agentSchema,
|
|
||||||
compactionShader
|
|
||||||
),
|
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.compactedAgentsCopyPipeline = device.createComputePipeline({
|
||||||
|
layout: device.createPipelineLayout({
|
||||||
|
bindGroupLayouts: [commonState.bindGroupLayout, this.compactionBindGroupLayout],
|
||||||
|
}),
|
||||||
|
compute: {
|
||||||
|
module: compactionModule,
|
||||||
|
entryPoint: 'copyCompactedAgents',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public get maxAgentCount(): number {
|
public get maxAgentCount(): number {
|
||||||
|
|
@ -364,13 +377,19 @@ export class AgentGenerationPipeline {
|
||||||
);
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
commandEncoder.copyBufferToBuffer(
|
const copyPassEncoder = commandEncoder.beginComputePass();
|
||||||
this.compactedAgentsBuffer,
|
copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline);
|
||||||
0,
|
this.commonState.execute(copyPassEncoder);
|
||||||
this.agentsBuffer,
|
copyPassEncoder.setBindGroup(1, this.compactionBindGroup);
|
||||||
0,
|
copyPassEncoder.dispatchWorkgroups(
|
||||||
agentCount * AGENT_SIZE_IN_BYTES
|
...getWorkgroupCounts(
|
||||||
|
this.device,
|
||||||
|
agentCount,
|
||||||
|
AgentGenerationPipeline.WORKGROUP_SIZE
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
copyPassEncoder.end();
|
||||||
|
|
||||||
commandEncoder.copyBufferToBuffer(
|
commandEncoder.copyBufferToBuffer(
|
||||||
this.countersBuffer,
|
this.countersBuffer,
|
||||||
0,
|
0,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,27 @@ describe('Agent TS/WGSL contract', () => {
|
||||||
expect(agentSchema).toContain('workgroup_count.x * 64');
|
expect(agentSchema).toContain('workgroup_count.x * 64');
|
||||||
expect(agentSchema).toContain('workgroup_count.x * workgroup_count.y * 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('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];
|
var agent = agents[id];
|
||||||
if agent.colorIndex < 0.0 {
|
if agent.colorIndex < 0.0 || agent.colorIndex >= 2.5 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,12 +48,7 @@ fn main(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let random = textureSampleLevel(
|
let random = random_vec4(id, state.time);
|
||||||
noise,
|
|
||||||
noiseSampler,
|
|
||||||
fract(vec2(f32(id) * 0.7548777, state.time * 0.00017 + f32(id) * 0.5698403)),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
|
let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
|
||||||
let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
|
let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
|
||||||
|
|
@ -154,8 +149,11 @@ fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
if colorIndex < 1.5 {
|
if colorIndex < 1.5 {
|
||||||
return vec3<f32>(0, 1, 0);
|
return vec3<f32>(0, 1, 0);
|
||||||
}
|
}
|
||||||
|
if colorIndex < 2.5 {
|
||||||
return vec3<f32>(0, 0, 1);
|
return vec3<f32>(0, 0, 1);
|
||||||
}
|
}
|
||||||
|
return vec3<f32>(0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
if colorIndex < 0.5 {
|
if colorIndex < 0.5 {
|
||||||
|
|
@ -172,13 +170,37 @@ fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
settings.color2ToColor3
|
settings.color2ToColor3
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if colorIndex < 2.5 {
|
||||||
return vec3<f32>(
|
return vec3<f32>(
|
||||||
settings.color3ToColor1,
|
settings.color3ToColor1,
|
||||||
settings.color3ToColor2,
|
settings.color3ToColor2,
|
||||||
settings.color3ToColor3
|
settings.color3ToColor3
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return vec3<f32>(0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
||||||
return atan2(sin(targetAngle - sourceAngle), cos(targetAngle - sourceAngle));
|
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 bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPURenderPipeline;
|
||||||
|
private readonly multiTargetPipeline: GPURenderPipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(BrushPipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
|
|
@ -57,68 +58,16 @@ export class BrushPipeline {
|
||||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||||
layout: device.createPipelineLayout({
|
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 2);
|
||||||
}),
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
this.uniforms = this.device.createBuffer({
|
||||||
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: BrushPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroup = this.bindGroup = this.device.createBindGroup({
|
this.bindGroup = this.device.createBindGroup({
|
||||||
layout: this.bindGroupLayout,
|
layout: this.bindGroupLayout,
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
|
|
@ -315,23 +264,40 @@ export class BrushPipeline {
|
||||||
return offset;
|
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) {
|
if (this.lineCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||||
colorAttachments: [
|
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
|
||||||
{
|
view,
|
||||||
view: trailMapOut,
|
|
||||||
loadOp: 'load',
|
loadOp: 'load',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
},
|
})),
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(pipeline);
|
||||||
this.commonState.execute(passEncoder);
|
this.commonState.execute(passEncoder);
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
passEncoder.setBindGroup(1, this.bindGroup);
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
||||||
|
|
@ -344,6 +310,73 @@ export class BrushPipeline {
|
||||||
this.uniforms.destroy();
|
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 {
|
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
||||||
return {
|
return {
|
||||||
entries: [
|
entries: [
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ struct VertexOutput {
|
||||||
@location(2) end: vec2<f32>
|
@location(2) end: vec2<f32>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BrushTargets {
|
||||||
|
@location(0) source: vec4<f32>,
|
||||||
|
@location(1) influence: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vertex(
|
fn vertex(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
|
|
@ -32,6 +37,32 @@ fn fragment(
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) end: vec2<f32>
|
||||||
) -> @location(0) vec4<f32> {
|
) -> @location(0) vec4<f32> {
|
||||||
|
let strength = brushStrength(screenPosition, start, end);
|
||||||
|
|
||||||
|
if(strength < 0.02) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 distance = distanceFromLine(screenPosition, start, end);
|
||||||
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
|
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
|
||||||
let grainNoise = textureSample(
|
let grainNoise = textureSample(
|
||||||
|
|
@ -42,12 +73,10 @@ fn fragment(
|
||||||
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
|
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
|
||||||
let feather = max(1.0, settings.brushSize * 0.22);
|
let feather = max(1.0, settings.brushSize * 0.22);
|
||||||
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
|
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
|
||||||
let strength = edge * mix(0.45, 1.0, grainNoise);
|
return edge * mix(0.45, 1.0, grainNoise);
|
||||||
|
|
||||||
if(strength < 0.02) {
|
|
||||||
discard;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn brushOutput(strength: f32) -> vec4<f32> {
|
||||||
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
|
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,28 @@ struct Settings {
|
||||||
|
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: 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>;
|
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
||||||
|
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||||
var current = textureSample(trailMap, Sampler, uv);
|
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 += (
|
current += (
|
||||||
propagate(uv, vec2(-1.0, -1.0), current)
|
propagate(pixel, textureSize, vec2<i32>(-1, -1), current, trailWeight, brushWeight)
|
||||||
+ propagate(uv, vec2(-1.0, 1.0), current)
|
+ propagate(pixel, textureSize, vec2<i32>(-1, 1), current, trailWeight, brushWeight)
|
||||||
+ propagate(uv, vec2(1.0, -1.0), current)
|
+ propagate(pixel, textureSize, vec2<i32>(1, -1), current, trailWeight, brushWeight)
|
||||||
+ propagate(uv, vec2(1.0, 1.0), current)
|
+ propagate(pixel, textureSize, vec2<i32>(1, 1), current, trailWeight, brushWeight)
|
||||||
|
|
||||||
+ propagate(uv, vec2(-1.0, 0.0), current)
|
+ propagate(pixel, textureSize, vec2<i32>(-1, 0), current, trailWeight, brushWeight)
|
||||||
+ propagate(uv, vec2(0.0, -1.0), current)
|
+ propagate(pixel, textureSize, vec2<i32>(0, -1), current, trailWeight, brushWeight)
|
||||||
+ propagate(uv, vec2(1.0, 0.0), current)
|
+ propagate(pixel, textureSize, vec2<i32>(1, 0), current, trailWeight, brushWeight)
|
||||||
+ propagate(uv, vec2(0.0, 1.0), current)
|
+ propagate(pixel, textureSize, vec2<i32>(0, 1), current, trailWeight, brushWeight)
|
||||||
) / 8;
|
) / 8;
|
||||||
|
|
||||||
let decayed = clamp(vec4(
|
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> {
|
fn propagate(
|
||||||
let neighbour = textureSample(trailMap, Sampler, uv + offset / state.size);
|
pixel: vec2<i32>,
|
||||||
var random = textureSample(noise, noiseSampler, uv + offset / state.size * 0.5).r;
|
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));
|
let difference = clamp(neighbour - currentColor, vec4(0), vec4(1));
|
||||||
|
|
||||||
return vec4(
|
return vec4(
|
||||||
vec3(length(neighbour.rgb) * pow(random, settings.inverseDiffusionRateTrails)),
|
vec3(length(neighbour.rgb) * trailWeight),
|
||||||
length(neighbour.a) * pow(random, settings.inverseDiffusionRateBrush)
|
neighbour.a * brushWeight
|
||||||
) * difference;
|
) * 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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import shader from './diffuse.wgsl?raw';
|
||||||
import {
|
import {
|
||||||
getSafeInverseDiffusionRate,
|
getSafeInverseDiffusionRate,
|
||||||
setDiffusionUniformValues,
|
setDiffusionUniformValues,
|
||||||
|
|
@ -26,4 +27,11 @@ describe('diffusion pipeline parameters', () => {
|
||||||
expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
|
expect(getSafeInverseDiffusionRate(2)).toBe(0.5);
|
||||||
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
|
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(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
DiffusionPipeline.UNIFORM_COUNT
|
DiffusionPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly sampler: GPUSampler;
|
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
private readonly vertexBuffer: GPUBuffer;
|
||||||
|
|
||||||
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
|
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||||
|
|
@ -86,11 +85,6 @@ export class DiffusionPipeline {
|
||||||
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sampler = this.device.createSampler({
|
|
||||||
magFilter: 'linear',
|
|
||||||
minFilter: 'linear',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({
|
public setParameters({
|
||||||
|
|
@ -155,10 +149,6 @@ export class DiffusionPipeline {
|
||||||
buffer: this.uniforms,
|
buffer: this.uniforms,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: this.sampler,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 2,
|
||||||
resource: trailMapIn,
|
resource: trailMapIn,
|
||||||
|
|
@ -185,13 +175,6 @@ export class DiffusionPipeline {
|
||||||
type: 'uniform',
|
type: 'uniform',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
sampler: {
|
|
||||||
type: 'filtering',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 2,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
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 { CommonState } from '../common-state/common-state';
|
||||||
import shader from './eraser-agent.wgsl?raw';
|
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 {
|
export class EraserAgentPipeline {
|
||||||
private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize;
|
private static readonly WORKGROUP_SIZE = appConfig.pipelines.eraser.workgroupSize;
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
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 bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
EraserAgentPipeline.UNIFORM_COUNT
|
EraserAgentPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly segmentsBuffer: GPUBuffer;
|
private readonly bindGroupsByMask = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||||
private readonly segmentUploadData = new Float32Array(
|
|
||||||
EraserAgentPipeline.MAX_SEGMENT_COUNT * EraserAgentPipeline.SEGMENT_FLOAT_COUNT
|
|
||||||
);
|
|
||||||
|
|
||||||
private linePoints: Array<vec2> = [];
|
private linePoints: Array<vec2> = [];
|
||||||
private lineSegments: Array<LineSegment> = [];
|
private pendingSegmentCount = 0;
|
||||||
private actualSegments: Array<LineSegment> = [];
|
private activeSegmentCount = 0;
|
||||||
private segmentCount = 0;
|
|
||||||
private agentCount = 0;
|
private agentCount = 0;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|
@ -71,8 +53,8 @@ export class EraserAgentPipeline {
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 2,
|
||||||
visibility: GPUShaderStage.COMPUTE,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
buffer: {
|
texture: {
|
||||||
type: 'read-only-storage',
|
sampleType: 'float',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -83,15 +65,93 @@ export class EraserAgentPipeline {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.segmentsBuffer = this.device.createBuffer({
|
this.pipeline = device.createComputePipeline({
|
||||||
size:
|
layout: device.createPipelineLayout({
|
||||||
EraserAgentPipeline.MAX_SEGMENT_COUNT *
|
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||||
EraserAgentPipeline.SEGMENT_FLOAT_COUNT *
|
}),
|
||||||
Float32Array.BYTES_PER_ELEMENT,
|
compute: {
|
||||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
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,
|
layout: this.bindGroupLayout,
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
|
|
@ -108,137 +168,12 @@ export class EraserAgentPipeline {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 2,
|
||||||
resource: {
|
resource: eraserMask,
|
||||||
buffer: this.segmentsBuffer,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.pipeline = device.createComputePipeline({
|
this.bindGroupsByMask.set(eraserMask, bindGroup);
|
||||||
layout: device.createPipelineLayout({
|
return bindGroup;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
eraserRadius: f32,
|
|
||||||
segmentCount: f32,
|
|
||||||
agentCount: 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(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)
|
@compute @workgroup_size(64)
|
||||||
fn main(
|
fn main(
|
||||||
|
|
@ -26,38 +24,16 @@ fn main(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var i = 0u; i < MAX_SEGMENT_COUNT; i++) {
|
let maskSize = vec2<i32>(textureDimensions(eraserMask));
|
||||||
if i >= u32(settings.segmentCount) {
|
let maskPosition = clamp(
|
||||||
break;
|
vec2<i32>(agent.position),
|
||||||
}
|
vec2<i32>(0, 0),
|
||||||
|
maskSize - vec2<i32>(1, 1)
|
||||||
let segment = segments[i];
|
|
||||||
let distanceSquared = distanceSquaredFromLine(
|
|
||||||
agent.position,
|
|
||||||
segment.xy,
|
|
||||||
segment.zw
|
|
||||||
);
|
);
|
||||||
|
let maskSample = textureLoad(eraserMask, maskPosition, 0);
|
||||||
|
|
||||||
if distanceSquared <= settings.eraserRadiusSquared {
|
if maskSample.a < 0.5 {
|
||||||
agent.position = vec2<f32>(-1.0, -1.0);
|
|
||||||
agent.targetPosition = vec2<f32>(-1.0, -1.0);
|
|
||||||
agent.colorIndex = -1.0;
|
agent.colorIndex = -1.0;
|
||||||
agents[id] = agent;
|
agents[id] = agent;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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 bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPURenderPipeline;
|
||||||
|
private readonly multiTargetPipeline: GPURenderPipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(EraserTexturePipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
|
|
@ -65,49 +66,9 @@ export class EraserTexturePipeline {
|
||||||
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||||
layout: device.createPipelineLayout({
|
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
|
||||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 3);
|
||||||
}),
|
|
||||||
vertex: {
|
|
||||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
|
||||||
entryPoint: 'vertex',
|
|
||||||
buffers: [
|
|
||||||
{
|
|
||||||
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
shaderLocation: 0,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shaderLocation: 1,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shaderLocation: 2,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fragment: {
|
|
||||||
module: smartCompile(device, CommonState.shaderCode, shader),
|
|
||||||
entryPoint: 'fragment',
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
format: 'rgba16float',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
primitive: {
|
|
||||||
topology: 'triangle-list',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
this.uniforms = this.device.createBuffer({
|
||||||
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -194,22 +155,41 @@ export class EraserTexturePipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
|
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) {
|
if (this.lineCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||||
colorAttachments: [
|
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
|
||||||
{
|
view,
|
||||||
view: textureOut,
|
|
||||||
loadOp: 'load',
|
loadOp: 'load',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
},
|
})),
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(pipeline);
|
||||||
this.commonState.execute(passEncoder);
|
this.commonState.execute(passEncoder);
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
passEncoder.setBindGroup(1, this.bindGroup);
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
||||||
|
|
@ -222,6 +202,54 @@ export class EraserTexturePipeline {
|
||||||
this.uniforms.destroy();
|
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> {
|
private static subsampleSegments(segments: Array<LineSegment>): Array<LineSegment> {
|
||||||
if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
|
if (segments.length <= EraserTexturePipeline.MAX_LINE_COUNT) {
|
||||||
return segments;
|
return segments;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ struct VertexOutput {
|
||||||
@location(2) end: vec2<f32>
|
@location(2) end: vec2<f32>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct EraserTextureTargets {
|
||||||
|
@location(0) source: vec4<f32>,
|
||||||
|
@location(1) influence: vec4<f32>,
|
||||||
|
@location(2) trail: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vertex(
|
fn vertex(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
|
|
@ -31,13 +37,35 @@ fn fragment(
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) end: vec2<f32>
|
||||||
) -> @location(0) vec4<f32> {
|
) -> @location(0) vec4<f32> {
|
||||||
if distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared {
|
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||||
discard;
|
discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
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 {
|
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
||||||
let pa = position - start;
|
let pa = position - start;
|
||||||
let direction = end - start;
|
let direction = end - start;
|
||||||
|
|
|
||||||
|
|
@ -121,13 +121,14 @@ export class RenderPipeline {
|
||||||
commandEncoder: GPUCommandEncoder,
|
commandEncoder: GPUCommandEncoder,
|
||||||
colorTexture: GPUTextureView,
|
colorTexture: GPUTextureView,
|
||||||
sourceTexture: GPUTextureView
|
sourceTexture: GPUTextureView
|
||||||
) {
|
): GPUTexture {
|
||||||
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
|
const bindGroup = this.getBindGroup(colorTexture, sourceTexture);
|
||||||
|
const canvasTexture = this.context.getCurrentTexture();
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||||
colorAttachments: [
|
colorAttachments: [
|
||||||
{
|
{
|
||||||
view: this.context.getCurrentTexture().createView(),
|
view: canvasTexture.createView(),
|
||||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||||
loadOp: 'clear',
|
loadOp: 'clear',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
|
|
@ -141,6 +142,8 @@ export class RenderPipeline {
|
||||||
passEncoder.setBindGroup(1, bindGroup);
|
passEncoder.setBindGroup(1, bindGroup);
|
||||||
passEncoder.draw(4, 1);
|
passEncoder.draw(4, 1);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
|
return canvasTexture;
|
||||||
}
|
}
|
||||||
|
|
||||||
public executeToView(
|
public executeToView(
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
pipeline: EraserAgentPipeline,
|
pipeline: EraserAgentPipeline,
|
||||||
source: eraserAgentShader,
|
source: eraserAgentShader,
|
||||||
structName: 'Settings',
|
structName: 'Settings',
|
||||||
fieldNames: ['eraserRadius', 'segmentCount', 'agentCount', 'eraserRadiusSquared'],
|
fieldNames: ['agentCount', 'padding0', 'padding1', 'padding2'],
|
||||||
});
|
});
|
||||||
expectStructUniformLayout({
|
expectStructUniformLayout({
|
||||||
pipeline: EraserTexturePipeline,
|
pipeline: EraserTexturePipeline,
|
||||||
|
|
@ -199,4 +199,10 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
getUniformCount(AgentGenerationPipeline)
|
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 {
|
export enum ErrorCode {
|
||||||
UNKNOWN = 'unknown',
|
|
||||||
WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
|
WEBGPU_INSECURE_CONTEXT = 'webgpu-insecure-context',
|
||||||
WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
|
WEBGPU_UNSUPPORTED = 'webgpu-unsupported',
|
||||||
WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',
|
WEBGPU_ADAPTER_UNAVAILABLE = 'webgpu-adapter-unavailable',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export const initializeContext = ({
|
||||||
context.configure({
|
context.configure({
|
||||||
device: device,
|
device: device,
|
||||||
format: gpu.getPreferredCanvasFormat(),
|
format: gpu.getPreferredCanvasFormat(),
|
||||||
|
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||||
alphaMode: 'premultiplied',
|
alphaMode: 'premultiplied',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ export class ResizableTexture {
|
||||||
return this.textureView;
|
return this.textureView;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getTexture(): GPUTexture {
|
||||||
|
return this.texture;
|
||||||
|
}
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.texture.destroy();
|
this.texture.destroy();
|
||||||
this.copyPipeline.destroy();
|
this.copyPipeline.destroy();
|
||||||
|
|
@ -61,7 +65,9 @@ export class ResizableTexture {
|
||||||
usage:
|
usage:
|
||||||
GPUTextureUsage.STORAGE_BINDING |
|
GPUTextureUsage.STORAGE_BINDING |
|
||||||
GPUTextureUsage.TEXTURE_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(new Set(vibeIds).size).toBe(vibeIds.length);
|
||||||
expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true);
|
expect(vibeIds.every((id) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))).toBe(true);
|
||||||
expect(audioIds.slice().sort()).toEqual(vibeIds.slice().sort());
|
expect(audioIds.slice().sort()).toEqual(vibeIds.slice().sort());
|
||||||
expect(vibeIds).toContain(gardenAudioConfig.fallbackVibeId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps each vibe palette and audio profile complete', () => {
|
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', () => {
|
it('uses discrete color interaction matrices for every vibe', () => {
|
||||||
VIBE_PRESETS.forEach((vibe) => {
|
VIBE_PRESETS.forEach((vibe) => {
|
||||||
colorInteractionKeys.forEach((key) => {
|
colorInteractionKeys.forEach((key) => {
|
||||||
|
|
|
||||||
18
src/vibes.ts
18
src/vibes.ts
|
|
@ -1,17 +1,21 @@
|
||||||
import { appConfig, type VibePreset } from './config';
|
import { appConfig, type VibePreset } from './config';
|
||||||
import { readBrowserStorage } from './utils/browser-storage';
|
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;
|
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] => {
|
export const hexToRgb = (hex: string): [number, number, number] => {
|
||||||
const value = hex.replace('#', '');
|
const match = HEX_COLOR_PATTERN.exec(hex);
|
||||||
return [
|
if (!match?.groups) {
|
||||||
parseInt(value.slice(0, 2), 16) / 255,
|
return [0, 0, 0];
|
||||||
parseInt(value.slice(2, 4), 16) / 255,
|
}
|
||||||
parseInt(value.slice(4, 6), 16) / 255,
|
|
||||||
];
|
const { red, green, blue } = match.groups;
|
||||||
|
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInitialVibe = (): VibePreset => {
|
export const getInitialVibe = (): VibePreset => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue