not sure
This commit is contained in:
parent
560398fefb
commit
80ed37298b
73 changed files with 3619 additions and 1295 deletions
|
|
@ -86,6 +86,15 @@ test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
||||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
const soundButton = page.locator('button.sound');
|
const soundButton = page.locator('button.sound');
|
||||||
|
const volumeSlider = page.getByLabel('Master volume');
|
||||||
|
await expect(volumeSlider).toHaveValue('0.42');
|
||||||
|
await volumeSlider.evaluate((input) => {
|
||||||
|
const slider = input as HTMLInputElement;
|
||||||
|
slider.value = '0.25';
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect(volumeSlider).toHaveValue('0.25');
|
||||||
|
await expect(volumeSlider).toHaveAttribute('aria-valuetext', '25%');
|
||||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
||||||
await soundButton.click();
|
await soundButton.click();
|
||||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
@ -93,6 +102,11 @@ test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||||
await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true');
|
await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.getByLabel('Master volume')).toHaveValue('0.25');
|
||||||
|
await expect(page.getByLabel('Master volume')).toHaveAttribute(
|
||||||
|
'aria-valuetext',
|
||||||
|
'Muted, 25%'
|
||||||
|
);
|
||||||
|
|
||||||
const initialSwatchColor = await getFirstSwatchColor(page);
|
const initialSwatchColor = await getFirstSwatchColor(page);
|
||||||
const initialBackground = await getGardenBackground(page);
|
const initialBackground = await getGardenBackground(page);
|
||||||
|
|
|
||||||
|
|
@ -167,12 +167,17 @@
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
title="Show config overlay"
|
title="Show config overlay"
|
||||||
></button>
|
></button>
|
||||||
|
<div class="audio-control">
|
||||||
<button
|
<button
|
||||||
class="sound"
|
class="sound"
|
||||||
aria-label="Mute audio"
|
aria-label="Mute audio"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
title="Mute audio"
|
title="Mute audio"
|
||||||
></button>
|
></button>
|
||||||
|
<label class="volume-control" title="Master volume">
|
||||||
|
<input class="volume-slider" type="range" aria-label="Master volume" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="export-4k"
|
class="export-4k"
|
||||||
aria-label="Download 4K upscale image"
|
aria-label="Download 4K upscale image"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
import type { PianoNoteRole } from './garden-audio-types';
|
||||||
|
|
||||||
type GardenAudioChordQuality = 'major' | 'minor';
|
type GardenAudioChordQuality = 'major' | 'minor';
|
||||||
|
|
||||||
|
|
@ -27,7 +28,154 @@ export interface GardenAudioStylePool extends GardenAudioRegister {
|
||||||
interface GardenAudioGenerativePianoConfig {
|
interface GardenAudioGenerativePianoConfig {
|
||||||
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
|
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
|
||||||
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
||||||
|
chordVoicings: {
|
||||||
|
majorOpen: Array<number>;
|
||||||
|
minorOpen: Array<number>;
|
||||||
|
majorClosed: Array<number>;
|
||||||
|
minorClosed: Array<number>;
|
||||||
|
};
|
||||||
|
vibeChangeStinger: {
|
||||||
|
velocities: [number, number, number];
|
||||||
|
pans: [number, number, number];
|
||||||
|
delaySends: [number, number, number];
|
||||||
|
lowpassExpression: number;
|
||||||
|
};
|
||||||
|
highActivityExtra: {
|
||||||
|
barOffset: number;
|
||||||
|
expressionMultiplier: number;
|
||||||
|
};
|
||||||
|
padChord: {
|
||||||
|
velocities: [number, number, number];
|
||||||
|
expressionVelocityWeight: number;
|
||||||
|
delaySend: number;
|
||||||
|
lowpassExpressionWeight: number;
|
||||||
|
};
|
||||||
|
supportNote: {
|
||||||
|
velocityBase: number;
|
||||||
|
velocityExpressionWeight: number;
|
||||||
|
durationBaseSeconds: number;
|
||||||
|
durationExpressionSeconds: number;
|
||||||
|
delaySendBase: number;
|
||||||
|
delaySendExpressionWeight: number;
|
||||||
|
lowpassExpressionWeight: number;
|
||||||
|
expressionThreshold: number;
|
||||||
|
offsetsByStyle: [Array<number>, Array<number>, Array<number>];
|
||||||
|
};
|
||||||
|
textureNote: {
|
||||||
|
velocityBase: number;
|
||||||
|
velocityExpressionWeight: number;
|
||||||
|
durationBaseSeconds: number;
|
||||||
|
durationExpressionSeconds: number;
|
||||||
|
delaySendBase: number;
|
||||||
|
delaySendExpressionWeight: number;
|
||||||
|
idleExpressionThreshold: number;
|
||||||
|
mediumExpressionThreshold: number;
|
||||||
|
intenseSpacing: number;
|
||||||
|
idlePhase: number;
|
||||||
|
};
|
||||||
|
gestureAccent: {
|
||||||
|
rotationStrengthMultiplier: number;
|
||||||
|
quantizeStepLookahead: number;
|
||||||
|
velocityBase: number;
|
||||||
|
velocityStrengthWeight: number;
|
||||||
|
durationBaseSeconds: number;
|
||||||
|
durationStrengthSeconds: number;
|
||||||
|
delaySend: number;
|
||||||
|
};
|
||||||
|
touchNote: {
|
||||||
|
registerBiasManiaAmount: number;
|
||||||
|
velocityBase: number;
|
||||||
|
velocityStrengthWeight: number;
|
||||||
|
durationBaseSeconds: number;
|
||||||
|
durationStrengthSeconds: number;
|
||||||
|
delaySend: number;
|
||||||
|
lowpassBaseExpression: number;
|
||||||
|
lowpassStrengthWeight: number;
|
||||||
|
};
|
||||||
|
brushPhrase: {
|
||||||
|
initialMotifOffset: number;
|
||||||
|
energyRetain: number;
|
||||||
|
maniaRetain: number;
|
||||||
|
energyDecaySeconds: number;
|
||||||
|
maniaDecaySeconds: number;
|
||||||
|
fadeMinimumLifetimeSeconds: number;
|
||||||
|
layerIntensityBase: number;
|
||||||
|
layerIntensityManiaWeight: number;
|
||||||
|
frameActivityWeight: number;
|
||||||
|
frameManiaWeight: number;
|
||||||
|
};
|
||||||
|
brushStream: {
|
||||||
|
inferredManiaThreshold: number;
|
||||||
|
inferredManiaRange: number;
|
||||||
|
registerManiaShift: number;
|
||||||
|
chordToneEverySteps: number;
|
||||||
|
durationBaseSeconds: number;
|
||||||
|
durationIntensitySeconds: number;
|
||||||
|
durationManiaSeconds: number;
|
||||||
|
durationMinSeconds: number;
|
||||||
|
durationMaxSeconds: number;
|
||||||
|
delaySendBase: number;
|
||||||
|
delaySendIntensityWeight: number;
|
||||||
|
delaySendManiaWeight: number;
|
||||||
|
delaySendMin: number;
|
||||||
|
delaySendMax: number;
|
||||||
|
velocityBase: number;
|
||||||
|
velocityIntensityWeight: number;
|
||||||
|
lowpassBaseExpression: number;
|
||||||
|
lowpassIntensityWeight: number;
|
||||||
|
lowpassManiaWeight: number;
|
||||||
|
manicThreshold: number;
|
||||||
|
intenseThreshold: number;
|
||||||
|
activeThreshold: number;
|
||||||
|
};
|
||||||
|
brushStreamEcho: {
|
||||||
|
maniaThreshold: number;
|
||||||
|
stepModulo: number;
|
||||||
|
stepRemainder: number;
|
||||||
|
intensityThreshold: number;
|
||||||
|
octaveSemitones: number;
|
||||||
|
maxMidi: number;
|
||||||
|
velocityBase: number;
|
||||||
|
velocityIntensityWeight: number;
|
||||||
|
durationMinSeconds: number;
|
||||||
|
durationScale: number;
|
||||||
|
panScale: number;
|
||||||
|
delaySendMin: number;
|
||||||
|
delaySendScale: number;
|
||||||
|
lowpassBaseExpression: number;
|
||||||
|
lowpassManiaWeight: number;
|
||||||
|
};
|
||||||
|
brushMotif: {
|
||||||
|
highThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
highOffset: number;
|
||||||
|
mediumOffset: number;
|
||||||
|
lowOffset: number;
|
||||||
|
minOffset: number;
|
||||||
|
maxOffset: number;
|
||||||
|
};
|
||||||
|
registerBias: {
|
||||||
|
maniaShiftSemitones: number;
|
||||||
|
midiMin: number;
|
||||||
|
midiMaxForMin: number;
|
||||||
|
minimumSpan: number;
|
||||||
|
midiMax: number;
|
||||||
|
};
|
||||||
|
candidateOctaveSearch: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
styleRotationMinSeconds: number;
|
||||||
|
stylePanOffsetScale: number;
|
||||||
|
lowpass: {
|
||||||
|
midiBase: number;
|
||||||
|
midiRange: number;
|
||||||
|
midiLiftHz: number;
|
||||||
|
expressionBase: number;
|
||||||
|
expressionWeight: number;
|
||||||
|
};
|
||||||
styleRotationSeconds: number;
|
styleRotationSeconds: number;
|
||||||
|
styleRotationBars: number;
|
||||||
chordBars: number;
|
chordBars: number;
|
||||||
supportBarSpacing: number;
|
supportBarSpacing: number;
|
||||||
supportBarOffset: number;
|
supportBarOffset: number;
|
||||||
|
|
@ -38,13 +186,16 @@ interface GardenAudioGenerativePianoConfig {
|
||||||
highActivityExtraThreshold: number;
|
highActivityExtraThreshold: number;
|
||||||
noteScorePreferenceWeight: number;
|
noteScorePreferenceWeight: number;
|
||||||
noteScoreRegisterWeight: number;
|
noteScoreRegisterWeight: number;
|
||||||
|
noteScoreChordToneWeight: number;
|
||||||
noteScoreRepeatPenalty: number;
|
noteScoreRepeatPenalty: number;
|
||||||
gestureAccentMinIntervalSeconds: number;
|
gestureAccentMinIntervalSeconds: number;
|
||||||
strokeAccentMinIntervalSeconds: number;
|
strokeAccentMinIntervalSeconds: number;
|
||||||
|
strokeAccentMinSteps: number;
|
||||||
strokeAccentThreshold: number;
|
strokeAccentThreshold: number;
|
||||||
stingerDurationSeconds: number;
|
stingerDurationSeconds: number;
|
||||||
stingerSpacingSeconds: number;
|
stingerSpacingSeconds: number;
|
||||||
maxBrushPhraseLayers: number;
|
maxBrushPhraseLayers: number;
|
||||||
|
maxBrushStreamNotesPerBar: number;
|
||||||
brushLayerBaseSeconds: number;
|
brushLayerBaseSeconds: number;
|
||||||
brushLayerEnergySeconds: number;
|
brushLayerEnergySeconds: number;
|
||||||
brushLayerMinIntensity: number;
|
brushLayerMinIntensity: number;
|
||||||
|
|
@ -80,10 +231,15 @@ export interface GardenAudioConfig {
|
||||||
feedbackMin: number;
|
feedbackMin: number;
|
||||||
outputActivityWeight: number;
|
outputActivityWeight: number;
|
||||||
outputBase: number;
|
outputBase: number;
|
||||||
|
outputActivityDuck: number;
|
||||||
timeRampSeconds: number;
|
timeRampSeconds: number;
|
||||||
|
feedbackHighPassHz: number;
|
||||||
|
feedbackLowPassHz: number;
|
||||||
|
returnLowPassHz: number;
|
||||||
};
|
};
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: number;
|
maxVoices: number;
|
||||||
|
filterType: BiquadFilterType;
|
||||||
gain: number;
|
gain: number;
|
||||||
sustainSeconds: number;
|
sustainSeconds: number;
|
||||||
sustainLevel: number;
|
sustainLevel: number;
|
||||||
|
|
@ -103,6 +259,12 @@ export interface GardenAudioConfig {
|
||||||
tailStopExtraSeconds: number;
|
tailStopExtraSeconds: number;
|
||||||
voiceStealFadeSeconds: number;
|
voiceStealFadeSeconds: number;
|
||||||
voiceStealStopSeconds: number;
|
voiceStealStopSeconds: number;
|
||||||
|
sampleBaseUrl: string;
|
||||||
|
preloadDecode: {
|
||||||
|
channels: number;
|
||||||
|
frames: number;
|
||||||
|
sampleRateHz: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: number;
|
bpm: number;
|
||||||
|
|
@ -117,6 +279,8 @@ export interface GardenAudioConfig {
|
||||||
filterMinHz: number;
|
filterMinHz: number;
|
||||||
filterMaxHz: number;
|
filterMaxHz: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
|
pan: number;
|
||||||
|
pianoActivity: number;
|
||||||
};
|
};
|
||||||
energy: {
|
energy: {
|
||||||
attackSeconds: number;
|
attackSeconds: number;
|
||||||
|
|
@ -134,12 +298,37 @@ export interface GardenAudioConfig {
|
||||||
noiseMin: number;
|
noiseMin: number;
|
||||||
unlockTickFrequencyHz: number;
|
unlockTickFrequencyHz: number;
|
||||||
unlockTickSeconds: number;
|
unlockTickSeconds: number;
|
||||||
|
unlockTickType: OscillatorType;
|
||||||
|
latencyHint: AudioContextLatencyCategory;
|
||||||
|
outputFilterType: BiquadFilterType;
|
||||||
|
noiseBufferChannels: number;
|
||||||
|
noiseBufferDurationSeconds: number;
|
||||||
|
pianoBusGains: Record<PianoNoteRole, number>;
|
||||||
|
pianoBusActivityDucking: Record<PianoNoteRole, number>;
|
||||||
|
noiseBusGain: number;
|
||||||
|
compressor: {
|
||||||
|
thresholdDb: number;
|
||||||
|
kneeDb: number;
|
||||||
|
ratio: number;
|
||||||
|
attackSeconds: number;
|
||||||
|
releaseSeconds: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
input: {
|
input: {
|
||||||
distanceWindowForFullActivityPixels: number;
|
distanceWindowForFullActivityPixels: number;
|
||||||
distanceWindowSeconds: number;
|
distanceWindowSeconds: number;
|
||||||
fallbackFrameSeconds: number;
|
fallbackFrameSeconds: number;
|
||||||
|
fullActivitySpeed: number;
|
||||||
|
activityNoiseFloorSpeed: number;
|
||||||
|
activityCurve: number;
|
||||||
|
activitySoftCeiling: number;
|
||||||
|
activityAttackSeconds: number;
|
||||||
|
activityReleaseSeconds: number;
|
||||||
|
minAudibleDistance: number;
|
||||||
manicActivityThreshold: number;
|
manicActivityThreshold: number;
|
||||||
|
manicReleaseThreshold: number;
|
||||||
|
maniaSmoothingSeconds: number;
|
||||||
|
minElapsedSeconds: number;
|
||||||
};
|
};
|
||||||
muteGain: number;
|
muteGain: number;
|
||||||
muteRampSeconds: number;
|
muteRampSeconds: number;
|
||||||
|
|
@ -149,6 +338,7 @@ export interface GardenAudioConfig {
|
||||||
offsetRandomSeconds: number;
|
offsetRandomSeconds: number;
|
||||||
scheduleAheadSeconds: number;
|
scheduleAheadSeconds: number;
|
||||||
silentGain: number;
|
silentGain: number;
|
||||||
|
filterType: BiquadFilterType;
|
||||||
};
|
};
|
||||||
startDelaySeconds: number;
|
startDelaySeconds: number;
|
||||||
vibeChangeStingerMinIntervalSeconds: number;
|
vibeChangeStingerMinIntervalSeconds: number;
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,94 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { gardenAudioConfig } from './garden-audio-config';
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||||
|
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||||
|
|
||||||
|
const makeMetrics = ({
|
||||||
|
elapsedSeconds,
|
||||||
|
normalizedDistance,
|
||||||
|
}: {
|
||||||
|
elapsedSeconds: number;
|
||||||
|
normalizedDistance: number;
|
||||||
|
}): GardenAudioStrokeMetrics => ({
|
||||||
|
distancePixels: normalizedDistance * 1000,
|
||||||
|
elapsedSeconds,
|
||||||
|
normalizedDistance,
|
||||||
|
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
describe('GardenAudioGestureState', () => {
|
describe('GardenAudioGestureState', () => {
|
||||||
it('uses only distance accumulated in the last half second', () => {
|
it('ignores tiny jitter below the activity speed floor', () => {
|
||||||
const state = new GardenAudioGestureState(gardenAudioConfig.input);
|
const state = new GardenAudioGestureState(gardenAudioConfig.input);
|
||||||
|
|
||||||
state.beginGesture();
|
state.beginGesture();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
state.recordStroke({
|
state.recordStroke({
|
||||||
metrics: {
|
metrics: makeMetrics({
|
||||||
distancePixels: 70,
|
|
||||||
elapsedSeconds: 0.1,
|
elapsedSeconds: 0.1,
|
||||||
},
|
normalizedDistance: 0.001,
|
||||||
}).activity
|
}),
|
||||||
).toBeCloseTo(0.5);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
state.recordStroke({
|
|
||||||
metrics: {
|
|
||||||
distancePixels: 70,
|
|
||||||
elapsedSeconds: 0.1,
|
|
||||||
},
|
|
||||||
}).activity
|
|
||||||
).toBe(1);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
state.recordStroke({
|
|
||||||
metrics: {
|
|
||||||
distancePixels: 0,
|
|
||||||
elapsedSeconds: 0.51,
|
|
||||||
},
|
|
||||||
}).activity
|
}).activity
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes equal drawing speeds across pointer sample rates', () => {
|
||||||
|
const lowRate = new GardenAudioGestureState(gardenAudioConfig.input);
|
||||||
|
const highRate = new GardenAudioGestureState(gardenAudioConfig.input);
|
||||||
|
|
||||||
|
lowRate.beginGesture();
|
||||||
|
highRate.beginGesture();
|
||||||
|
|
||||||
|
const lowRateFrame = lowRate.recordStroke({
|
||||||
|
metrics: makeMetrics({
|
||||||
|
elapsedSeconds: 0.1,
|
||||||
|
normalizedDistance: 0.07,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let highRateFrame = { activity: 0, maniaAmount: 0 };
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
highRateFrame = highRate.recordStroke({
|
||||||
|
metrics: makeMetrics({
|
||||||
|
elapsedSeconds: 0.02,
|
||||||
|
normalizedDistance: 0.014,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(highRateFrame.activity).toBeCloseTo(lowRateFrame.activity, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds mania with hysteresis before releasing', () => {
|
||||||
|
const state = new GardenAudioGestureState(gardenAudioConfig.input);
|
||||||
|
|
||||||
|
state.beginGesture();
|
||||||
|
|
||||||
|
const manicFrame = state.recordStroke({
|
||||||
|
metrics: makeMetrics({
|
||||||
|
elapsedSeconds: 0.25,
|
||||||
|
normalizedDistance: 0.3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manicFrame.maniaAmount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const heldFrame = state.recordStroke({
|
||||||
|
metrics: makeMetrics({
|
||||||
|
elapsedSeconds: 0.06,
|
||||||
|
normalizedDistance: 0.04,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(heldFrame.maniaAmount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const releasedFrame = state.recordStroke({
|
||||||
|
metrics: makeMetrics({
|
||||||
|
elapsedSeconds: 0.7,
|
||||||
|
normalizedDistance: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(releasedFrame.maniaAmount).toBeLessThan(heldFrame.maniaAmount);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { clamp01 } from '../utils/clamp';
|
import { clamp, clamp01 } from '../utils/clamp';
|
||||||
import type { GardenAudioConfig } from './garden-audio-config';
|
import type { GardenAudioConfig } from './garden-audio-config';
|
||||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||||
|
|
||||||
|
|
@ -7,14 +7,11 @@ interface GardenAudioGestureFrame {
|
||||||
maniaAmount: number;
|
maniaAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GestureDistanceSample {
|
|
||||||
at: number;
|
|
||||||
distancePixels: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GardenAudioGestureState {
|
export class GardenAudioGestureState {
|
||||||
private readonly samples: Array<GestureDistanceSample> = [];
|
|
||||||
private gestureClockSeconds = 0;
|
private gestureClockSeconds = 0;
|
||||||
|
private activity = 0;
|
||||||
|
private maniaAmount = 0;
|
||||||
|
private isManic = false;
|
||||||
|
|
||||||
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||||
|
|
||||||
|
|
@ -33,41 +30,79 @@ export class GardenAudioGestureState {
|
||||||
}): GardenAudioGestureFrame {
|
}): GardenAudioGestureFrame {
|
||||||
this.gestureClockSeconds += metrics.elapsedSeconds;
|
this.gestureClockSeconds += metrics.elapsedSeconds;
|
||||||
|
|
||||||
if (metrics.distancePixels > 0) {
|
const targetActivity = this.getTargetActivity(metrics);
|
||||||
this.samples.push({
|
const activityTimeConstant =
|
||||||
at: this.gestureClockSeconds,
|
targetActivity > this.activity
|
||||||
distancePixels: metrics.distancePixels,
|
? this.inputConfig.activityAttackSeconds
|
||||||
});
|
: this.inputConfig.activityReleaseSeconds;
|
||||||
}
|
this.activity = approach(
|
||||||
this.trimSamples();
|
this.activity,
|
||||||
|
targetActivity,
|
||||||
const windowDistancePixels = this.samples.reduce(
|
metrics.elapsedSeconds,
|
||||||
(total, sample) => total + sample.distancePixels,
|
activityTimeConstant
|
||||||
0
|
|
||||||
);
|
);
|
||||||
const activity = clamp01(
|
|
||||||
windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels
|
if (this.activity >= this.inputConfig.manicActivityThreshold) {
|
||||||
|
this.isManic = true;
|
||||||
|
} else if (this.activity <= this.inputConfig.manicReleaseThreshold) {
|
||||||
|
this.isManic = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maniaTarget = this.isManic
|
||||||
|
? smoothstep(this.inputConfig.manicReleaseThreshold, 1, this.activity)
|
||||||
|
: 0;
|
||||||
|
this.maniaAmount = approach(
|
||||||
|
this.maniaAmount,
|
||||||
|
maniaTarget,
|
||||||
|
metrics.elapsedSeconds,
|
||||||
|
this.inputConfig.maniaSmoothingSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activity,
|
activity: this.activity,
|
||||||
maniaAmount: smoothstep(this.inputConfig.manicActivityThreshold, 1, activity),
|
maniaAmount: this.maniaAmount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this.samples.length = 0;
|
|
||||||
this.gestureClockSeconds = 0;
|
this.gestureClockSeconds = 0;
|
||||||
|
this.activity = 0;
|
||||||
|
this.maniaAmount = 0;
|
||||||
|
this.isManic = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private trimSamples(): void {
|
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
|
||||||
const earliest = this.gestureClockSeconds - this.inputConfig.distanceWindowSeconds;
|
const normalizedSpeed = Math.max(0, metrics.normalizedSpeed);
|
||||||
while (this.samples.length > 0 && this.samples[0].at < earliest) {
|
const activeSpeed = Math.max(
|
||||||
this.samples.shift();
|
this.inputConfig.activityNoiseFloorSpeed,
|
||||||
}
|
this.inputConfig.fullActivitySpeed
|
||||||
|
);
|
||||||
|
const speedAmount = clamp01(
|
||||||
|
(normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) /
|
||||||
|
(activeSpeed - this.inputConfig.activityNoiseFloorSpeed)
|
||||||
|
);
|
||||||
|
const distanceAmount = clamp01(
|
||||||
|
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
|
||||||
|
);
|
||||||
|
const activity = Math.pow(
|
||||||
|
speedAmount,
|
||||||
|
Math.max(0.001, this.inputConfig.activityCurve)
|
||||||
|
);
|
||||||
|
|
||||||
|
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const approach = (
|
||||||
|
current: number,
|
||||||
|
target: number,
|
||||||
|
elapsedSeconds: number,
|
||||||
|
timeConstantSeconds: number
|
||||||
|
): number => {
|
||||||
|
const amount = 1 - Math.exp(-elapsedSeconds / Math.max(0.001, timeConstantSeconds));
|
||||||
|
return current + (target - current) * amount;
|
||||||
|
};
|
||||||
|
|
||||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||||
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
const amount = clamp01((value - edge0) / (edge1 - edge0));
|
||||||
return amount * amount * (3 - 2 * amount);
|
return amount * amount * (3 - 2 * amount);
|
||||||
|
|
|
||||||
92
src/audio/garden-audio-graph.test.ts
Normal file
92
src/audio/garden-audio-graph.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { VIBE_PRESETS } from '../vibes';
|
||||||
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
|
import { GardenAudioGraph } from './garden-audio-graph';
|
||||||
|
|
||||||
|
class FakeAudioParam {
|
||||||
|
public value = 0;
|
||||||
|
public setTargetAtTime = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeAudioNode {
|
||||||
|
public readonly gain = new FakeAudioParam();
|
||||||
|
public readonly frequency = new FakeAudioParam();
|
||||||
|
public readonly threshold = new FakeAudioParam();
|
||||||
|
public readonly knee = new FakeAudioParam();
|
||||||
|
public readonly ratio = new FakeAudioParam();
|
||||||
|
public readonly attack = new FakeAudioParam();
|
||||||
|
public readonly release = new FakeAudioParam();
|
||||||
|
public readonly delayTime = new FakeAudioParam();
|
||||||
|
public readonly connections: Array<unknown> = [];
|
||||||
|
public type = '';
|
||||||
|
|
||||||
|
public connect(target: unknown): unknown {
|
||||||
|
this.connections.push(target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeAudioBuffer {
|
||||||
|
private readonly data: Float32Array;
|
||||||
|
|
||||||
|
public constructor(length: number) {
|
||||||
|
this.data = new Float32Array(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChannelData(): Float32Array {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeAudioContext {
|
||||||
|
public readonly currentTime = 1;
|
||||||
|
public readonly destination = new FakeAudioNode() as unknown as AudioDestinationNode;
|
||||||
|
public readonly sampleRate = 16;
|
||||||
|
public readonly state = 'running';
|
||||||
|
public readonly compressors: Array<FakeAudioNode> = [];
|
||||||
|
|
||||||
|
public createGain(): GainNode {
|
||||||
|
return new FakeAudioNode() as unknown as GainNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createBiquadFilter(): BiquadFilterNode {
|
||||||
|
return new FakeAudioNode() as unknown as BiquadFilterNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createDelay(): DelayNode {
|
||||||
|
return new FakeAudioNode() as unknown as DelayNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createDynamicsCompressor(): DynamicsCompressorNode {
|
||||||
|
const node = new FakeAudioNode();
|
||||||
|
this.compressors.push(node);
|
||||||
|
return node as unknown as DynamicsCompressorNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createBuffer(_channels: number, length: number): AudioBuffer {
|
||||||
|
return new FakeAudioBuffer(length) as unknown as AudioBuffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GardenAudioGraph', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds controlled output, role buses, and delay automation', () => {
|
||||||
|
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||||
|
const graph = new GardenAudioGraph(gardenAudioConfig);
|
||||||
|
const context = graph.ensureContext(true) as unknown as FakeAudioContext;
|
||||||
|
|
||||||
|
expect(context.compressors).toHaveLength(1);
|
||||||
|
expect(graph.getPianoBus('pad')).not.toBeNull();
|
||||||
|
expect(graph.getPianoBus('pad')).not.toBe(graph.getPianoBus('gesture'));
|
||||||
|
expect(graph.noiseBus).not.toBeNull();
|
||||||
|
|
||||||
|
graph.updateDelay(VIBE_PRESETS[0].audio, 1);
|
||||||
|
|
||||||
|
expect(graph.getPianoBus('pad')?.gain.setTargetAtTime).toHaveBeenCalled();
|
||||||
|
expect(graph.getPianoBus('brush')?.gain.setTargetAtTime).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { clamp } from '../utils/clamp';
|
import { clamp } from '../utils/clamp';
|
||||||
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
import { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||||
|
import type { PianoNoteRole } from './garden-audio-types';
|
||||||
|
|
||||||
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;
|
||||||
public delayInput: GainNode | null = null;
|
public delayInput: GainNode | null = null;
|
||||||
|
public noiseBus: GainNode | null = null;
|
||||||
public noiseBuffer: AudioBuffer | null = null;
|
public noiseBuffer: AudioBuffer | null = null;
|
||||||
|
|
||||||
private masterGain: GainNode | null = null;
|
private masterGain: GainNode | null = null;
|
||||||
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 readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {}
|
public constructor(private readonly config: GardenAudioConfig) {}
|
||||||
|
|
||||||
|
|
@ -30,19 +33,28 @@ export class GardenAudioGraph {
|
||||||
|
|
||||||
let context: AudioContext;
|
let context: AudioContext;
|
||||||
try {
|
try {
|
||||||
context = new AudioContextConstructor({ latencyHint: 'interactive' });
|
context = new AudioContextConstructor({
|
||||||
|
latencyHint: this.config.graph.latencyHint,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
context = new AudioContextConstructor();
|
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 = this.config.graph.outputFilterType;
|
||||||
highPass.frequency.value = this.config.highPassFrequencyHz;
|
highPass.frequency.value = this.config.highPassFrequencyHz;
|
||||||
|
compressor.threshold.value = this.config.graph.compressor.thresholdDb;
|
||||||
|
compressor.knee.value = this.config.graph.compressor.kneeDb;
|
||||||
|
compressor.ratio.value = this.config.graph.compressor.ratio;
|
||||||
|
compressor.attack.value = this.config.graph.compressor.attackSeconds;
|
||||||
|
compressor.release.value = this.config.graph.compressor.releaseSeconds;
|
||||||
|
|
||||||
masterGain.connect(highPass);
|
masterGain.connect(highPass);
|
||||||
highPass.connect(context.destination);
|
highPass.connect(compressor);
|
||||||
|
compressor.connect(context.destination);
|
||||||
|
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.masterGain = masterGain;
|
this.masterGain = masterGain;
|
||||||
|
|
@ -65,7 +77,7 @@ export class GardenAudioGraph {
|
||||||
const source = this.context.createOscillator();
|
const source = this.context.createOscillator();
|
||||||
const gain = this.context.createGain();
|
const gain = this.context.createGain();
|
||||||
|
|
||||||
source.type = 'sine';
|
source.type = this.config.graph.unlockTickType;
|
||||||
source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now);
|
source.frequency.setValueAtTime(this.config.graph.unlockTickFrequencyHz, now);
|
||||||
gain.gain.setValueAtTime(this.config.piano.minGain, now);
|
gain.gain.setValueAtTime(this.config.piano.minGain, now);
|
||||||
gain.gain.exponentialRampToValueAtTime(
|
gain.gain.exponentialRampToValueAtTime(
|
||||||
|
|
@ -116,6 +128,7 @@ export class GardenAudioGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = this.context.currentTime;
|
const now = this.context.currentTime;
|
||||||
|
const normalizedActivity = clamp(activity, 0, 1);
|
||||||
this.delayNode.delayTime.setTargetAtTime(
|
this.delayNode.delayTime.setTargetAtTime(
|
||||||
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
this.config.delay.timeSeconds * profile.delayTimeMultiplier,
|
||||||
now,
|
now,
|
||||||
|
|
@ -123,7 +136,8 @@ export class GardenAudioGraph {
|
||||||
);
|
);
|
||||||
this.delayFeedback.gain.setTargetAtTime(
|
this.delayFeedback.gain.setTargetAtTime(
|
||||||
clamp(
|
clamp(
|
||||||
this.config.delay.feedback + activity * this.config.delay.activityFeedbackWeight,
|
this.config.delay.feedback +
|
||||||
|
normalizedActivity * this.config.delay.activityFeedbackWeight,
|
||||||
this.config.delay.feedbackMin,
|
this.config.delay.feedbackMin,
|
||||||
this.config.delay.feedbackMax
|
this.config.delay.feedbackMax
|
||||||
),
|
),
|
||||||
|
|
@ -133,10 +147,16 @@ export class GardenAudioGraph {
|
||||||
this.delayOutput.gain.setTargetAtTime(
|
this.delayOutput.gain.setTargetAtTime(
|
||||||
this.config.delay.wetGain *
|
this.config.delay.wetGain *
|
||||||
(this.config.delay.outputBase +
|
(this.config.delay.outputBase +
|
||||||
activity * this.config.delay.outputActivityWeight),
|
normalizedActivity * this.config.delay.outputActivityWeight) *
|
||||||
|
(1 - normalizedActivity * this.config.delay.outputActivityDuck),
|
||||||
now,
|
now,
|
||||||
this.config.updateRampSeconds
|
this.config.updateRampSeconds
|
||||||
);
|
);
|
||||||
|
this.updatePianoBusGains(normalizedActivity, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPianoBus(role: PianoNoteRole | undefined): GainNode | null {
|
||||||
|
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async close(): Promise<void> {
|
public async close(): Promise<void> {
|
||||||
|
|
@ -165,15 +185,27 @@ export class GardenAudioGraph {
|
||||||
const delayNode = context.createDelay(this.config.graph.delayMaxSeconds);
|
const delayNode = context.createDelay(this.config.graph.delayMaxSeconds);
|
||||||
const delayFeedback = context.createGain();
|
const delayFeedback = context.createGain();
|
||||||
const delayOutput = context.createGain();
|
const delayOutput = context.createGain();
|
||||||
|
const feedbackHighPass = context.createBiquadFilter();
|
||||||
|
const feedbackLowPass = context.createBiquadFilter();
|
||||||
|
const returnLowPass = context.createBiquadFilter();
|
||||||
|
|
||||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
||||||
delayFeedback.gain.value = this.config.delay.feedback;
|
delayFeedback.gain.value = this.config.delay.feedback;
|
||||||
delayOutput.gain.value = this.config.delay.wetGain;
|
delayOutput.gain.value = this.config.delay.wetGain;
|
||||||
|
feedbackHighPass.type = 'highpass';
|
||||||
|
feedbackHighPass.frequency.value = this.config.delay.feedbackHighPassHz;
|
||||||
|
feedbackLowPass.type = 'lowpass';
|
||||||
|
feedbackLowPass.frequency.value = this.config.delay.feedbackLowPassHz;
|
||||||
|
returnLowPass.type = 'lowpass';
|
||||||
|
returnLowPass.frequency.value = this.config.delay.returnLowPassHz;
|
||||||
|
|
||||||
delayInput.connect(delayNode);
|
delayInput.connect(delayNode);
|
||||||
delayNode.connect(delayFeedback);
|
delayNode.connect(feedbackHighPass);
|
||||||
|
feedbackHighPass.connect(feedbackLowPass);
|
||||||
|
feedbackLowPass.connect(delayFeedback);
|
||||||
delayFeedback.connect(delayNode);
|
delayFeedback.connect(delayNode);
|
||||||
delayNode.connect(delayOutput);
|
delayNode.connect(returnLowPass);
|
||||||
|
returnLowPass.connect(delayOutput);
|
||||||
delayOutput.connect(masterGain);
|
delayOutput.connect(masterGain);
|
||||||
|
|
||||||
this.delayInput = delayInput;
|
this.delayInput = delayInput;
|
||||||
|
|
@ -183,13 +215,50 @@ export class GardenAudioGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
private createBuses(context: AudioContext, masterGain: GainNode): void {
|
||||||
this.eventBus = context.createGain();
|
const eventBus = context.createGain();
|
||||||
this.eventBus.gain.value = this.config.graph.eventBusGain;
|
eventBus.gain.value = this.config.graph.eventBusGain;
|
||||||
this.eventBus.connect(masterGain);
|
eventBus.connect(masterGain);
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
this.pianoBuses.clear();
|
||||||
|
|
||||||
|
(Object.keys(this.config.graph.pianoBusGains) as Array<PianoNoteRole>).forEach(
|
||||||
|
(role) => {
|
||||||
|
const bus = context.createGain();
|
||||||
|
bus.gain.value = this.config.graph.pianoBusGains[role];
|
||||||
|
bus.connect(eventBus);
|
||||||
|
this.pianoBuses.set(role, bus);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.noiseBus = context.createGain();
|
||||||
|
this.noiseBus.gain.value = this.config.graph.noiseBusGain;
|
||||||
|
this.noiseBus.connect(eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePianoBusGains(activity: number, now: number): void {
|
||||||
|
this.pianoBuses.forEach((bus, role) => {
|
||||||
|
const baseGain = this.config.graph.pianoBusGains[role];
|
||||||
|
const ducking = this.config.graph.pianoBusActivityDucking[role];
|
||||||
|
bus.gain.setTargetAtTime(
|
||||||
|
Math.max(0, baseGain * (1 - activity * ducking)),
|
||||||
|
now,
|
||||||
|
this.config.updateRampSeconds
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||||
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
|
const buffer = context.createBuffer(
|
||||||
|
appPositiveInteger(this.config.graph.noiseBufferChannels),
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
context.sampleRate *
|
||||||
|
Math.max(0.001, this.config.graph.noiseBufferDurationSeconds)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
context.sampleRate
|
||||||
|
);
|
||||||
const data = buffer.getChannelData(0);
|
const data = buffer.getChannelData(0);
|
||||||
|
|
||||||
for (let index = 0; index < data.length; index++) {
|
for (let index = 0; index < data.length; index++) {
|
||||||
|
|
@ -205,10 +274,15 @@ export class GardenAudioGraph {
|
||||||
this.context = null;
|
this.context = null;
|
||||||
this.eventBus = null;
|
this.eventBus = null;
|
||||||
this.delayInput = null;
|
this.delayInput = null;
|
||||||
|
this.noiseBus = null;
|
||||||
this.noiseBuffer = null;
|
this.noiseBuffer = null;
|
||||||
this.masterGain = null;
|
this.masterGain = null;
|
||||||
this.delayNode = null;
|
this.delayNode = null;
|
||||||
this.delayFeedback = null;
|
this.delayFeedback = null;
|
||||||
this.delayOutput = null;
|
this.delayOutput = null;
|
||||||
|
this.pianoBuses.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appPositiveInteger = (value: number): number =>
|
||||||
|
Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));
|
||||||
|
|
|
||||||
52
src/audio/garden-audio-input.test.ts
Normal file
52
src/audio/garden-audio-input.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
|
import { getStrokeMetrics } from './garden-audio-input';
|
||||||
|
|
||||||
|
describe('getStrokeMetrics', () => {
|
||||||
|
it('normalizes stroke distance against canvas size', () => {
|
||||||
|
const standardDensity = getStrokeMetrics(
|
||||||
|
{
|
||||||
|
vibe: {} as never,
|
||||||
|
from: [0, 0],
|
||||||
|
to: [100, 0],
|
||||||
|
canvasSize: [1000, 500],
|
||||||
|
elapsedSeconds: 0.1,
|
||||||
|
isErasing: false,
|
||||||
|
},
|
||||||
|
gardenAudioConfig.input
|
||||||
|
);
|
||||||
|
const highDensity = getStrokeMetrics(
|
||||||
|
{
|
||||||
|
vibe: {} as never,
|
||||||
|
from: [0, 0],
|
||||||
|
to: [200, 0],
|
||||||
|
canvasSize: [2000, 1000],
|
||||||
|
elapsedSeconds: 0.1,
|
||||||
|
isErasing: false,
|
||||||
|
},
|
||||||
|
gardenAudioConfig.input
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(highDensity.normalizedDistance).toBeCloseTo(
|
||||||
|
standardDensity.normalizedDistance
|
||||||
|
);
|
||||||
|
expect(highDensity.normalizedSpeed).toBeCloseTo(standardDensity.normalizedSpeed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses configured elapsed-time floors for missing or invalid samples', () => {
|
||||||
|
const metrics = getStrokeMetrics(
|
||||||
|
{
|
||||||
|
vibe: {} as never,
|
||||||
|
from: [0, 0],
|
||||||
|
to: [10, 0],
|
||||||
|
elapsedSeconds: 0,
|
||||||
|
isErasing: false,
|
||||||
|
},
|
||||||
|
gardenAudioConfig.input
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(metrics.elapsedSeconds).toBe(gardenAudioConfig.input.fallbackFrameSeconds);
|
||||||
|
expect(Number.isFinite(metrics.normalizedSpeed)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,8 @@ import type { GardenAudioStroke } from './garden-audio-types';
|
||||||
export interface GardenAudioStrokeMetrics {
|
export interface GardenAudioStrokeMetrics {
|
||||||
distancePixels: number;
|
distancePixels: number;
|
||||||
elapsedSeconds: number;
|
elapsedSeconds: number;
|
||||||
|
normalizedDistance: number;
|
||||||
|
normalizedSpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStrokeMetrics = (
|
export const getStrokeMetrics = (
|
||||||
|
|
@ -12,10 +14,16 @@ export const getStrokeMetrics = (
|
||||||
): 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 elapsedSeconds = getElapsedSeconds(stroke, inputConfig);
|
||||||
|
const normalizedDistance =
|
||||||
|
distancePixels / getStrokeNormalizationPixels(stroke, inputConfig);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
distancePixels: Math.hypot(dx, dy),
|
distancePixels,
|
||||||
elapsedSeconds: getElapsedSeconds(stroke, inputConfig),
|
elapsedSeconds,
|
||||||
|
normalizedDistance,
|
||||||
|
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -28,8 +36,28 @@ const getElapsedSeconds = (
|
||||||
Number.isFinite(stroke.elapsedSeconds) &&
|
Number.isFinite(stroke.elapsedSeconds) &&
|
||||||
stroke.elapsedSeconds > 0
|
stroke.elapsedSeconds > 0
|
||||||
) {
|
) {
|
||||||
return Math.max(0.001, stroke.elapsedSeconds);
|
return Math.max(inputConfig.minElapsedSeconds, stroke.elapsedSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inputConfig.fallbackFrameSeconds;
|
return inputConfig.fallbackFrameSeconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStrokeNormalizationPixels = (
|
||||||
|
stroke: GardenAudioStroke,
|
||||||
|
inputConfig: GardenAudioConfig['input']
|
||||||
|
): number => {
|
||||||
|
const width = stroke.canvasSize?.[0];
|
||||||
|
const height = stroke.canvasSize?.[1];
|
||||||
|
if (
|
||||||
|
width !== undefined &&
|
||||||
|
height !== undefined &&
|
||||||
|
Number.isFinite(width) &&
|
||||||
|
Number.isFinite(height) &&
|
||||||
|
width > 0 &&
|
||||||
|
height > 0
|
||||||
|
) {
|
||||||
|
return Math.max(1, Math.min(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, inputConfig.distanceWindowForFullActivityPixels);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { VibePreset } from '../vibes';
|
import { VibePreset } from '../vibes';
|
||||||
import { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
import {
|
||||||
|
GardenAudioChord,
|
||||||
|
gardenAudioConfig,
|
||||||
|
GardenAudioVibeProfile,
|
||||||
|
} from './garden-audio-config';
|
||||||
|
|
||||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
||||||
|
|
||||||
|
|
@ -8,10 +12,14 @@ export const getChordIntervals = (
|
||||||
openVoicing: boolean
|
openVoicing: boolean
|
||||||
): Array<number> => {
|
): Array<number> => {
|
||||||
if (openVoicing) {
|
if (openVoicing) {
|
||||||
return chord.quality === 'major' ? [0, 7, 12, 16] : [0, 7, 12, 15];
|
return chord.quality === 'major'
|
||||||
|
? gardenAudioConfig.generativePiano.chordVoicings.majorOpen
|
||||||
|
: gardenAudioConfig.generativePiano.chordVoicings.minorOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
return chord.quality === 'major' ? [0, 4, 7, 12, 16] : [0, 3, 7, 12, 15];
|
return chord.quality === 'major'
|
||||||
|
? gardenAudioConfig.generativePiano.chordVoicings.majorClosed
|
||||||
|
: gardenAudioConfig.generativePiano.chordVoicings.minorClosed;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const degreeToSemitone = (
|
export const degreeToSemitone = (
|
||||||
|
|
@ -21,5 +29,7 @@ export const degreeToSemitone = (
|
||||||
const scaleIndex =
|
const scaleIndex =
|
||||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||||
const octave = Math.floor(degree / profile.scale.length);
|
const octave = Math.floor(degree / profile.scale.length);
|
||||||
return profile.scale[scaleIndex] + octave * 12;
|
return (
|
||||||
|
profile.scale[scaleIndex] + octave * gardenAudioConfig.piano.pitchSemitonesPerOctave
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface GardenAudioStroke {
|
||||||
vibe: VibePreset;
|
vibe: VibePreset;
|
||||||
from: ArrayLike<number>;
|
from: ArrayLike<number>;
|
||||||
to: ArrayLike<number>;
|
to: ArrayLike<number>;
|
||||||
|
canvasSize?: ArrayLike<number>;
|
||||||
isErasing: boolean;
|
isErasing: boolean;
|
||||||
elapsedSeconds?: number;
|
elapsedSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -34,10 +35,19 @@ export interface PianoNote {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
pan: number;
|
pan: number;
|
||||||
|
role?: PianoNoteRole;
|
||||||
delaySend?: number;
|
delaySend?: number;
|
||||||
lowpassHz?: number;
|
lowpassHz?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PianoNoteRole =
|
||||||
|
| 'pad'
|
||||||
|
| 'support'
|
||||||
|
| 'texture'
|
||||||
|
| 'gesture'
|
||||||
|
| 'brush'
|
||||||
|
| 'stinger';
|
||||||
|
|
||||||
export interface NoiseBurst {
|
export interface NoiseBurst {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const calls = {
|
||||||
resumed: 0,
|
resumed: 0,
|
||||||
sourcesStarted: 0,
|
sourcesStarted: 0,
|
||||||
sources: [] as Array<FakeScheduledSourceNode>,
|
sources: [] as Array<FakeScheduledSourceNode>,
|
||||||
|
gains: [] as Array<FakeAudioNode>,
|
||||||
};
|
};
|
||||||
|
|
||||||
let contextState: AudioContextState = 'suspended';
|
let contextState: AudioContextState = 'suspended';
|
||||||
|
|
@ -80,7 +81,9 @@ class FakeAudioContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
public createGain(): GainNode {
|
public createGain(): GainNode {
|
||||||
return new FakeAudioNode() as unknown as GainNode;
|
const node = new FakeAudioNode();
|
||||||
|
calls.gains.push(node);
|
||||||
|
return node as unknown as GainNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createBiquadFilter(): BiquadFilterNode {
|
public createBiquadFilter(): BiquadFilterNode {
|
||||||
|
|
@ -91,6 +94,10 @@ class FakeAudioContext {
|
||||||
return new FakeAudioNode() as unknown as DelayNode;
|
return new FakeAudioNode() as unknown as DelayNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public createDynamicsCompressor(): DynamicsCompressorNode {
|
||||||
|
return new FakeAudioNode() as unknown as DynamicsCompressorNode;
|
||||||
|
}
|
||||||
|
|
||||||
public createStereoPanner(): StereoPannerNode {
|
public createStereoPanner(): StereoPannerNode {
|
||||||
return new FakeAudioNode() as unknown as StereoPannerNode;
|
return new FakeAudioNode() as unknown as StereoPannerNode;
|
||||||
}
|
}
|
||||||
|
|
@ -151,6 +158,7 @@ describe('GardenAudio startup policy', () => {
|
||||||
calls.resumed = 0;
|
calls.resumed = 0;
|
||||||
calls.sourcesStarted = 0;
|
calls.sourcesStarted = 0;
|
||||||
calls.sources = [];
|
calls.sources = [];
|
||||||
|
calls.gains = [];
|
||||||
contextState = 'suspended';
|
contextState = 'suspended';
|
||||||
resumeError = null;
|
resumeError = null;
|
||||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||||
|
|
@ -210,6 +218,40 @@ describe('GardenAudio startup policy', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates live master gain from adjustable volume', () => {
|
||||||
|
const config = makeConfig();
|
||||||
|
const audio = new GardenAudio(config);
|
||||||
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
||||||
|
audio.start(vibe, { userGesture: true });
|
||||||
|
const masterGain = calls.gains[0]?.gain;
|
||||||
|
if (!masterGain) {
|
||||||
|
throw new Error('Missing fake master gain');
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.setMasterVolume(0.2);
|
||||||
|
|
||||||
|
expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith(
|
||||||
|
0.2,
|
||||||
|
1,
|
||||||
|
config.updateRampSeconds
|
||||||
|
);
|
||||||
|
|
||||||
|
audio.setMuted(true);
|
||||||
|
const mutedCallCount = masterGain.setTargetAtTime.mock.calls.length;
|
||||||
|
audio.setMasterVolume(0.8);
|
||||||
|
|
||||||
|
expect(masterGain.setTargetAtTime).toHaveBeenCalledTimes(mutedCallCount);
|
||||||
|
|
||||||
|
audio.setMuted(false);
|
||||||
|
|
||||||
|
expect(masterGain.setTargetAtTime).toHaveBeenLastCalledWith(
|
||||||
|
0.8,
|
||||||
|
1,
|
||||||
|
config.fadeInSeconds
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('stays silent without piano samples while preserving eraser noise', () => {
|
it('stays silent without piano samples while preserving eraser noise', () => {
|
||||||
const audio = new GardenAudio(makeConfig());
|
const audio = new GardenAudio(makeConfig());
|
||||||
const vibe = VIBE_PRESETS[0];
|
const vibe = VIBE_PRESETS[0];
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,14 @@ export class GardenAudio {
|
||||||
private hasStarted = false;
|
private hasStarted = false;
|
||||||
private isDestroyed = false;
|
private isDestroyed = false;
|
||||||
private isMuted = false;
|
private isMuted = false;
|
||||||
|
private masterVolume: number;
|
||||||
private isGestureActive = false;
|
private isGestureActive = false;
|
||||||
private hasQueuedPianoLoad = false;
|
private hasQueuedPianoLoad = false;
|
||||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
public constructor(private readonly config: GardenAudioConfig) {
|
public constructor(private readonly config: GardenAudioConfig) {
|
||||||
|
this.masterVolume = clamp01(config.masterVolume);
|
||||||
this.graph = new GardenAudioGraph(config);
|
this.graph = new GardenAudioGraph(config);
|
||||||
this.piano = new PianoSampler(config, this.graph);
|
this.piano = new PianoSampler(config, this.graph);
|
||||||
this.noise = new NoiseBurstPlayer(config, this.graph);
|
this.noise = new NoiseBurstPlayer(config, this.graph);
|
||||||
|
|
@ -81,7 +83,7 @@ export class GardenAudio {
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
|
if (this.graph.context === context && !this.isDestroyed && !this.isMuted) {
|
||||||
this.graph.unlock();
|
this.graph.unlock();
|
||||||
this.graph.setMasterGain(this.config.masterVolume, startupRampSeconds);
|
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -95,7 +97,7 @@ export class GardenAudio {
|
||||||
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.config.masterVolume, startupRampSeconds);
|
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||||
|
|
||||||
if (!this.hasQueuedPianoLoad) {
|
if (!this.hasQueuedPianoLoad) {
|
||||||
this.hasQueuedPianoLoad = true;
|
this.hasQueuedPianoLoad = true;
|
||||||
|
|
@ -132,13 +134,24 @@ export class GardenAudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
public setMuted(isMuted: boolean): void {
|
public setMuted(isMuted: boolean): void {
|
||||||
|
if (this.isMuted === isMuted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.isMuted = isMuted;
|
this.isMuted = isMuted;
|
||||||
this.graph.setMasterGain(
|
this.graph.setMasterGain(
|
||||||
isMuted ? this.config.muteGain : this.config.masterVolume,
|
isMuted ? this.config.muteGain : this.masterVolume,
|
||||||
isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
|
isMuted ? this.config.muteRampSeconds : this.config.fadeInSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setMasterVolume(masterVolume: number): void {
|
||||||
|
this.masterVolume = clamp01(masterVolume);
|
||||||
|
if (!this.isMuted) {
|
||||||
|
this.graph.setMasterGain(this.masterVolume, this.config.updateRampSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public beginGesture(): void {
|
public beginGesture(): void {
|
||||||
const context = this.graph.context;
|
const context = this.graph.context;
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|
@ -174,7 +187,9 @@ export class GardenAudio {
|
||||||
this.pianoEngine.renderLookahead({
|
this.pianoEngine.renderLookahead({
|
||||||
vibe: snapshot.vibe,
|
vibe: snapshot.vibe,
|
||||||
now: context.currentTime,
|
now: context.currentTime,
|
||||||
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
|
activity: snapshot.isErasing
|
||||||
|
? this.config.eraser.pianoActivity
|
||||||
|
: this.energy.getLevel(),
|
||||||
});
|
});
|
||||||
this.updateDelay(snapshot);
|
this.updateDelay(snapshot);
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +282,7 @@ export class GardenAudio {
|
||||||
durationSeconds: this.config.eraser.durationSeconds,
|
durationSeconds: this.config.eraser.durationSeconds,
|
||||||
gain: this.config.eraser.noiseGain * distanceActivity,
|
gain: this.config.eraser.noiseGain * distanceActivity,
|
||||||
filterHz,
|
filterHz,
|
||||||
pan: 0,
|
pan: this.config.eraser.pan,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@ const countNotesBetween = (
|
||||||
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
|
notes.filter((note) => note.startTime >= startSeconds && note.startTime < endSeconds)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
|
const getNoteKey = (note: PianoNote): string =>
|
||||||
|
[
|
||||||
|
note.startTime.toFixed(3),
|
||||||
|
note.midi,
|
||||||
|
note.role ?? 'none',
|
||||||
|
note.pan.toFixed(3),
|
||||||
|
].join(':');
|
||||||
|
|
||||||
describe('GenerativePianoEngine', () => {
|
describe('GenerativePianoEngine', () => {
|
||||||
it('plays quiet background music even when the garden is idle', () => {
|
it('plays quiet background music even when the garden is idle', () => {
|
||||||
const { engine, notes } = makeEngine();
|
const { engine, notes } = makeEngine();
|
||||||
|
|
@ -187,4 +195,55 @@ describe('GenerativePianoEngine', () => {
|
||||||
|
|
||||||
expect(second.notes).toEqual(first.notes);
|
expect(second.notes).toEqual(first.notes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not duplicate notes across overlapping lookahead windows', () => {
|
||||||
|
const { engine, notes } = makeEngine();
|
||||||
|
|
||||||
|
engine.renderLookahead({
|
||||||
|
vibe: VIBE_PRESETS[0],
|
||||||
|
now: 0,
|
||||||
|
activity: 0.72,
|
||||||
|
lookaheadSeconds: getBeatSeconds() * 2,
|
||||||
|
});
|
||||||
|
engine.renderLookahead({
|
||||||
|
vibe: VIBE_PRESETS[0],
|
||||||
|
now: getBeatSeconds() * 0.5,
|
||||||
|
activity: 0.72,
|
||||||
|
lookaheadSeconds: getBeatSeconds() * 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noteKeys = notes.map(getNoteKey);
|
||||||
|
expect(new Set(noteKeys).size).toBe(noteKeys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps generated notes inside the audio contract', () => {
|
||||||
|
const { engine, notes } = makeEngine();
|
||||||
|
|
||||||
|
VIBE_PRESETS.forEach((vibe) => {
|
||||||
|
engine.cue(0);
|
||||||
|
engine.renderLookahead({
|
||||||
|
vibe,
|
||||||
|
now: 0,
|
||||||
|
activity: 1,
|
||||||
|
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
notes.forEach((note) => {
|
||||||
|
expect(Number.isFinite(note.startTime)).toBe(true);
|
||||||
|
expect(note.midi).toBeGreaterThanOrEqual(21);
|
||||||
|
expect(note.midi).toBeLessThanOrEqual(108);
|
||||||
|
expect(note.velocity).toBeGreaterThan(0);
|
||||||
|
expect(note.velocity).toBeLessThanOrEqual(0.4);
|
||||||
|
expect(note.pan).toBeGreaterThanOrEqual(-1);
|
||||||
|
expect(note.pan).toBeLessThanOrEqual(1);
|
||||||
|
expect(note.durationSeconds).toBeGreaterThan(0);
|
||||||
|
expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeGreaterThanOrEqual(
|
||||||
|
gardenAudioConfig.piano.lowpassMinHz
|
||||||
|
);
|
||||||
|
expect(note.lowpassHz ?? gardenAudioConfig.piano.lowpassHz).toBeLessThanOrEqual(
|
||||||
|
gardenAudioConfig.piano.lowpassMaxHz
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,8 +9,9 @@ export class NoiseBurstPlayer {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||||
const { context, eventBus, noiseBuffer } = this.graph;
|
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
|
||||||
if (!context || !eventBus || !noiseBuffer) {
|
const outputBus = noiseBus ?? eventBus;
|
||||||
|
if (!context || !outputBus || !noiseBuffer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ export class NoiseBurstPlayer {
|
||||||
const stopAt = scheduledStart + durationSeconds;
|
const stopAt = scheduledStart + durationSeconds;
|
||||||
|
|
||||||
source.buffer = noiseBuffer;
|
source.buffer = noiseBuffer;
|
||||||
filter.type = 'bandpass';
|
filter.type = this.config.noiseBurst.filterType;
|
||||||
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
filter.frequency.setValueAtTime(filterHz, scheduledStart);
|
||||||
filter.Q.value = this.config.noiseBurst.filterQ;
|
filter.Q.value = this.config.noiseBurst.filterQ;
|
||||||
envelope.gain.setValueAtTime(this.config.noiseBurst.silentGain, scheduledStart);
|
envelope.gain.setValueAtTime(this.config.noiseBurst.silentGain, scheduledStart);
|
||||||
|
|
@ -39,7 +40,7 @@ export class NoiseBurstPlayer {
|
||||||
source.connect(filter);
|
source.connect(filter);
|
||||||
filter.connect(envelope);
|
filter.connect(envelope);
|
||||||
envelope.connect(panner);
|
envelope.connect(panner);
|
||||||
panner.connect(eventBus);
|
panner.connect(outputBus);
|
||||||
source.start(
|
source.start(
|
||||||
scheduledStart,
|
scheduledStart,
|
||||||
Math.random() * this.config.noiseBurst.offsetRandomSeconds
|
Math.random() * this.config.noiseBurst.offsetRandomSeconds
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ const makeSampler = async (context: AudioContext): Promise<PianoSampler> => {
|
||||||
context,
|
context,
|
||||||
delayInput: null,
|
delayInput: null,
|
||||||
eventBus,
|
eventBus,
|
||||||
|
getPianoBus: vi.fn(() => eventBus),
|
||||||
} as unknown as GardenAudioGraph;
|
} as unknown as GardenAudioGraph;
|
||||||
|
|
||||||
return new PianoSampler(gardenAudioConfig, graph);
|
return new PianoSampler(gardenAudioConfig, graph);
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,12 @@ export class PianoSampler {
|
||||||
startTime,
|
startTime,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
pan,
|
pan,
|
||||||
|
role,
|
||||||
delaySend = 0,
|
delaySend = 0,
|
||||||
lowpassHz = this.config.piano.lowpassHz,
|
lowpassHz = this.config.piano.lowpassHz,
|
||||||
}: PianoNote): void {
|
}: PianoNote): void {
|
||||||
const { context, eventBus, delayInput } = this.graph;
|
const { context, delayInput } = this.graph;
|
||||||
|
const eventBus = this.graph.getPianoBus(role);
|
||||||
if (!context || !eventBus) {
|
if (!context || !eventBus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +90,7 @@ export class PianoSampler {
|
||||||
Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave),
|
Math.pow(2, (midi - sample.midi) / this.config.piano.pitchSemitonesPerOctave),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
);
|
);
|
||||||
filter.type = 'lowpass';
|
filter.type = this.config.piano.filterType;
|
||||||
filter.frequency.setValueAtTime(
|
filter.frequency.setValueAtTime(
|
||||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||||
scheduledStart
|
scheduledStart
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { gardenAudioConfig } from './garden-audio-config';
|
||||||
import type { LoadedPianoSample } from './garden-audio-types';
|
import type { LoadedPianoSample } from './garden-audio-types';
|
||||||
|
|
||||||
interface PianoSampleDefinition {
|
interface PianoSampleDefinition {
|
||||||
|
|
@ -11,8 +12,6 @@ export interface PianoSampleLoadProgress {
|
||||||
sample?: PianoSampleDefinition;
|
sample?: PianoSampleDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/piano/`;
|
|
||||||
|
|
||||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||||
['A0v12.m4a', 21],
|
['A0v12.m4a', 21],
|
||||||
['C1v12.m4a', 24],
|
['C1v12.m4a', 24],
|
||||||
|
|
@ -49,7 +48,7 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||||
.map(([fileName, midi]) => ({
|
.map(([fileName, midi]) => ({
|
||||||
midi,
|
midi,
|
||||||
url: `${sampleBaseUrl}${fileName}`,
|
url: `${gardenAudioConfig.piano.sampleBaseUrl}${fileName}`,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.midi - b.midi);
|
.sort((a, b) => a.midi - b.midi);
|
||||||
|
|
||||||
|
|
@ -68,7 +67,11 @@ export const preloadPianoSamples = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 44_100);
|
const decodeContext = new OfflineAudioContextConstructor(
|
||||||
|
gardenAudioConfig.piano.preloadDecode.channels,
|
||||||
|
gardenAudioConfig.piano.preloadDecode.frames,
|
||||||
|
gardenAudioConfig.piano.preloadDecode.sampleRateHz
|
||||||
|
);
|
||||||
return loadPianoSamples(decodeContext, onProgress);
|
return loadPianoSamples(decodeContext, onProgress);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
280
src/config.ts
280
src/config.ts
|
|
@ -3,6 +3,8 @@ import { runtimeControls } from './config/runtime-controls';
|
||||||
import type { GardenAppConfig } from './config/types';
|
import type { GardenAppConfig } from './config/types';
|
||||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||||
|
|
||||||
|
const defaultAudioMasterVolume = 0.42;
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
GardenAppConfig,
|
GardenAppConfig,
|
||||||
GardenRuntimeSettings,
|
GardenRuntimeSettings,
|
||||||
|
|
@ -12,7 +14,7 @@ export type {
|
||||||
|
|
||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
audio: {
|
audio: {
|
||||||
masterVolume: 0.42,
|
masterVolume: defaultAudioMasterVolume,
|
||||||
fadeInSeconds: 0.45,
|
fadeInSeconds: 0.45,
|
||||||
updateRampSeconds: 0.08,
|
updateRampSeconds: 0.08,
|
||||||
highPassFrequencyHz: 45,
|
highPassFrequencyHz: 45,
|
||||||
|
|
@ -26,10 +28,15 @@ export const appConfig = {
|
||||||
feedbackMin: 0.04,
|
feedbackMin: 0.04,
|
||||||
outputActivityWeight: 0.5,
|
outputActivityWeight: 0.5,
|
||||||
outputBase: 0.65,
|
outputBase: 0.65,
|
||||||
|
outputActivityDuck: 0.28,
|
||||||
timeRampSeconds: 0.12,
|
timeRampSeconds: 0.12,
|
||||||
|
feedbackHighPassHz: 180,
|
||||||
|
feedbackLowPassHz: 5200,
|
||||||
|
returnLowPassHz: 6200,
|
||||||
},
|
},
|
||||||
piano: {
|
piano: {
|
||||||
maxVoices: 24,
|
maxVoices: 24,
|
||||||
|
filterType: 'lowpass',
|
||||||
gain: 0.48,
|
gain: 0.48,
|
||||||
sustainSeconds: 0.42,
|
sustainSeconds: 0.42,
|
||||||
sustainLevel: 0.32,
|
sustainLevel: 0.32,
|
||||||
|
|
@ -49,6 +56,12 @@ export const appConfig = {
|
||||||
tailStopExtraSeconds: 0.05,
|
tailStopExtraSeconds: 0.05,
|
||||||
voiceStealFadeSeconds: 0.025,
|
voiceStealFadeSeconds: 0.025,
|
||||||
voiceStealStopSeconds: 0.05,
|
voiceStealStopSeconds: 0.05,
|
||||||
|
sampleBaseUrl: `${import.meta.env.BASE_URL}audio/piano/`,
|
||||||
|
preloadDecode: {
|
||||||
|
channels: 1,
|
||||||
|
frames: 1,
|
||||||
|
sampleRateHz: 44_100,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rhythm: {
|
rhythm: {
|
||||||
bpm: 74,
|
bpm: 74,
|
||||||
|
|
@ -63,6 +76,8 @@ export const appConfig = {
|
||||||
filterMinHz: 650,
|
filterMinHz: 650,
|
||||||
filterMaxHz: 3600,
|
filterMaxHz: 3600,
|
||||||
durationSeconds: 0.08,
|
durationSeconds: 0.08,
|
||||||
|
pan: 0,
|
||||||
|
pianoActivity: 0,
|
||||||
},
|
},
|
||||||
energy: {
|
energy: {
|
||||||
attackSeconds: 0.08,
|
attackSeconds: 0.08,
|
||||||
|
|
@ -80,12 +95,51 @@ export const appConfig = {
|
||||||
noiseMin: -1,
|
noiseMin: -1,
|
||||||
unlockTickFrequencyHz: 440,
|
unlockTickFrequencyHz: 440,
|
||||||
unlockTickSeconds: 0.035,
|
unlockTickSeconds: 0.035,
|
||||||
|
unlockTickType: 'sine',
|
||||||
|
latencyHint: 'interactive',
|
||||||
|
outputFilterType: 'highpass',
|
||||||
|
noiseBufferChannels: 1,
|
||||||
|
noiseBufferDurationSeconds: 1,
|
||||||
|
pianoBusGains: {
|
||||||
|
pad: 0.86,
|
||||||
|
support: 0.94,
|
||||||
|
texture: 0.88,
|
||||||
|
gesture: 1,
|
||||||
|
brush: 0.9,
|
||||||
|
stinger: 0.92,
|
||||||
|
},
|
||||||
|
pianoBusActivityDucking: {
|
||||||
|
pad: 0.42,
|
||||||
|
support: 0.18,
|
||||||
|
texture: -0.06,
|
||||||
|
gesture: 0,
|
||||||
|
brush: -0.08,
|
||||||
|
stinger: 0,
|
||||||
|
},
|
||||||
|
noiseBusGain: 0.72,
|
||||||
|
compressor: {
|
||||||
|
thresholdDb: -18,
|
||||||
|
kneeDb: 18,
|
||||||
|
ratio: 2.1,
|
||||||
|
attackSeconds: 0.018,
|
||||||
|
releaseSeconds: 0.18,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
distanceWindowForFullActivityPixels: 140,
|
distanceWindowForFullActivityPixels: 140,
|
||||||
distanceWindowSeconds: 0.5,
|
distanceWindowSeconds: 0.5,
|
||||||
fallbackFrameSeconds: 1 / 60,
|
fallbackFrameSeconds: 1 / 60,
|
||||||
manicActivityThreshold: 0.82,
|
fullActivitySpeed: 0.86,
|
||||||
|
activityNoiseFloorSpeed: 0.025,
|
||||||
|
activityCurve: 0.74,
|
||||||
|
activitySoftCeiling: 0.96,
|
||||||
|
activityAttackSeconds: 0.055,
|
||||||
|
activityReleaseSeconds: 0.2,
|
||||||
|
minAudibleDistance: 0.0025,
|
||||||
|
manicActivityThreshold: 0.9,
|
||||||
|
manicReleaseThreshold: 0.76,
|
||||||
|
maniaSmoothingSeconds: 0.12,
|
||||||
|
minElapsedSeconds: 0.001,
|
||||||
},
|
},
|
||||||
muteGain: 0.0001,
|
muteGain: 0.0001,
|
||||||
muteRampSeconds: 0.02,
|
muteRampSeconds: 0.02,
|
||||||
|
|
@ -95,6 +149,7 @@ export const appConfig = {
|
||||||
offsetRandomSeconds: 0.4,
|
offsetRandomSeconds: 0.4,
|
||||||
scheduleAheadSeconds: 0.002,
|
scheduleAheadSeconds: 0.002,
|
||||||
silentGain: 0.0001,
|
silentGain: 0.0001,
|
||||||
|
filterType: 'bandpass',
|
||||||
},
|
},
|
||||||
startDelaySeconds: 0.02,
|
startDelaySeconds: 0.02,
|
||||||
vibeChangeStingerMinIntervalSeconds: 0.45,
|
vibeChangeStingerMinIntervalSeconds: 0.45,
|
||||||
|
|
@ -142,7 +197,158 @@ export const appConfig = {
|
||||||
pan: 0.2,
|
pan: 0.2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
chordVoicings: {
|
||||||
|
majorOpen: [0, 7, 12, 16],
|
||||||
|
minorOpen: [0, 7, 12, 15],
|
||||||
|
majorClosed: [0, 4, 7, 12, 16],
|
||||||
|
minorClosed: [0, 3, 7, 12, 15],
|
||||||
|
},
|
||||||
|
vibeChangeStinger: {
|
||||||
|
velocities: [0.1, 0.085, 0.07],
|
||||||
|
pans: [-0.16, 0, 0.16],
|
||||||
|
delaySends: [0.012, 0.014, 0.016],
|
||||||
|
lowpassExpression: 0.35,
|
||||||
|
},
|
||||||
|
highActivityExtra: {
|
||||||
|
barOffset: 1,
|
||||||
|
expressionMultiplier: 0.9,
|
||||||
|
},
|
||||||
|
padChord: {
|
||||||
|
velocities: [0.052, 0.041, 0.033],
|
||||||
|
expressionVelocityWeight: 0.02,
|
||||||
|
delaySend: 0.008,
|
||||||
|
lowpassExpressionWeight: 0.28,
|
||||||
|
},
|
||||||
|
supportNote: {
|
||||||
|
velocityBase: 0.105,
|
||||||
|
velocityExpressionWeight: 0.07,
|
||||||
|
durationBaseSeconds: 1.35,
|
||||||
|
durationExpressionSeconds: 0.4,
|
||||||
|
delaySendBase: 0.016,
|
||||||
|
delaySendExpressionWeight: 0.006,
|
||||||
|
lowpassExpressionWeight: 0.7,
|
||||||
|
expressionThreshold: 0.55,
|
||||||
|
offsetsByStyle: [
|
||||||
|
[0, 2, 12],
|
||||||
|
[1, 2, 0, 12],
|
||||||
|
[2, 12, 3, 13],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
textureNote: {
|
||||||
|
velocityBase: 0.09,
|
||||||
|
velocityExpressionWeight: 0.08,
|
||||||
|
durationBaseSeconds: 0.62,
|
||||||
|
durationExpressionSeconds: 0.24,
|
||||||
|
delaySendBase: 0.016,
|
||||||
|
delaySendExpressionWeight: 0.006,
|
||||||
|
idleExpressionThreshold: 0.35,
|
||||||
|
mediumExpressionThreshold: 0.7,
|
||||||
|
intenseSpacing: 1,
|
||||||
|
idlePhase: 1,
|
||||||
|
},
|
||||||
|
gestureAccent: {
|
||||||
|
rotationStrengthMultiplier: 3,
|
||||||
|
quantizeStepLookahead: 1,
|
||||||
|
velocityBase: 0.12,
|
||||||
|
velocityStrengthWeight: 0.09,
|
||||||
|
durationBaseSeconds: 0.48,
|
||||||
|
durationStrengthSeconds: 0.22,
|
||||||
|
delaySend: 0.012,
|
||||||
|
},
|
||||||
|
touchNote: {
|
||||||
|
registerBiasManiaAmount: 0,
|
||||||
|
velocityBase: 0.14,
|
||||||
|
velocityStrengthWeight: 0.11,
|
||||||
|
durationBaseSeconds: 0.55,
|
||||||
|
durationStrengthSeconds: 0.18,
|
||||||
|
delaySend: 0.006,
|
||||||
|
lowpassBaseExpression: 0.55,
|
||||||
|
lowpassStrengthWeight: 0.35,
|
||||||
|
},
|
||||||
|
brushPhrase: {
|
||||||
|
initialMotifOffset: -1,
|
||||||
|
energyRetain: 0.94,
|
||||||
|
maniaRetain: 0.92,
|
||||||
|
energyDecaySeconds: 0.72,
|
||||||
|
maniaDecaySeconds: 0.54,
|
||||||
|
fadeMinimumLifetimeSeconds: 0.001,
|
||||||
|
layerIntensityBase: 0.8,
|
||||||
|
layerIntensityManiaWeight: 0.42,
|
||||||
|
frameActivityWeight: 0.42,
|
||||||
|
frameManiaWeight: 0.18,
|
||||||
|
},
|
||||||
|
brushStream: {
|
||||||
|
inferredManiaThreshold: 0.82,
|
||||||
|
inferredManiaRange: 0.18,
|
||||||
|
registerManiaShift: 0.45,
|
||||||
|
chordToneEverySteps: 4,
|
||||||
|
durationBaseSeconds: 0.48,
|
||||||
|
durationIntensitySeconds: 0.08,
|
||||||
|
durationManiaSeconds: 0.34,
|
||||||
|
durationMinSeconds: 0.14,
|
||||||
|
durationMaxSeconds: 0.62,
|
||||||
|
delaySendBase: 0.012,
|
||||||
|
delaySendIntensityWeight: 0.011,
|
||||||
|
delaySendManiaWeight: 0.006,
|
||||||
|
delaySendMin: 0.006,
|
||||||
|
delaySendMax: 0.032,
|
||||||
|
velocityBase: 0.1,
|
||||||
|
velocityIntensityWeight: 0.13,
|
||||||
|
lowpassBaseExpression: 0.39,
|
||||||
|
lowpassIntensityWeight: 0.48,
|
||||||
|
lowpassManiaWeight: 0.18,
|
||||||
|
manicThreshold: 0.85,
|
||||||
|
intenseThreshold: 0.62,
|
||||||
|
activeThreshold: 0.34,
|
||||||
|
},
|
||||||
|
brushStreamEcho: {
|
||||||
|
maniaThreshold: 0.86,
|
||||||
|
stepModulo: 2,
|
||||||
|
stepRemainder: 1,
|
||||||
|
intensityThreshold: 0.95,
|
||||||
|
octaveSemitones: 12,
|
||||||
|
maxMidi: 88,
|
||||||
|
velocityBase: 0.045,
|
||||||
|
velocityIntensityWeight: 0.05,
|
||||||
|
durationMinSeconds: 0.11,
|
||||||
|
durationScale: 0.68,
|
||||||
|
panScale: -0.75,
|
||||||
|
delaySendMin: 0.006,
|
||||||
|
delaySendScale: 0.72,
|
||||||
|
lowpassBaseExpression: 0.62,
|
||||||
|
lowpassManiaWeight: 0.24,
|
||||||
|
},
|
||||||
|
brushMotif: {
|
||||||
|
highThreshold: 0.82,
|
||||||
|
mediumThreshold: 0.55,
|
||||||
|
highOffset: 1,
|
||||||
|
mediumOffset: 0,
|
||||||
|
lowOffset: -1,
|
||||||
|
minOffset: -3,
|
||||||
|
maxOffset: 4,
|
||||||
|
},
|
||||||
|
registerBias: {
|
||||||
|
maniaShiftSemitones: 4,
|
||||||
|
midiMin: 36,
|
||||||
|
midiMaxForMin: 86,
|
||||||
|
minimumSpan: 4,
|
||||||
|
midiMax: 91,
|
||||||
|
},
|
||||||
|
candidateOctaveSearch: {
|
||||||
|
min: -3,
|
||||||
|
max: 3,
|
||||||
|
},
|
||||||
|
styleRotationMinSeconds: 0.001,
|
||||||
|
stylePanOffsetScale: 0.35,
|
||||||
|
lowpass: {
|
||||||
|
midiBase: 48,
|
||||||
|
midiRange: 33,
|
||||||
|
midiLiftHz: 720,
|
||||||
|
expressionBase: 0.58,
|
||||||
|
expressionWeight: 0.32,
|
||||||
|
},
|
||||||
styleRotationSeconds: 8,
|
styleRotationSeconds: 8,
|
||||||
|
styleRotationBars: 2,
|
||||||
chordBars: 4,
|
chordBars: 4,
|
||||||
supportBarSpacing: 2,
|
supportBarSpacing: 2,
|
||||||
supportBarOffset: 1,
|
supportBarOffset: 1,
|
||||||
|
|
@ -153,20 +359,23 @@ export const appConfig = {
|
||||||
highActivityExtraThreshold: 0.45,
|
highActivityExtraThreshold: 0.45,
|
||||||
noteScorePreferenceWeight: 1.8,
|
noteScorePreferenceWeight: 1.8,
|
||||||
noteScoreRegisterWeight: 0.28,
|
noteScoreRegisterWeight: 0.28,
|
||||||
|
noteScoreChordToneWeight: 0.75,
|
||||||
noteScoreRepeatPenalty: 3.2,
|
noteScoreRepeatPenalty: 3.2,
|
||||||
gestureAccentMinIntervalSeconds: 2.5,
|
gestureAccentMinIntervalSeconds: 2.5,
|
||||||
strokeAccentMinIntervalSeconds: 3.2,
|
strokeAccentMinIntervalSeconds: 3.2,
|
||||||
|
strokeAccentMinSteps: 12,
|
||||||
strokeAccentThreshold: 0.58,
|
strokeAccentThreshold: 0.58,
|
||||||
stingerSpacingSeconds: 0.08,
|
stingerSpacingSeconds: 0.08,
|
||||||
stingerDurationSeconds: 1.1,
|
stingerDurationSeconds: 1.1,
|
||||||
maxBrushPhraseLayers: 5,
|
maxBrushPhraseLayers: 3,
|
||||||
|
maxBrushStreamNotesPerBar: 9,
|
||||||
brushLayerBaseSeconds: 5.5,
|
brushLayerBaseSeconds: 5.5,
|
||||||
brushLayerEnergySeconds: 2.5,
|
brushLayerEnergySeconds: 2.5,
|
||||||
brushLayerMinIntensity: 0.08,
|
brushLayerMinIntensity: 0.12,
|
||||||
brushStreamIdleIntervalBeats: 2,
|
brushStreamIdleIntervalBeats: 2,
|
||||||
brushStreamActiveIntervalBeats: 1,
|
brushStreamActiveIntervalBeats: 1,
|
||||||
brushStreamIntenseIntervalBeats: 0.5,
|
brushStreamIntenseIntervalBeats: 0.5,
|
||||||
brushStreamManicIntervalBeats: 0.25,
|
brushStreamManicIntervalBeats: 0.5,
|
||||||
brushMotifMaxSteps: 8,
|
brushMotifMaxSteps: 8,
|
||||||
brushMotifCanonDelaySeconds: 0.055,
|
brushMotifCanonDelaySeconds: 0.055,
|
||||||
padDurationBarScale: 0.46,
|
padDurationBarScale: 0.46,
|
||||||
|
|
@ -195,10 +404,15 @@ export const appConfig = {
|
||||||
},
|
},
|
||||||
export4k: {
|
export4k: {
|
||||||
bytesPerPixel: 4,
|
bytesPerPixel: 4,
|
||||||
|
filenameExtension: 'png',
|
||||||
|
filenamePrefix: 'fleeting-garden',
|
||||||
|
filenameSuffix: '-upscale',
|
||||||
height: 2160,
|
height: 2160,
|
||||||
jsHeapSafetyMultiplier: 1.5,
|
jsHeapSafetyMultiplier: 1.5,
|
||||||
|
jsHeapTextureBytesMultiplier: 4,
|
||||||
lowMemoryDeviceGiB: 2,
|
lowMemoryDeviceGiB: 2,
|
||||||
lowMemoryExportFraction: 0.08,
|
lowMemoryExportFraction: 0.08,
|
||||||
|
mimeType: 'image/png',
|
||||||
rowAlignmentBytes: 256,
|
rowAlignmentBytes: 256,
|
||||||
width: 3840,
|
width: 3840,
|
||||||
},
|
},
|
||||||
|
|
@ -208,6 +422,17 @@ export const appConfig = {
|
||||||
hideDelayMs: 3000,
|
hideDelayMs: 3000,
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
|
common: {
|
||||||
|
noiseChannelSeeds: [0, 1, 2, 3],
|
||||||
|
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
noiseDrawInstanceCount: 1,
|
||||||
|
noiseDrawVertexCount: 3,
|
||||||
|
noiseHashMultiplier: 43758.5453123,
|
||||||
|
noiseHashX: 12.9898,
|
||||||
|
noiseHashY: 78.233,
|
||||||
|
noiseTextureFormat: 'rgba8unorm',
|
||||||
|
noiseTextureSize: 2048,
|
||||||
|
},
|
||||||
brush: {
|
brush: {
|
||||||
maxLineCount: 240,
|
maxLineCount: 240,
|
||||||
},
|
},
|
||||||
|
|
@ -228,20 +453,27 @@ export const appConfig = {
|
||||||
adaptiveCapInitial: 1_000_000,
|
adaptiveCapInitial: 1_000_000,
|
||||||
adaptiveCapMax: 2_000_000,
|
adaptiveCapMax: 2_000_000,
|
||||||
adaptiveCapMin: 500_000,
|
adaptiveCapMin: 500_000,
|
||||||
|
adaptiveRefreshTargetFps: 60,
|
||||||
|
frameGapResetSeconds: 1,
|
||||||
fpsHeadroom: 0.95,
|
fpsHeadroom: 0.95,
|
||||||
fpsSmoothingNew: 0.06,
|
fpsSmoothingNew: 0.06,
|
||||||
fpsSmoothingRetain: 0.94,
|
fpsSmoothingRetain: 0.94,
|
||||||
|
initialFps: 60,
|
||||||
},
|
},
|
||||||
brushEffectFramesPerSecond: 60,
|
brushEffectFramesPerSecond: 60,
|
||||||
|
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
initialAgentCount: 180_000,
|
initialAgentCount: 180_000,
|
||||||
intro: {
|
intro: {
|
||||||
angleJitterRadians: Math.PI * 0.08,
|
angleJitterRadians: Math.PI * 0.08,
|
||||||
|
angleEaseEnd: 1,
|
||||||
|
angleEaseStart: 0.6,
|
||||||
circleMaxSideRatio: 0.46,
|
circleMaxSideRatio: 0.46,
|
||||||
circleMinSideRatio: 0.32,
|
circleMinSideRatio: 0.32,
|
||||||
drawHintDelayMs: 3000,
|
drawHintDelayMs: 3000,
|
||||||
durationSeconds: 4,
|
durationSeconds: 4,
|
||||||
entryJitterSideRatio: 0.035,
|
entryJitterSideRatio: 0.035,
|
||||||
fontScaleDown: 0.94,
|
fontScaleDown: 0.94,
|
||||||
|
fontFamily: '"Open Sans", sans-serif',
|
||||||
initialFontHeightRatio: 0.28,
|
initialFontHeightRatio: 0.28,
|
||||||
initialFontWidthRatio: 0.19,
|
initialFontWidthRatio: 0.19,
|
||||||
letterSpacingEm: 0.07,
|
letterSpacingEm: 0.07,
|
||||||
|
|
@ -253,7 +485,12 @@ export const appConfig = {
|
||||||
minEntryJitterPx: 6,
|
minEntryJitterPx: 6,
|
||||||
minFontSizePx: 18,
|
minFontSizePx: 18,
|
||||||
minTargetJitterPx: 1,
|
minTargetJitterPx: 1,
|
||||||
|
pathEasing: 'easeOutQuad' as GardenAppConfig['simulation']['intro']['pathEasing'],
|
||||||
|
pathProgressEpsilon: 0.001,
|
||||||
radialJitterRatio: 0.35,
|
radialJitterRatio: 0.35,
|
||||||
|
radialStartEpsilon: 0.001,
|
||||||
|
resizeMinimumRemainingSeconds: 1.4,
|
||||||
|
resizeSettleMs: 120,
|
||||||
targetDelayDistanceMultiplier: 0.12,
|
targetDelayDistanceMultiplier: 0.12,
|
||||||
targetDelayMax: 0.22,
|
targetDelayMax: 0.22,
|
||||||
targetDelayRandomMultiplier: 0.06,
|
targetDelayRandomMultiplier: 0.06,
|
||||||
|
|
@ -272,10 +509,12 @@ export const appConfig = {
|
||||||
densityMultiplier: 110,
|
densityMultiplier: 110,
|
||||||
maxAgentCount: 2_400,
|
maxAgentCount: 2_400,
|
||||||
minAgentCount: 140,
|
minAgentCount: 140,
|
||||||
|
minSegmentLengthPx: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
audioMutedKey: 'fleeting-garden:audio-muted',
|
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||||
|
audioVolumeKey: 'fleeting-garden:audio-volume',
|
||||||
vibeKey: 'fleeting-garden:vibe',
|
vibeKey: 'fleeting-garden:vibe',
|
||||||
},
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
|
|
@ -289,6 +528,7 @@ export const appConfig = {
|
||||||
},
|
},
|
||||||
mirror: {
|
mirror: {
|
||||||
default: 1,
|
default: 1,
|
||||||
|
fallbackSegmentName: 'slices',
|
||||||
max: 12,
|
max: 12,
|
||||||
min: 1,
|
min: 1,
|
||||||
names: {
|
names: {
|
||||||
|
|
@ -304,8 +544,38 @@ export const appConfig = {
|
||||||
11: 'elevenths',
|
11: 'elevenths',
|
||||||
12: 'twelfths',
|
12: 'twelfths',
|
||||||
},
|
},
|
||||||
|
offLabel: 'Mirror off',
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
|
contrast: {
|
||||||
|
backgroundOpacityMax: 0.82,
|
||||||
|
brightLuminanceThreshold: 0.32,
|
||||||
|
brightWeight: 0.65,
|
||||||
|
bytesPerSample: 4,
|
||||||
|
contrastOffset: 0.05,
|
||||||
|
linearChannelBreakpoint: 0.03928,
|
||||||
|
linearChannelDivisor: 12.92,
|
||||||
|
linearChannelGamma: 2.4,
|
||||||
|
linearChannelOffset: 0.055,
|
||||||
|
linearChannelScale: 1.055,
|
||||||
|
lowContrastThreshold: 3,
|
||||||
|
lowContrastWeight: 1.8,
|
||||||
|
luminanceBase: 0.11,
|
||||||
|
luminanceBlueWeight: 0.0722,
|
||||||
|
luminanceGreenWeight: 0.7152,
|
||||||
|
luminanceRange: 0.28,
|
||||||
|
luminanceRedWeight: 0.2126,
|
||||||
|
sampleColumns: 13,
|
||||||
|
sampleIntervalMs: 300,
|
||||||
|
sampleRows: 7,
|
||||||
|
whiteContrastNumerator: 1.05,
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
default: defaultAudioMasterVolume,
|
||||||
|
max: 1,
|
||||||
|
min: 0,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tuningPane: {
|
tuningPane: {
|
||||||
expandedDepth: 1,
|
expandedDepth: 1,
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,59 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||||
...colorInteractionSettings,
|
...colorInteractionSettings,
|
||||||
|
|
||||||
turnWhenLost: 0.8,
|
turnWhenLost: 0.8,
|
||||||
|
sourceAttractionWeight: 24,
|
||||||
|
sourceSlowMoveRate: 0.08,
|
||||||
|
sourceTrailWeightMultiplier: 16,
|
||||||
|
forwardRotationScale: 0.25,
|
||||||
|
introNearDistanceMin: 28,
|
||||||
|
introNearDistanceInner: 4,
|
||||||
|
introNearSensorOffsetMultiplier: 0.75,
|
||||||
|
introTargetAngleBlend: 0.2,
|
||||||
|
introProgressCutoff: 0.999,
|
||||||
|
introTurnRateMultiplier: 3.4,
|
||||||
|
introRandomTurnMultiplier: 0.18,
|
||||||
|
introFarMoveMultiplier: 2.65,
|
||||||
|
introNearMoveMultiplier: 0.01,
|
||||||
|
introStepStopDistance: 0.5,
|
||||||
|
randomTimeScale: 0.34816,
|
||||||
|
|
||||||
diffusionRateBrush: 0.35,
|
diffusionRateBrush: 0.35,
|
||||||
decayRateBrush: 18,
|
decayRateBrush: 18,
|
||||||
|
diffusionDecayRateDivisor: 1000,
|
||||||
|
diffusionNeighborDivisor: 8,
|
||||||
|
brushDecayAlphaOffset: 1.001,
|
||||||
brushEffectDuration: 8,
|
brushEffectDuration: 8,
|
||||||
|
|
||||||
brushCurveResolution: 12,
|
brushCurveResolution: 12,
|
||||||
|
brushCurveMinBrushRadius: 1,
|
||||||
|
brushCurveMinSegmentSpacing: 4,
|
||||||
|
brushCurveMirrorResolutionExponent: 0.5,
|
||||||
|
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||||
|
brushSmoothingMinSampleDistance: 0.5,
|
||||||
|
|
||||||
brushSizeVariation: 0.5,
|
brushSizeVariation: 0.5,
|
||||||
|
brushAlpha: 1,
|
||||||
|
brushFeatherRatio: 0.22,
|
||||||
|
brushMinimumFeather: 1,
|
||||||
|
brushDiscardThreshold: 0.02,
|
||||||
|
brushCoarseNoiseScale: 160,
|
||||||
|
brushGrainNoiseScale: 22,
|
||||||
|
brushGrainNoiseOffsetX: 0.31,
|
||||||
|
brushGrainNoiseOffsetY: 0.67,
|
||||||
|
brushGrainMinStrength: 0.45,
|
||||||
|
brushGrainMaxStrength: 1,
|
||||||
|
|
||||||
|
eraserClearAlpha: 0,
|
||||||
|
eraserClearBlue: 0,
|
||||||
|
eraserClearGreen: 0,
|
||||||
|
eraserClearRed: 0,
|
||||||
|
eraserLineDistanceEpsilon: 0.0001,
|
||||||
|
eraserMaskAlphaThreshold: 0.5,
|
||||||
|
|
||||||
|
strokeSpawnSpreadBrushSizeMultiplier: 1,
|
||||||
|
|
||||||
|
renderTraceNormalizationFloor: 1,
|
||||||
|
renderBrushColorBase: 1.2,
|
||||||
|
renderBrushColorStrengthMultiplier: 1.6,
|
||||||
|
backgroundGrainStrength: 0.018,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,66 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
},
|
},
|
||||||
|
brushAlpha: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
brushFeatherRatio: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
brushMinimumFeather: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 8,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
brushDiscardThreshold: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 0.25,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
brushCoarseNoiseScale: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 1,
|
||||||
|
max: 480,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
brushGrainNoiseScale: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 1,
|
||||||
|
max: 180,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
brushGrainNoiseOffsetX: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: -2,
|
||||||
|
max: 2,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
brushGrainNoiseOffsetY: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: -2,
|
||||||
|
max: 2,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
brushGrainMinStrength: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
brushGrainMaxStrength: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
brushCurveResolution: {
|
brushCurveResolution: {
|
||||||
folder: 'Brush',
|
folder: 'Brush',
|
||||||
integer: true,
|
integer: true,
|
||||||
|
|
@ -37,12 +97,66 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 32,
|
max: 32,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
|
brushSmoothingMinSampleDistance: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
brushCurveMinSegmentSpacing: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0.1,
|
||||||
|
max: 32,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
brushCurveSegmentBrushRadiusRatio: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
brushCurveMinBrushRadius: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0.1,
|
||||||
|
max: 16,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
brushCurveMirrorResolutionExponent: {
|
||||||
|
folder: 'Brush',
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
clarity: {
|
clarity: {
|
||||||
folder: 'Render',
|
folder: 'Render',
|
||||||
min: 0.00001,
|
min: 0.00001,
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
|
renderTraceNormalizationFloor: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0.01,
|
||||||
|
max: 4,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
renderBrushColorBase: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
renderBrushColorStrengthMultiplier: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
backgroundGrainStrength: {
|
||||||
|
folder: 'Render',
|
||||||
|
min: 0,
|
||||||
|
max: 0.12,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
decayRateBrush: {
|
decayRateBrush: {
|
||||||
folder: 'Diffusion',
|
folder: 'Diffusion',
|
||||||
min: 0.1,
|
min: 0.1,
|
||||||
|
|
@ -61,6 +175,24 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
|
diffusionDecayRateDivisor: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 1,
|
||||||
|
max: 5000,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
diffusionNeighborDivisor: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 1,
|
||||||
|
max: 16,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
brushDecayAlphaOffset: {
|
||||||
|
folder: 'Diffusion',
|
||||||
|
min: 1,
|
||||||
|
max: 1.1,
|
||||||
|
step: 0.0001,
|
||||||
|
},
|
||||||
diffusionRateTrails: {
|
diffusionRateTrails: {
|
||||||
folder: 'Diffusion',
|
folder: 'Diffusion',
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|
@ -74,6 +206,42 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 240,
|
max: 240,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
|
eraserMaskAlphaThreshold: {
|
||||||
|
folder: 'Eraser',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
eraserLineDistanceEpsilon: {
|
||||||
|
folder: 'Eraser',
|
||||||
|
min: 0,
|
||||||
|
max: 0.01,
|
||||||
|
step: 0.00001,
|
||||||
|
},
|
||||||
|
eraserClearRed: {
|
||||||
|
folder: 'Eraser',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
eraserClearGreen: {
|
||||||
|
folder: 'Eraser',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
eraserClearBlue: {
|
||||||
|
folder: 'Eraser',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
eraserClearAlpha: {
|
||||||
|
folder: 'Eraser',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
individualTrailWeight: {
|
individualTrailWeight: {
|
||||||
folder: 'Agent',
|
folder: 'Agent',
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|
@ -118,6 +286,12 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
|
strokeSpawnSpreadBrushSizeMultiplier: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
turnSpeed: {
|
turnSpeed: {
|
||||||
folder: 'Agent',
|
folder: 'Agent',
|
||||||
min: 1,
|
min: 1,
|
||||||
|
|
@ -130,4 +304,94 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||||
max: 1,
|
max: 1,
|
||||||
step: 0.001,
|
step: 0.001,
|
||||||
},
|
},
|
||||||
|
sourceAttractionWeight: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 64,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
sourceSlowMoveRate: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
sourceTrailWeightMultiplier: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 64,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
forwardRotationScale: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
introNearDistanceMin: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
introNearDistanceInner: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 0.1,
|
||||||
|
},
|
||||||
|
introNearSensorOffsetMultiplier: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
introTargetAngleBlend: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
introProgressCutoff: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
introTurnRateMultiplier: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 8,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
introRandomTurnMultiplier: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
introFarMoveMultiplier: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 6,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
introNearMoveMultiplier: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.001,
|
||||||
|
},
|
||||||
|
introStepStopDistance: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 8,
|
||||||
|
step: 0.01,
|
||||||
|
},
|
||||||
|
randomTimeScale: {
|
||||||
|
folder: 'Agent',
|
||||||
|
min: 0,
|
||||||
|
max: 4,
|
||||||
|
step: 0.00001,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,23 @@ export interface NumberControlConfig {
|
||||||
|
|
||||||
export type GardenRuntimeSettings = {
|
export type GardenRuntimeSettings = {
|
||||||
brushCurveResolution: number;
|
brushCurveResolution: number;
|
||||||
|
brushCurveMinBrushRadius: number;
|
||||||
|
brushCurveMinSegmentSpacing: number;
|
||||||
|
brushCurveMirrorResolutionExponent: number;
|
||||||
|
brushCurveSegmentBrushRadiusRatio: number;
|
||||||
brushEffectDuration: number;
|
brushEffectDuration: number;
|
||||||
|
brushSmoothingMinSampleDistance: number;
|
||||||
|
eraserClearAlpha: number;
|
||||||
|
eraserClearBlue: number;
|
||||||
|
eraserClearGreen: number;
|
||||||
|
eraserClearRed: number;
|
||||||
|
eraserLineDistanceEpsilon: number;
|
||||||
|
eraserMaskAlphaThreshold: number;
|
||||||
eraserSize: number;
|
eraserSize: number;
|
||||||
mirrorSegmentCount: number;
|
mirrorSegmentCount: number;
|
||||||
selectedColorIndex: number;
|
selectedColorIndex: number;
|
||||||
spawnPerPixel: number;
|
spawnPerPixel: number;
|
||||||
|
strokeSpawnSpreadBrushSizeMultiplier: number;
|
||||||
} & AgentSettings &
|
} & AgentSettings &
|
||||||
BrushSettings &
|
BrushSettings &
|
||||||
DiffusionSettings &
|
DiffusionSettings &
|
||||||
|
|
@ -69,10 +81,15 @@ export interface GardenAppConfig {
|
||||||
};
|
};
|
||||||
export4k: {
|
export4k: {
|
||||||
bytesPerPixel: number;
|
bytesPerPixel: number;
|
||||||
|
filenameExtension: string;
|
||||||
|
filenamePrefix: string;
|
||||||
|
filenameSuffix: string;
|
||||||
height: number;
|
height: number;
|
||||||
jsHeapSafetyMultiplier: number;
|
jsHeapSafetyMultiplier: number;
|
||||||
|
jsHeapTextureBytesMultiplier: number;
|
||||||
lowMemoryDeviceGiB: number;
|
lowMemoryDeviceGiB: number;
|
||||||
lowMemoryExportFraction: number;
|
lowMemoryExportFraction: number;
|
||||||
|
mimeType: string;
|
||||||
rowAlignmentBytes: number;
|
rowAlignmentBytes: number;
|
||||||
width: number;
|
width: number;
|
||||||
};
|
};
|
||||||
|
|
@ -82,6 +99,17 @@ export interface GardenAppConfig {
|
||||||
hideDelayMs: number;
|
hideDelayMs: number;
|
||||||
};
|
};
|
||||||
pipelines: {
|
pipelines: {
|
||||||
|
common: {
|
||||||
|
noiseChannelSeeds: [number, number, number, number];
|
||||||
|
noiseClearValue: GPUColor;
|
||||||
|
noiseDrawInstanceCount: number;
|
||||||
|
noiseDrawVertexCount: number;
|
||||||
|
noiseHashMultiplier: number;
|
||||||
|
noiseHashX: number;
|
||||||
|
noiseHashY: number;
|
||||||
|
noiseTextureFormat: GPUTextureFormat;
|
||||||
|
noiseTextureSize: number;
|
||||||
|
};
|
||||||
brush: {
|
brush: {
|
||||||
maxLineCount: number;
|
maxLineCount: number;
|
||||||
};
|
};
|
||||||
|
|
@ -102,20 +130,27 @@ export interface GardenAppConfig {
|
||||||
adaptiveCapInitial: number;
|
adaptiveCapInitial: number;
|
||||||
adaptiveCapMax: number;
|
adaptiveCapMax: number;
|
||||||
adaptiveCapMin: number;
|
adaptiveCapMin: number;
|
||||||
|
adaptiveRefreshTargetFps: number;
|
||||||
|
frameGapResetSeconds: number;
|
||||||
fpsHeadroom: number;
|
fpsHeadroom: number;
|
||||||
fpsSmoothingNew: number;
|
fpsSmoothingNew: number;
|
||||||
fpsSmoothingRetain: number;
|
fpsSmoothingRetain: number;
|
||||||
|
initialFps: number;
|
||||||
};
|
};
|
||||||
brushEffectFramesPerSecond: number;
|
brushEffectFramesPerSecond: number;
|
||||||
|
clearColor: GPUColor;
|
||||||
initialAgentCount: number;
|
initialAgentCount: number;
|
||||||
intro: {
|
intro: {
|
||||||
angleJitterRadians: number;
|
angleJitterRadians: number;
|
||||||
|
angleEaseEnd: number;
|
||||||
|
angleEaseStart: number;
|
||||||
circleMaxSideRatio: number;
|
circleMaxSideRatio: number;
|
||||||
circleMinSideRatio: number;
|
circleMinSideRatio: number;
|
||||||
drawHintDelayMs: number;
|
drawHintDelayMs: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
entryJitterSideRatio: number;
|
entryJitterSideRatio: number;
|
||||||
fontScaleDown: number;
|
fontScaleDown: number;
|
||||||
|
fontFamily: string;
|
||||||
initialFontHeightRatio: number;
|
initialFontHeightRatio: number;
|
||||||
initialFontWidthRatio: number;
|
initialFontWidthRatio: number;
|
||||||
letterSpacingEm: number;
|
letterSpacingEm: number;
|
||||||
|
|
@ -127,7 +162,12 @@ export interface GardenAppConfig {
|
||||||
minEntryJitterPx: number;
|
minEntryJitterPx: number;
|
||||||
minFontSizePx: number;
|
minFontSizePx: number;
|
||||||
minTargetJitterPx: number;
|
minTargetJitterPx: number;
|
||||||
|
pathEasing: 'easeOutQuad' | 'linear';
|
||||||
|
pathProgressEpsilon: number;
|
||||||
radialJitterRatio: number;
|
radialJitterRatio: number;
|
||||||
|
radialStartEpsilon: number;
|
||||||
|
resizeMinimumRemainingSeconds: number;
|
||||||
|
resizeSettleMs: number;
|
||||||
targetDelayDistanceMultiplier: number;
|
targetDelayDistanceMultiplier: number;
|
||||||
targetDelayMax: number;
|
targetDelayMax: number;
|
||||||
targetDelayRandomMultiplier: number;
|
targetDelayRandomMultiplier: number;
|
||||||
|
|
@ -146,10 +186,12 @@ export interface GardenAppConfig {
|
||||||
densityMultiplier: number;
|
densityMultiplier: number;
|
||||||
maxAgentCount: number;
|
maxAgentCount: number;
|
||||||
minAgentCount: number;
|
minAgentCount: number;
|
||||||
|
minSegmentLengthPx: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
storage: {
|
storage: {
|
||||||
audioMutedKey: string;
|
audioMutedKey: string;
|
||||||
|
audioVolumeKey: string;
|
||||||
vibeKey: string;
|
vibeKey: string;
|
||||||
};
|
};
|
||||||
toolbar: {
|
toolbar: {
|
||||||
|
|
@ -163,9 +205,40 @@ export interface GardenAppConfig {
|
||||||
};
|
};
|
||||||
mirror: {
|
mirror: {
|
||||||
default: number;
|
default: number;
|
||||||
|
fallbackSegmentName: string;
|
||||||
max: number;
|
max: number;
|
||||||
min: number;
|
min: number;
|
||||||
names: Record<number, string>;
|
names: Record<number, string>;
|
||||||
|
offLabel: string;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
contrast: {
|
||||||
|
backgroundOpacityMax: number;
|
||||||
|
brightLuminanceThreshold: number;
|
||||||
|
brightWeight: number;
|
||||||
|
bytesPerSample: number;
|
||||||
|
contrastOffset: number;
|
||||||
|
linearChannelBreakpoint: number;
|
||||||
|
linearChannelDivisor: number;
|
||||||
|
linearChannelGamma: number;
|
||||||
|
linearChannelOffset: number;
|
||||||
|
linearChannelScale: number;
|
||||||
|
lowContrastThreshold: number;
|
||||||
|
lowContrastWeight: number;
|
||||||
|
luminanceBase: number;
|
||||||
|
luminanceBlueWeight: number;
|
||||||
|
luminanceGreenWeight: number;
|
||||||
|
luminanceRange: number;
|
||||||
|
luminanceRedWeight: number;
|
||||||
|
sampleColumns: number;
|
||||||
|
sampleIntervalMs: number;
|
||||||
|
sampleRows: number;
|
||||||
|
whiteContrastNumerator: number;
|
||||||
|
};
|
||||||
|
volume: {
|
||||||
|
default: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
step: number;
|
step: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ const minorProgression: Array<GardenAudioChord> = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const majorPentatonic = [0, 2, 4, 7, 9];
|
const majorPentatonic = [0, 2, 4, 7, 9];
|
||||||
const minorPentatonic = [0, 3, 5, 7, 10];
|
const suspendedPentatonic = [0, 2, 5, 7, 9];
|
||||||
|
const mixolydianPentatonic = [0, 2, 4, 7, 10];
|
||||||
|
const dorianHexatonic = [0, 2, 3, 5, 7, 10];
|
||||||
|
const darkMinorPentatonic = [0, 2, 3, 7, 10];
|
||||||
|
|
||||||
export const defaultVibeId = 'candy-rain';
|
export const defaultVibeId = 'candy-rain';
|
||||||
|
|
||||||
|
|
@ -65,7 +68,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 53,
|
rootMidi: 53,
|
||||||
scale: majorPentatonic,
|
scale: mixolydianPentatonic,
|
||||||
brightness: 0.92,
|
brightness: 0.92,
|
||||||
delayTimeMultiplier: 1.08,
|
delayTimeMultiplier: 1.08,
|
||||||
progression: [
|
progression: [
|
||||||
|
|
@ -95,7 +98,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 50,
|
rootMidi: 50,
|
||||||
scale: minorPentatonic,
|
scale: dorianHexatonic,
|
||||||
brightness: 1,
|
brightness: 1,
|
||||||
delayTimeMultiplier: 1.12,
|
delayTimeMultiplier: 1.12,
|
||||||
progression: minorProgression,
|
progression: minorProgression,
|
||||||
|
|
@ -120,7 +123,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 49,
|
rootMidi: 49,
|
||||||
scale: minorPentatonic,
|
scale: darkMinorPentatonic,
|
||||||
brightness: 0.9,
|
brightness: 0.9,
|
||||||
delayTimeMultiplier: 1.24,
|
delayTimeMultiplier: 1.24,
|
||||||
progression: minorProgression,
|
progression: minorProgression,
|
||||||
|
|
@ -170,7 +173,7 @@ export const vibePresets: Array<VibePreset> = [
|
||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
rootMidi: 62,
|
rootMidi: 62,
|
||||||
scale: majorPentatonic,
|
scale: suspendedPentatonic,
|
||||||
brightness: 0.88,
|
brightness: 0.88,
|
||||||
delayTimeMultiplier: 1.32,
|
delayTimeMultiplier: 1.32,
|
||||||
progression: [
|
progression: [
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,6 @@ import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/ag
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import { createIntroTitleAgents } from './intro-title-agents';
|
import { createIntroTitleAgents } from './intro-title-agents';
|
||||||
|
|
||||||
const INITIAL_AGENT_COUNT = appConfig.simulation.initialAgentCount;
|
|
||||||
const MIN_STROKE_AGENT_COUNT = appConfig.simulation.stroke.minAgentCount;
|
|
||||||
const MAX_STROKE_AGENT_COUNT = appConfig.simulation.stroke.maxAgentCount;
|
|
||||||
const STROKE_AGENT_DENSITY_MULTIPLIER = appConfig.simulation.stroke.densityMultiplier;
|
|
||||||
const ADAPTIVE_CAP_MAX = appConfig.simulation.budget.adaptiveCapMax;
|
|
||||||
const ADAPTIVE_CAP_MIN = appConfig.simulation.budget.adaptiveCapMin;
|
|
||||||
const ADAPTIVE_CAP_INITIAL = appConfig.simulation.budget.adaptiveCapInitial;
|
|
||||||
const ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND =
|
|
||||||
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond;
|
|
||||||
const ADAPTIVE_REFRESH_TARGET_FPS = 60;
|
|
||||||
|
|
||||||
export class AgentPopulation {
|
export class AgentPopulation {
|
||||||
private activeCount = 0;
|
private activeCount = 0;
|
||||||
private adaptiveCap: number;
|
private adaptiveCap: number;
|
||||||
|
|
@ -25,11 +14,16 @@ export class AgentPopulation {
|
||||||
private shouldCompactAfterErase = false;
|
private shouldCompactAfterErase = false;
|
||||||
private isCompacting = false;
|
private isCompacting = false;
|
||||||
private readonly strokeAgentData = new Float32Array(
|
private readonly strokeAgentData = new Float32Array(
|
||||||
MAX_STROKE_AGENT_COUNT * AGENT_FLOAT_COUNT
|
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||||
);
|
);
|
||||||
|
|
||||||
public constructor(private readonly pipeline: AgentGenerationPipeline) {
|
public constructor(
|
||||||
this.adaptiveCap = this.clampAdaptiveCap(ADAPTIVE_CAP_INITIAL);
|
private readonly pipeline: AgentGenerationPipeline,
|
||||||
|
private readonly introSeed = Math.floor(Math.random() * 0xffffffff)
|
||||||
|
) {
|
||||||
|
this.adaptiveCap = this.clampAdaptiveCap(
|
||||||
|
appConfig.simulation.budget.adaptiveCapInitial
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get activeAgentCount(): number {
|
public get activeAgentCount(): number {
|
||||||
|
|
@ -37,15 +31,30 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
public initializeIntroAgents(canvasSize: vec2): void {
|
public initializeIntroAgents(canvasSize: vec2): void {
|
||||||
|
this.replaceIntroAgents(canvasSize, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
|
||||||
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||||
const introAgentCount = Math.min(this.adaptiveCap, INITIAL_AGENT_COUNT);
|
const introAgentCount = Math.min(
|
||||||
this.writeAgentBatch(
|
this.adaptiveCap,
|
||||||
createIntroTitleAgents({
|
appConfig.simulation.initialAgentCount
|
||||||
|
);
|
||||||
|
const data = createIntroTitleAgents({
|
||||||
count: introAgentCount,
|
count: introAgentCount,
|
||||||
width: canvasSize[0],
|
width: canvasSize[0],
|
||||||
height: canvasSize[1],
|
height: canvasSize[1],
|
||||||
})
|
progress,
|
||||||
);
|
seed: this.introSeed,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pipeline.writeAgents(0, data);
|
||||||
|
this.activeCount = data.length / AGENT_FLOAT_COUNT;
|
||||||
|
this.replacementCursor = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onVibeChanged(): void {
|
public onVibeChanged(): void {
|
||||||
|
|
@ -87,12 +96,18 @@ export class AgentPopulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||||
const length = Math.max(1, vec2.dist(from, to));
|
const length = Math.max(
|
||||||
|
appConfig.simulation.stroke.minSegmentLengthPx,
|
||||||
|
vec2.dist(from, to)
|
||||||
|
);
|
||||||
const count = Math.max(
|
const count = Math.max(
|
||||||
MIN_STROKE_AGENT_COUNT,
|
appConfig.simulation.stroke.minAgentCount,
|
||||||
Math.min(
|
Math.min(
|
||||||
MAX_STROKE_AGENT_COUNT,
|
appConfig.simulation.stroke.maxAgentCount,
|
||||||
Math.ceil(length * settings.spawnPerPixel * STROKE_AGENT_DENSITY_MULTIPLIER)
|
this.strokeAgentData.length / AGENT_FLOAT_COUNT,
|
||||||
|
Math.ceil(
|
||||||
|
length * settings.spawnPerPixel * appConfig.simulation.stroke.densityMultiplier
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const direction = vec2.sub(vec2.create(), to, from);
|
const direction = vec2.sub(vec2.create(), to, from);
|
||||||
|
|
@ -106,8 +121,9 @@ export class AgentPopulation {
|
||||||
baseAngle +
|
baseAngle +
|
||||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||||
const base = i * AGENT_FLOAT_COUNT;
|
const base = i * AGENT_FLOAT_COUNT;
|
||||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * settings.brushSize;
|
const spread = settings.brushSize * settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * settings.brushSize;
|
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
||||||
|
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
||||||
this.strokeAgentData[base + 2] = angle;
|
this.strokeAgentData[base + 2] = angle;
|
||||||
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
|
this.strokeAgentData[base + 3] = settings.selectedColorIndex;
|
||||||
this.strokeAgentData[base + 4] = -1;
|
this.strokeAgentData[base + 4] = -1;
|
||||||
|
|
@ -164,7 +180,8 @@ export class AgentPopulation {
|
||||||
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
|
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||||
this.canExpandAdaptiveCap =
|
this.canExpandAdaptiveCap =
|
||||||
smoothedFps >=
|
smoothedFps >=
|
||||||
ADAPTIVE_REFRESH_TARGET_FPS * appConfig.simulation.budget.fpsHeadroom;
|
appConfig.simulation.budget.adaptiveRefreshTargetFps *
|
||||||
|
appConfig.simulation.budget.fpsHeadroom;
|
||||||
|
|
||||||
if (this.canExpandAdaptiveCap) {
|
if (this.canExpandAdaptiveCap) {
|
||||||
this.adaptiveCap = previousCap;
|
this.adaptiveCap = previousCap;
|
||||||
|
|
@ -174,7 +191,9 @@ export class AgentPopulation {
|
||||||
|
|
||||||
const decrease = Math.max(
|
const decrease = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil(ADAPTIVE_CAP_DECREASE_AGENTS_PER_SECOND * deltaTime)
|
Math.ceil(
|
||||||
|
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
const nextCap = this.clampAdaptiveCap(previousCap - decrease);
|
||||||
this.adaptiveCap = nextCap;
|
this.adaptiveCap = nextCap;
|
||||||
|
|
@ -207,8 +226,8 @@ export class AgentPopulation {
|
||||||
|
|
||||||
private clampAdaptiveCap(value: number): number {
|
private clampAdaptiveCap(value: number): number {
|
||||||
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
const pipelineCap = Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||||
const maxCap = Math.min(ADAPTIVE_CAP_MAX, pipelineCap);
|
const maxCap = Math.min(appConfig.simulation.budget.adaptiveCapMax, pipelineCap);
|
||||||
const minCap = Math.min(ADAPTIVE_CAP_MIN, maxCap);
|
const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
|
||||||
const finiteValue = Number.isFinite(value) ? value : minCap;
|
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||||
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
return Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||||
import {
|
import {
|
||||||
estimateExport4KMemory,
|
estimateExport4KMemory,
|
||||||
|
|
@ -133,14 +134,14 @@ export class Export4KRenderer {
|
||||||
throw new Error('Could not create export canvas');
|
throw new Error('Could not create export canvas');
|
||||||
}
|
}
|
||||||
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||||
const blob = await canvas.convertToBlob({ type: 'image/png' });
|
const blob = await canvas.convertToBlob({ type: appConfig.export4k.mimeType });
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
try {
|
try {
|
||||||
link.href = objectUrl;
|
link.href = objectUrl;
|
||||||
link.download = `fleeting-garden_${this.options.getVibeId()}_${
|
link.download = `${appConfig.export4k.filenamePrefix}_${this.options.getVibeId()}_${
|
||||||
this.options.seed
|
this.options.seed
|
||||||
}_${width}x${height}-upscale.png`;
|
}_${width}x${height}${appConfig.export4k.filenameSuffix}.${appConfig.export4k.filenameExtension}`;
|
||||||
link.click();
|
link.click();
|
||||||
} finally {
|
} finally {
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
|
@ -178,8 +179,8 @@ const readExportPixels = ({
|
||||||
const sourceOffset = y * bytesPerRow;
|
const sourceOffset = y * bytesPerRow;
|
||||||
const targetOffset = y * unpaddedBytesPerRow;
|
const targetOffset = y * unpaddedBytesPerRow;
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const source = sourceOffset + x * 4;
|
const source = sourceOffset + x * appConfig.export4k.bytesPerPixel;
|
||||||
const target = targetOffset + x * 4;
|
const target = targetOffset + x * appConfig.export4k.bytesPerPixel;
|
||||||
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||||
pixels[target + 1] = mapped[source + 1];
|
pixels[target + 1] = mapped[source + 1];
|
||||||
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
import { RuntimeError } from '../utils/error-handler';
|
import { RuntimeError } from '../utils/error-handler';
|
||||||
|
|
||||||
const EXPORT_4K_WIDTH = appConfig.export4k.width;
|
|
||||||
const EXPORT_4K_HEIGHT = appConfig.export4k.height;
|
|
||||||
|
|
||||||
const BYTES_PER_PIXEL = appConfig.export4k.bytesPerPixel;
|
|
||||||
const ROW_ALIGNMENT_BYTES = appConfig.export4k.rowAlignmentBytes;
|
|
||||||
const GIBIBYTE = 1024 ** 3;
|
const GIBIBYTE = 1024 ** 3;
|
||||||
const LOW_MEMORY_DEVICE_GIB = appConfig.export4k.lowMemoryDeviceGiB;
|
|
||||||
const LOW_MEMORY_EXPORT_FRACTION = appConfig.export4k.lowMemoryExportFraction;
|
|
||||||
const JS_HEAP_SAFETY_MULTIPLIER = appConfig.export4k.jsHeapSafetyMultiplier;
|
|
||||||
|
|
||||||
interface Export4KMemoryEstimate {
|
interface Export4KMemoryEstimate {
|
||||||
width: number;
|
width: number;
|
||||||
|
|
@ -50,8 +42,8 @@ const formatByteSize = (bytes: number): string => `${Math.ceil(bytes / 1024 / 10
|
||||||
export const getAspectFitExport4KDimensions = (
|
export const getAspectFitExport4KDimensions = (
|
||||||
sourceWidth: number,
|
sourceWidth: number,
|
||||||
sourceHeight: number,
|
sourceHeight: number,
|
||||||
maxWidth = EXPORT_4K_WIDTH,
|
maxWidth = appConfig.export4k.width,
|
||||||
maxHeight = EXPORT_4K_HEIGHT
|
maxHeight = appConfig.export4k.height
|
||||||
): Export4KDimensions => {
|
): Export4KDimensions => {
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(sourceWidth) ||
|
!Number.isFinite(sourceWidth) ||
|
||||||
|
|
@ -71,14 +63,15 @@ export const getAspectFitExport4KDimensions = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const estimateExport4KMemory = (
|
export const estimateExport4KMemory = (
|
||||||
width = EXPORT_4K_WIDTH,
|
width = appConfig.export4k.width,
|
||||||
height = EXPORT_4K_HEIGHT
|
height = appConfig.export4k.height
|
||||||
): Export4KMemoryEstimate => {
|
): Export4KMemoryEstimate => {
|
||||||
const unpaddedBytesPerRow = width * BYTES_PER_PIXEL;
|
const unpaddedBytesPerRow = width * appConfig.export4k.bytesPerPixel;
|
||||||
const bytesPerRow = alignTo(unpaddedBytesPerRow, ROW_ALIGNMENT_BYTES);
|
const bytesPerRow = alignTo(unpaddedBytesPerRow, appConfig.export4k.rowAlignmentBytes);
|
||||||
const textureBytes = unpaddedBytesPerRow * height;
|
const textureBytes = unpaddedBytesPerRow * height;
|
||||||
const readbackBufferBytes = bytesPerRow * height;
|
const readbackBufferBytes = bytesPerRow * height;
|
||||||
const estimatedJsHeapBytes = textureBytes * 4;
|
const estimatedJsHeapBytes =
|
||||||
|
textureBytes * appConfig.export4k.jsHeapTextureBytesMultiplier;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width,
|
width,
|
||||||
|
|
@ -135,7 +128,7 @@ export const getExport4KPreflightError = ({
|
||||||
) {
|
) {
|
||||||
return new RuntimeError(
|
return new RuntimeError(
|
||||||
'export-4k-texture-too-large',
|
'export-4k-texture-too-large',
|
||||||
'This GPU cannot create a 3840x2160 export texture.',
|
`This GPU cannot create a ${estimate.width}x${estimate.height} export texture.`,
|
||||||
{
|
{
|
||||||
details: {
|
details: {
|
||||||
exportWidth: estimate.width,
|
exportWidth: estimate.width,
|
||||||
|
|
@ -161,9 +154,9 @@ export const getExport4KPreflightError = ({
|
||||||
|
|
||||||
if (
|
if (
|
||||||
memoryInfo.deviceMemoryBytes !== undefined &&
|
memoryInfo.deviceMemoryBytes !== undefined &&
|
||||||
memoryInfo.deviceMemoryBytes <= LOW_MEMORY_DEVICE_GIB * GIBIBYTE &&
|
memoryInfo.deviceMemoryBytes <= appConfig.export4k.lowMemoryDeviceGiB * GIBIBYTE &&
|
||||||
estimate.estimatedPeakBytes >
|
estimate.estimatedPeakBytes >
|
||||||
memoryInfo.deviceMemoryBytes * LOW_MEMORY_EXPORT_FRACTION
|
memoryInfo.deviceMemoryBytes * appConfig.export4k.lowMemoryExportFraction
|
||||||
) {
|
) {
|
||||||
return new RuntimeError(
|
return new RuntimeError(
|
||||||
'export-4k-low-device-memory',
|
'export-4k-low-device-memory',
|
||||||
|
|
@ -187,7 +180,7 @@ export const getExport4KPreflightError = ({
|
||||||
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
|
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
|
||||||
if (
|
if (
|
||||||
availableJsHeapBytes <
|
availableJsHeapBytes <
|
||||||
estimate.estimatedJsHeapBytes * JS_HEAP_SAFETY_MULTIPLIER
|
estimate.estimatedJsHeapBytes * appConfig.export4k.jsHeapSafetyMultiplier
|
||||||
) {
|
) {
|
||||||
return new RuntimeError(
|
return new RuntimeError(
|
||||||
'export-4k-low-js-heap',
|
'export-4k-low-js-heap',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
const INITIAL_FPS = 60;
|
|
||||||
const FRAME_GAP_RESET_SECONDS = 1;
|
|
||||||
|
|
||||||
export class FramePerformance {
|
export class FramePerformance {
|
||||||
public smoothedFps = INITIAL_FPS;
|
public smoothedFps = appConfig.simulation.budget.initialFps;
|
||||||
|
|
||||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||||
|
|
||||||
|
|
@ -16,7 +13,10 @@ export class FramePerformance {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaSeconds = (time - previous) / 1000;
|
const deltaSeconds = (time - previous) / 1000;
|
||||||
if (deltaSeconds <= 0 || deltaSeconds > FRAME_GAP_RESET_SECONDS) {
|
if (
|
||||||
|
deltaSeconds <= 0 ||
|
||||||
|
deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ describe('GameLoop ping-pong texture flow', () => {
|
||||||
expect(resizableTextureSource).toContain('public getTexture(): GPUTexture');
|
expect(resizableTextureSource).toContain('public getTexture(): GPUTexture');
|
||||||
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
|
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_SRC');
|
||||||
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
|
expect(resizableTextureSource).toContain('GPUTextureUsage.COPY_DST');
|
||||||
expect(resizableTextureSource).toContain('this.copyPipeline.execute(');
|
expect(resizableTextureSource).toContain('commandEncoder.copyTextureToTexture(');
|
||||||
expect(simulationTexturesSource).toContain('private readonly copyPipeline');
|
expect(simulationTexturesSource).not.toContain('private readonly copyPipeline');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
it('keeps ping-pong texture references mutable and swaps A/B identities', () => {
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,12 @@ export class GameLoopResources {
|
||||||
this.agentPipeline = new AgentPipeline(
|
this.agentPipeline = new AgentPipeline(
|
||||||
this.device,
|
this.device,
|
||||||
this.commonState,
|
this.commonState,
|
||||||
this.agentGenerationPipeline.agentsBuffer
|
() => this.agentGenerationPipeline.agentsBuffer
|
||||||
);
|
);
|
||||||
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
this.brushPipeline = new BrushPipeline(this.device, this.commonState);
|
||||||
this.eraserAgentPipeline = new EraserAgentPipeline(
|
this.eraserAgentPipeline = new EraserAgentPipeline(
|
||||||
this.device,
|
this.device,
|
||||||
this.agentGenerationPipeline.agentsBuffer
|
() => this.agentGenerationPipeline.agentsBuffer
|
||||||
);
|
);
|
||||||
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
this.eraserTexturePipeline = new EraserTexturePipeline(this.device, this.commonState);
|
||||||
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
this.diffusionPipeline = new DiffusionPipeline(this.device);
|
||||||
|
|
@ -90,6 +90,10 @@ export class GameLoopResources {
|
||||||
return this.textures.resizeTo(nextSize);
|
return this.textures.resizeTo(nextSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public clearSimulation(): void {
|
||||||
|
this.textures.clear();
|
||||||
|
}
|
||||||
|
|
||||||
public setFrameParameters({
|
public setFrameParameters({
|
||||||
time,
|
time,
|
||||||
deltaTime,
|
deltaTime,
|
||||||
|
|
@ -129,9 +133,15 @@ export class GameLoopResources {
|
||||||
});
|
});
|
||||||
this.eraserAgentPipeline.setParameters({
|
this.eraserAgentPipeline.setParameters({
|
||||||
agentCount: activeAgentCount,
|
agentCount: activeAgentCount,
|
||||||
|
eraserMaskAlphaThreshold: settings.eraserMaskAlphaThreshold,
|
||||||
});
|
});
|
||||||
this.eraserTexturePipeline.setParameters({
|
this.eraserTexturePipeline.setParameters({
|
||||||
eraserSize: eraserPixelSize,
|
eraserSize: eraserPixelSize,
|
||||||
|
eraserLineDistanceEpsilon: settings.eraserLineDistanceEpsilon,
|
||||||
|
eraserClearRed: settings.eraserClearRed,
|
||||||
|
eraserClearGreen: settings.eraserClearGreen,
|
||||||
|
eraserClearBlue: settings.eraserClearBlue,
|
||||||
|
eraserClearAlpha: settings.eraserClearAlpha,
|
||||||
});
|
});
|
||||||
this.setBrushEffectDiffusionParameters();
|
this.setBrushEffectDiffusionParameters();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ import { RenderInputCache } from './render-input-cache';
|
||||||
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
||||||
|
|
||||||
export default class GameLoop {
|
export default class GameLoop {
|
||||||
private static readonly MAX_MIRROR_SEGMENT_COUNT = appConfig.toolbar.mirror.max;
|
|
||||||
|
|
||||||
private readonly resources: GameLoopResources;
|
private readonly resources: GameLoopResources;
|
||||||
private readonly audio = new GardenAudio(gardenAudioConfig);
|
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||||
private readonly renderInputs = new RenderInputCache();
|
private readonly renderInputs = new RenderInputCache();
|
||||||
|
|
@ -29,10 +27,12 @@ export default class GameLoop {
|
||||||
private readonly export4KRenderer: Export4KRenderer;
|
private readonly export4KRenderer: Export4KRenderer;
|
||||||
private readonly framePerformance = new FramePerformance();
|
private readonly framePerformance = new FramePerformance();
|
||||||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||||
private readonly seed = Math.floor(Math.random() * 0xffffffff).toString(16);
|
private readonly seedValue = Math.floor(Math.random() * 0xffffffff);
|
||||||
|
private readonly seed = this.seedValue.toString(16);
|
||||||
private readonly resizeListener = this.resize.bind(this);
|
private readonly resizeListener = this.resize.bind(this);
|
||||||
private readonly keydownListener: (event: KeyboardEvent) => void;
|
private readonly keydownListener: (event: KeyboardEvent) => void;
|
||||||
|
|
||||||
|
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||||
private hasFinished = false;
|
private hasFinished = false;
|
||||||
private readonly finished = Promise.withResolvers<void>();
|
private readonly finished = Promise.withResolvers<void>();
|
||||||
|
|
||||||
|
|
@ -47,7 +47,10 @@ export default class GameLoop {
|
||||||
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.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
||||||
this.agentPopulation = new AgentPopulation(this.resources.agentGenerationPipeline);
|
this.agentPopulation = new AgentPopulation(
|
||||||
|
this.resources.agentGenerationPipeline,
|
||||||
|
this.seedValue
|
||||||
|
);
|
||||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||||
this.pointerInput = new GardenPointerInput({
|
this.pointerInput = new GardenPointerInput({
|
||||||
canvas,
|
canvas,
|
||||||
|
|
@ -102,6 +105,10 @@ export default class GameLoop {
|
||||||
this.audio.setMuted(isMuted);
|
this.audio.setMuted(isMuted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setAudioVolume(volume: number): void {
|
||||||
|
this.audio.setMasterVolume(volume);
|
||||||
|
}
|
||||||
|
|
||||||
public startAudio(userGesture = false): void {
|
public startAudio(userGesture = false): void {
|
||||||
this.audio.start(activeVibe, { userGesture });
|
this.audio.start(activeVibe, { userGesture });
|
||||||
}
|
}
|
||||||
|
|
@ -141,9 +148,10 @@ export default class GameLoop {
|
||||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||||
this.framePerformance.update(time);
|
this.framePerformance.update(time);
|
||||||
this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
|
this.agentPopulation.growBudget(deltaTime, this.framePerformance.smoothedFps);
|
||||||
this.introPrompt.update();
|
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
|
||||||
this.resize();
|
this.resize();
|
||||||
this.resizeSimulationToCanvas();
|
this.resizeSimulationToCanvas(time);
|
||||||
|
this.regenerateIntroAfterSettledResize(time);
|
||||||
|
|
||||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||||
const introProgress = this.introPrompt.progress;
|
const introProgress = this.introPrompt.progress;
|
||||||
|
|
@ -198,7 +206,7 @@ export default class GameLoop {
|
||||||
this.canvas.height = height;
|
this.canvas.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resizeSimulationToCanvas(): void {
|
private resizeSimulationToCanvas(time: DOMHighResTimeStamp): void {
|
||||||
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
const scale = this.resources.resizeSimulationTo(this.canvasSize);
|
||||||
if (!scale) {
|
if (!scale) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -206,6 +214,32 @@ export default class GameLoop {
|
||||||
|
|
||||||
this.agentPopulation.resizeAgents(scale);
|
this.agentPopulation.resizeAgents(scale);
|
||||||
this.pointerInput.scaleLastPointerPosition(scale);
|
this.pointerInput.scaleLastPointerPosition(scale);
|
||||||
|
|
||||||
|
if (this.introPrompt.shouldRegenerateTitleOnResize) {
|
||||||
|
this.pendingIntroResizeAt = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private regenerateIntroAfterSettledResize(time: DOMHighResTimeStamp): void {
|
||||||
|
if (this.pendingIntroResizeAt === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.introPrompt.shouldRegenerateTitleOnResize) {
|
||||||
|
this.pendingIntroResizeAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.introPrompt.rewindToLeaveRemainingTime(
|
||||||
|
appConfig.simulation.intro.resizeMinimumRemainingSeconds
|
||||||
|
);
|
||||||
|
this.resources.clearSimulation();
|
||||||
|
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
|
||||||
|
this.pendingIntroResizeAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get canvasSize(): vec2 {
|
private get canvasSize(): vec2 {
|
||||||
|
|
@ -220,7 +254,10 @@ export default class GameLoop {
|
||||||
private get mirrorSegmentCount(): number {
|
private get mirrorSegmentCount(): number {
|
||||||
const count = Number.isFinite(settings.mirrorSegmentCount)
|
const count = Number.isFinite(settings.mirrorSegmentCount)
|
||||||
? settings.mirrorSegmentCount
|
? settings.mirrorSegmentCount
|
||||||
: 1;
|
: appConfig.toolbar.mirror.min;
|
||||||
return Math.min(GameLoop.MAX_MIRROR_SEGMENT_COUNT, Math.max(1, Math.round(count)));
|
return Math.min(
|
||||||
|
appConfig.toolbar.mirror.max,
|
||||||
|
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/game-loop/intro-prompt.test.ts
Normal file
58
src/game-loop/intro-prompt.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { appConfig } from '../config';
|
||||||
|
import { IntroPrompt } from './intro-prompt';
|
||||||
|
|
||||||
|
const createPromptElement = (): HTMLElement =>
|
||||||
|
({
|
||||||
|
classList: {
|
||||||
|
add: vi.fn(),
|
||||||
|
contains: vi.fn(() => false),
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
|
innerHTML: '',
|
||||||
|
replaceChildren: vi.fn(),
|
||||||
|
}) as unknown as HTMLElement;
|
||||||
|
|
||||||
|
describe('IntroPrompt', () => {
|
||||||
|
it('advances progress from simulation delta time', () => {
|
||||||
|
const prompt = new IntroPrompt(createPromptElement());
|
||||||
|
|
||||||
|
prompt.update(appConfig.simulation.intro.durationSeconds / 2);
|
||||||
|
|
||||||
|
expect(prompt.progress).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps progress when the intro completes', () => {
|
||||||
|
const prompt = new IntroPrompt(createPromptElement());
|
||||||
|
|
||||||
|
prompt.update(appConfig.simulation.intro.durationSeconds * 2);
|
||||||
|
prompt.update(appConfig.simulation.intro.durationSeconds);
|
||||||
|
|
||||||
|
expect(prompt.progress).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can rewind an active intro to leave a minimum resize paint window', () => {
|
||||||
|
const prompt = new IntroPrompt(createPromptElement());
|
||||||
|
|
||||||
|
prompt.update(appConfig.simulation.intro.durationSeconds * 0.95);
|
||||||
|
prompt.rewindToLeaveRemainingTime(appConfig.simulation.intro.durationSeconds * 0.25);
|
||||||
|
|
||||||
|
expect(prompt.progress).toBeCloseTo(0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows title regeneration only before drawing or completion', () => {
|
||||||
|
const prompt = new IntroPrompt(createPromptElement());
|
||||||
|
|
||||||
|
expect(prompt.shouldRegenerateTitleOnResize).toBe(true);
|
||||||
|
|
||||||
|
prompt.markStartedDrawing();
|
||||||
|
|
||||||
|
expect(prompt.shouldRegenerateTitleOnResize).toBe(false);
|
||||||
|
|
||||||
|
const completedPrompt = new IntroPrompt(createPromptElement());
|
||||||
|
completedPrompt.complete();
|
||||||
|
|
||||||
|
expect(completedPrompt.shouldRegenerateTitleOnResize).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { appConfig } from '../config';
|
import { appConfig } from '../config';
|
||||||
|
|
||||||
const INTRO_TITLE_DURATION_MS = appConfig.simulation.intro.durationSeconds * 1000;
|
|
||||||
const DRAW_HINT_CLASS = 'draw-hint';
|
const DRAW_HINT_CLASS = 'draw-hint';
|
||||||
|
|
||||||
export class IntroPrompt {
|
export class IntroPrompt {
|
||||||
private introComplete = false;
|
private introComplete = false;
|
||||||
private introStartedAt = performance.now();
|
private introElapsedSeconds = 0;
|
||||||
private introCompletedAt: number | null = null;
|
private introCompletedAt: number | null = null;
|
||||||
private hasStartedDrawing = false;
|
private hasStartedDrawing = false;
|
||||||
|
|
||||||
|
|
@ -14,13 +13,42 @@ export class IntroPrompt {
|
||||||
public get progress(): number {
|
public get progress(): number {
|
||||||
return this.introComplete
|
return this.introComplete
|
||||||
? 1
|
? 1
|
||||||
: Math.min(1, (performance.now() - this.introStartedAt) / INTRO_TITLE_DURATION_MS);
|
: Math.min(
|
||||||
|
1,
|
||||||
|
this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(): void {
|
public get shouldRegenerateTitleOnResize(): boolean {
|
||||||
|
return !this.introComplete && !this.hasStartedDrawing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public rewindToLeaveRemainingTime(remainingSeconds: number): void {
|
||||||
|
if (this.introComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRemainingSeconds = Number.isFinite(remainingSeconds)
|
||||||
|
? Math.max(0, remainingSeconds)
|
||||||
|
: 0;
|
||||||
|
this.introElapsedSeconds = Math.min(
|
||||||
|
this.introElapsedSeconds,
|
||||||
|
Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(deltaTime: number): void {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
|
|
||||||
if (!this.introComplete && now - this.introStartedAt > INTRO_TITLE_DURATION_MS) {
|
if (!this.introComplete) {
|
||||||
|
const safeDeltaTime = Number.isFinite(deltaTime) ? Math.max(0, deltaTime) : 0;
|
||||||
|
this.introElapsedSeconds += safeDeltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.introComplete &&
|
||||||
|
this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
|
||||||
|
) {
|
||||||
this.complete(now);
|
this.complete(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
97
src/game-loop/intro-title-agents.test.ts
Normal file
97
src/game-loop/intro-title-agents.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-generation/agent';
|
||||||
|
import { createIntroTitleAgents } from './intro-title-agents';
|
||||||
|
|
||||||
|
const installCanvasMask = () => {
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: vi.fn(() => {
|
||||||
|
const canvas = {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
getContext: vi.fn(() => ({
|
||||||
|
clearRect: vi.fn(),
|
||||||
|
fillStyle: '',
|
||||||
|
fillText: vi.fn(),
|
||||||
|
font: '',
|
||||||
|
getImageData: vi.fn(() => {
|
||||||
|
const data = new Uint8ClampedArray(canvas.width * canvas.height * 4);
|
||||||
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
|
data[i] = 255;
|
||||||
|
}
|
||||||
|
return { data };
|
||||||
|
}),
|
||||||
|
lineJoin: '',
|
||||||
|
lineWidth: 0,
|
||||||
|
measureText: vi.fn((text: string) => ({
|
||||||
|
actualBoundingBoxAscent: 10,
|
||||||
|
actualBoundingBoxDescent: 4,
|
||||||
|
width: text.length * 10,
|
||||||
|
})),
|
||||||
|
strokeStyle: '',
|
||||||
|
strokeText: vi.fn(),
|
||||||
|
textAlign: '',
|
||||||
|
textBaseline: '',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createIntroTitleAgents', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
installCanvasMask();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'document');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates deterministic agents for the same seed and progress', () => {
|
||||||
|
const first = createIntroTitleAgents({
|
||||||
|
count: 4,
|
||||||
|
height: 32,
|
||||||
|
progress: 0.4,
|
||||||
|
seed: 123,
|
||||||
|
width: 64,
|
||||||
|
});
|
||||||
|
const second = createIntroTitleAgents({
|
||||||
|
count: 4,
|
||||||
|
height: 32,
|
||||||
|
progress: 0.4,
|
||||||
|
seed: 123,
|
||||||
|
width: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(first)).toEqual(Array.from(second));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves targets while advancing positions for a later intro progress', () => {
|
||||||
|
const initial = createIntroTitleAgents({
|
||||||
|
count: 1,
|
||||||
|
height: 32,
|
||||||
|
progress: 0,
|
||||||
|
seed: 456,
|
||||||
|
width: 64,
|
||||||
|
});
|
||||||
|
const later = createIntroTitleAgents({
|
||||||
|
count: 1,
|
||||||
|
height: 32,
|
||||||
|
progress: 0.8,
|
||||||
|
seed: 456,
|
||||||
|
width: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(later[0]).not.toBe(initial[0]);
|
||||||
|
expect(later[1]).not.toBe(initial[1]);
|
||||||
|
expect(later[4]).toBe(initial[4]);
|
||||||
|
expect(later[5]).toBe(initial[5]);
|
||||||
|
expect(later[7]).toBe(initial[7]);
|
||||||
|
expect(later.length).toBe(AGENT_FLOAT_COUNT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,19 +12,27 @@ interface IntroTitleAgentOptions {
|
||||||
count: number;
|
count: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
progress?: number;
|
||||||
|
seed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RandomSource = () => number;
|
||||||
|
|
||||||
const INTRO_TITLE = appConfig.simulation.intro.title;
|
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||||
|
|
||||||
export const createIntroTitleAgents = ({
|
export const createIntroTitleAgents = ({
|
||||||
count,
|
count,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
progress = 0,
|
||||||
|
seed,
|
||||||
}: IntroTitleAgentOptions): Float32Array => {
|
}: IntroTitleAgentOptions): Float32Array => {
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
return new Float32Array();
|
return new Float32Array();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const random = seed === undefined ? Math.random : createSeededRandom(seed);
|
||||||
|
const introProgress = clamp(progress, 0, 1);
|
||||||
const safeWidth = Math.max(1, width);
|
const safeWidth = Math.max(1, width);
|
||||||
const safeHeight = Math.max(1, height);
|
const safeHeight = Math.max(1, height);
|
||||||
const points = createIntroTitlePoints(safeWidth, safeHeight);
|
const points = createIntroTitlePoints(safeWidth, safeHeight);
|
||||||
|
|
@ -62,14 +70,14 @@ export const createIntroTitleAgents = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const point = points[Math.floor(Math.random() * points.length)];
|
const point = points[Math.floor(random() * points.length)];
|
||||||
const targetX = Math.max(
|
const targetX = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(safeWidth - 1, point.x + (Math.random() - 0.5) * targetJitter)
|
Math.min(safeWidth - 1, point.x + (random() - 0.5) * targetJitter)
|
||||||
);
|
);
|
||||||
const targetY = Math.max(
|
const targetY = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(safeHeight - 1, point.y + (Math.random() - 0.5) * targetJitter)
|
Math.min(safeHeight - 1, point.y + (random() - 0.5) * targetJitter)
|
||||||
);
|
);
|
||||||
const [startX, startY] = getIntroRadialStart(
|
const [startX, startY] = getIntroRadialStart(
|
||||||
targetX,
|
targetX,
|
||||||
|
|
@ -77,7 +85,8 @@ export const createIntroTitleAgents = ({
|
||||||
safeWidth,
|
safeWidth,
|
||||||
safeHeight,
|
safeHeight,
|
||||||
introCircleRadius,
|
introCircleRadius,
|
||||||
entryJitter
|
entryJitter,
|
||||||
|
random
|
||||||
);
|
);
|
||||||
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
|
const approachAngle = Math.atan2(targetY - startY, targetX - startX);
|
||||||
let targetAngle = point.tangent ?? approachAngle;
|
let targetAngle = point.tangent ?? approachAngle;
|
||||||
|
|
@ -87,21 +96,32 @@ export const createIntroTitleAgents = ({
|
||||||
|
|
||||||
const distanceFraction =
|
const distanceFraction =
|
||||||
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
||||||
|
const introDelay = Math.min(
|
||||||
|
appConfig.simulation.intro.targetDelayMax,
|
||||||
|
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
||||||
|
random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
||||||
|
);
|
||||||
|
const pathProgress = getIntroAgentPathProgress(introProgress, introDelay);
|
||||||
|
const initialAngle =
|
||||||
|
approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
||||||
|
const currentAngle = mixAngle(
|
||||||
|
initialAngle,
|
||||||
|
targetAngle,
|
||||||
|
smoothstep(
|
||||||
|
appConfig.simulation.intro.angleEaseStart,
|
||||||
|
appConfig.simulation.intro.angleEaseEnd,
|
||||||
|
pathProgress
|
||||||
|
)
|
||||||
|
);
|
||||||
const base = i * AGENT_FLOAT_COUNT;
|
const base = i * AGENT_FLOAT_COUNT;
|
||||||
data[base] = startX;
|
data[base] = mix(startX, targetX, pathProgress);
|
||||||
data[base + 1] = startY;
|
data[base + 1] = mix(startY, targetY, pathProgress);
|
||||||
data[base + 2] =
|
data[base + 2] = currentAngle;
|
||||||
approachAngle +
|
|
||||||
(Math.random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
|
||||||
data[base + 3] = point.colorIndex;
|
data[base + 3] = point.colorIndex;
|
||||||
data[base + 4] = targetX;
|
data[base + 4] = targetX;
|
||||||
data[base + 5] = targetY;
|
data[base + 5] = targetY;
|
||||||
data[base + 6] = targetAngle;
|
data[base + 6] = targetAngle;
|
||||||
data[base + 7] = Math.min(
|
data[base + 7] = introDelay;
|
||||||
appConfig.simulation.intro.targetDelayMax,
|
|
||||||
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
|
||||||
Math.random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|
@ -113,7 +133,8 @@ const getIntroRadialStart = (
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
radius: number,
|
radius: number,
|
||||||
jitter: number
|
jitter: number,
|
||||||
|
random: RandomSource
|
||||||
): [number, number] => {
|
): [number, number] => {
|
||||||
const centerX = width / 2;
|
const centerX = width / 2;
|
||||||
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
||||||
|
|
@ -121,14 +142,16 @@ const getIntroRadialStart = (
|
||||||
const offsetY = targetY - centerY;
|
const offsetY = targetY - centerY;
|
||||||
const length = Math.hypot(offsetX, offsetY);
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
const angle =
|
const angle =
|
||||||
length > 0.001 ? Math.atan2(offsetY, offsetX) : Math.random() * Math.PI * 2;
|
length > appConfig.simulation.intro.radialStartEpsilon
|
||||||
|
? Math.atan2(offsetY, offsetX)
|
||||||
|
: random() * Math.PI * 2;
|
||||||
const directionX = Math.cos(angle);
|
const directionX = Math.cos(angle);
|
||||||
const directionY = Math.sin(angle);
|
const directionY = Math.sin(angle);
|
||||||
const tangentX = -directionY;
|
const tangentX = -directionY;
|
||||||
const tangentY = directionX;
|
const tangentY = directionX;
|
||||||
const tangentJitter = (Math.random() - 0.5) * jitter;
|
const tangentJitter = (random() - 0.5) * jitter;
|
||||||
const radialJitter =
|
const radialJitter =
|
||||||
(Math.random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
(random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
||||||
const startX =
|
const startX =
|
||||||
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
||||||
const startY =
|
const startY =
|
||||||
|
|
@ -154,7 +177,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 "Open Sans", sans-serif`;
|
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||||
context.textAlign = 'center';
|
context.textAlign = 'center';
|
||||||
context.textBaseline = 'middle';
|
context.textBaseline = 'middle';
|
||||||
context.fillStyle = '#fff';
|
context.fillStyle = '#fff';
|
||||||
|
|
@ -302,7 +325,7 @@ const getIntroTitleFontSize = (
|
||||||
);
|
);
|
||||||
|
|
||||||
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
||||||
context.font = `${fontSize}px "Open Sans", sans-serif`;
|
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||||
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;
|
||||||
|
|
@ -352,3 +375,53 @@ const getMaskAlpha = (
|
||||||
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
|
const clampedY = Math.max(0, Math.min(height - 1, Math.round(y)));
|
||||||
return data[(clampedY * width + clampedX) * 4 + 3];
|
return data[(clampedY * width + clampedX) * 4 + 3];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getIntroAgentPathProgress = (introProgress: number, introDelay: number): number => {
|
||||||
|
if (introProgress <= introDelay) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProgress =
|
||||||
|
(introProgress - introDelay) /
|
||||||
|
Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay);
|
||||||
|
return easePathProgress(clamp(activeProgress, 0, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSeededRandom = (seed: number): RandomSource => {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mix = (from: number, to: number, amount: number): number =>
|
||||||
|
from + (to - from) * amount;
|
||||||
|
|
||||||
|
const mixAngle = (from: number, to: number, amount: number): number => {
|
||||||
|
const delta = Math.atan2(Math.sin(to - from), Math.cos(to - from));
|
||||||
|
return from + delta * amount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||||
|
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const easePathProgress = (amount: number): number => {
|
||||||
|
if (appConfig.simulation.intro.pathEasing === 'linear') {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return easeOutQuad(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const easeOutQuad = (value: number): number => value * (2 - value);
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
const safeValue = Number.isFinite(value) ? value : min;
|
||||||
|
return Math.min(max, Math.max(min, safeValue));
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,31 @@ describe('GardenPointerInput drawing startup', () => {
|
||||||
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
|
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes normalized audio geometry context with stroke events', async () => {
|
||||||
|
const { audio, canvas } = await createPointerInput();
|
||||||
|
|
||||||
|
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9, timeStamp: 100 });
|
||||||
|
canvas.dispatchPointerEvent('pointermove', {
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 50,
|
||||||
|
pointerId: 9,
|
||||||
|
timeStamp: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(audio.stroke).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
canvasSize: [300, 200],
|
||||||
|
elapsedSeconds: 0.05,
|
||||||
|
from: expect.anything(),
|
||||||
|
isErasing: false,
|
||||||
|
to: expect.anything(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const stroke = audio.stroke.mock.calls[0][0];
|
||||||
|
expect(toPoint(stroke.from)).toEqual([10, 20]);
|
||||||
|
expect(toPoint(stroke.to)).toEqual([40, 50]);
|
||||||
|
});
|
||||||
|
|
||||||
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
it('caps curve tessellation with the brush curve resolution setting', async () => {
|
||||||
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
|
||||||
runtimeSettings.brushCurveResolution = 2;
|
runtimeSettings.brushCurveResolution = 2;
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,6 @@ interface GardenPointerInputOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GardenPointerInput {
|
export class GardenPointerInput {
|
||||||
private static readonly MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED = 0.25;
|
|
||||||
private static readonly MIN_CURVE_SEGMENT_SPACING_PIXELS = 4;
|
|
||||||
private static readonly CURVE_SEGMENT_BRUSH_RADIUS_RATIO = 0.65;
|
|
||||||
|
|
||||||
private activePointerId: number | null = null;
|
private activePointerId: number | null = null;
|
||||||
private lastPointerPosition: vec2 | null = null;
|
private lastPointerPosition: vec2 | null = null;
|
||||||
private lastPointerEventTimeMs: number | null = null;
|
private lastPointerEventTimeMs: number | null = null;
|
||||||
|
|
@ -194,6 +190,7 @@ export class GardenPointerInput {
|
||||||
vibe: activeVibe,
|
vibe: activeVibe,
|
||||||
from: previousPosition,
|
from: previousPosition,
|
||||||
to: position,
|
to: position,
|
||||||
|
canvasSize: [this.canvas.width, this.canvas.height],
|
||||||
isErasing: this.isErasing,
|
isErasing: this.isErasing,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
});
|
});
|
||||||
|
|
@ -216,8 +213,7 @@ export class GardenPointerInput {
|
||||||
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||||
if (
|
if (
|
||||||
previousSample !== undefined &&
|
previousSample !== undefined &&
|
||||||
vec2.squaredDistance(previousSample, position) <=
|
vec2.squaredDistance(previousSample, position) <= getBrushSmoothingDistanceSquared()
|
||||||
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -251,16 +247,22 @@ export class GardenPointerInput {
|
||||||
|
|
||||||
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
||||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||||
const brushRadius = Math.max(1, settings.brushSize / 2);
|
const brushRadius = Math.max(
|
||||||
|
settings.brushCurveMinBrushRadius,
|
||||||
|
settings.brushSize / 2
|
||||||
|
);
|
||||||
const segmentSpacing = Math.max(
|
const segmentSpacing = Math.max(
|
||||||
GardenPointerInput.MIN_CURVE_SEGMENT_SPACING_PIXELS,
|
settings.brushCurveMinSegmentSpacing,
|
||||||
brushRadius * GardenPointerInput.CURVE_SEGMENT_BRUSH_RADIUS_RATIO
|
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||||
);
|
);
|
||||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||||
const curveResolution = getBrushCurveResolution();
|
const curveResolution = getBrushCurveResolution();
|
||||||
const maxCurveSegments = Math.max(
|
const maxCurveSegments = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor(curveResolution / Math.sqrt(mirrorSegmentCount))
|
Math.floor(
|
||||||
|
curveResolution /
|
||||||
|
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const segmentCount = Math.min(
|
const segmentCount = Math.min(
|
||||||
maxCurveSegments,
|
maxCurveSegments,
|
||||||
|
|
@ -290,7 +292,7 @@ export class GardenPointerInput {
|
||||||
if (
|
if (
|
||||||
this.lastSmoothedBrushPosition !== null &&
|
this.lastSmoothedBrushPosition !== null &&
|
||||||
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
||||||
GardenPointerInput.MIN_SMOOTH_SAMPLE_DISTANCE_SQUARED
|
getBrushSmoothingDistanceSquared()
|
||||||
) {
|
) {
|
||||||
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
||||||
}
|
}
|
||||||
|
|
@ -372,6 +374,13 @@ const getBrushCurveResolution = (): number => {
|
||||||
return Math.max(1, Math.floor(resolution));
|
return Math.max(1, Math.floor(resolution));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBrushSmoothingDistanceSquared = (): number => {
|
||||||
|
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||||
|
? settings.brushSmoothingMinSampleDistance
|
||||||
|
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||||
|
return Math.max(0, distance) ** 2;
|
||||||
|
};
|
||||||
|
|
||||||
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||||
left.clientX === right.clientX &&
|
left.clientX === right.clientX &&
|
||||||
left.clientY === right.clientY &&
|
left.clientY === right.clientY &&
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,10 @@ export class SimulationFrameRenderer {
|
||||||
this.textures.copyTrailMapAToB(commandEncoder);
|
this.textures.copyTrailMapAToB(commandEncoder);
|
||||||
if (isErasing) {
|
if (isErasing) {
|
||||||
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
||||||
const eraserMask = this.textures.clearEraserMask(commandEncoder);
|
const eraserMask = this.textures.eraserMask.getTextureView();
|
||||||
this.pipelines.eraserTexturePipeline.execute(commandEncoder, eraserMask);
|
this.pipelines.eraserTexturePipeline.executeCombined(
|
||||||
this.pipelines.eraserTexturePipeline.executeMultiTarget(
|
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
|
eraserMask,
|
||||||
this.textures.sourceMapA.getTextureView(),
|
this.textures.sourceMapA.getTextureView(),
|
||||||
this.textures.influenceMapA.getTextureView(),
|
this.textures.influenceMapA.getTextureView(),
|
||||||
this.textures.trailMapB.getTextureView()
|
this.textures.trailMapB.getTextureView()
|
||||||
|
|
@ -59,7 +59,8 @@ export class SimulationFrameRenderer {
|
||||||
this.pipelines.diffusionPipeline.execute(
|
this.pipelines.diffusionPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.trailMapB.getTextureView(),
|
this.textures.trailMapB.getTextureView(),
|
||||||
this.textures.trailMapA.getTextureView()
|
this.textures.trailMapA.getTextureView(),
|
||||||
|
this.textures.trailMapA.getSize()
|
||||||
);
|
);
|
||||||
const canvasTexture = this.pipelines.renderPipeline.execute(
|
const canvasTexture = this.pipelines.renderPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
|
|
@ -70,12 +71,14 @@ export class SimulationFrameRenderer {
|
||||||
this.pipelines.diffusionPipeline.execute(
|
this.pipelines.diffusionPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.sourceMapA.getTextureView(),
|
this.textures.sourceMapA.getTextureView(),
|
||||||
this.textures.sourceMapB.getTextureView()
|
this.textures.sourceMapB.getTextureView(),
|
||||||
|
this.textures.sourceMapB.getSize()
|
||||||
);
|
);
|
||||||
this.pipelines.brushEffectDiffusionPipeline.execute(
|
this.pipelines.brushEffectDiffusionPipeline.execute(
|
||||||
commandEncoder,
|
commandEncoder,
|
||||||
this.textures.influenceMapA.getTextureView(),
|
this.textures.influenceMapA.getTextureView(),
|
||||||
this.textures.influenceMapB.getTextureView()
|
this.textures.influenceMapB.getTextureView(),
|
||||||
|
this.textures.influenceMapB.getSize()
|
||||||
);
|
);
|
||||||
|
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { CopyPipeline } from '../pipelines/copy/copy-pipeline';
|
import { appConfig } from '../config';
|
||||||
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
import { ResizableTexture } from '../utils/graphics/resizable-texture';
|
||||||
|
|
||||||
export class SimulationTextures {
|
export class SimulationTextures {
|
||||||
|
|
@ -11,13 +11,11 @@ export class SimulationTextures {
|
||||||
public influenceMapA: ResizableTexture;
|
public influenceMapA: ResizableTexture;
|
||||||
public influenceMapB: ResizableTexture;
|
public influenceMapB: ResizableTexture;
|
||||||
public eraserMask: ResizableTexture;
|
public eraserMask: ResizableTexture;
|
||||||
private readonly copyPipeline: CopyPipeline;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
canvasSize: vec2
|
canvasSize: vec2
|
||||||
) {
|
) {
|
||||||
this.copyPipeline = new CopyPipeline(this.device);
|
|
||||||
this.trailMapA = this.createTexture(canvasSize);
|
this.trailMapA = this.createTexture(canvasSize);
|
||||||
this.trailMapB = this.createTexture(canvasSize);
|
this.trailMapB = this.createTexture(canvasSize);
|
||||||
this.sourceMapA = this.createTexture(canvasSize);
|
this.sourceMapA = this.createTexture(canvasSize);
|
||||||
|
|
@ -45,21 +43,30 @@ export class SimulationTextures {
|
||||||
return scale;
|
return scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearEraserMask(commandEncoder: GPUCommandEncoder): GPUTextureView {
|
public clear(): void {
|
||||||
const eraserMaskView = this.eraserMask.getTextureView();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
|
[
|
||||||
|
this.trailMapA,
|
||||||
|
this.trailMapB,
|
||||||
|
this.sourceMapA,
|
||||||
|
this.sourceMapB,
|
||||||
|
this.influenceMapA,
|
||||||
|
this.influenceMapB,
|
||||||
|
this.eraserMask,
|
||||||
|
].forEach((texture) => {
|
||||||
const passEncoder = commandEncoder.beginRenderPass({
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
colorAttachments: [
|
colorAttachments: [
|
||||||
{
|
{
|
||||||
view: eraserMaskView,
|
view: texture.getTextureView(),
|
||||||
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
clearValue: appConfig.simulation.clearColor,
|
||||||
loadOp: 'clear',
|
loadOp: 'clear',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
});
|
||||||
return eraserMaskView;
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void {
|
public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void {
|
||||||
|
|
@ -85,10 +92,9 @@ export class SimulationTextures {
|
||||||
this.influenceMapA.destroy();
|
this.influenceMapA.destroy();
|
||||||
this.influenceMapB.destroy();
|
this.influenceMapB.destroy();
|
||||||
this.eraserMask.destroy();
|
this.eraserMask.destroy();
|
||||||
this.copyPipeline.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private createTexture(size: vec2): ResizableTexture {
|
private createTexture(size: vec2): ResizableTexture {
|
||||||
return new ResizableTexture(this.device, this.copyPipeline, size);
|
return new ResizableTexture(this.device, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { appConfig } from '../config';
|
||||||
import type { CanvasReadbackRequest } from './game-loop-types';
|
import type { CanvasReadbackRequest } from './game-loop-types';
|
||||||
|
|
||||||
interface CanvasSamplePoint {
|
interface CanvasSamplePoint {
|
||||||
|
|
@ -12,11 +13,6 @@ interface ToolbarContrastMetrics {
|
||||||
lowContrastRatio: number;
|
lowContrastRatio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BYTES_PER_SAMPLE = 4;
|
|
||||||
const SAMPLE_COLUMNS = 13;
|
|
||||||
const SAMPLE_ROWS = 7;
|
|
||||||
const SAMPLE_INTERVAL_MS = 300;
|
|
||||||
const BACKGROUND_OPACITY_MAX = 0.82;
|
|
||||||
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
||||||
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
||||||
|
|
||||||
|
|
@ -24,22 +20,30 @@ const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
const getLinearChannel = (channel: number): number => {
|
const getLinearChannel = (channel: number): number => {
|
||||||
const normalized = channel / 255;
|
const normalized = channel / 255;
|
||||||
return normalized <= 0.03928
|
return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
|
||||||
? normalized / 12.92
|
? normalized / appConfig.toolbar.contrast.linearChannelDivisor
|
||||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
: ((normalized + appConfig.toolbar.contrast.linearChannelOffset) /
|
||||||
|
appConfig.toolbar.contrast.linearChannelScale) **
|
||||||
|
appConfig.toolbar.contrast.linearChannelGamma;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
|
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
|
||||||
0.2126 * getLinearChannel(red) +
|
appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) +
|
||||||
0.7152 * getLinearChannel(green) +
|
appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
|
||||||
0.0722 * getLinearChannel(blue);
|
appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
|
||||||
|
|
||||||
export const getToolbarContrastMetrics = (
|
export const getToolbarContrastMetrics = (
|
||||||
pixels: Uint8Array,
|
pixels: Uint8Array,
|
||||||
sampleCount: number,
|
sampleCount: number,
|
||||||
isBgra: boolean
|
isBgra: boolean
|
||||||
): ToolbarContrastMetrics => {
|
): ToolbarContrastMetrics => {
|
||||||
const count = Math.max(0, Math.min(sampleCount, Math.floor(pixels.length / 4)));
|
const count = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
sampleCount,
|
||||||
|
Math.floor(pixels.length / appConfig.toolbar.contrast.bytesPerSample)
|
||||||
|
)
|
||||||
|
);
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
return {
|
return {
|
||||||
averageLuminance: 0,
|
averageLuminance: 0,
|
||||||
|
|
@ -54,18 +58,20 @@ export const getToolbarContrastMetrics = (
|
||||||
let lowContrastCount = 0;
|
let lowContrastCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const offset = i * BYTES_PER_SAMPLE;
|
const offset = i * appConfig.toolbar.contrast.bytesPerSample;
|
||||||
const red = pixels[offset + (isBgra ? 2 : 0)];
|
const red = pixels[offset + (isBgra ? 2 : 0)];
|
||||||
const green = pixels[offset + 1];
|
const green = pixels[offset + 1];
|
||||||
const blue = pixels[offset + (isBgra ? 0 : 2)];
|
const blue = pixels[offset + (isBgra ? 0 : 2)];
|
||||||
const luminance = getRelativeLuminance(red, green, blue);
|
const luminance = getRelativeLuminance(red, green, blue);
|
||||||
const contrastWithWhite = 1.05 / (luminance + 0.05);
|
const contrastWithWhite =
|
||||||
|
appConfig.toolbar.contrast.whiteContrastNumerator /
|
||||||
|
(luminance + appConfig.toolbar.contrast.contrastOffset);
|
||||||
|
|
||||||
luminanceTotal += luminance;
|
luminanceTotal += luminance;
|
||||||
if (luminance > 0.32) {
|
if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) {
|
||||||
brightCount++;
|
brightCount++;
|
||||||
}
|
}
|
||||||
if (contrastWithWhite < 3) {
|
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
|
||||||
lowContrastCount++;
|
lowContrastCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,11 +80,13 @@ export const getToolbarContrastMetrics = (
|
||||||
const brightRatio = brightCount / count;
|
const brightRatio = brightCount / count;
|
||||||
const lowContrastRatio = lowContrastCount / count;
|
const lowContrastRatio = lowContrastCount / count;
|
||||||
const backgroundStrength = clamp01(
|
const backgroundStrength = clamp01(
|
||||||
Math.max(0, averageLuminance - 0.11) / 0.28 +
|
Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) /
|
||||||
brightRatio * 0.65 +
|
appConfig.toolbar.contrast.luminanceRange +
|
||||||
lowContrastRatio * 1.8
|
brightRatio * appConfig.toolbar.contrast.brightWeight +
|
||||||
|
lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight
|
||||||
);
|
);
|
||||||
const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
|
const backgroundOpacity =
|
||||||
|
backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
averageLuminance,
|
averageLuminance,
|
||||||
|
|
@ -106,7 +114,7 @@ export class ToolbarContrastMonitor {
|
||||||
if (
|
if (
|
||||||
this.isDestroyed ||
|
this.isDestroyed ||
|
||||||
this.isReadbackPending ||
|
this.isReadbackPending ||
|
||||||
time - this.lastSampleAt < SAMPLE_INTERVAL_MS
|
time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +127,7 @@ export class ToolbarContrastMonitor {
|
||||||
let buffer: GPUBuffer;
|
let buffer: GPUBuffer;
|
||||||
try {
|
try {
|
||||||
buffer = this.device.createBuffer({
|
buffer = this.device.createBuffer({
|
||||||
size: samplePoints.length * BYTES_PER_SAMPLE,
|
size: samplePoints.length * appConfig.toolbar.contrast.bytesPerSample,
|
||||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -167,7 +175,7 @@ export class ToolbarContrastMonitor {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
buffer,
|
buffer,
|
||||||
offset: index * BYTES_PER_SAMPLE,
|
offset: index * appConfig.toolbar.contrast.bytesPerSample,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
depthOrArrayLayers: 1,
|
depthOrArrayLayers: 1,
|
||||||
|
|
@ -205,12 +213,12 @@ export class ToolbarContrastMonitor {
|
||||||
|
|
||||||
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
|
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
|
||||||
const safeBackgroundOpacity = Math.min(
|
const safeBackgroundOpacity = Math.min(
|
||||||
BACKGROUND_OPACITY_MAX,
|
appConfig.toolbar.contrast.backgroundOpacityMax,
|
||||||
Math.max(0, backgroundOpacity)
|
Math.max(0, backgroundOpacity)
|
||||||
);
|
);
|
||||||
const backgroundStrength =
|
const backgroundStrength =
|
||||||
BACKGROUND_OPACITY_MAX > 0
|
appConfig.toolbar.contrast.backgroundOpacityMax > 0
|
||||||
? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
|
? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
this.toolbar.style.setProperty(
|
this.toolbar.style.setProperty(
|
||||||
|
|
@ -249,15 +257,16 @@ export class ToolbarContrastMonitor {
|
||||||
const height = bottom - top;
|
const height = bottom - top;
|
||||||
const points = new Map<string, CanvasSamplePoint>();
|
const points = new Map<string, CanvasSamplePoint>();
|
||||||
|
|
||||||
for (let row = 0; row < SAMPLE_ROWS; row++) {
|
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
|
||||||
const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * height;
|
const cssY = top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * height;
|
||||||
const y = Math.min(
|
const y = Math.min(
|
||||||
this.canvas.height - 1,
|
this.canvas.height - 1,
|
||||||
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
|
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let column = 0; column < SAMPLE_COLUMNS; column++) {
|
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
|
||||||
const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * width;
|
const cssX =
|
||||||
|
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * width;
|
||||||
const x = Math.min(
|
const x = Math.min(
|
||||||
this.canvas.width - 1,
|
this.canvas.width - 1,
|
||||||
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
|
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
|
||||||
|
|
|
||||||
89
src/index.ts
89
src/index.ts
|
|
@ -11,6 +11,7 @@ import { FullScreenHandler } from './page/full-screen-handler';
|
||||||
import { MenuHider } from './page/menu-hider';
|
import { MenuHider } from './page/menu-hider';
|
||||||
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
import { activeVibe, applyVibeSettings, resetSettings, settings } from './settings';
|
||||||
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
import { readBrowserStorage, writeBrowserStorage } from './utils/browser-storage';
|
||||||
|
import { clamp } from './utils/clamp';
|
||||||
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 {
|
import {
|
||||||
|
|
@ -51,8 +52,30 @@ const mirrorSegmentNames: Readonly<Record<number, string>> =
|
||||||
|
|
||||||
const formatMirrorSegmentCount = (count: number): string =>
|
const formatMirrorSegmentCount = (count: number): string =>
|
||||||
count === appConfig.toolbar.mirror.default
|
count === appConfig.toolbar.mirror.default
|
||||||
? 'Mirror off'
|
? appConfig.toolbar.mirror.offLabel
|
||||||
: `${count} ${mirrorSegmentNames[count] ?? 'slices'}`;
|
: `${count} ${mirrorSegmentNames[count] ?? appConfig.toolbar.mirror.fallbackSegmentName}`;
|
||||||
|
|
||||||
|
const clampAudioVolume = (value: number): number => {
|
||||||
|
const safeValue = Number.isFinite(value) ? value : appConfig.toolbar.volume.default;
|
||||||
|
return clamp(safeValue, appConfig.toolbar.volume.min, appConfig.toolbar.volume.max);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAudioVolumeRatio = (volume: number): number =>
|
||||||
|
(volume - appConfig.toolbar.volume.min) /
|
||||||
|
(appConfig.toolbar.volume.max - appConfig.toolbar.volume.min);
|
||||||
|
|
||||||
|
const getAudioVolumePercent = (volume: number): number =>
|
||||||
|
Math.round(getAudioVolumeRatio(volume) * 100);
|
||||||
|
|
||||||
|
const readInitialAudioVolume = (): number => {
|
||||||
|
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
|
||||||
|
return storedVolume === null
|
||||||
|
? appConfig.toolbar.volume.default
|
||||||
|
: clampAudioVolume(Number(storedVolume));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStoredAudioVolume = (volume: number): string =>
|
||||||
|
clampAudioVolume(volume).toFixed(2);
|
||||||
|
|
||||||
type RuntimeUiError = Parameters<
|
type RuntimeUiError = Parameters<
|
||||||
Parameters<typeof ErrorHandler.addOnErrorListener>[0]
|
Parameters<typeof ErrorHandler.addOnErrorListener>[0]
|
||||||
|
|
@ -112,6 +135,8 @@ const queryAppElements = () => ({
|
||||||
),
|
),
|
||||||
settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
|
settingsButton: queryRequiredElement('button.settings', HTMLButtonElement),
|
||||||
soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
|
soundButton: queryRequiredElement('button.sound', HTMLButtonElement),
|
||||||
|
volumeControl: queryRequiredElement('.volume-control', HTMLLabelElement),
|
||||||
|
volumeSlider: queryRequiredElement('.volume-slider', HTMLInputElement),
|
||||||
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
restartButton: queryRequiredElement('button.restart', HTMLButtonElement),
|
||||||
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
canvas: queryRequiredElement('canvas', HTMLCanvasElement),
|
||||||
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
eraserPreview: queryRequiredElement('.eraser-preview', HTMLDivElement),
|
||||||
|
|
@ -141,18 +166,49 @@ const setLoadingStage = (label: string, ratio: number) => {
|
||||||
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
|
elements.loadingProgress.setAttribute('aria-valuenow', String(percent));
|
||||||
};
|
};
|
||||||
|
|
||||||
let isAudioMuted = readBrowserStorage(appConfig.storage.audioMutedKey) === '1';
|
let audioVolume = readInitialAudioVolume();
|
||||||
|
let isAudioMuted =
|
||||||
|
readBrowserStorage(appConfig.storage.audioMutedKey) === '1' ||
|
||||||
|
audioVolume <= appConfig.toolbar.volume.min;
|
||||||
let isEraserActive = false;
|
let isEraserActive = false;
|
||||||
|
|
||||||
|
const persistAudioUiState = () => {
|
||||||
|
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
||||||
|
writeBrowserStorage(
|
||||||
|
appConfig.storage.audioVolumeKey,
|
||||||
|
formatStoredAudioVolume(audioVolume)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderAudioUi = (game: GameLoop | null) => {
|
const renderAudioUi = (game: GameLoop | null) => {
|
||||||
elements.soundButton.classList.toggle('muted', isAudioMuted);
|
audioVolume = clampAudioVolume(audioVolume);
|
||||||
elements.soundButton.setAttribute('aria-pressed', String(isAudioMuted));
|
const isEffectivelyMuted = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
|
||||||
|
const volumePercent = getAudioVolumePercent(audioVolume);
|
||||||
|
|
||||||
|
elements.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
||||||
|
elements.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
||||||
elements.soundButton.setAttribute(
|
elements.soundButton.setAttribute(
|
||||||
'aria-label',
|
'aria-label',
|
||||||
isAudioMuted ? 'Unmute audio' : 'Mute audio'
|
isEffectivelyMuted ? 'Unmute audio' : 'Mute audio'
|
||||||
);
|
);
|
||||||
elements.soundButton.title = isAudioMuted ? 'Unmute audio' : 'Mute audio';
|
elements.soundButton.title = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
|
||||||
game?.setAudioMuted(isAudioMuted);
|
|
||||||
|
elements.volumeSlider.min = appConfig.toolbar.volume.min.toString();
|
||||||
|
elements.volumeSlider.max = appConfig.toolbar.volume.max.toString();
|
||||||
|
elements.volumeSlider.step = appConfig.toolbar.volume.step.toString();
|
||||||
|
elements.volumeSlider.value = formatStoredAudioVolume(audioVolume);
|
||||||
|
elements.volumeSlider.setAttribute(
|
||||||
|
'aria-valuetext',
|
||||||
|
isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
|
||||||
|
);
|
||||||
|
elements.volumeControl.classList.toggle('muted', isEffectivelyMuted);
|
||||||
|
elements.volumeControl.title = isEffectivelyMuted
|
||||||
|
? `Muted, ${volumePercent}% volume`
|
||||||
|
: `${volumePercent}% volume`;
|
||||||
|
elements.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
|
||||||
|
|
||||||
|
game?.setAudioVolume(audioVolume);
|
||||||
|
game?.setAudioMuted(isEffectivelyMuted);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPaletteUi = (game: GameLoop | null) => {
|
const renderPaletteUi = (game: GameLoop | null) => {
|
||||||
|
|
@ -322,8 +378,21 @@ const main = async () => {
|
||||||
|
|
||||||
elements.restartButton.addEventListener('click', () => game?.destroy());
|
elements.restartButton.addEventListener('click', () => game?.destroy());
|
||||||
elements.soundButton.addEventListener('click', () => {
|
elements.soundButton.addEventListener('click', () => {
|
||||||
isAudioMuted = !isAudioMuted;
|
const shouldUnmute = isAudioMuted || audioVolume <= appConfig.toolbar.volume.min;
|
||||||
writeBrowserStorage(appConfig.storage.audioMutedKey, isAudioMuted ? '1' : '0');
|
if (shouldUnmute && audioVolume <= appConfig.toolbar.volume.min) {
|
||||||
|
audioVolume = appConfig.toolbar.volume.default;
|
||||||
|
}
|
||||||
|
isAudioMuted = !shouldUnmute;
|
||||||
|
persistAudioUiState();
|
||||||
|
renderAudioUi(game);
|
||||||
|
if (!isAudioMuted) {
|
||||||
|
game?.startAudio(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
elements.volumeSlider.addEventListener('input', () => {
|
||||||
|
audioVolume = clampAudioVolume(Number(elements.volumeSlider.value));
|
||||||
|
isAudioMuted = audioVolume <= appConfig.toolbar.volume.min;
|
||||||
|
persistAudioUiState();
|
||||||
renderAudioUi(game);
|
renderAudioUi(game);
|
||||||
if (!isAudioMuted) {
|
if (!isAudioMuted) {
|
||||||
game?.startAudio(true);
|
game?.startAudio(true);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ struct Counters {
|
||||||
|
|
||||||
var<workgroup> workgroupAliveCount: atomic<u32>;
|
var<workgroup> workgroupAliveCount: atomic<u32>;
|
||||||
var<workgroup> workgroupCompactedOffset: u32;
|
var<workgroup> workgroupCompactedOffset: u32;
|
||||||
var<workgroup> workgroupCopyCount: u32;
|
|
||||||
|
|
||||||
@compute @workgroup_size(64)
|
@compute @workgroup_size(64)
|
||||||
fn main(
|
fn main(
|
||||||
|
|
@ -31,9 +30,12 @@ fn main(
|
||||||
workgroupBarrier();
|
workgroupBarrier();
|
||||||
|
|
||||||
var localCompactedIndex = 0u;
|
var localCompactedIndex = 0u;
|
||||||
|
var agent = Agent(vec2<f32>(0.0, 0.0), 0.0, -1.0, vec2<f32>(-1.0, -1.0), 0.0, 0.0);
|
||||||
|
var isAlive = false;
|
||||||
if id < settings.agentCount {
|
if id < settings.agentCount {
|
||||||
let agent = agents[id];
|
agent = agents[id];
|
||||||
if agent.colorIndex >= 0.0 {
|
isAlive = agent.colorIndex >= 0.0;
|
||||||
|
if isAlive {
|
||||||
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
|
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,30 +53,7 @@ fn main(
|
||||||
|
|
||||||
workgroupBarrier();
|
workgroupBarrier();
|
||||||
|
|
||||||
if id < settings.agentCount {
|
if isAlive {
|
||||||
let agent = agents[id];
|
|
||||||
if agent.colorIndex >= 0.0 {
|
|
||||||
compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;
|
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>
|
|
||||||
) {
|
|
||||||
let id = get_id(global_id);
|
|
||||||
|
|
||||||
if local_id.x == 0u {
|
|
||||||
workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
workgroupBarrier();
|
|
||||||
|
|
||||||
if id >= workgroupCopyCount {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
agents[id] = compactedAgents[id];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -177,17 +177,14 @@ const createPipeline = (compactedCount: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('AgentGenerationPipeline compaction', () => {
|
describe('AgentGenerationPipeline compaction', () => {
|
||||||
it('copies compacted agents back with compute instead of a full agent buffer copy', async () => {
|
it('swaps compacted agents into the active buffer without a copy-back dispatch', async () => {
|
||||||
const agentCount = 10;
|
const agentCount = 10;
|
||||||
const { device, pipeline } = createPipeline(3);
|
const { device, pipeline } = createPipeline(3);
|
||||||
|
|
||||||
await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3);
|
await expect(pipeline.compactAgents(agentCount)).resolves.toBe(3);
|
||||||
|
|
||||||
expect(device.createdComputeEntryPoints).toContain('copyCompactedAgents');
|
expect(device.createdComputeEntryPoints).not.toContain('copyCompactedAgents');
|
||||||
expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual([
|
expect(device.dispatchCalls.map((call) => call.entryPoint)).toEqual(['main']);
|
||||||
'main',
|
|
||||||
'copyCompactedAgents',
|
|
||||||
]);
|
|
||||||
expect(device.copyCalls.map((call) => call.size)).toEqual([
|
expect(device.copyCalls.map((call) => call.size)).toEqual([
|
||||||
Uint32Array.BYTES_PER_ELEMENT,
|
Uint32Array.BYTES_PER_ELEMENT,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,16 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroupsByActiveBuffer = new WeakMap<
|
||||||
|
GPUBuffer,
|
||||||
|
WeakMap<GPUBuffer, GPUBindGroup>
|
||||||
|
>();
|
||||||
|
|
||||||
private readonly resizePipeline: GPUComputePipeline;
|
private readonly resizePipeline: GPUComputePipeline;
|
||||||
private readonly compactionPipeline: GPUComputePipeline;
|
private readonly compactionPipeline: GPUComputePipeline;
|
||||||
private readonly compactedAgentsCopyPipeline: GPUComputePipeline;
|
|
||||||
|
|
||||||
public readonly agentsBuffer: GPUBuffer;
|
private activeAgentsBuffer: GPUBuffer;
|
||||||
private readonly compactedAgentsBuffer: GPUBuffer;
|
private inactiveAgentsBuffer: GPUBuffer;
|
||||||
private readonly countersBuffer: GPUBuffer;
|
private readonly countersBuffer: GPUBuffer;
|
||||||
private readonly countersStagingBuffer: GPUBuffer;
|
private readonly countersStagingBuffer: GPUBuffer;
|
||||||
private readonly counterClearValues = new Uint32Array(
|
private readonly counterClearValues = new Uint32Array(
|
||||||
|
|
@ -69,15 +71,8 @@ export class AgentGenerationPipeline {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.agentsBuffer = this.device.createBuffer({
|
this.activeAgentsBuffer = this.createAgentsBuffer();
|
||||||
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
this.inactiveAgentsBuffer = this.createAgentsBuffer();
|
||||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.compactedAgentsBuffer = this.device.createBuffer({
|
|
||||||
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
|
||||||
usage: GPUBufferUsage.STORAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.countersBuffer = this.device.createBuffer({
|
this.countersBuffer = this.device.createBuffer({
|
||||||
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
size: AgentGenerationPipeline.COUNTER_COUNT * Uint32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -94,36 +89,6 @@ export class AgentGenerationPipeline {
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroup = this.device.createBindGroup({
|
|
||||||
layout: this.bindGroupLayout,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: {
|
|
||||||
buffer: this.agentsBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
resource: {
|
|
||||||
buffer: this.countersBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 3,
|
|
||||||
resource: {
|
|
||||||
buffer: this.compactedAgentsBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.resizePipeline = device.createComputePipeline({
|
this.resizePipeline = device.createComputePipeline({
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||||
|
|
@ -145,15 +110,16 @@ export class AgentGenerationPipeline {
|
||||||
entryPoint: 'main',
|
entryPoint: 'main',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.compactedAgentsCopyPipeline = device.createComputePipeline({
|
public get agentsBuffer(): GPUBuffer {
|
||||||
layout: device.createPipelineLayout({
|
return this.activeAgentsBuffer;
|
||||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
}
|
||||||
}),
|
|
||||||
compute: {
|
private createAgentsBuffer(): GPUBuffer {
|
||||||
module: compactionModule,
|
return this.device.createBuffer({
|
||||||
entryPoint: 'copyCompactedAgents',
|
size: this.maxAgentCount * AGENT_SIZE_IN_BYTES,
|
||||||
},
|
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +129,10 @@ export class AgentGenerationPipeline {
|
||||||
? this.maxAgentCountUpperLimit
|
? this.maxAgentCountUpperLimit
|
||||||
: Number.POSITIVE_INFINITY,
|
: Number.POSITIVE_INFINITY,
|
||||||
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
|
Math.floor(this.device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES) - 1,
|
||||||
|
Math.floor(
|
||||||
|
((this.device.limits as GPUSupportedLimits).maxStorageBufferBindingSize ??
|
||||||
|
this.device.limits.maxBufferSize) / AGENT_SIZE_IN_BYTES
|
||||||
|
) - 1,
|
||||||
this.device.limits.maxComputeWorkgroupsPerDimension *
|
this.device.limits.maxComputeWorkgroupsPerDimension *
|
||||||
AgentGenerationPipeline.WORKGROUP_SIZE
|
AgentGenerationPipeline.WORKGROUP_SIZE
|
||||||
);
|
);
|
||||||
|
|
@ -170,7 +140,7 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
public writeAgents(agentOffset: number, data: Float32Array): void {
|
public writeAgents(agentOffset: number, data: Float32Array): void {
|
||||||
this.device.queue.writeBuffer(
|
this.device.queue.writeBuffer(
|
||||||
this.agentsBuffer,
|
this.activeAgentsBuffer,
|
||||||
agentOffset * AGENT_SIZE_IN_BYTES,
|
agentOffset * AGENT_SIZE_IN_BYTES,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
|
|
@ -190,7 +160,7 @@ export class AgentGenerationPipeline {
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.resizePipeline);
|
passEncoder.setPipeline(this.resizePipeline);
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
passEncoder.dispatchWorkgroups(
|
passEncoder.dispatchWorkgroups(
|
||||||
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
||||||
);
|
);
|
||||||
|
|
@ -204,8 +174,6 @@ export class AgentGenerationPipeline {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.counterClearValues.fill(0);
|
|
||||||
this.agentCountUniformValues.fill(0);
|
|
||||||
this.agentCountUniformValues[0] = agentCount;
|
this.agentCountUniformValues[0] = agentCount;
|
||||||
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
|
this.device.queue.writeBuffer(this.countersBuffer, 0, this.counterClearValues);
|
||||||
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
this.device.queue.writeBuffer(this.uniforms, 0, this.agentCountUniformValues);
|
||||||
|
|
@ -213,20 +181,12 @@ export class AgentGenerationPipeline {
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
const passEncoder = commandEncoder.beginComputePass();
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
passEncoder.setPipeline(this.compactionPipeline);
|
passEncoder.setPipeline(this.compactionPipeline);
|
||||||
passEncoder.setBindGroup(1, this.bindGroup);
|
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||||
passEncoder.dispatchWorkgroups(
|
passEncoder.dispatchWorkgroups(
|
||||||
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
||||||
);
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
const copyPassEncoder = commandEncoder.beginComputePass();
|
|
||||||
copyPassEncoder.setPipeline(this.compactedAgentsCopyPipeline);
|
|
||||||
copyPassEncoder.setBindGroup(1, this.bindGroup);
|
|
||||||
copyPassEncoder.dispatchWorkgroups(
|
|
||||||
getWorkgroupCount(agentCount, AgentGenerationPipeline.WORKGROUP_SIZE)
|
|
||||||
);
|
|
||||||
copyPassEncoder.end();
|
|
||||||
|
|
||||||
commandEncoder.copyBufferToBuffer(
|
commandEncoder.copyBufferToBuffer(
|
||||||
this.countersBuffer,
|
this.countersBuffer,
|
||||||
0,
|
0,
|
||||||
|
|
@ -239,18 +199,74 @@ export class AgentGenerationPipeline {
|
||||||
|
|
||||||
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
await this.countersStagingBuffer.mapAsync(GPUMapMode.READ);
|
||||||
const compactedCount = new Uint32Array(
|
const compactedCount = new Uint32Array(
|
||||||
this.countersStagingBuffer.getMappedRange().slice(0, Uint32Array.BYTES_PER_ELEMENT)
|
this.countersStagingBuffer.getMappedRange(),
|
||||||
|
0,
|
||||||
|
1
|
||||||
)[0];
|
)[0];
|
||||||
this.countersStagingBuffer.unmap();
|
this.countersStagingBuffer.unmap();
|
||||||
|
this.swapAgentBuffers();
|
||||||
|
|
||||||
return compactedCount;
|
return compactedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getBindGroup(): GPUBindGroup {
|
||||||
|
let inactiveCache = this.bindGroupsByActiveBuffer.get(this.activeAgentsBuffer);
|
||||||
|
if (!inactiveCache) {
|
||||||
|
inactiveCache = new WeakMap<GPUBuffer, GPUBindGroup>();
|
||||||
|
this.bindGroupsByActiveBuffer.set(this.activeAgentsBuffer, inactiveCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = inactiveCache.get(this.inactiveAgentsBuffer);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindGroup = this.device.createBindGroup({
|
||||||
|
layout: this.bindGroupLayout,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
binding: 0,
|
||||||
|
resource: {
|
||||||
|
buffer: this.uniforms,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 1,
|
||||||
|
resource: {
|
||||||
|
buffer: this.activeAgentsBuffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
resource: {
|
||||||
|
buffer: this.countersBuffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binding: 3,
|
||||||
|
resource: {
|
||||||
|
buffer: this.inactiveAgentsBuffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
inactiveCache.set(this.inactiveAgentsBuffer, bindGroup);
|
||||||
|
return bindGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private swapAgentBuffers(): void {
|
||||||
|
[this.activeAgentsBuffer, this.inactiveAgentsBuffer] = [
|
||||||
|
this.inactiveAgentsBuffer,
|
||||||
|
this.activeAgentsBuffer,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
this.countersBuffer.destroy();
|
this.countersBuffer.destroy();
|
||||||
this.countersStagingBuffer.destroy();
|
this.countersStagingBuffer.destroy();
|
||||||
this.compactedAgentsBuffer.destroy();
|
this.inactiveAgentsBuffer.destroy();
|
||||||
this.agentsBuffer.destroy();
|
this.activeAgentsBuffer.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,6 @@ fn main(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var agent = agents[id];
|
agents[id].position = agents[id].position * resizeSettings.scale;
|
||||||
agent.position *= resizeSettings.scale;
|
agents[id].targetPosition = agents[id].targetPosition * resizeSettings.scale;
|
||||||
|
|
||||||
if agent.targetPosition.x >= 0.0 && agent.targetPosition.y >= 0.0 {
|
|
||||||
agent.targetPosition *= resizeSettings.scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
agents[id] = agent;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,12 @@ describe('Agent TS/WGSL contract', () => {
|
||||||
expect(compactionShader).toContain('if id < settings.agentCount');
|
expect(compactionShader).toContain('if id < settings.agentCount');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps compaction copy-back bounded by the compacted count', () => {
|
it('keeps compaction as a ping-pong write without copy-back shader work', () => {
|
||||||
expect(compactionShader).toContain('fn copyCompactedAgents');
|
expect(compactionShader).not.toContain('fn copyCompactedAgents');
|
||||||
|
expect(compactionShader).not.toContain('agents[id] = compactedAgents[id];');
|
||||||
expect(compactionShader).toContain(
|
expect(compactionShader).toContain(
|
||||||
'workgroupCopyCount = atomicLoad(&counters.aliveAgentCount);'
|
'compactedAgents[workgroupCompactedOffset + localCompactedIndex] = agent;'
|
||||||
);
|
);
|
||||||
expect(compactionShader).toContain('if id >= workgroupCopyCount');
|
|
||||||
expect(compactionShader).toContain('agents[id] = compactedAgents[id];');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses workgroup-local counting before allocating global compacted ranges', () => {
|
it('uses workgroup-local counting before allocating global compacted ranges', () => {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import shader from './agent.wgsl?raw';
|
||||||
|
|
||||||
export class AgentPipeline {
|
export class AgentPipeline {
|
||||||
private static readonly WORKGROUP_SIZE = 64;
|
private static readonly WORKGROUP_SIZE = 64;
|
||||||
private static readonly UNIFORM_COUNT = 17;
|
private static readonly UNIFORM_COUNT = 33;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPUComputePipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
|
|
@ -20,9 +20,12 @@ export class AgentPipeline {
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
AgentPipeline.UNIFORM_COUNT
|
AgentPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly bindGroupsByTexture = new WeakMap<
|
private readonly bindGroupsByAgentsBuffer = new WeakMap<
|
||||||
|
GPUBuffer,
|
||||||
|
WeakMap<
|
||||||
GPUTextureView,
|
GPUTextureView,
|
||||||
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
|
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
|
||||||
|
>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private agentCount = 0;
|
private agentCount = 0;
|
||||||
|
|
@ -30,7 +33,7 @@ export class AgentPipeline {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly commonState: CommonState,
|
private readonly commonState: CommonState,
|
||||||
private readonly agentsBuffer: GPUBuffer // doesn't get destroyed
|
private readonly getAgentsBuffer: () => GPUBuffer // doesn't get destroyed
|
||||||
) {
|
) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout);
|
this.bindGroupLayout = device.createBindGroupLayout(AgentPipeline.bindGroupLayout);
|
||||||
|
|
||||||
|
|
@ -67,6 +70,21 @@ export class AgentPipeline {
|
||||||
color3ToColor1,
|
color3ToColor1,
|
||||||
color3ToColor2,
|
color3ToColor2,
|
||||||
color3ToColor3,
|
color3ToColor3,
|
||||||
|
sourceAttractionWeight,
|
||||||
|
sourceSlowMoveRate,
|
||||||
|
sourceTrailWeightMultiplier,
|
||||||
|
forwardRotationScale,
|
||||||
|
introNearDistanceInner,
|
||||||
|
introNearDistanceMin,
|
||||||
|
introNearSensorOffsetMultiplier,
|
||||||
|
introTargetAngleBlend,
|
||||||
|
introProgressCutoff,
|
||||||
|
introTurnRateMultiplier,
|
||||||
|
introRandomTurnMultiplier,
|
||||||
|
introFarMoveMultiplier,
|
||||||
|
introNearMoveMultiplier,
|
||||||
|
introStepStopDistance,
|
||||||
|
randomTimeScale,
|
||||||
agentCount,
|
agentCount,
|
||||||
introProgress,
|
introProgress,
|
||||||
}: AgentSettings & {
|
}: AgentSettings & {
|
||||||
|
|
@ -77,21 +95,38 @@ export class AgentPipeline {
|
||||||
this.agentCount = agentCount;
|
this.agentCount = agentCount;
|
||||||
this.uniformValues[0] = moveSpeed * deltaTime;
|
this.uniformValues[0] = moveSpeed * deltaTime;
|
||||||
this.uniformValues[1] = turnSpeed * deltaTime;
|
this.uniformValues[1] = turnSpeed * deltaTime;
|
||||||
this.uniformValues[2] = (sensorOffsetAngle * Math.PI) / 180;
|
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
|
||||||
this.uniformValues[3] = sensorOffsetDistance;
|
this.uniformValues[2] = Math.sin(sensorAngle);
|
||||||
this.uniformValues[4] = turnWhenLost;
|
this.uniformValues[3] = Math.cos(sensorAngle);
|
||||||
this.uniformValues[5] = individualTrailWeight;
|
this.uniformValues[4] = sensorOffsetDistance;
|
||||||
this.uniformValues[6] = agentCount;
|
this.uniformValues[5] = turnWhenLost;
|
||||||
this.uniformValues[7] = introProgress ?? 1;
|
this.uniformValues[6] = individualTrailWeight;
|
||||||
this.uniformValues[8] = color1ToColor1;
|
this.uniformValues[7] = agentCount;
|
||||||
this.uniformValues[9] = color1ToColor2;
|
this.uniformValues[8] = introProgress ?? 1;
|
||||||
this.uniformValues[10] = color1ToColor3;
|
this.uniformValues[9] = color1ToColor1;
|
||||||
this.uniformValues[11] = color2ToColor1;
|
this.uniformValues[10] = color1ToColor2;
|
||||||
this.uniformValues[12] = color2ToColor2;
|
this.uniformValues[11] = color1ToColor3;
|
||||||
this.uniformValues[13] = color2ToColor3;
|
this.uniformValues[12] = color2ToColor1;
|
||||||
this.uniformValues[14] = color3ToColor1;
|
this.uniformValues[13] = color2ToColor2;
|
||||||
this.uniformValues[15] = color3ToColor2;
|
this.uniformValues[14] = color2ToColor3;
|
||||||
this.uniformValues[16] = color3ToColor3;
|
this.uniformValues[15] = color3ToColor1;
|
||||||
|
this.uniformValues[16] = color3ToColor2;
|
||||||
|
this.uniformValues[17] = color3ToColor3;
|
||||||
|
this.uniformValues[18] = sourceAttractionWeight;
|
||||||
|
this.uniformValues[19] = sourceSlowMoveRate;
|
||||||
|
this.uniformValues[20] = sourceTrailWeightMultiplier;
|
||||||
|
this.uniformValues[21] = forwardRotationScale;
|
||||||
|
this.uniformValues[22] = introNearDistanceInner;
|
||||||
|
this.uniformValues[23] = introNearDistanceMin;
|
||||||
|
this.uniformValues[24] = introNearSensorOffsetMultiplier;
|
||||||
|
this.uniformValues[25] = introTargetAngleBlend;
|
||||||
|
this.uniformValues[26] = introProgressCutoff;
|
||||||
|
this.uniformValues[27] = introTurnRateMultiplier;
|
||||||
|
this.uniformValues[28] = introRandomTurnMultiplier;
|
||||||
|
this.uniformValues[29] = introFarMoveMultiplier;
|
||||||
|
this.uniformValues[30] = introNearMoveMultiplier;
|
||||||
|
this.uniformValues[31] = introStepStopDistance;
|
||||||
|
this.uniformValues[32] = randomTimeScale;
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
@ -127,10 +162,20 @@ export class AgentPipeline {
|
||||||
trailMapOut: GPUTextureView,
|
trailMapOut: GPUTextureView,
|
||||||
sourceMap: GPUTextureView
|
sourceMap: GPUTextureView
|
||||||
): GPUBindGroup {
|
): GPUBindGroup {
|
||||||
let outputCache = this.bindGroupsByTexture.get(trailMapIn);
|
const agentsBuffer = this.getAgentsBuffer();
|
||||||
|
let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
|
||||||
|
if (!textureCache) {
|
||||||
|
textureCache = new WeakMap<
|
||||||
|
GPUTextureView,
|
||||||
|
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
|
||||||
|
>();
|
||||||
|
this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputCache = textureCache.get(trailMapIn);
|
||||||
if (!outputCache) {
|
if (!outputCache) {
|
||||||
outputCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
|
outputCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
|
||||||
this.bindGroupsByTexture.set(trailMapIn, outputCache);
|
textureCache.set(trailMapIn, outputCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceCache = outputCache.get(trailMapOut);
|
let sourceCache = outputCache.get(trailMapOut);
|
||||||
|
|
@ -156,7 +201,7 @@ export class AgentPipeline {
|
||||||
{
|
{
|
||||||
binding: 1,
|
binding: 1,
|
||||||
resource: {
|
resource: {
|
||||||
buffer: this.agentsBuffer,
|
buffer: agentsBuffer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,19 @@ export interface AgentSettings {
|
||||||
sensorOffsetDistance: number;
|
sensorOffsetDistance: number;
|
||||||
turnWhenLost: number;
|
turnWhenLost: number;
|
||||||
individualTrailWeight: number;
|
individualTrailWeight: number;
|
||||||
|
sourceAttractionWeight: number;
|
||||||
|
sourceSlowMoveRate: number;
|
||||||
|
sourceTrailWeightMultiplier: number;
|
||||||
|
forwardRotationScale: number;
|
||||||
|
introNearDistanceMin: number;
|
||||||
|
introNearSensorOffsetMultiplier: number;
|
||||||
|
introTargetAngleBlend: number;
|
||||||
|
introProgressCutoff: number;
|
||||||
|
introNearDistanceInner: number;
|
||||||
|
introTurnRateMultiplier: number;
|
||||||
|
introRandomTurnMultiplier: number;
|
||||||
|
introFarMoveMultiplier: number;
|
||||||
|
introNearMoveMultiplier: number;
|
||||||
|
introStepStopDistance: number;
|
||||||
|
randomTimeScale: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
moveRate: f32,
|
moveRate: f32,
|
||||||
turnRate: f32,
|
turnRate: f32,
|
||||||
sensorAngle: f32,
|
sensorAngleSin: f32,
|
||||||
|
sensorAngleCos: f32,
|
||||||
sensorOffset: f32,
|
sensorOffset: f32,
|
||||||
turnWhenLost: f32,
|
turnWhenLost: f32,
|
||||||
individualTrailWeight: f32,
|
individualTrailWeight: f32,
|
||||||
|
|
@ -16,6 +17,21 @@ struct Settings {
|
||||||
color3ToColor1: f32,
|
color3ToColor1: f32,
|
||||||
color3ToColor2: f32,
|
color3ToColor2: f32,
|
||||||
color3ToColor3: f32,
|
color3ToColor3: f32,
|
||||||
|
sourceAttractionWeight: f32,
|
||||||
|
sourceSlowMoveRate: f32,
|
||||||
|
sourceTrailWeightMultiplier: f32,
|
||||||
|
forwardRotationScale: f32,
|
||||||
|
introNearDistanceInner: f32,
|
||||||
|
introNearDistanceMin: f32,
|
||||||
|
introNearSensorOffsetMultiplier: f32,
|
||||||
|
introTargetAngleBlend: f32,
|
||||||
|
introProgressCutoff: f32,
|
||||||
|
introTurnRateMultiplier: f32,
|
||||||
|
introRandomTurnMultiplier: f32,
|
||||||
|
introFarMoveMultiplier: f32,
|
||||||
|
introNearMoveMultiplier: f32,
|
||||||
|
introStepStopDistance: f32,
|
||||||
|
randomTimeScale: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
@ -33,25 +49,38 @@ fn main(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var agent = agents[id];
|
let colorIndex = agents[id].colorIndex;
|
||||||
if agent.colorIndex < 0.0 || agent.colorIndex >= 2.5 {
|
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasIntroTarget =
|
var position = agents[id].position;
|
||||||
settings.introProgress < 0.999 &&
|
var angle = agents[id].angle;
|
||||||
agent.targetPosition.x >= 0.0 &&
|
var targetPosition = vec2<f32>(-1.0, -1.0);
|
||||||
agent.targetPosition.y >= 0.0;
|
var hasIntroTarget = false;
|
||||||
|
if settings.introProgress < settings.introProgressCutoff {
|
||||||
if hasIntroTarget && settings.introProgress < agent.introDelay {
|
targetPosition = agents[id].targetPosition;
|
||||||
|
hasIntroTarget = targetPosition.x >= 0.0 && targetPosition.y >= 0.0;
|
||||||
|
if hasIntroTarget && settings.introProgress < agents[id].introDelay {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let random = random_vec4(id, state.time);
|
let randomSeed = random_seed(id, state.time);
|
||||||
|
let randomTurn = random_float(randomSeed);
|
||||||
|
let direction = vec2(cos(angle), sin(angle));
|
||||||
|
|
||||||
let forwardSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, 0);
|
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
|
||||||
let leftSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, settings.sensorAngle);
|
let leftSensor = sensor_position(
|
||||||
let rightSensor = sensor_position(agent.position, agent.angle, settings.sensorOffset, -settings.sensorAngle);
|
position,
|
||||||
|
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
|
||||||
|
settings.sensorOffset
|
||||||
|
);
|
||||||
|
let rightSensor = sensor_position(
|
||||||
|
position,
|
||||||
|
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
|
||||||
|
settings.sensorOffset
|
||||||
|
);
|
||||||
|
|
||||||
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
|
let trailForward = textureLoad(trailMapIn, forwardSensor, 0);
|
||||||
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
|
let trailLeft = textureLoad(trailMapIn, leftSensor, 0);
|
||||||
|
|
@ -60,8 +89,8 @@ fn main(
|
||||||
let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
|
let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
|
||||||
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
|
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
|
||||||
|
|
||||||
let channelMask = get_channel_mask(agent.colorIndex);
|
let channelMask = get_channel_mask(colorIndex);
|
||||||
let reactionMask = get_reaction_mask(agent.colorIndex);
|
let reactionMask = get_reaction_mask(colorIndex);
|
||||||
|
|
||||||
let trailForwardWeight = dot(trailForward.rgb, reactionMask);
|
let trailForwardWeight = dot(trailForward.rgb, reactionMask);
|
||||||
let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
|
let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
|
||||||
|
|
@ -71,76 +100,105 @@ fn main(
|
||||||
let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
|
let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
|
||||||
let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
|
let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
|
||||||
|
|
||||||
let weightForward = trailForwardWeight + sourceForwardWeight * 24.0;
|
let weightForward =
|
||||||
let weightLeft = trailLeftWeight + sourceLeftWeight * 24.0;
|
trailForwardWeight + sourceForwardWeight * settings.sourceAttractionWeight;
|
||||||
let weightRight = trailRightWeight + sourceRightWeight * 24.0;
|
let weightLeft = trailLeftWeight + sourceLeftWeight * settings.sourceAttractionWeight;
|
||||||
|
let weightRight =
|
||||||
|
trailRightWeight + sourceRightWeight * settings.sourceAttractionWeight;
|
||||||
|
|
||||||
var rotation = (random.r - 0.5) * settings.turnWhenLost;
|
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
|
||||||
if weightForward >= weightLeft && weightForward >= weightRight {
|
if weightForward >= weightLeft && weightForward >= weightRight {
|
||||||
rotation = rotation * 0.25;
|
rotation = rotation * settings.forwardRotationScale;
|
||||||
} else {
|
} else {
|
||||||
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(agent.position), 0);
|
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(position), 0);
|
||||||
let positiveReactionMask = max(reactionMask, vec3<f32>(0.0));
|
let positiveReactionMask = max(reactionMask, vec3<f32>(0.0));
|
||||||
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
|
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
|
||||||
var moveRate = settings.moveRate * mix(1.0, 0.08, sourceAtAgentStrength);
|
var moveRate = settings.moveRate * mix(1.0, settings.sourceSlowMoveRate, sourceAtAgentStrength);
|
||||||
var introTargetOffset = vec2<f32>(0.0, 0.0);
|
var introTargetOffset = vec2<f32>(0.0, 0.0);
|
||||||
var introTargetDistance = 0.0;
|
var introTargetDistance = 0.0;
|
||||||
|
|
||||||
if hasIntroTarget {
|
if hasIntroTarget {
|
||||||
introTargetOffset = agent.targetPosition - agent.position;
|
introTargetOffset = targetPosition - position;
|
||||||
introTargetDistance = length(introTargetOffset);
|
introTargetDistance = length(introTargetOffset);
|
||||||
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
||||||
let nearTitle = 1.0 - smoothstep(4.0, max(28.0, settings.sensorOffset * 0.75), introTargetDistance);
|
let nearTitle = 1.0 - smoothstep(
|
||||||
let desiredAngle = mix(targetAngle, agent.targetAngle, nearTitle * 0.2);
|
settings.introNearDistanceInner,
|
||||||
let introTurn = angle_delta(agent.angle, desiredAngle);
|
max(
|
||||||
|
settings.introNearDistanceMin,
|
||||||
|
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
|
||||||
|
),
|
||||||
|
introTargetDistance
|
||||||
|
);
|
||||||
|
let desiredAngle = mix(
|
||||||
|
targetAngle,
|
||||||
|
agents[id].targetAngle,
|
||||||
|
nearTitle * settings.introTargetAngleBlend
|
||||||
|
);
|
||||||
|
let introTurn = angle_delta(angle, desiredAngle);
|
||||||
|
|
||||||
rotation = clamp(introTurn, -settings.turnRate * 3.4, settings.turnRate * 3.4)
|
rotation = clamp(
|
||||||
+ (random.g - 0.5) * settings.turnWhenLost * 0.18;
|
introTurn,
|
||||||
moveRate = min(settings.moveRate * mix(2.65, 0.01, nearTitle), introTargetDistance);
|
-settings.turnRate * settings.introTurnRateMultiplier,
|
||||||
|
settings.turnRate * settings.introTurnRateMultiplier
|
||||||
|
)
|
||||||
|
+ (random_float(randomSeed + 1013904223u) - 0.5) *
|
||||||
|
settings.turnWhenLost *
|
||||||
|
settings.introRandomTurnMultiplier;
|
||||||
|
moveRate = min(
|
||||||
|
settings.moveRate *
|
||||||
|
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
|
||||||
|
introTargetDistance
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var step = vec2(cos(agent.angle), sin(agent.angle)) * moveRate;
|
var step = direction * moveRate;
|
||||||
if hasIntroTarget {
|
if hasIntroTarget {
|
||||||
step = vec2<f32>(0.0, 0.0);
|
step = vec2<f32>(0.0, 0.0);
|
||||||
if introTargetDistance > 0.5 {
|
if introTargetDistance > settings.introStepStopDistance {
|
||||||
step = introTargetOffset / introTargetDistance * moveRate;
|
step = introTargetOffset / introTargetDistance * moveRate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||||
let nextPosition = clamp(agent.position + step, vec2<f32>(0, 0), maxPosition);
|
let nextPosition = clamp(position + step, vec2<f32>(0, 0), maxPosition);
|
||||||
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
|
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
|
||||||
rotation = 3.14159265359 + random.a - 0.5;
|
rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
|
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
|
||||||
let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
|
let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
|
||||||
let trailWeight = settings.individualTrailWeight * (1.0 + sourceBelowStrength * 16.0);
|
let trailWeight =
|
||||||
|
settings.individualTrailWeight *
|
||||||
|
(1.0 + sourceBelowStrength * settings.sourceTrailWeightMultiplier);
|
||||||
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
||||||
trailBelow = vec4<f32>(
|
trailBelow = vec4<f32>(
|
||||||
trailBelow.rgb + channelMask * trailWeight,
|
trailBelow.rgb + channelMask * trailWeight,
|
||||||
max(trailBelow.a, 0.0)
|
max(trailBelow.a, 0.0)
|
||||||
);
|
);
|
||||||
|
|
||||||
agent.angle += rotation;
|
|
||||||
agent.position = nextPosition;
|
|
||||||
|
|
||||||
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
|
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
|
||||||
agents[id] = agent;
|
agents[id].angle = angle + rotation;
|
||||||
|
agents[id].position = nextPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sensor_position(agentPosition: vec2<f32>, agentAngle: f32, sensorOffset: f32, sensorOffsetAngle: f32) -> vec2<i32> {
|
fn sensor_position(agentPosition: vec2<f32>, direction: vec2<f32>, sensorOffset: f32) -> vec2<i32> {
|
||||||
let sensorAngle = agentAngle + sensorOffsetAngle;
|
|
||||||
return vec2<i32>(clamp(
|
return vec2<i32>(clamp(
|
||||||
agentPosition + vec2(cos(sensorAngle), sin(sensorAngle)) * sensorOffset,
|
agentPosition + direction * sensorOffset,
|
||||||
vec2<f32>(0, 0),
|
vec2<f32>(0, 0),
|
||||||
state.size - vec2<f32>(1, 1)
|
state.size - vec2<f32>(1, 1)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rotate_direction(direction: vec2<f32>, angleSin: f32, angleCos: f32) -> vec2<f32> {
|
||||||
|
return vec2<f32>(
|
||||||
|
direction.x * angleCos - direction.y * angleSin,
|
||||||
|
direction.x * angleSin + direction.y * angleCos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
||||||
if colorIndex < 0.5 {
|
if colorIndex < 0.5 {
|
||||||
return vec3<f32>(1, 0, 0);
|
return vec3<f32>(1, 0, 0);
|
||||||
|
|
@ -183,15 +241,9 @@ 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> {
|
fn random_seed(id: u32, time: f32) -> u32 {
|
||||||
let timeSeed = u32(time * 0.34816);
|
let timeSeed = u32(time * settings.randomTimeScale);
|
||||||
let seed = id * 747796405u + timeSeed * 2891336453u;
|
return 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 {
|
fn random_float(seed: u32) -> f32 {
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,10 @@ interface LineSegment {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrushPipeline {
|
export class BrushPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 8;
|
private static readonly UNIFORM_COUNT = 16;
|
||||||
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
private static readonly MAX_LINE_COUNT = appConfig.pipelines.brush.maxLineCount;
|
||||||
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
|
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
|
||||||
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
|
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4;
|
||||||
private static readonly FEATHER_RADIUS_RATIO = 0.22;
|
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
|
|
@ -91,22 +90,40 @@ export class BrushPipeline {
|
||||||
public setParameters({
|
public setParameters({
|
||||||
brushSize,
|
brushSize,
|
||||||
brushSizeVariation,
|
brushSizeVariation,
|
||||||
|
brushAlpha,
|
||||||
|
brushFeatherRatio,
|
||||||
|
brushMinimumFeather,
|
||||||
|
brushDiscardThreshold,
|
||||||
|
brushCoarseNoiseScale,
|
||||||
|
brushGrainNoiseScale,
|
||||||
|
brushGrainNoiseOffsetX,
|
||||||
|
brushGrainNoiseOffsetY,
|
||||||
|
brushGrainMinStrength,
|
||||||
|
brushGrainMaxStrength,
|
||||||
selectedColorIndex,
|
selectedColorIndex,
|
||||||
}: BrushSettings & { selectedColorIndex: number }) {
|
}: BrushSettings & { selectedColorIndex: number }) {
|
||||||
const brushRadius = brushSize / 2;
|
const brushRadius = brushSize / 2;
|
||||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||||
const brushFeather = Math.max(1, brushRadius * BrushPipeline.FEATHER_RADIUS_RATIO);
|
const brushFeather = Math.max(brushMinimumFeather, brushRadius * brushFeatherRatio);
|
||||||
const brushGeometryRadius =
|
const brushGeometryRadius =
|
||||||
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
||||||
|
|
||||||
this.uniformValues[0] = brushRadius;
|
this.uniformValues[0] = brushRadius;
|
||||||
this.uniformValues[1] = brushRadiusVariation;
|
this.uniformValues[1] = brushRadiusVariation;
|
||||||
this.uniformValues[2] = 0;
|
this.uniformValues[2] = brushFeatherRatio;
|
||||||
this.uniformValues[3] = 0;
|
this.uniformValues[3] = brushMinimumFeather;
|
||||||
this.uniformValues[4] = selectedColorIndex === 0 ? 1 : 0;
|
this.uniformValues[4] = selectedColorIndex === 0 ? 1 : 0;
|
||||||
this.uniformValues[5] = selectedColorIndex === 1 ? 1 : 0;
|
this.uniformValues[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||||
this.uniformValues[6] = selectedColorIndex === 2 ? 1 : 0;
|
this.uniformValues[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||||
this.uniformValues[7] = 1;
|
this.uniformValues[7] = brushAlpha;
|
||||||
|
this.uniformValues[8] = brushCoarseNoiseScale;
|
||||||
|
this.uniformValues[9] = brushGrainNoiseScale;
|
||||||
|
this.uniformValues[10] = brushGrainNoiseOffsetX;
|
||||||
|
this.uniformValues[11] = brushGrainNoiseOffsetY;
|
||||||
|
this.uniformValues[12] = brushDiscardThreshold;
|
||||||
|
this.uniformValues[13] = brushGrainMinStrength;
|
||||||
|
this.uniformValues[14] = brushGrainMaxStrength;
|
||||||
|
this.uniformValues[15] = brushGeometryRadius;
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
@ -133,8 +150,7 @@ export class BrushPipeline {
|
||||||
this.vertexUploadData,
|
this.vertexUploadData,
|
||||||
floatOffset,
|
floatOffset,
|
||||||
segment.from,
|
segment.from,
|
||||||
segment.to,
|
segment.to
|
||||||
brushGeometryRadius
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,84 +183,8 @@ export class BrushPipeline {
|
||||||
target: Float32Array,
|
target: Float32Array,
|
||||||
offset: number,
|
offset: number,
|
||||||
from: vec2,
|
from: vec2,
|
||||||
to: vec2,
|
|
||||||
width: number
|
|
||||||
): number {
|
|
||||||
const dx = to[0] - from[0];
|
|
||||||
const dy = to[1] - from[1];
|
|
||||||
const length = Math.hypot(dx, dy);
|
|
||||||
const directionX = length > 0 ? dx / length : 1;
|
|
||||||
const directionY = length > 0 ? dy / length : 0;
|
|
||||||
const scaledDirectionX = directionX * width;
|
|
||||||
const scaledDirectionY = directionY * width;
|
|
||||||
const perpendicularX = directionY * width;
|
|
||||||
const perpendicularY = -directionX * width;
|
|
||||||
|
|
||||||
const startX = from[0] - scaledDirectionX;
|
|
||||||
const startY = from[1] - scaledDirectionY;
|
|
||||||
const endX = to[0] + scaledDirectionX;
|
|
||||||
const endY = to[1] + scaledDirectionY;
|
|
||||||
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
startX + perpendicularX,
|
|
||||||
startY + perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
startX - perpendicularX,
|
|
||||||
startY - perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
endX + perpendicularX,
|
|
||||||
endY + perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
startX - perpendicularX,
|
|
||||||
startY - perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
endX + perpendicularX,
|
|
||||||
endY + perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
return this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
endX - perpendicularX,
|
|
||||||
endY - perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeVertex(
|
|
||||||
target: Float32Array,
|
|
||||||
offset: number,
|
|
||||||
screenX: number,
|
|
||||||
screenY: number,
|
|
||||||
from: vec2,
|
|
||||||
to: vec2
|
to: vec2
|
||||||
): number {
|
): number {
|
||||||
target[offset++] = screenX;
|
|
||||||
target[offset++] = screenY;
|
|
||||||
target[offset++] = from[0];
|
target[offset++] = from[0];
|
||||||
target[offset++] = from[1];
|
target[offset++] = from[1];
|
||||||
target[offset++] = to[0];
|
target[offset++] = to[0];
|
||||||
|
|
@ -285,7 +225,7 @@ export class BrushPipeline {
|
||||||
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);
|
||||||
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
|
passEncoder.draw(BrushPipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +248,9 @@ export class BrushPipeline {
|
||||||
entryPoint: 'vertex',
|
entryPoint: 'vertex',
|
||||||
buffers: [
|
buffers: [
|
||||||
{
|
{
|
||||||
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
|
arrayStride:
|
||||||
|
Float32Array.BYTES_PER_ELEMENT * BrushPipeline.ATTRIBUTES_PER_LINE_SEGMENT,
|
||||||
|
stepMode: 'instance',
|
||||||
attributes: [
|
attributes: [
|
||||||
{
|
{
|
||||||
shaderLocation: 0,
|
shaderLocation: 0,
|
||||||
|
|
@ -320,11 +262,6 @@ export class BrushPipeline {
|
||||||
format: 'float32x2',
|
format: 'float32x2',
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 2,
|
offset: Float32Array.BYTES_PER_ELEMENT * 2,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
shaderLocation: 2,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 4,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -366,7 +303,7 @@ export class BrushPipeline {
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
binding: 0,
|
binding: 0,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
||||||
buffer: {
|
buffer: {
|
||||||
type: 'uniform',
|
type: 'uniform',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
export interface BrushSettings {
|
export interface BrushSettings {
|
||||||
brushSize: number;
|
brushSize: number;
|
||||||
brushSizeVariation: number;
|
brushSizeVariation: number;
|
||||||
|
brushAlpha: number;
|
||||||
|
brushFeatherRatio: number;
|
||||||
|
brushMinimumFeather: number;
|
||||||
|
brushDiscardThreshold: number;
|
||||||
|
brushCoarseNoiseScale: number;
|
||||||
|
brushGrainNoiseScale: number;
|
||||||
|
brushGrainNoiseOffsetX: number;
|
||||||
|
brushGrainNoiseOffsetY: number;
|
||||||
|
brushGrainMinStrength: number;
|
||||||
|
brushGrainMaxStrength: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
brushSize: f32,
|
brushSize: f32,
|
||||||
brushSizeVariation: f32,
|
brushSizeVariation: f32,
|
||||||
padding0: f32,
|
brushFeatherRatio: f32,
|
||||||
padding1: f32,
|
brushMinimumFeather: f32,
|
||||||
brushValue: vec4<f32>,
|
brushValue: vec4<f32>,
|
||||||
|
brushCoarseNoiseScale: f32,
|
||||||
|
brushGrainNoiseScale: f32,
|
||||||
|
brushGrainNoiseOffsetX: f32,
|
||||||
|
brushGrainNoiseOffsetY: f32,
|
||||||
|
brushDiscardThreshold: f32,
|
||||||
|
brushGrainMinStrength: f32,
|
||||||
|
brushGrainMaxStrength: f32,
|
||||||
|
brushGeometryRadius: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
@ -11,8 +19,8 @@ struct Settings {
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@builtin(position) position: vec4<f32>,
|
@builtin(position) position: vec4<f32>,
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) @interpolate(flat) end: vec2<f32>
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BrushTargets {
|
struct BrushTargets {
|
||||||
|
|
@ -22,10 +30,11 @@ struct BrushTargets {
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vertex(
|
fn vertex(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@builtin(vertex_index) vertexIndex: u32,
|
||||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
@location(0) start: vec2<f32>,
|
||||||
@location(2) @interpolate(flat) end: vec2<f32>
|
@location(1) end: vec2<f32>
|
||||||
) -> VertexOutput {
|
) -> VertexOutput {
|
||||||
|
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius);
|
||||||
let uv = screenPosition / state.size;
|
let uv = screenPosition / state.size;
|
||||||
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
|
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
|
||||||
|
|
@ -34,12 +43,17 @@ fn vertex(
|
||||||
@fragment
|
@fragment
|
||||||
fn fragmentMrt(
|
fn fragmentMrt(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) @interpolate(flat) end: vec2<f32>
|
||||||
) -> BrushTargets {
|
) -> BrushTargets {
|
||||||
let strength = brushStrength(screenPosition, start, end);
|
let distanceSquared = distanceSquaredFromLine(screenPosition, start, end);
|
||||||
|
if distanceSquared > settings.brushGeometryRadius * settings.brushGeometryRadius {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
if(strength < 0.02) {
|
let strength = brushStrength(screenPosition, start, end, distanceSquared);
|
||||||
|
|
||||||
|
if(strength < settings.brushDiscardThreshold) {
|
||||||
discard;
|
discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,33 +61,77 @@ fn fragmentMrt(
|
||||||
return BrushTargets(color, color);
|
return BrushTargets(color, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn brushStrength(screenPosition: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
fn brushStrength(
|
||||||
let distance = distanceFromLine(screenPosition, start, end);
|
screenPosition: vec2<f32>,
|
||||||
let coarseNoise = textureSample(noise, noiseSampler, fract(screenPosition / 160.0)).r;
|
start: vec2<f32>,
|
||||||
|
end: vec2<f32>,
|
||||||
|
distanceSquared: f32
|
||||||
|
) -> f32 {
|
||||||
|
let distance = sqrt(distanceSquared);
|
||||||
|
let coarseNoise = textureSample(
|
||||||
|
noise,
|
||||||
|
noiseSampler,
|
||||||
|
fract(screenPosition / settings.brushCoarseNoiseScale)
|
||||||
|
).r;
|
||||||
let grainNoise = textureSample(
|
let grainNoise = textureSample(
|
||||||
noise,
|
noise,
|
||||||
noiseSampler,
|
noiseSampler,
|
||||||
fract(screenPosition / 22.0 + vec2(0.31, 0.67))
|
fract(
|
||||||
|
screenPosition / settings.brushGrainNoiseScale +
|
||||||
|
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY)
|
||||||
|
)
|
||||||
).r;
|
).r;
|
||||||
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(settings.brushMinimumFeather, settings.brushSize * settings.brushFeatherRatio);
|
||||||
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
|
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
|
||||||
return edge * mix(0.45, 1.0, grainNoise);
|
return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn brushOutput(strength: f32) -> vec4<f32> {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn distanceFromLine(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;
|
||||||
let denominator = dot(direction, direction);
|
let denominator = dot(direction, direction);
|
||||||
|
|
||||||
if denominator <= 0.0001 {
|
if denominator <= 0.0001 {
|
||||||
return length(pa);
|
return dot(pa, pa);
|
||||||
}
|
}
|
||||||
|
|
||||||
let q = clamp(dot(pa, direction) / denominator, 0, 1);
|
let q = clamp(dot(pa, direction) / denominator, 0, 1);
|
||||||
return length(pa - direction * q);
|
let nearestOffset = pa - direction * q;
|
||||||
|
return dot(nearestOffset, nearestOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_vertex_position(
|
||||||
|
vertexIndex: u32,
|
||||||
|
start: vec2<f32>,
|
||||||
|
end: vec2<f32>,
|
||||||
|
radius: f32
|
||||||
|
) -> vec2<f32> {
|
||||||
|
let directionVector = end - start;
|
||||||
|
let segmentLength = length(directionVector);
|
||||||
|
var direction = vec2<f32>(1.0, 0.0);
|
||||||
|
if segmentLength > 0.0 {
|
||||||
|
direction = directionVector / segmentLength;
|
||||||
|
}
|
||||||
|
let perpendicular = vec2<f32>(direction.y, -direction.x);
|
||||||
|
let corner = segment_vertex_corner(vertexIndex % 6u);
|
||||||
|
let center = mix(start, end, (corner.x + 1.0) * 0.5);
|
||||||
|
return center + direction * corner.x * radius + perpendicular * corner.y * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_vertex_corner(index: u32) -> vec2<f32> {
|
||||||
|
if index == 0u {
|
||||||
|
return vec2<f32>(-1.0, 1.0);
|
||||||
|
}
|
||||||
|
if index == 1u || index == 3u {
|
||||||
|
return vec2<f32>(-1.0, -1.0);
|
||||||
|
}
|
||||||
|
if index == 2u || index == 4u {
|
||||||
|
return vec2<f32>(1.0, 1.0);
|
||||||
|
}
|
||||||
|
return vec2<f32>(1.0, -1.0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
|
import { appConfig } from '../../config';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedFloat32BufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeFloat32BufferIfChanged,
|
||||||
|
|
@ -8,7 +9,6 @@ import { generateNoise } from '../../utils/graphics/noise';
|
||||||
|
|
||||||
export class CommonState {
|
export class CommonState {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 4;
|
||||||
private static readonly NOISE_TEXTURE_SIZE = 2048;
|
|
||||||
|
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(CommonState.UNIFORM_COUNT);
|
||||||
|
|
@ -40,8 +40,8 @@ export class CommonState {
|
||||||
|
|
||||||
this.noise = generateNoise({
|
this.noise = generateNoise({
|
||||||
device,
|
device,
|
||||||
width: CommonState.NOISE_TEXTURE_SIZE,
|
width: appConfig.pipelines.common.noiseTextureSize,
|
||||||
height: CommonState.NOISE_TEXTURE_SIZE,
|
height: appConfig.pipelines.common.noiseTextureSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createCachedFloat32BufferWrite,
|
|
||||||
writeFloat32BufferIfChanged,
|
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
|
||||||
import shader from './copy.wgsl?raw';
|
|
||||||
|
|
||||||
export class CopyPipeline {
|
|
||||||
private static readonly UNIFORM_COUNT = 2;
|
|
||||||
private static readonly DEFAULT_SCALE = vec2.fromValues(1, 1);
|
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
|
||||||
private readonly pipeline: GPURenderPipeline;
|
|
||||||
private readonly uniforms: GPUBuffer;
|
|
||||||
private readonly uniformValues = new Float32Array(CopyPipeline.UNIFORM_COUNT);
|
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
|
||||||
CopyPipeline.UNIFORM_COUNT
|
|
||||||
);
|
|
||||||
private readonly sampler: GPUSampler;
|
|
||||||
|
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
|
||||||
|
|
||||||
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
|
|
||||||
|
|
||||||
public constructor(private readonly device: GPUDevice) {
|
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(CopyPipeline.bindGroupLayout);
|
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
|
||||||
size: CopyPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
|
||||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sampler = this.device.createSampler({
|
|
||||||
magFilter: 'linear',
|
|
||||||
minFilter: 'linear',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.vertexBuffer = device.createBuffer({
|
|
||||||
size: 2 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec2<f32>
|
|
||||||
usage: GPUBufferUsage.VERTEX,
|
|
||||||
mappedAtCreation: true,
|
|
||||||
});
|
|
||||||
// prettier-ignore
|
|
||||||
const vertexData = [
|
|
||||||
// U V
|
|
||||||
0.0, 1.0,
|
|
||||||
1.0, 1.0,
|
|
||||||
0.0, 0.0,
|
|
||||||
1.0, 0.0,
|
|
||||||
];
|
|
||||||
new Float32Array(this.vertexBuffer.getMappedRange()).set(vertexData);
|
|
||||||
this.vertexBuffer.unmap();
|
|
||||||
|
|
||||||
const shaderModule = smartCompile(device, shader);
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
|
||||||
layout: device.createPipelineLayout({
|
|
||||||
bindGroupLayouts: [this.bindGroupLayout],
|
|
||||||
}),
|
|
||||||
vertex: {
|
|
||||||
module: shaderModule,
|
|
||||||
entryPoint: 'vertex',
|
|
||||||
buffers: [
|
|
||||||
{
|
|
||||||
arrayStride: 2 * Float32Array.BYTES_PER_ELEMENT,
|
|
||||||
stepMode: 'vertex',
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
shaderLocation: 0,
|
|
||||||
offset: 0,
|
|
||||||
format: 'float32x2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fragment: {
|
|
||||||
module: shaderModule,
|
|
||||||
entryPoint: 'fragment',
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
format: 'rgba16float',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
primitive: {
|
|
||||||
topology: 'triangle-strip',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public execute(
|
|
||||||
commandEncoder: GPUCommandEncoder,
|
|
||||||
trailMapIn: GPUTextureView,
|
|
||||||
trailMapOut: GPUTextureView,
|
|
||||||
scale: vec2 = CopyPipeline.DEFAULT_SCALE
|
|
||||||
) {
|
|
||||||
this.uniformValues[0] = scale[0];
|
|
||||||
this.uniformValues[1] = scale[1];
|
|
||||||
writeFloat32BufferIfChanged(
|
|
||||||
this.device,
|
|
||||||
this.uniforms,
|
|
||||||
this.uniformValues,
|
|
||||||
this.uniformCache
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
|
||||||
colorAttachments: [
|
|
||||||
{
|
|
||||||
view: trailMapOut,
|
|
||||||
loadOp: 'clear',
|
|
||||||
storeOp: 'store',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const bindGroup = this.getBindGroup(trailMapIn);
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
|
||||||
passEncoder.setPipeline(this.pipeline);
|
|
||||||
passEncoder.setBindGroup(0, bindGroup);
|
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
|
||||||
passEncoder.draw(4, 1);
|
|
||||||
passEncoder.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
this.vertexBuffer.destroy();
|
|
||||||
this.uniforms.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
|
|
||||||
const cached = this.bindGroupsByInput.get(trailMapIn);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bindGroup = this.device.createBindGroup({
|
|
||||||
layout: this.bindGroupLayout,
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
resource: {
|
|
||||||
buffer: this.uniforms,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
resource: this.sampler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
resource: trailMapIn,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.bindGroupsByInput.set(trailMapIn, bindGroup);
|
|
||||||
return bindGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
|
||||||
return {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
binding: 0,
|
|
||||||
visibility: GPUShaderStage.VERTEX,
|
|
||||||
buffer: {
|
|
||||||
type: 'uniform',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 1,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
sampler: {
|
|
||||||
type: 'filtering',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
binding: 2,
|
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
|
||||||
texture: {
|
|
||||||
sampleType: 'float',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
struct VertexOutput {
|
|
||||||
@builtin(position) position: vec4<f32>,
|
|
||||||
@location(0) uv: vec2<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
@vertex
|
|
||||||
fn vertex(@location(0) uv: vec2<f32>) -> VertexOutput {
|
|
||||||
let ndc = uv * sourceScaler * vec2(2) - vec2(1);
|
|
||||||
return VertexOutput(vec4(ndc.x, -ndc.y, 0, 1), uv);
|
|
||||||
}
|
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> sourceScaler: vec2<f32>;
|
|
||||||
@group(0) @binding(1) var Sampler: sampler;
|
|
||||||
@group(0) @binding(2) var original: texture_2d<f32>;
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
|
||||||
return textureSample(original, Sampler, uv);
|
|
||||||
}
|
|
||||||
|
|
@ -3,56 +3,96 @@ struct Settings {
|
||||||
decayRateTrails: f32,
|
decayRateTrails: f32,
|
||||||
inverseDiffusionRateBrush: f32,
|
inverseDiffusionRateBrush: f32,
|
||||||
decayRateBrush: f32,
|
decayRateBrush: f32,
|
||||||
|
diffusionNeighborDivisor: f32,
|
||||||
|
brushDecayAlphaOffset: f32,
|
||||||
|
padding0: f32,
|
||||||
|
padding1: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WORKGROUP_SIZE_X = 16u;
|
||||||
|
const WORKGROUP_SIZE_Y = 16u;
|
||||||
|
const TILE_SIZE_X = WORKGROUP_SIZE_X + 2u;
|
||||||
|
const TILE_SIZE_Y = WORKGROUP_SIZE_Y + 2u;
|
||||||
|
const TILE_TEXEL_COUNT = TILE_SIZE_X * TILE_SIZE_Y;
|
||||||
|
|
||||||
@group(0) @binding(0) var<uniform> settings: Settings;
|
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||||
@group(0) @binding(2) var trailMap: texture_2d<f32>;
|
@group(0) @binding(1) var trailMap: texture_2d<f32>;
|
||||||
|
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba16float, write>;
|
||||||
|
|
||||||
|
var<workgroup> tile: array<vec4<f32>, 324>;
|
||||||
|
|
||||||
@fragment
|
@compute @workgroup_size(16, 16)
|
||||||
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
fn main(
|
||||||
|
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||||
|
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||||
|
@builtin(workgroup_id) workgroup_id: vec3<u32>
|
||||||
|
) {
|
||||||
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
||||||
let pixel = clamp(vec2<i32>(position.xy), vec2<i32>(0), textureSize - vec2<i32>(1));
|
let localLinearIndex = local_id.y * WORKGROUP_SIZE_X + local_id.x;
|
||||||
var current = textureLoad(trailMap, pixel, 0);
|
var tileIndex = localLinearIndex;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if tileIndex >= TILE_TEXEL_COUNT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tilePosition = vec2<u32>(tileIndex % TILE_SIZE_X, tileIndex / TILE_SIZE_X);
|
||||||
|
let sourcePixelU32 =
|
||||||
|
workgroup_id.xy * vec2<u32>(WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y) +
|
||||||
|
tilePosition;
|
||||||
|
let sourcePixel = clamp(
|
||||||
|
vec2<i32>(i32(sourcePixelU32.x), i32(sourcePixelU32.y)) - vec2<i32>(1, 1),
|
||||||
|
vec2<i32>(0, 0),
|
||||||
|
textureSize - vec2<i32>(1, 1)
|
||||||
|
);
|
||||||
|
tile[tileIndex] = textureLoad(trailMap, sourcePixel, 0);
|
||||||
|
tileIndex += WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroupBarrier();
|
||||||
|
|
||||||
|
let pixel = vec2<i32>(i32(global_id.x), i32(global_id.y));
|
||||||
|
if pixel.x >= textureSize.x || pixel.y >= textureSize.y {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let centerTilePosition = local_id.xy + vec2<u32>(1u, 1u);
|
||||||
|
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
|
||||||
|
var current = tile[centerTileIndex];
|
||||||
let random = random_from_pixel(pixel);
|
let random = random_from_pixel(pixel);
|
||||||
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
|
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
|
||||||
let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
|
let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
|
||||||
|
|
||||||
current += (
|
current += (
|
||||||
propagate(pixel, textureSize, vec2<i32>(-1, -1), current, trailWeight, brushWeight)
|
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight)
|
||||||
+ propagate(pixel, textureSize, vec2<i32>(-1, 1), current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, -1, 1, current, trailWeight, brushWeight)
|
||||||
+ propagate(pixel, textureSize, vec2<i32>(1, -1), current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 1, -1, current, trailWeight, brushWeight)
|
||||||
+ propagate(pixel, textureSize, vec2<i32>(1, 1), current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 1, 1, current, trailWeight, brushWeight)
|
||||||
|
|
||||||
+ propagate(pixel, textureSize, vec2<i32>(-1, 0), current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, -1, 0, current, trailWeight, brushWeight)
|
||||||
+ propagate(pixel, textureSize, vec2<i32>(0, -1), current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight)
|
||||||
+ propagate(pixel, textureSize, vec2<i32>(1, 0), current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight)
|
||||||
+ propagate(pixel, textureSize, vec2<i32>(0, 1), current, trailWeight, brushWeight)
|
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight)
|
||||||
) / 8;
|
) / max(1.0, settings.diffusionNeighborDivisor);
|
||||||
|
|
||||||
let decayed = clamp(vec4(
|
let decayed = clamp(vec4(
|
||||||
current.rgb * settings.decayRateTrails,
|
current.rgb * settings.decayRateTrails,
|
||||||
max(0, current.a + (current.a - 1.001) * settings.decayRateBrush)
|
max(0, current.a + (current.a - settings.brushDecayAlphaOffset) * settings.decayRateBrush)
|
||||||
), vec4(0), vec4(1));
|
), vec4(0), vec4(1));
|
||||||
|
|
||||||
return decayed;
|
textureStore(trailMapOut, pixel, decayed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn propagate(
|
fn propagate(
|
||||||
pixel: vec2<i32>,
|
centerTileIndex: u32,
|
||||||
textureSize: vec2<i32>,
|
offsetX: i32,
|
||||||
offset: vec2<i32>,
|
offsetY: i32,
|
||||||
currentColor: vec4<f32>,
|
currentColor: vec4<f32>,
|
||||||
trailWeight: f32,
|
trailWeight: f32,
|
||||||
brushWeight: f32
|
brushWeight: f32
|
||||||
) -> vec4<f32> {
|
) -> vec4<f32> {
|
||||||
let neighbour = textureLoad(
|
let neighbourIndex = i32(centerTileIndex) + offsetY * i32(TILE_SIZE_X) + offsetX;
|
||||||
trailMap,
|
let neighbour = tile[u32(neighbourIndex)];
|
||||||
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(
|
||||||
|
|
@ -72,9 +112,6 @@ fn random_from_pixel(pixel: vec2<i32>) -> f32 {
|
||||||
|
|
||||||
fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
|
fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
|
||||||
let r = clamp(random, 0.0, 1.0);
|
let r = clamp(random, 0.0, 1.0);
|
||||||
let r2 = r * r;
|
|
||||||
let r4 = r2 * r2;
|
|
||||||
let r8 = r4 * r4;
|
|
||||||
|
|
||||||
if inverseRate < 1.0 {
|
if inverseRate < 1.0 {
|
||||||
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
|
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
|
||||||
|
|
@ -85,14 +122,17 @@ fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let r2 = r * r;
|
||||||
if inverseRate < 2.0 {
|
if inverseRate < 2.0 {
|
||||||
return mix(r, r2, inverseRate - 1.0);
|
return mix(r, r2, inverseRate - 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let r4 = r2 * r2;
|
||||||
if inverseRate < 4.0 {
|
if inverseRate < 4.0 {
|
||||||
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
|
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let r8 = r4 * r4;
|
||||||
if inverseRate < 8.0 {
|
if inverseRate < 8.0 {
|
||||||
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
|
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ import {
|
||||||
|
|
||||||
describe('diffusion pipeline parameters', () => {
|
describe('diffusion pipeline parameters', () => {
|
||||||
it('keeps zero diffusion rates finite before writing shader uniforms', () => {
|
it('keeps zero diffusion rates finite before writing shader uniforms', () => {
|
||||||
const uniformValues = new Float32Array(4);
|
const uniformValues = new Float32Array(8);
|
||||||
|
|
||||||
setDiffusionUniformValues(uniformValues, {
|
setDiffusionUniformValues(uniformValues, {
|
||||||
|
brushDecayAlphaOffset: 1.001,
|
||||||
decayRateBrush: 900,
|
decayRateBrush: 900,
|
||||||
decayRateTrails: 970,
|
decayRateTrails: 970,
|
||||||
|
diffusionDecayRateDivisor: 1000,
|
||||||
|
diffusionNeighborDivisor: 8,
|
||||||
diffusionRateBrush: 0,
|
diffusionRateBrush: 0,
|
||||||
diffusionRateTrails: 0,
|
diffusionRateTrails: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -28,7 +31,9 @@ describe('diffusion pipeline parameters', () => {
|
||||||
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
|
expect(getSafeInverseDiffusionRate(0.25)).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the diffusion shader on the low-cost trail sampling path', () => {
|
it('keeps the diffusion shader on the tiled compute sampling path', () => {
|
||||||
|
expect(shader).toContain('@compute @workgroup_size(16, 16)');
|
||||||
|
expect(shader).toContain('var<workgroup> tile');
|
||||||
expect(shader).toContain('textureLoad');
|
expect(shader).toContain('textureLoad');
|
||||||
expect(shader).not.toContain('textureSample');
|
expect(shader).not.toContain('textureSample');
|
||||||
expect(shader).not.toContain('pow(');
|
expect(shader).not.toContain('pow(');
|
||||||
|
|
@ -37,7 +42,8 @@ describe('diffusion pipeline parameters', () => {
|
||||||
|
|
||||||
it('keeps shader resource groups aligned with the simplified pipeline layout', () => {
|
it('keeps shader resource groups aligned with the simplified pipeline layout', () => {
|
||||||
expect(shader).toContain('@group(0) @binding(0) var<uniform> settings');
|
expect(shader).toContain('@group(0) @binding(0) var<uniform> settings');
|
||||||
expect(shader).toContain('@group(0) @binding(2) var trailMap');
|
expect(shader).toContain('@group(0) @binding(1) var trailMap');
|
||||||
|
expect(shader).toContain('@group(0) @binding(2) var trailMapOut');
|
||||||
expect(shader).not.toContain('@group(1)');
|
expect(shader).not.toContain('@group(1)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,32 @@
|
||||||
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { appConfig } from '../../config';
|
import { appConfig } from '../../config';
|
||||||
import {
|
import {
|
||||||
createCachedFloat32BufferWrite,
|
createCachedFloat32BufferWrite,
|
||||||
writeFloat32BufferIfChanged,
|
writeFloat32BufferIfChanged,
|
||||||
} from '../../utils/graphics/cached-buffer-write';
|
} from '../../utils/graphics/cached-buffer-write';
|
||||||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
import { getWorkgroupCount } from '../../utils/graphics/get-workgroup-count';
|
||||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||||
import shader from './diffuse.wgsl?raw';
|
import shader from './diffuse.wgsl?raw';
|
||||||
import { DiffusionSettings } from './diffusion-settings';
|
import { DiffusionSettings } from './diffusion-settings';
|
||||||
|
|
||||||
const MIN_DIFFUSION_RATE = appConfig.pipelines.diffusion.minDiffusionRate;
|
|
||||||
|
|
||||||
type DiffusionUniformSettings = Pick<
|
type DiffusionUniformSettings = Pick<
|
||||||
DiffusionSettings,
|
DiffusionSettings,
|
||||||
'diffusionRateTrails' | 'decayRateTrails' | 'diffusionRateBrush' | 'decayRateBrush'
|
| 'diffusionRateTrails'
|
||||||
|
| 'decayRateTrails'
|
||||||
|
| 'diffusionRateBrush'
|
||||||
|
| 'decayRateBrush'
|
||||||
|
| 'diffusionDecayRateDivisor'
|
||||||
|
| 'diffusionNeighborDivisor'
|
||||||
|
| 'brushDecayAlphaOffset'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
|
export const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
|
||||||
1 /
|
1 /
|
||||||
(Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
|
(Number.isFinite(diffusionRate) &&
|
||||||
|
diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
|
||||||
? diffusionRate
|
? diffusionRate
|
||||||
: MIN_DIFFUSION_RATE);
|
: appConfig.pipelines.diffusion.minDiffusionRate);
|
||||||
|
|
||||||
export const setDiffusionUniformValues = (
|
export const setDiffusionUniformValues = (
|
||||||
target: Float32Array,
|
target: Float32Array,
|
||||||
|
|
@ -28,52 +35,48 @@ export const setDiffusionUniformValues = (
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
diffusionRateBrush,
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
|
diffusionDecayRateDivisor,
|
||||||
|
diffusionNeighborDivisor,
|
||||||
|
brushDecayAlphaOffset,
|
||||||
}: DiffusionUniformSettings
|
}: DiffusionUniformSettings
|
||||||
): void => {
|
): void => {
|
||||||
|
const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
|
||||||
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
|
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
|
||||||
target[1] = decayRateTrails / 1000;
|
target[1] = decayRateTrails / decayDivisor;
|
||||||
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
|
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
|
||||||
target[3] = decayRateBrush / 1000;
|
target[3] = decayRateBrush / decayDivisor;
|
||||||
|
target[4] = diffusionNeighborDivisor;
|
||||||
|
target[5] = brushDecayAlphaOffset;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class DiffusionPipeline {
|
export class DiffusionPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly WORKGROUP_SIZE = 16;
|
||||||
|
private static readonly UNIFORM_COUNT = 8;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPUComputePipeline;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
DiffusionPipeline.UNIFORM_COUNT
|
DiffusionPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
private readonly bindGroupsByInput = new WeakMap<
|
||||||
|
GPUTextureView,
|
||||||
private readonly bindGroupsByInput = new WeakMap<GPUTextureView, GPUBindGroup>();
|
WeakMap<GPUTextureView, GPUBindGroup>
|
||||||
|
>();
|
||||||
|
|
||||||
public constructor(private readonly device: GPUDevice) {
|
public constructor(private readonly device: GPUDevice) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(
|
this.bindGroupLayout = device.createBindGroupLayout(
|
||||||
DiffusionPipeline.bindGroupLayout
|
DiffusionPipeline.bindGroupLayout
|
||||||
);
|
);
|
||||||
|
|
||||||
const { buffer, vertex } = setUpFullScreenQuad(device);
|
this.pipeline = device.createComputePipeline({
|
||||||
this.vertexBuffer = buffer;
|
|
||||||
|
|
||||||
this.pipeline = device.createRenderPipeline({
|
|
||||||
layout: device.createPipelineLayout({
|
layout: device.createPipelineLayout({
|
||||||
bindGroupLayouts: [this.bindGroupLayout],
|
bindGroupLayouts: [this.bindGroupLayout],
|
||||||
}),
|
}),
|
||||||
vertex,
|
compute: {
|
||||||
fragment: {
|
|
||||||
module: smartCompile(device, shader),
|
module: smartCompile(device, shader),
|
||||||
entryPoint: 'fragment',
|
entryPoint: 'main',
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
format: 'rgba16float',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
primitive: {
|
|
||||||
topology: 'triangle-strip',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,12 +91,18 @@ export class DiffusionPipeline {
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
diffusionRateBrush,
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
|
diffusionDecayRateDivisor,
|
||||||
|
diffusionNeighborDivisor,
|
||||||
|
brushDecayAlphaOffset,
|
||||||
}: DiffusionSettings) {
|
}: DiffusionSettings) {
|
||||||
setDiffusionUniformValues(this.uniformValues, {
|
setDiffusionUniformValues(this.uniformValues, {
|
||||||
diffusionRateTrails,
|
diffusionRateTrails,
|
||||||
decayRateTrails,
|
decayRateTrails,
|
||||||
diffusionRateBrush,
|
diffusionRateBrush,
|
||||||
decayRateBrush,
|
decayRateBrush,
|
||||||
|
diffusionDecayRateDivisor,
|
||||||
|
diffusionNeighborDivisor,
|
||||||
|
brushDecayAlphaOffset,
|
||||||
});
|
});
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
|
|
@ -106,31 +115,32 @@ export class DiffusionPipeline {
|
||||||
public execute(
|
public execute(
|
||||||
commandEncoder: GPUCommandEncoder,
|
commandEncoder: GPUCommandEncoder,
|
||||||
trailMapIn: GPUTextureView,
|
trailMapIn: GPUTextureView,
|
||||||
trailMapOut: GPUTextureView
|
trailMapOut: GPUTextureView,
|
||||||
|
size: vec2
|
||||||
) {
|
) {
|
||||||
const bindGroup = this.getBindGroup(trailMapIn);
|
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
const passEncoder = commandEncoder.beginComputePass();
|
||||||
colorAttachments: [
|
|
||||||
{
|
|
||||||
view: trailMapOut,
|
|
||||||
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
||||||
loadOp: 'clear',
|
|
||||||
storeOp: 'store',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
|
||||||
passEncoder.setBindGroup(0, bindGroup);
|
passEncoder.setBindGroup(0, bindGroup);
|
||||||
passEncoder.draw(4, 1);
|
passEncoder.dispatchWorkgroups(
|
||||||
|
getWorkgroupCount(size[0], DiffusionPipeline.WORKGROUP_SIZE),
|
||||||
|
getWorkgroupCount(size[1], DiffusionPipeline.WORKGROUP_SIZE)
|
||||||
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBindGroup(trailMapIn: GPUTextureView): GPUBindGroup {
|
private getBindGroup(
|
||||||
const cached = this.bindGroupsByInput.get(trailMapIn);
|
trailMapIn: GPUTextureView,
|
||||||
|
trailMapOut: GPUTextureView
|
||||||
|
): GPUBindGroup {
|
||||||
|
let outputCache = this.bindGroupsByInput.get(trailMapIn);
|
||||||
|
if (!outputCache) {
|
||||||
|
outputCache = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||||
|
this.bindGroupsByInput.set(trailMapIn, outputCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = outputCache.get(trailMapOut);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
@ -145,18 +155,21 @@ export class DiffusionPipeline {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 1,
|
||||||
resource: trailMapIn,
|
resource: trailMapIn,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
resource: trailMapOut,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroupsByInput.set(trailMapIn, bindGroup);
|
outputCache.set(trailMapOut, bindGroup);
|
||||||
return bindGroup;
|
return bindGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.vertexBuffer.destroy();
|
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,18 +178,26 @@ export class DiffusionPipeline {
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
binding: 0,
|
binding: 0,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
buffer: {
|
buffer: {
|
||||||
type: 'uniform',
|
type: 'uniform',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
binding: 2,
|
binding: 1,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
texture: {
|
texture: {
|
||||||
sampleType: 'float',
|
sampleType: 'float',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
binding: 2,
|
||||||
|
visibility: GPUShaderStage.COMPUTE,
|
||||||
|
storageTexture: {
|
||||||
|
access: 'write-only',
|
||||||
|
format: 'rgba16float',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,7 @@ export interface DiffusionSettings {
|
||||||
decayRateTrails: number;
|
decayRateTrails: number;
|
||||||
diffusionRateBrush: number;
|
diffusionRateBrush: number;
|
||||||
decayRateBrush: number;
|
decayRateBrush: number;
|
||||||
|
diffusionDecayRateDivisor: number;
|
||||||
|
diffusionNeighborDivisor: number;
|
||||||
|
brushDecayAlphaOffset: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ export class EraserAgentPipeline {
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
EraserAgentPipeline.UNIFORM_COUNT
|
EraserAgentPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly bindGroupsByMask = new WeakMap<GPUTextureView, GPUBindGroup>();
|
private readonly bindGroupsByAgentsBuffer = new WeakMap<
|
||||||
|
GPUBuffer,
|
||||||
|
WeakMap<GPUTextureView, GPUBindGroup>
|
||||||
|
>();
|
||||||
|
|
||||||
private pendingSegmentCount = 0;
|
private pendingSegmentCount = 0;
|
||||||
private activeSegmentCount = 0;
|
private activeSegmentCount = 0;
|
||||||
|
|
@ -26,7 +29,7 @@ export class EraserAgentPipeline {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly agentsBuffer: GPUBuffer
|
private readonly getAgentsBuffer: () => GPUBuffer
|
||||||
) {
|
) {
|
||||||
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
|
const emptyBindGroupLayout = device.createBindGroupLayout({ entries: [] });
|
||||||
this.bindGroupLayout = device.createBindGroupLayout({
|
this.bindGroupLayout = device.createBindGroupLayout({
|
||||||
|
|
@ -80,13 +83,19 @@ export class EraserAgentPipeline {
|
||||||
this.activeSegmentCount = 0;
|
this.activeSegmentCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({ agentCount }: { agentCount: number }): void {
|
public setParameters({
|
||||||
|
agentCount,
|
||||||
|
eraserMaskAlphaThreshold,
|
||||||
|
}: {
|
||||||
|
agentCount: number;
|
||||||
|
eraserMaskAlphaThreshold: number;
|
||||||
|
}): void {
|
||||||
this.agentCount = agentCount;
|
this.agentCount = agentCount;
|
||||||
this.activeSegmentCount = this.pendingSegmentCount;
|
this.activeSegmentCount = this.pendingSegmentCount;
|
||||||
this.pendingSegmentCount = 0;
|
this.pendingSegmentCount = 0;
|
||||||
|
|
||||||
this.uniformValues[0] = agentCount;
|
this.uniformValues[0] = agentCount;
|
||||||
this.uniformValues[1] = 0;
|
this.uniformValues[1] = eraserMaskAlphaThreshold;
|
||||||
this.uniformValues[2] = 0;
|
this.uniformValues[2] = 0;
|
||||||
this.uniformValues[3] = 0;
|
this.uniformValues[3] = 0;
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
|
|
@ -120,7 +129,14 @@ export class EraserAgentPipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup {
|
private getBindGroup(eraserMask: GPUTextureView): GPUBindGroup {
|
||||||
const cached = this.bindGroupsByMask.get(eraserMask);
|
const agentsBuffer = this.getAgentsBuffer();
|
||||||
|
let maskCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
|
||||||
|
if (!maskCache) {
|
||||||
|
maskCache = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||||
|
this.bindGroupsByAgentsBuffer.set(agentsBuffer, maskCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = maskCache.get(eraserMask);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +153,7 @@ export class EraserAgentPipeline {
|
||||||
{
|
{
|
||||||
binding: 1,
|
binding: 1,
|
||||||
resource: {
|
resource: {
|
||||||
buffer: this.agentsBuffer,
|
buffer: agentsBuffer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -147,7 +163,7 @@ export class EraserAgentPipeline {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bindGroupsByMask.set(eraserMask, bindGroup);
|
maskCache.set(eraserMask, bindGroup);
|
||||||
return bindGroup;
|
return bindGroup;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
agentCount: f32,
|
agentCount: f32,
|
||||||
padding0: f32,
|
eraserMaskAlphaThreshold: f32,
|
||||||
padding1: f32,
|
padding1: f32,
|
||||||
padding2: f32,
|
padding2: f32,
|
||||||
};
|
};
|
||||||
|
|
@ -18,21 +18,20 @@ fn main(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var agent = agents[id];
|
let colorIndex = agents[id].colorIndex;
|
||||||
if agent.colorIndex < 0.0 {
|
if colorIndex < 0.0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let maskSize = vec2<i32>(textureDimensions(eraserMask));
|
let maskSize = vec2<i32>(textureDimensions(eraserMask));
|
||||||
let maskPosition = clamp(
|
let maskPosition = clamp(
|
||||||
vec2<i32>(agent.position),
|
vec2<i32>(agents[id].position),
|
||||||
vec2<i32>(0, 0),
|
vec2<i32>(0, 0),
|
||||||
maskSize - vec2<i32>(1, 1)
|
maskSize - vec2<i32>(1, 1)
|
||||||
);
|
);
|
||||||
let maskSample = textureLoad(eraserMask, maskPosition, 0);
|
let maskSample = textureLoad(eraserMask, maskPosition, 0);
|
||||||
|
|
||||||
if maskSample.a < 0.5 {
|
if maskSample.a < settings.eraserMaskAlphaThreshold {
|
||||||
agent.colorIndex = -1.0;
|
agents[id].colorIndex = -1.0;
|
||||||
agents[id] = agent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,14 @@ interface LineSegment {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EraserTexturePipeline {
|
export class EraserTexturePipeline {
|
||||||
private static readonly UNIFORM_COUNT = 4;
|
private static readonly UNIFORM_COUNT = 8;
|
||||||
private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount;
|
private static readonly MAX_LINE_COUNT = appConfig.pipelines.eraser.maxTextureLineCount;
|
||||||
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
|
private static readonly VERTICES_PER_LINE_SEGMENT = 6;
|
||||||
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 6;
|
private static readonly ATTRIBUTES_PER_LINE_SEGMENT = 4;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly bindGroup: GPUBindGroup;
|
private readonly bindGroup: GPUBindGroup;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly combinedPipeline: 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(
|
||||||
|
|
@ -47,7 +46,7 @@ export class EraserTexturePipeline {
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
binding: 0,
|
binding: 0,
|
||||||
visibility: GPUShaderStage.FRAGMENT,
|
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
|
||||||
buffer: {
|
buffer: {
|
||||||
type: 'uniform',
|
type: 'uniform',
|
||||||
},
|
},
|
||||||
|
|
@ -65,8 +64,7 @@ export class EraserTexturePipeline {
|
||||||
});
|
});
|
||||||
|
|
||||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||||
this.pipeline = this.createPipeline(shaderModule, 'fragment', 1);
|
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', 4);
|
||||||
this.multiTargetPipeline = this.createPipeline(shaderModule, 'fragmentMrt', 3);
|
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -98,13 +96,31 @@ export class EraserTexturePipeline {
|
||||||
this.actualSegments.length = 0;
|
this.actualSegments.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setParameters({ eraserSize }: { eraserSize: number }): void {
|
public setParameters({
|
||||||
|
eraserSize,
|
||||||
|
eraserLineDistanceEpsilon,
|
||||||
|
eraserClearRed,
|
||||||
|
eraserClearGreen,
|
||||||
|
eraserClearBlue,
|
||||||
|
eraserClearAlpha,
|
||||||
|
}: {
|
||||||
|
eraserSize: number;
|
||||||
|
eraserLineDistanceEpsilon: number;
|
||||||
|
eraserClearRed: number;
|
||||||
|
eraserClearGreen: number;
|
||||||
|
eraserClearBlue: number;
|
||||||
|
eraserClearAlpha: number;
|
||||||
|
}): void {
|
||||||
const eraserRadius = eraserSize / 2;
|
const eraserRadius = eraserSize / 2;
|
||||||
|
|
||||||
this.uniformValues[0] = eraserRadius * eraserRadius;
|
this.uniformValues[0] = eraserRadius * eraserRadius;
|
||||||
this.uniformValues[1] = 0;
|
this.uniformValues[1] = eraserLineDistanceEpsilon;
|
||||||
this.uniformValues[2] = 0;
|
this.uniformValues[2] = eraserClearRed;
|
||||||
this.uniformValues[3] = 0;
|
this.uniformValues[3] = eraserClearGreen;
|
||||||
|
this.uniformValues[4] = eraserClearBlue;
|
||||||
|
this.uniformValues[5] = eraserClearAlpha;
|
||||||
|
this.uniformValues[6] = 0;
|
||||||
|
this.uniformValues[7] = 0;
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
@ -131,8 +147,7 @@ export class EraserTexturePipeline {
|
||||||
this.vertexUploadData,
|
this.vertexUploadData,
|
||||||
floatOffset,
|
floatOffset,
|
||||||
segment.from,
|
segment.from,
|
||||||
segment.to,
|
segment.to
|
||||||
eraserRadius
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,46 +160,60 @@ export class EraserTexturePipeline {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public execute(commandEncoder: GPUCommandEncoder, textureOut: GPUTextureView): void {
|
public executeCombined(
|
||||||
this.executeWithPipeline(commandEncoder, this.pipeline, [textureOut]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public executeMultiTarget(
|
|
||||||
commandEncoder: GPUCommandEncoder,
|
commandEncoder: GPUCommandEncoder,
|
||||||
|
eraserMaskOut: GPUTextureView,
|
||||||
sourceMapOut: GPUTextureView,
|
sourceMapOut: GPUTextureView,
|
||||||
influenceMapOut: GPUTextureView,
|
influenceMapOut: GPUTextureView,
|
||||||
trailMapOut: GPUTextureView
|
trailMapOut: GPUTextureView
|
||||||
): void {
|
|
||||||
this.executeWithPipeline(commandEncoder, this.multiTargetPipeline, [
|
|
||||||
sourceMapOut,
|
|
||||||
influenceMapOut,
|
|
||||||
trailMapOut,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private executeWithPipeline(
|
|
||||||
commandEncoder: GPUCommandEncoder,
|
|
||||||
pipeline: GPURenderPipeline,
|
|
||||||
textureViews: Array<GPUTextureView>
|
|
||||||
): void {
|
): void {
|
||||||
if (this.lineCount === 0) {
|
if (this.lineCount === 0) {
|
||||||
|
const passEncoder = commandEncoder.beginRenderPass({
|
||||||
|
colorAttachments: [
|
||||||
|
{
|
||||||
|
view: eraserMaskOut,
|
||||||
|
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
passEncoder.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPassDescriptor: GPURenderPassDescriptor = {
|
const renderPassDescriptor: GPURenderPassDescriptor = {
|
||||||
colorAttachments: textureViews.map<GPURenderPassColorAttachment>((view) => ({
|
colorAttachments: [
|
||||||
view,
|
{
|
||||||
|
view: eraserMaskOut,
|
||||||
|
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: sourceMapOut,
|
||||||
loadOp: 'load',
|
loadOp: 'load',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
})),
|
},
|
||||||
|
{
|
||||||
|
view: influenceMapOut,
|
||||||
|
loadOp: 'load',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: trailMapOut,
|
||||||
|
loadOp: 'load',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||||
passEncoder.setPipeline(pipeline);
|
passEncoder.setPipeline(this.combinedPipeline);
|
||||||
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);
|
||||||
passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT * this.lineCount, 1);
|
passEncoder.draw(EraserTexturePipeline.VERTICES_PER_LINE_SEGMENT, this.lineCount);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +236,10 @@ export class EraserTexturePipeline {
|
||||||
entryPoint: 'vertex',
|
entryPoint: 'vertex',
|
||||||
buffers: [
|
buffers: [
|
||||||
{
|
{
|
||||||
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
|
arrayStride:
|
||||||
|
Float32Array.BYTES_PER_ELEMENT *
|
||||||
|
EraserTexturePipeline.ATTRIBUTES_PER_LINE_SEGMENT,
|
||||||
|
stepMode: 'instance',
|
||||||
attributes: [
|
attributes: [
|
||||||
{
|
{
|
||||||
shaderLocation: 0,
|
shaderLocation: 0,
|
||||||
|
|
@ -219,11 +251,6 @@ export class EraserTexturePipeline {
|
||||||
format: 'float32x2',
|
format: 'float32x2',
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 2,
|
offset: Float32Array.BYTES_PER_ELEMENT * 2,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
shaderLocation: 2,
|
|
||||||
format: 'float32x2',
|
|
||||||
offset: Float32Array.BYTES_PER_ELEMENT * 4,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -261,84 +288,8 @@ export class EraserTexturePipeline {
|
||||||
target: Float32Array,
|
target: Float32Array,
|
||||||
offset: number,
|
offset: number,
|
||||||
from: vec2,
|
from: vec2,
|
||||||
to: vec2,
|
|
||||||
width: number
|
|
||||||
): number {
|
|
||||||
const dx = to[0] - from[0];
|
|
||||||
const dy = to[1] - from[1];
|
|
||||||
const length = Math.hypot(dx, dy);
|
|
||||||
const directionX = length > 0 ? dx / length : 1;
|
|
||||||
const directionY = length > 0 ? dy / length : 0;
|
|
||||||
const scaledDirectionX = directionX * width;
|
|
||||||
const scaledDirectionY = directionY * width;
|
|
||||||
const perpendicularX = directionY * width;
|
|
||||||
const perpendicularY = -directionX * width;
|
|
||||||
|
|
||||||
const startX = from[0] - scaledDirectionX;
|
|
||||||
const startY = from[1] - scaledDirectionY;
|
|
||||||
const endX = to[0] + scaledDirectionX;
|
|
||||||
const endY = to[1] + scaledDirectionY;
|
|
||||||
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
startX + perpendicularX,
|
|
||||||
startY + perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
startX - perpendicularX,
|
|
||||||
startY - perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
endX + perpendicularX,
|
|
||||||
endY + perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
startX - perpendicularX,
|
|
||||||
startY - perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
offset = this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
endX + perpendicularX,
|
|
||||||
endY + perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
return this.writeVertex(
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
endX - perpendicularX,
|
|
||||||
endY - perpendicularY,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeVertex(
|
|
||||||
target: Float32Array,
|
|
||||||
offset: number,
|
|
||||||
screenX: number,
|
|
||||||
screenY: number,
|
|
||||||
from: vec2,
|
|
||||||
to: vec2
|
to: vec2
|
||||||
): number {
|
): number {
|
||||||
target[offset++] = screenX;
|
|
||||||
target[offset++] = screenY;
|
|
||||||
target[offset++] = from[0];
|
target[offset++] = from[0];
|
||||||
target[offset++] = from[1];
|
target[offset++] = from[1];
|
||||||
target[offset++] = to[0];
|
target[offset++] = to[0];
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
struct Settings {
|
struct Settings {
|
||||||
eraserRadiusSquared: f32,
|
eraserRadiusSquared: f32,
|
||||||
|
lineDistanceEpsilon: f32,
|
||||||
|
clearRed: f32,
|
||||||
|
clearGreen: f32,
|
||||||
|
clearBlue: f32,
|
||||||
|
clearAlpha: f32,
|
||||||
padding0: f32,
|
padding0: f32,
|
||||||
padding1: f32,
|
padding1: f32,
|
||||||
padding2: f32,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
@ -10,8 +14,8 @@ struct Settings {
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@builtin(position) position: vec4<f32>,
|
@builtin(position) position: vec4<f32>,
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) @interpolate(flat) end: vec2<f32>
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EraserTextureTargets {
|
struct EraserTextureTargets {
|
||||||
|
|
@ -20,12 +24,20 @@ struct EraserTextureTargets {
|
||||||
@location(2) trail: vec4<f32>,
|
@location(2) trail: vec4<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct EraserCombinedTargets {
|
||||||
|
@location(0) mask: vec4<f32>,
|
||||||
|
@location(1) source: vec4<f32>,
|
||||||
|
@location(2) influence: vec4<f32>,
|
||||||
|
@location(3) trail: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vertex(
|
fn vertex(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@builtin(vertex_index) vertexIndex: u32,
|
||||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
@location(0) start: vec2<f32>,
|
||||||
@location(2) @interpolate(flat) end: vec2<f32>
|
@location(1) end: vec2<f32>
|
||||||
) -> VertexOutput {
|
) -> VertexOutput {
|
||||||
|
let screenPosition = segment_vertex_position(vertexIndex, start, end, sqrt(settings.eraserRadiusSquared));
|
||||||
let uv = screenPosition / state.size;
|
let uv = screenPosition / state.size;
|
||||||
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
|
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
|
||||||
|
|
@ -34,30 +46,53 @@ fn vertex(
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(
|
fn fragment(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) @interpolate(flat) end: vec2<f32>
|
||||||
) -> @location(0) vec4<f32> {
|
) -> @location(0) vec4<f32> {
|
||||||
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||||
discard;
|
discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
return getEraserClearValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragmentMrt(
|
fn fragmentMrt(
|
||||||
@location(0) screenPosition: vec2<f32>,
|
@location(0) screenPosition: vec2<f32>,
|
||||||
@location(1) start: vec2<f32>,
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
@location(2) end: vec2<f32>
|
@location(2) @interpolate(flat) end: vec2<f32>
|
||||||
) -> EraserTextureTargets {
|
) -> EraserTextureTargets {
|
||||||
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||||
discard;
|
discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cleared = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
let cleared = getEraserClearValue();
|
||||||
return EraserTextureTargets(cleared, cleared, cleared);
|
return EraserTextureTargets(cleared, cleared, cleared);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragmentCombined(
|
||||||
|
@location(0) screenPosition: vec2<f32>,
|
||||||
|
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||||
|
@location(2) @interpolate(flat) end: vec2<f32>
|
||||||
|
) -> EraserCombinedTargets {
|
||||||
|
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleared = getEraserClearValue();
|
||||||
|
return EraserCombinedTargets(cleared, cleared, cleared, cleared);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getEraserClearValue() -> vec4<f32> {
|
||||||
|
return vec4<f32>(
|
||||||
|
settings.clearRed,
|
||||||
|
settings.clearGreen,
|
||||||
|
settings.clearBlue,
|
||||||
|
settings.clearAlpha
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn shouldDiscardEraserFragment(
|
fn shouldDiscardEraserFragment(
|
||||||
screenPosition: vec2<f32>,
|
screenPosition: vec2<f32>,
|
||||||
start: vec2<f32>,
|
start: vec2<f32>,
|
||||||
|
|
@ -71,7 +106,7 @@ fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>
|
||||||
let direction = end - start;
|
let direction = end - start;
|
||||||
let denominator = dot(direction, direction);
|
let denominator = dot(direction, direction);
|
||||||
|
|
||||||
if denominator <= 0.0001 {
|
if denominator <= settings.lineDistanceEpsilon {
|
||||||
return dot(pa, pa);
|
return dot(pa, pa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,3 +114,34 @@ fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>
|
||||||
let nearestOffset = pa - direction * q;
|
let nearestOffset = pa - direction * q;
|
||||||
return dot(nearestOffset, nearestOffset);
|
return dot(nearestOffset, nearestOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn segment_vertex_position(
|
||||||
|
vertexIndex: u32,
|
||||||
|
start: vec2<f32>,
|
||||||
|
end: vec2<f32>,
|
||||||
|
radius: f32
|
||||||
|
) -> vec2<f32> {
|
||||||
|
let directionVector = end - start;
|
||||||
|
let segmentLength = length(directionVector);
|
||||||
|
var direction = vec2<f32>(1.0, 0.0);
|
||||||
|
if segmentLength > 0.0 {
|
||||||
|
direction = directionVector / segmentLength;
|
||||||
|
}
|
||||||
|
let perpendicular = vec2<f32>(direction.y, -direction.x);
|
||||||
|
let corner = segment_vertex_corner(vertexIndex % 6u);
|
||||||
|
let center = mix(start, end, (corner.x + 1.0) * 0.5);
|
||||||
|
return center + direction * corner.x * radius + perpendicular * corner.y * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_vertex_corner(index: u32) -> vec2<f32> {
|
||||||
|
if index == 0u {
|
||||||
|
return vec2<f32>(-1.0, 1.0);
|
||||||
|
}
|
||||||
|
if index == 1u || index == 3u {
|
||||||
|
return vec2<f32>(-1.0, -1.0);
|
||||||
|
}
|
||||||
|
if index == 2u || index == 4u {
|
||||||
|
return vec2<f32>(1.0, 1.0);
|
||||||
|
}
|
||||||
|
return vec2<f32>(1.0, -1.0);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,17 @@ import { RenderSettings } from './render-settings';
|
||||||
import shader from './render.wgsl?raw';
|
import shader from './render.wgsl?raw';
|
||||||
|
|
||||||
export class RenderPipeline {
|
export class RenderPipeline {
|
||||||
private static readonly UNIFORM_COUNT = 16;
|
private static readonly UNIFORM_COUNT = 20;
|
||||||
|
|
||||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||||
private readonly pipeline: GPURenderPipeline;
|
private readonly pipeline: GPURenderPipeline;
|
||||||
|
private readonly sampledPipeline: GPURenderPipeline;
|
||||||
private readonly sampler: GPUSampler;
|
private readonly sampler: GPUSampler;
|
||||||
private readonly uniforms: GPUBuffer;
|
private readonly uniforms: GPUBuffer;
|
||||||
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
|
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
|
||||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||||
RenderPipeline.UNIFORM_COUNT
|
RenderPipeline.UNIFORM_COUNT
|
||||||
);
|
);
|
||||||
private readonly vertexBuffer: GPUBuffer;
|
|
||||||
|
|
||||||
private readonly bindGroupsByTexture = new WeakMap<
|
private readonly bindGroupsByTexture = new WeakMap<
|
||||||
GPUTextureView,
|
GPUTextureView,
|
||||||
|
|
@ -33,8 +33,7 @@ export class RenderPipeline {
|
||||||
) {
|
) {
|
||||||
this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout);
|
this.bindGroupLayout = device.createBindGroupLayout(RenderPipeline.bindGroupLayout);
|
||||||
|
|
||||||
const { buffer, vertex } = setUpFullScreenQuad(device);
|
const vertex = setUpFullScreenQuad(device);
|
||||||
this.vertexBuffer = buffer;
|
|
||||||
|
|
||||||
this.sampler = device.createSampler({
|
this.sampler = device.createSampler({
|
||||||
magFilter: 'linear',
|
magFilter: 'linear',
|
||||||
|
|
@ -42,7 +41,8 @@ export class RenderPipeline {
|
||||||
});
|
});
|
||||||
|
|
||||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||||
this.pipeline = this.createPipeline(format, vertex);
|
this.pipeline = this.createPipeline(format, vertex, 'fragment');
|
||||||
|
this.sampledPipeline = this.createPipeline(format, vertex, 'fragmentSampled');
|
||||||
|
|
||||||
this.uniforms = this.device.createBuffer({
|
this.uniforms = this.device.createBuffer({
|
||||||
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||||
|
|
@ -52,7 +52,8 @@ export class RenderPipeline {
|
||||||
|
|
||||||
private createPipeline(
|
private createPipeline(
|
||||||
format: GPUTextureFormat,
|
format: GPUTextureFormat,
|
||||||
vertex: GPUVertexState
|
vertex: GPUVertexState,
|
||||||
|
fragmentEntryPoint: string
|
||||||
): GPURenderPipeline {
|
): GPURenderPipeline {
|
||||||
return this.device.createRenderPipeline({
|
return this.device.createRenderPipeline({
|
||||||
layout: this.device.createPipelineLayout({
|
layout: this.device.createPipelineLayout({
|
||||||
|
|
@ -61,7 +62,7 @@ export class RenderPipeline {
|
||||||
vertex,
|
vertex,
|
||||||
fragment: {
|
fragment: {
|
||||||
module: smartCompile(this.device, CommonState.shaderCode, shader),
|
module: smartCompile(this.device, CommonState.shaderCode, shader),
|
||||||
entryPoint: 'fragment',
|
entryPoint: fragmentEntryPoint,
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format,
|
format,
|
||||||
|
|
@ -69,7 +70,7 @@ export class RenderPipeline {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
primitive: {
|
primitive: {
|
||||||
topology: 'triangle-strip',
|
topology: 'triangle-list',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +79,10 @@ export class RenderPipeline {
|
||||||
channelColors,
|
channelColors,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
clarity,
|
clarity,
|
||||||
|
renderTraceNormalizationFloor,
|
||||||
|
renderBrushColorBase,
|
||||||
|
renderBrushColorStrengthMultiplier,
|
||||||
|
backgroundGrainStrength,
|
||||||
}: RenderSettings & {
|
}: RenderSettings & {
|
||||||
channelColors: Array<[number, number, number]>;
|
channelColors: Array<[number, number, number]>;
|
||||||
backgroundColor: [number, number, number];
|
backgroundColor: [number, number, number];
|
||||||
|
|
@ -99,6 +104,10 @@ export class RenderPipeline {
|
||||||
this.uniformValues[13] = backgroundColor[1];
|
this.uniformValues[13] = backgroundColor[1];
|
||||||
this.uniformValues[14] = backgroundColor[2];
|
this.uniformValues[14] = backgroundColor[2];
|
||||||
this.uniformValues[15] = clarity;
|
this.uniformValues[15] = clarity;
|
||||||
|
this.uniformValues[16] = renderTraceNormalizationFloor;
|
||||||
|
this.uniformValues[17] = renderBrushColorBase;
|
||||||
|
this.uniformValues[18] = renderBrushColorStrengthMultiplier;
|
||||||
|
this.uniformValues[19] = backgroundGrainStrength;
|
||||||
writeFloat32BufferIfChanged(
|
writeFloat32BufferIfChanged(
|
||||||
this.device,
|
this.device,
|
||||||
this.uniforms,
|
this.uniforms,
|
||||||
|
|
@ -128,9 +137,8 @@ export class RenderPipeline {
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.pipeline);
|
||||||
this.commonState.execute(passEncoder);
|
this.commonState.execute(passEncoder);
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
|
||||||
passEncoder.setBindGroup(1, bindGroup);
|
passEncoder.setBindGroup(1, bindGroup);
|
||||||
passEncoder.draw(4, 1);
|
passEncoder.draw(3, 1);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
return canvasTexture;
|
return canvasTexture;
|
||||||
|
|
@ -154,11 +162,10 @@ export class RenderPipeline {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
passEncoder.setPipeline(this.pipeline);
|
passEncoder.setPipeline(this.sampledPipeline);
|
||||||
this.commonState.execute(passEncoder);
|
this.commonState.execute(passEncoder);
|
||||||
passEncoder.setVertexBuffer(0, this.vertexBuffer);
|
|
||||||
passEncoder.setBindGroup(1, bindGroup);
|
passEncoder.setBindGroup(1, bindGroup);
|
||||||
passEncoder.draw(4, 1);
|
passEncoder.draw(3, 1);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,7 +213,6 @@ export class RenderPipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.vertexBuffer.destroy();
|
|
||||||
this.uniforms.destroy();
|
this.uniforms.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
export interface RenderSettings {
|
export interface RenderSettings {
|
||||||
clarity: number;
|
clarity: number;
|
||||||
|
renderTraceNormalizationFloor: number;
|
||||||
|
renderBrushColorBase: number;
|
||||||
|
renderBrushColorStrengthMultiplier: number;
|
||||||
|
backgroundGrainStrength: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ struct Settings {
|
||||||
backgroundColorPadding2: f32,
|
backgroundColorPadding2: f32,
|
||||||
backgroundColor: vec3<f32>,
|
backgroundColor: vec3<f32>,
|
||||||
clarity: f32,
|
clarity: f32,
|
||||||
|
traceNormalizationFloor: f32,
|
||||||
|
brushColorBase: f32,
|
||||||
|
brushColorStrengthMultiplier: f32,
|
||||||
|
backgroundGrainStrength: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||||
|
|
@ -15,9 +19,29 @@ struct Settings {
|
||||||
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
|
@group(1) @binding(3) var sourceMap: 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> {
|
||||||
|
let textureSize = vec2<i32>(textureDimensions(trailMap, 0));
|
||||||
|
let pixel = clamp(vec2<i32>(position.xy), vec2<i32>(0, 0), textureSize - vec2<i32>(1, 1));
|
||||||
|
let traces = textureLoad(trailMap, pixel, 0);
|
||||||
|
let sources = textureLoad(sourceMap, pixel, 0);
|
||||||
|
return renderColor(traces, sources, vec2<i32>(position.xy));
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragmentSampled(
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
@builtin(position) position: vec4<f32>
|
||||||
|
) -> @location(0) vec4<f32> {
|
||||||
let traces = textureSample(trailMap, Sampler, uv);
|
let traces = textureSample(trailMap, Sampler, uv);
|
||||||
let sources = textureSample(sourceMap, Sampler, uv);
|
let sources = textureSample(sourceMap, Sampler, uv);
|
||||||
|
return renderColor(traces, sources, vec2<i32>(position.xy));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<f32> {
|
||||||
|
let background = getTexturedBackground(pixel);
|
||||||
|
if max(max(max(traces.r, traces.g), traces.b), max(max(sources.r, sources.g), sources.b)) <= 0.0 {
|
||||||
|
return vec4(background, 1);
|
||||||
|
}
|
||||||
|
|
||||||
let traceStrengths = vec3(
|
let traceStrengths = vec3(
|
||||||
clarity(traces.r),
|
clarity(traces.r),
|
||||||
|
|
@ -34,17 +58,19 @@ fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
strengths.r * settings.colorA
|
strengths.r * settings.colorA
|
||||||
+ strengths.g * settings.colorB
|
+ strengths.g * settings.colorB
|
||||||
+ strengths.b * settings.colorC;
|
+ strengths.b * settings.colorC;
|
||||||
let normalizedTraceColor = traceColor / max(1.0, strengths.r + strengths.g + strengths.b);
|
let normalizedTraceColor =
|
||||||
|
traceColor / max(settings.traceNormalizationFloor, strengths.r + strengths.g + strengths.b);
|
||||||
let brushColor =
|
let brushColor =
|
||||||
sourceStrengths.r * settings.colorA
|
sourceStrengths.r * settings.colorA
|
||||||
+ sourceStrengths.g * settings.colorB
|
+ sourceStrengths.g * settings.colorB
|
||||||
+ sourceStrengths.b * settings.colorC;
|
+ sourceStrengths.b * settings.colorC;
|
||||||
let brushStrength = clamp(max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b), 0, 1);
|
let brushStrength = max(max(sourceStrengths.r, sourceStrengths.g), sourceStrengths.b);
|
||||||
let color = max(normalizedTraceColor, brushColor * (1.2 + brushStrength * 1.6));
|
let color = max(
|
||||||
|
normalizedTraceColor,
|
||||||
let strength = clamp(max(max(max(strengths.r, strengths.g), strengths.b), brushStrength), 0, 1);
|
brushColor * (settings.brushColorBase + brushStrength * settings.brushColorStrengthMultiplier)
|
||||||
let background = getTexturedBackground(uv);
|
);
|
||||||
|
|
||||||
|
let strength = max(max(strengths.r, strengths.g), strengths.b);
|
||||||
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
return vec4(mix(background, clamp(color, vec3(0), vec3(1)), strength), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,11 +78,14 @@ fn clarity(strength: f32) -> f32 {
|
||||||
return pow(clamp(strength, 0, 1), settings.clarity);
|
return pow(clamp(strength, 0, 1), settings.clarity);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn getTexturedBackground(uv: vec2<f32>) -> vec3<f32> {
|
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
|
||||||
let noiseSize = vec2<f32>(textureDimensions(noise, 0));
|
let noiseSize = vec2<i32>(textureDimensions(noise, 0));
|
||||||
let pixel = floor(uv * state.size);
|
let noiseCoord = pixel % noiseSize;
|
||||||
let noiseCoord = vec2<i32>(fract(pixel / noiseSize) * noiseSize);
|
|
||||||
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
|
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
|
||||||
|
|
||||||
return clamp(settings.backgroundColor + vec3(grain * 0.018), vec3(0), vec3(1));
|
return clamp(
|
||||||
|
settings.backgroundColor + vec3(grain * settings.backgroundGrainStrength),
|
||||||
|
vec3(0),
|
||||||
|
vec3(1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import agentShader from './agents/agent.wgsl?raw';
|
||||||
import { BrushPipeline } from './brush/brush-pipeline';
|
import { BrushPipeline } from './brush/brush-pipeline';
|
||||||
import brushShader from './brush/brush.wgsl?raw';
|
import brushShader from './brush/brush.wgsl?raw';
|
||||||
import { CommonState } from './common-state/common-state';
|
import { CommonState } from './common-state/common-state';
|
||||||
import { CopyPipeline } from './copy/copy-pipeline';
|
|
||||||
import copyShader from './copy/copy.wgsl?raw';
|
|
||||||
import diffusionShader from './diffusion/diffuse.wgsl?raw';
|
import diffusionShader from './diffusion/diffuse.wgsl?raw';
|
||||||
import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
|
import { DiffusionPipeline } from './diffusion/diffusion-pipeline';
|
||||||
import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
|
import { EraserAgentPipeline } from './eraser/eraser-agent-pipeline';
|
||||||
|
|
@ -103,7 +101,8 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
fieldNames: [
|
fieldNames: [
|
||||||
'moveRate',
|
'moveRate',
|
||||||
'turnRate',
|
'turnRate',
|
||||||
'sensorAngle',
|
'sensorAngleSin',
|
||||||
|
'sensorAngleCos',
|
||||||
'sensorOffset',
|
'sensorOffset',
|
||||||
'turnWhenLost',
|
'turnWhenLost',
|
||||||
'individualTrailWeight',
|
'individualTrailWeight',
|
||||||
|
|
@ -118,6 +117,21 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
'color3ToColor1',
|
'color3ToColor1',
|
||||||
'color3ToColor2',
|
'color3ToColor2',
|
||||||
'color3ToColor3',
|
'color3ToColor3',
|
||||||
|
'sourceAttractionWeight',
|
||||||
|
'sourceSlowMoveRate',
|
||||||
|
'sourceTrailWeightMultiplier',
|
||||||
|
'forwardRotationScale',
|
||||||
|
'introNearDistanceInner',
|
||||||
|
'introNearDistanceMin',
|
||||||
|
'introNearSensorOffsetMultiplier',
|
||||||
|
'introTargetAngleBlend',
|
||||||
|
'introProgressCutoff',
|
||||||
|
'introTurnRateMultiplier',
|
||||||
|
'introRandomTurnMultiplier',
|
||||||
|
'introFarMoveMultiplier',
|
||||||
|
'introNearMoveMultiplier',
|
||||||
|
'introStepStopDistance',
|
||||||
|
'randomTimeScale',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expectStructUniformLayout({
|
expectStructUniformLayout({
|
||||||
|
|
@ -127,9 +141,17 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
fieldNames: [
|
fieldNames: [
|
||||||
'brushSize',
|
'brushSize',
|
||||||
'brushSizeVariation',
|
'brushSizeVariation',
|
||||||
'padding0',
|
'brushFeatherRatio',
|
||||||
'padding1',
|
'brushMinimumFeather',
|
||||||
'brushValue',
|
'brushValue',
|
||||||
|
'brushCoarseNoiseScale',
|
||||||
|
'brushGrainNoiseScale',
|
||||||
|
'brushGrainNoiseOffsetX',
|
||||||
|
'brushGrainNoiseOffsetY',
|
||||||
|
'brushDiscardThreshold',
|
||||||
|
'brushGrainMinStrength',
|
||||||
|
'brushGrainMaxStrength',
|
||||||
|
'brushGeometryRadius',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expectStructUniformLayout({
|
expectStructUniformLayout({
|
||||||
|
|
@ -141,6 +163,10 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
'decayRateTrails',
|
'decayRateTrails',
|
||||||
'inverseDiffusionRateBrush',
|
'inverseDiffusionRateBrush',
|
||||||
'decayRateBrush',
|
'decayRateBrush',
|
||||||
|
'diffusionNeighborDivisor',
|
||||||
|
'brushDecayAlphaOffset',
|
||||||
|
'padding0',
|
||||||
|
'padding1',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expectStructUniformLayout({
|
expectStructUniformLayout({
|
||||||
|
|
@ -156,6 +182,10 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
'backgroundColorPadding2',
|
'backgroundColorPadding2',
|
||||||
'backgroundColor',
|
'backgroundColor',
|
||||||
'clarity',
|
'clarity',
|
||||||
|
'traceNormalizationFloor',
|
||||||
|
'brushColorBase',
|
||||||
|
'brushColorStrengthMultiplier',
|
||||||
|
'backgroundGrainStrength',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -165,25 +195,25 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
pipeline: EraserAgentPipeline,
|
pipeline: EraserAgentPipeline,
|
||||||
source: eraserAgentShader,
|
source: eraserAgentShader,
|
||||||
structName: 'Settings',
|
structName: 'Settings',
|
||||||
fieldNames: ['agentCount', 'padding0', 'padding1', 'padding2'],
|
fieldNames: ['agentCount', 'eraserMaskAlphaThreshold', 'padding1', 'padding2'],
|
||||||
});
|
});
|
||||||
expectStructUniformLayout({
|
expectStructUniformLayout({
|
||||||
pipeline: EraserTexturePipeline,
|
pipeline: EraserTexturePipeline,
|
||||||
source: eraserTextureShader,
|
source: eraserTextureShader,
|
||||||
structName: 'Settings',
|
structName: 'Settings',
|
||||||
fieldNames: ['eraserRadiusSquared', 'padding0', 'padding1', 'padding2'],
|
fieldNames: [
|
||||||
|
'eraserRadiusSquared',
|
||||||
|
'lineDistanceEpsilon',
|
||||||
|
'clearRed',
|
||||||
|
'clearGreen',
|
||||||
|
'clearBlue',
|
||||||
|
'clearAlpha',
|
||||||
|
'padding0',
|
||||||
|
'padding1',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps copy uniforms aligned with WGSL', () => {
|
|
||||||
const match = /var<uniform>\s+sourceScaler:\s*(?<type>[^;]+);/.exec(copyShader);
|
|
||||||
|
|
||||||
expect(match?.groups?.type).toBe('vec2<f32>');
|
|
||||||
expect(wgslFloatCountsByType[match?.groups?.type ?? '']).toBe(
|
|
||||||
getUniformCount(CopyPipeline)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps agent-generation uniforms large enough for every generation shader', () => {
|
it('keeps agent-generation uniforms large enough for every generation shader', () => {
|
||||||
const generationUniformCounts = [
|
const generationUniformCounts = [
|
||||||
countUniformScalars(resizeShader, 'ResizeSettings'),
|
countUniformScalars(resizeShader, 'ResizeSettings'),
|
||||||
|
|
@ -196,7 +226,7 @@ describe('WGSL uniform layout contracts', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('guards invalid high agent color indexes instead of treating them as color 3', () => {
|
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('colorIndex < 0.0 || colorIndex >= 2.5');
|
||||||
expect(agentShader).toContain('if colorIndex < 2.5');
|
expect(agentShader).toContain('if colorIndex < 2.5');
|
||||||
expect(agentShader).toContain('return vec3<f32>(0.0, 0.0, 0.0);');
|
expect(agentShader).toContain('return vec3<f32>(0.0, 0.0, 0.0);');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,8 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
padding-top: 7px;
|
padding-top: 7px;
|
||||||
border-top: 1px solid rgb(255 255 255 / 12%);
|
border-top: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
|
||||||
> button {
|
> button,
|
||||||
|
> .audio-control > button {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|
@ -229,6 +230,143 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .audio-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 132px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 132px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(255 255 255 / 4%);
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-time),
|
||||||
|
background-color var(--transition-time),
|
||||||
|
box-shadow var(--transition-time),
|
||||||
|
opacity var(--transition-time);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgb(255 255 255 / 10%);
|
||||||
|
background: rgb(255 255 255 / 7%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
outline: 2px solid white;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
flex: 0 0 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
border-color: transparent;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .volume-control {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
height: 44px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 3px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0.96;
|
||||||
|
transition: opacity var(--transition-time);
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .volume-control input[type='range'] {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: ew-resize;
|
||||||
|
outline: none;
|
||||||
|
touch-action: pan-y;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-runnable-track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--accent-color) 62%, white 8%) 0
|
||||||
|
var(--volume-progress, 42%),
|
||||||
|
rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 1px rgb(0 0 0 / 24%),
|
||||||
|
0 1px 0 rgb(255 255 255 / 8%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgb(13 18 24);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(245 250 244);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(255 255 255 / 46%),
|
||||||
|
0 3px 8px rgb(0 0 0 / 28%);
|
||||||
|
margin-top: -4px;
|
||||||
|
appearance: none;
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-time),
|
||||||
|
transform var(--transition-time);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(255 255 255 / 56%),
|
||||||
|
0 0 0 5px color-mix(in srgb, var(--accent-color) 25%, transparent),
|
||||||
|
0 4px 10px rgb(0 0 0 / 34%);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
height: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--accent-color) 62%, white 8%) 0
|
||||||
|
var(--volume-progress, 42%),
|
||||||
|
rgb(255 255 255 / 18%) var(--volume-progress, 42%) 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 1px rgb(0 0 0 / 24%),
|
||||||
|
0 1px 0 rgb(255 255 255 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgb(13 18 24);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(245 250 244);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(255 255 255 / 46%),
|
||||||
|
0 3px 8px rgb(0 0 0 / 28%);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .export-status {
|
> .export-status {
|
||||||
flex: 0 1 140px;
|
flex: 0 1 140px;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
|
|
@ -451,6 +589,22 @@ html > body > aside.control-dock > .toolbar-row {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .audio-control {
|
||||||
|
width: 118px;
|
||||||
|
height: 38px;
|
||||||
|
flex-basis: 118px;
|
||||||
|
padding-right: 9px;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
flex-basis: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .volume-control {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .export-status {
|
> .export-status {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ export class DeltaTimeCalculator {
|
||||||
private previousTime: DOMHighResTimeStamp | null = null;
|
private previousTime: DOMHighResTimeStamp | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly maxDeltaTimeInSeconds: number = appConfig.deltaTime
|
private readonly maxDeltaTimeInSeconds?: number,
|
||||||
.maxDeltaTimeSeconds,
|
private readonly minDeltaTimeInSeconds?: number
|
||||||
private readonly minDeltaTimeInSeconds: number = appConfig.deltaTime
|
|
||||||
.minDeltaTimeSeconds
|
|
||||||
) {
|
) {
|
||||||
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +20,7 @@ export class DeltaTimeCalculator {
|
||||||
|
|
||||||
const delta = currentTime - this.previousTime;
|
const delta = currentTime - this.previousTime;
|
||||||
this.previousTime = currentTime;
|
this.previousTime = currentTime;
|
||||||
return clamp(delta / 1000, this.minDeltaTimeInSeconds, this.maxDeltaTimeInSeconds);
|
return clamp(delta / 1000, this.minDeltaTime, this.maxDeltaTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleVisibilityChange() {
|
private handleVisibilityChange() {
|
||||||
|
|
@ -30,4 +28,12 @@ export class DeltaTimeCalculator {
|
||||||
this.previousTime = null;
|
this.previousTime = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get maxDeltaTime(): number {
|
||||||
|
return this.maxDeltaTimeInSeconds ?? appConfig.deltaTime.maxDeltaTimeSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get minDeltaTime(): number {
|
||||||
|
return this.minDeltaTimeInSeconds ?? appConfig.deltaTime.minDeltaTimeSeconds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,6 @@
|
||||||
import { smartCompile } from './smart-compile';
|
import { smartCompile } from './smart-compile';
|
||||||
|
|
||||||
export const setUpFullScreenQuad = (
|
export const setUpFullScreenQuad = (device: GPUDevice): GPUVertexState => ({
|
||||||
device: GPUDevice
|
|
||||||
): {
|
|
||||||
buffer: GPUBuffer;
|
|
||||||
vertex: GPUVertexState;
|
|
||||||
} => {
|
|
||||||
const buffer = device.createBuffer({
|
|
||||||
size: 4 * 4 * Float32Array.BYTES_PER_ELEMENT, // 4 x vec4<f32>
|
|
||||||
usage: GPUBufferUsage.VERTEX,
|
|
||||||
mappedAtCreation: true,
|
|
||||||
});
|
|
||||||
// prettier-ignore
|
|
||||||
const vertexData = [
|
|
||||||
// posX posY U V
|
|
||||||
-1.0, -1.0, 0.0, 1.0,
|
|
||||||
+1.0, -1.0, 1.0, 1.0,
|
|
||||||
-1.0, +1.0, 0.0, 0.0,
|
|
||||||
+1.0, +1.0, 1.0, 0.0,
|
|
||||||
];
|
|
||||||
new Float32Array(buffer.getMappedRange()).set(vertexData);
|
|
||||||
buffer.unmap();
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer,
|
|
||||||
vertex: {
|
|
||||||
module: smartCompile(
|
module: smartCompile(
|
||||||
device,
|
device,
|
||||||
/* wgsl */ `
|
/* wgsl */ `
|
||||||
|
|
@ -34,32 +10,16 @@ export const setUpFullScreenQuad = (
|
||||||
}
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vertex(
|
fn vertex(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
||||||
@location(0) position: vec2<f32>,
|
let positions = array<vec2<f32>, 3>(
|
||||||
@location(1) uv: vec2<f32>
|
vec2<f32>(-1.0, -1.0),
|
||||||
) -> VertexOutput {
|
vec2<f32>(3.0, -1.0),
|
||||||
|
vec2<f32>(-1.0, 3.0)
|
||||||
|
);
|
||||||
|
let position = positions[vertexIndex];
|
||||||
|
let uv = vec2<f32>(position.x * 0.5 + 0.5, 0.5 - position.y * 0.5);
|
||||||
return VertexOutput(vec4(position, 0.0, 1.0), uv);
|
return VertexOutput(vec4(position, 0.0, 1.0), uv);
|
||||||
}`
|
}`
|
||||||
),
|
),
|
||||||
entryPoint: 'vertex',
|
entryPoint: 'vertex',
|
||||||
buffers: [
|
});
|
||||||
{
|
|
||||||
arrayStride: 4 * Float32Array.BYTES_PER_ELEMENT,
|
|
||||||
stepMode: 'vertex',
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
shaderLocation: 0,
|
|
||||||
offset: 0,
|
|
||||||
format: 'float32x2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shaderLocation: 1,
|
|
||||||
offset: 8,
|
|
||||||
format: 'float32x2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { appConfig } from '../../config';
|
||||||
import { setUpFullScreenQuad } from './full-screen-quad';
|
import { setUpFullScreenQuad } from './full-screen-quad';
|
||||||
import { smartCompile } from './smart-compile';
|
import { smartCompile } from './smart-compile';
|
||||||
|
|
||||||
const textureCache = new WeakMap<GPUDevice, Map<string, GPUTexture>>();
|
const textureCache = new WeakMap<GPUDevice, Map<string, GPUTexture>>();
|
||||||
const NOISE_TEXTURE_FORMAT: GPUTextureFormat = 'rgba8unorm';
|
|
||||||
|
|
||||||
export const generateNoise = ({
|
export const generateNoise = ({
|
||||||
device,
|
device,
|
||||||
|
|
@ -13,7 +13,7 @@ export const generateNoise = ({
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}): GPUTextureView => {
|
}): GPUTextureView => {
|
||||||
const cacheKey = `${width}x${height}`;
|
const cacheKey = `${width}x${height}:${appConfig.pipelines.common.noiseTextureFormat}`;
|
||||||
let deviceCache = textureCache.get(device);
|
let deviceCache = textureCache.get(device);
|
||||||
if (!deviceCache) {
|
if (!deviceCache) {
|
||||||
deviceCache = new Map<string, GPUTexture>();
|
deviceCache = new Map<string, GPUTexture>();
|
||||||
|
|
@ -25,8 +25,7 @@ export const generateNoise = ({
|
||||||
return cached.createView();
|
return cached.createView();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { buffer, vertex } = setUpFullScreenQuad(device);
|
const vertex = setUpFullScreenQuad(device);
|
||||||
const vertexBuffer = buffer;
|
|
||||||
|
|
||||||
const pipeline = device.createRenderPipeline({
|
const pipeline = device.createRenderPipeline({
|
||||||
layout: 'auto',
|
layout: 'auto',
|
||||||
|
|
@ -36,28 +35,34 @@ export const generateNoise = ({
|
||||||
device,
|
device,
|
||||||
/* wgsl */ `
|
/* wgsl */ `
|
||||||
fn random_with_seed(uv: vec2<f32>, seed: f32) -> f32 {
|
fn random_with_seed(uv: vec2<f32>, seed: f32) -> f32 {
|
||||||
return fract(sin(dot(uv, vec2(12.9898 + seed, 78.233 + seed)))* 43758.5453123 + seed);
|
return fract(sin(dot(
|
||||||
|
uv,
|
||||||
|
vec2(
|
||||||
|
${appConfig.pipelines.common.noiseHashX} + seed,
|
||||||
|
${appConfig.pipelines.common.noiseHashY} + seed
|
||||||
|
)
|
||||||
|
)) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||||
return vec4(
|
return vec4(
|
||||||
random_with_seed(uv, 0),
|
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
|
||||||
random_with_seed(uv, 1),
|
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[1]}),
|
||||||
random_with_seed(uv, 2),
|
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[2]}),
|
||||||
random_with_seed(uv, 3),
|
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[3]}),
|
||||||
);
|
);
|
||||||
}`
|
}`
|
||||||
),
|
),
|
||||||
entryPoint: 'fragment',
|
entryPoint: 'fragment',
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
format: NOISE_TEXTURE_FORMAT,
|
format: appConfig.pipelines.common.noiseTextureFormat,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
primitive: {
|
primitive: {
|
||||||
topology: 'triangle-strip',
|
topology: 'triangle-list',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,7 +72,7 @@ export const generateNoise = ({
|
||||||
height,
|
height,
|
||||||
depthOrArrayLayers: 1,
|
depthOrArrayLayers: 1,
|
||||||
},
|
},
|
||||||
format: NOISE_TEXTURE_FORMAT,
|
format: appConfig.pipelines.common.noiseTextureFormat,
|
||||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -75,7 +80,7 @@ export const generateNoise = ({
|
||||||
colorAttachments: [
|
colorAttachments: [
|
||||||
{
|
{
|
||||||
view: colorTexture.createView(),
|
view: colorTexture.createView(),
|
||||||
clearValue: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
|
clearValue: appConfig.pipelines.common.noiseClearValue,
|
||||||
loadOp: 'clear',
|
loadOp: 'clear',
|
||||||
storeOp: 'store',
|
storeOp: 'store',
|
||||||
},
|
},
|
||||||
|
|
@ -86,8 +91,10 @@ export const generateNoise = ({
|
||||||
|
|
||||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||||
passEncoder.setPipeline(pipeline);
|
passEncoder.setPipeline(pipeline);
|
||||||
passEncoder.setVertexBuffer(0, vertexBuffer);
|
passEncoder.draw(
|
||||||
passEncoder.draw(4, 1);
|
appConfig.pipelines.common.noiseDrawVertexCount,
|
||||||
|
appConfig.pipelines.common.noiseDrawInstanceCount
|
||||||
|
);
|
||||||
passEncoder.end();
|
passEncoder.end();
|
||||||
|
|
||||||
device.queue.submit([commandEncoder.finish()]);
|
device.queue.submit([commandEncoder.finish()]);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
|
|
||||||
import { CopyPipeline } from '../../pipelines/copy/copy-pipeline';
|
|
||||||
|
|
||||||
export class ResizableTexture {
|
export class ResizableTexture {
|
||||||
private texture: GPUTexture;
|
private texture: GPUTexture;
|
||||||
private textureView: GPUTextureView;
|
private textureView: GPUTextureView;
|
||||||
|
|
@ -9,7 +7,6 @@ export class ResizableTexture {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly device: GPUDevice,
|
private readonly device: GPUDevice,
|
||||||
private readonly copyPipeline: CopyPipeline,
|
|
||||||
size: vec2
|
size: vec2
|
||||||
) {
|
) {
|
||||||
this.size = vec2.clone(size);
|
this.size = vec2.clone(size);
|
||||||
|
|
@ -24,13 +21,27 @@ export class ResizableTexture {
|
||||||
|
|
||||||
const newTexture = this.createTexture(size);
|
const newTexture = this.createTexture(size);
|
||||||
const newTextureView = newTexture.createView();
|
const newTextureView = newTexture.createView();
|
||||||
|
const copySize = {
|
||||||
|
width: Math.min(this.size[0], size[0]),
|
||||||
|
height: Math.min(this.size[1], size[1]),
|
||||||
|
};
|
||||||
|
|
||||||
const commandEncoder = this.device.createCommandEncoder();
|
const commandEncoder = this.device.createCommandEncoder();
|
||||||
this.copyPipeline.execute(
|
const clearPass = commandEncoder.beginRenderPass({
|
||||||
commandEncoder,
|
colorAttachments: [
|
||||||
this.textureView,
|
{
|
||||||
newTextureView,
|
view: newTextureView,
|
||||||
vec2.div(vec2.create(), this.size, size)
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||||
|
loadOp: 'clear',
|
||||||
|
storeOp: 'store',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
clearPass.end();
|
||||||
|
commandEncoder.copyTextureToTexture(
|
||||||
|
{ texture: this.texture },
|
||||||
|
{ texture: newTexture },
|
||||||
|
copySize
|
||||||
);
|
);
|
||||||
this.device.queue.submit([commandEncoder.finish()]);
|
this.device.queue.submit([commandEncoder.finish()]);
|
||||||
this.texture.destroy();
|
this.texture.destroy();
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,48 @@ describe('vibe and audio config contract', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps audio timing, graph, and density settings bounded', () => {
|
||||||
|
const { delay, generativePiano, graph, piano, rhythm } = gardenAudioConfig;
|
||||||
|
|
||||||
|
expect(rhythm.bpm).toBeGreaterThan(0);
|
||||||
|
expect(rhythm.stepsPerBeat).toBeGreaterThan(0);
|
||||||
|
expect(rhythm.stepsPerBar).toBeGreaterThanOrEqual(rhythm.stepsPerBeat);
|
||||||
|
expect(rhythm.lookaheadSeconds).toBeGreaterThanOrEqual(piano.scheduleAheadSeconds);
|
||||||
|
|
||||||
|
expect(delay.feedbackMin).toBeLessThanOrEqual(delay.feedback);
|
||||||
|
expect(delay.feedback).toBeLessThanOrEqual(delay.feedbackMax);
|
||||||
|
expect(delay.feedbackHighPassHz).toBeLessThan(delay.feedbackLowPassHz);
|
||||||
|
expect(delay.returnLowPassHz).toBeGreaterThan(delay.feedbackHighPassHz);
|
||||||
|
|
||||||
|
generativePiano.stylePools.forEach((register) => {
|
||||||
|
expect(register.midiMin).toBeLessThan(register.preferredMidi);
|
||||||
|
expect(register.preferredMidi).toBeLessThan(register.midiMax);
|
||||||
|
});
|
||||||
|
generativePiano.padRegisters.forEach((register) => {
|
||||||
|
expect(register.midiMin).toBeLessThan(register.preferredMidi);
|
||||||
|
expect(register.preferredMidi).toBeLessThan(register.midiMax);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(generativePiano.brushStreamIdleIntervalBeats).toBeGreaterThanOrEqual(
|
||||||
|
generativePiano.brushStreamActiveIntervalBeats
|
||||||
|
);
|
||||||
|
expect(generativePiano.brushStreamActiveIntervalBeats).toBeGreaterThanOrEqual(
|
||||||
|
generativePiano.brushStreamIntenseIntervalBeats
|
||||||
|
);
|
||||||
|
expect(generativePiano.brushStreamIntenseIntervalBeats).toBeGreaterThanOrEqual(
|
||||||
|
generativePiano.brushStreamManicIntervalBeats
|
||||||
|
);
|
||||||
|
expect(generativePiano.maxBrushPhraseLayers).toBeLessThanOrEqual(3);
|
||||||
|
expect(generativePiano.maxBrushStreamNotesPerBar).toBeLessThanOrEqual(
|
||||||
|
rhythm.stepsPerBar
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.values(graph.pianoBusGains).forEach((gain) => {
|
||||||
|
expect(gain).toBeGreaterThan(0);
|
||||||
|
expect(gain).toBeLessThanOrEqual(1.2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to finite RGB channels for malformed hex colors', () => {
|
it('falls back to finite RGB channels for malformed hex colors', () => {
|
||||||
expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]);
|
expect(hexToRgb('not-a-color')).toEqual([0, 0, 0]);
|
||||||
expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]);
|
expect(hexToRgb('#abcdzz')).toEqual([0, 0, 0]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue