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