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