Getting there
This commit is contained in:
parent
ed5a4379db
commit
f300dbd394
43 changed files with 1218 additions and 464 deletions
|
|
@ -138,6 +138,22 @@ test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
|||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('syncs the selected vibe with the URI', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/?vibe=Bone%20Archive');
|
||||
|
||||
await expect(page).toHaveURL(/vibe=bone-archive/);
|
||||
|
||||
await page.locator('.next-vibe').click();
|
||||
await expect(page).toHaveURL(/vibe=pelagic-caustics/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/vibe=bone-archive/);
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
await page.goto('/');
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
"@eslint/js": "^10.0.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tweakpane/core": "^2.0.5",
|
||||
"@tweakpane/core": "~2.0.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
|
|
@ -71,6 +71,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "^0.4.5",
|
||||
"tweakpane": "^4.0.5"
|
||||
"tweakpane": "~4.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 301 KiB |
|
|
@ -1,9 +1,11 @@
|
|||
import { DEFAULT_AUDIO_VOLUME } from '../consts';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
rootOffset: number;
|
||||
quality: 'major' | 'minor';
|
||||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeSettings {
|
||||
|
|
@ -14,6 +16,8 @@ export interface GardenAudioVibeSettings {
|
|||
noteLength: number;
|
||||
notePitchOffset: number;
|
||||
brightness: number;
|
||||
scale?: Array<number>;
|
||||
progression?: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||
|
|
@ -37,7 +41,9 @@ export const createGardenAudioConfig = () => ({
|
|||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
delay: {
|
||||
timeSeconds: 0.405,
|
||||
timeBeats: 0.5,
|
||||
timeMinSeconds: 0.18,
|
||||
timeMaxSeconds: 0.72,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
erasingActivity: 0.12,
|
||||
|
|
|
|||
|
|
@ -121,19 +121,19 @@ export class GardenAudioGraph {
|
|||
);
|
||||
}
|
||||
|
||||
public applyDelayProfile(): void {
|
||||
public applyDelayProfile(bpm: number): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds,
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
this.context.currentTime,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(activity: number): void {
|
||||
public updateDelay(activity: number, bpm: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -141,7 +141,7 @@ export class GardenAudioGraph {
|
|||
const now = this.context.currentTime;
|
||||
const normalizedActivity = clamp(activity, 0, 1);
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds,
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
now,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
|
|
@ -214,7 +214,7 @@ export class GardenAudioGraph {
|
|||
const feedbackLowPass = context.createBiquadFilter();
|
||||
const returnLowPass = context.createBiquadFilter();
|
||||
|
||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
||||
delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
|
||||
delayFeedback.gain.value = this.config.delay.feedback;
|
||||
delayOutput.gain.value = this.config.delay.wetGain;
|
||||
feedbackHighPass.type = 'highpass';
|
||||
|
|
@ -283,6 +283,15 @@ export class GardenAudioGraph {
|
|||
});
|
||||
}
|
||||
|
||||
private getDelayTimeSecondsForBpm(bpm: number): number {
|
||||
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
|
||||
return clamp(
|
||||
(60 / safeBpm) * this.config.delay.timeBeats,
|
||||
this.config.delay.timeMinSeconds,
|
||||
this.config.delay.timeMaxSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -15,14 +15,24 @@ const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
|||
|
||||
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
|
||||
|
||||
const getProfileScale = (vibe: VibePreset): Array<number> => {
|
||||
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
|
||||
return [...scale];
|
||||
};
|
||||
|
||||
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
|
||||
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
|
||||
(chord) => ({ ...chord })
|
||||
);
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||
let profile = profileCache.get(vibe);
|
||||
if (!profile) {
|
||||
profile = {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: DEFAULT_SCALE as Array<number>,
|
||||
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
|
||||
scale: getProfileScale(vibe),
|
||||
progression: getProfileProgression(vibe),
|
||||
};
|
||||
profileCache.set(vibe, profile);
|
||||
return profile;
|
||||
|
|
@ -30,5 +40,7 @@ export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
|||
|
||||
Object.assign(profile, vibe.audio);
|
||||
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
|
||||
profile.scale = getProfileScale(vibe);
|
||||
profile.progression = getProfileProgression(vibe);
|
||||
return profile;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
|
|
@ -155,11 +155,12 @@ export class GardenAudio {
|
|||
): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile();
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
if (cuePiano) {
|
||||
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
|
||||
this.pianoEngine.cue(context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +246,7 @@ export class GardenAudio {
|
|||
|
||||
if (!this.isGestureActive && this.isReleasingPiano) {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot);
|
||||
this.updateDelay(snapshot, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +257,7 @@ export class GardenAudio {
|
|||
? this.config.eraser.pianoActivity
|
||||
: this.energy.getLevel(),
|
||||
});
|
||||
this.updateDelay(snapshot);
|
||||
this.updateDelay(snapshot, profile);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
|
|
@ -371,7 +372,10 @@ export class GardenAudio {
|
|||
}
|
||||
}
|
||||
|
||||
private updateDelay(snapshot: GardenAudioSnapshot): void {
|
||||
private updateDelay(
|
||||
snapshot: GardenAudioSnapshot,
|
||||
profile: GardenAudioVibeProfile
|
||||
): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
|
|
@ -380,7 +384,7 @@ export class GardenAudio {
|
|||
const activity = snapshot.isErasing
|
||||
? this.config.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(activity);
|
||||
this.graph.updateDelay(activity, profile.bpm);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
|
|
@ -390,7 +394,7 @@ export class GardenAudio {
|
|||
|
||||
this.currentVibeId = vibe.id;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile();
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,12 @@ interface GenerativePianoTuning {
|
|||
min: number;
|
||||
max: number;
|
||||
};
|
||||
stereoWidth: {
|
||||
idle: number;
|
||||
active: number;
|
||||
intense: number;
|
||||
intenseThreshold: number;
|
||||
};
|
||||
stylePanOffsetScale: number;
|
||||
lowpass: {
|
||||
midiBase: number;
|
||||
|
|
@ -371,6 +377,12 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
min: -3,
|
||||
max: 3,
|
||||
},
|
||||
stereoWidth: {
|
||||
idle: 0.46,
|
||||
active: 0.9,
|
||||
intense: 1.16,
|
||||
intenseThreshold: 0.72,
|
||||
},
|
||||
stylePanOffsetScale: 0.35,
|
||||
lowpass: {
|
||||
midiBase: 48,
|
||||
|
|
|
|||
|
|
@ -18,23 +18,34 @@ 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 chordVoicings: Record<
|
||||
GardenAudioChord['quality'],
|
||||
{ closed: Array<number>; open: Array<number> }
|
||||
> = {
|
||||
major: {
|
||||
closed: [0, 4, 7, 12, 16],
|
||||
open: [0, 7, 12, 16],
|
||||
},
|
||||
minor: {
|
||||
closed: [0, 3, 7, 12, 15],
|
||||
open: [0, 7, 12, 15],
|
||||
},
|
||||
sus2: {
|
||||
closed: [0, 2, 7, 12, 14],
|
||||
open: [0, 7, 12, 14],
|
||||
},
|
||||
sus4: {
|
||||
closed: [0, 5, 7, 12, 17],
|
||||
open: [0, 7, 12, 17],
|
||||
},
|
||||
};
|
||||
|
||||
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 voicing = chordVoicings[chord.quality];
|
||||
return openVoicing ? voicing.open : voicing.closed;
|
||||
};
|
||||
|
||||
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
|
||||
|
|
@ -406,7 +417,7 @@ export class GenerativePianoEngine {
|
|||
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan: register.pan,
|
||||
pan: this.getActivityPan(register.pan, expression),
|
||||
role: 'pad',
|
||||
delaySend: generativePianoTuning.padChord.delaySend,
|
||||
lowpassHz: this.getLowpassHz(
|
||||
|
|
@ -446,7 +457,7 @@ export class GenerativePianoEngine {
|
|||
velocity: release.velocities[index],
|
||||
startTime: startTime + index * release.strumSeconds,
|
||||
durationSeconds: release.durationSeconds,
|
||||
pan: register.pan,
|
||||
pan: this.getActivityPan(register.pan, 0),
|
||||
role: 'pad',
|
||||
delaySend: release.delaySend,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
|
||||
|
|
@ -487,7 +498,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.supportNote.durationBaseSeconds +
|
||||
expression * generativePianoTuning.supportNote.durationExpressionSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, expression),
|
||||
role: 'support',
|
||||
delaySend:
|
||||
generativePianoTuning.supportNote.delaySendBase +
|
||||
|
|
@ -533,7 +544,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.textureNote.durationBaseSeconds +
|
||||
expression * generativePianoTuning.textureNote.durationExpressionSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, expression),
|
||||
role: 'texture',
|
||||
delaySend:
|
||||
generativePianoTuning.textureNote.delaySendBase +
|
||||
|
|
@ -582,7 +593,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.gestureAccent.durationBaseSeconds +
|
||||
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, strength),
|
||||
role: 'gesture',
|
||||
delaySend: generativePianoTuning.gestureAccent.delaySend,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
||||
|
|
@ -627,7 +638,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.touchNote.durationBaseSeconds +
|
||||
strength * generativePianoTuning.touchNote.durationStrengthSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, strength),
|
||||
role: 'gesture',
|
||||
delaySend: generativePianoTuning.touchNote.delaySend,
|
||||
lowpassHz: this.getLowpassHz(
|
||||
|
|
@ -813,7 +824,7 @@ export class GenerativePianoEngine {
|
|||
chordOffsets: this.getChordOffsets(chord, chordIntervals),
|
||||
};
|
||||
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
||||
const pan = this.getStylePan(styleIndex);
|
||||
const pan = this.getStylePan(styleIndex, intensity);
|
||||
const durationSeconds = clamp(
|
||||
generativePianoTuning.brushStream.durationBaseSeconds +
|
||||
intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
|
||||
|
|
@ -1128,16 +1139,29 @@ export class GenerativePianoEngine {
|
|||
styleCount) as GardenAudioStyleIndex;
|
||||
}
|
||||
|
||||
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
||||
private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number {
|
||||
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||
const styleVoice = styleVoices[styleIndex];
|
||||
return clamp(
|
||||
return this.getActivityPan(
|
||||
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
|
||||
-1,
|
||||
1
|
||||
activity
|
||||
);
|
||||
}
|
||||
|
||||
private getActivityPan(pan: number, activity: number): number {
|
||||
const { active, idle, intense, intenseThreshold } = generativePianoTuning.stereoWidth;
|
||||
const normalizedActivity = clamp01(activity);
|
||||
const safeThreshold = clamp(intenseThreshold, 0.001, 0.999);
|
||||
const width =
|
||||
normalizedActivity < safeThreshold
|
||||
? idle + ((active - idle) * normalizedActivity) / safeThreshold
|
||||
: active +
|
||||
((intense - active) * (normalizedActivity - safeThreshold)) /
|
||||
(1 - safeThreshold);
|
||||
|
||||
return clamp(pan * width, -1, 1);
|
||||
}
|
||||
|
||||
private getLowpassHz(
|
||||
profile: GardenAudioVibeProfile,
|
||||
midi: number,
|
||||
|
|
|
|||
|
|
@ -107,8 +107,7 @@ export const appConfig = {
|
|||
titleStrokeWidthRatio: 0.11,
|
||||
verticalAnchor: 0.47,
|
||||
},
|
||||
introMoveSpeedBaseMultiplier: 1.8,
|
||||
introMoveSpeedProgressMultiplier: 0.35,
|
||||
introMoveSpeed: 280,
|
||||
stroke: {
|
||||
densityMultiplier: 110,
|
||||
maxAgentCount: 2_400,
|
||||
|
|
@ -129,7 +128,7 @@ export const appConfig = {
|
|||
step: 1,
|
||||
},
|
||||
mirror: {
|
||||
default: 1,
|
||||
default: 8,
|
||||
fallbackSegmentName: 'slices',
|
||||
max: 12,
|
||||
min: 1,
|
||||
|
|
|
|||
27
src/config/brush-size.test.ts
Normal file
27
src/config/brush-size.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS,
|
||||
getBrushRenderQualityScale,
|
||||
getRenderQualityBrushSize,
|
||||
} from './brush-size';
|
||||
|
||||
describe('render-quality brush sizing', () => {
|
||||
it('keeps brush sizes unchanged at the 7.3 MP baseline', () => {
|
||||
expect(
|
||||
getRenderQualityBrushSize(21, BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS)
|
||||
).toBe(21);
|
||||
});
|
||||
|
||||
it('scales linear brush size with the square root of render area', () => {
|
||||
const doubledLinearQuality = BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS * 4;
|
||||
|
||||
expect(getBrushRenderQualityScale(doubledLinearQuality)).toBe(2);
|
||||
expect(getRenderQualityBrushSize(9.75, doubledLinearQuality)).toBe(19.5);
|
||||
});
|
||||
|
||||
it('falls back to baseline scaling for invalid render areas', () => {
|
||||
expect(getBrushRenderQualityScale(0)).toBe(1);
|
||||
expect(getRenderQualityBrushSize(6.5, Number.NaN)).toBe(6.5);
|
||||
});
|
||||
});
|
||||
19
src/config/brush-size.ts
Normal file
19
src/config/brush-size.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS = 7.3;
|
||||
|
||||
const getSafeRenderAreaMegapixels = (renderAreaMegapixels: number): number =>
|
||||
Number.isFinite(renderAreaMegapixels) && renderAreaMegapixels > 0
|
||||
? renderAreaMegapixels
|
||||
: BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS;
|
||||
|
||||
export const getBrushRenderQualityScale = (renderAreaMegapixels: number): number =>
|
||||
Math.sqrt(
|
||||
getSafeRenderAreaMegapixels(renderAreaMegapixels) /
|
||||
BRUSH_SIZE_BASELINE_RENDER_AREA_MEGAPIXELS
|
||||
);
|
||||
|
||||
export const getRenderQualityBrushSize = (
|
||||
brushSize: number,
|
||||
renderAreaMegapixels: number
|
||||
): number =>
|
||||
Math.max(0, Number.isFinite(brushSize) ? brushSize : 0) *
|
||||
getBrushRenderQualityScale(renderAreaMegapixels);
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
import type { NumberControlConfig } from './types';
|
||||
|
||||
export const colorInteractionSettings = {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: 0,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: 0,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 1,
|
||||
};
|
||||
|
||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||
folder: 'Color Reactions',
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { colorInteractionSettings } from './color-interactions';
|
||||
import { runtimeControls } from './runtime-controls';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
|
|
@ -27,12 +26,8 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
|||
};
|
||||
|
||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||
...colorInteractionSettings,
|
||||
selectedColorIndex: 0,
|
||||
|
||||
turnWhenLost: 0.8,
|
||||
forwardRotationScale: 0.25,
|
||||
sensorOffsetAngle: 32,
|
||||
introNearDistanceMin: 28,
|
||||
introNearDistanceInner: 4,
|
||||
introNearSensorOffsetMultiplier: 0.75,
|
||||
|
|
@ -40,8 +35,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
introProgressCutoff: 0.999,
|
||||
introTurnRateMultiplier: 3.4,
|
||||
introRandomTurnMultiplier: 0.18,
|
||||
introFarMoveMultiplier: 2.65,
|
||||
introNearMoveMultiplier: 0.01,
|
||||
introStepStopDistance: 0.5,
|
||||
randomTimeScale: 0.34816,
|
||||
|
||||
|
|
@ -58,7 +51,6 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
brushCurveMirrorResolutionExponent: 0.5,
|
||||
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||
brushSmoothingMinSampleDistance: 0.5,
|
||||
strokeAngleJitterRadians: Math.PI * 0.7,
|
||||
|
||||
brushAlpha: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
|
|
@ -78,7 +70,7 @@ export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
|||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 50_000,
|
||||
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
||||
maxAgentCount: 700_000,
|
||||
maxAgentCount: 1_500_000,
|
||||
|
||||
renderTraceNormalizationFloor: 1,
|
||||
renderBrushColorBase: 1.2,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@ import type { GardenAppConfig } from './types';
|
|||
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||
const formatRadiansAsDegrees = (value: number): string =>
|
||||
`${Math.round((value * 180) / Math.PI)} deg`;
|
||||
const formatCompactNumber = (value: number): string => {
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000;
|
||||
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return `${value}`;
|
||||
};
|
||||
|
||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||
|
|
@ -20,14 +30,14 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
folder: 'Brush',
|
||||
label: 'Brush Size',
|
||||
min: 1,
|
||||
max: 60,
|
||||
max: 36,
|
||||
step: 0.25,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Brush',
|
||||
label: 'Density',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
max: 0.38,
|
||||
step: 0.001,
|
||||
},
|
||||
strokeAngleJitterRadians: {
|
||||
|
|
@ -35,7 +45,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
format: formatRadiansAsDegrees,
|
||||
label: 'Spawn Spread',
|
||||
min: 0,
|
||||
max: Math.PI * 2,
|
||||
max: Math.PI,
|
||||
step: 0.01,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
|
|
@ -81,6 +91,13 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
diffusionRateTrails: {
|
||||
folder: 'Movement',
|
||||
label: 'Diffusion Rate',
|
||||
min: 0.01,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
decayRateTrails: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Fade',
|
||||
|
|
@ -106,6 +123,7 @@ export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
|||
|
||||
maxAgentCount: {
|
||||
folder: 'Performance',
|
||||
format: formatCompactNumber,
|
||||
integer: true,
|
||||
label: 'Population Limit',
|
||||
min: 0,
|
||||
|
|
|
|||
|
|
@ -52,17 +52,30 @@ type RuntimeSettingControlConfig = Partial<
|
|||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||
>;
|
||||
|
||||
type GardenVibeSettings = Pick<
|
||||
export type GardenVibeSettings = Pick<
|
||||
GardenRuntimeSettings,
|
||||
| 'backgroundGrainStrength'
|
||||
| 'brushSize'
|
||||
| 'clarity'
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
| 'decayRateTrails'
|
||||
| 'forwardRotationScale'
|
||||
| 'individualTrailWeight'
|
||||
| 'moveSpeed'
|
||||
| 'sensorOffsetAngle'
|
||||
| 'sensorOffsetDistance'
|
||||
| 'spawnPerPixel'
|
||||
| 'strokeAngleJitterRadians'
|
||||
| 'turnSpeed'
|
||||
| 'turnWhenLost'
|
||||
>;
|
||||
|
||||
type GardenDefaultSettings = Omit<
|
||||
|
|
@ -72,10 +85,8 @@ type GardenDefaultSettings = Omit<
|
|||
|
||||
export enum VibeId {
|
||||
AuroraMycelium = 'aurora-mycelium',
|
||||
EmberCircuit = 'ember-circuit',
|
||||
VelvetObservatory = 'velvet-observatory',
|
||||
LichenSignal = 'lichen-signal',
|
||||
UltravioletSiren = 'ultraviolet-siren',
|
||||
TidepoolLantern = 'tidepool-lantern',
|
||||
PaperLanternFog = 'paper-lantern-fog',
|
||||
ChromePollen = 'chrome-pollen',
|
||||
|
|
@ -179,8 +190,7 @@ export interface GardenAppConfig {
|
|||
titleStrokeWidthRatio: number;
|
||||
verticalAnchor: number;
|
||||
};
|
||||
introMoveSpeedBaseMultiplier: number;
|
||||
introMoveSpeedProgressMultiplier: number;
|
||||
introMoveSpeed: number;
|
||||
stroke: {
|
||||
densityMultiplier: number;
|
||||
maxAgentCount: number;
|
||||
|
|
|
|||
66
src/config/vibe-presets.test.ts
Normal file
66
src/config/vibe-presets.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { vibePresets } from './vibe-presets';
|
||||
|
||||
const FINAL_VIBE_NAMES = [
|
||||
'Aurora Mycelium',
|
||||
'Velvet Observatory',
|
||||
'Lichen Signal',
|
||||
'Tidepool Lantern',
|
||||
'Paper Lantern Fog Copy',
|
||||
'Chrome Pollen',
|
||||
];
|
||||
|
||||
describe('vibePresets', () => {
|
||||
it('keeps the classic preset set distinct', () => {
|
||||
expect(vibePresets.map((preset) => preset.name)).toEqual(FINAL_VIBE_NAMES);
|
||||
|
||||
const ids = vibePresets.map((preset) => preset.id);
|
||||
expect(new Set(ids).size).toBe(vibePresets.length);
|
||||
});
|
||||
|
||||
it('includes both blended and visibly particulate styles', () => {
|
||||
const blendedNames = vibePresets
|
||||
.filter(
|
||||
(preset) => preset.settings.brushSize >= 17 && preset.settings.clarity <= 0.56
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
const softParticleNames = vibePresets
|
||||
.filter(
|
||||
(preset) => preset.settings.brushSize <= 5 && preset.settings.clarity <= 0.2
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
|
||||
expect(blendedNames).toEqual([
|
||||
'Aurora Mycelium',
|
||||
'Tidepool Lantern',
|
||||
]);
|
||||
expect(softParticleNames).toEqual(['Chrome Pollen']);
|
||||
});
|
||||
|
||||
it('stays inside interactive performance guardrails', () => {
|
||||
const violations = vibePresets.flatMap((preset) => {
|
||||
const { name, settings } = preset;
|
||||
const presetViolations: Array<string> = [];
|
||||
|
||||
if (settings.spawnPerPixel > 0.38) {
|
||||
presetViolations.push(`${name} density exceeds 0.38`);
|
||||
}
|
||||
if (settings.brushSize > 36) {
|
||||
presetViolations.push(`${name} brush size exceeds 36`);
|
||||
}
|
||||
if (
|
||||
settings.spawnPerPixel >= 0.28 &&
|
||||
(settings.decayRateTrails > 940 ||
|
||||
settings.brushSize > 14 ||
|
||||
settings.individualTrailWeight > 0.055)
|
||||
) {
|
||||
presetViolations.push(`${name} combines high density with too much persistence`);
|
||||
}
|
||||
|
||||
return presetViolations;
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,136 @@
|
|||
import { defaultGardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||
import { VibeId, type VibePreset } from './types';
|
||||
import {
|
||||
defaultGardenAudioVibeSettings,
|
||||
type GardenAudioChord,
|
||||
} from '../audio/garden-audio-config';
|
||||
import { VibeId, type GardenVibeSettings, type VibePreset } from './types';
|
||||
|
||||
type ColorReactionSettings = Pick<
|
||||
GardenVibeSettings,
|
||||
| 'color1ToColor1'
|
||||
| 'color1ToColor2'
|
||||
| 'color1ToColor3'
|
||||
| 'color2ToColor1'
|
||||
| 'color2ToColor2'
|
||||
| 'color2ToColor3'
|
||||
| 'color3ToColor1'
|
||||
| 'color3ToColor2'
|
||||
| 'color3ToColor3'
|
||||
>;
|
||||
|
||||
const colorReactions = {
|
||||
auroraMycelium: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 1,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: 0,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
velvetObservatory: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: -1,
|
||||
color1ToColor3: -1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: -1,
|
||||
color3ToColor1: -1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
lichenSignal: {
|
||||
color1ToColor1: 0,
|
||||
color1ToColor2: -1,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 0,
|
||||
color2ToColor3: -1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: -1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
tidepoolLantern: {
|
||||
color1ToColor1: 0,
|
||||
color1ToColor2: 1,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: 0,
|
||||
color2ToColor2: 0,
|
||||
color2ToColor3: 1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 0,
|
||||
},
|
||||
paperLanternFog: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 1,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: 1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 1,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 1,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
chromePollen: {
|
||||
color1ToColor1: 1,
|
||||
color1ToColor2: 0,
|
||||
color1ToColor3: 1,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: 1,
|
||||
color3ToColor2: 0,
|
||||
color3ToColor3: 1,
|
||||
},
|
||||
} satisfies Record<string, ColorReactionSettings>;
|
||||
|
||||
const musicScales = {
|
||||
dorian: [0, 2, 3, 5, 7, 9, 10],
|
||||
lydian: [0, 2, 4, 6, 7, 9, 11],
|
||||
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
||||
naturalMinor: [0, 2, 3, 5, 7, 8, 10],
|
||||
} satisfies Record<string, Array<number>>;
|
||||
|
||||
const musicProgressions = {
|
||||
aurora: [
|
||||
{ rootOffset: 0, quality: 'sus2' },
|
||||
{ rootOffset: 7, quality: 'major' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'sus4' },
|
||||
],
|
||||
chrome: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 2, quality: 'major' },
|
||||
{ rootOffset: 7, quality: 'sus2' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
],
|
||||
lichen: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 5, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
],
|
||||
paperLantern: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'minor' },
|
||||
{ rootOffset: 10, quality: 'sus4' },
|
||||
],
|
||||
tidepool: [
|
||||
{ rootOffset: 0, quality: 'major' },
|
||||
{ rootOffset: 10, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'sus2' },
|
||||
{ rootOffset: 9, quality: 'minor' },
|
||||
],
|
||||
velvet: [
|
||||
{ rootOffset: 0, quality: 'minor' },
|
||||
{ rootOffset: 8, quality: 'major' },
|
||||
{ rootOffset: 3, quality: 'major' },
|
||||
{ rootOffset: 5, quality: 'sus4' },
|
||||
],
|
||||
} satisfies Record<string, Array<GardenAudioChord>>;
|
||||
|
||||
export const defaultVibeId = VibeId.AuroraMycelium;
|
||||
|
||||
|
|
@ -14,15 +145,20 @@ export const vibePresets: Array<VibePreset> = [
|
|||
],
|
||||
backgroundColor: [6, 13, 22],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.016,
|
||||
brushSize: 20,
|
||||
...colorReactions.auroraMycelium,
|
||||
backgroundGrainStrength: 0.014,
|
||||
brushSize: 21,
|
||||
clarity: 0.52,
|
||||
decayRateTrails: 988,
|
||||
individualTrailWeight: 0.085,
|
||||
forwardRotationScale: 0.28,
|
||||
individualTrailWeight: 0.082,
|
||||
moveSpeed: 54,
|
||||
sensorOffsetDistance: 72,
|
||||
spawnPerPixel: 0.13,
|
||||
turnSpeed: 35,
|
||||
sensorOffsetAngle: 36,
|
||||
sensorOffsetDistance: 76,
|
||||
spawnPerPixel: 0.14,
|
||||
strokeAngleJitterRadians: 1.45,
|
||||
turnSpeed: 34,
|
||||
turnWhenLost: 0.75,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
|
|
@ -33,130 +169,84 @@ export const vibePresets: Array<VibePreset> = [
|
|||
noteLength: 0.86,
|
||||
notePitchOffset: -2,
|
||||
brightness: 0.84,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.EmberCircuit,
|
||||
name: 'Ember Circuit',
|
||||
colors: [
|
||||
[255, 95, 38],
|
||||
[255, 43, 132],
|
||||
[43, 219, 255],
|
||||
],
|
||||
backgroundColor: [17, 10, 8],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.03,
|
||||
brushSize: 8,
|
||||
clarity: 0.82,
|
||||
decayRateTrails: 918,
|
||||
individualTrailWeight: 0.04,
|
||||
moveSpeed: 150,
|
||||
sensorOffsetDistance: 24,
|
||||
spawnPerPixel: 0.31,
|
||||
turnSpeed: 130,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.03,
|
||||
bpm: 124,
|
||||
rampUpIntensity: 1.35,
|
||||
rampUpTime: 0.04,
|
||||
noteLength: 0.18,
|
||||
notePitchOffset: 7,
|
||||
brightness: 1.34,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.aurora,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.VelvetObservatory,
|
||||
name: 'Velvet Observatory',
|
||||
colors: [
|
||||
[72, 98, 255],
|
||||
[255, 89, 176],
|
||||
[235, 236, 255],
|
||||
[178, 76, 62],
|
||||
[2, 174, 255],
|
||||
[213, 193, 9],
|
||||
],
|
||||
backgroundColor: [7, 8, 20],
|
||||
backgroundColor: [7, 4, 22],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.01,
|
||||
brushSize: 24,
|
||||
clarity: 0.45,
|
||||
decayRateTrails: 992,
|
||||
individualTrailWeight: 0.095,
|
||||
moveSpeed: 45,
|
||||
sensorOffsetDistance: 86,
|
||||
spawnPerPixel: 0.1,
|
||||
turnSpeed: 24,
|
||||
...colorReactions.velvetObservatory,
|
||||
backgroundGrainStrength: 0.005,
|
||||
brushSize: 9.75,
|
||||
clarity: 0.437,
|
||||
decayRateTrails: 915,
|
||||
forwardRotationScale: 2.0816681711721685e-17,
|
||||
individualTrailWeight: 0.1,
|
||||
moveSpeed: 216,
|
||||
sensorOffsetAngle: 24,
|
||||
sensorOffsetDistance: 17,
|
||||
spawnPerPixel: 0.24,
|
||||
strokeAngleJitterRadians: 0.16999999999999993,
|
||||
turnSpeed: 33,
|
||||
turnWhenLost: 0.42000000000000004,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.14,
|
||||
bpm: 56,
|
||||
rampUpIntensity: 0.6,
|
||||
rampUpTime: 0.16,
|
||||
noteLength: 1.15,
|
||||
notePitchOffset: -5,
|
||||
brightness: 0.72,
|
||||
idleIntensity: 0.55,
|
||||
bpm: 72,
|
||||
rampUpIntensity: 1.42,
|
||||
rampUpTime: 0.07000000000000002,
|
||||
noteLength: 0.7000000000000001,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.94,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.velvet,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.LichenSignal,
|
||||
name: 'Lichen Signal',
|
||||
colors: [
|
||||
[174, 205, 91],
|
||||
[71, 162, 126],
|
||||
[229, 117, 71],
|
||||
[183, 216, 92],
|
||||
[65, 166, 128],
|
||||
[238, 120, 76],
|
||||
],
|
||||
backgroundColor: [18, 24, 17],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.028,
|
||||
brushSize: 17,
|
||||
clarity: 0.66,
|
||||
decayRateTrails: 974,
|
||||
individualTrailWeight: 0.065,
|
||||
moveSpeed: 68,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.19,
|
||||
turnSpeed: 38,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.1,
|
||||
bpm: 68,
|
||||
rampUpIntensity: 0.8,
|
||||
rampUpTime: 0.1,
|
||||
noteLength: 0.62,
|
||||
notePitchOffset: -3,
|
||||
brightness: 0.82,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.UltravioletSiren,
|
||||
name: 'Ultraviolet Siren',
|
||||
colors: [
|
||||
[184, 75, 255],
|
||||
[0, 224, 255],
|
||||
[214, 255, 72],
|
||||
],
|
||||
backgroundColor: [13, 9, 31],
|
||||
backgroundColor: [0, 0, 0],
|
||||
settings: {
|
||||
...colorReactions.lichenSignal,
|
||||
backgroundGrainStrength: 0.02,
|
||||
brushSize: 11,
|
||||
clarity: 0.72,
|
||||
decayRateTrails: 946,
|
||||
individualTrailWeight: 0.052,
|
||||
moveSpeed: 118,
|
||||
sensorOffsetDistance: 30,
|
||||
spawnPerPixel: 0.28,
|
||||
turnSpeed: 96,
|
||||
brushSize: 6.5,
|
||||
clarity: 0.74,
|
||||
decayRateTrails: 962,
|
||||
forwardRotationScale: 0.3,
|
||||
individualTrailWeight: 0.052000000000000005,
|
||||
moveSpeed: 72,
|
||||
sensorOffsetAngle: 42,
|
||||
sensorOffsetDistance: 54,
|
||||
spawnPerPixel: 0.15999999999999998,
|
||||
strokeAngleJitterRadians: 3.1399999999999997,
|
||||
turnSpeed: 44,
|
||||
turnWhenLost: 0.9200000000000002,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.04,
|
||||
bpm: 112,
|
||||
rampUpIntensity: 1.2,
|
||||
rampUpTime: 0.05,
|
||||
noteLength: 0.25,
|
||||
notePitchOffset: 5,
|
||||
brightness: 1.22,
|
||||
idleIntensity: 0.13,
|
||||
bpm: 68,
|
||||
rampUpIntensity: 1.46,
|
||||
rampUpTime: 0.10000000000000002,
|
||||
noteLength: 0.6,
|
||||
notePitchOffset: -3,
|
||||
brightness: 1.21,
|
||||
scale: musicScales.dorian,
|
||||
progression: musicProgressions.lichen,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -167,89 +257,110 @@ export const vibePresets: Array<VibePreset> = [
|
|||
[61, 118, 255],
|
||||
[255, 191, 91],
|
||||
],
|
||||
backgroundColor: [5, 20, 28],
|
||||
backgroundColor: [4, 18, 29],
|
||||
settings: {
|
||||
...colorReactions.tidepoolLantern,
|
||||
backgroundGrainStrength: 0.018,
|
||||
brushSize: 15,
|
||||
clarity: 0.6,
|
||||
decayRateTrails: 963,
|
||||
individualTrailWeight: 0.058,
|
||||
brushSize: 17,
|
||||
clarity: 0.56,
|
||||
decayRateTrails: 968,
|
||||
forwardRotationScale: 0.38,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 88,
|
||||
sensorOffsetDistance: 44,
|
||||
sensorOffsetAngle: 64,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.22,
|
||||
turnSpeed: 60,
|
||||
strokeAngleJitterRadians: 1.8,
|
||||
turnSpeed: 66,
|
||||
turnWhenLost: 1.05,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.08,
|
||||
bpm: 82,
|
||||
bpm: 84,
|
||||
rampUpIntensity: 0.95,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.48,
|
||||
noteLength: 0.46,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.98,
|
||||
scale: musicScales.mixolydian,
|
||||
progression: musicProgressions.tidepool,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.PaperLanternFog,
|
||||
name: 'Paper Lantern Fog',
|
||||
name: 'Paper Lantern Fog Copy',
|
||||
colors: [
|
||||
[255, 174, 104],
|
||||
[242, 102, 107],
|
||||
[132, 211, 185],
|
||||
[255, 176, 108],
|
||||
[239, 90, 108],
|
||||
[128, 213, 184],
|
||||
],
|
||||
backgroundColor: [31, 23, 20],
|
||||
backgroundColor: [30, 23, 20],
|
||||
settings: {
|
||||
backgroundGrainStrength: 0.036,
|
||||
brushSize: 22,
|
||||
clarity: 0.5,
|
||||
decayRateTrails: 984,
|
||||
individualTrailWeight: 0.08,
|
||||
moveSpeed: 56,
|
||||
sensorOffsetDistance: 64,
|
||||
spawnPerPixel: 0.14,
|
||||
turnSpeed: 32,
|
||||
...colorReactions.paperLanternFog,
|
||||
backgroundGrainStrength: 0.038,
|
||||
brushSize: 3.5,
|
||||
clarity: 1,
|
||||
decayRateTrails: 999,
|
||||
forwardRotationScale: 0.24,
|
||||
individualTrailWeight: 0.937,
|
||||
moveSpeed: 28,
|
||||
sensorOffsetAngle: 34,
|
||||
sensorOffsetDistance: 66,
|
||||
spawnPerPixel: 0.05499999999999998,
|
||||
strokeAngleJitterRadians: 0,
|
||||
turnSpeed: 30,
|
||||
turnWhenLost: 1.52,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.13,
|
||||
bpm: 64,
|
||||
rampUpIntensity: 0.72,
|
||||
rampUpTime: 0.12,
|
||||
noteLength: 0.9,
|
||||
notePitchOffset: -4,
|
||||
brightness: 0.76,
|
||||
idleIntensity: 0.33,
|
||||
bpm: 127,
|
||||
rampUpIntensity: 0.66,
|
||||
rampUpTime: 0.03000000000000001,
|
||||
noteLength: 0.92,
|
||||
notePitchOffset: 10,
|
||||
brightness: 1.42,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.paperLantern,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.ChromePollen,
|
||||
name: 'Chrome Pollen',
|
||||
colors: [
|
||||
[235, 255, 238],
|
||||
[178, 34, 34],
|
||||
[255, 214, 48],
|
||||
[77, 240, 157],
|
||||
],
|
||||
backgroundColor: [9, 13, 12],
|
||||
backgroundColor: [7, 12, 11],
|
||||
settings: {
|
||||
...colorReactions.chromePollen,
|
||||
backgroundGrainStrength: 0.012,
|
||||
brushSize: 10,
|
||||
clarity: 0.9,
|
||||
decayRateTrails: 935,
|
||||
individualTrailWeight: 0.045,
|
||||
moveSpeed: 104,
|
||||
sensorOffsetDistance: 36,
|
||||
spawnPerPixel: 0.24,
|
||||
turnSpeed: 78,
|
||||
brushSize: 4.5,
|
||||
clarity: 0.1,
|
||||
decayRateTrails: 922,
|
||||
forwardRotationScale: 0.5,
|
||||
individualTrailWeight: 0.026000000000000002,
|
||||
moveSpeed: 86,
|
||||
sensorOffsetAngle: 46,
|
||||
sensorOffsetDistance: 14,
|
||||
spawnPerPixel: 0.36,
|
||||
strokeAngleJitterRadians: 3,
|
||||
turnSpeed: 34,
|
||||
turnWhenLost: 1.35,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.05,
|
||||
bpm: 96,
|
||||
rampUpIntensity: 1.05,
|
||||
rampUpTime: 0.07,
|
||||
noteLength: 0.3,
|
||||
notePitchOffset: 3,
|
||||
brightness: 1.18,
|
||||
idleIntensity: 0.11,
|
||||
bpm: 150,
|
||||
rampUpIntensity: 2,
|
||||
rampUpTime: 0.06000000000000001,
|
||||
noteLength: 1.8,
|
||||
notePitchOffset: -12,
|
||||
brightness: 0.5,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.chrome,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
|
|
@ -162,7 +163,11 @@ export class AgentPopulation {
|
|||
}
|
||||
|
||||
const baseAngle = Math.atan2(deltaY, deltaX);
|
||||
const spread = settings.brushSize * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||
const spread =
|
||||
getRenderQualityBrushSize(
|
||||
settings.brushSize,
|
||||
settings.internalRenderAreaMegapixels
|
||||
) * getSafePixelRatio(this.getCanvasPixelRatio());
|
||||
const batchCapacity = this.strokeAgentData.length / AGENT_FLOAT_COUNT;
|
||||
if (batchCapacity <= 0) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
import { type StrokeSegment } from './game-loop-types';
|
||||
|
|
@ -90,9 +91,13 @@ export class BrushStrokeSmoother {
|
|||
): Array<StrokeSegment> {
|
||||
const curveLength = vec2.distance(start, control) + vec2.distance(control, end);
|
||||
const canvasPixelRatio = getSafePixelRatio(this.options.getCanvasPixelRatio());
|
||||
const brushSize = getRenderQualityBrushSize(
|
||||
settings.brushSize,
|
||||
settings.internalRenderAreaMegapixels
|
||||
);
|
||||
const brushRadius = Math.max(
|
||||
settings.brushCurveMinBrushRadius * canvasPixelRatio,
|
||||
(settings.brushSize * canvasPixelRatio) / 2
|
||||
(brushSize * canvasPixelRatio) / 2
|
||||
);
|
||||
const segmentSpacing = Math.max(
|
||||
settings.brushCurveMinSegmentSpacing * canvasPixelRatio,
|
||||
|
|
|
|||
|
|
@ -137,12 +137,8 @@ export class GameLoopResources {
|
|||
deltaTime,
|
||||
time,
|
||||
agentCount: activeAgentCount,
|
||||
moveSpeed:
|
||||
settings.moveSpeed *
|
||||
(introProgress >= 1
|
||||
? 1
|
||||
: appConfig.simulation.introMoveSpeedBaseMultiplier +
|
||||
introProgress * appConfig.simulation.introMoveSpeedProgressMultiplier),
|
||||
moveSpeed: settings.moveSpeed,
|
||||
introMoveSpeed: appConfig.simulation.introMoveSpeed,
|
||||
introProgress,
|
||||
});
|
||||
this.brushPipeline.setParameters({
|
||||
|
|
|
|||
|
|
@ -46,16 +46,19 @@ export class SimulationFrameRenderer {
|
|||
const commandEncoder = this.device.createCommandEncoder();
|
||||
this.gpuProfiler?.beginFrame();
|
||||
|
||||
this.textures.copyTrailMapAToB(commandEncoder);
|
||||
// Clear the deposit map up-front so agents write fresh deposits each frame
|
||||
// and diffuse sees only this frame's contributions added to trailMapA.
|
||||
this.textures.clearDepositMap(commandEncoder);
|
||||
let wroteSourceMap = false;
|
||||
if (isErasing) {
|
||||
if (this.pipelines.eraserAgentPipeline.hasActiveMask()) {
|
||||
const eraserMask = this.textures.eraserMask.getTextureView();
|
||||
// Erase trailMapA directly — it's what agent and diffuse will read.
|
||||
this.pipelines.eraserTexturePipeline.executeCombined(
|
||||
commandEncoder,
|
||||
eraserMask,
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.gpuProfiler?.timestampWrites('eraserTexture')
|
||||
);
|
||||
this.pipelines.eraserAgentPipeline.execute(
|
||||
|
|
@ -86,19 +89,20 @@ export class SimulationFrameRenderer {
|
|||
this.pipelines.agentPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.depositMap.getTextureView(),
|
||||
this.gpuProfiler?.timestampWrites('agent')
|
||||
);
|
||||
this.pipelines.diffusionPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.trailMapA.getSize(),
|
||||
this.textures.depositMap.getTextureView(),
|
||||
this.gpuProfiler?.timestampWrites('trailDiffusion')
|
||||
);
|
||||
const canvasTexture = this.pipelines.renderPipeline.execute(
|
||||
commandEncoder,
|
||||
this.textures.trailMapA.getTextureView(),
|
||||
this.textures.trailMapB.getTextureView(),
|
||||
this.textures.sourceMapA.getTextureView(),
|
||||
useSourceMap,
|
||||
this.gpuProfiler?.timestampWrites('render')
|
||||
|
|
@ -111,6 +115,7 @@ export class SimulationFrameRenderer {
|
|||
this.textures.sourceMapA.getTextureView(),
|
||||
this.textures.sourceMapB.getTextureView(),
|
||||
this.textures.sourceMapB.getSize(),
|
||||
null,
|
||||
this.gpuProfiler?.timestampWrites('sourceDiffusion')
|
||||
);
|
||||
}
|
||||
|
|
@ -118,6 +123,10 @@ export class SimulationFrameRenderer {
|
|||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
afterGpuProfileSubmit?.();
|
||||
canvasReadbackRequest?.afterSubmit();
|
||||
// After this frame's diffuse, trailMapB holds the fresh trail; swap so
|
||||
// trailMapA is "current trail" again for the next frame and any external
|
||||
// readers (e.g. export snapshot).
|
||||
this.textures.swapTrailMaps();
|
||||
if (useSourceMap) {
|
||||
this.textures.swapSourceMaps();
|
||||
this.sourceActiveFramesRemaining -= 1;
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@ import {
|
|||
} from '../utils/graphics/resizable-texture';
|
||||
|
||||
export class SimulationTextures {
|
||||
public readonly trailMapA: ResizableTexture;
|
||||
public readonly trailMapB: ResizableTexture;
|
||||
// trailMapA holds the current trail (read by agent and diffuse). trailMapB
|
||||
// receives the diffuse output; the two swap each frame so the freshly
|
||||
// diffused texture becomes trailMapA for the next frame.
|
||||
public trailMapA: ResizableTexture;
|
||||
public trailMapB: ResizableTexture;
|
||||
// Per-frame deposit accumulator: cleared each frame, written sparsely by
|
||||
// agents, then read by diffuse alongside trailMapA. Replaces the previous
|
||||
// full-resolution copyTrailMapAToB seed.
|
||||
public readonly depositMap: ResizableTexture;
|
||||
public readonly eraserMask: ResizableTexture;
|
||||
// A/B are swapped each frame to ping-pong the diffusion pass.
|
||||
public sourceMapA: ResizableTexture;
|
||||
public sourceMapB: ResizableTexture;
|
||||
|
||||
|
|
@ -21,6 +27,7 @@ export class SimulationTextures {
|
|||
) {
|
||||
this.trailMapA = this.createTexture(canvasSize);
|
||||
this.trailMapB = this.createTexture(canvasSize);
|
||||
this.depositMap = this.createTexture(canvasSize);
|
||||
this.sourceMapA = this.createTexture(canvasSize);
|
||||
this.sourceMapB = this.createTexture(canvasSize);
|
||||
this.eraserMask = this.createEraserMask(canvasSize);
|
||||
|
|
@ -36,6 +43,7 @@ export class SimulationTextures {
|
|||
const resizes = [
|
||||
this.trailMapA,
|
||||
this.trailMapB,
|
||||
this.depositMap,
|
||||
this.sourceMapA,
|
||||
this.sourceMapB,
|
||||
this.eraserMask,
|
||||
|
|
@ -67,6 +75,7 @@ export class SimulationTextures {
|
|||
[
|
||||
this.trailMapA,
|
||||
this.trailMapB,
|
||||
this.depositMap,
|
||||
this.sourceMapA,
|
||||
this.sourceMapB,
|
||||
this.eraserMask,
|
||||
|
|
@ -86,14 +95,25 @@ export class SimulationTextures {
|
|||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
}
|
||||
|
||||
public copyTrailMapAToB(commandEncoder: GPUCommandEncoder): void {
|
||||
const size = this.trailMapA.getSize();
|
||||
public clearDepositMap(commandEncoder: GPUCommandEncoder): void {
|
||||
// Hardware fast-clear via a render pass with loadOp 'clear' and an empty
|
||||
// body. Cheaper than copyTextureToTexture and writes no actual color data
|
||||
// on tile-based GPUs.
|
||||
const passEncoder = commandEncoder.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{
|
||||
view: this.depositMap.getTextureView(),
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
},
|
||||
],
|
||||
});
|
||||
passEncoder.end();
|
||||
}
|
||||
|
||||
commandEncoder.copyTextureToTexture(
|
||||
{ texture: this.trailMapA.getTexture() },
|
||||
{ texture: this.trailMapB.getTexture() },
|
||||
{ width: size[0], height: size[1] }
|
||||
);
|
||||
public swapTrailMaps(): void {
|
||||
[this.trailMapA, this.trailMapB] = [this.trailMapB, this.trailMapA];
|
||||
}
|
||||
|
||||
public clearSourceMaps(commandEncoder: GPUCommandEncoder): void {
|
||||
|
|
@ -119,6 +139,7 @@ export class SimulationTextures {
|
|||
public destroy(): void {
|
||||
this.trailMapA.destroy();
|
||||
this.trailMapB.destroy();
|
||||
this.depositMap.destroy();
|
||||
this.sourceMapA.destroy();
|
||||
this.sourceMapB.destroy();
|
||||
this.eraserMask.destroy();
|
||||
|
|
|
|||
|
|
@ -120,12 +120,12 @@ const main = async () => {
|
|||
new FullScreenHandler(fullScreenButton, document.documentElement);
|
||||
|
||||
new VibeNavigator({
|
||||
onChange: ({ vibeId, vibeName, source }) => {
|
||||
onChange: ({ vibeId, vibeName, source, userGesture }) => {
|
||||
trackVibeChange({ vibeId, vibeName, source });
|
||||
game?.onVibeChanged();
|
||||
syncRuntimeUi();
|
||||
configPane?.refresh();
|
||||
game?.playVibeChangeAudio(true);
|
||||
game?.playVibeChangeAudio(userGesture);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ type PaneContainer = Pick<FolderApi, 'addBinding' | 'addButton' | 'addFolder'>;
|
|||
type ColorReactionKey = (typeof colorReactionRows)[number]['keys'][number];
|
||||
type RuntimeControlKey = keyof GardenRuntimeSettings & string;
|
||||
type VibeColorKey = 'color1' | 'color2' | 'color3' | 'backgroundColor';
|
||||
type VibeNumberKey = keyof GardenAudioVibeSettings;
|
||||
type NumberPropertyKey<T> = {
|
||||
[Key in keyof T]-?: T[Key] extends number ? Key : never;
|
||||
}[keyof T] &
|
||||
string;
|
||||
type VibeNumberKey = NumberPropertyKey<GardenAudioVibeSettings>;
|
||||
|
||||
interface PaneState extends GardenAudioVibeSettings {
|
||||
backgroundColor: string;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { activeVibe, applyVibeSettings } from '../settings';
|
||||
import { activeVibe, applyVibeSettings, rememberActiveVibeSelection } from '../settings';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
import { VIBE_PRESETS, type VibeId } from '../vibes';
|
||||
import { getCurrentUriVibeId, writeCurrentVibeUri } from '../vibe-uri';
|
||||
import { getVibeById, VIBE_PRESETS, type VibeId } from '../vibes';
|
||||
|
||||
interface VibeSelection {
|
||||
source: string;
|
||||
userGesture: boolean;
|
||||
vibeId: VibeId;
|
||||
vibeName: string;
|
||||
}
|
||||
|
|
@ -20,18 +22,51 @@ export class VibeNavigator {
|
|||
private readonly nextButton = queryRequiredElement('.next-vibe', HTMLButtonElement);
|
||||
|
||||
public constructor(private readonly options: VibeNavigatorOptions) {
|
||||
rememberActiveVibeSelection();
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
|
||||
this.previousButton.addEventListener('click', () =>
|
||||
this.select(-1, 'previous-button')
|
||||
);
|
||||
this.nextButton.addEventListener('click', () => this.select(1, 'next-button'));
|
||||
window.addEventListener('popstate', () => this.selectFromCurrentUri());
|
||||
}
|
||||
|
||||
private select(offset: number, source: string): void {
|
||||
const current = VIBE_PRESETS.findIndex((vibe) => vibe.id === activeVibe.id);
|
||||
const currentIndex = current >= 0 ? current : 0;
|
||||
const vibe =
|
||||
VIBE_PRESETS[(current + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
|
||||
VIBE_PRESETS[(currentIndex + VIBE_PRESETS.length + offset) % VIBE_PRESETS.length];
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
writeCurrentVibeUri(activePreset.id, 'push');
|
||||
this.notifyChange(activePreset, source, true);
|
||||
}
|
||||
|
||||
private selectFromCurrentUri(): void {
|
||||
const vibeId = getCurrentUriVibeId();
|
||||
if (!vibeId || vibeId === activeVibe.id) {
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
return;
|
||||
}
|
||||
|
||||
const vibe = getVibeById(vibeId);
|
||||
if (!vibe) {
|
||||
writeCurrentVibeUri(activeVibe.id, 'replace');
|
||||
return;
|
||||
}
|
||||
|
||||
const activePreset = applyVibeSettings(vibe);
|
||||
writeCurrentVibeUri(activePreset.id, 'replace');
|
||||
this.notifyChange(activePreset, 'uri-popstate', false);
|
||||
}
|
||||
|
||||
private notifyChange(
|
||||
activePreset: typeof activeVibe,
|
||||
source: string,
|
||||
userGesture: boolean
|
||||
): void {
|
||||
this.options.onChange({
|
||||
userGesture,
|
||||
vibeId: activePreset.id,
|
||||
vibeName: activePreset.name,
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,43 @@
|
|||
// Use the device's max workgroup size so we get full SIMD/wave occupancy on
|
||||
// hardware that supports more than the WebGPU minimum of 256.
|
||||
export const getAgentWorkgroupSize = (device: GPUDevice): number =>
|
||||
device.limits.maxComputeInvocationsPerWorkgroup;
|
||||
const AGENT_WORKGROUP_KINDS = ['simulation', 'eraser', 'resize', 'compaction'] as const;
|
||||
|
||||
export type AgentWorkgroupKind = (typeof AGENT_WORKGROUP_KINDS)[number];
|
||||
|
||||
const AGENT_WORKGROUP_SIZE_TARGETS = {
|
||||
// Keep shader-specific targets conservative. Using the device maximum can
|
||||
// hurt occupancy and makes compaction's workgroup scan more expensive.
|
||||
simulation: 256,
|
||||
eraser: 256,
|
||||
resize: 256,
|
||||
compaction: 256,
|
||||
} satisfies Record<AgentWorkgroupKind, number>;
|
||||
|
||||
export const getAgentWorkgroupSize = (
|
||||
device: GPUDevice,
|
||||
kind: AgentWorkgroupKind = 'simulation'
|
||||
): number => {
|
||||
const deviceLimit = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
Math.min(
|
||||
device.limits.maxComputeInvocationsPerWorkgroup,
|
||||
device.limits.maxComputeWorkgroupSizeX
|
||||
)
|
||||
)
|
||||
);
|
||||
return Math.min(AGENT_WORKGROUP_SIZE_TARGETS[kind], deviceLimit);
|
||||
};
|
||||
|
||||
export const getMinAgentWorkgroupSize = (device: GPUDevice): number =>
|
||||
Math.min(...AGENT_WORKGROUP_KINDS.map((kind) => getAgentWorkgroupSize(device, kind)));
|
||||
|
||||
export const substituteAgentWorkgroupSize = (
|
||||
device: GPUDevice,
|
||||
shaderCode: string
|
||||
shaderCode: string,
|
||||
kind: AgentWorkgroupKind = 'simulation'
|
||||
): string =>
|
||||
shaderCode.replaceAll(
|
||||
'__AGENT_WORKGROUP_SIZE__',
|
||||
String(getAgentWorkgroupSize(device))
|
||||
String(getAgentWorkgroupSize(device, kind))
|
||||
);
|
||||
|
||||
export const dispatchAgentWorkgroups = (
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export class AgentGenerationPipeline {
|
|||
private readonly resizePipeline: GPUComputePipeline;
|
||||
private readonly compactionPipeline: GPUComputePipeline;
|
||||
private readonly clearCompactedTailPipeline: GPUComputePipeline;
|
||||
private readonly workgroupSize: number;
|
||||
private readonly resizeWorkgroupSize: number;
|
||||
private readonly compactionWorkgroupSize: number;
|
||||
|
||||
private activeAgentsBuffer: GPUBuffer;
|
||||
private inactiveAgentsBuffer: GPUBuffer;
|
||||
|
|
@ -110,20 +111,26 @@ export class AgentGenerationPipeline {
|
|||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.workgroupSize = getAgentWorkgroupSize(device);
|
||||
const sizedSchema = substituteAgentWorkgroupSize(device, agentSchema);
|
||||
this.resizeWorkgroupSize = getAgentWorkgroupSize(device, 'resize');
|
||||
this.compactionWorkgroupSize = getAgentWorkgroupSize(device, 'compaction');
|
||||
const resizeSchema = substituteAgentWorkgroupSize(device, agentSchema, 'resize');
|
||||
const compactionSchema = substituteAgentWorkgroupSize(
|
||||
device,
|
||||
agentSchema,
|
||||
'compaction'
|
||||
);
|
||||
|
||||
this.resizePipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: smartCompile(device, sizedSchema, resizeShader),
|
||||
module: smartCompile(device, resizeSchema, resizeShader),
|
||||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
|
||||
const compactionModule = smartCompile(device, sizedSchema, compactionShader);
|
||||
const compactionModule = smartCompile(device, compactionSchema, compactionShader);
|
||||
|
||||
this.compactionPipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
|
|
@ -248,7 +255,7 @@ export class AgentGenerationPipeline {
|
|||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.resizePipeline);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
|
||||
dispatchAgentWorkgroups(passEncoder, this.resizeWorkgroupSize, agentCount);
|
||||
passEncoder.end();
|
||||
|
||||
this.device.queue.submit([commandEncoder.finish()]);
|
||||
|
|
@ -267,11 +274,11 @@ export class AgentGenerationPipeline {
|
|||
const passEncoder = commandEncoder.beginComputePass();
|
||||
passEncoder.setPipeline(this.compactionPipeline);
|
||||
passEncoder.setBindGroup(1, this.getBindGroup());
|
||||
dispatchAgentWorkgroups(passEncoder, this.workgroupSize, agentCount);
|
||||
dispatchAgentWorkgroups(passEncoder, this.compactionWorkgroupSize, agentCount);
|
||||
passEncoder.setPipeline(this.clearCompactedTailPipeline);
|
||||
dispatchAgentWorkgroups(
|
||||
passEncoder,
|
||||
this.workgroupSize,
|
||||
this.compactionWorkgroupSize,
|
||||
Math.ceil(agentCount / AgentGenerationPipeline.CLEAR_COMPACTED_TAIL_STRIDE)
|
||||
);
|
||||
passEncoder.end();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAgentWorkgroupSize } from './agent-dispatch';
|
||||
import { getMinAgentWorkgroupSize } from './agent-dispatch';
|
||||
|
||||
export const AGENT_FLOAT_COUNT = 8;
|
||||
export const AGENT_SIZE_IN_BYTES = AGENT_FLOAT_COUNT * Float32Array.BYTES_PER_ELEMENT;
|
||||
|
|
@ -58,7 +58,7 @@ export const getMaxSupportedAgentCount = (
|
|||
Math.floor(device.limits.maxBufferSize / AGENT_SIZE_IN_BYTES),
|
||||
Math.floor(storageBufferBindingSize / AGENT_SIZE_IN_BYTES),
|
||||
Math.floor(device.limits.maxComputeWorkgroupsPerDimension) *
|
||||
getAgentWorkgroupSize(device)
|
||||
getMinAgentWorkgroupSize(device)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,19 +38,27 @@ export interface AgentSettings {
|
|||
introNearDistanceInner: number;
|
||||
introTurnRateMultiplier: number;
|
||||
introRandomTurnMultiplier: number;
|
||||
introFarMoveMultiplier: number;
|
||||
introNearMoveMultiplier: number;
|
||||
introStepStopDistance: number;
|
||||
randomTimeScale: number;
|
||||
}
|
||||
|
||||
const UNIFORM_COUNT = 30;
|
||||
// The Settings struct in WGSL starts with a mat3x3<f32> reactionMatrix.
|
||||
// In uniform layout each of its 3 columns is stored as a vec3<f32> padded to
|
||||
// 16 bytes, so the matrix occupies floats [0..12] (with [3], [7], [11] unused
|
||||
// padding). Remaining scalars pack tightly from float 12 onward.
|
||||
const UNIFORM_COUNT = 32;
|
||||
const REACTION_MATRIX_COL0 = 0;
|
||||
const REACTION_MATRIX_COL1 = 4;
|
||||
const REACTION_MATRIX_COL2 = 8;
|
||||
const SCALAR_BASE = 12;
|
||||
|
||||
export class AgentPipeline {
|
||||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly pipeline: GPUComputePipeline;
|
||||
private readonly pipelineFull: GPUComputePipeline;
|
||||
private readonly pipelineSteady: GPUComputePipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
private readonly workgroupSize: number;
|
||||
private useSteadyPipeline = false;
|
||||
private readonly uniformValues = new Float32Array(UNIFORM_COUNT);
|
||||
private readonly uniformUintValues = new Uint32Array(this.uniformValues.buffer);
|
||||
private readonly uniformCache = createCachedBufferWrite(
|
||||
|
|
@ -104,23 +112,30 @@ export class AgentPipeline {
|
|||
],
|
||||
});
|
||||
|
||||
this.workgroupSize = getAgentWorkgroupSize(device);
|
||||
this.workgroupSize = getAgentWorkgroupSize(device, 'simulation');
|
||||
const shaderModule = smartCompile(
|
||||
device,
|
||||
CommonState.shaderCode,
|
||||
substituteAgentWorkgroupSize(device, agentSchema),
|
||||
substituteAgentWorkgroupSize(device, agentSchema, 'simulation'),
|
||||
shader
|
||||
);
|
||||
const pipelineLayout = device.createPipelineLayout({
|
||||
bindGroupLayouts: [commonState.bindGroupLayout, this.bindGroupLayout],
|
||||
});
|
||||
this.pipeline = device.createComputePipeline({
|
||||
this.pipelineFull = device.createComputePipeline({
|
||||
layout: pipelineLayout,
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: 'main',
|
||||
},
|
||||
});
|
||||
this.pipelineSteady = device.createComputePipeline({
|
||||
layout: pipelineLayout,
|
||||
compute: {
|
||||
module: shaderModule,
|
||||
entryPoint: 'mainSteady',
|
||||
},
|
||||
});
|
||||
|
||||
this.uniforms = device.createBuffer({
|
||||
size: UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
|
|
@ -153,8 +168,7 @@ export class AgentPipeline {
|
|||
introProgressCutoff,
|
||||
introTurnRateMultiplier,
|
||||
introRandomTurnMultiplier,
|
||||
introFarMoveMultiplier,
|
||||
introNearMoveMultiplier,
|
||||
introMoveSpeed,
|
||||
introStepStopDistance,
|
||||
randomTimeScale,
|
||||
time,
|
||||
|
|
@ -164,40 +178,46 @@ export class AgentPipeline {
|
|||
deltaTime: number;
|
||||
time: number;
|
||||
agentCount: number;
|
||||
introMoveSpeed: number;
|
||||
introProgress?: number;
|
||||
}) {
|
||||
this.agentCount = agentCount;
|
||||
this.uniformValues[0] = moveSpeed * deltaTime;
|
||||
this.uniformValues[1] = turnSpeed * deltaTime;
|
||||
const resolvedIntroProgress = introProgress ?? 1;
|
||||
// Once the intro target phase ends nothing reads intro fields again, so the
|
||||
// steady-only pipeline can replace the full one for the rest of the session.
|
||||
this.useSteadyPipeline = resolvedIntroProgress >= introProgressCutoff;
|
||||
// Reaction matrix: column N holds the weights for source colorIndex == N.
|
||||
this.uniformValues[REACTION_MATRIX_COL0] = color1ToColor1;
|
||||
this.uniformValues[REACTION_MATRIX_COL0 + 1] = color1ToColor2;
|
||||
this.uniformValues[REACTION_MATRIX_COL0 + 2] = color1ToColor3;
|
||||
this.uniformValues[REACTION_MATRIX_COL1] = color2ToColor1;
|
||||
this.uniformValues[REACTION_MATRIX_COL1 + 1] = color2ToColor2;
|
||||
this.uniformValues[REACTION_MATRIX_COL1 + 2] = color2ToColor3;
|
||||
this.uniformValues[REACTION_MATRIX_COL2] = color3ToColor1;
|
||||
this.uniformValues[REACTION_MATRIX_COL2 + 1] = color3ToColor2;
|
||||
this.uniformValues[REACTION_MATRIX_COL2 + 2] = color3ToColor3;
|
||||
this.uniformValues[SCALAR_BASE + 0] = moveSpeed * deltaTime;
|
||||
this.uniformValues[SCALAR_BASE + 1] = turnSpeed * deltaTime;
|
||||
const sensorAngle = (sensorOffsetAngle * Math.PI) / 180;
|
||||
this.uniformValues[2] = Math.sin(sensorAngle);
|
||||
this.uniformValues[3] = Math.cos(sensorAngle);
|
||||
this.uniformValues[4] = sensorOffsetDistance;
|
||||
this.uniformValues[5] = turnWhenLost;
|
||||
this.uniformValues[6] = individualTrailWeight;
|
||||
this.uniformUintValues[7] = Math.max(0, Math.floor(agentCount));
|
||||
this.uniformValues[8] = introProgress ?? 1;
|
||||
this.uniformValues[9] = color1ToColor1;
|
||||
this.uniformValues[10] = color1ToColor2;
|
||||
this.uniformValues[11] = color1ToColor3;
|
||||
this.uniformValues[12] = color2ToColor1;
|
||||
this.uniformValues[13] = color2ToColor2;
|
||||
this.uniformValues[14] = color2ToColor3;
|
||||
this.uniformValues[15] = color3ToColor1;
|
||||
this.uniformValues[16] = color3ToColor2;
|
||||
this.uniformValues[17] = color3ToColor3;
|
||||
this.uniformValues[18] = forwardRotationScale;
|
||||
this.uniformValues[19] = introNearDistanceInner;
|
||||
this.uniformValues[20] = introNearDistanceMin;
|
||||
this.uniformValues[21] = introNearSensorOffsetMultiplier;
|
||||
this.uniformValues[22] = introTargetAngleBlend;
|
||||
this.uniformValues[23] = introProgressCutoff;
|
||||
this.uniformValues[24] = introTurnRateMultiplier;
|
||||
this.uniformValues[25] = introRandomTurnMultiplier;
|
||||
this.uniformValues[26] = introFarMoveMultiplier;
|
||||
this.uniformValues[27] = introNearMoveMultiplier;
|
||||
this.uniformValues[28] = introStepStopDistance;
|
||||
this.uniformUintValues[29] = Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
||||
this.uniformValues[SCALAR_BASE + 2] = Math.sin(sensorAngle);
|
||||
this.uniformValues[SCALAR_BASE + 3] = Math.cos(sensorAngle);
|
||||
this.uniformValues[SCALAR_BASE + 4] = sensorOffsetDistance;
|
||||
this.uniformValues[SCALAR_BASE + 5] = turnWhenLost;
|
||||
this.uniformValues[SCALAR_BASE + 6] = individualTrailWeight;
|
||||
this.uniformUintValues[SCALAR_BASE + 7] = Math.max(0, Math.floor(agentCount));
|
||||
this.uniformValues[SCALAR_BASE + 8] = resolvedIntroProgress;
|
||||
this.uniformValues[SCALAR_BASE + 9] = forwardRotationScale;
|
||||
this.uniformValues[SCALAR_BASE + 10] = introNearDistanceInner;
|
||||
this.uniformValues[SCALAR_BASE + 11] = introNearDistanceMin;
|
||||
this.uniformValues[SCALAR_BASE + 12] = introNearSensorOffsetMultiplier;
|
||||
this.uniformValues[SCALAR_BASE + 13] = introTargetAngleBlend;
|
||||
this.uniformValues[SCALAR_BASE + 14] = introProgressCutoff;
|
||||
this.uniformValues[SCALAR_BASE + 15] = introTurnRateMultiplier;
|
||||
this.uniformValues[SCALAR_BASE + 16] = introRandomTurnMultiplier;
|
||||
this.uniformValues[SCALAR_BASE + 17] = introMoveSpeed * deltaTime;
|
||||
this.uniformValues[SCALAR_BASE + 18] = introStepStopDistance;
|
||||
this.uniformUintValues[SCALAR_BASE + 19] =
|
||||
Math.max(0, Math.floor(time * randomTimeScale)) >>> 0;
|
||||
writeBufferIfChanged(
|
||||
this.device,
|
||||
this.uniforms,
|
||||
|
|
@ -219,7 +239,9 @@ export class AgentPipeline {
|
|||
const passEncoder = commandEncoder.beginComputePass(
|
||||
timestampWrites ? { timestampWrites } : undefined
|
||||
);
|
||||
passEncoder.setPipeline(this.pipeline);
|
||||
passEncoder.setPipeline(
|
||||
this.useSteadyPipeline ? this.pipelineSteady : this.pipelineFull
|
||||
);
|
||||
this.commonState.execute(passEncoder);
|
||||
passEncoder.setBindGroup(
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,16 @@ const PI: f32 = 3.14159265359;
|
|||
const TAU: f32 = 6.28318530718;
|
||||
const INV_TAU: f32 = 0.15915494309;
|
||||
|
||||
const CHANNEL_MASKS = array<vec3<f32>, 3>(
|
||||
vec3<f32>(1.0, 0.0, 0.0),
|
||||
vec3<f32>(0.0, 1.0, 0.0),
|
||||
vec3<f32>(0.0, 0.0, 1.0),
|
||||
);
|
||||
|
||||
struct Settings {
|
||||
// Columns are indexed by source colorIndex; each column holds the per-target
|
||||
// weights (colorXToColor1, colorXToColor2, colorXToColor3).
|
||||
reactionMatrix: mat3x3<f32>,
|
||||
moveRate: f32,
|
||||
turnRate: f32,
|
||||
sensorAngleSin: f32,
|
||||
|
|
@ -12,15 +21,6 @@ struct Settings {
|
|||
individualTrailWeight: f32,
|
||||
agentCount: u32,
|
||||
introProgress: f32,
|
||||
color1ToColor1: f32,
|
||||
color1ToColor2: f32,
|
||||
color1ToColor3: f32,
|
||||
color2ToColor1: f32,
|
||||
color2ToColor2: f32,
|
||||
color2ToColor3: f32,
|
||||
color3ToColor1: f32,
|
||||
color3ToColor2: f32,
|
||||
color3ToColor3: f32,
|
||||
forwardRotationScale: f32,
|
||||
introNearDistanceInner: f32,
|
||||
introNearDistanceMin: f32,
|
||||
|
|
@ -29,8 +29,7 @@ struct Settings {
|
|||
introProgressCutoff: f32,
|
||||
introTurnRateMultiplier: f32,
|
||||
introRandomTurnMultiplier: f32,
|
||||
introFarMoveMultiplier: f32,
|
||||
introNearMoveMultiplier: f32,
|
||||
introMoveRate: f32,
|
||||
introStepStopDistance: f32,
|
||||
randomTimeSeed: u32,
|
||||
};
|
||||
|
|
@ -39,6 +38,11 @@ struct Settings {
|
|||
@group(1) @binding(2) var trailMapIn: texture_2d<f32>;
|
||||
@group(1) @binding(3) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
struct AgentMovement {
|
||||
rotation: f32,
|
||||
step: vec2<f32>,
|
||||
}
|
||||
|
||||
@compute @workgroup_size(agentWorkgroupSize)
|
||||
fn main(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
|
|
@ -54,8 +58,8 @@ fn main(
|
|||
return;
|
||||
}
|
||||
|
||||
var position = agents[id].position;
|
||||
var angle = agents[id].angle;
|
||||
let position = agents[id].position;
|
||||
let angle = agents[id].angle;
|
||||
var targetPosition = vec2<f32>(-1.0, -1.0);
|
||||
var hasIntroTarget = false;
|
||||
if settings.introProgress < settings.introProgressCutoff {
|
||||
|
|
@ -70,92 +74,153 @@ fn main(
|
|||
let reactionMask = get_reaction_mask(colorIndex);
|
||||
let randomSeed = random_seed(id);
|
||||
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||
var rotation = 0.0;
|
||||
var step = vec2<f32>(0.0, 0.0);
|
||||
|
||||
var movement = AgentMovement(0.0, vec2<f32>(0.0, 0.0));
|
||||
if hasIntroTarget {
|
||||
let introTargetOffset = targetPosition - position;
|
||||
let introTargetDistance = length(introTargetOffset);
|
||||
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
||||
let nearTitle = 1.0 - smoothstep(
|
||||
settings.introNearDistanceInner,
|
||||
max(
|
||||
settings.introNearDistanceMin,
|
||||
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
|
||||
),
|
||||
introTargetDistance
|
||||
);
|
||||
let desiredAngle = mix(
|
||||
targetAngle,
|
||||
agents[id].targetAngle,
|
||||
nearTitle * settings.introTargetAngleBlend
|
||||
);
|
||||
let introTurn = angle_delta(angle, desiredAngle);
|
||||
|
||||
rotation = clamp(
|
||||
introTurn,
|
||||
-settings.turnRate * settings.introTurnRateMultiplier,
|
||||
settings.turnRate * settings.introTurnRateMultiplier
|
||||
)
|
||||
+ (random_float(randomSeed + 1013904223u) - 0.5) *
|
||||
settings.turnWhenLost *
|
||||
settings.introRandomTurnMultiplier;
|
||||
let moveRate = min(
|
||||
settings.moveRate *
|
||||
mix(settings.introFarMoveMultiplier, settings.introNearMoveMultiplier, nearTitle),
|
||||
introTargetDistance
|
||||
);
|
||||
if introTargetDistance > settings.introStepStopDistance {
|
||||
step = introTargetOffset / introTargetDistance * moveRate;
|
||||
}
|
||||
movement = intro_decide(id, position, angle, targetPosition, randomSeed);
|
||||
} else {
|
||||
let randomTurn = random_float(randomSeed);
|
||||
let direction = vec2(cos(angle), sin(angle));
|
||||
|
||||
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
|
||||
let leftSensor = sensor_position(
|
||||
position,
|
||||
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
|
||||
settings.sensorOffset,
|
||||
maxPosition
|
||||
);
|
||||
let rightSensor = sensor_position(
|
||||
position,
|
||||
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
|
||||
settings.sensorOffset,
|
||||
maxPosition
|
||||
);
|
||||
|
||||
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;
|
||||
movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
|
||||
}
|
||||
|
||||
let nextPosition = clamp(position + step, vec2<f32>(0, 0), maxPosition);
|
||||
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
|
||||
}
|
||||
|
||||
// Steady-state-only entry point used after introProgress >= introProgressCutoff.
|
||||
// Drops the intro target reads, atan2/smoothstep math, and introDelay check —
|
||||
// once intro completes those paths are dead for the rest of the session.
|
||||
@compute @workgroup_size(agentWorkgroupSize)
|
||||
fn mainSteady(
|
||||
@builtin(global_invocation_id) global_id: vec3<u32>
|
||||
) {
|
||||
let id = get_id(global_id);
|
||||
|
||||
if id >= settings.agentCount {
|
||||
return;
|
||||
}
|
||||
|
||||
let colorIndex = agents[id].colorIndex;
|
||||
if colorIndex < 0.0 || colorIndex >= 2.5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let position = agents[id].position;
|
||||
let angle = agents[id].angle;
|
||||
let channelMask = get_channel_mask(colorIndex);
|
||||
let reactionMask = get_reaction_mask(colorIndex);
|
||||
let randomSeed = random_seed(id);
|
||||
let maxPosition = state.size - vec2<f32>(1.0, 1.0);
|
||||
|
||||
let movement = steady_decide(position, angle, reactionMask, randomSeed, maxPosition);
|
||||
agent_finalize(id, position, angle, channelMask, randomSeed, maxPosition, movement);
|
||||
}
|
||||
|
||||
fn steady_decide(
|
||||
position: vec2<f32>,
|
||||
angle: f32,
|
||||
reactionMask: vec3<f32>,
|
||||
randomSeed: u32,
|
||||
maxPosition: vec2<f32>
|
||||
) -> AgentMovement {
|
||||
let randomTurn = random_float(randomSeed);
|
||||
let direction = vec2(cos(angle), sin(angle));
|
||||
|
||||
let forwardSensor = sensor_position(position, direction, settings.sensorOffset, maxPosition);
|
||||
let leftSensor = sensor_position(
|
||||
position,
|
||||
rotate_direction(direction, settings.sensorAngleSin, settings.sensorAngleCos),
|
||||
settings.sensorOffset,
|
||||
maxPosition
|
||||
);
|
||||
let rightSensor = sensor_position(
|
||||
position,
|
||||
rotate_direction(direction, -settings.sensorAngleSin, settings.sensorAngleCos),
|
||||
settings.sensorOffset,
|
||||
maxPosition
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
var rotation = (randomTurn - 0.5) * settings.turnWhenLost;
|
||||
if weightForward >= weightLeft && weightForward >= weightRight {
|
||||
rotation = rotation * settings.forwardRotationScale;
|
||||
} else {
|
||||
rotation += sign(weightLeft - weightRight) * settings.turnRate;
|
||||
}
|
||||
|
||||
return AgentMovement(rotation, direction * settings.moveRate);
|
||||
}
|
||||
|
||||
fn intro_decide(
|
||||
id: u32,
|
||||
position: vec2<f32>,
|
||||
angle: f32,
|
||||
targetPosition: vec2<f32>,
|
||||
randomSeed: u32
|
||||
) -> AgentMovement {
|
||||
let introTargetOffset = targetPosition - position;
|
||||
let introTargetDistance = length(introTargetOffset);
|
||||
let targetAngle = atan2(introTargetOffset.y, introTargetOffset.x);
|
||||
let nearTitle = 1.0 - smoothstep(
|
||||
settings.introNearDistanceInner,
|
||||
max(
|
||||
settings.introNearDistanceMin,
|
||||
settings.sensorOffset * settings.introNearSensorOffsetMultiplier
|
||||
),
|
||||
introTargetDistance
|
||||
);
|
||||
let desiredAngle = mix(
|
||||
targetAngle,
|
||||
agents[id].targetAngle,
|
||||
nearTitle * settings.introTargetAngleBlend
|
||||
);
|
||||
let introTurn = angle_delta(angle, desiredAngle);
|
||||
|
||||
let rotation = clamp(
|
||||
introTurn,
|
||||
-settings.turnRate * settings.introTurnRateMultiplier,
|
||||
settings.turnRate * settings.introTurnRateMultiplier
|
||||
)
|
||||
+ (random_float(randomSeed + 1013904223u) - 0.5) *
|
||||
settings.turnWhenLost *
|
||||
settings.introRandomTurnMultiplier;
|
||||
let moveRate = min(settings.introMoveRate, introTargetDistance);
|
||||
var step = vec2<f32>(0.0, 0.0);
|
||||
if introTargetDistance > settings.introStepStopDistance {
|
||||
step = introTargetOffset / introTargetDistance * moveRate;
|
||||
}
|
||||
return AgentMovement(rotation, step);
|
||||
}
|
||||
|
||||
fn agent_finalize(
|
||||
id: u32,
|
||||
position: vec2<f32>,
|
||||
angle: f32,
|
||||
channelMask: vec3<f32>,
|
||||
randomSeed: u32,
|
||||
maxPosition: vec2<f32>,
|
||||
movement: AgentMovement
|
||||
) {
|
||||
let nextPosition = clamp(position + movement.step, vec2<f32>(0, 0), maxPosition);
|
||||
var rotation = movement.rotation;
|
||||
if nextPosition.x == 0 || nextPosition.x == maxPosition.x || nextPosition.y == 0 || nextPosition.y == maxPosition.y {
|
||||
rotation = PI + random_float(randomSeed + 22695477u) - 0.5;
|
||||
}
|
||||
|
||||
var trailBelow = textureLoad(trailMapIn, vec2<i32>(nextPosition), 0);
|
||||
trailBelow = vec4<f32>(
|
||||
trailBelow.rgb + channelMask * settings.individualTrailWeight,
|
||||
max(trailBelow.a, 0.0)
|
||||
// Writes only the deposit into a per-frame-cleared depositMap. The diffusion
|
||||
// pass sums trailMap + depositMap at tile-load time, so the previous trail
|
||||
// value is no longer needed here. Alpha stays 0 in depositMap — diffuse's
|
||||
// alpha decay reads it from trailMap (where deposit alpha contributes 0).
|
||||
textureStore(
|
||||
trailMapOut,
|
||||
vec2<i32>(nextPosition),
|
||||
vec4<f32>(channelMask * settings.individualTrailWeight, 0.0)
|
||||
);
|
||||
|
||||
textureStore(trailMapOut, vec2<i32>(nextPosition), trailBelow);
|
||||
agents[id].angle = angle + rotation;
|
||||
agents[id].position = nextPosition;
|
||||
}
|
||||
|
|
@ -181,41 +246,11 @@ fn rotate_direction(direction: vec2<f32>, angleSin: f32, angleCos: f32) -> vec2<
|
|||
}
|
||||
|
||||
fn get_channel_mask(colorIndex: f32) -> vec3<f32> {
|
||||
if colorIndex < 0.5 {
|
||||
return vec3<f32>(1, 0, 0);
|
||||
}
|
||||
if colorIndex < 1.5 {
|
||||
return vec3<f32>(0, 1, 0);
|
||||
}
|
||||
if colorIndex < 2.5 {
|
||||
return vec3<f32>(0, 0, 1);
|
||||
}
|
||||
return vec3<f32>(0.0, 0.0, 0.0);
|
||||
return CHANNEL_MASKS[u32(clamp(colorIndex, 0.0, 2.0))];
|
||||
}
|
||||
|
||||
fn get_reaction_mask(colorIndex: f32) -> vec3<f32> {
|
||||
if colorIndex < 0.5 {
|
||||
return vec3<f32>(
|
||||
settings.color1ToColor1,
|
||||
settings.color1ToColor2,
|
||||
settings.color1ToColor3
|
||||
);
|
||||
}
|
||||
if colorIndex < 1.5 {
|
||||
return vec3<f32>(
|
||||
settings.color2ToColor1,
|
||||
settings.color2ToColor2,
|
||||
settings.color2ToColor3
|
||||
);
|
||||
}
|
||||
if colorIndex < 2.5 {
|
||||
return vec3<f32>(
|
||||
settings.color3ToColor1,
|
||||
settings.color3ToColor2,
|
||||
settings.color3ToColor3
|
||||
);
|
||||
}
|
||||
return vec3<f32>(0.0, 0.0, 0.0);
|
||||
return settings.reactionMatrix[u32(clamp(colorIndex, 0.0, 2.0))];
|
||||
}
|
||||
|
||||
fn angle_delta(sourceAngle: f32, targetAngle: f32) -> f32 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../../config';
|
||||
import { getRenderQualityBrushSize } from '../../config/brush-size';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
writeBufferIfChanged,
|
||||
|
|
@ -28,6 +29,7 @@ export interface BrushSettings {
|
|||
}
|
||||
|
||||
interface BrushParameters extends BrushSettings {
|
||||
internalRenderAreaMegapixels: number;
|
||||
pixelRatio?: number;
|
||||
selectedColorIndex: number;
|
||||
}
|
||||
|
|
@ -50,12 +52,16 @@ const setBrushUniformValues = (
|
|||
brushGrainNoiseOffsetY,
|
||||
brushGrainMinStrength,
|
||||
brushGrainMaxStrength,
|
||||
internalRenderAreaMegapixels,
|
||||
selectedColorIndex,
|
||||
pixelRatio,
|
||||
}: BrushParameters
|
||||
): void => {
|
||||
const safePixelRatio = getSafePixelRatio(pixelRatio);
|
||||
const brushRadius = (brushSize * safePixelRatio) / 2;
|
||||
const brushRadius =
|
||||
(getRenderQualityBrushSize(brushSize, internalRenderAreaMegapixels) *
|
||||
safePixelRatio) /
|
||||
2;
|
||||
|
||||
target[0] = brushRadius;
|
||||
target[1] = brushRadius * brushRadius;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ const HASH_TO_UNIT_FLOAT: f32 = 2.3283064365386963e-10;
|
|||
@group(0) @binding(0) var<uniform> settings: Settings;
|
||||
@group(0) @binding(1) var trailMap: texture_2d<f32>;
|
||||
@group(0) @binding(2) var trailMapOut: texture_storage_2d<rgba8unorm, write>;
|
||||
// Per-frame deposit accumulator written sparsely by agents. Summed with
|
||||
// trailMap at tile-load so deposits propagate through the diffusion kernel
|
||||
// in the same frame.
|
||||
@group(0) @binding(3) var depositMap: texture_2d<f32>;
|
||||
|
||||
var<workgroup> tile: array<vec4<f32>, 324>;
|
||||
var<workgroup> tileTrailStrength: array<f32, 324>;
|
||||
|
|
@ -49,7 +53,8 @@ fn main(
|
|||
vec2<i32>(0, 0),
|
||||
textureBound
|
||||
);
|
||||
let texel = textureLoad(trailMap, sourcePixel, 0);
|
||||
let texel = textureLoad(trailMap, sourcePixel, 0)
|
||||
+ textureLoad(depositMap, sourcePixel, 0);
|
||||
tile[tileIndex] = texel;
|
||||
tileTrailStrength[tileIndex] = length(texel.rgb);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../../config';
|
||||
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||
import { createBindGroupCache3 } from '../../utils/graphics/bind-group-cache';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
writeBufferIfChanged,
|
||||
|
|
@ -69,20 +69,29 @@ export class DiffusionPipeline {
|
|||
private readonly bindGroupLayout: GPUBindGroupLayout;
|
||||
private readonly pipeline: GPUComputePipeline;
|
||||
private readonly uniforms: GPUBuffer;
|
||||
// 1x1 zero texture used as the depositMap binding when callers don't supply
|
||||
// one (e.g. source-map diffusion). WebGPU's textureLoad returns zero for
|
||||
// out-of-bounds coordinates, so the diffusion shader sums in zeros.
|
||||
private readonly emptyDepositTexture: GPUTexture;
|
||||
private readonly emptyDepositTextureView: GPUTextureView;
|
||||
private readonly uniformValues = new Float32Array(DiffusionPipeline.UNIFORM_COUNT);
|
||||
private readonly uniformCache = createCachedBufferWrite(
|
||||
DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT
|
||||
);
|
||||
private readonly getBindGroup = createBindGroupCache<GPUTextureView, GPUTextureView>(
|
||||
(trailMapIn, trailMapOut) =>
|
||||
this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||
{ binding: 1, resource: trailMapIn },
|
||||
{ binding: 2, resource: trailMapOut },
|
||||
],
|
||||
})
|
||||
private readonly getBindGroup = createBindGroupCache3<
|
||||
GPUTextureView,
|
||||
GPUTextureView,
|
||||
GPUTextureView
|
||||
>((trailMapIn, trailMapOut, depositMap) =>
|
||||
this.device.createBindGroup({
|
||||
layout: this.bindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.uniforms } },
|
||||
{ binding: 1, resource: trailMapIn },
|
||||
{ binding: 2, resource: trailMapOut },
|
||||
{ binding: 3, resource: depositMap },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
public constructor(private readonly device: GPUDevice) {
|
||||
|
|
@ -104,6 +113,26 @@ export class DiffusionPipeline {
|
|||
size: DiffusionPipeline.UNIFORM_COUNT * Float32Array.BYTES_PER_ELEMENT,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.emptyDepositTexture = device.createTexture({
|
||||
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||
size: { width: 1, height: 1 },
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
this.emptyDepositTextureView = this.emptyDepositTexture.createView();
|
||||
const clearEncoder = device.createCommandEncoder();
|
||||
const clearPass = clearEncoder.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{
|
||||
view: this.emptyDepositTextureView,
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
},
|
||||
],
|
||||
});
|
||||
clearPass.end();
|
||||
device.queue.submit([clearEncoder.finish()]);
|
||||
}
|
||||
|
||||
public setParameters({
|
||||
|
|
@ -135,9 +164,14 @@ export class DiffusionPipeline {
|
|||
trailMapIn: GPUTextureView,
|
||||
trailMapOut: GPUTextureView,
|
||||
size: vec2,
|
||||
depositMap: GPUTextureView | null,
|
||||
timestampWrites?: GPUComputePassTimestampWrites
|
||||
) {
|
||||
const bindGroup = this.getBindGroup(trailMapIn, trailMapOut);
|
||||
const bindGroup = this.getBindGroup(
|
||||
trailMapIn,
|
||||
trailMapOut,
|
||||
depositMap ?? this.emptyDepositTextureView
|
||||
);
|
||||
|
||||
const passEncoder = commandEncoder.beginComputePass(
|
||||
timestampWrites ? { timestampWrites } : undefined
|
||||
|
|
@ -153,6 +187,7 @@ export class DiffusionPipeline {
|
|||
|
||||
public destroy() {
|
||||
this.uniforms.destroy();
|
||||
this.emptyDepositTexture.destroy();
|
||||
}
|
||||
|
||||
private static get bindGroupLayout(): GPUBindGroupLayoutDescriptor {
|
||||
|
|
@ -180,6 +215,13 @@ export class DiffusionPipeline {
|
|||
format: TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||
},
|
||||
},
|
||||
{
|
||||
binding: 3,
|
||||
visibility: GPUShaderStage.COMPUTE,
|
||||
texture: {
|
||||
sampleType: 'float',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export class EraserAgentPipeline {
|
|||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
this.workgroupSize = getAgentWorkgroupSize(device);
|
||||
this.workgroupSize = getAgentWorkgroupSize(device, 'eraser');
|
||||
this.pipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [emptyBindGroupLayout, this.bindGroupLayout],
|
||||
|
|
@ -94,7 +94,7 @@ export class EraserAgentPipeline {
|
|||
compute: {
|
||||
module: smartCompile(
|
||||
device,
|
||||
substituteAgentWorkgroupSize(device, agentSchema),
|
||||
substituteAgentWorkgroupSize(device, agentSchema, 'eraser'),
|
||||
shader
|
||||
),
|
||||
entryPoint: 'main',
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ export const settings: GardenRuntimeSettings = {
|
|||
...buildSettings(activeVibe),
|
||||
};
|
||||
|
||||
export const rememberActiveVibeSelection = (): void => {
|
||||
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
|
||||
};
|
||||
|
||||
export const applyVibeSettings = (vibe: VibePreset) => {
|
||||
activeVibe = cloneVibePreset(vibe);
|
||||
const nextSettings = buildSettings(activeVibe);
|
||||
|
|
@ -59,7 +63,7 @@ export const applyVibeSettings = (vibe: VibePreset) => {
|
|||
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
|
||||
);
|
||||
|
||||
writeBrowserStorage(appConfig.storage.vibeKey, vibe.id);
|
||||
rememberActiveVibeSelection();
|
||||
|
||||
return activeVibe;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@
|
|||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
// Tweakpane v4 internal classes — re-verify on upgrade.
|
||||
// No public theming hook exists for label padding or the slider/number
|
||||
// flex ratio; if a fourth override appears here, switch to a custom plugin.
|
||||
.tp-lblv_l {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
|
@ -139,6 +142,7 @@
|
|||
|
||||
font-size: 11px;
|
||||
|
||||
// Tweakpane v4 internal class — re-verify on upgrade.
|
||||
.tp-sldtxtv_t {
|
||||
flex-basis: 48px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,20 @@
|
|||
--toolbar-background-strength: 0;
|
||||
--toolbar-divider-space: clamp(6px, 1.8vw, 14px);
|
||||
--toolbar-top-max-width: 594px;
|
||||
--vibe-button-hit-size: 64px;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'previous controls next'
|
||||
'previous divider next'
|
||||
'previous buttons next';
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-columns:
|
||||
var(--vibe-button-hit-size)
|
||||
minmax(0, var(--toolbar-top-max-width))
|
||||
var(--vibe-button-hit-size);
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding-inline: clamp(8px, 1.4vw, 14px);
|
||||
|
|
@ -85,9 +89,9 @@
|
|||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
width: var(--vibe-button-hit-size);
|
||||
height: auto;
|
||||
min-height: 66px;
|
||||
min-height: 72px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
@include on-small-screen {
|
||||
--toolbar-divider-space: 4px;
|
||||
--toolbar-top-max-width: 329px;
|
||||
--vibe-button-hit-size: 44px;
|
||||
|
||||
grid-template-areas:
|
||||
'previous controls next'
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
row-gap: 0;
|
||||
|
||||
> .vibe-button {
|
||||
width: 36px;
|
||||
width: var(--vibe-button-hit-size);
|
||||
min-height: 44px;
|
||||
|
||||
&::before {
|
||||
|
|
|
|||
54
src/vibe-uri.test.ts
Normal file
54
src/vibe-uri.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { VibeId } from './config/types';
|
||||
import { createVibeUri, getVibeIdFromUri } from './vibe-uri';
|
||||
|
||||
describe('vibe URI handling', () => {
|
||||
it('loads vibes from slug IDs and display names', () => {
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=aurora-mycelium')).toBe(
|
||||
VibeId.AuroraMycelium
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=Aurora%20Mycelium')).toBe(
|
||||
VibeId.AuroraMycelium
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=velvet%20observatory')).toBe(
|
||||
VibeId.VelvetObservatory
|
||||
);
|
||||
});
|
||||
|
||||
it('uses query values before path or hash fallbacks', () => {
|
||||
expect(
|
||||
getVibeIdFromUri(
|
||||
'https://example.test/chrome-pollen?vibe=lichen-signal#vibe=aurora-mycelium'
|
||||
)
|
||||
).toBe(VibeId.LichenSignal);
|
||||
});
|
||||
|
||||
it('accepts explicit path segments and hash fallbacks', () => {
|
||||
expect(getVibeIdFromUri('https://example.test/vibes/tidepool-lantern')).toBe(
|
||||
VibeId.TidepoolLantern
|
||||
);
|
||||
expect(getVibeIdFromUri('https://example.test/#paper-lantern-fog')).toBe(
|
||||
VibeId.PaperLanternFog
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores unknown or malformed vibe values', () => {
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=missing')).toBeNull();
|
||||
expect(getVibeIdFromUri('https://example.test/?vibe=%E0%A4%A')).toBeNull();
|
||||
expect(getVibeIdFromUri('not a url')).toBeNull();
|
||||
});
|
||||
|
||||
it('creates a canonical query URI without dropping other URL parts', () => {
|
||||
expect(
|
||||
createVibeUri('https://example.test/garden?debug=1#panel', VibeId.ChromePollen)
|
||||
).toBe('/garden?debug=1&vibe=chrome-pollen#panel');
|
||||
|
||||
expect(
|
||||
createVibeUri(
|
||||
'https://example.test/garden?vibe=aurora-mycelium&debug=1',
|
||||
VibeId.LichenSignal
|
||||
)
|
||||
).toBe('/garden?vibe=lichen-signal&debug=1');
|
||||
});
|
||||
});
|
||||
148
src/vibe-uri.ts
Normal file
148
src/vibe-uri.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { VibeId } from './config/types';
|
||||
import { vibePresets } from './config/vibe-presets';
|
||||
|
||||
const VIBE_URI_QUERY_PARAM = 'vibe';
|
||||
const FALLBACK_URL_ORIGIN = 'https://fleeting.garden';
|
||||
|
||||
const slugifyVibeName = (value: string): string =>
|
||||
value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const safeDecodeURIComponent = (value: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeVibeIdentifier = (value: string): string =>
|
||||
slugifyVibeName(safeDecodeURIComponent(value).replace(/^[#/\\?\s]+|[/\\?\s]+$/g, ''));
|
||||
|
||||
const vibeIdByIdentifier = new Map<string, VibeId>();
|
||||
|
||||
for (const vibe of vibePresets) {
|
||||
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.id), vibe.id);
|
||||
vibeIdByIdentifier.set(normalizeVibeIdentifier(vibe.name), vibe.id);
|
||||
}
|
||||
|
||||
const toUrl = (url: string | URL): URL | null => {
|
||||
try {
|
||||
return new URL(url, FALLBACK_URL_ORIGIN);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveVibeId = (value: string | null | undefined): VibeId | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return vibeIdByIdentifier.get(normalizeVibeIdentifier(value)) ?? null;
|
||||
};
|
||||
|
||||
const getHashSearchParam = (hash: string): string | null => {
|
||||
const hashValue = hash.replace(/^#/, '');
|
||||
if (!hashValue.includes('=')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchText = hashValue.startsWith('?') ? hashValue.slice(1) : hashValue;
|
||||
try {
|
||||
return new URLSearchParams(searchText).get(VIBE_URI_QUERY_PARAM);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPathVibeCandidates = (pathname: string): Array<string> => {
|
||||
const segments = pathname.split('/').map(safeDecodeURIComponent).filter(Boolean);
|
||||
const explicitVibeIndex = segments.findIndex((segment) =>
|
||||
['vibe', 'vibes'].includes(segment.toLowerCase())
|
||||
);
|
||||
|
||||
return [
|
||||
explicitVibeIndex >= 0 ? segments[explicitVibeIndex + 1] : undefined,
|
||||
segments.at(-1),
|
||||
].filter((candidate): candidate is string => typeof candidate === 'string');
|
||||
};
|
||||
|
||||
export const getVibeIdFromUri = (url: string | URL): VibeId | null => {
|
||||
const parsedUrl = toUrl(url);
|
||||
if (!parsedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
parsedUrl.searchParams.get(VIBE_URI_QUERY_PARAM),
|
||||
getHashSearchParam(parsedUrl.hash),
|
||||
...getPathVibeCandidates(parsedUrl.pathname),
|
||||
parsedUrl.hash.replace(/^#/, ''),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const vibeId = resolveVibeId(candidate);
|
||||
if (vibeId) {
|
||||
return vibeId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCurrentUriVibeId = (): VibeId | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getVibeIdFromUri(window.location.href);
|
||||
};
|
||||
|
||||
const getVibeSlug = (vibeId: VibeId): string => {
|
||||
const vibe = vibePresets.find((preset) => preset.id === vibeId);
|
||||
return vibe ? slugifyVibeName(vibe.name) : vibeId;
|
||||
};
|
||||
|
||||
export const createVibeUri = (url: string | URL, vibeId: VibeId): string => {
|
||||
const parsedUrl = toUrl(url);
|
||||
if (!parsedUrl) {
|
||||
return `?${VIBE_URI_QUERY_PARAM}=${encodeURIComponent(getVibeSlug(vibeId))}`;
|
||||
}
|
||||
|
||||
parsedUrl.searchParams.set(VIBE_URI_QUERY_PARAM, getVibeSlug(vibeId));
|
||||
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
};
|
||||
|
||||
export const writeCurrentVibeUri = (
|
||||
vibeId: VibeId,
|
||||
mode: 'push' | 'replace' = 'replace'
|
||||
): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUri = createVibeUri(window.location.href, vibeId);
|
||||
const currentUri = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (nextUri === currentUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState =
|
||||
typeof window.history.state === 'object' && window.history.state !== null
|
||||
? { ...window.history.state, vibeId }
|
||||
: { vibeId };
|
||||
|
||||
if (mode === 'push') {
|
||||
window.history.pushState(nextState, '', nextUri);
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState(nextState, '', nextUri);
|
||||
};
|
||||
19
src/vibes.ts
19
src/vibes.ts
|
|
@ -1,6 +1,7 @@
|
|||
import { appConfig } from './config';
|
||||
import { VibeId, type VibePreset } from './config/types';
|
||||
import { readBrowserStorage } from './utils/browser-storage';
|
||||
import { getCurrentUriVibeId, getVibeIdFromUri } from './vibe-uri';
|
||||
|
||||
export { VibeId };
|
||||
export type { VibePreset };
|
||||
|
|
@ -11,11 +12,17 @@ const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
|||
const isVibeId = (value: unknown): value is VibeId =>
|
||||
typeof value === 'string' && VIBE_IDS.has(value as VibeId);
|
||||
|
||||
export const getInitialVibe = (): VibePreset => {
|
||||
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||
const initialVibeId = isVibeId(storedVibeId)
|
||||
? storedVibeId
|
||||
: appConfig.vibes.defaultVibeId;
|
||||
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
|
||||
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
|
||||
|
||||
return VIBE_PRESETS.find((vibe) => vibe.id === initialVibeId) ?? VIBE_PRESETS[0];
|
||||
export const getInitialVibe = (): VibePreset => {
|
||||
const uriVibeId = getCurrentUriVibeId();
|
||||
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||
const storedOrLegacyVibeId = isVibeId(storedVibeId)
|
||||
? storedVibeId
|
||||
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
|
||||
const initialVibeId =
|
||||
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
|
||||
|
||||
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export default defineConfig(({ command }) => ({
|
|||
cssMinify: 'lightningcss',
|
||||
},
|
||||
server: {
|
||||
open: true,
|
||||
host: true,
|
||||
hmr: false,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue