Add configurable vibe presets
This commit is contained in:
parent
e54bddc7db
commit
f8294934fd
34 changed files with 1698 additions and 338 deletions
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);
|
||||
14
src/config/color-interactions.ts
Normal file
14
src/config/color-interactions.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { NumberControlConfig } from './types';
|
||||
|
||||
export const colorInteractionControl = (label: string): NumberControlConfig => ({
|
||||
folder: 'Color Reactions',
|
||||
label,
|
||||
min: -1,
|
||||
max: 1,
|
||||
step: 1,
|
||||
options: {
|
||||
'Move Toward': 1,
|
||||
Ignore: 0,
|
||||
'Move Away': -1,
|
||||
},
|
||||
});
|
||||
74
src/config/default-settings.ts
Normal file
74
src/config/default-settings.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
|
||||
// Mirrors the historical render-scale cap so the default render area stays
|
||||
// roughly equivalent to native rendering on high-DPR phones without the
|
||||
// pipeline applying its own clamp. The slider can override freely.
|
||||
const DEFAULT_DEVICE_PIXEL_RATIO_CAP = 2;
|
||||
|
||||
const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
||||
const rawDpr =
|
||||
typeof window !== 'undefined' && Number.isFinite(window.devicePixelRatio)
|
||||
? window.devicePixelRatio
|
||||
: 1;
|
||||
const dpr = Math.min(Math.max(rawDpr, 1), DEFAULT_DEVICE_PIXEL_RATIO_CAP);
|
||||
const cssWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
|
||||
const cssHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
|
||||
const cssMegapixels = (Math.max(cssWidth, 1) * Math.max(cssHeight, 1)) / 1_000_000;
|
||||
return Math.min(
|
||||
INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
Math.max(INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min, dpr * dpr * cssMegapixels)
|
||||
);
|
||||
};
|
||||
|
||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||
selectedColorIndex: 0,
|
||||
|
||||
introNearDistanceMin: 28,
|
||||
introNearDistanceInner: 4,
|
||||
introNearSensorOffsetMultiplier: 0.75,
|
||||
introTargetAngleBlend: 0.2,
|
||||
introProgressCutoff: 0.999,
|
||||
introTurnRateMultiplier: 3.4,
|
||||
introRandomTurnMultiplier: 0.18,
|
||||
introStepStopDistance: 0.5,
|
||||
randomTimeScale: 0.34816,
|
||||
|
||||
diffusionRateTrails: 0.22,
|
||||
decayRateBrush: 18,
|
||||
diffusionDecayRateDivisor: 1000,
|
||||
diffusionNeighborDivisor: 8,
|
||||
brushDecayAlphaOffset: 1.001,
|
||||
brushEffectDuration: 8,
|
||||
|
||||
brushCurveResolution: 12,
|
||||
brushCurveMinBrushRadius: 1,
|
||||
brushCurveMinSegmentSpacing: 4,
|
||||
brushCurveMirrorResolutionExponent: 0.5,
|
||||
brushCurveSegmentBrushRadiusRatio: 0.65,
|
||||
brushSmoothingMinSampleDistance: 0.5,
|
||||
|
||||
brushAlpha: 1,
|
||||
brushDiscardThreshold: 0.02,
|
||||
brushGrainNoiseScale: 22,
|
||||
brushGrainNoiseOffsetX: 0.31,
|
||||
brushGrainNoiseOffsetY: 0.67,
|
||||
brushGrainMinStrength: 0.45,
|
||||
brushGrainMaxStrength: 1,
|
||||
|
||||
eraserClearAlpha: 0,
|
||||
eraserClearBlue: 0,
|
||||
eraserClearGreen: 0,
|
||||
eraserClearRed: 0,
|
||||
eraserLineDistanceEpsilon: 0.0001,
|
||||
eraserMaskAlphaThreshold: 0.5,
|
||||
|
||||
adaptiveCapInitial: 1_000_000,
|
||||
adaptiveCapMin: 50_000,
|
||||
internalRenderAreaMegapixels: computeDefaultInternalRenderAreaMegapixels(),
|
||||
maxAgentCount: 1_500_000,
|
||||
|
||||
renderTraceNormalizationFloor: 1,
|
||||
renderBrushColorBase: 1.2,
|
||||
renderBrushColorStrengthMultiplier: 1.6,
|
||||
};
|
||||
65
src/config/normalize-runtime-settings.test.ts
Normal file
65
src/config/normalize-runtime-settings.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './normalize-runtime-settings';
|
||||
import type { GardenRuntimeSettings } from './types';
|
||||
|
||||
describe('normalizeNumberControlValue', () => {
|
||||
it('clamps and rounds numeric controls', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(12.6, {
|
||||
folder: 'Test',
|
||||
integer: true,
|
||||
max: 10,
|
||||
min: 0,
|
||||
})
|
||||
).toBe(10);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(Number.NaN, {
|
||||
folder: 'Test',
|
||||
min: 3,
|
||||
})
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps only declared option values', () => {
|
||||
expect(
|
||||
normalizeNumberControlValue(2, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
normalizeNumberControlValue(3, {
|
||||
folder: 'Test',
|
||||
options: { off: 0, on: 2 },
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRuntimeSettings', () => {
|
||||
it('normalizes configured runtime keys and leaves hidden keys alone', () => {
|
||||
const settings = {
|
||||
brushSize: 99,
|
||||
selectedColorIndex: 7,
|
||||
} as GardenRuntimeSettings;
|
||||
|
||||
expect(
|
||||
normalizeRuntimeSettings(settings, {
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
max: 12,
|
||||
min: 1,
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
brushSize: 12,
|
||||
selectedColorIndex: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/config/normalize-runtime-settings.ts
Normal file
46
src/config/normalize-runtime-settings.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type {
|
||||
GardenAppConfig,
|
||||
GardenRuntimeSettings,
|
||||
NumberControlConfig,
|
||||
} from './types';
|
||||
|
||||
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
|
||||
|
||||
export const normalizeNumberControlValue = (
|
||||
value: number,
|
||||
config: NumberControlConfig
|
||||
): number => {
|
||||
if (config.options) {
|
||||
const optionValues = Object.values(config.options);
|
||||
if (optionValues.includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return optionValues.includes(0) ? 0 : (optionValues[0] ?? config.min ?? 0);
|
||||
}
|
||||
|
||||
const min = config.min ?? Number.NEGATIVE_INFINITY;
|
||||
const max = config.max ?? Number.POSITIVE_INFINITY;
|
||||
const fallbackValue = config.min ?? 0;
|
||||
const finiteValue = Number.isFinite(value) ? value : fallbackValue;
|
||||
const clampedValue = Math.min(max, Math.max(min, finiteValue));
|
||||
return config.integer ? Math.round(clampedValue) : clampedValue;
|
||||
};
|
||||
|
||||
export const normalizeRuntimeSettings = (
|
||||
settings: GardenRuntimeSettings,
|
||||
controls: RuntimeSettingControls
|
||||
): GardenRuntimeSettings => {
|
||||
const normalized = { ...settings };
|
||||
|
||||
(
|
||||
Object.entries(controls) as Array<
|
||||
[keyof GardenRuntimeSettings, NumberControlConfig | undefined]
|
||||
>
|
||||
).forEach(([key, config]) => {
|
||||
if (config) {
|
||||
normalized[key] = normalizeNumberControlValue(normalized[key], config);
|
||||
}
|
||||
});
|
||||
|
||||
return normalized;
|
||||
};
|
||||
147
src/config/runtime-controls.ts
Normal file
147
src/config/runtime-controls.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { colorInteractionControl } from './color-interactions';
|
||||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
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'),
|
||||
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
|
||||
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
|
||||
color2ToColor1: colorInteractionControl('Color 2 Follows Color 1'),
|
||||
color2ToColor2: colorInteractionControl('Color 2 Follows Color 2'),
|
||||
color2ToColor3: colorInteractionControl('Color 2 Follows Color 3'),
|
||||
color3ToColor1: colorInteractionControl('Color 3 Follows Color 1'),
|
||||
color3ToColor2: colorInteractionControl('Color 3 Follows Color 2'),
|
||||
color3ToColor3: colorInteractionControl('Color 3 Follows Color 3'),
|
||||
|
||||
brushSize: {
|
||||
folder: 'Brush',
|
||||
label: 'Brush Size',
|
||||
min: 1,
|
||||
max: 36,
|
||||
step: 0.25,
|
||||
},
|
||||
spawnPerPixel: {
|
||||
folder: 'Brush',
|
||||
label: 'Density',
|
||||
min: 0.01,
|
||||
max: 0.38,
|
||||
step: 0.001,
|
||||
},
|
||||
strokeAngleJitterRadians: {
|
||||
folder: 'Brush',
|
||||
format: formatRadiansAsDegrees,
|
||||
label: 'Spawn Spread',
|
||||
min: 0,
|
||||
max: Math.PI,
|
||||
step: 0.01,
|
||||
},
|
||||
sensorOffsetDistance: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Reach',
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
sensorOffsetAngle: {
|
||||
folder: 'Movement',
|
||||
label: 'Sensor Angle',
|
||||
min: 0,
|
||||
max: 180,
|
||||
step: 1,
|
||||
},
|
||||
moveSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Travel Speed',
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 1,
|
||||
},
|
||||
turnSpeed: {
|
||||
folder: 'Movement',
|
||||
label: 'Turning Speed',
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
forwardRotationScale: {
|
||||
folder: 'Movement',
|
||||
format: formatPercent,
|
||||
label: 'Forward Focus',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
turnWhenLost: {
|
||||
folder: 'Movement',
|
||||
label: 'Wander Turn',
|
||||
min: 0,
|
||||
max: Math.PI * 2,
|
||||
step: 0.01,
|
||||
},
|
||||
individualTrailWeight: {
|
||||
folder: 'Movement',
|
||||
label: 'Trail Strength',
|
||||
min: 0,
|
||||
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',
|
||||
min: 800,
|
||||
max: 1000,
|
||||
step: 1,
|
||||
},
|
||||
|
||||
clarity: {
|
||||
folder: 'Look',
|
||||
label: 'Sharpness',
|
||||
min: 0.00001,
|
||||
max: 1,
|
||||
step: 0.001,
|
||||
},
|
||||
backgroundGrainStrength: {
|
||||
folder: 'Look',
|
||||
label: 'Background Grain',
|
||||
min: 0,
|
||||
max: 0.12,
|
||||
step: 0.001,
|
||||
},
|
||||
|
||||
maxAgentCount: {
|
||||
folder: 'Performance',
|
||||
format: formatCompactNumber,
|
||||
integer: true,
|
||||
label: 'Population Limit',
|
||||
min: 0,
|
||||
step: 10_000,
|
||||
},
|
||||
internalRenderAreaMegapixels: {
|
||||
folder: 'Performance',
|
||||
label: 'Render Quality (MP)',
|
||||
min: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.min,
|
||||
max: INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS.max,
|
||||
step: 0.1,
|
||||
},
|
||||
};
|
||||
4
src/config/runtime-setting-bounds.ts
Normal file
4
src/config/runtime-setting-bounds.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS = {
|
||||
min: 0.5,
|
||||
max: 16.6,
|
||||
} as const;
|
||||
268
src/config/types.ts
Normal file
268
src/config/types.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeSettings,
|
||||
} from '../audio/garden-audio-config';
|
||||
import type { AgentSettings } from '../pipelines/agents/agent-pipeline';
|
||||
import type { BrushSettings } from '../pipelines/brush/brush-pipeline';
|
||||
import type { DiffusionSettings } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
import type { RenderSettings } from '../pipelines/render/render-pipeline';
|
||||
import type { RgbColor } from '../utils/rgb-color';
|
||||
|
||||
export interface NumberControlConfig {
|
||||
format?: (value: number) => string;
|
||||
folder: string;
|
||||
integer?: boolean;
|
||||
label?: string;
|
||||
max?: number;
|
||||
min?: number;
|
||||
options?: Record<string, number>;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type GardenRuntimeSettings = {
|
||||
adaptiveCapInitial: number;
|
||||
adaptiveCapMin: number;
|
||||
backgroundGrainStrength: number;
|
||||
brushCurveResolution: number;
|
||||
brushCurveMinBrushRadius: number;
|
||||
brushCurveMinSegmentSpacing: number;
|
||||
brushCurveMirrorResolutionExponent: number;
|
||||
brushCurveSegmentBrushRadiusRatio: number;
|
||||
brushEffectDuration: number;
|
||||
brushSmoothingMinSampleDistance: number;
|
||||
eraserClearAlpha: number;
|
||||
eraserClearBlue: number;
|
||||
eraserClearGreen: number;
|
||||
eraserClearRed: number;
|
||||
eraserLineDistanceEpsilon: number;
|
||||
eraserMaskAlphaThreshold: number;
|
||||
eraserSize: number;
|
||||
internalRenderAreaMegapixels: number;
|
||||
mirrorSegmentCount: number;
|
||||
maxAgentCount: number;
|
||||
selectedColorIndex: number;
|
||||
spawnPerPixel: number;
|
||||
strokeAngleJitterRadians: number;
|
||||
} & AgentSettings &
|
||||
BrushSettings &
|
||||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
type RuntimeSettingControlConfig = Partial<
|
||||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||
>;
|
||||
|
||||
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<
|
||||
GardenRuntimeSettings,
|
||||
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||
>;
|
||||
|
||||
export enum VibeId {
|
||||
AuroraMycelium = 'aurora-mycelium',
|
||||
VelvetObservatory = 'velvet-observatory',
|
||||
LichenSignal = 'lichen-signal',
|
||||
TidepoolLantern = 'tidepool-lantern',
|
||||
PaperLanternFog = 'paper-lantern-fog',
|
||||
ChromePollen = 'chrome-pollen',
|
||||
}
|
||||
|
||||
export interface VibePreset {
|
||||
id: VibeId;
|
||||
name: string;
|
||||
colors: [RgbColor, RgbColor, RgbColor];
|
||||
backgroundColor: RgbColor;
|
||||
settings: GardenVibeSettings;
|
||||
audio: GardenAudioVibeSettings;
|
||||
}
|
||||
|
||||
export interface GardenAppConfig {
|
||||
audio: GardenAudioConfig;
|
||||
analytics: {
|
||||
autoCapturePageviews: boolean;
|
||||
domain: string;
|
||||
endpoint: string;
|
||||
logging: boolean;
|
||||
};
|
||||
deltaTime: {
|
||||
maxDeltaTimeSeconds: number;
|
||||
minDeltaTimeSeconds: number;
|
||||
};
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: number;
|
||||
filenameExtension: string;
|
||||
filenamePrefix: string;
|
||||
filenameSuffix: string;
|
||||
mimeType: string;
|
||||
rowAlignmentBytes: number;
|
||||
};
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: number;
|
||||
desktopMediaQuery: string;
|
||||
hideDelayMs: number;
|
||||
};
|
||||
pipelines: {
|
||||
common: {
|
||||
noiseChannelSeeds: [number, number, number, number];
|
||||
noiseClearValue: GPUColor;
|
||||
noiseDrawInstanceCount: number;
|
||||
noiseDrawVertexCount: number;
|
||||
noiseHashMultiplier: number;
|
||||
noiseHashX: number;
|
||||
noiseHashY: number;
|
||||
noiseTextureFormat: GPUTextureFormat;
|
||||
noiseTextureSize: number;
|
||||
};
|
||||
brush: {
|
||||
maxLineCount: number;
|
||||
};
|
||||
diffusion: {
|
||||
minDiffusionRate: number;
|
||||
};
|
||||
eraser: {
|
||||
maxTextureLineCount: number;
|
||||
};
|
||||
};
|
||||
defaultSettings: GardenDefaultSettings;
|
||||
runtimeSettings: {
|
||||
controls: RuntimeSettingControlConfig;
|
||||
};
|
||||
simulation: {
|
||||
brushEffectFramesPerSecond: number;
|
||||
clearColor: GPUColor;
|
||||
initialAgentCount: number;
|
||||
sourceActiveFramesAfterWrite: number;
|
||||
intro: {
|
||||
angleJitterRadians: number;
|
||||
angleEaseEnd: number;
|
||||
angleEaseStart: number;
|
||||
circleMaxSideRatio: number;
|
||||
circleMinSideRatio: number;
|
||||
drawHintDelayMs: number;
|
||||
durationSeconds: number;
|
||||
entryJitterSideRatio: number;
|
||||
fontScaleDown: number;
|
||||
fontFamily: string;
|
||||
initialFontHeightRatio: number;
|
||||
initialFontWidthRatio: number;
|
||||
letterSpacingEm: number;
|
||||
maskAlphaThreshold: number;
|
||||
maskGradientThreshold: number;
|
||||
maskMaxPixels: number;
|
||||
maskSampleDensity: number;
|
||||
maxHeightRatio: number;
|
||||
maxWidthRatio: number;
|
||||
minEntryJitterPx: number;
|
||||
minFontSizePx: number;
|
||||
minTargetJitterPx: number;
|
||||
pathEasing: 'easeOutQuad' | 'linear';
|
||||
pathProgressEpsilon: number;
|
||||
radialJitterRatio: number;
|
||||
radialStartEpsilon: number;
|
||||
resizeMinimumRemainingSeconds: number;
|
||||
resizeSettleMs: number;
|
||||
targetDelayDistanceMultiplier: number;
|
||||
targetDelayMax: number;
|
||||
targetDelayRandomMultiplier: number;
|
||||
targetJitterSideRatio: number;
|
||||
title: string;
|
||||
titleColorCutLetters: [number, number];
|
||||
titleRadiusMultiplier: number;
|
||||
titleStrokeWidthMinPx: number;
|
||||
titleStrokeWidthRatio: number;
|
||||
verticalAnchor: number;
|
||||
};
|
||||
introMoveSpeed: number;
|
||||
stroke: {
|
||||
densityMultiplier: number;
|
||||
maxAgentCount: number;
|
||||
};
|
||||
};
|
||||
storage: {
|
||||
audioMutedKey: string;
|
||||
audioVolumeKey: string;
|
||||
vibeKey: string;
|
||||
};
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: number;
|
||||
controlScaleMin: number;
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
};
|
||||
mirror: {
|
||||
default: number;
|
||||
fallbackSegmentName: string;
|
||||
max: number;
|
||||
min: number;
|
||||
names: Record<number, string>;
|
||||
offLabel: string;
|
||||
step: number;
|
||||
};
|
||||
contrast: {
|
||||
backgroundOpacityMax: number;
|
||||
brightLuminanceThreshold: number;
|
||||
brightWeight: number;
|
||||
bytesPerSample: number;
|
||||
contrastOffset: number;
|
||||
linearChannelBreakpoint: number;
|
||||
linearChannelDivisor: number;
|
||||
linearChannelGamma: number;
|
||||
linearChannelOffset: number;
|
||||
linearChannelScale: number;
|
||||
lowContrastThreshold: number;
|
||||
lowContrastWeight: number;
|
||||
luminanceBase: number;
|
||||
luminanceBlueWeight: number;
|
||||
luminanceGreenWeight: number;
|
||||
luminanceRange: number;
|
||||
luminanceRedWeight: number;
|
||||
sampleColumns: number;
|
||||
sampleIntervalMs: number;
|
||||
sampleRows: number;
|
||||
whiteContrastNumerator: number;
|
||||
};
|
||||
volume: {
|
||||
default: number;
|
||||
max: number;
|
||||
min: number;
|
||||
step: number;
|
||||
};
|
||||
};
|
||||
tuningPane: {
|
||||
showFpsOverlay: boolean;
|
||||
startHidden: boolean;
|
||||
title: string;
|
||||
};
|
||||
vibes: {
|
||||
defaultVibeId: VibeId;
|
||||
presets: Array<VibePreset>;
|
||||
};
|
||||
}
|
||||
81
src/config/vibe-presets.test.ts
Normal file
81
src/config/vibe-presets.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runtimeControls } from './runtime-controls';
|
||||
import { vibePresets } from './vibe-presets';
|
||||
|
||||
const FINAL_VIBE_NAMES = [
|
||||
'Aurora Mycelium Copy',
|
||||
'Velvet Observatory Copy',
|
||||
'Lichen Signal',
|
||||
'Tidepool Lantern',
|
||||
'Paper Lantern Fog',
|
||||
'Chrome Pollen',
|
||||
];
|
||||
|
||||
const BLENDED_BRUSH_SIZE_MIN = 17;
|
||||
const BLENDED_CLARITY_MAX = 0.56;
|
||||
const SOFT_PARTICLE_BRUSH_SIZE_MAX = 5;
|
||||
const SOFT_PARTICLE_CLARITY_MAX = 0.2;
|
||||
|
||||
// Performance guardrails — bumping any of these is an explicit perf trade-off.
|
||||
const MAX_SPAWN_PER_PIXEL = runtimeControls.spawnPerPixel?.max ?? 0.38;
|
||||
const MAX_BRUSH_SIZE = runtimeControls.brushSize?.max ?? 36;
|
||||
const HIGH_DENSITY_SPAWN_THRESHOLD = 0.28;
|
||||
const HIGH_DENSITY_DECAY_LIMIT = 940;
|
||||
const HIGH_DENSITY_BRUSH_SIZE_LIMIT = 14;
|
||||
const HIGH_DENSITY_TRAIL_WEIGHT_LIMIT = 0.055;
|
||||
|
||||
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 >= BLENDED_BRUSH_SIZE_MIN &&
|
||||
preset.settings.clarity <= BLENDED_CLARITY_MAX
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
const softParticleNames = vibePresets
|
||||
.filter(
|
||||
(preset) =>
|
||||
preset.settings.brushSize <= SOFT_PARTICLE_BRUSH_SIZE_MAX &&
|
||||
preset.settings.clarity <= SOFT_PARTICLE_CLARITY_MAX
|
||||
)
|
||||
.map((preset) => preset.name);
|
||||
|
||||
expect(blendedNames).toEqual(['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 > MAX_SPAWN_PER_PIXEL) {
|
||||
presetViolations.push(`${name} density exceeds ${MAX_SPAWN_PER_PIXEL}`);
|
||||
}
|
||||
if (settings.brushSize > MAX_BRUSH_SIZE) {
|
||||
presetViolations.push(`${name} brush size exceeds ${MAX_BRUSH_SIZE}`);
|
||||
}
|
||||
if (
|
||||
settings.spawnPerPixel >= HIGH_DENSITY_SPAWN_THRESHOLD &&
|
||||
(settings.decayRateTrails > HIGH_DENSITY_DECAY_LIMIT ||
|
||||
settings.brushSize > HIGH_DENSITY_BRUSH_SIZE_LIMIT ||
|
||||
settings.individualTrailWeight > HIGH_DENSITY_TRAIL_WEIGHT_LIMIT)
|
||||
) {
|
||||
presetViolations.push(`${name} combines high density with too much persistence`);
|
||||
}
|
||||
|
||||
return presetViolations;
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
366
src/config/vibe-presets.ts
Normal file
366
src/config/vibe-presets.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
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: 0,
|
||||
color1ToColor3: 0,
|
||||
color2ToColor1: -1,
|
||||
color2ToColor2: 1,
|
||||
color2ToColor3: 0,
|
||||
color3ToColor1: -1,
|
||||
color3ToColor2: -1,
|
||||
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;
|
||||
|
||||
export const vibePresets: Array<VibePreset> = [
|
||||
{
|
||||
id: VibeId.AuroraMycelium,
|
||||
name: 'Aurora Mycelium Copy',
|
||||
colors: [
|
||||
[251, 210, 94],
|
||||
[154, 99, 255],
|
||||
[255, 31, 199],
|
||||
],
|
||||
backgroundColor: [6, 13, 22],
|
||||
settings: {
|
||||
...colorReactions.auroraMycelium,
|
||||
backgroundGrainStrength: 0.003,
|
||||
brushSize: 8.75,
|
||||
clarity: 1,
|
||||
decayRateTrails: 973,
|
||||
forwardRotationScale: 0.37,
|
||||
individualTrailWeight: 0.053000000000000005,
|
||||
moveSpeed: 144,
|
||||
sensorOffsetAngle: 35,
|
||||
sensorOffsetDistance: 52,
|
||||
spawnPerPixel: 0.13999999999999999,
|
||||
strokeAngleJitterRadians: 0.45,
|
||||
turnSpeed: 13,
|
||||
turnWhenLost: 0,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.12000000000000002,
|
||||
bpm: 60,
|
||||
rampUpIntensity: 0.7,
|
||||
rampUpTime: 0.14,
|
||||
noteLength: 0.8599999999999999,
|
||||
notePitchOffset: -2,
|
||||
brightness: 0.84,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.aurora,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.VelvetObservatory,
|
||||
name: 'Velvet Observatory Copy',
|
||||
colors: [
|
||||
[178, 76, 62],
|
||||
[2, 174, 255],
|
||||
[213, 193, 9],
|
||||
],
|
||||
backgroundColor: [7, 4, 22],
|
||||
settings: {
|
||||
...colorReactions.velvetObservatory,
|
||||
backgroundGrainStrength: 0.005,
|
||||
brushSize: 9.75,
|
||||
clarity: 1,
|
||||
decayRateTrails: 974,
|
||||
forwardRotationScale: 0,
|
||||
individualTrailWeight: 0.232,
|
||||
moveSpeed: 121,
|
||||
sensorOffsetAngle: 24,
|
||||
sensorOffsetDistance: 17,
|
||||
spawnPerPixel: 0.11499999999999999,
|
||||
strokeAngleJitterRadians: 0.17,
|
||||
turnSpeed: 33,
|
||||
turnWhenLost: 0.42,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.24000000000000002,
|
||||
bpm: 72,
|
||||
rampUpIntensity: 1.42,
|
||||
rampUpTime: 0.07,
|
||||
noteLength: 0.7,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.94,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.velvet,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.LichenSignal,
|
||||
name: 'Lichen Signal',
|
||||
colors: [
|
||||
[183, 216, 92],
|
||||
[65, 166, 128],
|
||||
[238, 120, 76],
|
||||
],
|
||||
backgroundColor: [0, 0, 0],
|
||||
settings: {
|
||||
...colorReactions.lichenSignal,
|
||||
backgroundGrainStrength: 0.02,
|
||||
brushSize: 6.5,
|
||||
clarity: 0.74,
|
||||
decayRateTrails: 962,
|
||||
forwardRotationScale: 0.3,
|
||||
individualTrailWeight: 0.052,
|
||||
moveSpeed: 72,
|
||||
sensorOffsetAngle: 42,
|
||||
sensorOffsetDistance: 54,
|
||||
spawnPerPixel: 0.16,
|
||||
strokeAngleJitterRadians: 3.14,
|
||||
turnSpeed: 44,
|
||||
turnWhenLost: 0.92,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.13,
|
||||
bpm: 68,
|
||||
rampUpIntensity: 1.46,
|
||||
rampUpTime: 0.1,
|
||||
noteLength: 0.6,
|
||||
notePitchOffset: -3,
|
||||
brightness: 1.21,
|
||||
scale: musicScales.dorian,
|
||||
progression: musicProgressions.lichen,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.TidepoolLantern,
|
||||
name: 'Tidepool Lantern',
|
||||
colors: [
|
||||
[30, 219, 194],
|
||||
[61, 118, 255],
|
||||
[255, 191, 91],
|
||||
],
|
||||
backgroundColor: [4, 18, 29],
|
||||
settings: {
|
||||
...colorReactions.tidepoolLantern,
|
||||
backgroundGrainStrength: 0.018,
|
||||
brushSize: 17,
|
||||
clarity: 0.56,
|
||||
decayRateTrails: 968,
|
||||
forwardRotationScale: 0.38,
|
||||
individualTrailWeight: 0.06,
|
||||
moveSpeed: 88,
|
||||
sensorOffsetAngle: 64,
|
||||
sensorOffsetDistance: 46,
|
||||
spawnPerPixel: 0.22,
|
||||
strokeAngleJitterRadians: 1.8,
|
||||
turnSpeed: 66,
|
||||
turnWhenLost: 1.05,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.08,
|
||||
bpm: 84,
|
||||
rampUpIntensity: 0.95,
|
||||
rampUpTime: 0.08,
|
||||
noteLength: 0.46,
|
||||
notePitchOffset: 0,
|
||||
brightness: 0.98,
|
||||
scale: musicScales.mixolydian,
|
||||
progression: musicProgressions.tidepool,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.PaperLanternFog,
|
||||
name: 'Paper Lantern Fog',
|
||||
colors: [
|
||||
[255, 176, 108],
|
||||
[239, 90, 108],
|
||||
[128, 213, 184],
|
||||
],
|
||||
backgroundColor: [30, 23, 20],
|
||||
settings: {
|
||||
...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.055,
|
||||
strokeAngleJitterRadians: 0,
|
||||
turnSpeed: 30,
|
||||
turnWhenLost: 1.52,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.33,
|
||||
bpm: 127,
|
||||
rampUpIntensity: 0.66,
|
||||
rampUpTime: 0.03,
|
||||
noteLength: 0.92,
|
||||
notePitchOffset: 10,
|
||||
brightness: 1.42,
|
||||
scale: musicScales.naturalMinor,
|
||||
progression: musicProgressions.paperLantern,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: VibeId.ChromePollen,
|
||||
name: 'Chrome Pollen',
|
||||
colors: [
|
||||
[178, 34, 34],
|
||||
[255, 214, 48],
|
||||
[77, 240, 157],
|
||||
],
|
||||
backgroundColor: [7, 12, 11],
|
||||
settings: {
|
||||
...colorReactions.chromePollen,
|
||||
backgroundGrainStrength: 0.012,
|
||||
brushSize: 4.5,
|
||||
clarity: 0.1,
|
||||
decayRateTrails: 922,
|
||||
forwardRotationScale: 0.5,
|
||||
individualTrailWeight: 0.026,
|
||||
moveSpeed: 86,
|
||||
sensorOffsetAngle: 46,
|
||||
sensorOffsetDistance: 14,
|
||||
spawnPerPixel: 0.36,
|
||||
strokeAngleJitterRadians: 3,
|
||||
turnSpeed: 34,
|
||||
turnWhenLost: 1.35,
|
||||
},
|
||||
audio: {
|
||||
...defaultGardenAudioVibeSettings,
|
||||
idleIntensity: 0.11,
|
||||
bpm: 150,
|
||||
rampUpIntensity: 2,
|
||||
rampUpTime: 0.06,
|
||||
noteLength: 1.8,
|
||||
notePitchOffset: -12,
|
||||
brightness: 0.5,
|
||||
scale: musicScales.lydian,
|
||||
progression: musicProgressions.chrome,
|
||||
},
|
||||
},
|
||||
];
|
||||
Loading…
Reference in a new issue