Compare commits
3 commits
7f628180f0
...
0fddad6b45
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fddad6b45 | |||
| 79638d5fa4 | |||
| f863588060 |
41 changed files with 628 additions and 710 deletions
5
assets/icons/close.svg
Normal file
5
assets/icons/close.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19 5 17.6 10.6 12 5 6.4z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 179 B |
50
index.html
50
index.html
|
|
@ -11,7 +11,7 @@
|
|||
<meta name="author" content="Andras Schmelczer" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
||||
/>
|
||||
|
||||
<link rel="canonical" href="https://schmelczer.dev/fleeting/" />
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<meta property="og:locale" content="en_US" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
||||
/>
|
||||
<meta property="og:url" content="https://schmelczer.dev/fleeting/" />
|
||||
<meta property="og:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
<meta name="twitter:title" content="Fleeting Garden" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser."
|
||||
content="Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://schmelczer.dev/fleeting/og-image.jpg" />
|
||||
<meta name="twitter:image:alt" content="Fleeting Garden social preview image." />
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
"@type": "WebApplication",
|
||||
"name": "Fleeting Garden",
|
||||
"url": "https://schmelczer.dev/fleeting/",
|
||||
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
|
||||
"description": "Plant colour, fold gestures with mirrors, and watch small agents turn each brushstroke into a shifting WebGPU garden.",
|
||||
"image": "https://schmelczer.dev/fleeting/og-image.jpg",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"operatingSystem": "Any",
|
||||
|
|
@ -118,35 +118,47 @@
|
|||
id="info-panel"
|
||||
class="hidden info-page"
|
||||
role="region"
|
||||
aria-label="About panel"
|
||||
aria-labelledby="info-panel-title"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
inert
|
||||
>
|
||||
<section>
|
||||
<h1>Fleeting Garden</h1>
|
||||
<p>
|
||||
<div class="info-page__content">
|
||||
<header class="info-page__header">
|
||||
<span class="info-page__mark" aria-hidden="true"></span>
|
||||
<div class="info-page__heading">
|
||||
<p class="info-page__eyebrow">About</p>
|
||||
<h2 id="info-panel-title">Fleeting Garden</h2>
|
||||
</div>
|
||||
<button
|
||||
class="info-page__close"
|
||||
data-control="info-close"
|
||||
type="button"
|
||||
aria-label="Close about panel"
|
||||
title="Close"
|
||||
></button>
|
||||
</header>
|
||||
|
||||
<p class="info-page__lede">
|
||||
A garden is what we tend; the wild is what we get the moment we look away.
|
||||
Both happen here at once. Your strokes plant colour, small agents follow them,
|
||||
branch off, and slowly rewrite the patch you laid down into something you
|
||||
didn't quite plan.
|
||||
</p>
|
||||
<p>
|
||||
Three swatches plant the line. The eraser carves a clearing. The mirror folds
|
||||
one gesture into many, like footpaths around a hidden well.
|
||||
</p>
|
||||
<p>
|
||||
Switch vibes to change the season; your shapes stay, the light moves. Add or
|
||||
quiet the piano. Restart when you want a fresh field. Take a snapshot if you
|
||||
want to keep one particular instant of weather.
|
||||
</p>
|
||||
<p>
|
||||
|
||||
<ul class="info-page__notes">
|
||||
<li>Three swatches plant the line; the eraser carves a clearing.</li>
|
||||
<li>The mirror folds one gesture into many.</li>
|
||||
<li>The arrows change the season.</li>
|
||||
</ul>
|
||||
|
||||
<p class="info-page__meta">
|
||||
Built with WebGPU, running locally in your browser. More of my work at
|
||||
<a href="https://schmelczer.dev" target="_blank" rel="noopener"
|
||||
>schmelczer.dev</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="toolbar-row" role="toolbar" aria-label="Garden toolbar">
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 164 KiB |
|
|
@ -4,9 +4,13 @@ import {
|
|||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
import { appConfig } from './config';
|
||||
import type { VibeId } from './vibes';
|
||||
|
||||
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
|
||||
const ANALYTICS_DOMAIN = 'schmelczer.dev/fleeting';
|
||||
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
const ANALYTICS_LOGGING = import.meta.env.DEV;
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
|
|
@ -24,10 +28,10 @@ export const initAnalytics = () => {
|
|||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: appConfig.analytics.domain,
|
||||
endpoint: appConfig.analytics.endpoint,
|
||||
autoCapturePageviews: appConfig.analytics.autoCapturePageviews,
|
||||
logging: appConfig.analytics.logging,
|
||||
domain: ANALYTICS_DOMAIN,
|
||||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: ANALYTICS_LOGGING,
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
|
|
|
|||
198
src/config.ts
198
src/config.ts
|
|
@ -1,199 +1,9 @@
|
|||
import {
|
||||
createGardenAudioConfig,
|
||||
DEFAULT_AUDIO_VOLUME,
|
||||
} from './audio/garden-audio-config';
|
||||
import { defaultSettings } from './config/default-settings';
|
||||
import { runtimeControls } from './config/runtime-controls';
|
||||
import type { GardenAppConfig } from './config/types';
|
||||
import { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||
|
||||
export { defaultSettings } from './config/default-settings';
|
||||
export {
|
||||
normalizeNumberControlValue,
|
||||
normalizeRuntimeSettings,
|
||||
} from './config/normalize-runtime-settings';
|
||||
export { runtimeControls } from './config/runtime-controls';
|
||||
export { defaultVibeId, vibePresets } from './config/vibe-presets';
|
||||
|
||||
export type {
|
||||
GardenAppConfig,
|
||||
GardenRuntimeSettings,
|
||||
NumberControlConfig,
|
||||
} from './config/types';
|
||||
|
||||
export const appConfig = {
|
||||
audio: createGardenAudioConfig(),
|
||||
analytics: {
|
||||
autoCapturePageviews: true,
|
||||
domain: 'fleeting.garden',
|
||||
endpoint: 'https://stats.schmelczer.dev/status',
|
||||
logging: import.meta.env.DEV,
|
||||
},
|
||||
deltaTime: {
|
||||
maxDeltaTimeSeconds: 1 / 30,
|
||||
minDeltaTimeSeconds: 1 / 240,
|
||||
},
|
||||
exportSnapshot: {
|
||||
bytesPerPixel: 4,
|
||||
filenameExtension: 'png',
|
||||
filenamePrefix: 'fleeting-garden',
|
||||
filenameSuffix: '-snapshot',
|
||||
mimeType: 'image/png',
|
||||
rowAlignmentBytes: 256,
|
||||
},
|
||||
menuHider: {
|
||||
bottomRevealDistancePx: 96,
|
||||
desktopMediaQuery: '(min-width: 600px) and (hover: hover) and (pointer: fine)',
|
||||
hideDelayMs: 3000,
|
||||
},
|
||||
pipelines: {
|
||||
common: {
|
||||
noiseChannelSeeds: [0, 1, 2, 3],
|
||||
noiseClearValue: { r: 1, g: 1, b: 1, a: 1 },
|
||||
noiseDrawInstanceCount: 1,
|
||||
noiseDrawVertexCount: 3,
|
||||
noiseHashMultiplier: 43758.5453123,
|
||||
noiseHashX: 12.9898,
|
||||
noiseHashY: 78.233,
|
||||
noiseTextureFormat: 'r8unorm',
|
||||
noiseTextureSize: 2048,
|
||||
},
|
||||
brush: {
|
||||
maxLineCount: 240,
|
||||
},
|
||||
diffusion: {
|
||||
minDiffusionRate: 0.000001,
|
||||
},
|
||||
eraser: {
|
||||
maxTextureLineCount: 384,
|
||||
},
|
||||
},
|
||||
defaultSettings,
|
||||
runtimeSettings: {
|
||||
controls: runtimeControls,
|
||||
},
|
||||
simulation: {
|
||||
brushEffectFramesPerSecond: 60,
|
||||
clearColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||
initialAgentCount: 180_000,
|
||||
// How long the source map continues to be diffused after a brush stroke ends.
|
||||
// 600 frames at ~60 FPS ≈ 10 seconds.
|
||||
sourceActiveFramesAfterWrite: 600,
|
||||
intro: {
|
||||
angleJitterRadians: Math.PI * 0.08,
|
||||
angleEaseEnd: 1,
|
||||
angleEaseStart: 0.6,
|
||||
circleMaxSideRatio: 0.46,
|
||||
circleMinSideRatio: 0.32,
|
||||
drawHintDelayMs: 3000,
|
||||
durationSeconds: 4,
|
||||
entryJitterSideRatio: 0.035,
|
||||
fontScaleDown: 0.94,
|
||||
fontFamily: '"Open Sans", sans-serif',
|
||||
initialFontHeightRatio: 0.28,
|
||||
initialFontWidthRatio: 0.19,
|
||||
letterSpacingEm: 0.07,
|
||||
maskAlphaThreshold: 32,
|
||||
maskGradientThreshold: 8,
|
||||
maskMaxPixels: 1_000_000,
|
||||
maskSampleDensity: 540,
|
||||
maxHeightRatio: 0.25,
|
||||
maxWidthRatio: 0.76,
|
||||
minEntryJitterPx: 6,
|
||||
minFontSizePx: 18,
|
||||
minTargetJitterPx: 1,
|
||||
pathEasing: 'easeOutQuad',
|
||||
pathProgressEpsilon: 0.001,
|
||||
radialJitterRatio: 0.35,
|
||||
radialStartEpsilon: 0.001,
|
||||
resizeMinimumRemainingSeconds: 1.4,
|
||||
resizeSettleMs: 120,
|
||||
targetDelayDistanceMultiplier: 0.12,
|
||||
targetDelayMax: 0.22,
|
||||
targetDelayRandomMultiplier: 0.06,
|
||||
targetJitterSideRatio: 0.0035,
|
||||
title: 'Fleeting',
|
||||
titleColorCutLetters: [2, 5],
|
||||
titleRadiusMultiplier: 1.55,
|
||||
titleStrokeWidthMinPx: 6,
|
||||
titleStrokeWidthRatio: 0.11,
|
||||
verticalAnchor: 0.47,
|
||||
},
|
||||
introMoveSpeed: 280,
|
||||
stroke: {
|
||||
densityMultiplier: 110,
|
||||
maxAgentCount: 2_400,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
audioMutedKey: 'fleeting-garden:audio-muted',
|
||||
audioVolumeKey: 'fleeting-garden:audio-volume',
|
||||
vibeKey: 'fleeting-garden:vibe',
|
||||
},
|
||||
toolbar: {
|
||||
eraser: {
|
||||
controlScaleMax: 1.34,
|
||||
controlScaleMin: 0.74,
|
||||
default: 96,
|
||||
max: 480,
|
||||
min: 24,
|
||||
step: 1,
|
||||
},
|
||||
mirror: {
|
||||
default: 8,
|
||||
fallbackSegmentName: 'slices',
|
||||
max: 12,
|
||||
min: 1,
|
||||
names: {
|
||||
2: 'halves',
|
||||
3: 'thirds',
|
||||
4: 'quarters',
|
||||
5: 'fifths',
|
||||
6: 'sixths',
|
||||
7: 'sevenths',
|
||||
8: 'eighths',
|
||||
9: 'ninths',
|
||||
10: 'tenths',
|
||||
11: 'elevenths',
|
||||
12: 'twelfths',
|
||||
},
|
||||
offLabel: 'Mirror off',
|
||||
step: 1,
|
||||
},
|
||||
contrast: {
|
||||
backgroundOpacityMax: 0.82,
|
||||
brightLuminanceThreshold: 0.32,
|
||||
brightWeight: 0.65,
|
||||
bytesPerSample: 4,
|
||||
contrastOffset: 0.05,
|
||||
linearChannelBreakpoint: 0.03928,
|
||||
linearChannelDivisor: 12.92,
|
||||
linearChannelGamma: 2.4,
|
||||
linearChannelOffset: 0.055,
|
||||
linearChannelScale: 1.055,
|
||||
lowContrastThreshold: 3,
|
||||
lowContrastWeight: 1.8,
|
||||
luminanceBase: 0.11,
|
||||
luminanceBlueWeight: 0.0722,
|
||||
luminanceGreenWeight: 0.7152,
|
||||
luminanceRange: 0.28,
|
||||
luminanceRedWeight: 0.2126,
|
||||
sampleColumns: 13,
|
||||
sampleIntervalMs: 300,
|
||||
sampleRows: 7,
|
||||
whiteContrastNumerator: 1.05,
|
||||
},
|
||||
volume: {
|
||||
default: DEFAULT_AUDIO_VOLUME,
|
||||
max: 1,
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
tuningPane: {
|
||||
showFpsOverlay: import.meta.env.DEV,
|
||||
startHidden: true,
|
||||
title: 'Garden Settings',
|
||||
},
|
||||
vibes: {
|
||||
defaultVibeId,
|
||||
presets: vibePresets,
|
||||
},
|
||||
} satisfies GardenAppConfig;
|
||||
export type { GardenRuntimeSettings, NumberControlConfig } from './config/types';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
import type { GardenDefaultSettings } 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
|
||||
|
|
@ -21,7 +21,7 @@ const computeDefaultInternalRenderAreaMegapixels = (): number => {
|
|||
);
|
||||
};
|
||||
|
||||
export const defaultSettings: GardenAppConfig['defaultSettings'] = {
|
||||
export const defaultSettings: GardenDefaultSettings = {
|
||||
selectedColorIndex: 0,
|
||||
|
||||
introNearDistanceMin: 28,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import type {
|
||||
GardenAppConfig,
|
||||
GardenRuntimeSettings,
|
||||
NumberControlConfig,
|
||||
RuntimeSettingControlConfig,
|
||||
} from './types';
|
||||
|
||||
type RuntimeSettingControls = GardenAppConfig['runtimeSettings']['controls'];
|
||||
|
||||
export const normalizeNumberControlValue = (
|
||||
value: number,
|
||||
config: NumberControlConfig
|
||||
|
|
@ -28,7 +26,7 @@ export const normalizeNumberControlValue = (
|
|||
|
||||
export const normalizeRuntimeSettings = (
|
||||
settings: GardenRuntimeSettings,
|
||||
controls: RuntimeSettingControls
|
||||
controls: RuntimeSettingControlConfig
|
||||
): GardenRuntimeSettings => {
|
||||
const normalized = { ...settings };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { colorInteractionControl } from './color-interactions';
|
||||
import { INTERNAL_RENDER_AREA_MEGAPIXEL_LIMITS } from './runtime-setting-bounds';
|
||||
import type { GardenAppConfig } from './types';
|
||||
import type { RuntimeSettingControlConfig } from './types';
|
||||
|
||||
const formatPercent = (value: number): string => `${Math.round(value * 100)}%`;
|
||||
const formatRadiansAsDegrees = (value: number): string =>
|
||||
|
|
@ -16,7 +16,7 @@ const formatCompactNumber = (value: number): string => {
|
|||
return `${value}`;
|
||||
};
|
||||
|
||||
export const runtimeControls: GardenAppConfig['runtimeSettings']['controls'] = {
|
||||
export const runtimeControls: RuntimeSettingControlConfig = {
|
||||
color1ToColor1: colorInteractionControl('Color 1 Follows Color 1'),
|
||||
color1ToColor2: colorInteractionControl('Color 1 Follows Color 2'),
|
||||
color1ToColor3: colorInteractionControl('Color 1 Follows Color 3'),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import type {
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeSettings,
|
||||
} from '../audio/garden-audio-config';
|
||||
import type { 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';
|
||||
|
|
@ -49,7 +46,7 @@ export type GardenRuntimeSettings = {
|
|||
DiffusionSettings &
|
||||
RenderSettings;
|
||||
|
||||
type RuntimeSettingControlConfig = Partial<
|
||||
export type RuntimeSettingControlConfig = Partial<
|
||||
Record<keyof GardenRuntimeSettings, NumberControlConfig>
|
||||
>;
|
||||
|
||||
|
|
@ -79,7 +76,7 @@ export type GardenVibeSettings = Pick<
|
|||
| 'turnWhenLost'
|
||||
>;
|
||||
|
||||
type GardenDefaultSettings = Omit<
|
||||
export type GardenDefaultSettings = Omit<
|
||||
GardenRuntimeSettings,
|
||||
keyof GardenVibeSettings | 'eraserSize' | 'mirrorSegmentCount'
|
||||
>;
|
||||
|
|
@ -101,169 +98,3 @@ export interface VibePreset {
|
|||
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>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { type AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AGENT_FLOAT_COUNT } from '../pipelines/agents/agent-limits';
|
||||
import { settings } from '../settings';
|
||||
import { AgentPopulation } from './agent-population';
|
||||
import {
|
||||
AgentPopulation,
|
||||
STROKE_AGENT_BATCH_CAPACITY,
|
||||
STROKE_DENSITY_MULTIPLIER,
|
||||
} from './agent-population';
|
||||
import { type FramePerformance } from './frame-performance';
|
||||
|
||||
const originalSettings = {
|
||||
|
|
@ -70,7 +73,7 @@ const createPopulation = (): {
|
|||
};
|
||||
|
||||
const setSpawnRate = (agentsPerPixel: number): void => {
|
||||
settings.spawnPerPixel = agentsPerPixel / appConfig.simulation.stroke.densityMultiplier;
|
||||
settings.spawnPerPixel = agentsPerPixel / STROKE_DENSITY_MULTIPLIER;
|
||||
};
|
||||
|
||||
describe('AgentPopulation stroke spawning', () => {
|
||||
|
|
@ -124,7 +127,7 @@ describe('AgentPopulation stroke spawning', () => {
|
|||
|
||||
it('chunks long stroke writes without clipping length-linear spawn counts', () => {
|
||||
const { pipeline, population } = createPopulation();
|
||||
const batchCapacity = appConfig.simulation.stroke.maxAgentCount;
|
||||
const batchCapacity = STROKE_AGENT_BATCH_CAPACITY;
|
||||
const expectedAgentCount = batchCapacity + 10;
|
||||
|
||||
population.spawnStrokeAgents(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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';
|
||||
|
|
@ -9,6 +8,11 @@ import { settings } from '../settings';
|
|||
import type { FramePerformance } from './frame-performance';
|
||||
import { createIntroTitleAgents } from './intro-title-agents';
|
||||
|
||||
export const STROKE_AGENT_BATCH_CAPACITY = 2_400;
|
||||
export const STROKE_DENSITY_MULTIPLIER = 110;
|
||||
|
||||
const INITIAL_INTRO_AGENT_COUNT = 180_000;
|
||||
|
||||
export class AgentPopulation {
|
||||
private activeCount = 0;
|
||||
// Current performance-aware limit; new agents above it replace old agents.
|
||||
|
|
@ -22,7 +26,7 @@ export class AgentPopulation {
|
|||
private readonly queuedAgentBatches: Array<Float32Array> = [];
|
||||
private pendingStrokeAgentCount = 0;
|
||||
private readonly strokeAgentData = new Float32Array(
|
||||
appConfig.simulation.stroke.maxAgentCount * AGENT_FLOAT_COUNT
|
||||
STROKE_AGENT_BATCH_CAPACITY * AGENT_FLOAT_COUNT
|
||||
);
|
||||
|
||||
public constructor(
|
||||
|
|
@ -46,10 +50,7 @@ export class AgentPopulation {
|
|||
|
||||
public replaceIntroAgents(canvasSize: vec2, progress: number): void {
|
||||
this.adaptiveCap = this.clampAndEnsureAdaptiveCap(this.adaptiveCap);
|
||||
const introAgentCount = Math.min(
|
||||
this.adaptiveCap,
|
||||
appConfig.simulation.initialAgentCount
|
||||
);
|
||||
const introAgentCount = Math.min(this.adaptiveCap, INITIAL_INTRO_AGENT_COUNT);
|
||||
const data = createIntroTitleAgents({
|
||||
count: introAgentCount,
|
||||
width: canvasSize[0],
|
||||
|
|
@ -332,8 +333,5 @@ const getStrokeSpawnRate = (): number => {
|
|||
const spawnPerPixel = Number.isFinite(settings.spawnPerPixel)
|
||||
? settings.spawnPerPixel
|
||||
: 0;
|
||||
const densityMultiplier = Number.isFinite(appConfig.simulation.stroke.densityMultiplier)
|
||||
? appConfig.simulation.stroke.densityMultiplier
|
||||
: 0;
|
||||
return Math.max(0, spawnPerPixel * densityMultiplier);
|
||||
return Math.max(0, spawnPerPixel * STROKE_DENSITY_MULTIPLIER);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { defaultSettings } from '../config';
|
||||
import { getRenderQualityBrushSize } from '../config/brush-size';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { settings } from '../settings';
|
||||
|
|
@ -143,13 +143,13 @@ const getQuadraticPoint = (start: vec2, control: vec2, end: vec2, t: number): ve
|
|||
const getBrushCurveResolution = (): number => {
|
||||
const resolution = Number.isFinite(settings.brushCurveResolution)
|
||||
? settings.brushCurveResolution
|
||||
: appConfig.defaultSettings.brushCurveResolution;
|
||||
: defaultSettings.brushCurveResolution;
|
||||
return Math.max(1, Math.floor(resolution));
|
||||
};
|
||||
|
||||
const getBrushSmoothingDistanceSquared = (pixelRatio?: number): number => {
|
||||
const distance = Number.isFinite(settings.brushSmoothingMinSampleDistance)
|
||||
? settings.brushSmoothingMinSampleDistance
|
||||
: appConfig.defaultSettings.brushSmoothingMinSampleDistance;
|
||||
: defaultSettings.brushSmoothingMinSampleDistance;
|
||||
return Math.max(0, distance * getSafePixelRatio(pixelRatio)) ** 2;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { appConfig } from '../config';
|
||||
import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
||||
import type { VibeId } from '../vibes';
|
||||
|
||||
const SNAPSHOT_BYTES_PER_PIXEL = 4;
|
||||
const SNAPSHOT_FILENAME_EXTENSION = 'png';
|
||||
const SNAPSHOT_FILENAME_PREFIX = 'fleeting-garden';
|
||||
const SNAPSHOT_FILENAME_SUFFIX = '-snapshot';
|
||||
const SNAPSHOT_MIME_TYPE = 'image/png';
|
||||
const SNAPSHOT_ROW_ALIGNMENT_BYTES = 256;
|
||||
|
||||
interface ExportSnapshotRendererOptions {
|
||||
device: GPUDevice;
|
||||
renderPipeline: RenderPipeline;
|
||||
|
|
@ -121,15 +127,15 @@ export class ExportSnapshotRenderer {
|
|||
|
||||
context.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: appConfig.exportSnapshot.mimeType,
|
||||
type: SNAPSHOT_MIME_TYPE,
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
link.href = objectUrl;
|
||||
link.download = `${appConfig.exportSnapshot.filenamePrefix}_${this.options.getVibeId()}_${
|
||||
link.download = `${SNAPSHOT_FILENAME_PREFIX}_${this.options.getVibeId()}_${
|
||||
this.options.seed
|
||||
}_${width}x${height}${appConfig.exportSnapshot.filenameSuffix}.${appConfig.exportSnapshot.filenameExtension}`;
|
||||
}_${width}x${height}${SNAPSHOT_FILENAME_SUFFIX}.${SNAPSHOT_FILENAME_EXTENSION}`;
|
||||
link.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
|
@ -154,11 +160,8 @@ const getSnapshotDimension = (value: number): number =>
|
|||
const getSnapshotLayout = (sourceWidth: number, sourceHeight: number): SnapshotLayout => {
|
||||
const width = getSnapshotDimension(sourceWidth);
|
||||
const height = getSnapshotDimension(sourceHeight);
|
||||
const unpaddedBytesPerRow = width * appConfig.exportSnapshot.bytesPerPixel;
|
||||
const bytesPerRow = alignTo(
|
||||
unpaddedBytesPerRow,
|
||||
appConfig.exportSnapshot.rowAlignmentBytes
|
||||
);
|
||||
const unpaddedBytesPerRow = width * SNAPSHOT_BYTES_PER_PIXEL;
|
||||
const bytesPerRow = alignTo(unpaddedBytesPerRow, SNAPSHOT_ROW_ALIGNMENT_BYTES);
|
||||
|
||||
return {
|
||||
width,
|
||||
|
|
@ -191,8 +194,8 @@ const readSnapshotPixels = ({
|
|||
const sourceOffset = y * bytesPerRow;
|
||||
const targetOffset = y * unpaddedBytesPerRow;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const source = sourceOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||
const target = targetOffset + x * appConfig.exportSnapshot.bytesPerPixel;
|
||||
const source = sourceOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
|
||||
const target = targetOffset + x * SNAPSHOT_BYTES_PER_PIXEL;
|
||||
pixels[target] = isBgra ? mapped[source + 2] : mapped[source];
|
||||
pixels[target + 1] = mapped[source + 1];
|
||||
pixels[target + 2] = isBgra ? mapped[source] : mapped[source + 2];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig, type GardenRuntimeSettings } from '../config';
|
||||
import { type GardenRuntimeSettings } from '../config';
|
||||
import { AgentGenerationPipeline } from '../pipelines/agents/agent-generation/agent-generation-pipeline';
|
||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
|
|
@ -12,9 +12,12 @@ import { RenderPipeline } from '../pipelines/render/render-pipeline';
|
|||
import { initializeContext } from '../utils/graphics/initialize-context';
|
||||
import { CanvasReadbackRequest, RenderInputs } from './game-loop-types';
|
||||
import { GpuProfiler } from './gpu-profiler';
|
||||
import { perfStatsOverlayState } from './perf-stats-overlay';
|
||||
import { SimulationFrameRenderer } from './simulation-frame';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
||||
const INTRO_MOVE_SPEED = 280;
|
||||
|
||||
interface FrameParameters extends RenderInputs {
|
||||
time: number;
|
||||
deltaTime: number;
|
||||
|
|
@ -78,7 +81,7 @@ export class GameLoopResources {
|
|||
this.renderPipeline = new RenderPipeline(context, this.device, this.canvasFormat);
|
||||
this.gpuProfiler = GpuProfiler.create(
|
||||
this.device,
|
||||
() => appConfig.tuningPane.showFpsOverlay
|
||||
() => perfStatsOverlayState.isVisible
|
||||
);
|
||||
|
||||
this.frameRenderer = new SimulationFrameRenderer(
|
||||
|
|
@ -134,7 +137,7 @@ export class GameLoopResources {
|
|||
deltaTime,
|
||||
time,
|
||||
agentCount: activeAgentCount,
|
||||
introMoveSpeed: appConfig.simulation.introMoveSpeed,
|
||||
introMoveSpeed: INTRO_MOVE_SPEED,
|
||||
introProgress,
|
||||
});
|
||||
this.brushPipeline.setParameters({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { appConfig } from '../config';
|
||||
import { createGardenAudioConfig } from '../audio/garden-audio-config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { DeltaTimeCalculator } from '../utils/delta-time-calculator';
|
||||
import { rgbColorToCss, type RgbColor } from '../utils/rgb-color';
|
||||
|
|
@ -13,14 +13,19 @@ import { GameLoopResources } from './game-loop-resources';
|
|||
import { GardenUi } from './game-loop-types';
|
||||
import { getInternalRenderSize } from './internal-render-size';
|
||||
import { IntroPrompt } from './intro-prompt';
|
||||
import { PerfStatsOverlay } from './perf-stats-overlay';
|
||||
import { PerfStatsOverlay, perfStatsOverlayState } from './perf-stats-overlay';
|
||||
import { GardenPointerInput } from './pointer-input';
|
||||
import { MAX_MIRROR_SEGMENT_COUNT, MIN_MIRROR_SEGMENT_COUNT } from './stroke-mirroring';
|
||||
import { PipelineStrokeOutput } from './stroke-output';
|
||||
import { ToolbarContrastMonitor } from './toolbar-contrast-monitor';
|
||||
|
||||
const INTRO_RESIZE_SETTLE_MS = 120;
|
||||
const INTRO_RESIZE_MINIMUM_REMAINING_SECONDS = 1.4;
|
||||
const GARDEN_AUDIO_CONFIG = createGardenAudioConfig();
|
||||
|
||||
export default class GameLoop {
|
||||
private readonly resources: GameLoopResources;
|
||||
private readonly audio = new GardenAudio(appConfig.audio);
|
||||
private readonly audio = new GardenAudio(GARDEN_AUDIO_CONFIG);
|
||||
private readonly introPrompt: IntroPrompt;
|
||||
private readonly eraserPreview: EraserPreview;
|
||||
private readonly pointerInput: GardenPointerInput;
|
||||
|
|
@ -250,7 +255,7 @@ export default class GameLoop {
|
|||
};
|
||||
|
||||
private syncPerfStatsOverlay(): void {
|
||||
if (appConfig.tuningPane.showFpsOverlay) {
|
||||
if (perfStatsOverlayState.isVisible) {
|
||||
this.perfStatsOverlay ??= new PerfStatsOverlay(
|
||||
this.canvas.parentElement ?? document.body
|
||||
);
|
||||
|
|
@ -323,13 +328,11 @@ export default class GameLoop {
|
|||
return;
|
||||
}
|
||||
|
||||
if (time - this.pendingIntroResizeAt < appConfig.simulation.intro.resizeSettleMs) {
|
||||
if (time - this.pendingIntroResizeAt < INTRO_RESIZE_SETTLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.introPrompt.rewindToLeaveRemainingTime(
|
||||
appConfig.simulation.intro.resizeMinimumRemainingSeconds
|
||||
);
|
||||
this.introPrompt.rewindToLeaveRemainingTime(INTRO_RESIZE_MINIMUM_REMAINING_SECONDS);
|
||||
this.resources.clearSimulation();
|
||||
this.agentPopulation.replaceIntroAgents(this.canvasSize, this.introPrompt.progress);
|
||||
this.pendingIntroResizeAt = null;
|
||||
|
|
@ -351,10 +354,10 @@ export default class GameLoop {
|
|||
private get mirrorSegmentCount(): number {
|
||||
const count = Number.isFinite(settings.mirrorSegmentCount)
|
||||
? settings.mirrorSegmentCount
|
||||
: appConfig.toolbar.mirror.min;
|
||||
: MIN_MIRROR_SEGMENT_COUNT;
|
||||
return Math.min(
|
||||
appConfig.toolbar.mirror.max,
|
||||
Math.max(appConfig.toolbar.mirror.min, Math.round(count))
|
||||
MAX_MIRROR_SEGMENT_COUNT,
|
||||
Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(count))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { appConfig } from '../config';
|
||||
|
||||
const DRAW_HINT_CLASS = 'draw-hint';
|
||||
const INTRO_DURATION_SECONDS = 4;
|
||||
const DRAW_HINT_DELAY_MS = 3000;
|
||||
|
||||
export class IntroPrompt {
|
||||
private introComplete = false;
|
||||
|
|
@ -13,10 +13,7 @@ export class IntroPrompt {
|
|||
public get progress(): number {
|
||||
return this.introComplete
|
||||
? 1
|
||||
: Math.min(
|
||||
1,
|
||||
this.introElapsedSeconds / appConfig.simulation.intro.durationSeconds
|
||||
);
|
||||
: Math.min(1, this.introElapsedSeconds / INTRO_DURATION_SECONDS);
|
||||
}
|
||||
|
||||
public get shouldRegenerateTitleOnResize(): boolean {
|
||||
|
|
@ -33,7 +30,7 @@ export class IntroPrompt {
|
|||
: 0;
|
||||
this.introElapsedSeconds = Math.min(
|
||||
this.introElapsedSeconds,
|
||||
Math.max(0, appConfig.simulation.intro.durationSeconds - safeRemainingSeconds)
|
||||
Math.max(0, INTRO_DURATION_SECONDS - safeRemainingSeconds)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -45,10 +42,7 @@ export class IntroPrompt {
|
|||
this.introElapsedSeconds += safeDeltaTime;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.introComplete &&
|
||||
this.introElapsedSeconds >= appConfig.simulation.intro.durationSeconds
|
||||
) {
|
||||
if (!this.introComplete && this.introElapsedSeconds >= INTRO_DURATION_SECONDS) {
|
||||
this.complete(now);
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +50,7 @@ export class IntroPrompt {
|
|||
!this.introComplete ||
|
||||
this.hasStartedDrawing ||
|
||||
this.introCompletedAt === null ||
|
||||
now - this.introCompletedAt < appConfig.simulation.intro.drawHintDelayMs
|
||||
now - this.introCompletedAt < DRAW_HINT_DELAY_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { appConfig, type GardenAppConfig } from '../config';
|
||||
import { AGENT_FLOAT_COUNT, writeAgentValues } from '../pipelines/agents/agent-limits';
|
||||
import { clamp, easeOutQuad, mix, mixAngle, smoothstep } from '../utils/math';
|
||||
|
||||
|
|
@ -18,9 +17,43 @@ interface IntroTitleAgentOptions {
|
|||
}
|
||||
|
||||
type RandomSource = () => number;
|
||||
type IntroPathEasing = GardenAppConfig['simulation']['intro']['pathEasing'];
|
||||
type IntroPathEasing = 'easeOutQuad' | 'linear';
|
||||
|
||||
const INTRO_TITLE = 'Fleeting';
|
||||
const INTRO_ANGLE_JITTER_RADIANS = Math.PI * 0.08;
|
||||
const INTRO_ANGLE_EASE_START = 0.6;
|
||||
const INTRO_ANGLE_EASE_END = 1;
|
||||
const INTRO_CIRCLE_MIN_SIDE_RATIO = 0.32;
|
||||
const INTRO_CIRCLE_MAX_SIDE_RATIO = 0.46;
|
||||
const INTRO_ENTRY_JITTER_SIDE_RATIO = 0.035;
|
||||
const INTRO_FONT_FAMILY = '"Open Sans", sans-serif';
|
||||
const INTRO_FONT_SCALE_DOWN = 0.94;
|
||||
const INTRO_INITIAL_FONT_HEIGHT_RATIO = 0.28;
|
||||
const INTRO_INITIAL_FONT_WIDTH_RATIO = 0.19;
|
||||
const INTRO_LETTER_SPACING_EM = 0.07;
|
||||
const INTRO_MASK_ALPHA_THRESHOLD = 32;
|
||||
const INTRO_MASK_GRADIENT_THRESHOLD = 8;
|
||||
const INTRO_MASK_MAX_PIXELS = 1_000_000;
|
||||
const INTRO_MASK_SAMPLE_DENSITY = 540;
|
||||
const INTRO_MAX_HEIGHT_RATIO = 0.25;
|
||||
const INTRO_MAX_WIDTH_RATIO = 0.76;
|
||||
const INTRO_MIN_ENTRY_JITTER_PX = 6;
|
||||
const INTRO_MIN_FONT_SIZE_PX = 18;
|
||||
const INTRO_MIN_TARGET_JITTER_PX = 1;
|
||||
const INTRO_PATH_EASING: IntroPathEasing = 'easeOutQuad';
|
||||
const INTRO_PATH_PROGRESS_EPSILON = 0.001;
|
||||
const INTRO_RADIAL_JITTER_RATIO = 0.35;
|
||||
const INTRO_RADIAL_START_EPSILON = 0.001;
|
||||
const INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER = 0.12;
|
||||
const INTRO_TARGET_DELAY_MAX = 0.22;
|
||||
const INTRO_TARGET_DELAY_RANDOM_MULTIPLIER = 0.06;
|
||||
const INTRO_TARGET_JITTER_SIDE_RATIO = 0.0035;
|
||||
const INTRO_TITLE_COLOR_CUT_LETTERS = [2, 5] as const;
|
||||
const INTRO_TITLE_RADIUS_MULTIPLIER = 1.55;
|
||||
const INTRO_TITLE_STROKE_WIDTH_MIN_PX = 6;
|
||||
const INTRO_TITLE_STROKE_WIDTH_RATIO = 0.11;
|
||||
const INTRO_VERTICAL_ANCHOR = 0.47;
|
||||
|
||||
const INTRO_TITLE = appConfig.simulation.intro.title;
|
||||
const isLinearPathEasing = (pathEasing: IntroPathEasing): boolean =>
|
||||
pathEasing === 'linear';
|
||||
|
||||
|
|
@ -47,30 +80,27 @@ export const createIntroTitleAgents = ({
|
|||
const data = new Float32Array(count * AGENT_FLOAT_COUNT);
|
||||
const minSide = Math.min(safeWidth, safeHeight);
|
||||
const targetJitter = Math.max(
|
||||
appConfig.simulation.intro.minTargetJitterPx,
|
||||
minSide * appConfig.simulation.intro.targetJitterSideRatio
|
||||
INTRO_MIN_TARGET_JITTER_PX,
|
||||
minSide * INTRO_TARGET_JITTER_SIDE_RATIO
|
||||
);
|
||||
const entryJitter = Math.max(
|
||||
appConfig.simulation.intro.minEntryJitterPx,
|
||||
minSide * appConfig.simulation.intro.entryJitterSideRatio
|
||||
INTRO_MIN_ENTRY_JITTER_PX,
|
||||
minSide * INTRO_ENTRY_JITTER_SIDE_RATIO
|
||||
);
|
||||
const titleRadius = points.reduce(
|
||||
(radius, point) =>
|
||||
Math.max(
|
||||
radius,
|
||||
Math.hypot(
|
||||
point.x - safeWidth / 2,
|
||||
point.y - safeHeight * appConfig.simulation.intro.verticalAnchor
|
||||
)
|
||||
Math.hypot(point.x - safeWidth / 2, point.y - safeHeight * INTRO_VERTICAL_ANCHOR)
|
||||
),
|
||||
0
|
||||
);
|
||||
const introCircleRadius = Math.min(
|
||||
Math.max(
|
||||
titleRadius * appConfig.simulation.intro.titleRadiusMultiplier,
|
||||
minSide * appConfig.simulation.intro.circleMinSideRatio
|
||||
titleRadius * INTRO_TITLE_RADIUS_MULTIPLIER,
|
||||
minSide * INTRO_CIRCLE_MIN_SIDE_RATIO
|
||||
),
|
||||
minSide * appConfig.simulation.intro.circleMaxSideRatio
|
||||
minSide * INTRO_CIRCLE_MAX_SIDE_RATIO
|
||||
);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
|
|
@ -101,21 +131,16 @@ export const createIntroTitleAgents = ({
|
|||
const distanceFraction =
|
||||
Math.hypot(targetX - startX, targetY - startY) / Math.hypot(safeWidth, safeHeight);
|
||||
const introDelay = Math.min(
|
||||
appConfig.simulation.intro.targetDelayMax,
|
||||
distanceFraction * appConfig.simulation.intro.targetDelayDistanceMultiplier +
|
||||
random() * appConfig.simulation.intro.targetDelayRandomMultiplier
|
||||
INTRO_TARGET_DELAY_MAX,
|
||||
distanceFraction * INTRO_TARGET_DELAY_DISTANCE_MULTIPLIER +
|
||||
random() * INTRO_TARGET_DELAY_RANDOM_MULTIPLIER
|
||||
);
|
||||
const pathProgress = getIntroAgentPathProgress(introProgress, introDelay);
|
||||
const initialAngle =
|
||||
approachAngle + (random() - 0.5) * appConfig.simulation.intro.angleJitterRadians;
|
||||
const initialAngle = approachAngle + (random() - 0.5) * INTRO_ANGLE_JITTER_RADIANS;
|
||||
const currentAngle = mixAngle(
|
||||
initialAngle,
|
||||
targetAngle,
|
||||
smoothstep(
|
||||
appConfig.simulation.intro.angleEaseStart,
|
||||
appConfig.simulation.intro.angleEaseEnd,
|
||||
pathProgress
|
||||
)
|
||||
smoothstep(INTRO_ANGLE_EASE_START, INTRO_ANGLE_EASE_END, pathProgress)
|
||||
);
|
||||
writeAgentValues(data, i, {
|
||||
positionX: mix(startX, targetX, pathProgress),
|
||||
|
|
@ -142,12 +167,12 @@ const getIntroRadialStart = (
|
|||
random: RandomSource
|
||||
): [number, number] => {
|
||||
const centerX = width / 2;
|
||||
const centerY = height * appConfig.simulation.intro.verticalAnchor;
|
||||
const centerY = height * INTRO_VERTICAL_ANCHOR;
|
||||
const offsetX = targetX - centerX;
|
||||
const offsetY = targetY - centerY;
|
||||
const length = Math.hypot(offsetX, offsetY);
|
||||
const angle =
|
||||
length > appConfig.simulation.intro.radialStartEpsilon
|
||||
length > INTRO_RADIAL_START_EPSILON
|
||||
? Math.atan2(offsetY, offsetX)
|
||||
: random() * Math.PI * 2;
|
||||
const directionX = Math.cos(angle);
|
||||
|
|
@ -155,8 +180,7 @@ const getIntroRadialStart = (
|
|||
const tangentX = -directionY;
|
||||
const tangentY = directionX;
|
||||
const tangentJitter = (random() - 0.5) * jitter;
|
||||
const radialJitter =
|
||||
(random() - 0.5) * jitter * appConfig.simulation.intro.radialJitterRatio;
|
||||
const radialJitter = (random() - 0.5) * jitter * INTRO_RADIAL_JITTER_RATIO;
|
||||
const startX =
|
||||
centerX + directionX * (radius + radialJitter) + tangentX * tangentJitter;
|
||||
const startY =
|
||||
|
|
@ -172,7 +196,7 @@ const createIntroTitlePoints = (
|
|||
width: number,
|
||||
height: number
|
||||
): Array<IntroTitlePoint> => {
|
||||
const safeMaxPixels = Math.max(1, appConfig.simulation.intro.maskMaxPixels);
|
||||
const safeMaxPixels = Math.max(1, INTRO_MASK_MAX_PIXELS);
|
||||
const maskScale = Math.min(1, Math.sqrt(safeMaxPixels / Math.max(1, width * height)));
|
||||
const maskWidth = Math.max(1, Math.round(width * maskScale));
|
||||
const maskHeight = Math.max(1, Math.round(height * maskScale));
|
||||
|
|
@ -188,28 +212,28 @@ const createIntroTitlePoints = (
|
|||
|
||||
const fontSize = getIntroTitleFontSize(context, maskWidth, maskHeight);
|
||||
context.clearRect(0, 0, maskWidth, maskHeight);
|
||||
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||
context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillStyle = '#fff';
|
||||
context.strokeStyle = '#fff';
|
||||
context.lineJoin = 'round';
|
||||
context.lineWidth = Math.max(
|
||||
appConfig.simulation.intro.titleStrokeWidthMinPx,
|
||||
fontSize * appConfig.simulation.intro.titleStrokeWidthRatio
|
||||
INTRO_TITLE_STROKE_WIDTH_MIN_PX,
|
||||
fontSize * INTRO_TITLE_STROKE_WIDTH_RATIO
|
||||
);
|
||||
const letterSpacing = fontSize * appConfig.simulation.intro.letterSpacingEm;
|
||||
const letterSpacing = fontSize * INTRO_LETTER_SPACING_EM;
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
maskWidth / 2,
|
||||
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||
maskHeight * INTRO_VERTICAL_ANCHOR,
|
||||
letterSpacing,
|
||||
'stroke'
|
||||
);
|
||||
drawIntroTitleText(
|
||||
context,
|
||||
maskWidth / 2,
|
||||
maskHeight * appConfig.simulation.intro.verticalAnchor,
|
||||
maskHeight * INTRO_VERTICAL_ANCHOR,
|
||||
letterSpacing,
|
||||
'fill'
|
||||
);
|
||||
|
|
@ -217,9 +241,7 @@ const createIntroTitlePoints = (
|
|||
const { data } = context.getImageData(0, 0, maskWidth, maskHeight);
|
||||
const step = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
Math.min(maskWidth, maskHeight) / appConfig.simulation.intro.maskSampleDensity
|
||||
)
|
||||
Math.floor(Math.min(maskWidth, maskHeight) / INTRO_MASK_SAMPLE_DENSITY)
|
||||
);
|
||||
const points: Array<IntroTitlePoint> = [];
|
||||
const characterColorBoundaries = getIntroTitleColorBoundaries(
|
||||
|
|
@ -231,7 +253,7 @@ const createIntroTitlePoints = (
|
|||
for (let y = 0; y < maskHeight; y += step) {
|
||||
for (let x = 0; x < maskWidth; x += step) {
|
||||
const alpha = getMaskAlpha(data, maskWidth, maskHeight, x, y);
|
||||
if (alpha < appConfig.simulation.intro.maskAlphaThreshold) {
|
||||
if (alpha < INTRO_MASK_ALPHA_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -255,9 +277,9 @@ const getIntroTitleColorBoundaries = (
|
|||
const letters = Array.from(INTRO_TITLE);
|
||||
const totalWidth = measureIntroTitleText(context, letters, letterSpacing);
|
||||
let x = width / 2 - totalWidth / 2;
|
||||
const cutLetters = appConfig.simulation.intro.titleColorCutLetters
|
||||
.map((cutLetter) => Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter))))
|
||||
.sort((a, b) => a - b);
|
||||
const cutLetters = INTRO_TITLE_COLOR_CUT_LETTERS.map((cutLetter) =>
|
||||
Math.min(letters.length - 1, Math.max(1, Math.round(cutLetter)))
|
||||
).sort((a, b) => a - b);
|
||||
const [firstCutLetter, secondCutLetter] = cutLetters;
|
||||
const letterBoxes = letters.map((letter, index) => {
|
||||
const letterWidth = context.measureText(letter).width;
|
||||
|
|
@ -330,17 +352,17 @@ const getIntroTitleFontSize = (
|
|||
width: number,
|
||||
height: number
|
||||
): number => {
|
||||
const maxWidth = width * appConfig.simulation.intro.maxWidthRatio;
|
||||
const maxHeight = height * appConfig.simulation.intro.maxHeightRatio;
|
||||
const maxWidth = width * INTRO_MAX_WIDTH_RATIO;
|
||||
const maxHeight = height * INTRO_MAX_HEIGHT_RATIO;
|
||||
let fontSize = Math.floor(
|
||||
Math.min(
|
||||
height * appConfig.simulation.intro.initialFontHeightRatio,
|
||||
width * appConfig.simulation.intro.initialFontWidthRatio
|
||||
height * INTRO_INITIAL_FONT_HEIGHT_RATIO,
|
||||
width * INTRO_INITIAL_FONT_WIDTH_RATIO
|
||||
)
|
||||
);
|
||||
|
||||
while (fontSize > appConfig.simulation.intro.minFontSizePx) {
|
||||
context.font = `${fontSize}px ${appConfig.simulation.intro.fontFamily}`;
|
||||
while (fontSize > INTRO_MIN_FONT_SIZE_PX) {
|
||||
context.font = `${fontSize}px ${INTRO_FONT_FAMILY}`;
|
||||
const metrics = context.measureText(INTRO_TITLE);
|
||||
const measuredHeight =
|
||||
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize;
|
||||
|
|
@ -349,7 +371,7 @@ const getIntroTitleFontSize = (
|
|||
return fontSize;
|
||||
}
|
||||
|
||||
fontSize = Math.floor(fontSize * appConfig.simulation.intro.fontScaleDown);
|
||||
fontSize = Math.floor(fontSize * INTRO_FONT_SCALE_DOWN);
|
||||
}
|
||||
|
||||
return fontSize;
|
||||
|
|
@ -369,10 +391,7 @@ const estimateMaskTangent = (
|
|||
getMaskAlpha(data, width, height, x, y + 1) -
|
||||
getMaskAlpha(data, width, height, x, y - 1);
|
||||
|
||||
if (
|
||||
Math.abs(gradientX) + Math.abs(gradientY) <
|
||||
appConfig.simulation.intro.maskGradientThreshold
|
||||
) {
|
||||
if (Math.abs(gradientX) + Math.abs(gradientY) < INTRO_MASK_GRADIENT_THRESHOLD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -397,8 +416,7 @@ const getIntroAgentPathProgress = (introProgress: number, introDelay: number): n
|
|||
}
|
||||
|
||||
const activeProgress =
|
||||
(introProgress - introDelay) /
|
||||
Math.max(appConfig.simulation.intro.pathProgressEpsilon, 1 - introDelay);
|
||||
(introProgress - introDelay) / Math.max(INTRO_PATH_PROGRESS_EPSILON, 1 - introDelay);
|
||||
return easePathProgress(clamp(activeProgress, 0, 1));
|
||||
};
|
||||
|
||||
|
|
@ -414,7 +432,7 @@ const createSeededRandom = (seed: number): RandomSource => {
|
|||
};
|
||||
|
||||
const easePathProgress = (amount: number): number => {
|
||||
if (isLinearPathEasing(appConfig.simulation.intro.pathEasing)) {
|
||||
if (isLinearPathEasing(INTRO_PATH_EASING)) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ const ZERO_STAT_TEXT = '0';
|
|||
const ZERO_FRAME_TIME_TEXT = '0ms';
|
||||
const ZERO_RESOLUTION_TEXT = '0x0';
|
||||
|
||||
export const perfStatsOverlayState = {
|
||||
isVisible: import.meta.env.DEV,
|
||||
};
|
||||
|
||||
interface PerfStatsSnapshot {
|
||||
time: DOMHighResTimeStamp;
|
||||
fps: number;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { GardenAudio } from '../audio/garden-audio';
|
||||
import { appConfig } from '../config';
|
||||
import { getSafePixelRatio } from '../pipelines/brush/brush-pipeline';
|
||||
import { activeVibe } from '../settings';
|
||||
import { MIN_DELTA_TIME_SECONDS } from '../utils/delta-time-calculator';
|
||||
import { BrushStrokeSmoother } from './brush-stroke-smoother';
|
||||
import { type StrokeSegment } from './game-loop-types';
|
||||
import { getMirroredStrokeSegments } from './stroke-mirroring';
|
||||
|
|
@ -155,7 +155,7 @@ export class GardenPointerInput {
|
|||
const previousPosition = this.lastPointerPosition ?? position;
|
||||
const previousTimeMs = this.lastPointerEventTimeMs ?? event.timeStamp;
|
||||
const elapsedSeconds = Math.max(
|
||||
appConfig.deltaTime.minDeltaTimeSeconds,
|
||||
MIN_DELTA_TIME_SECONDS,
|
||||
(event.timeStamp - previousTimeMs) / 1000
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { appConfig } from '../config';
|
||||
import { AgentPipeline } from '../pipelines/agents/agent-pipeline';
|
||||
import { BrushPipeline } from '../pipelines/brush/brush-pipeline';
|
||||
import { DiffusionPipeline } from '../pipelines/diffusion/diffusion-pipeline';
|
||||
|
|
@ -10,6 +9,11 @@ import { CanvasReadbackRequest } from './game-loop-types';
|
|||
import { GpuProfiler } from './gpu-profiler';
|
||||
import { SimulationTextures } from './simulation-textures';
|
||||
|
||||
const BRUSH_EFFECT_FRAMES_PER_SECOND = 60;
|
||||
// How long the source map continues to be diffused after a brush stroke ends.
|
||||
// 600 frames at ~60 FPS is roughly 10 seconds.
|
||||
const SOURCE_ACTIVE_FRAMES_AFTER_WRITE = 600;
|
||||
|
||||
interface SimulationFramePipelines {
|
||||
agentPipeline: AgentPipeline;
|
||||
brushPipeline: BrushPipeline;
|
||||
|
|
@ -135,10 +139,9 @@ export class SimulationFrameRenderer {
|
|||
}
|
||||
|
||||
const getSourceActiveFrameCount = (): number => {
|
||||
const frameCount =
|
||||
settings.brushEffectDuration * appConfig.simulation.brushEffectFramesPerSecond;
|
||||
const frameCount = settings.brushEffectDuration * BRUSH_EFFECT_FRAMES_PER_SECOND;
|
||||
if (Number.isFinite(frameCount) && frameCount > 0) {
|
||||
return Math.ceil(frameCount);
|
||||
}
|
||||
return Math.max(1, appConfig.simulation.sourceActiveFramesAfterWrite);
|
||||
return Math.max(1, SOURCE_ACTIVE_FRAMES_AFTER_WRITE);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { ERASER_MASK_TEXTURE_FORMAT } from '../pipelines/texture-formats';
|
||||
import {
|
||||
ResizableTexture,
|
||||
type PendingTextureResize,
|
||||
} from '../utils/graphics/resizable-texture';
|
||||
|
||||
const SIMULATION_CLEAR_COLOR = { r: 0, g: 0, b: 0, a: 0 };
|
||||
|
||||
export class SimulationTextures {
|
||||
// trailMapA holds the current trail (read by agent and diffuse). trailMapB
|
||||
// receives the diffuse output; the two swap each frame so the freshly
|
||||
|
|
@ -83,7 +84,7 @@ export class SimulationTextures {
|
|||
colorAttachments: [
|
||||
{
|
||||
view: texture.getTextureView(),
|
||||
clearValue: appConfig.simulation.clearColor,
|
||||
clearValue: SIMULATION_CLEAR_COLOR,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
},
|
||||
|
|
@ -122,7 +123,7 @@ export class SimulationTextures {
|
|||
colorAttachments: [
|
||||
{
|
||||
view: this.sourceMapA.getTextureView(),
|
||||
clearValue: appConfig.simulation.clearColor,
|
||||
clearValue: SIMULATION_CLEAR_COLOR,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { vec2 } from 'gl-matrix';
|
|||
|
||||
import { type StrokeSegment } from './game-loop-types';
|
||||
|
||||
export const MIN_MIRROR_SEGMENT_COUNT = 1;
|
||||
export const MAX_MIRROR_SEGMENT_COUNT = 12;
|
||||
|
||||
export const getMirroredStrokeSegments = (
|
||||
from: vec2,
|
||||
to: vec2,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { CanvasReadbackRequest } from './game-loop-types';
|
||||
|
||||
|
|
@ -24,21 +23,41 @@ interface ToolbarContrastMetrics {
|
|||
|
||||
const TOOLBAR_BACKGROUND_OPACITY_PROPERTY = '--toolbar-background-opacity';
|
||||
const TOOLBAR_BACKGROUND_STRENGTH_PROPERTY = '--toolbar-background-strength';
|
||||
const BACKGROUND_OPACITY_MAX = 0.82;
|
||||
const BRIGHT_LUMINANCE_THRESHOLD = 0.32;
|
||||
const BRIGHT_WEIGHT = 0.65;
|
||||
const BYTES_PER_SAMPLE = 4;
|
||||
const CONTRAST_OFFSET = 0.05;
|
||||
const GPU_COPY_BYTES_PER_ROW_ALIGNMENT = 256;
|
||||
const LINEAR_CHANNEL_BREAKPOINT = 0.03928;
|
||||
const LINEAR_CHANNEL_DIVISOR = 12.92;
|
||||
const LINEAR_CHANNEL_GAMMA = 2.4;
|
||||
const LINEAR_CHANNEL_OFFSET = 0.055;
|
||||
const LINEAR_CHANNEL_SCALE = 1.055;
|
||||
const LOW_CONTRAST_THRESHOLD = 3;
|
||||
const LOW_CONTRAST_WEIGHT = 1.8;
|
||||
const LUMINANCE_BASE = 0.11;
|
||||
const LUMINANCE_BLUE_WEIGHT = 0.0722;
|
||||
const LUMINANCE_GREEN_WEIGHT = 0.7152;
|
||||
const LUMINANCE_RANGE = 0.28;
|
||||
const LUMINANCE_RED_WEIGHT = 0.2126;
|
||||
const SAMPLE_COLUMNS = 13;
|
||||
const SAMPLE_INTERVAL_MS = 300;
|
||||
const SAMPLE_ROWS = 7;
|
||||
const WHITE_CONTRAST_NUMERATOR = 1.05;
|
||||
|
||||
const getLinearChannel = (channel: number): number => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= appConfig.toolbar.contrast.linearChannelBreakpoint
|
||||
? normalized / appConfig.toolbar.contrast.linearChannelDivisor
|
||||
: ((normalized + appConfig.toolbar.contrast.linearChannelOffset) /
|
||||
appConfig.toolbar.contrast.linearChannelScale) **
|
||||
appConfig.toolbar.contrast.linearChannelGamma;
|
||||
return normalized <= LINEAR_CHANNEL_BREAKPOINT
|
||||
? normalized / LINEAR_CHANNEL_DIVISOR
|
||||
: ((normalized + LINEAR_CHANNEL_OFFSET) / LINEAR_CHANNEL_SCALE) **
|
||||
LINEAR_CHANNEL_GAMMA;
|
||||
};
|
||||
|
||||
const getRelativeLuminance = (red: number, green: number, blue: number): number =>
|
||||
appConfig.toolbar.contrast.luminanceRedWeight * getLinearChannel(red) +
|
||||
appConfig.toolbar.contrast.luminanceGreenWeight * getLinearChannel(green) +
|
||||
appConfig.toolbar.contrast.luminanceBlueWeight * getLinearChannel(blue);
|
||||
LUMINANCE_RED_WEIGHT * getLinearChannel(red) +
|
||||
LUMINANCE_GREEN_WEIGHT * getLinearChannel(green) +
|
||||
LUMINANCE_BLUE_WEIGHT * getLinearChannel(blue);
|
||||
|
||||
const getToolbarContrastMetrics = (
|
||||
pixels: Uint8Array,
|
||||
|
|
@ -46,8 +65,7 @@ const getToolbarContrastMetrics = (
|
|||
isBgra: boolean
|
||||
): ToolbarContrastMetrics => {
|
||||
const count = sampleOffsets.filter(
|
||||
(offset) =>
|
||||
offset >= 0 && offset + appConfig.toolbar.contrast.bytesPerSample <= pixels.length
|
||||
(offset) => offset >= 0 && offset + BYTES_PER_SAMPLE <= pixels.length
|
||||
).length;
|
||||
if (count === 0) {
|
||||
return {
|
||||
|
|
@ -63,10 +81,7 @@ const getToolbarContrastMetrics = (
|
|||
let lowContrastCount = 0;
|
||||
|
||||
sampleOffsets.forEach((offset) => {
|
||||
if (
|
||||
offset < 0 ||
|
||||
offset + appConfig.toolbar.contrast.bytesPerSample > pixels.length
|
||||
) {
|
||||
if (offset < 0 || offset + BYTES_PER_SAMPLE > pixels.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -74,15 +89,13 @@ const getToolbarContrastMetrics = (
|
|||
const green = pixels[offset + 1];
|
||||
const blue = pixels[offset + (isBgra ? 0 : 2)];
|
||||
const luminance = getRelativeLuminance(red, green, blue);
|
||||
const contrastWithWhite =
|
||||
appConfig.toolbar.contrast.whiteContrastNumerator /
|
||||
(luminance + appConfig.toolbar.contrast.contrastOffset);
|
||||
const contrastWithWhite = WHITE_CONTRAST_NUMERATOR / (luminance + CONTRAST_OFFSET);
|
||||
|
||||
luminanceTotal += luminance;
|
||||
if (luminance > appConfig.toolbar.contrast.brightLuminanceThreshold) {
|
||||
if (luminance > BRIGHT_LUMINANCE_THRESHOLD) {
|
||||
brightCount++;
|
||||
}
|
||||
if (contrastWithWhite < appConfig.toolbar.contrast.lowContrastThreshold) {
|
||||
if (contrastWithWhite < LOW_CONTRAST_THRESHOLD) {
|
||||
lowContrastCount++;
|
||||
}
|
||||
});
|
||||
|
|
@ -91,13 +104,11 @@ const getToolbarContrastMetrics = (
|
|||
const brightRatio = brightCount / count;
|
||||
const lowContrastRatio = lowContrastCount / count;
|
||||
const backgroundStrength = clamp01(
|
||||
Math.max(0, averageLuminance - appConfig.toolbar.contrast.luminanceBase) /
|
||||
appConfig.toolbar.contrast.luminanceRange +
|
||||
brightRatio * appConfig.toolbar.contrast.brightWeight +
|
||||
lowContrastRatio * appConfig.toolbar.contrast.lowContrastWeight
|
||||
Math.max(0, averageLuminance - LUMINANCE_BASE) / LUMINANCE_RANGE +
|
||||
brightRatio * BRIGHT_WEIGHT +
|
||||
lowContrastRatio * LOW_CONTRAST_WEIGHT
|
||||
);
|
||||
const backgroundOpacity =
|
||||
backgroundStrength * appConfig.toolbar.contrast.backgroundOpacityMax;
|
||||
const backgroundOpacity = backgroundStrength * BACKGROUND_OPACITY_MAX;
|
||||
|
||||
return {
|
||||
averageLuminance,
|
||||
|
|
@ -128,7 +139,7 @@ export class ToolbarContrastMonitor {
|
|||
if (
|
||||
this.isDestroyed ||
|
||||
this.isReadbackPending ||
|
||||
time - this.lastSampleAt < appConfig.toolbar.contrast.sampleIntervalMs
|
||||
time - this.lastSampleAt < SAMPLE_INTERVAL_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -211,12 +222,12 @@ export class ToolbarContrastMonitor {
|
|||
|
||||
private setToolbarBackgroundOpacity(backgroundOpacity: number): void {
|
||||
const safeBackgroundOpacity = Math.min(
|
||||
appConfig.toolbar.contrast.backgroundOpacityMax,
|
||||
BACKGROUND_OPACITY_MAX,
|
||||
Math.max(0, backgroundOpacity)
|
||||
);
|
||||
const backgroundStrength =
|
||||
appConfig.toolbar.contrast.backgroundOpacityMax > 0
|
||||
? clamp01(safeBackgroundOpacity / appConfig.toolbar.contrast.backgroundOpacityMax)
|
||||
BACKGROUND_OPACITY_MAX > 0
|
||||
? clamp01(safeBackgroundOpacity / BACKGROUND_OPACITY_MAX)
|
||||
: 0;
|
||||
|
||||
this.toolbar.style.setProperty(
|
||||
|
|
@ -279,22 +290,20 @@ export class ToolbarContrastMonitor {
|
|||
}
|
||||
|
||||
const bytesPerRow = alignTo(
|
||||
width * appConfig.toolbar.contrast.bytesPerSample,
|
||||
width * BYTES_PER_SAMPLE,
|
||||
GPU_COPY_BYTES_PER_ROW_ALIGNMENT
|
||||
);
|
||||
const points = new Map<string, CanvasSamplePoint>();
|
||||
|
||||
for (let row = 0; row < appConfig.toolbar.contrast.sampleRows; row++) {
|
||||
const cssY =
|
||||
top + ((row + 0.5) / appConfig.toolbar.contrast.sampleRows) * cssHeight;
|
||||
for (let row = 0; row < SAMPLE_ROWS; row++) {
|
||||
const cssY = top + ((row + 0.5) / SAMPLE_ROWS) * cssHeight;
|
||||
const y = Math.min(
|
||||
this.canvas.height - 1,
|
||||
Math.max(0, Math.floor((cssY - canvasRect.top) * yScale))
|
||||
);
|
||||
|
||||
for (let column = 0; column < appConfig.toolbar.contrast.sampleColumns; column++) {
|
||||
const cssX =
|
||||
left + ((column + 0.5) / appConfig.toolbar.contrast.sampleColumns) * cssWidth;
|
||||
for (let column = 0; column < SAMPLE_COLUMNS; column++) {
|
||||
const cssX = left + ((column + 0.5) / SAMPLE_COLUMNS) * cssWidth;
|
||||
const x = Math.min(
|
||||
this.canvas.width - 1,
|
||||
Math.max(0, Math.floor((cssX - canvasRect.left) * xScale))
|
||||
|
|
@ -309,8 +318,7 @@ export class ToolbarContrastMonitor {
|
|||
origin,
|
||||
sampleOffsets: [...points.values()].map(
|
||||
(point) =>
|
||||
(point.y - origin.y) * bytesPerRow +
|
||||
(point.x - origin.x) * appConfig.toolbar.contrast.bytesPerSample
|
||||
(point.y - origin.y) * bytesPerRow + (point.x - origin.x) * BYTES_PER_SAMPLE
|
||||
),
|
||||
width,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ const main = async () => {
|
|||
HTMLButtonElement
|
||||
);
|
||||
const infoButton = queryRequiredElement('[data-control="info"]', HTMLButtonElement);
|
||||
const infoCloseButton = queryRequiredElement(
|
||||
'[data-control="info-close"]',
|
||||
HTMLButtonElement
|
||||
);
|
||||
const infoElement = queryRequiredElement('.info-page', HTMLElement);
|
||||
const fullScreenButton = queryRequiredElement(
|
||||
'[data-control="full-screen"]',
|
||||
|
|
@ -113,6 +117,7 @@ const main = async () => {
|
|||
};
|
||||
|
||||
const infoPageHandler = new CollapsiblePanelAnimator(infoButton, infoElement, aside);
|
||||
infoCloseButton.addEventListener('click', () => infoPageHandler.close());
|
||||
new MenuHider(
|
||||
aside,
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
import { appConfig } from '../config';
|
||||
import { DEFAULT_AUDIO_VOLUME } from '../audio/garden-audio-config';
|
||||
import type GameLoop from '../game-loop/game-loop';
|
||||
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
import { clamp01 } from '../utils/math';
|
||||
|
||||
const AUDIO_MUTED_STORAGE_KEY = 'fleeting-garden:audio-muted';
|
||||
const AUDIO_VOLUME_STORAGE_KEY = 'fleeting-garden:audio-volume';
|
||||
const AUDIO_VOLUME_MIN = 0;
|
||||
const AUDIO_VOLUME_MAX = 1;
|
||||
const AUDIO_VOLUME_STEP = 0.01;
|
||||
|
||||
const clampAudioVolume = (value: number): number => {
|
||||
const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
|
||||
const safeValue = Number.isFinite(value) ? value : defaultVolume;
|
||||
return Math.min(max, Math.max(min, clamp01(safeValue)));
|
||||
const safeValue = Number.isFinite(value) ? value : DEFAULT_AUDIO_VOLUME;
|
||||
return Math.min(AUDIO_VOLUME_MAX, Math.max(AUDIO_VOLUME_MIN, clamp01(safeValue)));
|
||||
};
|
||||
|
||||
const readInitialAudioVolume = (): number => {
|
||||
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
|
||||
const storedVolume = readBrowserStorage(AUDIO_VOLUME_STORAGE_KEY);
|
||||
return storedVolume === null
|
||||
? appConfig.toolbar.volume.default
|
||||
? DEFAULT_AUDIO_VOLUME
|
||||
: clampAudioVolume(Number(storedVolume));
|
||||
};
|
||||
|
||||
|
|
@ -45,7 +50,7 @@ export class AudioControl {
|
|||
|
||||
private audioVolume = readInitialAudioVolume();
|
||||
private isMutedState =
|
||||
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
|
||||
readBrowserStorage(AUDIO_MUTED_STORAGE_KEY) === STORED_MUTED_TRUE ||
|
||||
this.audioVolume <= 0;
|
||||
|
||||
public constructor(private readonly options: AudioControlOptions) {
|
||||
|
|
@ -85,9 +90,9 @@ export class AudioControl {
|
|||
this.soundButton.setAttribute('aria-label', muteLabel);
|
||||
this.soundButton.title = muteLabel;
|
||||
|
||||
this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
|
||||
this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
|
||||
this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
|
||||
this.volumeSlider.min = AUDIO_VOLUME_MIN.toString();
|
||||
this.volumeSlider.max = AUDIO_VOLUME_MAX.toString();
|
||||
this.volumeSlider.step = AUDIO_VOLUME_STEP.toString();
|
||||
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
|
||||
this.volumeSlider.setAttribute(
|
||||
'aria-valuetext',
|
||||
|
|
@ -107,7 +112,7 @@ export class AudioControl {
|
|||
private readonly onToggleMute = () => {
|
||||
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
|
||||
if (shouldUnmute && this.audioVolume <= 0) {
|
||||
this.audioVolume = appConfig.toolbar.volume.default;
|
||||
this.audioVolume = DEFAULT_AUDIO_VOLUME;
|
||||
}
|
||||
this.isMutedState = !shouldUnmute;
|
||||
this.persist();
|
||||
|
|
@ -141,11 +146,11 @@ export class AudioControl {
|
|||
|
||||
private persist(): void {
|
||||
writeBrowserStorage(
|
||||
appConfig.storage.audioMutedKey,
|
||||
AUDIO_MUTED_STORAGE_KEY,
|
||||
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
|
||||
);
|
||||
writeBrowserStorage(
|
||||
appConfig.storage.audioVolumeKey,
|
||||
AUDIO_VOLUME_STORAGE_KEY,
|
||||
formatStoredAudioVolume(this.audioVolume)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FolderApi } from '@tweakpane/core';
|
||||
|
||||
import { appConfig, normalizeNumberControlValue } from '../config';
|
||||
import { normalizeNumberControlValue, runtimeControls } from '../config';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { rgbColorToCss } from '../utils/rgb-color';
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ export class ColorReactionMatrixControl {
|
|||
const cell = document.createElement('div');
|
||||
cell.className = 'color-reaction-matrix__cell';
|
||||
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
const config = runtimeControls[key];
|
||||
if (!config) {
|
||||
return cell;
|
||||
}
|
||||
|
|
@ -165,7 +165,7 @@ export class ColorReactionMatrixControl {
|
|||
sourceColorIndex: number,
|
||||
targetColorIndex: number
|
||||
): void {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
const config = runtimeControls[key];
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { Pane } from 'tweakpane';
|
|||
|
||||
import type { GardenAudioVibeSettings } from '../audio/garden-audio-config';
|
||||
import {
|
||||
appConfig,
|
||||
normalizeNumberControlValue,
|
||||
runtimeControls,
|
||||
type GardenRuntimeSettings,
|
||||
type NumberControlConfig,
|
||||
} from '../config';
|
||||
import { perfStatsOverlayState } from '../game-loop/perf-stats-overlay';
|
||||
import { activeVibe, settings } from '../settings';
|
||||
import { hexColorToRgbColor, rgbColorToHex, type RgbColor } from '../utils/rgb-color';
|
||||
import { ColorReactionMatrixControl } from './color-reaction-matrix-control';
|
||||
|
|
@ -29,6 +30,8 @@ interface PaneState extends GardenAudioVibeSettings {
|
|||
}
|
||||
|
||||
const runtimeFolderOrder = ['Brush', 'Movement', 'Look', 'Performance'] as const;
|
||||
const CONFIG_PANE_TITLE = 'Garden Settings';
|
||||
const CONFIG_PANE_START_HIDDEN = true;
|
||||
|
||||
const MUSIC_CONTROLS: ReadonlyArray<{
|
||||
key: VibeNumberKey;
|
||||
|
|
@ -56,7 +59,7 @@ interface ConfigPaneOptions {
|
|||
|
||||
const getRuntimeControlKeys = (folder: string): Array<RuntimeControlKey> =>
|
||||
(
|
||||
Object.entries(appConfig.runtimeSettings.controls) as Array<
|
||||
Object.entries(runtimeControls) as Array<
|
||||
[RuntimeControlKey, NumberControlConfig | undefined]
|
||||
>
|
||||
)
|
||||
|
|
@ -123,10 +126,10 @@ export class ConfigPane {
|
|||
|
||||
this.pane = new Pane({
|
||||
container: this.container,
|
||||
title: appConfig.tuningPane.title,
|
||||
title: CONFIG_PANE_TITLE,
|
||||
expanded: true,
|
||||
});
|
||||
this.pane.hidden = appConfig.tuningPane.startHidden;
|
||||
this.pane.hidden = CONFIG_PANE_START_HIDDEN;
|
||||
this.pane.element.classList.add('config-pane');
|
||||
this.pane.element.id = 'config-pane';
|
||||
|
||||
|
|
@ -310,7 +313,7 @@ export class ConfigPane {
|
|||
private getRuntimeControlConfig(
|
||||
key: RuntimeControlKey
|
||||
): NumberControlConfig | undefined {
|
||||
const config = appConfig.runtimeSettings.controls[key];
|
||||
const config = runtimeControls[key];
|
||||
if (!config || key !== 'maxAgentCount') {
|
||||
return config;
|
||||
}
|
||||
|
|
@ -323,7 +326,7 @@ export class ConfigPane {
|
|||
|
||||
private addFpsOverlayBinding(container: PaneContainer): void {
|
||||
container
|
||||
.addBinding(appConfig.tuningPane, 'showFpsOverlay', {
|
||||
.addBinding(perfStatsOverlayState, 'isVisible', {
|
||||
label: 'Show FPS',
|
||||
})
|
||||
.on('change', () => this.options.onConfigChange());
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import {
|
||||
ERASER_SIZE_MAX,
|
||||
ERASER_SIZE_MIN,
|
||||
getEraserSizeFromSliderRatio,
|
||||
getEraserSliderRatioFromSize,
|
||||
} from './eraser-size-control';
|
||||
|
||||
describe('eraser size slider mapping', () => {
|
||||
it('maps slider position quadratically to eraser size', () => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
|
||||
expect(getEraserSizeFromSliderRatio(0)).toBe(min);
|
||||
expect(getEraserSizeFromSliderRatio(0.5)).toBe(min + (max - min) * 0.25);
|
||||
expect(getEraserSizeFromSliderRatio(1)).toBe(max);
|
||||
expect(getEraserSizeFromSliderRatio(0)).toBe(ERASER_SIZE_MIN);
|
||||
expect(getEraserSizeFromSliderRatio(0.5)).toBe(
|
||||
ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25
|
||||
);
|
||||
expect(getEraserSizeFromSliderRatio(1)).toBe(ERASER_SIZE_MAX);
|
||||
});
|
||||
|
||||
it('maps eraser size back to the inverse slider position', () => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
const quarterRangeSize = min + (max - min) * 0.25;
|
||||
const quarterRangeSize = ERASER_SIZE_MIN + (ERASER_SIZE_MAX - ERASER_SIZE_MIN) * 0.25;
|
||||
|
||||
expect(getEraserSliderRatioFromSize(min)).toBe(0);
|
||||
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MIN)).toBe(0);
|
||||
expect(getEraserSliderRatioFromSize(quarterRangeSize)).toBe(0.5);
|
||||
expect(getEraserSliderRatioFromSize(max)).toBe(1);
|
||||
expect(getEraserSliderRatioFromSize(ERASER_SIZE_MAX)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { appConfig } from '../config';
|
||||
import type GameLoop from '../game-loop/game-loop';
|
||||
import { settings } from '../settings';
|
||||
import { DEFAULT_ERASER_SIZE, settings } from '../settings';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
|
||||
export const ERASER_SIZE_MIN = 24;
|
||||
export const ERASER_SIZE_MAX = 480;
|
||||
|
||||
const ERASER_CONTROL_SCALE_MIN = 0.74;
|
||||
const ERASER_CONTROL_SCALE_MAX = 1.34;
|
||||
|
||||
const clampEraserSize = (value: number): number => {
|
||||
const { default: defaultSize, max, min } = appConfig.toolbar.eraser;
|
||||
const safeValue = Number.isFinite(value) ? value : defaultSize;
|
||||
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
||||
const safeValue = Number.isFinite(value) ? value : DEFAULT_ERASER_SIZE;
|
||||
return Math.min(ERASER_SIZE_MAX, Math.max(ERASER_SIZE_MIN, Math.round(safeValue)));
|
||||
};
|
||||
|
||||
const ERASER_SLIDER_MIN = 0;
|
||||
|
|
@ -19,13 +23,14 @@ const clampSliderRatio = (value: number): number => {
|
|||
};
|
||||
|
||||
const getEraserSizeRatio = (size: number): number => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
return (clampEraserSize(size) - min) / (max - min);
|
||||
return (clampEraserSize(size) - ERASER_SIZE_MIN) / (ERASER_SIZE_MAX - ERASER_SIZE_MIN);
|
||||
};
|
||||
|
||||
export const getEraserSizeFromSliderRatio = (sliderRatio: number): number => {
|
||||
const { max, min } = appConfig.toolbar.eraser;
|
||||
return clampEraserSize(min + (max - min) * clampSliderRatio(sliderRatio) ** 2);
|
||||
return clampEraserSize(
|
||||
ERASER_SIZE_MIN +
|
||||
(ERASER_SIZE_MAX - ERASER_SIZE_MIN) * clampSliderRatio(sliderRatio) ** 2
|
||||
);
|
||||
};
|
||||
|
||||
export const getEraserSliderRatioFromSize = (size: number): number =>
|
||||
|
|
@ -72,10 +77,8 @@ export class EraserSizeControl {
|
|||
|
||||
const sizeRatio = getEraserSizeRatio(size);
|
||||
const scale =
|
||||
appConfig.toolbar.eraser.controlScaleMin +
|
||||
(appConfig.toolbar.eraser.controlScaleMax -
|
||||
appConfig.toolbar.eraser.controlScaleMin) *
|
||||
sizeRatio;
|
||||
ERASER_CONTROL_SCALE_MIN +
|
||||
(ERASER_CONTROL_SCALE_MAX - ERASER_CONTROL_SCALE_MIN) * sizeRatio;
|
||||
this.control.style.setProperty('--eraser-progress', `${sliderRatio * 100}%`);
|
||||
this.control.style.setProperty('--eraser-control-scale', scale.toFixed(3));
|
||||
this.syncActiveState();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { appConfig } from '../config';
|
||||
const DESKTOP_AUTO_HIDE_MEDIA_QUERY =
|
||||
'(min-width: 600px) and (hover: hover) and (pointer: fine)';
|
||||
const HIDE_DELAY_MS = 3000;
|
||||
const BOTTOM_REVEAL_DISTANCE_PX = 96;
|
||||
|
||||
export class MenuHider {
|
||||
private readonly desktopMediaQuery = window.matchMedia(
|
||||
appConfig.menuHider.desktopMediaQuery
|
||||
);
|
||||
private readonly desktopMediaQuery = window.matchMedia(DESKTOP_AUTO_HIDE_MEDIA_QUERY);
|
||||
private hideTimeout: number | undefined;
|
||||
private isHidden = false;
|
||||
private pointerInside = false;
|
||||
|
|
@ -95,7 +96,7 @@ export class MenuHider {
|
|||
if (this.canAutoHide) {
|
||||
this.hide();
|
||||
}
|
||||
}, appConfig.menuHider.hideDelayMs);
|
||||
}, HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
private reveal(): void {
|
||||
|
|
@ -134,6 +135,6 @@ export class MenuHider {
|
|||
|
||||
private isNearViewportBottom(clientY: number): boolean {
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
return clientY >= viewportHeight - appConfig.menuHider.bottomRevealDistancePx;
|
||||
return clientY >= viewportHeight - BOTTOM_REVEAL_DISTANCE_PX;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,48 @@
|
|||
import { appConfig } from '../config';
|
||||
import { settings } from '../settings';
|
||||
import {
|
||||
MAX_MIRROR_SEGMENT_COUNT,
|
||||
MIN_MIRROR_SEGMENT_COUNT,
|
||||
} from '../game-loop/stroke-mirroring';
|
||||
import { DEFAULT_MIRROR_SEGMENT_COUNT, settings } from '../settings';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
|
||||
const MIRROR_SEGMENT_STEP = 1;
|
||||
const MIRROR_SEGMENT_OFF_LABEL = 'Mirror off';
|
||||
const MIRROR_SEGMENT_FALLBACK_NAME = 'slices';
|
||||
const MIRROR_SEGMENT_NAMES = {
|
||||
2: 'halves',
|
||||
3: 'thirds',
|
||||
4: 'quarters',
|
||||
5: 'fifths',
|
||||
6: 'sixths',
|
||||
7: 'sevenths',
|
||||
8: 'eighths',
|
||||
9: 'ninths',
|
||||
10: 'tenths',
|
||||
11: 'elevenths',
|
||||
12: 'twelfths',
|
||||
} satisfies Record<number, string>;
|
||||
|
||||
const clampMirrorSegmentCount = (value: number): number => {
|
||||
const { default: defaultCount, max, min } = appConfig.toolbar.mirror;
|
||||
const safeValue = Number.isFinite(value) ? value : defaultCount;
|
||||
return Math.min(max, Math.max(min, Math.round(safeValue)));
|
||||
const safeValue = Number.isFinite(value) ? value : DEFAULT_MIRROR_SEGMENT_COUNT;
|
||||
return Math.min(
|
||||
MAX_MIRROR_SEGMENT_COUNT,
|
||||
Math.max(MIN_MIRROR_SEGMENT_COUNT, Math.round(safeValue))
|
||||
);
|
||||
};
|
||||
|
||||
const getMirrorSegmentRatio = (count: number): number => {
|
||||
const { max, min } = appConfig.toolbar.mirror;
|
||||
return (count - min) / (max - min);
|
||||
return (
|
||||
(count - MIN_MIRROR_SEGMENT_COUNT) /
|
||||
(MAX_MIRROR_SEGMENT_COUNT - MIN_MIRROR_SEGMENT_COUNT)
|
||||
);
|
||||
};
|
||||
|
||||
const formatMirrorSegmentCount = (count: number): string =>
|
||||
count <= 1
|
||||
? appConfig.toolbar.mirror.offLabel
|
||||
? MIRROR_SEGMENT_OFF_LABEL
|
||||
: `${count} ${
|
||||
appConfig.toolbar.mirror.names[
|
||||
count as keyof typeof appConfig.toolbar.mirror.names
|
||||
] ?? appConfig.toolbar.mirror.fallbackSegmentName
|
||||
MIRROR_SEGMENT_NAMES[count as keyof typeof MIRROR_SEGMENT_NAMES] ??
|
||||
MIRROR_SEGMENT_FALLBACK_NAME
|
||||
}`;
|
||||
|
||||
interface MirrorSegmentControlOptions {
|
||||
|
|
@ -50,9 +73,9 @@ export class MirrorSegmentControl {
|
|||
settings.mirrorSegmentCount = count;
|
||||
}
|
||||
|
||||
this.slider.min = appConfig.toolbar.mirror.min.toString();
|
||||
this.slider.max = appConfig.toolbar.mirror.max.toString();
|
||||
this.slider.step = appConfig.toolbar.mirror.step.toString();
|
||||
this.slider.min = MIN_MIRROR_SEGMENT_COUNT.toString();
|
||||
this.slider.max = MAX_MIRROR_SEGMENT_COUNT.toString();
|
||||
this.slider.step = MIRROR_SEGMENT_STEP.toString();
|
||||
this.slider.value = count.toString();
|
||||
|
||||
const label = formatMirrorSegmentCount(count);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../../config';
|
||||
import { getRenderQualityBrushSize } from '../../config/brush-size';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
|
|
@ -40,6 +39,7 @@ export const getSafePixelRatio = (pixelRatio: number | undefined): number =>
|
|||
: 1;
|
||||
|
||||
const UNIFORM_COUNT = 16;
|
||||
const MAX_BRUSH_LINE_COUNT = 240;
|
||||
|
||||
const setBrushUniformValues = (
|
||||
target: Float32Array,
|
||||
|
|
@ -93,7 +93,7 @@ export class BrushPipeline {
|
|||
private readonly device: GPUDevice,
|
||||
private readonly commonState: CommonState
|
||||
) {
|
||||
this.segments = new LineSegmentBuffer(device, appConfig.pipelines.brush.maxLineCount);
|
||||
this.segments = new LineSegmentBuffer(device, MAX_BRUSH_LINE_COUNT);
|
||||
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../../config';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
writeBufferIfChanged,
|
||||
} from '../../utils/graphics/cached-buffer-write';
|
||||
import { generateNoise } from '../../utils/graphics/noise';
|
||||
import { generateNoise, NOISE_TEXTURE_SIZE } from '../../utils/graphics/noise';
|
||||
|
||||
export class CommonState {
|
||||
private static readonly UNIFORM_COUNT = 4;
|
||||
|
|
@ -39,8 +38,8 @@ export class CommonState {
|
|||
|
||||
const noise = generateNoise({
|
||||
device,
|
||||
width: appConfig.pipelines.common.noiseTextureSize,
|
||||
height: appConfig.pipelines.common.noiseTextureSize,
|
||||
width: NOISE_TEXTURE_SIZE,
|
||||
height: NOISE_TEXTURE_SIZE,
|
||||
});
|
||||
this.noise = noise.texture;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../../config';
|
||||
import { createBindGroupCache } from '../../utils/graphics/bind-group-cache';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
|
|
@ -29,12 +28,13 @@ type DiffusionUniformSettings = Pick<
|
|||
| 'brushDecayAlphaOffset'
|
||||
>;
|
||||
|
||||
const MIN_DIFFUSION_RATE = 0.000001;
|
||||
|
||||
const getSafeInverseDiffusionRate = (diffusionRate: number): number =>
|
||||
1 /
|
||||
(Number.isFinite(diffusionRate) &&
|
||||
diffusionRate > appConfig.pipelines.diffusion.minDiffusionRate
|
||||
(Number.isFinite(diffusionRate) && diffusionRate > MIN_DIFFUSION_RATE
|
||||
? diffusionRate
|
||||
: appConfig.pipelines.diffusion.minDiffusionRate);
|
||||
: MIN_DIFFUSION_RATE);
|
||||
|
||||
const setDiffusionUniformValues = (
|
||||
target: Float32Array,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
import { appConfig } from '../../config';
|
||||
import {
|
||||
createCachedBufferWrite,
|
||||
writeBufferIfChanged,
|
||||
|
|
@ -29,6 +28,7 @@ interface EraserTextureParameters {
|
|||
}
|
||||
|
||||
const UNIFORM_COUNT = 8;
|
||||
const MAX_ERASER_TEXTURE_LINE_COUNT = 384;
|
||||
const TARGET_FORMATS: Array<GPUTextureFormat> = [
|
||||
ERASER_MASK_TEXTURE_FORMAT,
|
||||
TRAIL_SOURCE_TEXTURE_FORMAT,
|
||||
|
|
@ -50,10 +50,7 @@ export class EraserTexturePipeline {
|
|||
private readonly device: GPUDevice,
|
||||
private readonly commonState: CommonState
|
||||
) {
|
||||
this.segments = new LineSegmentBuffer(
|
||||
device,
|
||||
appConfig.pipelines.eraser.maxTextureLineCount
|
||||
);
|
||||
this.segments = new LineSegmentBuffer(device, MAX_ERASER_TEXTURE_LINE_COUNT);
|
||||
|
||||
this.bindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import {
|
||||
appConfig,
|
||||
defaultSettings,
|
||||
normalizeRuntimeSettings,
|
||||
runtimeControls,
|
||||
type GardenRuntimeSettings,
|
||||
} from './config';
|
||||
import { writeBrowserStorage } from './utils/browser-storage';
|
||||
import { getInitialVibe, type VibePreset } from './vibes';
|
||||
import { getInitialVibe, VIBE_STORAGE_KEY, type VibePreset } from './vibes';
|
||||
|
||||
export const DEFAULT_ERASER_SIZE = 96;
|
||||
export const DEFAULT_MIRROR_SEGMENT_COUNT = 8;
|
||||
|
||||
const preservedRuntimeSettingKeys = [
|
||||
'eraserSize',
|
||||
|
|
@ -37,12 +41,12 @@ const cloneVibePreset = (vibe: VibePreset): VibePreset => ({
|
|||
const buildSettings = (vibe: VibePreset): GardenRuntimeSettings =>
|
||||
normalizeRuntimeSettings(
|
||||
{
|
||||
...appConfig.defaultSettings,
|
||||
eraserSize: appConfig.toolbar.eraser.default,
|
||||
mirrorSegmentCount: appConfig.toolbar.mirror.default,
|
||||
...defaultSettings,
|
||||
eraserSize: DEFAULT_ERASER_SIZE,
|
||||
mirrorSegmentCount: DEFAULT_MIRROR_SEGMENT_COUNT,
|
||||
...vibe.settings,
|
||||
},
|
||||
appConfig.runtimeSettings.controls
|
||||
runtimeControls
|
||||
);
|
||||
|
||||
export let activeVibe = cloneVibePreset(getInitialVibe());
|
||||
|
|
@ -52,7 +56,7 @@ export const settings: GardenRuntimeSettings = {
|
|||
};
|
||||
|
||||
export const rememberActiveVibeSelection = (): void => {
|
||||
writeBrowserStorage(appConfig.storage.vibeKey, activeVibe.id);
|
||||
writeBrowserStorage(VIBE_STORAGE_KEY, activeVibe.id);
|
||||
};
|
||||
|
||||
export const applyVibeSettings = (vibe: VibePreset) => {
|
||||
|
|
@ -66,10 +70,7 @@ export const applyVibeSettings = (vibe: VibePreset) => {
|
|||
activeVibe.colors.length - 1
|
||||
);
|
||||
|
||||
Object.assign(
|
||||
settings,
|
||||
normalizeRuntimeSettings(nextSettings, appConfig.runtimeSettings.controls)
|
||||
);
|
||||
Object.assign(settings, normalizeRuntimeSettings(nextSettings, runtimeControls));
|
||||
|
||||
rememberActiveVibeSelection();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
@use 'mixins' as *;
|
||||
|
||||
html > body > aside.control-dock > .info-page {
|
||||
width: min(calc(100vw - 1rem), 560px);
|
||||
max-height: min(58vh, 520px);
|
||||
max-height: min(58dvh, 520px);
|
||||
width: min(100%, 520px);
|
||||
max-height: min(62vh, 480px);
|
||||
max-height: min(62dvh, 480px);
|
||||
margin: 0 auto 10px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgb(255 255 255 / 78%);
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
border: 1px solid rgb(255 255 255 / 46%);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 97%), rgb(243 247 239 / 96%)),
|
||||
rgb(255 255 255);
|
||||
color: rgb(24 30 27);
|
||||
linear-gradient(180deg, rgb(252 255 249 / 94%), rgb(234 241 232 / 91%)),
|
||||
rgb(249 252 247 / 92%);
|
||||
color: rgb(18 28 24);
|
||||
box-shadow:
|
||||
0 20px 54px rgb(0 0 0 / 38%),
|
||||
0 2px 12px rgb(0 0 0 / 22%);
|
||||
backdrop-filter: blur(12px);
|
||||
inset 0 1px 0 rgb(255 255 255 / 58%),
|
||||
0 16px 42px rgb(0 0 0 / 30%),
|
||||
0 2px 10px rgb(0 0 0 / 18%);
|
||||
backdrop-filter: blur(16px) saturate(118%);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--main-color) transparent;
|
||||
scrollbar-color: rgb(69 98 88 / 62%) transparent;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
transition:
|
||||
max-height var(--transition-time-long),
|
||||
opacity var(--transition-time-long),
|
||||
|
|
@ -32,45 +36,179 @@ html > body > aside.control-dock > .info-page {
|
|||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--main-color);
|
||||
background-color: rgb(69 98 88 / 62%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid white;
|
||||
outline: 2px solid rgb(17 56 45);
|
||||
outline-offset: 3px;
|
||||
box-shadow:
|
||||
0 0 0 5px rgb(255 255 255 / 68%),
|
||||
inset 0 1px 0 rgb(255 255 255 / 58%),
|
||||
0 16px 42px rgb(0 0 0 / 30%),
|
||||
0 2px 10px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
> section {
|
||||
.info-page__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: var(--normal-margin);
|
||||
padding: 18px 20px 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
color: rgb(16 24 20);
|
||||
font-size: 2rem;
|
||||
line-height: 1.1;
|
||||
.info-page__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.info-page__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-page__mark {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border: 1px solid rgb(42 74 65 / 24%);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 74%), rgb(217 231 222 / 70%)),
|
||||
rgb(232 240 235);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 76%),
|
||||
0 5px 14px rgb(21 44 39 / 12%);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: rgb(19 57 48);
|
||||
mask: url('../../assets/icons/info.svg') center / contain no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.info-page__close {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-left: auto;
|
||||
border: 1px solid rgb(34 57 50 / 18%);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 36%);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-time),
|
||||
border-color var(--transition-time),
|
||||
transform var(--transition-time);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
margin: auto;
|
||||
background: rgb(28 45 39);
|
||||
mask: url('../../assets/icons/close.svg') center / contain no-repeat;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 54ch;
|
||||
margin-bottom: 0;
|
||||
color: rgb(42 48 45);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.65;
|
||||
hyphens: auto;
|
||||
&:hover {
|
||||
border-color: rgb(34 57 50 / 30%);
|
||||
background: rgb(255 255 255 / 64%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(0 84 120);
|
||||
font-weight: 400;
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgb(17 56 45);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px rgb(255 255 255 / 72%);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.info-page__eyebrow {
|
||||
margin-bottom: 0.12rem;
|
||||
color: rgb(73 91 85);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
overflow-wrap: break-word;
|
||||
margin-bottom: 0;
|
||||
color: rgb(9 21 17);
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.info-page__lede,
|
||||
.info-page__notes,
|
||||
.info-page__meta {
|
||||
max-width: 56ch;
|
||||
overflow-wrap: break-word;
|
||||
color: rgb(25 35 32);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.56;
|
||||
}
|
||||
|
||||
.info-page__lede,
|
||||
.info-page__meta {
|
||||
margin-bottom: 0;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.info-page__notes {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin: 0.1rem 0;
|
||||
padding-left: 1.1rem;
|
||||
list-style: disc;
|
||||
|
||||
li {
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: rgb(25 108 82);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
.info-page__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgb(43 66 57 / 16%);
|
||||
color: rgb(67 82 77);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(0 83 105);
|
||||
font-weight: 700;
|
||||
text-decoration-color: rgb(0 83 105 / 34%);
|
||||
text-underline-offset: 0.18em;
|
||||
|
||||
&:hover {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,16 +219,51 @@ html > body > aside.control-dock > .info-page {
|
|||
opacity: 0;
|
||||
pointer-events: none;
|
||||
box-shadow: none;
|
||||
transform: translateY(8px);
|
||||
transform: translateY(6px) scale(0.985);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@include on-small-screen {
|
||||
max-height: min(54vh, 500px);
|
||||
max-height: min(54dvh, 500px);
|
||||
width: min(100%, 520px);
|
||||
max-height: min(58vh, 500px);
|
||||
max-height: min(58dvh, 500px);
|
||||
|
||||
> section {
|
||||
padding: var(--small-margin);
|
||||
.info-page__content {
|
||||
gap: 0.75rem;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.info-page__lede,
|
||||
.info-page__notes {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
.info-page__meta {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 420px) {
|
||||
max-height: min(
|
||||
58vh,
|
||||
max(
|
||||
10rem,
|
||||
calc(
|
||||
100vh - 168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
||||
)
|
||||
)
|
||||
);
|
||||
max-height: min(
|
||||
58dvh,
|
||||
max(
|
||||
10rem,
|
||||
calc(
|
||||
100dvh -
|
||||
168px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { appConfig } from '../config';
|
||||
import { clamp } from './math';
|
||||
|
||||
export const MIN_DELTA_TIME_SECONDS = 1 / 240;
|
||||
|
||||
const MAX_DELTA_TIME_SECONDS = 1 / 30;
|
||||
|
||||
export class DeltaTimeCalculator {
|
||||
private previousTime: DOMHighResTimeStamp | null = null;
|
||||
private readonly visibilityChangeListener = () => this.handleVisibilityChange();
|
||||
|
|
@ -16,11 +19,7 @@ export class DeltaTimeCalculator {
|
|||
|
||||
const delta = currentTime - this.previousTime;
|
||||
this.previousTime = currentTime;
|
||||
return clamp(
|
||||
delta / 1000,
|
||||
appConfig.deltaTime.minDeltaTimeSeconds,
|
||||
appConfig.deltaTime.maxDeltaTimeSeconds
|
||||
);
|
||||
return clamp(delta / 1000, MIN_DELTA_TIME_SECONDS, MAX_DELTA_TIME_SECONDS);
|
||||
}
|
||||
|
||||
private handleVisibilityChange() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import { appConfig } from '../../config';
|
||||
import { setUpFullScreenQuad } from './full-screen-quad';
|
||||
import { smartCompile } from './smart-compile';
|
||||
|
||||
export const NOISE_TEXTURE_SIZE = 2048;
|
||||
|
||||
const NOISE_CHANNEL_SEEDS = [0, 1, 2, 3] as const;
|
||||
const NOISE_CLEAR_VALUE = { r: 1, g: 1, b: 1, a: 1 };
|
||||
const NOISE_DRAW_INSTANCE_COUNT = 1;
|
||||
const NOISE_DRAW_VERTEX_COUNT = 3;
|
||||
const NOISE_HASH_MULTIPLIER = 43758.5453123;
|
||||
const NOISE_HASH_X = 12.9898;
|
||||
const NOISE_HASH_Y = 78.233;
|
||||
const NOISE_TEXTURE_FORMAT = 'r8unorm';
|
||||
|
||||
export interface GeneratedNoiseTexture {
|
||||
texture: GPUTexture;
|
||||
view: GPUTextureView;
|
||||
|
|
@ -29,16 +39,16 @@ export const generateNoise = ({
|
|||
return fract(sin(dot(
|
||||
uv,
|
||||
vec2(
|
||||
${appConfig.pipelines.common.noiseHashX} + seed,
|
||||
${appConfig.pipelines.common.noiseHashY} + seed
|
||||
${NOISE_HASH_X} + seed,
|
||||
${NOISE_HASH_Y} + seed
|
||||
)
|
||||
)) * ${appConfig.pipelines.common.noiseHashMultiplier} + seed);
|
||||
)) * ${NOISE_HASH_MULTIPLIER} + seed);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4(
|
||||
random_with_seed(uv, ${appConfig.pipelines.common.noiseChannelSeeds[0]}),
|
||||
random_with_seed(uv, ${NOISE_CHANNEL_SEEDS[0]}),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
|
|
@ -48,7 +58,7 @@ export const generateNoise = ({
|
|||
entryPoint: 'fragment',
|
||||
targets: [
|
||||
{
|
||||
format: appConfig.pipelines.common.noiseTextureFormat,
|
||||
format: NOISE_TEXTURE_FORMAT,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -63,7 +73,7 @@ export const generateNoise = ({
|
|||
height,
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
format: appConfig.pipelines.common.noiseTextureFormat,
|
||||
format: NOISE_TEXTURE_FORMAT,
|
||||
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
|
||||
|
|
@ -71,7 +81,7 @@ export const generateNoise = ({
|
|||
colorAttachments: [
|
||||
{
|
||||
view: colorTexture.createView(),
|
||||
clearValue: appConfig.pipelines.common.noiseClearValue,
|
||||
clearValue: NOISE_CLEAR_VALUE,
|
||||
loadOp: 'clear',
|
||||
storeOp: 'store',
|
||||
},
|
||||
|
|
@ -82,10 +92,7 @@ export const generateNoise = ({
|
|||
|
||||
const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
|
||||
passEncoder.setPipeline(pipeline);
|
||||
passEncoder.draw(
|
||||
appConfig.pipelines.common.noiseDrawVertexCount,
|
||||
appConfig.pipelines.common.noiseDrawInstanceCount
|
||||
);
|
||||
passEncoder.draw(NOISE_DRAW_VERTEX_COUNT, NOISE_DRAW_INSTANCE_COUNT);
|
||||
passEncoder.end();
|
||||
|
||||
device.queue.submit([commandEncoder.finish()]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { appConfig } from './config';
|
||||
import { vibePresets } from './config';
|
||||
import type { VibeId, VibePreset } from './config/types';
|
||||
|
||||
export const VIBE_PRESETS: Array<VibePreset> = appConfig.vibes.presets;
|
||||
export const VIBE_PRESETS: Array<VibePreset> = vibePresets;
|
||||
|
||||
export const getVibeById = (vibeId: VibeId): VibePreset | undefined =>
|
||||
VIBE_PRESETS.find((vibe) => vibe.id === vibeId);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { appConfig } from './config';
|
||||
import { defaultVibeId } from './config';
|
||||
import { VibeId, type VibePreset } from './config/types';
|
||||
import { readBrowserStorage } from './utils/browser-storage';
|
||||
import { getVibeById, VIBE_PRESETS } from './vibe-registry';
|
||||
|
|
@ -8,6 +8,8 @@ export { VibeId };
|
|||
export { getVibeById, VIBE_PRESETS };
|
||||
export type { VibePreset };
|
||||
|
||||
export const VIBE_STORAGE_KEY = 'fleeting-garden:vibe';
|
||||
|
||||
const VIBE_IDS = new Set<VibeId>(VIBE_PRESETS.map((vibe) => vibe.id));
|
||||
|
||||
const isVibeId = (value: unknown): value is VibeId =>
|
||||
|
|
@ -15,12 +17,11 @@ const isVibeId = (value: unknown): value is VibeId =>
|
|||
|
||||
export const getInitialVibe = (): VibePreset => {
|
||||
const uriVibeId = getCurrentUriVibeId();
|
||||
const storedVibeId = readBrowserStorage(appConfig.storage.vibeKey);
|
||||
const storedVibeId = readBrowserStorage(VIBE_STORAGE_KEY);
|
||||
const storedOrLegacyVibeId = isVibeId(storedVibeId)
|
||||
? storedVibeId
|
||||
: getVibeIdFromUri(`?vibe=${encodeURIComponent(storedVibeId ?? '')}`);
|
||||
const initialVibeId =
|
||||
uriVibeId ?? storedOrLegacyVibeId ?? appConfig.vibes.defaultVibeId;
|
||||
const initialVibeId = uriVibeId ?? storedOrLegacyVibeId ?? defaultVibeId;
|
||||
|
||||
return getVibeById(initialVibeId) ?? VIBE_PRESETS[0];
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue