Clean up
This commit is contained in:
parent
ea0304356f
commit
7c70f15e49
65 changed files with 1127 additions and 1911 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette,
|
||||
draw persistent coloured paths, spawn agents from those strokes, erase locally,
|
||||
and export the scene as a 4K wallpaper.
|
||||
and export the scene as an internal render buffer snapshot.
|
||||
|
||||
Check out the [agent logic](./src/pipelines/agents/agent.wgsl).
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
|||
}, canvasName);
|
||||
|
||||
await page.goto('/');
|
||||
const startButton = page.getByRole('button', { name: 'Start' });
|
||||
await expect(startButton).toBeVisible();
|
||||
await expect(startButton).toBeEnabled({ timeout: 30_000 });
|
||||
await startButton.click();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@
|
|||
<p>
|
||||
Switch vibes to recolour the whole garden without clearing your drawing. Add
|
||||
or mute the generated piano, restart for a blank canvas, or export the current
|
||||
frame as a 4K image.
|
||||
frame as an internal buffer snapshot.
|
||||
</p>
|
||||
<p>
|
||||
Built with WebGPU and running locally in your browser. Source on
|
||||
|
|
@ -185,8 +185,8 @@
|
|||
</div>
|
||||
<button
|
||||
class="export-4k"
|
||||
aria-label="Download 4K upscale image"
|
||||
title="Download 4K upscale of the live simulation"
|
||||
aria-label="Download internal buffer snapshot"
|
||||
title="Download internal buffer snapshot"
|
||||
></button>
|
||||
<span class="export-status" aria-live="polite"></span>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const trackExport = ({ vibeId }: { vibeId: VibeId }) => {
|
|||
track('Export', {
|
||||
props: {
|
||||
format: 'png',
|
||||
resolution: '4k',
|
||||
resolution: 'internal-buffer',
|
||||
vibeId,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import { isIosLike } from './audio-platform';
|
||||
|
||||
interface AudioPanNode {
|
||||
input: AudioNode;
|
||||
output: AudioNode;
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
type AudioContextWithOptionalPanner = AudioContext & {
|
||||
createStereoPanner?: () => StereoPannerNode;
|
||||
};
|
||||
|
||||
export const createAudioPanNode = (
|
||||
context: AudioContext,
|
||||
pan: number,
|
||||
startTime: number
|
||||
): AudioPanNode => {
|
||||
const createStereoPanner = (context as AudioContextWithOptionalPanner)
|
||||
.createStereoPanner;
|
||||
|
||||
if (!isIosLike() && typeof createStereoPanner === 'function') {
|
||||
const panner = createStereoPanner.call(context);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), startTime);
|
||||
return {
|
||||
input: panner,
|
||||
output: panner,
|
||||
disconnect: () => panner.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
const passthrough = context.createGain();
|
||||
return {
|
||||
input: passthrough,
|
||||
output: passthrough,
|
||||
disconnect: () => passthrough.disconnect(),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const isIosLike = (): boolean =>
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor';
|
||||
|
|
@ -86,5 +85,3 @@ export interface GardenAudioConfig {
|
|||
maniaSmoothingSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||
|
|
|
|||
|
|
@ -19,20 +19,14 @@ export class GardenAudioEnergy {
|
|||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(strokeEnergy: number, now: number): void {
|
||||
const energy = clamp01(strokeEnergy);
|
||||
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
||||
public recordStroke(strokeEnergy: number): void {
|
||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(
|
||||
this.energy,
|
||||
energy * this.config.energy.immediateActivityScale
|
||||
strokeEnergy * this.config.energy.immediateActivityScale
|
||||
);
|
||||
}
|
||||
this.lastEnergyUpdateAt ||= now;
|
||||
}
|
||||
|
||||
public recordEraserStroke(): void {
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public silence(): void {
|
||||
|
|
@ -46,7 +40,7 @@ export class GardenAudioEnergy {
|
|||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||
const elapsedSeconds = now - this.lastEnergyUpdateAt;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / this.config.energy.strokeDecaySeconds
|
||||
|
|
@ -62,14 +56,6 @@ export class GardenAudioEnergy {
|
|||
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
|
||||
}
|
||||
|
||||
public getActivity(): number {
|
||||
if (!this.isGestureActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.getLevel();
|
||||
}
|
||||
|
||||
public getLevel(): number {
|
||||
return clamp01(this.energy);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,28 +8,17 @@ interface GardenAudioGestureFrame {
|
|||
}
|
||||
|
||||
export class GardenAudioGestureState {
|
||||
private gestureClockSeconds = 0;
|
||||
private activity = 0;
|
||||
private maniaAmount = 0;
|
||||
private isManic = false;
|
||||
|
||||
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||
|
||||
public beginGesture(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public recordStroke({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: GardenAudioStrokeMetrics;
|
||||
}): GardenAudioGestureFrame {
|
||||
this.gestureClockSeconds += metrics.elapsedSeconds;
|
||||
|
||||
const targetActivity = this.getTargetActivity(metrics);
|
||||
const activityTimeConstant =
|
||||
targetActivity > this.activity
|
||||
|
|
@ -65,29 +54,21 @@ export class GardenAudioGestureState {
|
|||
}
|
||||
|
||||
public reset(): void {
|
||||
this.gestureClockSeconds = 0;
|
||||
this.activity = 0;
|
||||
this.maniaAmount = 0;
|
||||
this.isManic = false;
|
||||
}
|
||||
|
||||
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
|
||||
const normalizedSpeed = Math.max(0, metrics.normalizedSpeed);
|
||||
const activeSpeed = Math.max(
|
||||
this.inputConfig.activityNoiseFloorSpeed,
|
||||
this.inputConfig.fullActivitySpeed
|
||||
);
|
||||
const speedRange =
|
||||
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
|
||||
const speedAmount = clamp01(
|
||||
(normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) /
|
||||
(activeSpeed - this.inputConfig.activityNoiseFloorSpeed)
|
||||
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
|
||||
);
|
||||
const distanceAmount = clamp01(
|
||||
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
|
||||
);
|
||||
const activity = Math.pow(
|
||||
speedAmount,
|
||||
Math.max(0.001, this.inputConfig.activityCurve)
|
||||
);
|
||||
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
|
||||
|
||||
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import { isIosLike } from './audio-platform';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type NavigatorWithAudioSession = Navigator & {
|
||||
audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
|
||||
};
|
||||
|
||||
const outputHighPassFrequencyHz = 45;
|
||||
const noiseBufferDurationSeconds = 1;
|
||||
const graphTuning = {
|
||||
closeGain: 0.0001,
|
||||
closeRampSeconds: 0.015,
|
||||
|
|
@ -13,12 +17,6 @@ const graphTuning = {
|
|||
noiseMin: -1,
|
||||
latencyHint: 'interactive' as AudioContextLatencyCategory,
|
||||
outputFilterType: 'highpass' as BiquadFilterType,
|
||||
noiseBufferChannels: 1,
|
||||
noiseBufferDurationSeconds: 1,
|
||||
unlockTickFrequencyHz: 440,
|
||||
unlockTickGain: 0.0001,
|
||||
unlockTickSeconds: 0.025,
|
||||
unlockTickType: 'sine' as OscillatorType,
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
|
|
@ -44,8 +42,10 @@ export class GardenAudioGraph {
|
|||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
|
||||
private mediaStreamElement: HTMLAudioElement | null = null;
|
||||
private lastPianoBusActivity = 0;
|
||||
private pianoBusGainScale = 1;
|
||||
private pianoBusGainScaleAutomationUntil = 0;
|
||||
private pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
|
@ -64,20 +64,20 @@ export class GardenAudioGraph {
|
|||
return null;
|
||||
}
|
||||
|
||||
let context: AudioContext;
|
||||
try {
|
||||
context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
} catch {
|
||||
context = new AudioContextConstructor();
|
||||
// Tells iOS to treat this as media playback, so the hardware ringer/mute
|
||||
// switch does not silence Web Audio output. No-op on browsers without the
|
||||
// Audio Session API.
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
audioSession.type = 'playback';
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
const mediaStreamDestination = isIosLike()
|
||||
? context.createMediaStreamDestination()
|
||||
: null;
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = graphTuning.outputFilterType;
|
||||
|
|
@ -90,15 +90,10 @@ export class GardenAudioGraph {
|
|||
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
if (mediaStreamDestination) {
|
||||
compressor.connect(mediaStreamDestination);
|
||||
} else {
|
||||
compressor.connect(context.destination);
|
||||
}
|
||||
compressor.connect(context.destination);
|
||||
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
this.mediaStreamDestination = mediaStreamDestination;
|
||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||
this.createDelay(context, masterGain);
|
||||
this.createBuses(context, masterGain);
|
||||
|
|
@ -106,35 +101,6 @@ export class GardenAudioGraph {
|
|||
return context;
|
||||
}
|
||||
|
||||
// iOS WebKit can report "running" while the hardware output is still silent.
|
||||
// A very short, nearly inaudible oscillator in the gesture stack is more
|
||||
// reliable than a fully silent buffer on recent Safari versions.
|
||||
public unlock(): void {
|
||||
if (!this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
const source = this.context.createOscillator();
|
||||
const gain = this.context.createGain();
|
||||
|
||||
source.type = graphTuning.unlockTickType;
|
||||
source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
|
||||
gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
|
||||
source.connect(gain);
|
||||
gain.connect(this.context.destination);
|
||||
source.start(now);
|
||||
source.stop(now + graphTuning.unlockTickSeconds);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
if (!this.context || !this.masterGain) {
|
||||
return;
|
||||
|
|
@ -147,38 +113,6 @@ export class GardenAudioGraph {
|
|||
);
|
||||
}
|
||||
|
||||
public startMediaElementOutput(): void {
|
||||
if (!this.mediaStreamDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaElement = this.ensureMediaStreamElement();
|
||||
const playPromise = mediaElement.play();
|
||||
void playPromise?.catch(() => undefined);
|
||||
}
|
||||
|
||||
private ensureMediaStreamElement(): HTMLAudioElement {
|
||||
if (this.mediaStreamElement) {
|
||||
return this.mediaStreamElement;
|
||||
}
|
||||
|
||||
const mediaElement = document.createElement('audio');
|
||||
mediaElement.autoplay = true;
|
||||
mediaElement.volume = 1;
|
||||
mediaElement.setAttribute('playsinline', '');
|
||||
mediaElement.setAttribute('aria-hidden', 'true');
|
||||
mediaElement.style.position = 'fixed';
|
||||
mediaElement.style.width = '1px';
|
||||
mediaElement.style.height = '1px';
|
||||
mediaElement.style.opacity = '0';
|
||||
mediaElement.style.pointerEvents = 'none';
|
||||
mediaElement.style.left = '-9999px';
|
||||
mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
|
||||
document.body.append(mediaElement);
|
||||
this.mediaStreamElement = mediaElement;
|
||||
return mediaElement;
|
||||
}
|
||||
|
||||
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
|
|
@ -228,6 +162,20 @@ export class GardenAudioGraph {
|
|||
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
|
||||
}
|
||||
|
||||
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
|
||||
if (!this.context) {
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
|
||||
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
|
||||
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
|
|
@ -304,27 +252,33 @@ export class GardenAudioGraph {
|
|||
this.noiseBus.connect(eventBus);
|
||||
}
|
||||
|
||||
private updatePianoBusGains(activity: number, now: number): void {
|
||||
private updatePianoBusGains(
|
||||
activity: number,
|
||||
now: number,
|
||||
timeConstantSeconds?: number
|
||||
): void {
|
||||
const effectiveTimeConstantSeconds =
|
||||
timeConstantSeconds ??
|
||||
(now < this.pianoBusGainScaleAutomationUntil
|
||||
? this.pianoBusGainScaleTimeConstantSeconds
|
||||
: this.config.updateRampSeconds);
|
||||
|
||||
this.lastPianoBusActivity = activity;
|
||||
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)),
|
||||
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
effectiveTimeConstantSeconds
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(
|
||||
appPositiveInteger(graphTuning.noiseBufferChannels),
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
|
||||
)
|
||||
),
|
||||
1,
|
||||
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
|
||||
context.sampleRate
|
||||
);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
|
@ -348,16 +302,10 @@ export class GardenAudioGraph {
|
|||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.mediaStreamDestination = null;
|
||||
if (this.mediaStreamElement) {
|
||||
this.mediaStreamElement.pause();
|
||||
this.mediaStreamElement.srcObject = null;
|
||||
this.mediaStreamElement.remove();
|
||||
this.mediaStreamElement = null;
|
||||
}
|
||||
this.lastPianoBusActivity = 0;
|
||||
this.pianoBusGainScale = 1;
|
||||
this.pianoBusGainScaleAutomationUntil = 0;
|
||||
this.pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
this.pianoBuses.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const appPositiveInteger = (value: number): number =>
|
||||
Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import type { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
const fallbackNormalizationPixels = 1000;
|
||||
const fallbackFrameSeconds = 1 / 60;
|
||||
const minElapsedSeconds = 0.001;
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
distancePixels: number;
|
||||
elapsedSeconds: number;
|
||||
normalizedDistance: number;
|
||||
normalizedSpeed: number;
|
||||
|
|
@ -15,42 +12,13 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
|
|||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const elapsedSeconds = getElapsedSeconds(stroke);
|
||||
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
|
||||
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
|
||||
const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1]));
|
||||
const normalizedDistance = distancePixels / normalizationPixels;
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
elapsedSeconds,
|
||||
normalizedDistance,
|
||||
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
||||
};
|
||||
};
|
||||
|
||||
const getElapsedSeconds = (stroke: GardenAudioStroke): number => {
|
||||
if (
|
||||
stroke.elapsedSeconds !== undefined &&
|
||||
Number.isFinite(stroke.elapsedSeconds) &&
|
||||
stroke.elapsedSeconds > 0
|
||||
) {
|
||||
return Math.max(minElapsedSeconds, stroke.elapsedSeconds);
|
||||
}
|
||||
|
||||
return fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): 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 fallbackNormalizationPixels;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,6 @@
|
|||
import type { VibePreset } from '../vibes';
|
||||
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import type { GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export const PITCH_SEMITONES_PER_OCTAVE = 12;
|
||||
|
||||
const chordVoicings = {
|
||||
majorOpen: [0, 7, 12, 16],
|
||||
minorOpen: [0, 7, 12, 15],
|
||||
majorClosed: [0, 4, 7, 12, 16],
|
||||
minorClosed: [0, 3, 7, 12, 15],
|
||||
};
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
): Array<number> => {
|
||||
if (openVoicing) {
|
||||
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
|
||||
}
|
||||
|
||||
return chord.quality === 'major'
|
||||
? chordVoicings.majorClosed
|
||||
: chordVoicings.minorClosed;
|
||||
};
|
||||
|
||||
export const degreeToSemitone = (
|
||||
profile: GardenAudioVibeProfile,
|
||||
degree: number
|
||||
): number => {
|
||||
const scaleIndex =
|
||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||
const octave = Math.floor(degree / profile.scale.length);
|
||||
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
|
||||
export const GENERATIVE_START_DELAY_SECONDS = 0.02;
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
|
@ -9,9 +9,9 @@ export interface GardenAudioStroke {
|
|||
vibe: VibePreset;
|
||||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
canvasSize?: ArrayLike<number>;
|
||||
canvasSize: ArrayLike<number>;
|
||||
isErasing: boolean;
|
||||
elapsedSeconds?: number;
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
|
|
@ -23,12 +23,6 @@ export interface LoadedPianoSample {
|
|||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
|
|
|
|||
|
|
@ -16,18 +16,12 @@ import { GenerativePianoEngine } from './generative-piano';
|
|||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
export type {
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
} from './garden-audio-types';
|
||||
|
||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||
|
||||
const muteGain = 0.0001;
|
||||
const muteRampSeconds = 0.02;
|
||||
const brushUpPianoFinishSeconds = 1.2;
|
||||
const brushUpPianoFadeSeconds = 1.1;
|
||||
const brushUpPianoBusFadeSeconds = 2.4;
|
||||
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
||||
const vibeChangeStingerMinIntervalSeconds = 0.45;
|
||||
|
||||
export class GardenAudio {
|
||||
|
|
@ -40,12 +34,12 @@ export class GardenAudio {
|
|||
|
||||
private currentVibeId: VibeId | null = null;
|
||||
private lifecycle: AudioLifecycle = 'idle';
|
||||
private isReleasingPiano = false;
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private isPianoStoppedAfterGesture = false;
|
||||
private fadePianoAfter: number | null = null;
|
||||
private fadePianoAt: number | null = null;
|
||||
private masterVolume: number;
|
||||
private stopPianoAfter: number | null = null;
|
||||
private stopPianoAt: number | null = null;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
|
@ -75,33 +69,16 @@ export class GardenAudio {
|
|||
? muteRampSeconds
|
||||
: this.config.fadeInSeconds;
|
||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||
let resumePromise: Promise<void> | null = null;
|
||||
|
||||
if (isUserGesture) {
|
||||
this.graph.startMediaElementOutput();
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (needsResume) {
|
||||
if (!isUserGesture) {
|
||||
return;
|
||||
}
|
||||
resumePromise = context.resume();
|
||||
}
|
||||
|
||||
if (isUserGesture) {
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (resumePromise) {
|
||||
void resumePromise
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.graph.unlock();
|
||||
this.completeStart(vibe, {
|
||||
context,
|
||||
startupRampSeconds,
|
||||
});
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -113,10 +90,7 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
this.completeStart(vibe, {
|
||||
context,
|
||||
startupRampSeconds,
|
||||
});
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
}
|
||||
|
||||
private completeStart(
|
||||
|
|
@ -207,23 +181,21 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.isPianoStoppedAfterGesture = false;
|
||||
this.fadePianoAfter = null;
|
||||
this.stopPianoAfter = null;
|
||||
this.gestureState.beginGesture();
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
||||
this.gestureState.reset();
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.gestureState.endGesture();
|
||||
this.gestureState.reset();
|
||||
this.isGestureActive = false;
|
||||
const context = this.graph.context;
|
||||
this.isPianoStoppedAfterGesture = true;
|
||||
this.fadePianoAfter = context
|
||||
? context.currentTime + brushUpPianoFinishSeconds
|
||||
: null;
|
||||
this.stopPianoAfter = null;
|
||||
this.isReleasingPiano = true;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
|
@ -241,17 +213,8 @@ export class GardenAudio {
|
|||
this.energy.silence();
|
||||
}
|
||||
|
||||
if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
|
||||
if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
|
||||
this.piano.fadeAll(brushUpPianoFadeSeconds);
|
||||
this.fadePianoAfter = null;
|
||||
this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
|
||||
}
|
||||
if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.stopPianoAfter = null;
|
||||
}
|
||||
if (!this.isGestureActive && this.isReleasingPiano) {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot);
|
||||
return;
|
||||
}
|
||||
|
|
@ -287,12 +250,11 @@ export class GardenAudio {
|
|||
const strokeEnergy = frame.activity;
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.energy.recordEraserStroke();
|
||||
this.playEraser(strokeEnergy, now);
|
||||
return;
|
||||
}
|
||||
|
||||
this.energy.recordStroke(strokeEnergy, now);
|
||||
this.energy.recordStroke(strokeEnergy);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
|
|
@ -311,9 +273,9 @@ export class GardenAudio {
|
|||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.isGestureActive = false;
|
||||
this.isPianoStoppedAfterGesture = false;
|
||||
this.fadePianoAfter = null;
|
||||
this.stopPianoAfter = null;
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
|
@ -333,6 +295,25 @@ export class GardenAudio {
|
|||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
||||
if (this.fadePianoAt === null && this.stopPianoAt === null) {
|
||||
this.fadePianoAt = this.pianoEngine.release(vibe, now);
|
||||
}
|
||||
|
||||
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
|
||||
}
|
||||
|
||||
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.stopPianoAt = null;
|
||||
this.isReleasingPiano = false;
|
||||
}
|
||||
}
|
||||
|
||||
private playEraser(activity: number, now: number): void {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ interface GenerativePianoTuning {
|
|||
pans: [number, number, number];
|
||||
delaySends: [number, number, number];
|
||||
lowpassExpression: number;
|
||||
noteDurationSeconds: number;
|
||||
spacingSeconds: number;
|
||||
};
|
||||
releaseResolution: {
|
||||
durationSeconds: number;
|
||||
fadeAfterSeconds: number;
|
||||
velocities: [number, number, number];
|
||||
delaySend: number;
|
||||
lowpassExpression: number;
|
||||
strumSeconds: number;
|
||||
};
|
||||
highActivityExtra: {
|
||||
barOffset: number;
|
||||
|
|
@ -67,7 +77,6 @@ interface GenerativePianoTuning {
|
|||
delaySend: number;
|
||||
};
|
||||
touchNote: {
|
||||
registerBiasManiaAmount: number;
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
|
|
@ -80,7 +89,6 @@ interface GenerativePianoTuning {
|
|||
initialMotifOffset: number;
|
||||
energyDecaySeconds: number;
|
||||
maniaDecaySeconds: number;
|
||||
fadeMinimumLifetimeSeconds: number;
|
||||
layerIntensityBase: number;
|
||||
layerIntensityManiaWeight: number;
|
||||
frameActivityWeight: number;
|
||||
|
|
@ -106,7 +114,6 @@ interface GenerativePianoTuning {
|
|||
lowpassBaseExpression: number;
|
||||
lowpassIntensityWeight: number;
|
||||
lowpassManiaWeight: number;
|
||||
manicThreshold: number;
|
||||
intenseThreshold: number;
|
||||
activeThreshold: number;
|
||||
};
|
||||
|
|
@ -133,8 +140,6 @@ interface GenerativePianoTuning {
|
|||
highOffset: number;
|
||||
mediumOffset: number;
|
||||
lowOffset: number;
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
};
|
||||
registerBias: {
|
||||
maniaShiftSemitones: number;
|
||||
|
|
@ -171,8 +176,6 @@ interface GenerativePianoTuning {
|
|||
gestureAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinSteps: number;
|
||||
strokeAccentThreshold: number;
|
||||
stingerDurationSeconds: number;
|
||||
stingerSpacingSeconds: number;
|
||||
maxBrushPhraseLayers: number;
|
||||
maxBrushStreamNotesPerBar: number;
|
||||
brushLayerBaseSeconds: number;
|
||||
|
|
@ -181,7 +184,6 @@ interface GenerativePianoTuning {
|
|||
brushStreamIdleIntervalBeats: number;
|
||||
brushStreamActiveIntervalBeats: number;
|
||||
brushStreamIntenseIntervalBeats: number;
|
||||
brushStreamManicIntervalBeats: number;
|
||||
brushMotifMaxSteps: number;
|
||||
brushMotifCanonDelaySeconds: number;
|
||||
padDurationBarScale: number;
|
||||
|
|
@ -236,6 +238,16 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
pans: [-0.16, 0, 0.16],
|
||||
delaySends: [0.012, 0.014, 0.016],
|
||||
lowpassExpression: 0.35,
|
||||
noteDurationSeconds: 1.1,
|
||||
spacingSeconds: 0.08,
|
||||
},
|
||||
releaseResolution: {
|
||||
durationSeconds: 3.4,
|
||||
fadeAfterSeconds: 2.4,
|
||||
velocities: [0.064, 0.05, 0.038],
|
||||
delaySend: 0.018,
|
||||
lowpassExpression: 0.34,
|
||||
strumSeconds: 0.055,
|
||||
},
|
||||
highActivityExtra: {
|
||||
barOffset: 1,
|
||||
|
|
@ -284,7 +296,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
delaySend: 0.012,
|
||||
},
|
||||
touchNote: {
|
||||
registerBiasManiaAmount: 0,
|
||||
velocityBase: 0.14,
|
||||
velocityStrengthWeight: 0.11,
|
||||
durationBaseSeconds: 0.55,
|
||||
|
|
@ -297,7 +308,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
initialMotifOffset: -1,
|
||||
energyDecaySeconds: 0.72,
|
||||
maniaDecaySeconds: 0.54,
|
||||
fadeMinimumLifetimeSeconds: 0.001,
|
||||
layerIntensityBase: 0.8,
|
||||
layerIntensityManiaWeight: 0.42,
|
||||
frameActivityWeight: 0.42,
|
||||
|
|
@ -323,7 +333,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
lowpassBaseExpression: 0.39,
|
||||
lowpassIntensityWeight: 0.48,
|
||||
lowpassManiaWeight: 0.18,
|
||||
manicThreshold: 0.85,
|
||||
intenseThreshold: 0.62,
|
||||
activeThreshold: 0.34,
|
||||
},
|
||||
|
|
@ -350,8 +359,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
highOffset: 1,
|
||||
mediumOffset: 0,
|
||||
lowOffset: -1,
|
||||
minOffset: -3,
|
||||
maxOffset: 4,
|
||||
},
|
||||
registerBias: {
|
||||
maniaShiftSemitones: 4,
|
||||
|
|
@ -388,8 +395,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
gestureAccentMinIntervalSeconds: 2.5,
|
||||
strokeAccentMinSteps: 12,
|
||||
strokeAccentThreshold: 0.58,
|
||||
stingerSpacingSeconds: 0.08,
|
||||
stingerDurationSeconds: 1.1,
|
||||
maxBrushPhraseLayers: 3,
|
||||
maxBrushStreamNotesPerBar: 9,
|
||||
brushLayerBaseSeconds: 5.5,
|
||||
|
|
@ -398,7 +403,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
brushStreamIdleIntervalBeats: 2,
|
||||
brushStreamActiveIntervalBeats: 1,
|
||||
brushStreamIntenseIntervalBeats: 0.5,
|
||||
brushStreamManicIntervalBeats: 0.5,
|
||||
brushMotifMaxSteps: 8,
|
||||
brushMotifCanonDelaySeconds: 0.055,
|
||||
padDurationBarScale: 0.46,
|
||||
|
|
|
|||
|
|
@ -5,17 +5,7 @@ import type {
|
|||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import {
|
||||
degreeToSemitone,
|
||||
getChordIntervals,
|
||||
getVibeProfile,
|
||||
PITCH_SEMITONES_PER_OCTAVE,
|
||||
} from './garden-audio-music';
|
||||
import {
|
||||
GENERATIVE_LOOKAHEAD_SECONDS,
|
||||
GENERATIVE_START_DELAY_SECONDS,
|
||||
PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
} from './garden-audio-scheduling';
|
||||
import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import type { PianoNote } from './garden-audio-types';
|
||||
import {
|
||||
generativePianoTuning,
|
||||
|
|
@ -23,6 +13,39 @@ import {
|
|||
type GardenAudioRegister,
|
||||
type GardenAudioStylePool,
|
||||
} from './generative-piano-tuning';
|
||||
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
|
||||
|
||||
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
|
||||
const GENERATIVE_START_DELAY_SECONDS = 0.02;
|
||||
|
||||
const chordVoicings = {
|
||||
majorOpen: [0, 7, 12, 16],
|
||||
minorOpen: [0, 7, 12, 15],
|
||||
majorClosed: [0, 4, 7, 12, 16],
|
||||
minorClosed: [0, 3, 7, 12, 15],
|
||||
};
|
||||
|
||||
const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
): Array<number> => {
|
||||
if (openVoicing) {
|
||||
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
|
||||
}
|
||||
return chord.quality === 'major'
|
||||
? chordVoicings.majorClosed
|
||||
: chordVoicings.minorClosed;
|
||||
};
|
||||
|
||||
const degreeToSemitone = (
|
||||
profile: GardenAudioVibeProfile,
|
||||
degree: number
|
||||
): number => {
|
||||
const scaleIndex =
|
||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||
const octave = Math.floor(degree / profile.scale.length);
|
||||
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
|
||||
};
|
||||
|
||||
type GardenAudioStyleIndex = 0 | 1 | 2;
|
||||
|
||||
|
|
@ -124,6 +147,23 @@ export class GenerativePianoEngine {
|
|||
this.isWaitingForGestureAccent = false;
|
||||
}
|
||||
|
||||
public release(vibe: VibePreset, now: number): number {
|
||||
this.prime(now);
|
||||
this.isWaitingForGestureAccent = false;
|
||||
|
||||
const profile = getVibeProfile(vibe);
|
||||
const releaseStep = this.getReleaseResolutionStep(now);
|
||||
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
|
||||
|
||||
this.playReleaseResolution(profile, releaseStep, releaseStart);
|
||||
this.nextBeatStep = null;
|
||||
this.nextBrushStreamStep = null;
|
||||
this.brushPhraseLayers = [];
|
||||
this.brushStreamNoteCountsByBar.clear();
|
||||
|
||||
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
|
||||
}
|
||||
|
||||
private recordTouchDown({
|
||||
vibe,
|
||||
now,
|
||||
|
|
@ -234,47 +274,27 @@ export class GenerativePianoEngine {
|
|||
const chord = this.getChord(profile, 0);
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const notes = [
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [0] },
|
||||
this.generation.padRegisters[0]
|
||||
),
|
||||
velocity: this.generation.vibeChangeStinger.velocities[0],
|
||||
pan: this.generation.vibeChangeStinger.pans[0],
|
||||
delaySend: this.generation.vibeChangeStinger.delaySends[0],
|
||||
},
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
|
||||
this.generation.padRegisters[1]
|
||||
),
|
||||
velocity: this.generation.vibeChangeStinger.velocities[1],
|
||||
pan: this.generation.vibeChangeStinger.pans[1],
|
||||
delaySend: this.generation.vibeChangeStinger.delaySends[1],
|
||||
},
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||
this.generation.padRegisters[2]
|
||||
),
|
||||
velocity: this.generation.vibeChangeStinger.velocities[2],
|
||||
pan: this.generation.vibeChangeStinger.pans[2],
|
||||
delaySend: this.generation.vibeChangeStinger.delaySends[2],
|
||||
},
|
||||
const stinger = this.generation.vibeChangeStinger;
|
||||
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
||||
[0],
|
||||
[intervals[1], intervals[2]],
|
||||
[intervals[3], intervals[2]],
|
||||
];
|
||||
|
||||
notes.forEach((note, index) => {
|
||||
offsetsByVoice.forEach((offsets, index) => {
|
||||
const midi = this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets },
|
||||
this.generation.padRegisters[index]
|
||||
);
|
||||
this.playNote({
|
||||
...note,
|
||||
durationSeconds: this.generation.stingerDurationSeconds,
|
||||
midi,
|
||||
velocity: stinger.velocities[index],
|
||||
pan: stinger.pans[index],
|
||||
delaySend: stinger.delaySends[index],
|
||||
durationSeconds: stinger.noteDurationSeconds,
|
||||
role: 'stinger',
|
||||
lowpassHz: this.getLowpassHz(
|
||||
profile,
|
||||
note.midi,
|
||||
this.generation.vibeChangeStinger.lowpassExpression
|
||||
),
|
||||
startTime: now + index * this.generation.stingerSpacingSeconds,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression),
|
||||
startTime: now + index * stinger.spacingSeconds,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -285,12 +305,8 @@ export class GenerativePianoEngine {
|
|||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
|
||||
this.lastMidiByStyle[0] = null;
|
||||
this.lastMidiByStyle[1] = null;
|
||||
this.lastMidiByStyle[2] = null;
|
||||
this.lastPadMidiByVoice[0] = null;
|
||||
this.lastPadMidiByVoice[1] = null;
|
||||
this.lastPadMidiByVoice[2] = null;
|
||||
this.lastMidiByStyle.fill(null);
|
||||
this.lastPadMidiByVoice.fill(null);
|
||||
this.brushPhraseLayers = [];
|
||||
this.nextBrushStreamStep = null;
|
||||
this.brushStreamNoteIndex = 0;
|
||||
|
|
@ -400,6 +416,37 @@ export class GenerativePianoEngine {
|
|||
});
|
||||
}
|
||||
|
||||
private playReleaseResolution(
|
||||
profile: GardenAudioVibeProfile,
|
||||
stepIndex: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const release = this.generation.releaseResolution;
|
||||
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
||||
[0],
|
||||
[intervals[1], intervals[2]],
|
||||
[intervals[3], intervals[2]],
|
||||
];
|
||||
|
||||
offsetsByVoice.forEach((offsets, index) => {
|
||||
const register = this.generation.padRegisters[index];
|
||||
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity: release.velocities[index],
|
||||
startTime: startTime + index * release.strumSeconds,
|
||||
durationSeconds: release.durationSeconds,
|
||||
pan: register.pan,
|
||||
role: 'pad',
|
||||
delaySend: release.delaySend,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private playSupportNote(
|
||||
profile: GardenAudioVibeProfile,
|
||||
barIndex: number,
|
||||
|
|
@ -546,10 +593,6 @@ export class GenerativePianoEngine {
|
|||
}): void {
|
||||
const profile = getVibeProfile(vibe);
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const register = this.getBiasedRegister(
|
||||
pool,
|
||||
this.generation.touchNote.registerBiasManiaAmount
|
||||
);
|
||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
|
|
@ -558,7 +601,7 @@ export class GenerativePianoEngine {
|
|||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
|
||||
},
|
||||
register,
|
||||
pool,
|
||||
this.lastMidiByStyle[styleIndex],
|
||||
true
|
||||
);
|
||||
|
|
@ -877,41 +920,26 @@ export class GenerativePianoEngine {
|
|||
|
||||
private getBrushStreamIntervalSteps(intensity: number): number {
|
||||
const intervalBeats =
|
||||
intensity >= this.generation.brushStream.manicThreshold
|
||||
? this.generation.brushStreamManicIntervalBeats
|
||||
: intensity >= this.generation.brushStream.intenseThreshold
|
||||
? this.generation.brushStreamIntenseIntervalBeats
|
||||
: intensity >= this.generation.brushStream.activeThreshold
|
||||
? this.generation.brushStreamActiveIntervalBeats
|
||||
: this.generation.brushStreamIdleIntervalBeats;
|
||||
intensity >= this.generation.brushStream.intenseThreshold
|
||||
? this.generation.brushStreamIntenseIntervalBeats
|
||||
: intensity >= this.generation.brushStream.activeThreshold
|
||||
? this.generation.brushStreamActiveIntervalBeats
|
||||
: this.generation.brushStreamIdleIntervalBeats;
|
||||
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
|
||||
}
|
||||
|
||||
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
|
||||
const lifetimeSeconds = layer.expiresAt - layer.startedAt;
|
||||
const lifetimeSeconds = Math.max(0.001, layer.expiresAt - layer.startedAt);
|
||||
const ageSeconds = startTime - layer.startedAt;
|
||||
return clamp01(
|
||||
1 -
|
||||
ageSeconds /
|
||||
Math.max(
|
||||
this.generation.brushPhrase.fadeMinimumLifetimeSeconds,
|
||||
lifetimeSeconds
|
||||
)
|
||||
);
|
||||
return clamp01(1 - ageSeconds / lifetimeSeconds);
|
||||
}
|
||||
|
||||
private getMotifOffset(strength: number): number {
|
||||
const energyStep =
|
||||
strength >= this.generation.brushMotif.highThreshold
|
||||
? this.generation.brushMotif.highOffset
|
||||
: strength >= this.generation.brushMotif.mediumThreshold
|
||||
? this.generation.brushMotif.mediumOffset
|
||||
: this.generation.brushMotif.lowOffset;
|
||||
return clamp(
|
||||
energyStep,
|
||||
this.generation.brushMotif.minOffset,
|
||||
this.generation.brushMotif.maxOffset
|
||||
);
|
||||
return strength >= this.generation.brushMotif.highThreshold
|
||||
? this.generation.brushMotif.highOffset
|
||||
: strength >= this.generation.brushMotif.mediumThreshold
|
||||
? this.generation.brushMotif.mediumOffset
|
||||
: this.generation.brushMotif.lowOffset;
|
||||
}
|
||||
|
||||
private getBrushMotifDegrees({
|
||||
|
|
@ -1185,6 +1213,19 @@ export class GenerativePianoEngine {
|
|||
);
|
||||
}
|
||||
|
||||
private getReleaseResolutionStep(startTime: number): number {
|
||||
const currentStep = this.getStepIndexAtTime(startTime);
|
||||
const nextBeatStep =
|
||||
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBeat) *
|
||||
this.config.rhythm.stepsPerBeat;
|
||||
const nextBarStep =
|
||||
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBar) *
|
||||
this.config.rhythm.stepsPerBar;
|
||||
const barIsClose = nextBarStep - currentStep <= this.config.rhythm.stepsPerBeat * 2;
|
||||
|
||||
return barIsClose ? nextBarStep : nextBeatStep;
|
||||
}
|
||||
|
||||
private getNextBarTimeAt(startTime: number): number {
|
||||
const nextBarStep =
|
||||
Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) *
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createAudioPanNode } from './audio-pan-node';
|
||||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import type { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
|
|
@ -15,9 +15,8 @@ export class NoiseBurstPlayer {
|
|||
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
|
||||
const outputBus = noiseBus ?? eventBus;
|
||||
if (!context || !outputBus || !noiseBuffer) {
|
||||
const { context, noiseBus, noiseBuffer } = this.graph;
|
||||
if (!context || !noiseBus || !noiseBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +27,7 @@ export class NoiseBurstPlayer {
|
|||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const envelope = context.createGain();
|
||||
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||
const panner = context.createStereoPanner();
|
||||
const stopAt = scheduledStart + durationSeconds;
|
||||
|
||||
source.buffer = noiseBuffer;
|
||||
|
|
@ -41,10 +40,11 @@ export class NoiseBurstPlayer {
|
|||
scheduledStart + noiseBurstTuning.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
source.connect(filter);
|
||||
filter.connect(envelope);
|
||||
envelope.connect(panNode.input);
|
||||
panNode.output.connect(outputBus);
|
||||
envelope.connect(panner);
|
||||
panner.connect(noiseBus);
|
||||
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
|
|
@ -53,7 +53,7 @@ export class NoiseBurstPlayer {
|
|||
source.disconnect();
|
||||
filter.disconnect();
|
||||
envelope.disconnect();
|
||||
panNode.disconnect();
|
||||
panner.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { clamp, clamp01 } from '../utils/math';
|
||||
import { createAudioPanNode } from './audio-pan-node';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
|
||||
import type {
|
||||
ActivePianoVoice,
|
||||
LoadedPianoSample,
|
||||
PianoNote,
|
||||
} from './garden-audio-types';
|
||||
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
||||
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
||||
|
||||
interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
const pianoSamplerTuning = {
|
||||
filterType: 'lowpass' as BiquadFilterType,
|
||||
filterQ: 0.7,
|
||||
|
|
@ -29,7 +31,6 @@ const pianoSamplerTuning = {
|
|||
|
||||
export class PianoSampler {
|
||||
private loadState: PianoLoadState = 'idle';
|
||||
private sampleLoadPromise: Promise<void> | null = null;
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
||||
|
|
@ -38,9 +39,9 @@ export class PianoSampler {
|
|||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
if (this.loadState === 'loaded') {
|
||||
return Promise.resolve();
|
||||
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
|
||||
if (this.loadState !== 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
|
|
@ -50,33 +51,16 @@ export class PianoSampler {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.sampleLoadPromise) {
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
this.loadState = 'loading';
|
||||
this.sampleLoadPromise = loadPianoSamples(context, undefined, {
|
||||
forceReload: true,
|
||||
})
|
||||
return loadPianoSamples(context)
|
||||
.then((samples) => {
|
||||
this.setSamples(samples);
|
||||
this.loadState = 'loaded';
|
||||
})
|
||||
.catch((error) => {
|
||||
this.loadState = 'idle';
|
||||
this.sampleLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
|
||||
if (this.loadState !== 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.load(context);
|
||||
}
|
||||
|
||||
public play({
|
||||
|
|
@ -89,115 +73,114 @@ export class PianoSampler {
|
|||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
const { context, delayInput } = this.graph;
|
||||
const { context } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
this.playSynthFallback({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
role,
|
||||
delaySend,
|
||||
lowpassHz,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const sample = this.findNearestSample(midi);
|
||||
|
||||
if (sample) {
|
||||
const noteGainValue = Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart +
|
||||
Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const noteGainValue = Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||
const stopAt = releaseAt + releaseSeconds;
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||
let sendGain: GainNode | null = null;
|
||||
const releaseAt =
|
||||
scheduledStart +
|
||||
clamp(
|
||||
durationSeconds + this.config.piano.sustainSeconds * 0.5,
|
||||
pianoSamplerTuning.minDurationSeconds,
|
||||
pianoSamplerTuning.synthMaxDurationSeconds
|
||||
);
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createOscillator();
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
if (oldest) {
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
}
|
||||
source.type = pianoSamplerTuning.synthOscillatorType;
|
||||
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds);
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panNode.input);
|
||||
panNode.output.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panNode.output.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panNode.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public stopAll(): void {
|
||||
|
|
@ -215,114 +198,64 @@ export class PianoSampler {
|
|||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
public fadeAll(fadeSeconds: number): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
this.activeVoices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
|
||||
|
||||
this.trimActiveVoices(now);
|
||||
this.activeVoices.forEach((voice) => {
|
||||
this.fadeVoice(voice, now, fadeDurationSeconds);
|
||||
});
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.loadState = 'idle';
|
||||
this.sampleLoadPromise = null;
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.samples.reduce((nearest, sample) =>
|
||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||
);
|
||||
}
|
||||
|
||||
private playSynthFallback({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
private scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
role,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope,
|
||||
}: {
|
||||
source: AudioScheduledSourceNode;
|
||||
scheduledStart: number;
|
||||
stopAt: number;
|
||||
pan: number;
|
||||
lowpassHz: number;
|
||||
delaySend: number;
|
||||
eventBus: GainNode;
|
||||
configureGainEnvelope: (gain: GainNode) => void;
|
||||
}): void {
|
||||
const { context, delayInput } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
if (!context || !eventBus) {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
|
||||
);
|
||||
const releaseAt =
|
||||
scheduledStart +
|
||||
clamp(
|
||||
durationSeconds + this.config.piano.sustainSeconds * 0.5,
|
||||
pianoSamplerTuning.minDurationSeconds,
|
||||
pianoSamplerTuning.synthMaxDurationSeconds
|
||||
);
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createOscillator();
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||
const panner = context.createStereoPanner();
|
||||
let sendGain: GainNode | null = null;
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
if (oldest) {
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
|
||||
}
|
||||
|
||||
source.type = pianoSamplerTuning.synthOscillatorType;
|
||||
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
configureGainEnvelope(gain);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panNode.input);
|
||||
panNode.output.connect(eventBus);
|
||||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panNode.output.connect(sendGain);
|
||||
panner.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +269,7 @@ export class PianoSampler {
|
|||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panNode.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
},
|
||||
|
|
@ -344,6 +277,16 @@ export class PianoSampler {
|
|||
);
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.samples.reduce((nearest, sample) =>
|
||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||
);
|
||||
}
|
||||
|
||||
private trimActiveVoices(now: number): void {
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||
}
|
||||
|
|
@ -358,32 +301,7 @@ export class PianoSampler {
|
|||
pianoSamplerTuning.voiceStealFadeSeconds
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
try {
|
||||
voice.source.stop(stopAt);
|
||||
} catch {
|
||||
// The voice may already have ended; either way it is no longer part of the mix.
|
||||
}
|
||||
}
|
||||
|
||||
private fadeVoice(
|
||||
voice: ActivePianoVoice,
|
||||
now: number,
|
||||
fadeDurationSeconds: number
|
||||
): void {
|
||||
const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
|
||||
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
now,
|
||||
Math.max(0.001, fadeDurationSeconds / 4)
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
try {
|
||||
voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
} catch {
|
||||
// The voice may already have ended; either way it is fading out of the mix.
|
||||
}
|
||||
voice.source.stop(stopAt);
|
||||
}
|
||||
|
||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ interface PianoSampleDefinition {
|
|||
export interface PianoSampleLoadProgress {
|
||||
loadedCount: number;
|
||||
totalCount: number;
|
||||
sample?: PianoSampleDefinition;
|
||||
}
|
||||
|
||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||
|
|
@ -45,11 +44,6 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
|
|||
];
|
||||
|
||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
|
||||
const preloadDecode = {
|
||||
channels: 2,
|
||||
frames: 128,
|
||||
sampleRateHz: 48_000,
|
||||
};
|
||||
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||
.map(([fileName, midi]) => ({
|
||||
|
|
@ -61,10 +55,6 @@ const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
|||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
|
||||
interface PianoSampleLoadOptions {
|
||||
forceReload?: boolean;
|
||||
}
|
||||
|
||||
const sampleLoadTuning = {
|
||||
concurrency: 4,
|
||||
sampleTimeoutMs: 15_000,
|
||||
|
|
@ -81,18 +71,14 @@ export const preloadPianoSamples = (
|
|||
);
|
||||
}
|
||||
|
||||
const decodeContext = new OfflineAudioContextConstructor(
|
||||
preloadDecode.channels,
|
||||
preloadDecode.frames,
|
||||
preloadDecode.sampleRateHz
|
||||
);
|
||||
// Decoding ignores these, but the constructor demands real numbers.
|
||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
|
||||
return loadPianoSamples(decodeContext, onProgress);
|
||||
};
|
||||
|
||||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void,
|
||||
options: PianoSampleLoadOptions = {}
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
if (loadedPianoSamples) {
|
||||
onProgress?.({
|
||||
|
|
@ -102,7 +88,7 @@ export const loadPianoSamples = (
|
|||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
|
||||
if (pianoSampleLoadPromise && options.forceReload !== true) {
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise;
|
||||
}
|
||||
|
||||
|
|
@ -115,12 +101,12 @@ export const loadPianoSamples = (
|
|||
async (sample) => {
|
||||
try {
|
||||
return await withTimeout(
|
||||
loadPianoSample(decodeContext, sample),
|
||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
} finally {
|
||||
loadedCount += 1;
|
||||
onProgress?.({ loadedCount, totalCount, sample });
|
||||
onProgress?.({ loadedCount, totalCount });
|
||||
}
|
||||
}
|
||||
).then(
|
||||
|
|
@ -147,15 +133,16 @@ export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
|||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition
|
||||
sample: PianoSampleDefinition,
|
||||
signal: AbortSignal
|
||||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url);
|
||||
const response = await fetch(sample.url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.url}`);
|
||||
}
|
||||
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await decodeAudioData(decodeContext, audioData);
|
||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
};
|
||||
|
||||
|
|
@ -178,13 +165,18 @@ const loadPianoSampleBatch = async (
|
|||
return results;
|
||||
};
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
|
||||
const withTimeout = <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number
|
||||
): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Timed out while loading a piano sample.'));
|
||||
}, timeoutMs);
|
||||
|
||||
promise.then(
|
||||
operation(controller.signal).then(
|
||||
(value) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
|
|
@ -195,12 +187,3 @@ const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
const decodeAudioData = (
|
||||
decodeContext: BaseAudioContext,
|
||||
audioData: ArrayBuffer
|
||||
): Promise<AudioBuffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
|
||||
decodePromise?.then(resolve, reject);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -102,19 +102,13 @@ export const appConfig = {
|
|||
maxDeltaTimeSeconds: 1 / 30,
|
||||
minDeltaTimeSeconds: 1 / 240,
|
||||
},
|
||||
export4k: {
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: 4,
|
||||
filenameExtension: 'png',
|
||||
filenamePrefix: 'fleeting-garden',
|
||||
filenameSuffix: '-upscale',
|
||||
height: 2160,
|
||||
jsHeapSafetyMultiplier: 1.5,
|
||||
jsHeapTextureBytesMultiplier: 4,
|
||||
lowMemoryDeviceGiB: 2,
|
||||
lowMemoryExportFraction: 0.08,
|
||||
filenameSuffix: '-snapshot',
|
||||
mimeType: 'image/png',
|
||||
rowAlignmentBytes: 256,
|
||||
width: 3840,
|
||||
},
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: 96,
|
||||
|
|
@ -148,17 +142,6 @@ export const appConfig = {
|
|||
controls: runtimeControls,
|
||||
},
|
||||
simulation: {
|
||||
budget: {
|
||||
adaptiveCapDecreaseAgentsPerSecond: 200_000,
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 50_000,
|
||||
adaptiveRefreshTargetFps: 60,
|
||||
frameGapResetSeconds: 1,
|
||||
fpsHeadroom: 0.95,
|
||||
fpsSmoothingNew: 0.06,
|
||||
fpsSmoothingRetain: 0.94,
|
||||
initialFps: 60,
|
||||
},
|
||||
brushEffectFramesPerSecond: 60,
|
||||
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||
initialAgentCount: 180_000,
|
||||
|
|
|
|||
|
|
@ -6,9 +6,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
...colorInteractionSettings,
|
||||
|
||||
turnWhenLost: 0.8,
|
||||
sourceAttractionWeight: 24,
|
||||
sourceSlowMoveRate: 0.08,
|
||||
sourceTrailWeightMultiplier: 16,
|
||||
forwardRotationScale: 0.25,
|
||||
introNearDistanceMin: 28,
|
||||
introNearDistanceInner: 4,
|
||||
|
|
@ -38,8 +35,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
|
||||
brushSizeVariation: 0.5,
|
||||
brushAlpha: 1,
|
||||
brushFeatherRatio: 0.22,
|
||||
brushMinimumFeather: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushCoarseNoiseScale: 160,
|
||||
brushGrainNoiseScale: 22,
|
||||
|
|
@ -55,8 +50,9 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
eraserLineDistanceEpsilon: 0.0001,
|
||||
eraserMaskAlphaThreshold: 0.5,
|
||||
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 50_000,
|
||||
internalRenderAreaMegapixels: 8.3,
|
||||
strokeSpawnSpreadBrushSizeMultiplier: 1,
|
||||
maxAgentCount: 700_000,
|
||||
|
||||
renderTraceNormalizationFloor: 1,
|
||||
|
|
|
|||
|
|
@ -35,18 +35,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
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,
|
||||
|
|
@ -255,6 +243,22 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
adaptiveCapInitial: {
|
||||
folder: 'Agent',
|
||||
integer: true,
|
||||
label: 'adaptive cap initial',
|
||||
min: 50_000,
|
||||
max: 2_000_000,
|
||||
step: 10_000,
|
||||
},
|
||||
adaptiveCapMin: {
|
||||
folder: 'Agent',
|
||||
integer: true,
|
||||
label: 'adaptive cap min',
|
||||
min: 0,
|
||||
max: 500_000,
|
||||
step: 10_000,
|
||||
},
|
||||
maxAgentCount: {
|
||||
folder: 'Agent',
|
||||
integer: true,
|
||||
|
|
@ -299,12 +303,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
strokeSpawnSpreadBrushSizeMultiplier: {
|
||||
folder: 'Agent',
|
||||
min: 0,
|
||||
max: 4,
|
||||
step: 0.01,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Agent',
|
||||
min: 1,
|
||||
|
|
@ -317,24 +315,6 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
max: 1,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { AgentSettings } from '../pipelines/agents/agent-settings';
|
|||
import type { BrushSettings } from '../pipelines/brush/brush-settings';
|
||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-settings';
|
||||
import type { RenderSettings } from '../pipelines/render/render-settings';
|
||||
import type { RgbColor } from '../utils/rgb-color';
|
||||
|
||||
export interface NumberControlConfig {
|
||||
folder: string;
|
||||
|
|
@ -18,6 +19,8 @@ export interface NumberControlConfig {
|
|||
}
|
||||
|
||||
export type GardenRuntimeSettings = {
|
||||
adaptiveCapInitial: number;
|
||||
adaptiveCapMin: number;
|
||||
brushCurveResolution: number;
|
||||
brushCurveMinBrushRadius: number;
|
||||
brushCurveMinSegmentSpacing: number;
|
||||
|
|
@ -37,7 +40,6 @@ export type GardenRuntimeSettings = {
|
|||
maxAgentCount: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
strokeSpawnSpreadBrushSizeMultiplier: number;
|
||||
} & AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
|
|
@ -78,8 +80,8 @@ export enum VibeId {
|
|||
export interface VibePreset {
|
||||
id: VibeId;
|
||||
name: string;
|
||||
colors: [string, string, string];
|
||||
backgroundColor: string;
|
||||
colors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeProfile;
|
||||
}
|
||||
|
|
@ -90,19 +92,13 @@ export interface GardenAppConfig {
|
|||
maxDeltaTimeSeconds: number;
|
||||
minDeltaTimeSeconds: number;
|
||||
};
|
||||
export4k: {
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: number;
|
||||
filenameExtension: string;
|
||||
filenamePrefix: string;
|
||||
filenameSuffix: string;
|
||||
height: number;
|
||||
jsHeapSafetyMultiplier: number;
|
||||
jsHeapTextureBytesMultiplier: number;
|
||||
lowMemoryDeviceGiB: number;
|
||||
lowMemoryExportFraction: number;
|
||||
mimeType: string;
|
||||
rowAlignmentBytes: number;
|
||||
width: number;
|
||||
};
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: number;
|
||||
|
|
@ -136,17 +132,6 @@ export interface GardenAppConfig {
|
|||
controls: RuntimeSettingControlConfig;
|
||||
};
|
||||
simulation: {
|
||||
budget: {
|
||||
adaptiveCapDecreaseAgentsPerSecond: number;
|
||||
adaptiveCapInitial: number;
|
||||
adaptiveCapMin: number;
|
||||
adaptiveRefreshTargetFps: number;
|
||||
frameGapResetSeconds: number;
|
||||
fpsHeadroom: number;
|
||||
fpsSmoothingNew: number;
|
||||
fpsSmoothingRetain: number;
|
||||
initialFps: number;
|
||||
};
|
||||
brushEffectFramesPerSecond: number;
|
||||
clearColor: GPUColor;
|
||||
initialAgentCount: number;
|
||||
|
|
|
|||
|
|
@ -27,8 +27,12 @@ export const vibePresets: Array<VibePreset> = [
|
|||
{
|
||||
id: VibeId.CandyRain,
|
||||
name: 'Candy Rain',
|
||||
colors: ['#ff5da2', '#36d7d0', '#ffd84d'],
|
||||
backgroundColor: '#10151f',
|
||||
colors: [
|
||||
[255, 93, 162],
|
||||
[54, 215, 208],
|
||||
[255, 216, 77],
|
||||
],
|
||||
backgroundColor: [16, 21, 31],
|
||||
settings: {
|
||||
brushSize: 14,
|
||||
clarity: 0.62,
|
||||
|
|
@ -52,8 +56,12 @@ export const vibePresets: Array<VibePreset> = [
|
|||
{
|
||||
id: VibeId.SunlitMoss,
|
||||
name: 'Sunlit Moss',
|
||||
colors: ['#83d483', '#f6d76b', '#5ec1a1'],
|
||||
backgroundColor: '#172016',
|
||||
colors: [
|
||||
[131, 212, 131],
|
||||
[246, 215, 107],
|
||||
[94, 193, 161],
|
||||
],
|
||||
backgroundColor: [23, 32, 22],
|
||||
settings: {
|
||||
brushSize: 16,
|
||||
clarity: 0.68,
|
||||
|
|
@ -82,8 +90,12 @@ export const vibePresets: Array<VibePreset> = [
|
|||
{
|
||||
id: VibeId.CoralTide,
|
||||
name: 'Coral Tide',
|
||||
colors: ['#ff7f6e', '#40b8ff', '#f4f0a6'],
|
||||
backgroundColor: '#0f1822',
|
||||
colors: [
|
||||
[255, 127, 110],
|
||||
[64, 184, 255],
|
||||
[244, 240, 166],
|
||||
],
|
||||
backgroundColor: [15, 24, 34],
|
||||
settings: {
|
||||
brushSize: 13,
|
||||
clarity: 0.58,
|
||||
|
|
@ -107,8 +119,12 @@ export const vibePresets: Array<VibePreset> = [
|
|||
{
|
||||
id: VibeId.MoonOrchid,
|
||||
name: 'Moon Orchid',
|
||||
colors: ['#c993ff', '#7dd8ff', '#f0f4ff'],
|
||||
backgroundColor: '#14121d',
|
||||
colors: [
|
||||
[201, 147, 255],
|
||||
[125, 216, 255],
|
||||
[240, 244, 255],
|
||||
],
|
||||
backgroundColor: [20, 18, 29],
|
||||
settings: {
|
||||
brushSize: 12,
|
||||
clarity: 0.64,
|
||||
|
|
@ -132,8 +148,12 @@ export const vibePresets: Array<VibePreset> = [
|
|||
{
|
||||
id: VibeId.PeachNeon,
|
||||
name: 'Peach Neon',
|
||||
colors: ['#ff9b73', '#5bf0a9', '#6ea8ff'],
|
||||
backgroundColor: '#191716',
|
||||
colors: [
|
||||
[255, 155, 115],
|
||||
[91, 240, 169],
|
||||
[110, 168, 255],
|
||||
],
|
||||
backgroundColor: [25, 23, 22],
|
||||
settings: {
|
||||
brushSize: 15,
|
||||
clarity: 0.55,
|
||||
|
|
@ -157,8 +177,12 @@ export const vibePresets: Array<VibePreset> = [
|
|||
{
|
||||
id: VibeId.FrostBloom,
|
||||
name: 'Frost Bloom',
|
||||
colors: ['#b4f7ff', '#9ec8ff', '#ffb8d2'],
|
||||
backgroundColor: '#101820',
|
||||
colors: [
|
||||
[180, 247, 255],
|
||||
[158, 200, 255],
|
||||
[255, 184, 210],
|
||||
],
|
||||
backgroundColor: [16, 24, 32],
|
||||
settings: {
|
||||
brushSize: 18,
|
||||
clarity: 0.7,
|
||||
|
|
|
|||
|
|
@ -7,16 +7,20 @@ import {
|
|||
} from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import type { FramePerformance } from './frame-performance';
|
||||
import { createIntroTitleAgents } from './intro-title-agents';
|
||||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
// Current performance-aware limit; new agents above it replace old agents.
|
||||
private adaptiveCap: number;
|
||||
// Next active agent slot to overwrite when new agents exceed the current cap.
|
||||
private replacementCursor = 0;
|
||||
private canExpandAdaptiveCap = true;
|
||||
private shouldCompactAfterErase = false;
|
||||
private isCompacting = false;
|
||||
private pendingCompaction: Promise<void> | null = null;
|
||||
// Highest active slot written while async compaction is running.
|
||||
private postCompactionWriteEnd = 0;
|
||||
private readonly strokeAgentData = new Float32Array(
|
||||
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||
|
|
@ -24,11 +28,12 @@ export class AgentPopulation {
|
|||
|
||||
public constructor(
|
||||
private readonly pipeline: AgentGenerationPipeline,
|
||||
private readonly introSeed = Math.floor(Math.random() * 0xffffffff),
|
||||
private readonly getCanvasPixelRatio = () => 1
|
||||
private readonly introSeed: number,
|
||||
private readonly getCanvasPixelRatio: () => number,
|
||||
private readonly framePerformance: FramePerformance
|
||||
) {
|
||||
this.adaptiveCap = this.clampAdaptiveCap(
|
||||
appConfig.simulation.budget.adaptiveCapInitial
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(
|
||||
this.framePerformance.adaptiveCapInitial
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +46,7 @@ export class AgentPopulation {
|
|||
}
|
||||
|
||||
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
|
||||
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
const introAgentCount = Math.min(
|
||||
this.adaptiveCap,
|
||||
appConfig.simulation.initialAgentCount
|
||||
|
|
@ -65,14 +70,10 @@ export class AgentPopulation {
|
|||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.trimActiveCountToBudget();
|
||||
}
|
||||
|
||||
public growBudget(deltaTime: number, smoothedFps: number): void {
|
||||
this.updateAdaptiveCap(deltaTime, smoothedFps);
|
||||
}
|
||||
|
||||
public resizeAgents(scale: vec2): void {
|
||||
this.pipeline.resizeAgents(this.activeCount, scale);
|
||||
}
|
||||
|
|
@ -103,8 +104,7 @@ export class AgentPopulation {
|
|||
this.activeCount,
|
||||
Math.max(finiteCompactedAgentCount, this.postCompactionWriteEnd)
|
||||
);
|
||||
this.replacementCursor =
|
||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||
this.clampReplacementCursor();
|
||||
this.trimActiveCountToBudget();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
|
@ -121,10 +121,32 @@ export class AgentPopulation {
|
|||
await this.pendingCompaction;
|
||||
}
|
||||
|
||||
public updateAdaptiveCap(): void {
|
||||
const previousCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.canExpandAdaptiveCap = this.framePerformance.hasAdaptiveCapHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
this.adaptiveCap = previousCap;
|
||||
this.trimActiveCountToBudget();
|
||||
return;
|
||||
}
|
||||
|
||||
const decrease = this.framePerformance.adaptiveCapDecreaseAgents;
|
||||
const responsiveCap = Math.min(
|
||||
previousCap,
|
||||
this.clampAndEnsureAdaptiveCap(this.activeCount)
|
||||
);
|
||||
const nextCap = this.clampAndEnsureAdaptiveCap(responsiveCap - decrease);
|
||||
this.adaptiveCap = nextCap;
|
||||
this.trimActiveCountToBudget(decrease);
|
||||
}
|
||||
|
||||
public spawnStrokeAgents(from: vec2, to: vec2): void {
|
||||
const deltaX = to[0] - from[0];
|
||||
const deltaY = to[1] - from[1];
|
||||
const length = Math.max(
|
||||
appConfig.simulation.stroke.minSegmentLengthPx,
|
||||
vec2.dist(from, to)
|
||||
Math.hypot(deltaX, deltaY)
|
||||
);
|
||||
const count = Math.max(
|
||||
appConfig.simulation.stroke.minAgentCount,
|
||||
|
|
@ -136,8 +158,8 @@ export class AgentPopulation {
|
|||
)
|
||||
)
|
||||
);
|
||||
const direction = vec2.sub(vec2.create(), to, from);
|
||||
const baseAngle = Math.atan2(direction[1], direction[0]);
|
||||
const baseAngle = Math.atan2(deltaY, deltaX);
|
||||
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count === 1 ? 1 : i / (count - 1);
|
||||
|
|
@ -147,10 +169,6 @@ export class AgentPopulation {
|
|||
baseAngle +
|
||||
(Math.random() - 0.5) * appConfig.simulation.stroke.angleJitterRadians;
|
||||
const base = i * AGENT_FLOAT_COUNT;
|
||||
const spread =
|
||||
settings.brushSize *
|
||||
getSafePixelRatio(this.getCanvasPixelRatio()) *
|
||||
settings.strokeSpawnSpreadBrushSizeMultiplier;
|
||||
this.strokeAgentData[base] = x + (Math.random() - 0.5) * spread;
|
||||
this.strokeAgentData[base + 1] = y + (Math.random() - 0.5) * spread;
|
||||
this.strokeAgentData[base + 2] = angle;
|
||||
|
|
@ -170,7 +188,7 @@ export class AgentPopulation {
|
|||
}
|
||||
|
||||
const count = data.length / AGENT_FLOAT_COUNT;
|
||||
this.adaptiveCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
this.expandAdaptiveCapForPendingAgents(count);
|
||||
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
|
|
@ -218,40 +236,15 @@ export class AgentPopulation {
|
|||
);
|
||||
}
|
||||
|
||||
private updateAdaptiveCap(deltaTime: number, smoothedFps: number): void {
|
||||
const previousCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
this.canExpandAdaptiveCap =
|
||||
smoothedFps >=
|
||||
appConfig.simulation.budget.adaptiveRefreshTargetFps *
|
||||
appConfig.simulation.budget.fpsHeadroom;
|
||||
|
||||
if (this.canExpandAdaptiveCap) {
|
||||
this.adaptiveCap = previousCap;
|
||||
this.trimActiveCountToBudget();
|
||||
return;
|
||||
}
|
||||
|
||||
const decrease = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
appConfig.simulation.budget.adaptiveCapDecreaseAgentsPerSecond * deltaTime
|
||||
)
|
||||
);
|
||||
const responsiveCap = Math.min(previousCap, this.clampAdaptiveCap(this.activeCount));
|
||||
const nextCap = this.clampAdaptiveCap(responsiveCap - decrease);
|
||||
this.adaptiveCap = nextCap;
|
||||
this.trimActiveCountToBudget(decrease);
|
||||
}
|
||||
|
||||
private expandAdaptiveCapForPendingAgents(requestedAgentCount: number): void {
|
||||
const available = Math.max(0, this.adaptiveCap - this.activeCount);
|
||||
if (requestedAgentCount <= available || !this.canExpandAdaptiveCap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCap = this.clampAdaptiveCap(this.adaptiveCap);
|
||||
const currentCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
const pendingAgentCount = requestedAgentCount - available;
|
||||
this.adaptiveCap = this.clampAdaptiveCap(currentCap + pendingAgentCount);
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(currentCap + pendingAgentCount);
|
||||
}
|
||||
|
||||
private trimActiveCountToBudget(maxDecrease = Number.POSITIVE_INFINITY): void {
|
||||
|
|
@ -263,11 +256,15 @@ export class AgentPopulation {
|
|||
this.adaptiveCap,
|
||||
this.activeCount - Math.max(1, Math.ceil(maxDecrease))
|
||||
);
|
||||
this.clampReplacementCursor();
|
||||
}
|
||||
|
||||
private clampReplacementCursor(): void {
|
||||
this.replacementCursor =
|
||||
this.activeCount === 0 ? 0 : this.replacementCursor % this.activeCount;
|
||||
}
|
||||
|
||||
private clampAdaptiveCap(value: number): number {
|
||||
private clampAndEnsureAdaptiveCap(value: number): number {
|
||||
const runtimeMaxCap =
|
||||
settings.maxAgentCount === Number.POSITIVE_INFINITY
|
||||
? Number.POSITIVE_INFINITY
|
||||
|
|
@ -275,7 +272,7 @@ export class AgentPopulation {
|
|||
? Math.max(0, Math.floor(settings.maxAgentCount))
|
||||
: Math.max(0, Math.floor(this.pipeline.maxAgentCount));
|
||||
const maxCap = Math.min(this.pipeline.maxSupportedAgentCount, runtimeMaxCap);
|
||||
const minCap = Math.min(appConfig.simulation.budget.adaptiveCapMin, maxCap);
|
||||
const minCap = Math.min(this.framePerformance.adaptiveCapMin, maxCap);
|
||||
const finiteValue = Number.isFinite(value) ? value : minCap;
|
||||
const nextCap = Math.min(maxCap, Math.max(minCap, Math.round(finiteValue)));
|
||||
return Math.min(
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import type { VibeId } from '../vibes';
|
||||
import {
|
||||
estimateExport4KMemory,
|
||||
getAspectFitExport4KDimensions,
|
||||
getBrowserExportMemoryInfo,
|
||||
getExport4KPreflightError,
|
||||
} from './export-4k';
|
||||
|
||||
interface Export4KRendererOptions {
|
||||
device: GPUDevice;
|
||||
renderPipeline: RenderPipeline;
|
||||
statusElement: HTMLElement;
|
||||
seed: string;
|
||||
getSourceSize: () => { width: number; height: number };
|
||||
getColorTextureView: () => GPUTextureView;
|
||||
getSourceTextureView: () => GPUTextureView;
|
||||
getVibeId: () => VibeId;
|
||||
}
|
||||
|
||||
export class Export4KRenderer {
|
||||
private isExporting = false;
|
||||
|
||||
public constructor(private readonly options: Export4KRendererOptions) {}
|
||||
|
||||
public async export(): Promise<void> {
|
||||
if (this.isExporting) {
|
||||
this.statusElement.textContent = '4K upscale already rendering...';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExporting = true;
|
||||
this.statusElement.textContent = 'Rendering 4K upscale...';
|
||||
|
||||
try {
|
||||
const sourceSize = this.options.getSourceSize();
|
||||
const exportDimensions = getAspectFitExport4KDimensions(
|
||||
sourceSize.width,
|
||||
sourceSize.height
|
||||
);
|
||||
const estimate = estimateExport4KMemory(
|
||||
exportDimensions.width,
|
||||
exportDimensions.height
|
||||
);
|
||||
const preflightError = getExport4KPreflightError({
|
||||
limits: this.device.limits,
|
||||
memoryInfo: getBrowserExportMemoryInfo(),
|
||||
estimate,
|
||||
});
|
||||
if (preflightError) {
|
||||
this.statusElement.textContent = '4K upscale unavailable';
|
||||
throw preflightError;
|
||||
}
|
||||
|
||||
await this.renderExport(estimate);
|
||||
this.statusElement.textContent = '';
|
||||
} finally {
|
||||
this.isExporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async renderExport(
|
||||
estimate: ReturnType<typeof estimateExport4KMemory>
|
||||
): Promise<void> {
|
||||
const { width, height, unpaddedBytesPerRow, bytesPerRow } = estimate;
|
||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||
let texture: GPUTexture | null = null;
|
||||
let output: GPUBuffer | null = null;
|
||||
let isOutputMapped = false;
|
||||
|
||||
try {
|
||||
texture = this.device.createTexture({
|
||||
size: { width, height },
|
||||
format,
|
||||
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
|
||||
});
|
||||
output = this.device.createBuffer({
|
||||
size: estimate.readbackBufferBytes,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||||
});
|
||||
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
this.options.renderPipeline.executeToView(
|
||||
commandEncoder,
|
||||
this.options.getColorTextureView(),
|
||||
this.options.getSourceTextureView(),
|
||||
texture.createView()
|
||||
);
|
||||
commandEncoder.copyTextureToBuffer(
|
||||
{ texture },
|
||||
{ buffer: output, bytesPerRow, rowsPerImage: height },
|
||||
{ width, height }
|
||||
);
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
|
||||
await output.mapAsync(GPUMapMode.READ);
|
||||
isOutputMapped = true;
|
||||
const pixels = readExportPixels({
|
||||
mapped: new Uint8Array(output.getMappedRange()),
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra: format === 'bgra8unorm',
|
||||
});
|
||||
output.unmap();
|
||||
isOutputMapped = false;
|
||||
output.destroy();
|
||||
output = null;
|
||||
texture.destroy();
|
||||
texture = null;
|
||||
|
||||
await this.downloadPixels(pixels, width, height);
|
||||
} catch (error) {
|
||||
this.statusElement.textContent = '4K upscale failed';
|
||||
throw error;
|
||||
} finally {
|
||||
if (output && isOutputMapped) {
|
||||
output.unmap();
|
||||
}
|
||||
output?.destroy();
|
||||
texture?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadPixels(
|
||||
pixels: Uint8ClampedArray<ArrayBuffer>,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<void> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not create export canvas');
|
||||
}
|
||||
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||
const blob = await canvas.convertToBlob({ type: appConfig.export4k.mimeType });
|
||||
const link = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
link.href = objectUrl;
|
||||
link.download = `${appConfig.export4k.filenamePrefix}_${this.options.getVibeId()}_${
|
||||
this.options.seed
|
||||
}_${width}x${height}${appConfig.export4k.filenameSuffix}.${appConfig.export4k.filenameExtension}`;
|
||||
link.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private get device(): GPUDevice {
|
||||
return this.options.device;
|
||||
}
|
||||
|
||||
private get statusElement(): HTMLElement {
|
||||
return this.options.statusElement;
|
||||
}
|
||||
}
|
||||
|
||||
const readExportPixels = ({
|
||||
mapped,
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
isBgra,
|
||||
}: {
|
||||
mapped: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
isBgra: boolean;
|
||||
}): Uint8ClampedArray<ArrayBuffer> => {
|
||||
const pixels: Uint8ClampedArray<ArrayBuffer> = new Uint8ClampedArray(
|
||||
unpaddedBytesPerRow * height
|
||||
);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const sourceOffset = y * bytesPerRow;
|
||||
const targetOffset = y * unpaddedBytesPerRow;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const source = sourceOffset + x * appConfig.export4k.bytesPerPixel;
|
||||
const target = targetOffset + x * appConfig.export4k.bytesPerPixel;
|
||||
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||
pixels[target + 1] = mapped[source + 1];
|
||||
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||
pixels[target + 3] = mapped[source + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return pixels;
|
||||
};
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RuntimeError } from '../utils/error-handler';
|
||||
|
||||
const GIBIBYTE = 1024 ** 3;
|
||||
|
||||
interface Export4KMemoryEstimate {
|
||||
width: number;
|
||||
height: number;
|
||||
unpaddedBytesPerRow: number;
|
||||
bytesPerRow: number;
|
||||
textureBytes: number;
|
||||
readbackBufferBytes: number;
|
||||
estimatedJsHeapBytes: number;
|
||||
estimatedPeakBytes: number;
|
||||
}
|
||||
|
||||
interface Export4KDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BrowserMemoryInfo {
|
||||
deviceMemoryBytes?: number;
|
||||
jsHeapSizeLimitBytes?: number;
|
||||
usedJsHeapSizeBytes?: number;
|
||||
}
|
||||
|
||||
interface Export4KPreflightOptions {
|
||||
limits: Pick<GPUSupportedLimits, 'maxBufferSize' | 'maxTextureDimension2D'>;
|
||||
memoryInfo?: BrowserMemoryInfo;
|
||||
estimate?: Export4KMemoryEstimate;
|
||||
}
|
||||
|
||||
const alignTo = (value: number, alignment: number): number =>
|
||||
Math.ceil(value / alignment) * alignment;
|
||||
|
||||
const getPositiveFiniteNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
|
||||
const formatByteSize = (bytes: number): string => `${Math.ceil(bytes / 1024 / 1024)} MiB`;
|
||||
|
||||
export const getAspectFitExport4KDimensions = (
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
maxWidth = appConfig.export4k.width,
|
||||
maxHeight = appConfig.export4k.height
|
||||
): Export4KDimensions => {
|
||||
if (
|
||||
!Number.isFinite(sourceWidth) ||
|
||||
!Number.isFinite(sourceHeight) ||
|
||||
sourceWidth <= 0 ||
|
||||
sourceHeight <= 0
|
||||
) {
|
||||
return { width: maxWidth, height: maxHeight };
|
||||
}
|
||||
|
||||
const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
|
||||
|
||||
return {
|
||||
width: Math.min(maxWidth, Math.max(1, Math.round(sourceWidth * scale))),
|
||||
height: Math.min(maxHeight, Math.max(1, Math.round(sourceHeight * scale))),
|
||||
};
|
||||
};
|
||||
|
||||
export const estimateExport4KMemory = (
|
||||
width = appConfig.export4k.width,
|
||||
height = appConfig.export4k.height
|
||||
): Export4KMemoryEstimate => {
|
||||
const unpaddedBytesPerRow = width * appConfig.export4k.bytesPerPixel;
|
||||
const bytesPerRow = alignTo(unpaddedBytesPerRow, appConfig.export4k.rowAlignmentBytes);
|
||||
const textureBytes = unpaddedBytesPerRow * height;
|
||||
const readbackBufferBytes = bytesPerRow * height;
|
||||
const estimatedJsHeapBytes =
|
||||
textureBytes * appConfig.export4k.jsHeapTextureBytesMultiplier;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
unpaddedBytesPerRow,
|
||||
bytesPerRow,
|
||||
textureBytes,
|
||||
readbackBufferBytes,
|
||||
estimatedJsHeapBytes,
|
||||
estimatedPeakBytes: textureBytes + readbackBufferBytes + estimatedJsHeapBytes,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBrowserExportMemoryInfo = (): BrowserMemoryInfo => {
|
||||
const navigatorWithMemory =
|
||||
typeof navigator === 'undefined'
|
||||
? undefined
|
||||
: (navigator as Navigator & { deviceMemory?: number });
|
||||
const performanceWithMemory =
|
||||
typeof performance === 'undefined'
|
||||
? undefined
|
||||
: (performance as Performance & {
|
||||
memory?: {
|
||||
jsHeapSizeLimit?: number;
|
||||
usedJSHeapSize?: number;
|
||||
};
|
||||
});
|
||||
|
||||
const deviceMemoryGib = getPositiveFiniteNumber(navigatorWithMemory?.deviceMemory);
|
||||
const jsHeapSizeLimitBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.jsHeapSizeLimit
|
||||
);
|
||||
const usedJsHeapSizeBytes = getPositiveFiniteNumber(
|
||||
performanceWithMemory?.memory?.usedJSHeapSize
|
||||
);
|
||||
|
||||
return {
|
||||
...(deviceMemoryGib === undefined
|
||||
? {}
|
||||
: { deviceMemoryBytes: deviceMemoryGib * GIBIBYTE }),
|
||||
...(jsHeapSizeLimitBytes === undefined ? {} : { jsHeapSizeLimitBytes }),
|
||||
...(usedJsHeapSizeBytes === undefined ? {} : { usedJsHeapSizeBytes }),
|
||||
};
|
||||
};
|
||||
|
||||
export const getExport4KPreflightError = ({
|
||||
limits,
|
||||
memoryInfo = {},
|
||||
estimate = estimateExport4KMemory(),
|
||||
}: Export4KPreflightOptions): RuntimeError | null => {
|
||||
if (
|
||||
estimate.width > limits.maxTextureDimension2D ||
|
||||
estimate.height > limits.maxTextureDimension2D
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-texture-too-large',
|
||||
`This GPU cannot create a ${estimate.width}x${estimate.height} export texture.`,
|
||||
{
|
||||
details: {
|
||||
exportWidth: estimate.width,
|
||||
exportHeight: estimate.height,
|
||||
maxTextureDimension2D: limits.maxTextureDimension2D,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (estimate.readbackBufferBytes > limits.maxBufferSize) {
|
||||
return new RuntimeError(
|
||||
'export-4k-readback-too-large',
|
||||
'This GPU cannot allocate the 4K export readback buffer.',
|
||||
{
|
||||
details: {
|
||||
readbackBufferBytes: estimate.readbackBufferBytes,
|
||||
maxBufferSize: limits.maxBufferSize,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.deviceMemoryBytes !== undefined &&
|
||||
memoryInfo.deviceMemoryBytes <= appConfig.export4k.lowMemoryDeviceGiB * GIBIBYTE &&
|
||||
estimate.estimatedPeakBytes >
|
||||
memoryInfo.deviceMemoryBytes * appConfig.export4k.lowMemoryExportFraction
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-device-memory',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedPeakBytes
|
||||
)} of temporary memory, which is not safe on this low-memory device.`,
|
||||
{
|
||||
details: {
|
||||
deviceMemoryBytes: memoryInfo.deviceMemoryBytes,
|
||||
estimatedPeakBytes: estimate.estimatedPeakBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
memoryInfo.jsHeapSizeLimitBytes !== undefined &&
|
||||
memoryInfo.usedJsHeapSizeBytes !== undefined
|
||||
) {
|
||||
const availableJsHeapBytes =
|
||||
memoryInfo.jsHeapSizeLimitBytes - memoryInfo.usedJsHeapSizeBytes;
|
||||
if (
|
||||
availableJsHeapBytes <
|
||||
estimate.estimatedJsHeapBytes * appConfig.export4k.jsHeapSafetyMultiplier
|
||||
) {
|
||||
return new RuntimeError(
|
||||
'export-4k-low-js-heap',
|
||||
`4K upscale export needs about ${formatByteSize(
|
||||
estimate.estimatedJsHeapBytes
|
||||
)} of JavaScript heap, and this browser does not report enough free heap.`,
|
||||
{
|
||||
details: {
|
||||
availableJsHeapBytes,
|
||||
estimatedJsHeapBytes: estimate.estimatedJsHeapBytes,
|
||||
jsHeapSizeLimitBytes: memoryInfo.jsHeapSizeLimitBytes,
|
||||
usedJsHeapSizeBytes: memoryInfo.usedJsHeapSizeBytes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,13 +1,40 @@
|
|||
import { appConfig } from '../config';
|
||||
import { settings } from '../settings';
|
||||
|
||||
export class FramePerformance {
|
||||
public smoothedFps = appConfig.simulation.budget.initialFps;
|
||||
private readonly adaptiveRefreshTargetFps = 60;
|
||||
private readonly initialFps = this.adaptiveRefreshTargetFps;
|
||||
public smoothedFps = this.initialFps;
|
||||
|
||||
public measuredFps = 0;
|
||||
public frameDeltaSeconds = 0;
|
||||
public measuredFrameTimeMs = 0;
|
||||
|
||||
private readonly adaptiveCapDecreaseAgentsPerSecond = 200_000;
|
||||
private readonly frameGapResetSeconds = 1;
|
||||
private readonly fpsHeadroom = 0.9;
|
||||
private readonly fpsSmoothingNew = 0.06;
|
||||
private readonly fpsSmoothingRetain = 1 - this.fpsSmoothingNew;
|
||||
private previousFrameTime: DOMHighResTimeStamp | null = null;
|
||||
|
||||
public get adaptiveCapInitial(): number {
|
||||
return settings.adaptiveCapInitial;
|
||||
}
|
||||
|
||||
public get adaptiveCapMin(): number {
|
||||
return settings.adaptiveCapMin;
|
||||
}
|
||||
|
||||
public get hasAdaptiveCapHeadroom(): boolean {
|
||||
return this.smoothedFps >= this.adaptiveRefreshTargetFps * this.fpsHeadroom;
|
||||
}
|
||||
|
||||
public get adaptiveCapDecreaseAgents(): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.ceil(this.adaptiveCapDecreaseAgentsPerSecond * this.frameDeltaSeconds)
|
||||
);
|
||||
}
|
||||
|
||||
public update(time: DOMHighResTimeStamp): void {
|
||||
const previous = this.previousFrameTime;
|
||||
this.previousFrameTime = time;
|
||||
|
|
@ -24,12 +51,11 @@ export class FramePerformance {
|
|||
this.frameDeltaSeconds = deltaSeconds;
|
||||
this.measuredFrameTimeMs = deltaSeconds * 1000;
|
||||
this.measuredFps = fps;
|
||||
if (deltaSeconds > appConfig.simulation.budget.frameGapResetSeconds) {
|
||||
if (deltaSeconds > this.frameGapResetSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.smoothedFps =
|
||||
this.smoothedFps * appConfig.simulation.budget.fpsSmoothingRetain +
|
||||
fps * appConfig.simulation.budget.fpsSmoothingNew;
|
||||
this.smoothedFps * this.fpsSmoothingRetain + fps * this.fpsSmoothingNew;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ export class GameLoopResources {
|
|||
public constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
private readonly device: GPUDevice,
|
||||
canvasSize: vec2
|
||||
canvasSize: vec2,
|
||||
initialAgentCapacity: number
|
||||
) {
|
||||
const context = initializeContext({ device, canvas });
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ export class GameLoopResources {
|
|||
|
||||
this.agentGenerationPipeline = new AgentGenerationPipeline(
|
||||
this.device,
|
||||
Math.min(settings.maxAgentCount, appConfig.simulation.budget.adaptiveCapInitial)
|
||||
Math.min(settings.maxAgentCount, initialAgentCapacity)
|
||||
);
|
||||
|
||||
this.agentPipeline = new AgentPipeline(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import type { RgbColor } from '../utils/rgb-color';
|
||||
|
||||
export interface GardenUi {
|
||||
prompt: HTMLElement;
|
||||
eraserPreview: HTMLElement;
|
||||
|
|
@ -8,8 +10,8 @@ export interface GardenUi {
|
|||
}
|
||||
|
||||
export interface RenderInputs {
|
||||
channelColors: Array<[number, number, number]>;
|
||||
backgroundColor: [number, number, number];
|
||||
channelColors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
}
|
||||
|
||||
export interface StrokeSegment {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,33 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { gardenAudioConfig } from '../audio/garden-audio-config';
|
||||
import { appConfig } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import { DevStatsOverlay } from './dev-stats-overlay';
|
||||
import { EraserPointerPreviewController } from './eraser-pointer-preview-controller';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { Export4KRenderer } from './export-4k-renderer';
|
||||
import { ExportSnapshotRenderer } from './export-snapshot-renderer';
|
||||
import { FramePerformance } from './frame-performance';
|
||||
import { GameLoopResources } from './game-loop-resources';
|
||||
import { GardenUi } from './game-loop-types';
|
||||
import { getInternalRenderSize } from './internal-render-size';
|
||||
import { IntroPrompt } from './intro-prompt';
|
||||
import { GardenPointerInput } from './pointer-input';
|
||||
import { RenderInputCache } from './render-input-cache';
|
||||
import { PipelineStrokeOutput } from './stroke-output';
|
||||
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
||||
|
||||
export default class GameLoop {
|
||||
private readonly resources: GameLoopResources;
|
||||
private readonly audio = new GardenAudio(gardenAudioConfig);
|
||||
private readonly renderInputs = new RenderInputCache();
|
||||
private readonly audio = new GardenAudio(appConfig.audio);
|
||||
private readonly introPrompt: IntroPrompt;
|
||||
private readonly eraserPreview: EraserPreview;
|
||||
private readonly pointerInput: GardenPointerInput;
|
||||
private readonly eraserPreviewController: EraserPointerPreviewController;
|
||||
private readonly agentPopulation: AgentPopulation;
|
||||
private readonly export4KRenderer: Export4KRenderer;
|
||||
private readonly exportSnapshotRenderer: ExportSnapshotRenderer;
|
||||
private readonly framePerformance = new FramePerformance();
|
||||
private readonly devStatsOverlay: DevStatsOverlay | null;
|
||||
private readonly toolbarContrastMonitor: ToolbarContrastMonitor;
|
||||
|
|
@ -35,6 +36,7 @@ export default class GameLoop {
|
|||
private readonly resizeListener = this.resize.bind(this);
|
||||
|
||||
private pendingIntroResizeAt: DOMHighResTimeStamp | null = null;
|
||||
private previousAccentColor = '';
|
||||
private hasFinished = false;
|
||||
private readonly finished = Promise.withResolvers<void>();
|
||||
|
||||
|
|
@ -48,38 +50,53 @@ export default class GameLoop {
|
|||
this.devStatsOverlay = import.meta.env.DEV
|
||||
? new DevStatsOverlay(canvas.parentElement ?? document.body)
|
||||
: null;
|
||||
this.resources = new GameLoopResources(canvas, device, this.canvasSize);
|
||||
this.resources = new GameLoopResources(
|
||||
canvas,
|
||||
device,
|
||||
this.canvasSize,
|
||||
this.framePerformance.adaptiveCapInitial
|
||||
);
|
||||
this.introPrompt = new IntroPrompt(ui.prompt);
|
||||
this.eraserPreview = new EraserPreview(canvas, ui.eraserPreview);
|
||||
this.toolbarContrastMonitor = new ToolbarContrastMonitor(canvas, ui.toolbar, device);
|
||||
this.agentPopulation = new AgentPopulation(
|
||||
this.resources.agentGenerationPipeline,
|
||||
this.seedValue,
|
||||
() => this.canvasPixelRatio
|
||||
() => this.canvasPixelRatio,
|
||||
this.framePerformance
|
||||
);
|
||||
this.agentPopulation.initializeIntroAgents(this.canvasSize);
|
||||
this.pointerInput = new GardenPointerInput({
|
||||
canvas,
|
||||
audio: this.audio,
|
||||
brushPipeline: this.resources.brushPipeline,
|
||||
eraserAgentPipeline: this.resources.eraserAgentPipeline,
|
||||
eraserTexturePipeline: this.resources.eraserTexturePipeline,
|
||||
eraserPreview: this.eraserPreview,
|
||||
strokeOutput: new PipelineStrokeOutput(
|
||||
this.resources.brushPipeline,
|
||||
this.resources.eraserAgentPipeline,
|
||||
this.resources.eraserTexturePipeline
|
||||
),
|
||||
getCanvasPixelRatio: () => this.canvasPixelRatio,
|
||||
getMirrorSegmentCount: () => this.mirrorSegmentCount,
|
||||
onStartDrawing: () => this.introPrompt.markStartedDrawing(),
|
||||
onEraseGestureEnded: () => this.agentPopulation.requestCompactionAfterErase(),
|
||||
spawnStrokeAgents: (from, to) => this.agentPopulation.spawnStrokeAgents(from, to),
|
||||
});
|
||||
this.export4KRenderer = new Export4KRenderer({
|
||||
this.eraserPreviewController = new EraserPointerPreviewController({
|
||||
canvas,
|
||||
eraserPreview: this.eraserPreview,
|
||||
getIsSwipeActive: () => this.pointerInput.isSwipeActive,
|
||||
});
|
||||
this.exportSnapshotRenderer = new ExportSnapshotRenderer({
|
||||
device,
|
||||
renderPipeline: this.resources.renderPipeline,
|
||||
statusElement: ui.exportStatus,
|
||||
seed: this.seed,
|
||||
getSourceSize: () => ({
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
}),
|
||||
getSourceSize: () => {
|
||||
const size = this.resources.textures.trailMapA.getSize();
|
||||
return {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
};
|
||||
},
|
||||
getColorTextureView: () => this.resources.textures.trailMapA.getTextureView(),
|
||||
getSourceTextureView: () => this.resources.textures.sourceMapA.getTextureView(),
|
||||
getVibeId: () => activeVibe.id,
|
||||
|
|
@ -87,18 +104,19 @@ export default class GameLoop {
|
|||
|
||||
window.addEventListener('resize', this.resizeListener);
|
||||
this.pointerInput.attach();
|
||||
this.eraserPreviewController.attach();
|
||||
}
|
||||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.pointerInput.setEraseMode(isErasing);
|
||||
this.eraserPreviewController.setEraseMode(isErasing);
|
||||
}
|
||||
|
||||
public updateEraserPreview(event?: PointerEvent): void {
|
||||
this.pointerInput.updateEraserPreview(event);
|
||||
this.eraserPreviewController.update(event);
|
||||
}
|
||||
|
||||
public onVibeChanged(): void {
|
||||
this.renderInputs.invalidate();
|
||||
this.agentPopulation.onVibeChanged();
|
||||
}
|
||||
|
||||
|
|
@ -123,8 +141,8 @@ export default class GameLoop {
|
|||
return this.finished.promise;
|
||||
}
|
||||
|
||||
public async export4K(): Promise<void> {
|
||||
return this.export4KRenderer.export();
|
||||
public async exportSnapshot(): Promise<void> {
|
||||
return this.exportSnapshotRenderer.export();
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
|
|
@ -133,6 +151,7 @@ export default class GameLoop {
|
|||
|
||||
window.removeEventListener('resize', this.resizeListener);
|
||||
this.pointerInput.detach();
|
||||
this.eraserPreviewController.detach();
|
||||
this.devStatsOverlay?.destroy();
|
||||
this.toolbarContrastMonitor.destroy();
|
||||
this.introPrompt.destroy();
|
||||
|
|
@ -149,22 +168,20 @@ export default class GameLoop {
|
|||
|
||||
const deltaTime = this.deltaTimeCalculator.calculateDeltaTimeInSeconds(time);
|
||||
this.framePerformance.update(time);
|
||||
this.agentPopulation.growBudget(
|
||||
this.framePerformance.frameDeltaSeconds,
|
||||
this.framePerformance.smoothedFps
|
||||
);
|
||||
this.agentPopulation.updateAdaptiveCap();
|
||||
this.introPrompt.update(this.pendingIntroResizeAt === null ? deltaTime : 0);
|
||||
this.resize();
|
||||
this.resizeSimulationToCanvas(time);
|
||||
this.regenerateIntroAfterSettledResize(time);
|
||||
|
||||
const { channelColors, backgroundColor } = this.renderInputs.get();
|
||||
const channelColors = activeVibe.colors;
|
||||
const backgroundColor = activeVibe.backgroundColor;
|
||||
const introProgress = this.introPrompt.progress;
|
||||
const canvasPixelRatio = this.canvasPixelRatio;
|
||||
const eraserPixelSize = settings.eraserSize * canvasPixelRatio;
|
||||
const isErasing = this.pointerInput.isEraseMode;
|
||||
const accentColor = channelColors[settings.selectedColorIndex] ?? channelColors[0];
|
||||
this.renderInputs.updateAccentColor(accentColor);
|
||||
this.updateAccentColor(accentColor);
|
||||
this.audio.update({
|
||||
vibe: activeVibe,
|
||||
isErasing,
|
||||
|
|
@ -203,6 +220,16 @@ export default class GameLoop {
|
|||
requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
private updateAccentColor(color: RgbColor): void {
|
||||
const accentColor = rgbColorToCss(color);
|
||||
if (this.previousAccentColor === accentColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousAccentColor = accentColor;
|
||||
document.documentElement.style.setProperty('--accent-color', accentColor);
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const { width, height } = getInternalRenderSize({
|
||||
|
|
|
|||
|
|
@ -2,23 +2,17 @@ import { vec2 } from 'gl-matrix';
|
|||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { appConfig } from '../config';
|
||||
import {
|
||||
BrushPipeline,
|
||||
getSafePixelRatio,
|
||||
} from '../pipelines/brush/brush-pipeline';
|
||||
import { EraserAgentPipeline } from '../pipelines/eraser/eraser-agent-pipeline';
|
||||
import { EraserTexturePipeline } from '../pipelines/eraser/eraser-texture-pipeline';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { EraserPreview } from './eraser-preview';
|
||||
import { StrokeSegment } from './game-loop-types';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { activeVibe } from '../settings';
|
||||
import { BrushStrokeSmoother } from './brush-stroke-smoother';
|
||||
import { type StrokeSegment } from './game-loop-types';
|
||||
import { getMirroredStrokeSegments } from './stroke-mirroring';
|
||||
import { type StrokeOutput } from './stroke-output';
|
||||
|
||||
interface GardenPointerInputOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
audio: GardenAudio;
|
||||
brushPipeline: BrushPipeline;
|
||||
eraserAgentPipeline: EraserAgentPipeline;
|
||||
eraserTexturePipeline: EraserTexturePipeline;
|
||||
eraserPreview: EraserPreview;
|
||||
strokeOutput: StrokeOutput;
|
||||
getCanvasPixelRatio: () => number;
|
||||
getMirrorSegmentCount: () => number;
|
||||
onStartDrawing: () => void;
|
||||
|
|
@ -26,19 +20,28 @@ interface GardenPointerInputOptions {
|
|||
spawnStrokeAgents: (from: vec2, to: vec2) => void;
|
||||
}
|
||||
|
||||
interface PointerSample {
|
||||
position: vec2;
|
||||
previousPosition: vec2;
|
||||
elapsedSeconds: number;
|
||||
timeStamp: number;
|
||||
}
|
||||
|
||||
export class GardenPointerInput {
|
||||
private readonly brushSmoother: BrushStrokeSmoother;
|
||||
private activePointerId: number | null = null;
|
||||
private lastPointerPosition: vec2 | null = null;
|
||||
private lastPointerEventTimeMs: number | null = null;
|
||||
private smoothedStrokePoints: Array<vec2> = [];
|
||||
private lastSmoothedBrushPosition: vec2 | null = null;
|
||||
private isErasing = false;
|
||||
|
||||
public constructor(private readonly options: GardenPointerInputOptions) {}
|
||||
public constructor(private readonly options: GardenPointerInputOptions) {
|
||||
this.brushSmoother = new BrushStrokeSmoother({
|
||||
getCanvasPixelRatio: options.getCanvasPixelRatio,
|
||||
getMirrorSegmentCount: options.getMirrorSegmentCount,
|
||||
});
|
||||
}
|
||||
|
||||
public attach(): void {
|
||||
this.canvas.addEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.addEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.addEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.addEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.addEventListener('pointerup', this.onPointerUp);
|
||||
|
|
@ -46,8 +49,6 @@ export class GardenPointerInput {
|
|||
}
|
||||
|
||||
public detach(): void {
|
||||
this.canvas.removeEventListener('pointerenter', this.onPointerEnter);
|
||||
this.canvas.removeEventListener('pointerleave', this.onPointerLeave);
|
||||
this.canvas.removeEventListener('pointerdown', this.onPointerDown);
|
||||
this.canvas.removeEventListener('pointermove', this.onPointerMove);
|
||||
this.canvas.removeEventListener('pointerup', this.onPointerUp);
|
||||
|
|
@ -56,11 +57,6 @@ export class GardenPointerInput {
|
|||
|
||||
public setEraseMode(isErasing: boolean): void {
|
||||
this.isErasing = isErasing;
|
||||
this.options.eraserPreview.setEraseMode(isErasing, this.isSwipeActive);
|
||||
}
|
||||
|
||||
public updateEraserPreview(event?: PointerEvent): void {
|
||||
this.options.eraserPreview.update(event, this.isSwipeActive);
|
||||
}
|
||||
|
||||
public clearSwipesIfIdle(): void {
|
||||
|
|
@ -68,9 +64,7 @@ export class GardenPointerInput {
|
|||
return;
|
||||
}
|
||||
|
||||
this.options.brushPipeline.clearSwipes();
|
||||
this.options.eraserAgentPipeline.clearSwipes();
|
||||
this.options.eraserTexturePipeline.clearSwipes();
|
||||
this.options.strokeOutput.clearSwipes();
|
||||
}
|
||||
|
||||
public scaleLastPointerPosition(scale: vec2): void {
|
||||
|
|
@ -78,13 +72,7 @@ export class GardenPointerInput {
|
|||
vec2.mul(this.lastPointerPosition, this.lastPointerPosition, scale);
|
||||
}
|
||||
|
||||
this.smoothedStrokePoints.forEach((point) => {
|
||||
vec2.mul(point, point, scale);
|
||||
});
|
||||
|
||||
if (this.lastSmoothedBrushPosition !== null) {
|
||||
vec2.mul(this.lastSmoothedBrushPosition, this.lastSmoothedBrushPosition, scale);
|
||||
}
|
||||
this.brushSmoother.scale(scale);
|
||||
}
|
||||
|
||||
public get isSwipeActive(): boolean {
|
||||
|
|
@ -100,8 +88,6 @@ export class GardenPointerInput {
|
|||
}
|
||||
|
||||
private readonly onPointerDown = (event: PointerEvent) => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||
this.updateEraserPreview(event);
|
||||
if (this.activePointerId !== null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -111,17 +97,14 @@ export class GardenPointerInput {
|
|||
this.options.onStartDrawing();
|
||||
this.activePointerId = event.pointerId;
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
this.options.brushPipeline.clearSwipes();
|
||||
this.options.eraserAgentPipeline.clearSwipes();
|
||||
this.options.eraserTexturePipeline.clearSwipes();
|
||||
this.options.strokeOutput.clearSwipes();
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.clearSmoothedStroke();
|
||||
this.brushSmoother.clear();
|
||||
this.addSwipeAt(event, { emitAudio: false });
|
||||
};
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent) => {
|
||||
this.updateEraserPreview(event);
|
||||
if (event.pointerId !== this.activePointerId) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -136,7 +119,7 @@ export class GardenPointerInput {
|
|||
}
|
||||
this.options.audio.start(activeVibe, { userGesture: true });
|
||||
this.addSwipeAt(event, { emitAudio: false });
|
||||
this.finishSmoothedStroke();
|
||||
this.finishBrushStroke();
|
||||
this.options.audio.endGesture();
|
||||
if (this.isErasing) {
|
||||
this.options.onEraseGestureEnded();
|
||||
|
|
@ -145,24 +128,27 @@ export class GardenPointerInput {
|
|||
this.activePointerId = null;
|
||||
this.lastPointerPosition = null;
|
||||
this.lastPointerEventTimeMs = null;
|
||||
this.clearSmoothedStroke();
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(
|
||||
this.options.eraserPreview.isPointerInsideCanvas(event)
|
||||
);
|
||||
this.updateEraserPreview(event);
|
||||
};
|
||||
|
||||
private readonly onPointerEnter = (event: PointerEvent) => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(true);
|
||||
this.updateEraserPreview(event);
|
||||
};
|
||||
|
||||
private readonly onPointerLeave = () => {
|
||||
this.options.eraserPreview.setPointerHoveringCanvas(false);
|
||||
this.updateEraserPreview();
|
||||
this.brushSmoother.clear();
|
||||
};
|
||||
|
||||
private addSwipeAt(event: PointerEvent, options: { emitAudio?: boolean } = {}): void {
|
||||
const sample = this.getPointerSample(event);
|
||||
|
||||
if (this.isErasing) {
|
||||
this.addEraseSample(sample);
|
||||
} else {
|
||||
this.addBrushSample(sample);
|
||||
}
|
||||
|
||||
if (options.emitAudio !== false) {
|
||||
this.emitStrokeAudio(sample);
|
||||
}
|
||||
|
||||
this.lastPointerPosition = sample.position;
|
||||
this.lastPointerEventTimeMs = sample.timeStamp;
|
||||
}
|
||||
|
||||
private getPointerSample(event: PointerEvent): PointerSample {
|
||||
const position = this.getCanvasPointerPosition(event);
|
||||
const previousPosition = this.lastPointerPosition ?? position;
|
||||
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||
|
|
@ -171,33 +157,36 @@ export class GardenPointerInput {
|
|||
(event.timeStamp - previousTimeMs) / 1000
|
||||
);
|
||||
|
||||
const segments = this.isErasing
|
||||
? [{ from: previousPosition, to: position }]
|
||||
: this.getMirroredStrokeSegments(previousPosition, position);
|
||||
return {
|
||||
position,
|
||||
previousPosition,
|
||||
elapsedSeconds,
|
||||
timeStamp: event.timeStamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isErasing) {
|
||||
segments.forEach((segment) => {
|
||||
this.options.eraserAgentPipeline.addSwipeSegment();
|
||||
this.options.eraserTexturePipeline.addSwipeSegment(segment.from, segment.to);
|
||||
});
|
||||
} else {
|
||||
this.addSmoothedBrushSample(position);
|
||||
segments.forEach((segment) => {
|
||||
private addBrushSample(sample: PointerSample): void {
|
||||
this.addBrushSegments(this.brushSmoother.addSample(sample.position));
|
||||
this.getMirroredSegments(sample.previousPosition, sample.position).forEach(
|
||||
(segment) => {
|
||||
this.options.spawnStrokeAgents(segment.from, segment.to);
|
||||
});
|
||||
}
|
||||
if (options.emitAudio !== false) {
|
||||
this.options.audio.stroke({
|
||||
vibe: activeVibe,
|
||||
from: previousPosition,
|
||||
to: position,
|
||||
canvasSize: [this.canvas.width, this.canvas.height],
|
||||
isErasing: this.isErasing,
|
||||
elapsedSeconds,
|
||||
});
|
||||
}
|
||||
this.lastPointerPosition = position;
|
||||
this.lastPointerEventTimeMs = event.timeStamp;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private addEraseSample(sample: PointerSample): void {
|
||||
this.options.strokeOutput.addEraseSegment(sample.previousPosition, sample.position);
|
||||
}
|
||||
|
||||
private emitStrokeAudio(sample: PointerSample): void {
|
||||
this.options.audio.stroke({
|
||||
vibe: activeVibe,
|
||||
from: sample.previousPosition,
|
||||
to: sample.position,
|
||||
canvasSize: [this.canvas.width, this.canvas.height],
|
||||
isErasing: this.isErasing,
|
||||
elapsedSeconds: sample.elapsedSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
private getCanvasPointerPosition(event: PointerEvent): vec2 {
|
||||
|
|
@ -210,103 +199,23 @@ export class GardenPointerInput {
|
|||
);
|
||||
}
|
||||
|
||||
private addSmoothedBrushSample(position: vec2): void {
|
||||
const previousSample =
|
||||
this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||
if (
|
||||
previousSample !== undefined &&
|
||||
vec2.squaredDistance(previousSample, position) <=
|
||||
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.smoothedStrokePoints.push(vec2.clone(position));
|
||||
|
||||
if (this.smoothedStrokePoints.length > 3) {
|
||||
this.smoothedStrokePoints.shift();
|
||||
}
|
||||
|
||||
if (this.smoothedStrokePoints.length === 1) {
|
||||
this.addMirroredBrushSegment(position, position);
|
||||
this.lastSmoothedBrushPosition = vec2.clone(position);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.smoothedStrokePoints.length === 2) {
|
||||
const [start, end] = this.smoothedStrokePoints;
|
||||
const midpoint = getMidpoint(start, end);
|
||||
this.addMirroredBrushSegment(start, midpoint);
|
||||
this.lastSmoothedBrushPosition = midpoint;
|
||||
return;
|
||||
}
|
||||
|
||||
const [start, control, end] = this.smoothedStrokePoints;
|
||||
const curveStart = getMidpoint(start, control);
|
||||
const curveEnd = getMidpoint(control, end);
|
||||
this.addQuadraticBrushSegments(curveStart, control, curveEnd);
|
||||
this.lastSmoothedBrushPosition = curveEnd;
|
||||
}
|
||||
|
||||
private addQuadraticBrushSegments(start: vec2, control: vec2, end: vec2): void {
|
||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||
const canvasPixelRatio = getSafePixelRatio(
|
||||
this.options.getCanvasPixelRatio()
|
||||
);
|
||||
const brushRadius = Math.max(
|
||||
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||
(settings.brushSize * canvasPixelRatio) / 2
|
||||
);
|
||||
const segmentSpacing = Math.max(
|
||||
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||
brushRadius * settings.brushCurveSegmentBrushRadiusRatio
|
||||
);
|
||||
const mirrorSegmentCount = Math.max(1, this.options.getMirrorSegmentCount());
|
||||
const curveResolution = getBrushCurveResolution();
|
||||
const maxCurveSegments = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
curveResolution /
|
||||
Math.max(1, mirrorSegmentCount ** settings.brushCurveMirrorResolutionExponent)
|
||||
)
|
||||
);
|
||||
const segmentCount = Math.min(
|
||||
maxCurveSegments,
|
||||
Math.max(1, Math.ceil(curveLength / segmentSpacing))
|
||||
);
|
||||
|
||||
let previousPoint = start;
|
||||
for (let i = 1; i <= segmentCount; i++) {
|
||||
const point = getQuadraticPoint(start, control, end, i / segmentCount);
|
||||
this.addMirroredBrushSegment(previousPoint, point);
|
||||
previousPoint = point;
|
||||
}
|
||||
}
|
||||
|
||||
private addMirroredBrushSegment(from: vec2, to: vec2): void {
|
||||
this.getMirroredStrokeSegments(from, to).forEach((segment) => {
|
||||
this.options.brushPipeline.addSwipeSegment(segment.from, segment.to);
|
||||
private addBrushSegments(segments: Array<StrokeSegment>): void {
|
||||
segments.forEach((segment) => {
|
||||
this.getMirroredSegments(segment.from, segment.to).forEach((mirroredSegment) => {
|
||||
this.options.strokeOutput.addBrushSegment(
|
||||
mirroredSegment.from,
|
||||
mirroredSegment.to
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private finishSmoothedStroke(): void {
|
||||
if (this.isErasing || this.smoothedStrokePoints.length === 0) {
|
||||
private finishBrushStroke(): void {
|
||||
if (this.isErasing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalSample = this.smoothedStrokePoints[this.smoothedStrokePoints.length - 1];
|
||||
if (
|
||||
this.lastSmoothedBrushPosition !== null &&
|
||||
vec2.squaredDistance(this.lastSmoothedBrushPosition, finalSample) >
|
||||
getBrushSmoothingDistanceSquared(this.options.getCanvasPixelRatio())
|
||||
) {
|
||||
this.addMirroredBrushSegment(this.lastSmoothedBrushPosition, finalSample);
|
||||
}
|
||||
}
|
||||
|
||||
private clearSmoothedStroke(): void {
|
||||
this.smoothedStrokePoints.length = 0;
|
||||
this.lastSmoothedBrushPosition = null;
|
||||
this.addBrushSegments(this.brushSmoother.finish());
|
||||
}
|
||||
|
||||
private getCoalescedPointerEvents(event: PointerEvent): Array<PointerEvent> {
|
||||
|
|
@ -326,67 +235,16 @@ export class GardenPointerInput {
|
|||
: [...coalescedEvents, event];
|
||||
}
|
||||
|
||||
private getMirroredStrokeSegments(from: vec2, to: vec2): Array<StrokeSegment> {
|
||||
const segmentCount = this.options.getMirrorSegmentCount();
|
||||
if (segmentCount <= 1) {
|
||||
return [{ from, to }];
|
||||
}
|
||||
|
||||
const center = vec2.fromValues(this.canvas.width / 2, this.canvas.height / 2);
|
||||
const angleStep = (Math.PI * 2) / segmentCount;
|
||||
const segments: Array<StrokeSegment> = [];
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const angle = angleStep * i;
|
||||
segments.push({
|
||||
from: rotatePointAround(from, center, angle),
|
||||
to: rotatePointAround(to, center, angle),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
private getMirroredSegments(from: vec2, to: vec2): Array<StrokeSegment> {
|
||||
return getMirroredStrokeSegments(
|
||||
from,
|
||||
to,
|
||||
vec2.fromValues(this.canvas.width, this.canvas.height),
|
||||
this.options.getMirrorSegmentCount()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rotatePointAround = (point: vec2, center: vec2, angle: number): vec2 => {
|
||||
if (angle === 0) {
|
||||
return point;
|
||||
}
|
||||
|
||||
const offsetX = point[0] - center[0];
|
||||
const offsetY = point[1] - center[1];
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return vec2.fromValues(
|
||||
center[0] + offsetX * cos - offsetY * sin,
|
||||
center[1] + offsetX * sin + offsetY * cos
|
||||
);
|
||||
};
|
||||
|
||||
const getMidpoint = (from: vec2, to: vec2): vec2 =>
|
||||
vec2.fromValues((from[0] + to[0]) / 2, (from[1] + to[1]) / 2);
|
||||
|
||||
const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): vec2 => {
|
||||
const inverseT = 1 - t;
|
||||
return vec2.fromValues(
|
||||
inverseT * inverseT * start[0] + 2 * inverseT * t * control[0] + t * t * end[0],
|
||||
inverseT * inverseT * start[1] + 2 * inverseT * t * control[1] + t * t * end[1]
|
||||
);
|
||||
};
|
||||
|
||||
const getBrushCurveResolution = (): number => {
|
||||
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||
? settings.brushCurveResolution
|
||||
: appConfig.defaultSettings.brushCurveResolution;
|
||||
return Math.max(1, Math.floor(resolution));
|
||||
};
|
||||
|
||||
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
|
||||
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||
? settings.brushSmoothingMinSampleDistance
|
||||
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
|
||||
};
|
||||
|
||||
const isSamePointerSample = (left: PointerEvent, right: PointerEvent): boolean =>
|
||||
left.clientX === right.clientX &&
|
||||
left.clientY === right.clientY &&
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import { activeVibe } from '../settings';
|
||||
import { hexToRgb } from '../utils/hex-to-rgb';
|
||||
import { type VibeId } from '../vibes';
|
||||
import { RenderInputs } from './game-loop-types';
|
||||
|
||||
export class RenderInputCache {
|
||||
private cachedVibeId: VibeId | null = null;
|
||||
private cachedRenderInputs?: RenderInputs;
|
||||
private previousAccentColor = '';
|
||||
|
||||
public invalidate(): void {
|
||||
this.cachedVibeId = null;
|
||||
this.cachedRenderInputs = undefined;
|
||||
}
|
||||
|
||||
public get(): RenderInputs {
|
||||
if (this.cachedRenderInputs && this.cachedVibeId === activeVibe.id) {
|
||||
return this.cachedRenderInputs;
|
||||
}
|
||||
|
||||
this.cachedVibeId = activeVibe.id;
|
||||
this.cachedRenderInputs = {
|
||||
channelColors: activeVibe.colors.map(hexToRgb),
|
||||
backgroundColor: hexToRgb(activeVibe.backgroundColor),
|
||||
};
|
||||
|
||||
return this.cachedRenderInputs;
|
||||
}
|
||||
|
||||
public updateAccentColor(color: [number, number, number]): void {
|
||||
const accentColor = `rgb(${Math.round(color[0] * 255)},${Math.round(
|
||||
color[1] * 255
|
||||
)},${Math.round(color[2] * 255)})`;
|
||||
if (this.previousAccentColor === accentColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousAccentColor = accentColor;
|
||||
document.documentElement.style.setProperty('--accent-color', accentColor);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,8 +53,7 @@ export class SimulationFrameRenderer {
|
|||
this.pipelines.agentPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.influenceMapA.getTextureView()
|
||||
this.textures.trailMapB.getTextureView()
|
||||
);
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
|
|
@ -68,21 +67,25 @@ export class SimulationFrameRenderer {
|
|||
this.textures.sourceMapA.getTextureView()
|
||||
);
|
||||
canvasReadbackRequest?.encode(commandEncoder, canvasTexture);
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
canvasReadbackRequest?.afterSubmit();
|
||||
|
||||
const postRenderCommandEncoder = this.device.createCommandEncoder();
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
postRenderCommandEncoder,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.sourceMapB.getTextureView(),
|
||||
this.textures.sourceMapB.getSize()
|
||||
);
|
||||
this.pipelines.brushEffectDiffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
postRenderCommandEncoder,
|
||||
this.textures.influenceMapA.getTextureView(),
|
||||
this.textures.influenceMapB.getTextureView(),
|
||||
this.textures.influenceMapB.getSize()
|
||||
);
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
canvasReadbackRequest?.afterSubmit();
|
||||
this.device.queue.submit([postRenderCommandEncoder.finish()]);
|
||||
this.textures.swapBrushEffectMaps();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class SimulationTextures {
|
|||
this.sourceMapB = this.createTexture(canvasSize);
|
||||
this.influenceMapA = this.createTexture(canvasSize);
|
||||
this.influenceMapB = this.createTexture(canvasSize);
|
||||
this.eraserMask = this.createTexture(canvasSize);
|
||||
this.eraserMask = this.createEraserMask(canvasSize);
|
||||
}
|
||||
|
||||
public resizeTo(nextSize: vec2): vec2 | null {
|
||||
|
|
@ -97,4 +97,16 @@ export class SimulationTextures {
|
|||
private createTexture(size: vec2): ResizableTexture {
|
||||
return new ResizableTexture(this.device, size);
|
||||
}
|
||||
|
||||
private createEraserMask(size: vec2): ResizableTexture {
|
||||
return new ResizableTexture(this.device, size, {
|
||||
clearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||
format: 'r8unorm',
|
||||
usage:
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.COPY_DST,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/index.ts
11
src/index.ts
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from './utils/error-handler';
|
||||
import { initializeGpu } from './utils/graphics/initialize-gpu';
|
||||
import { clamp01 } from './utils/math';
|
||||
import { rgbColorToCss } from './utils/rgb-color';
|
||||
import { VIBE_PRESETS } from './vibes';
|
||||
|
||||
const AUDIO_VOLUME_STEP = 0.01;
|
||||
|
|
@ -358,7 +359,7 @@ const renderAudioUi = (game: GameLoop | null) => {
|
|||
|
||||
const renderPaletteUi = (game: GameLoop | null) => {
|
||||
elements.swatches.forEach((swatch, index) => {
|
||||
swatch.style.backgroundColor = activeVibe.colors[index];
|
||||
swatch.style.backgroundColor = rgbColorToCss(activeVibe.colors[index]);
|
||||
swatch.classList.toggle(
|
||||
CSS_CLASSES.active,
|
||||
settings.selectedColorIndex === index && !isEraserActive
|
||||
|
|
@ -368,7 +369,7 @@ const renderPaletteUi = (game: GameLoop | null) => {
|
|||
game?.setEraseMode(isEraserActive);
|
||||
document.documentElement.style.setProperty(
|
||||
CSS_VARIABLES.gardenBackground,
|
||||
activeVibe.backgroundColor
|
||||
rgbColorToCss(activeVibe.backgroundColor)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -471,7 +472,7 @@ const main = async () => {
|
|||
new FullScreenHandler(
|
||||
elements.minimizeFullScreenButton,
|
||||
elements.maximizeFullScreenButton,
|
||||
document.body
|
||||
document.documentElement
|
||||
);
|
||||
|
||||
const startAudioFromUserGesture = (event: Event) => {
|
||||
|
|
@ -599,7 +600,7 @@ const main = async () => {
|
|||
|
||||
elements.export4k.disabled = true;
|
||||
try {
|
||||
await game.export4K();
|
||||
await game.exportSnapshot();
|
||||
trackExport({ vibeId: activeVibe.id });
|
||||
} catch (error) {
|
||||
ErrorHandler.addException(error, { severity: Severity.WARNING });
|
||||
|
|
@ -716,7 +717,7 @@ const main = async () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Keep the toolbar/dock hidden until the user actually starts drawing.
|
||||
// Keep the dev stats overlay hidden until the user actually starts drawing.
|
||||
document.body.classList.add(CSS_CLASSES.preDrawing);
|
||||
elements.canvas.addEventListener(
|
||||
DOM_EVENTS.pointerDown,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type NumberControlConfig,
|
||||
} from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { rgbColorToCss } from '../utils/rgb-color';
|
||||
import { isVibeId, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||
|
||||
type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
||||
|
|
@ -55,9 +56,6 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|||
const isBindablePrimitive = (value: unknown): value is boolean | number | string =>
|
||||
['boolean', 'number', 'string'].includes(typeof value);
|
||||
|
||||
const isColorString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);
|
||||
|
||||
const toLabel = (value: string): string =>
|
||||
value
|
||||
.replace(/\[(\d+)\]/g, ' $1')
|
||||
|
|
@ -348,7 +346,7 @@ export class ConfigPane {
|
|||
});
|
||||
|
||||
this.colorReactionSwatches.forEach(({ colorIndex, element }) => {
|
||||
element.style.backgroundColor = activeVibe.colors[colorIndex] ?? '#ffffff';
|
||||
element.style.backgroundColor = rgbColorToCss(activeVibe.colors[colorIndex]);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -367,7 +365,7 @@ export class ConfigPane {
|
|||
): void {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (isBindablePrimitive(value)) {
|
||||
this.addPrimitiveBinding(container, source, key, value);
|
||||
this.addPrimitiveBinding(container, source, key);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -381,8 +379,7 @@ export class ConfigPane {
|
|||
this.addPrimitiveBinding(
|
||||
folder,
|
||||
value as unknown as Record<string, unknown>,
|
||||
`${index}`,
|
||||
item
|
||||
`${index}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -417,12 +414,10 @@ export class ConfigPane {
|
|||
private addPrimitiveBinding(
|
||||
container: PaneContainer,
|
||||
source: Record<string, unknown>,
|
||||
key: string,
|
||||
value: boolean | number | string
|
||||
key: string
|
||||
): void {
|
||||
const params: BindingParams = {
|
||||
label: toLabel(key),
|
||||
...(isColorString(value) ? { color: { type: 'int' } } : {}),
|
||||
...(key === 'quality' ? { options: { major: 'major', minor: 'minor' } } : {}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export class FullScreenHandler {
|
|||
private readonly maximizeButton: HTMLElement,
|
||||
target: HTMLElement
|
||||
) {
|
||||
if (!document.fullscreenEnabled) {
|
||||
if (!document.fullscreenEnabled || typeof target.requestFullscreen !== 'function') {
|
||||
minimizeButton.hidden = true;
|
||||
maximizeButton.hidden = true;
|
||||
return;
|
||||
|
|
@ -13,7 +13,9 @@ export class FullScreenHandler {
|
|||
this.updateButtons();
|
||||
|
||||
addEventListener('fullscreenchange', this.updateButtons.bind(this));
|
||||
maximizeButton.addEventListener('click', () => target.requestFullscreen());
|
||||
maximizeButton.addEventListener('click', () => {
|
||||
void target.requestFullscreen().catch(() => undefined);
|
||||
});
|
||||
minimizeButton.addEventListener('click', () => document.exitFullscreen());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ struct Counters {
|
|||
|
||||
var<workgroup> workgroupAliveCount: atomic<u32>;
|
||||
var<workgroup> workgroupCompactedOffset: u32;
|
||||
|
||||
fn dead_agent() -> Agent {
|
||||
return Agent(vec2<f32>(0.0, 0.0), 0.0, -1.0, vec2<f32>(-1.0, -1.0), 0.0, 0.0);
|
||||
}
|
||||
var<workgroup> clearAliveAgentCount: u32;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
|
|
@ -35,12 +32,12 @@ fn main(
|
|||
workgroupBarrier();
|
||||
|
||||
var localCompactedIndex = 0u;
|
||||
var agent = dead_agent();
|
||||
var agent: Agent;
|
||||
var isAlive = false;
|
||||
if id < settings.agentCount {
|
||||
agent = agents[id];
|
||||
isAlive = agent.colorIndex >= 0.0;
|
||||
isAlive = agents[id].colorIndex >= 0.0;
|
||||
if isAlive {
|
||||
agent = agents[id];
|
||||
localCompactedIndex = atomicAdd(&workgroupAliveCount, 1u);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,16 +63,22 @@ fn main(
|
|||
@compute @workgroup_size(64)
|
||||
fn clearCompactedTail(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>,
|
||||
@builtin(local_invocation_id) local_id: vec3<u32>,
|
||||
@builtin(num_workgroups) num_workgroups: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if local_id.x == 0u {
|
||||
clearAliveAgentCount = atomicLoad(&counters.aliveAgentCount);
|
||||
}
|
||||
|
||||
workgroupBarrier();
|
||||
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
}
|
||||
|
||||
let aliveAgentCount = atomicLoad(&counters.aliveAgentCount);
|
||||
if id >= aliveAgentCount {
|
||||
compactedAgents[id] = dead_agent();
|
||||
if id >= clearAliveAgentCount {
|
||||
compactedAgents[id].colorIndex = -1.0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ export class AgentGenerationPipeline {
|
|||
private readonly agentCountUniformValues = new Uint32Array(
|
||||
AgentGenerationPipeline.UNIFORM_COUNT
|
||||
);
|
||||
private readonly resizeUniformBuffer = new ArrayBuffer(
|
||||
AgentGenerationPipeline.UNIFORM_COUNT * Uint32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
private readonly resizeUniformFloatValues = new Float32Array(
|
||||
this.resizeUniformBuffer
|
||||
);
|
||||
private readonly resizeUniformUintValues = new Uint32Array(
|
||||
this.resizeUniformBuffer
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
|
|
@ -233,11 +242,11 @@ export class AgentGenerationPipeline {
|
|||
return;
|
||||
}
|
||||
|
||||
this.device.queue.writeBuffer(
|
||||
this.uniforms,
|
||||
0,
|
||||
new Float32Array([scale[0], scale[1], agentCount, 0])
|
||||
);
|
||||
this.resizeUniformFloatValues[0] = scale[0];
|
||||
this.resizeUniformFloatValues[1] = scale[1];
|
||||
this.resizeUniformUintValues[2] = Math.max(0, Math.floor(agentCount));
|
||||
this.resizeUniformUintValues[3] = 0;
|
||||
this.device.queue.writeBuffer(this.uniforms, 0, this.resizeUniformBuffer);
|
||||
|
||||
const commandEncoder = this.device.createCommandEncoder();
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
struct ResizeSettings {
|
||||
scale: vec2<f32>,
|
||||
agentCount: f32,
|
||||
padding: f32,
|
||||
agentCount: u32,
|
||||
padding: u32,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> resizeSettings: ResizeSettings;
|
||||
|
|
@ -13,10 +13,11 @@ fn main(
|
|||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if id >= u32(resizeSettings.agentCount) {
|
||||
if id >= resizeSettings.agentCount {
|
||||
return;
|
||||
}
|
||||
|
||||
agents[id].position = agents[id].position * resizeSettings.scale;
|
||||
agents[id].targetPosition = agents[id].targetPosition * resizeSettings.scale;
|
||||
let scale = resizeSettings.scale;
|
||||
agents[id].position = agents[id].position * scale;
|
||||
agents[id].targetPosition = agents[id].targetPosition * scale;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,21 +10,19 @@ import { AgentSettings } from './agent-settings';
|
|||
import shader from './agent.wgsl?raw';
|
||||
|
||||
export class AgentPipeline {
|
||||
private static readonly UNIFORM_COUNT = 33;
|
||||
private static readonly UNIFORM_COUNT = 30;
|
||||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly pipeline: GPUComputePipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly uniformValues = new Float32Array(AgentPipeline.UNIFORM_COUNT);
|
||||
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
AgentPipeline.UNIFORM_COUNT
|
||||
);
|
||||
private readonly bindGroupsByAgentsBuffer = new WeakMap<
|
||||
GPUBuffer,
|
||||
WeakMap<
|
||||
GPUTextureView,
|
||||
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
|
||||
>
|
||||
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
|
||||
>();
|
||||
|
||||
private agentCount = 0;
|
||||
|
|
@ -69,9 +67,6 @@ export class AgentPipeline {
|
|||
color3ToColor1,
|
||||
color3ToColor2,
|
||||
color3ToColor3,
|
||||
sourceAttractionWeight,
|
||||
sourceSlowMoveRate,
|
||||
sourceTrailWeightMultiplier,
|
||||
forwardRotationScale,
|
||||
introNearDistanceInner,
|
||||
introNearDistanceMin,
|
||||
|
|
@ -100,7 +95,7 @@ export class AgentPipeline {
|
|||
this.uniformValues[4] = sensorOffsetDistance;
|
||||
this.uniformValues[5] = turnWhenLost;
|
||||
this.uniformValues[6] = individualTrailWeight;
|
||||
this.uniformValues[7] = agentCount;
|
||||
this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount));
|
||||
this.uniformValues[8] = introProgress ?? 1;
|
||||
this.uniformValues[9] = color1ToColor1;
|
||||
this.uniformValues[10] = color1ToColor2;
|
||||
|
|
@ -111,21 +106,18 @@ export class AgentPipeline {
|
|||
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;
|
||||
this.uniformValues[18] = forwardRotationScale;
|
||||
this.uniformValues[19] = introNearDistanceInner;
|
||||
this.uniformValues[20] = introNearDistanceMin;
|
||||
this.uniformValues[21] = introNearSensorOffsetMultiplier;
|
||||
this.uniformValues[22] = introTargetAngleBlend;
|
||||
this.uniformValues[23] = introProgressCutoff;
|
||||
this.uniformValues[24] = introTurnRateMultiplier;
|
||||
this.uniformValues[25] = introRandomTurnMultiplier;
|
||||
this.uniformValues[26] = introFarMoveMultiplier;
|
||||
this.uniformValues[27] = introNearMoveMultiplier;
|
||||
this.uniformValues[28] = introStepStopDistance;
|
||||
this.uniformValues[29] = randomTimeScale;
|
||||
writeFloat32BufferIfChanged(
|
||||
this.device,
|
||||
this.uniforms,
|
||||
|
|
@ -137,14 +129,13 @@ export class AgentPipeline {
|
|||
public execute(
|
||||
commandEncoder: GPUCommandEncoder,
|
||||
trailMapIn: GPUTextureView,
|
||||
trailMapOut: GPUTextureView,
|
||||
sourceMap: GPUTextureView
|
||||
trailMapOut: GPUTextureView
|
||||
) {
|
||||
if (this.agentCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut, sourceMap);
|
||||
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
|
||||
|
||||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
|
|
@ -156,32 +147,22 @@ export class AgentPipeline {
|
|||
|
||||
private getBindGroup(
|
||||
trailMapIn: GPUTextureView,
|
||||
trailMapOut: GPUTextureView,
|
||||
sourceMap: GPUTextureView
|
||||
trailMapOut: GPUTextureView
|
||||
): GPUBindGroup {
|
||||
const agentsBuffer = this.getAgentsBuffer();
|
||||
let textureCache = this.bindGroupsByAgentsBuffer.get(agentsBuffer);
|
||||
if (!textureCache) {
|
||||
textureCache = new WeakMap<
|
||||
GPUTextureView,
|
||||
WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>
|
||||
>();
|
||||
textureCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
|
||||
this.bindGroupsByAgentsBuffer.set(agentsBuffer, textureCache);
|
||||
}
|
||||
|
||||
let outputCache = textureCache.get(trailMapIn);
|
||||
if (!outputCache) {
|
||||
outputCache = new WeakMap<GPUTextureView, WeakMap<GPUTextureView, GPUBindGroup>>();
|
||||
outputCache = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||
textureCache.set(trailMapIn, outputCache);
|
||||
}
|
||||
|
||||
let sourceCache = outputCache.get(trailMapOut);
|
||||
if (!sourceCache) {
|
||||
sourceCache = new WeakMap<GPUTextureView, GPUBindGroup>();
|
||||
outputCache.set(trailMapOut, sourceCache);
|
||||
}
|
||||
|
||||
const cached = sourceCache.get(sourceMap);
|
||||
const cached = outputCache.get(trailMapOut);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
|
@ -209,14 +190,10 @@ export class AgentPipeline {
|
|||
binding: 3,
|
||||
resource: trailMapOut,
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
resource: sourceMap,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
sourceCache.set(sourceMap, bindGroup);
|
||||
outputCache.set(trailMapOut, bindGroup);
|
||||
return bindGroup;
|
||||
}
|
||||
|
||||
|
|
@ -255,13 +232,6 @@ export class AgentPipeline {
|
|||
format: 'rgba16float',
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 4,
|
||||
visibility: GPUShaderStage.COMPUTE,
|
||||
texture: {
|
||||
sampleType: 'float',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ export interface AgentSettings {
|
|||
sensorOffsetDistance: number;
|
||||
turnWhenLost: number;
|
||||
individualTrailWeight: number;
|
||||
sourceAttractionWeight: number;
|
||||
sourceSlowMoveRate: number;
|
||||
sourceTrailWeightMultiplier: number;
|
||||
forwardRotationScale: number;
|
||||
introNearDistanceMin: number;
|
||||
introNearSensorOffsetMultiplier: number;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ struct Settings {
|
|||
sensorOffset: f32,
|
||||
turnWhenLost: f32,
|
||||
individualTrailWeight: f32,
|
||||
agentCount: f32,
|
||||
agentCount: u32,
|
||||
introProgress: f32,
|
||||
color1ToColor1: f32,
|
||||
color1ToColor2: f32,
|
||||
|
|
@ -17,9 +17,6 @@ struct Settings {
|
|||
color3ToColor1: f32,
|
||||
color3ToColor2: f32,
|
||||
color3ToColor3: f32,
|
||||
sourceAttractionWeight: f32,
|
||||
sourceSlowMoveRate: f32,
|
||||
sourceTrailWeightMultiplier: f32,
|
||||
forwardRotationScale: f32,
|
||||
introNearDistanceInner: f32,
|
||||
introNearDistanceMin: f32,
|
||||
|
|
@ -37,7 +34,6 @@ struct Settings {
|
|||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
||||
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba16float, write>;
|
||||
@group(1) @binding(4) var sourceMap: texture_2d<f32>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(
|
||||
|
|
@ -46,7 +42,7 @@ fn main(
|
|||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if id >= u32(settings.agentCount) {
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -67,63 +63,15 @@ fn main(
|
|||
}
|
||||
}
|
||||
|
||||
let randomSeed = random_seed(id, state.time);
|
||||
let randomTurn = random_float(randomSeed);
|
||||
let direction = vec2(cos(angle), sin(angle));
|
||||
|
||||
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
|
||||
let leftSensor = sensor_position(
|
||||
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 trailLeft = textureLoad(trailMapIn, leftSensor, 0);
|
||||
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
|
||||
let sourceForwardSample = textureLoad(sourceMap, forwardSensor, 0);
|
||||
let sourceLeftSample = textureLoad(sourceMap, leftSensor, 0);
|
||||
let sourceRightSample = textureLoad(sourceMap, rightSensor, 0);
|
||||
|
||||
let channelMask = get_channel_mask(colorIndex);
|
||||
let reactionMask = get_reaction_mask(colorIndex);
|
||||
|
||||
let trailForwardWeight = dot(trailForward.rgb, reactionMask);
|
||||
let trailLeftWeight = dot(trailLeft.rgb, reactionMask);
|
||||
let trailRightWeight = dot(trailRight.rgb, reactionMask);
|
||||
|
||||
let sourceForwardWeight = dot(sourceForwardSample.rgb, reactionMask);
|
||||
let sourceLeftWeight = dot(sourceLeftSample.rgb, reactionMask);
|
||||
let sourceRightWeight = dot(sourceRightSample.rgb, reactionMask);
|
||||
|
||||
let weightForward =
|
||||
trailForwardWeight + sourceForwardWeight * settings.sourceAttractionWeight;
|
||||
let weightLeft = trailLeftWeight + sourceLeftWeight * settings.sourceAttractionWeight;
|
||||
let weightRight =
|
||||
trailRightWeight + sourceRightWeight * settings.sourceAttractionWeight;
|
||||
|
||||
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
|
||||
if weightForward >= weightLeft && weightForward >= weightRight {
|
||||
rotation = rotation * settings.forwardRotationScale;
|
||||
} else {
|
||||
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
||||
}
|
||||
|
||||
let sourceAtAgent = textureLoad(sourceMap, vec2<i32>(position), 0);
|
||||
let positiveReactionMask = max(reactionMask, vec3<f32>(0.0));
|
||||
let sourceAtAgentStrength = clamp(dot(sourceAtAgent.rgb, positiveReactionMask), 0.0, 1.0);
|
||||
var moveRate = settings.moveRate * mix(1.0, settings.sourceSlowMoveRate, sourceAtAgentStrength);
|
||||
var introTargetOffset = vec2<f32>(0.0, 0.0);
|
||||
var introTargetDistance = 0.0;
|
||||
let randomSeed = random_seed(id, state.time);
|
||||
var rotation = 0.0;
|
||||
var step = vec2<f32>(0.0, 0.0);
|
||||
|
||||
if hasIntroTarget {
|
||||
introTargetOffset = targetPosition - position;
|
||||
introTargetDistance = length(introTargetOffset);
|
||||
let introTargetOffset = targetPosition - position;
|
||||
let introTargetDistance = length(introTargetOffset);
|
||||
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
||||
let nearTitle = 1.0 - smoothstep(
|
||||
settings.introNearDistanceInner,
|
||||
|
|
@ -148,19 +96,46 @@ fn main(
|
|||
+ (random_float(randomSeed + 1013904223u) - 0.5) *
|
||||
settings.turnWhenLost *
|
||||
settings.introRandomTurnMultiplier;
|
||||
moveRate = min(
|
||||
let moveRate = min(
|
||||
settings.moveRate *
|
||||
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
|
||||
introTargetDistance
|
||||
);
|
||||
}
|
||||
|
||||
var step = direction * moveRate;
|
||||
if hasIntroTarget {
|
||||
step = vec2<f32>(0.0, 0.0);
|
||||
if introTargetDistance > settings.introStepStopDistance {
|
||||
step = introTargetOffset / introTargetDistance * moveRate;
|
||||
}
|
||||
} else {
|
||||
let randomTurn = random_float(randomSeed);
|
||||
let direction = vec2(cos(angle), sin(angle));
|
||||
|
||||
let forwardSensor = sensor_position(position, direction, settings.sensorOffset);
|
||||
let leftSensor = sensor_position(
|
||||
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 trailLeft = textureLoad(trailMapIn, leftSensor, 0);
|
||||
let trailRight = textureLoad(trailMapIn, rightSensor, 0);
|
||||
|
||||
let weightForward = dot(trailForward.rgb, reactionMask);
|
||||
let weightLeft = dot(trailLeft.rgb, reactionMask);
|
||||
let weightRight = dot(trailRight.rgb, reactionMask);
|
||||
|
||||
rotation = (randomTurn - 0.5) * settings.turnWhenLost;
|
||||
if weightForward >= weightLeft && weightForward >= weightRight {
|
||||
rotation = rotation * settings.forwardRotationScale;
|
||||
} else {
|
||||
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
||||
}
|
||||
|
||||
step = direction * settings.moveRate;
|
||||
}
|
||||
|
||||
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||
|
|
@ -169,14 +144,9 @@ fn main(
|
|||
rotation = 3.14159265359 + random_float(randomSeed + 22695477u) - 0.5;
|
||||
}
|
||||
|
||||
let sourceBelow = textureLoad(sourceMap, vec2<i32>(nextPosition), 0);
|
||||
let sourceBelowStrength = clamp(dot(sourceBelow.rgb, positiveReactionMask), 0.0, 1.0);
|
||||
let trailWeight =
|
||||
settings.individualTrailWeight *
|
||||
(1.0 + sourceBelowStrength * settings.sourceTrailWeightMultiplier);
|
||||
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
||||
trailBelow = vec4<f32>(
|
||||
trailBelow.rgb + channelMask * trailWeight,
|
||||
trailBelow.rgb + channelMask * settings.individualTrailWeight,
|
||||
max(trailBelow.a, 0.0)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,6 @@ export const setBrushUniformValues = (
|
|||
brushSize,
|
||||
brushSizeVariation,
|
||||
brushAlpha,
|
||||
brushFeatherRatio,
|
||||
brushMinimumFeather,
|
||||
brushDiscardThreshold,
|
||||
brushCoarseNoiseScale,
|
||||
brushGrainNoiseScale,
|
||||
|
|
@ -47,30 +45,24 @@ export const setBrushUniformValues = (
|
|||
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
||||
const brushRadius = (brushSize * safePixelRatio) / 2;
|
||||
const brushRadiusVariation = Math.floor(brushRadius * brushSizeVariation);
|
||||
const brushMinimumFeatherPixels = brushMinimumFeather * safePixelRatio;
|
||||
const brushFeather = Math.max(
|
||||
brushMinimumFeatherPixels,
|
||||
brushRadius * brushFeatherRatio
|
||||
);
|
||||
const brushGeometryRadius =
|
||||
brushRadius + Math.max(0, brushRadiusVariation) + brushFeather;
|
||||
const brushGeometryRadius = brushRadius + Math.max(0, brushRadiusVariation);
|
||||
|
||||
target[0] = brushRadius;
|
||||
target[1] = brushRadiusVariation;
|
||||
target[2] = brushFeatherRatio;
|
||||
target[3] = brushMinimumFeatherPixels;
|
||||
target[1] = brushRadiusVariation * 2;
|
||||
target[2] = brushGeometryRadius * brushGeometryRadius;
|
||||
target[3] = brushGeometryRadius;
|
||||
target[4] = selectedColorIndex === 0 ? 1 : 0;
|
||||
target[5] = selectedColorIndex === 1 ? 1 : 0;
|
||||
target[6] = selectedColorIndex === 2 ? 1 : 0;
|
||||
target[7] = brushAlpha;
|
||||
target[8] = brushCoarseNoiseScale * safePixelRatio;
|
||||
target[9] = brushGrainNoiseScale * safePixelRatio;
|
||||
target[8] = 1 / Math.max(Number.EPSILON, brushCoarseNoiseScale * safePixelRatio);
|
||||
target[9] = 1 / Math.max(Number.EPSILON, brushGrainNoiseScale * safePixelRatio);
|
||||
target[10] = brushGrainNoiseOffsetX;
|
||||
target[11] = brushGrainNoiseOffsetY;
|
||||
target[12] = brushDiscardThreshold;
|
||||
target[13] = brushGrainMinStrength;
|
||||
target[14] = brushGrainMaxStrength;
|
||||
target[15] = brushGeometryRadius;
|
||||
target[15] = 0;
|
||||
};
|
||||
|
||||
export class BrushPipeline {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ export interface BrushSettings {
|
|||
brushSize: number;
|
||||
brushSizeVariation: number;
|
||||
brushAlpha: number;
|
||||
brushFeatherRatio: number;
|
||||
brushMinimumFeather: number;
|
||||
brushDiscardThreshold: number;
|
||||
brushCoarseNoiseScale: number;
|
||||
brushGrainNoiseScale: number;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
struct Settings {
|
||||
brushSize: f32,
|
||||
brushSizeVariation: f32,
|
||||
brushFeatherRatio: f32,
|
||||
brushMinimumFeather: f32,
|
||||
brushGeometryRadiusSquared: f32,
|
||||
brushGeometryRadius: f32,
|
||||
brushValue: vec4<f32>,
|
||||
brushCoarseNoiseScale: f32,
|
||||
brushGrainNoiseScale: f32,
|
||||
|
|
@ -11,7 +11,6 @@ struct Settings {
|
|||
brushDiscardThreshold: f32,
|
||||
brushGrainMinStrength: f32,
|
||||
brushGrainMaxStrength: f32,
|
||||
brushGeometryRadius: f32,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
|
|
@ -20,7 +19,8 @@ struct VertexOutput {
|
|||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) end: vec2<f32>
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32,
|
||||
}
|
||||
|
||||
struct BrushTargets {
|
||||
|
|
@ -34,24 +34,36 @@ fn vertex(
|
|||
@location(0) start: vec2<f32>,
|
||||
@location(1) end: vec2<f32>
|
||||
) -> VertexOutput {
|
||||
let direction = end - start;
|
||||
let denominator = dot(direction, direction);
|
||||
var inverseLengthSquared = 0.0;
|
||||
if denominator > 0.0001 {
|
||||
inverseLengthSquared = 1.0 / denominator;
|
||||
}
|
||||
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.brushGeometryRadius);
|
||||
let uv = screenPosition / state.size;
|
||||
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
|
||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragmentMrt(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) end: vec2<f32>
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||
) -> BrushTargets {
|
||||
let distanceSquared = distanceSquaredFromLine(screenPosition, start, end);
|
||||
if distanceSquared > settings.brushGeometryRadius * settings.brushGeometryRadius {
|
||||
let distanceSquared = distanceSquaredFromLine(
|
||||
screenPosition,
|
||||
start,
|
||||
direction,
|
||||
inverseLengthSquared
|
||||
);
|
||||
if distanceSquared > settings.brushGeometryRadiusSquared {
|
||||
discard;
|
||||
}
|
||||
|
||||
let strength = brushStrength(screenPosition, start, end, distanceSquared);
|
||||
let strength = brushStrength(screenPosition, distanceSquared);
|
||||
|
||||
if(strength < settings.brushDiscardThreshold) {
|
||||
discard;
|
||||
|
|
@ -63,27 +75,28 @@ fn fragmentMrt(
|
|||
|
||||
fn brushStrength(
|
||||
screenPosition: vec2<f32>,
|
||||
start: vec2<f32>,
|
||||
end: vec2<f32>,
|
||||
distanceSquared: f32
|
||||
) -> f32 {
|
||||
let distance = sqrt(distanceSquared);
|
||||
let coarseNoise = textureSample(
|
||||
let coarseNoise = textureSampleLevel(
|
||||
noise,
|
||||
noiseSampler,
|
||||
fract(screenPosition / settings.brushCoarseNoiseScale)
|
||||
screenPosition * settings.brushCoarseNoiseScale,
|
||||
0.0
|
||||
).r;
|
||||
let grainNoise = textureSample(
|
||||
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation;
|
||||
let edge = 1.0 - step(radius, distance);
|
||||
if edge * max(settings.brushGrainMinStrength, settings.brushGrainMaxStrength) < settings.brushDiscardThreshold {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let grainNoise = textureSampleLevel(
|
||||
noise,
|
||||
noiseSampler,
|
||||
fract(
|
||||
screenPosition / settings.brushGrainNoiseScale +
|
||||
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY)
|
||||
)
|
||||
screenPosition * settings.brushGrainNoiseScale +
|
||||
vec2(settings.brushGrainNoiseOffsetX, settings.brushGrainNoiseOffsetY),
|
||||
0.0
|
||||
).r;
|
||||
let radius = settings.brushSize + (coarseNoise - 0.5) * settings.brushSizeVariation * 2.0;
|
||||
let feather = max(settings.brushMinimumFeather, settings.brushSize * settings.brushFeatherRatio);
|
||||
let edge = 1.0 - smoothstep(radius - feather, radius + feather, distance);
|
||||
return edge * mix(settings.brushGrainMinStrength, settings.brushGrainMaxStrength, grainNoise);
|
||||
}
|
||||
|
||||
|
|
@ -91,16 +104,19 @@ fn brushOutput(strength: f32) -> vec4<f32> {
|
|||
return vec4(settings.brushValue.rgb * strength, settings.brushValue.a * strength);
|
||||
}
|
||||
|
||||
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
||||
fn distanceSquaredFromLine(
|
||||
position: vec2<f32>,
|
||||
start: vec2<f32>,
|
||||
direction: vec2<f32>,
|
||||
inverseLengthSquared: f32
|
||||
) -> f32 {
|
||||
let pa = position - start;
|
||||
let direction = end - start;
|
||||
let denominator = dot(direction, direction);
|
||||
|
||||
if denominator <= 0.0001 {
|
||||
if inverseLengthSquared <= 0.0 {
|
||||
return dot(pa, pa);
|
||||
}
|
||||
|
||||
let q = clamp(dot(pa, direction) / denominator, 0, 1);
|
||||
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0, 1);
|
||||
let nearestOffset = pa - direction * q;
|
||||
return dot(nearestOffset, nearestOffset);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ export class CommonState {
|
|||
{
|
||||
binding: 1,
|
||||
resource: this.device.createSampler({
|
||||
addressModeU: 'repeat',
|
||||
addressModeV: 'repeat',
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ struct Settings {
|
|||
decayRateTrails: f32,
|
||||
inverseDiffusionRateBrush: f32,
|
||||
decayRateBrush: f32,
|
||||
diffusionNeighborDivisor: f32,
|
||||
brushDecayAlphaOffset: f32,
|
||||
padding0: f32,
|
||||
diffusionNeighborScale: f32,
|
||||
brushDecayAlphaMultiplier: f32,
|
||||
brushDecayAlphaSubtract: f32,
|
||||
padding1: f32,
|
||||
};
|
||||
|
||||
|
|
@ -60,8 +60,26 @@ fn main(
|
|||
let centerTileIndex = centerTilePosition.y * TILE_SIZE_X + centerTilePosition.x;
|
||||
var current = tile[centerTileIndex];
|
||||
let random = random_from_pixel(pixel);
|
||||
let trailWeight = diffusion_weight(random, settings.inverseDiffusionRateTrails);
|
||||
let brushWeight = diffusion_weight(random, settings.inverseDiffusionRateBrush);
|
||||
let r2 = random * random;
|
||||
let r4 = r2 * r2;
|
||||
let r8 = r4 * r4;
|
||||
let r16 = r8 * r8;
|
||||
let trailWeight = diffusion_weight(
|
||||
random,
|
||||
r2,
|
||||
r4,
|
||||
r8,
|
||||
r16,
|
||||
settings.inverseDiffusionRateTrails
|
||||
);
|
||||
let brushWeight = diffusion_weight(
|
||||
random,
|
||||
r2,
|
||||
r4,
|
||||
r8,
|
||||
r16,
|
||||
settings.inverseDiffusionRateBrush
|
||||
);
|
||||
|
||||
current += (
|
||||
propagate(centerTileIndex, -1, -1, current, trailWeight, brushWeight)
|
||||
|
|
@ -73,11 +91,11 @@ fn main(
|
|||
+ propagate(centerTileIndex, 0, -1, current, trailWeight, brushWeight)
|
||||
+ propagate(centerTileIndex, 1, 0, current, trailWeight, brushWeight)
|
||||
+ propagate(centerTileIndex, 0, 1, current, trailWeight, brushWeight)
|
||||
) / max(1.0, settings.diffusionNeighborDivisor);
|
||||
) * settings.diffusionNeighborScale;
|
||||
|
||||
let decayed = clamp(vec4(
|
||||
current.rgb * settings.decayRateTrails,
|
||||
max(0, current.a + (current.a - settings.brushDecayAlphaOffset) * settings.decayRateBrush)
|
||||
max(0, current.a * settings.brushDecayAlphaMultiplier - settings.brushDecayAlphaSubtract)
|
||||
), vec4(0), vec4(1));
|
||||
|
||||
textureStore(trailMapOut, pixel, decayed);
|
||||
|
|
@ -110,9 +128,14 @@ fn random_from_pixel(pixel: vec2<i32>) -> f32 {
|
|||
return f32(hash) * 2.3283064365386963e-10;
|
||||
}
|
||||
|
||||
fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
|
||||
let r = clamp(random, 0.0, 1.0);
|
||||
|
||||
fn diffusion_weight(
|
||||
r: f32,
|
||||
r2: f32,
|
||||
r4: f32,
|
||||
r8: f32,
|
||||
r16: f32,
|
||||
inverseRate: f32
|
||||
) -> f32 {
|
||||
if inverseRate < 1.0 {
|
||||
let rootApproximation = r / max(0.5 + r * 0.5, 0.0001);
|
||||
return mix(
|
||||
|
|
@ -122,22 +145,18 @@ fn diffusion_weight(random: f32, inverseRate: f32) -> f32 {
|
|||
);
|
||||
}
|
||||
|
||||
let r2 = r * r;
|
||||
if inverseRate < 2.0 {
|
||||
return mix(r, r2, inverseRate - 1.0);
|
||||
}
|
||||
|
||||
let r4 = r2 * r2;
|
||||
if inverseRate < 4.0 {
|
||||
return mix(r2, r4, (inverseRate - 2.0) * 0.5);
|
||||
}
|
||||
|
||||
let r8 = r4 * r4;
|
||||
if inverseRate < 8.0 {
|
||||
return mix(r4, r8, (inverseRate - 4.0) * 0.25);
|
||||
}
|
||||
|
||||
let r16 = r8 * r8;
|
||||
return mix(r8, r16, clamp((inverseRate - 8.0) * 0.125, 0.0, 1.0))
|
||||
* min(1.0, 16.0 / inverseRate);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,12 +41,18 @@ export const setDiffusionUniformValues = (
|
|||
}: DiffusionUniformSettings
|
||||
): void => {
|
||||
const decayDivisor = Math.max(Number.EPSILON, diffusionDecayRateDivisor);
|
||||
const brushDecayRate = decayRateBrush / decayDivisor;
|
||||
const neighborDivisor = Number.isFinite(diffusionNeighborDivisor)
|
||||
? Math.max(1, diffusionNeighborDivisor)
|
||||
: 1;
|
||||
target[0] = getSafeInverseDiffusionRate(diffusionRateTrails);
|
||||
target[1] = decayRateTrails / decayDivisor;
|
||||
target[2] = getSafeInverseDiffusionRate(diffusionRateBrush);
|
||||
target[3] = decayRateBrush / decayDivisor;
|
||||
target[4] = diffusionNeighborDivisor;
|
||||
target[5] = brushDecayAlphaOffset;
|
||||
target[3] = brushDecayRate;
|
||||
target[4] = 1 / neighborDivisor;
|
||||
target[5] = 1 + brushDecayRate;
|
||||
target[6] = brushDecayAlphaOffset * brushDecayRate;
|
||||
target[7] = 0;
|
||||
};
|
||||
|
||||
export class DiffusionPipeline {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export class EraserAgentPipeline {
|
|||
private readonly pipeline: GPUComputePipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly uniformValues = new Float32Array(EraserAgentPipeline.UNIFORM_COUNT);
|
||||
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
EraserAgentPipeline.UNIFORM_COUNT
|
||||
);
|
||||
|
|
@ -93,7 +94,7 @@ export class EraserAgentPipeline {
|
|||
this.activeSegmentCount = this.pendingSegmentCount;
|
||||
this.pendingSegmentCount = 0;
|
||||
|
||||
this.uniformValues[0] = agentCount;
|
||||
this.uniformUintValues[0] = Math.max(0, Math.floor(agentCount));
|
||||
this.uniformValues[1] = eraserMaskAlphaThreshold;
|
||||
this.uniformValues[2] = 0;
|
||||
this.uniformValues[3] = 0;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
struct Settings {
|
||||
agentCount: f32,
|
||||
agentCount: u32,
|
||||
eraserMaskAlphaThreshold: f32,
|
||||
padding1: f32,
|
||||
padding2: f32,
|
||||
|
|
@ -15,7 +15,7 @@ fn main(
|
|||
) {
|
||||
let id = get_id(global_id, num_workgroups);
|
||||
|
||||
if id >= u32(settings.agentCount) {
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ fn main(
|
|||
);
|
||||
let maskSample = textureLoad(eraserMask, maskPosition, 0);
|
||||
|
||||
if maskSample.a < settings.eraserMaskAlphaThreshold {
|
||||
if maskSample.r < settings.eraserMaskAlphaThreshold {
|
||||
agents[id].colorIndex = -1.0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,12 @@ export class EraserTexturePipeline {
|
|||
});
|
||||
|
||||
const shaderModule = smartCompile(device, CommonState.shaderCode, shader);
|
||||
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', 4);
|
||||
this.combinedPipeline = this.createPipeline(shaderModule, 'fragmentCombined', [
|
||||
'r8unorm',
|
||||
'rgba16float',
|
||||
'rgba16float',
|
||||
'rgba16float',
|
||||
]);
|
||||
|
||||
this.uniforms = this.device.createBuffer({
|
||||
size: EraserTexturePipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
|
|
@ -119,7 +124,7 @@ export class EraserTexturePipeline {
|
|||
this.uniformValues[3] = eraserClearGreen;
|
||||
this.uniformValues[4] = eraserClearBlue;
|
||||
this.uniformValues[5] = eraserClearAlpha;
|
||||
this.uniformValues[6] = 0;
|
||||
this.uniformValues[6] = eraserRadius;
|
||||
this.uniformValues[7] = 0;
|
||||
writeFloat32BufferIfChanged(
|
||||
this.device,
|
||||
|
|
@ -225,7 +230,7 @@ export class EraserTexturePipeline {
|
|||
private createPipeline(
|
||||
shaderModule: GPUShaderModule,
|
||||
fragmentEntryPoint: string,
|
||||
colorTargetCount: number
|
||||
targetFormats: Array<GPUTextureFormat>
|
||||
): GPURenderPipeline {
|
||||
return this.device.createRenderPipeline({
|
||||
layout: this.device.createPipelineLayout({
|
||||
|
|
@ -258,9 +263,7 @@ export class EraserTexturePipeline {
|
|||
fragment: {
|
||||
module: shaderModule,
|
||||
entryPoint: fragmentEntryPoint,
|
||||
targets: Array.from({ length: colorTargetCount }, () => ({
|
||||
format: 'rgba16float' as const,
|
||||
})),
|
||||
targets: targetFormats.map((format) => ({ format })),
|
||||
},
|
||||
primitive: {
|
||||
topology: 'triangle-list',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ struct Settings {
|
|||
clearGreen: f32,
|
||||
clearBlue: f32,
|
||||
clearAlpha: f32,
|
||||
padding0: f32,
|
||||
eraserRadius: f32,
|
||||
padding1: f32,
|
||||
};
|
||||
|
||||
|
|
@ -15,7 +15,8 @@ struct VertexOutput {
|
|||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) end: vec2<f32>
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32,
|
||||
}
|
||||
|
||||
struct EraserTextureTargets {
|
||||
|
|
@ -37,19 +38,26 @@ fn vertex(
|
|||
@location(0) start: vec2<f32>,
|
||||
@location(1) end: vec2<f32>
|
||||
) -> VertexOutput {
|
||||
let screenPosition = segment_vertex_position(vertexIndex, start, end, sqrt(settings.eraserRadiusSquared));
|
||||
let direction = end - start;
|
||||
let denominator = dot(direction, direction);
|
||||
var inverseLengthSquared = 0.0;
|
||||
if denominator > settings.lineDistanceEpsilon {
|
||||
inverseLengthSquared = 1.0 / denominator;
|
||||
}
|
||||
let screenPosition = segment_vertex_position(vertexIndex, start, end, settings.eraserRadius);
|
||||
let uv = screenPosition / state.size;
|
||||
let position = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, end);
|
||||
return VertexOutput(vec4(position, 0.0, 1.0), screenPosition, start, direction, inverseLengthSquared);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) end: vec2<f32>
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||
) -> @location(0) vec4<f32> {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
|
||||
discard;
|
||||
}
|
||||
|
||||
|
|
@ -60,9 +68,10 @@ fn fragment(
|
|||
fn fragmentMrt(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) end: vec2<f32>
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||
) -> EraserTextureTargets {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
|
||||
discard;
|
||||
}
|
||||
|
||||
|
|
@ -74,14 +83,19 @@ fn fragmentMrt(
|
|||
fn fragmentCombined(
|
||||
@location(0) screenPosition: vec2<f32>,
|
||||
@location(1) @interpolate(flat) start: vec2<f32>,
|
||||
@location(2) @interpolate(flat) end: vec2<f32>
|
||||
@location(2) @interpolate(flat) direction: vec2<f32>,
|
||||
@location(3) @interpolate(flat) inverseLengthSquared: f32
|
||||
) -> EraserCombinedTargets {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, end) {
|
||||
if shouldDiscardEraserFragment(screenPosition, start, direction, inverseLengthSquared) {
|
||||
discard;
|
||||
}
|
||||
|
||||
let cleared = getEraserClearValue();
|
||||
return EraserCombinedTargets(cleared, cleared, cleared, cleared);
|
||||
return EraserCombinedTargets(getEraserMaskValue(), cleared, cleared, cleared);
|
||||
}
|
||||
|
||||
fn getEraserMaskValue() -> vec4<f32> {
|
||||
return vec4<f32>(settings.clearAlpha, 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn getEraserClearValue() -> vec4<f32> {
|
||||
|
|
@ -96,21 +110,25 @@ fn getEraserClearValue() -> vec4<f32> {
|
|||
fn shouldDiscardEraserFragment(
|
||||
screenPosition: vec2<f32>,
|
||||
start: vec2<f32>,
|
||||
end: vec2<f32>
|
||||
direction: vec2<f32>,
|
||||
inverseLengthSquared: f32
|
||||
) -> bool {
|
||||
return distanceSquaredFromLine(screenPosition, start, end) > settings.eraserRadiusSquared;
|
||||
return distanceSquaredFromLine(screenPosition, start, direction, inverseLengthSquared) > settings.eraserRadiusSquared;
|
||||
}
|
||||
|
||||
fn distanceSquaredFromLine(position: vec2<f32>, start: vec2<f32>, end: vec2<f32>) -> f32 {
|
||||
fn distanceSquaredFromLine(
|
||||
position: vec2<f32>,
|
||||
start: vec2<f32>,
|
||||
direction: vec2<f32>,
|
||||
inverseLengthSquared: f32
|
||||
) -> f32 {
|
||||
let pa = position - start;
|
||||
let direction = end - start;
|
||||
let denominator = dot(direction, direction);
|
||||
|
||||
if denominator <= settings.lineDistanceEpsilon {
|
||||
if inverseLengthSquared <= 0.0 {
|
||||
return dot(pa, pa);
|
||||
}
|
||||
|
||||
let q = clamp(dot(pa, direction) / denominator, 0.0, 1.0);
|
||||
let q = clamp(dot(pa, direction) * inverseLengthSquared, 0.0, 1.0);
|
||||
let nearestOffset = pa - direction * q;
|
||||
return dot(nearestOffset, nearestOffset);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
} from '../../utils/graphics/cached-buffer-write';
|
||||
import { setUpFullScreenQuad } from '../../utils/graphics/full-screen-quad';
|
||||
import { smartCompile } from '../../utils/graphics/smart-compile';
|
||||
import { rgbChannelToUnit, type RgbColor } from '../../utils/rgb-color';
|
||||
import { CommonState } from '../common-state/common-state';
|
||||
import { RenderSettings } from './render-settings';
|
||||
import shader from './render.wgsl?raw';
|
||||
|
|
@ -13,8 +14,6 @@ export class RenderPipeline {
|
|||
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly pipeline: GPURenderPipeline;
|
||||
private readonly sampledPipeline: GPURenderPipeline;
|
||||
private readonly sampler: GPUSampler;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly uniformValues = new Float32Array(RenderPipeline.UNIFORM_COUNT);
|
||||
private readonly uniformCache = createCachedFloat32BufferWrite(
|
||||
|
|
@ -35,14 +34,8 @@ export class RenderPipeline {
|
|||
|
||||
const vertex = setUpFullScreenQuad(device);
|
||||
|
||||
this.sampler = device.createSampler({
|
||||
magFilter: 'linear',
|
||||
minFilter: 'linear',
|
||||
});
|
||||
|
||||
const format = navigator.gpu.getPreferredCanvasFormat();
|
||||
this.pipeline = this.createPipeline(format, vertex, 'fragment');
|
||||
this.sampledPipeline = this.createPipeline(format, vertex, 'fragmentSampled');
|
||||
|
||||
this.uniforms = this.device.createBuffer({
|
||||
size: RenderPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
|
|
@ -84,25 +77,25 @@ export class RenderPipeline {
|
|||
renderBrushColorStrengthMultiplier,
|
||||
backgroundGrainStrength,
|
||||
}: RenderSettings & {
|
||||
channelColors: Array<[number, number, number]>;
|
||||
backgroundColor: [number, number, number];
|
||||
channelColors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
}) {
|
||||
const [a, b, c] = channelColors;
|
||||
this.uniformValues[0] = a[0];
|
||||
this.uniformValues[1] = a[1];
|
||||
this.uniformValues[2] = a[2];
|
||||
this.uniformValues[0] = rgbChannelToUnit(a[0]);
|
||||
this.uniformValues[1] = rgbChannelToUnit(a[1]);
|
||||
this.uniformValues[2] = rgbChannelToUnit(a[2]);
|
||||
this.uniformValues[3] = 0;
|
||||
this.uniformValues[4] = b[0];
|
||||
this.uniformValues[5] = b[1];
|
||||
this.uniformValues[6] = b[2];
|
||||
this.uniformValues[4] = rgbChannelToUnit(b[0]);
|
||||
this.uniformValues[5] = rgbChannelToUnit(b[1]);
|
||||
this.uniformValues[6] = rgbChannelToUnit(b[2]);
|
||||
this.uniformValues[7] = 0;
|
||||
this.uniformValues[8] = c[0];
|
||||
this.uniformValues[9] = c[1];
|
||||
this.uniformValues[10] = c[2];
|
||||
this.uniformValues[8] = rgbChannelToUnit(c[0]);
|
||||
this.uniformValues[9] = rgbChannelToUnit(c[1]);
|
||||
this.uniformValues[10] = rgbChannelToUnit(c[2]);
|
||||
this.uniformValues[11] = 0;
|
||||
this.uniformValues[12] = backgroundColor[0];
|
||||
this.uniformValues[13] = backgroundColor[1];
|
||||
this.uniformValues[14] = backgroundColor[2];
|
||||
this.uniformValues[12] = rgbChannelToUnit(backgroundColor[0]);
|
||||
this.uniformValues[13] = rgbChannelToUnit(backgroundColor[1]);
|
||||
this.uniformValues[14] = rgbChannelToUnit(backgroundColor[2]);
|
||||
this.uniformValues[15] = clarity;
|
||||
this.uniformValues[16] = renderTraceNormalizationFloor;
|
||||
this.uniformValues[17] = renderBrushColorBase;
|
||||
|
|
@ -162,7 +155,7 @@ export class RenderPipeline {
|
|||
},
|
||||
],
|
||||
});
|
||||
passEncoder.setPipeline(this.sampledPipeline);
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(1, bindGroup);
|
||||
passEncoder.draw(3, 1);
|
||||
|
|
@ -193,10 +186,6 @@ export class RenderPipeline {
|
|||
buffer: this.uniforms,
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
resource: this.sampler,
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
resource: colorTexture,
|
||||
|
|
@ -226,13 +215,6 @@ export class RenderPipeline {
|
|||
type: 'uniform',
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: GPUShaderStage.FRAGMENT,
|
||||
sampler: {
|
||||
type: 'filtering',
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: GPUShaderStage.FRAGMENT,
|
||||
|
|
|
|||
|
|
@ -14,27 +14,17 @@ struct Settings {
|
|||
};
|
||||
|
||||
@group(1) @binding(0) var<uniform> settings: Settings;
|
||||
@group(1) @binding(1) var Sampler: sampler;
|
||||
@group(1) @binding(2) var trailMap: texture_2d<f32>;
|
||||
@group(1) @binding(3) var sourceMap: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
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));
|
||||
}
|
||||
const NOISE_TEXTURE_MASK = 2047u;
|
||||
|
||||
@fragment
|
||||
fn fragmentSampled(
|
||||
@location(0) uv: vec2<f32>,
|
||||
@builtin(position) position: vec4<f32>
|
||||
) -> @location(0) vec4<f32> {
|
||||
let traces = textureSample(trailMap, Sampler, uv);
|
||||
let sources = textureSample(sourceMap, Sampler, uv);
|
||||
return renderColor(traces, sources, vec2<i32>(position.xy));
|
||||
fn fragment(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let pixel = vec2<i32>(position.xy);
|
||||
let traces = textureLoad(trailMap, pixel, 0);
|
||||
let sources = textureLoad(sourceMap, pixel, 0);
|
||||
return renderColor(traces, sources, pixel);
|
||||
}
|
||||
|
||||
fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<f32> {
|
||||
|
|
@ -48,6 +38,16 @@ fn renderColor(traces: vec4<f32>, sources: vec4<f32>, pixel: vec2<i32>) -> vec4<
|
|||
clarity(traces.g),
|
||||
clarity(traces.b)
|
||||
);
|
||||
if max(max(sources.r, sources.g), sources.b) <= 0.0 {
|
||||
let traceColor =
|
||||
traceStrengths.r * settings.colorA
|
||||
+ traceStrengths.g * settings.colorB
|
||||
+ traceStrengths.b * settings.colorC;
|
||||
let normalizedTraceColor = normalizeColorIntensity(traceColor);
|
||||
let traceStrength = max(max(traceStrengths.r, traceStrengths.g), traceStrengths.b);
|
||||
return vec4(mix(background, clamp(normalizedTraceColor, vec3(0), vec3(1)), traceStrength), 1);
|
||||
}
|
||||
|
||||
let sourceStrengths = vec3(
|
||||
clarity(sources.r),
|
||||
clarity(sources.g),
|
||||
|
|
@ -89,8 +89,7 @@ fn normalizeColorIntensity(color: vec3<f32>) -> vec3<f32> {
|
|||
}
|
||||
|
||||
fn getTexturedBackground(pixel: vec2<i32>) -> vec3<f32> {
|
||||
let noiseSize = vec2<i32>(textureDimensions(noise, 0));
|
||||
let noiseCoord = pixel % noiseSize;
|
||||
let noiseCoord = vec2<i32>(vec2<u32>(pixel) & vec2<u32>(NOISE_TEXTURE_MASK));
|
||||
let grain = textureLoad(noise, noiseCoord, 0).r - 0.5;
|
||||
|
||||
return clamp(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export const applyVibeSettings = (vibe: VibePreset) => {
|
|||
Object.assign(settings, {
|
||||
...buildSettings(vibe),
|
||||
eraserSize: settings.eraserSize,
|
||||
adaptiveCapInitial: settings.adaptiveCapInitial,
|
||||
adaptiveCapMin: settings.adaptiveCapMin,
|
||||
internalRenderAreaMegapixels: settings.internalRenderAreaMegapixels,
|
||||
maxAgentCount: settings.maxAgentCount,
|
||||
mirrorSegmentCount: settings.mirrorSegmentCount,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
html > body.pre-drawing .dev-stats-overlay {
|
||||
html > body.pre-drawing .dev-stats-overlay,
|
||||
html > body.is-loading .dev-stats-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,4 +155,3 @@ html > body.is-loading {
|
|||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,13 @@ $toolbar-icons: (
|
|||
html > body > aside.control-dock > .toolbar-row {
|
||||
--toolbar-background-opacity: 0%;
|
||||
--toolbar-background-strength: 0;
|
||||
--toolbar-divider-space: clamp(6px, 1.8vw, 14px);
|
||||
--toolbar-top-max-width: 594px;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'previous controls next'
|
||||
'previous divider next'
|
||||
'previous buttons next';
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
|
|
@ -51,7 +54,7 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
margin: 0 auto;
|
||||
padding-inline: clamp(8px, 1.4vw, 14px);
|
||||
column-gap: 0;
|
||||
row-gap: clamp(6px, 1.8vw, 14px);
|
||||
row-gap: 0;
|
||||
border-radius: 12px;
|
||||
color: rgb(245 250 244 / 92%);
|
||||
background-color: rgb(5 8 13 / var(--toolbar-background-opacity));
|
||||
|
|
@ -70,6 +73,17 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
background-color var(--transition-time-long),
|
||||
box-shadow var(--transition-time-long);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
grid-area: divider;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: min(100%, var(--toolbar-top-max-width));
|
||||
height: 1px;
|
||||
margin-block: var(--toolbar-divider-space);
|
||||
background: rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
|
|
@ -103,7 +117,7 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-self: center;
|
||||
width: min(100%, max-content);
|
||||
width: min(100%, var(--toolbar-top-max-width));
|
||||
min-width: 0;
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
|
@ -167,12 +181,12 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
justify-self: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid rgb(255 255 255 / 12%);
|
||||
|
||||
> button,
|
||||
> .audio-control > button {
|
||||
|
|
@ -571,10 +585,17 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
}
|
||||
|
||||
@include on-small-screen {
|
||||
--toolbar-divider-space: 4px;
|
||||
--toolbar-top-max-width: 329px;
|
||||
|
||||
grid-template-areas:
|
||||
'previous controls next'
|
||||
'. divider .'
|
||||
'buttons buttons buttons';
|
||||
width: 100%;
|
||||
padding-inline: 4px;
|
||||
column-gap: 0;
|
||||
row-gap: 4px;
|
||||
row-gap: 0;
|
||||
|
||||
> .vibe-button {
|
||||
width: 36px;
|
||||
|
|
@ -591,8 +612,12 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
}
|
||||
|
||||
> nav.buttons {
|
||||
justify-self: stretch;
|
||||
justify-content: space-between;
|
||||
gap: clamp(1px, 0.55vw, 2px);
|
||||
padding-top: 3px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
margin-inline: -4px;
|
||||
|
||||
> button {
|
||||
width: auto;
|
||||
|
|
@ -643,13 +668,18 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
justify-content: stretch;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 54px;
|
||||
min-height: 48px;
|
||||
flex: 1 1 100%;
|
||||
padding: 4px 6px;
|
||||
column-gap: 7px;
|
||||
row-gap: 8px;
|
||||
padding: 3px 5px;
|
||||
column-gap: 6px;
|
||||
row-gap: 6px;
|
||||
|
||||
> .color-swatch {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
min-width: 38px;
|
||||
min-height: 38px;
|
||||
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
|
|
@ -658,8 +688,8 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
justify-self: stretch;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
padding: 0 8px;
|
||||
height: 38px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
> .eraser-size-control {
|
||||
|
|
@ -667,8 +697,8 @@ html > body > aside.control-dock > .toolbar-row {
|
|||
}
|
||||
|
||||
> .mirror-segment-control {
|
||||
--thumb-height: 38px;
|
||||
--thumb-width: 38px;
|
||||
--thumb-height: 34px;
|
||||
--thumb-width: 34px;
|
||||
|
||||
grid-column: 4 / span 3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
:root {
|
||||
--transition-time: 200ms;
|
||||
--transition-time-long: 350ms;
|
||||
--accent-color: rgb(6.39851188659668, 70.28645324707031, 102.23043060302734);
|
||||
--accent-color: rgb(255, 93, 162);
|
||||
--main-color: #aaa;
|
||||
--normal-margin: 2rem;
|
||||
--small-margin: 1rem;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,32 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
interface ResizableTextureOptions {
|
||||
clearValue?: GPUColor;
|
||||
format?: GPUTextureFormat;
|
||||
usage?: GPUTextureUsageFlags;
|
||||
}
|
||||
|
||||
export class ResizableTexture {
|
||||
private texture: GPUTexture;
|
||||
private textureView: GPUTextureView;
|
||||
private size: vec2;
|
||||
private readonly clearValue: GPUColor;
|
||||
private readonly format: GPUTextureFormat;
|
||||
private readonly usage: GPUTextureUsageFlags;
|
||||
|
||||
public constructor(
|
||||
private readonly device: GPUDevice,
|
||||
size: vec2
|
||||
size: vec2,
|
||||
{
|
||||
clearValue = { r: 0, g: 0, b: 0, a: 0 },
|
||||
format = 'rgba16float',
|
||||
usage = defaultTextureUsage,
|
||||
}: ResizableTextureOptions = {}
|
||||
) {
|
||||
this.size = vec2.clone(size);
|
||||
this.clearValue = clearValue;
|
||||
this.format = format;
|
||||
this.usage = usage;
|
||||
this.texture = this.createTexture(size);
|
||||
this.textureView = this.texture.createView();
|
||||
}
|
||||
|
|
@ -31,7 +48,7 @@ export class ResizableTexture {
|
|||
colorAttachments: [
|
||||
{
|
||||
view: newTextureView,
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||
clearValue: this.clearValue,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
},
|
||||
|
|
@ -69,14 +86,16 @@ export class ResizableTexture {
|
|||
|
||||
private createTexture(size: vec2): GPUTexture {
|
||||
return this.device.createTexture({
|
||||
format: 'rgba16float',
|
||||
format: this.format,
|
||||
size: { width: size[0], height: size[1] },
|
||||
usage:
|
||||
GPUTextureUsage.STORAGE_BINDING |
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.COPY_DST,
|
||||
usage: this.usage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTextureUsage =
|
||||
GPUTextureUsage.STORAGE_BINDING |
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_SRC |
|
||||
GPUTextureUsage.COPY_DST;
|
||||
|
|
|
|||
|
|
@ -10,20 +10,25 @@ export const smartCompile = (
|
|||
code: concatenated,
|
||||
});
|
||||
|
||||
module.getCompilationInfo().then((info) =>
|
||||
info.messages.forEach((message) =>
|
||||
module.getCompilationInfo().then((info) => {
|
||||
if (info.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = concatenated.split('\n');
|
||||
info.messages.forEach((message) => {
|
||||
const sourceLine = lines[message.lineNum - 1] ?? '';
|
||||
const fullSource = import.meta.env.DEV ? `\n\nCode:\n${concatenated}\n` : '';
|
||||
ErrorHandler.addError(
|
||||
{
|
||||
info: Severity.INFO,
|
||||
warning: Severity.WARNING,
|
||||
error: Severity.ERROR,
|
||||
}[message.type],
|
||||
`${message.message}\n${
|
||||
concatenated.split('\n')[message.lineNum - 1]
|
||||
}\n\nCode:\n${concatenated}\n`
|
||||
)
|
||||
)
|
||||
);
|
||||
`${message.message}\n${sourceLine}${fullSource}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return module;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
const HEX_COLOR_PATTERN =
|
||||
/^#?(?<red>[0-9a-f]{2})(?<green>[0-9a-f]{2})(?<blue>[0-9a-f]{2})$/i;
|
||||
|
||||
export const hexToRgb = (hex: string): [number, number, number] => {
|
||||
const match = HEX_COLOR_PATTERN.exec(hex);
|
||||
if (!match?.groups) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const { red, green, blue } = match.groups;
|
||||
return [parseInt(red, 16) / 255, parseInt(green, 16) / 255, parseInt(blue, 16) / 255];
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue