mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-24 18:22:29 +00:00
website: live dev tuner + noisy settled-pile wordmark formation
Tuner (see TUNER.md): a draggable, collapsible dev panel rendering every group registered via defineTunables() as slider + number-input rows. - Live: physics/light/material/halo values are read per frame; formation values trigger a debounced re-sample - Values persist to localStorage; modified labels tint accent; Copy puts a JSON snapshot on the clipboard for baking back into defaults; Reset clears - Shown in dev builds or with ?tuner=1; drag position and section collapse state persist - Policy (documented): every new tweakable constant registers with the tuner by default - Hero groups: Formation, Physics, Cursor & Light, Material, Halo Formation: the hex lattice read too uniform. Balls are now placed by multi-pass dart throwing over the glyph mask — big balls claim spots first, smaller ones settle into leftover gaps, random depth within the slab, 3D overlap rejection via spatial hash — a faked settled-pile impression, not a physics solve. Stroke-margin test keeps big balls inside the letterforms so the type stays crisp.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Dev Tuner
|
||||
|
||||
A floating, draggable panel for live-tuning the site's visual/physics
|
||||
constants without an edit-build-reload loop. Drag it by its header; collapse
|
||||
the whole box with `–`, or expand/collapse individual sections.
|
||||
|
||||
## When it shows
|
||||
|
||||
- Always in `next dev`.
|
||||
- In any build when the URL has `?tuner=1` (handy for tuning a deployed
|
||||
preview).
|
||||
- Never otherwise — production visitors don't see it.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Live**: physics/lighting/material values are read every frame, so slider
|
||||
changes apply instantly. Formation values (anything that changes the ball
|
||||
arrangement itself) trigger a debounced rebuild (~250 ms after the last
|
||||
change).
|
||||
- **Persistent**: tuned values are stored in `localStorage`
|
||||
(`artcraft-tuner`), so a refresh keeps your tuning. Panel position and
|
||||
section collapse state persist too (`artcraft-tuner-ui`).
|
||||
- **Modified values** show their label in the accent color.
|
||||
- **Copy** puts a JSON snapshot of all current values on the clipboard —
|
||||
paste it into a message or use it to update defaults in code.
|
||||
- **Reset** clears every override back to the in-code defaults.
|
||||
|
||||
## Adding tunables (POLICY: always do this)
|
||||
|
||||
Whenever you introduce a new tweakable constant — a force, an intensity, a
|
||||
size, a duration — register it with the tuner instead of hardcoding a bare
|
||||
literal. Adding a slider is free; a missing one costs a whole
|
||||
iteration/build round-trip.
|
||||
|
||||
```ts
|
||||
// some-feature-tunables.ts
|
||||
import { defineTunables } from "@/lib/tuner";
|
||||
|
||||
export const glowTuner = defineTunables("glow", "Glow", {
|
||||
radius: { label: "Radius px", min: 0, max: 400, step: 5, default: 120 },
|
||||
strength: { label: "Strength", min: 0, max: 2, step: 0.05, default: 0.8 },
|
||||
});
|
||||
```
|
||||
|
||||
Then read values where they're used:
|
||||
|
||||
- **Per-frame consumers** (`useFrame`, rAF loops): call `glowTuner.read()`
|
||||
inside the loop — it's cheap and picks up changes instantly.
|
||||
- **Build-time consumers** (values baked into geometry/sampling): read at
|
||||
build time and subscribe to `useTunerStore` to trigger a debounced rebuild
|
||||
when the group's values change (see the Formation wiring in
|
||||
`hero-wordmark.tsx`).
|
||||
|
||||
The panel picks up new groups automatically — no panel changes needed.
|
||||
Registered groups: Formation, Physics, Cursor & Light, Material, Halo (all
|
||||
for the hero wordmark; see `src/components/landing/hero-tunables.ts`).
|
||||
|
||||
## Shipping tuned values
|
||||
|
||||
The defaults in `defineTunables` are the shipped values. After a tuning
|
||||
session: Copy → paste the JSON → update the `default:` fields → Reset.
|
||||
@@ -3,6 +3,7 @@ import { Outfit, Instrument_Serif, Source_Sans_3, Geist_Mono } from "next/font/g
|
||||
import SiteNav from "@/components/site-nav";
|
||||
import SiteFooter from "@/components/site-footer";
|
||||
import MotionProvider from "@/components/motion-provider";
|
||||
import TunerPanel from "@/components/dev/tuner-panel";
|
||||
import "./globals.css";
|
||||
|
||||
const SITE_URL = "https://getartcraft.com";
|
||||
@@ -103,6 +104,7 @@ export default function RootLayout({
|
||||
<main>{children}</main>
|
||||
<SiteFooter />
|
||||
</MotionProvider>
|
||||
<TunerPanel />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useTunerStore,
|
||||
tunerSnapshot,
|
||||
type TunableGroup,
|
||||
} from "@/lib/tuner";
|
||||
|
||||
const UI_KEY = "artcraft-tuner-ui";
|
||||
|
||||
type UiState = {
|
||||
x: number;
|
||||
y: number;
|
||||
collapsed: boolean;
|
||||
open: Record<string, boolean>;
|
||||
};
|
||||
|
||||
const DEFAULT_UI: UiState = { x: 16, y: 72, collapsed: false, open: {} };
|
||||
|
||||
function loadUi(): UiState {
|
||||
try {
|
||||
return { ...DEFAULT_UI, ...JSON.parse(localStorage.getItem(UI_KEY) ?? "{}") };
|
||||
} catch {
|
||||
return DEFAULT_UI;
|
||||
}
|
||||
}
|
||||
|
||||
// Floating dev tuner: renders every group registered via defineTunables as a
|
||||
// collapsible section of slider + number-input rows. Drag by the header.
|
||||
// Shown in dev builds, or anywhere with ?tuner=1. See TUNER.md.
|
||||
export default function TunerPanel() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
setEnabled(
|
||||
process.env.NODE_ENV === "development" ||
|
||||
new URLSearchParams(window.location.search).has("tuner"),
|
||||
);
|
||||
}, []);
|
||||
if (!enabled) return null;
|
||||
return <Panel />;
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
const groups = useTunerStore((s) => s.groups);
|
||||
const resetAll = useTunerStore((s) => s.resetAll);
|
||||
const [ui, setUi] = useState<UiState>(loadUi);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dragOrigin = useRef<{ px: number; py: number; x: number; y: number } | null>(null);
|
||||
|
||||
const saveUi = (next: UiState) => {
|
||||
setUi(next);
|
||||
try {
|
||||
localStorage.setItem(UI_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Non-persistent tuning is still tuning.
|
||||
}
|
||||
};
|
||||
|
||||
const onHeaderPointerDown = (e: React.PointerEvent) => {
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
dragOrigin.current = { px: e.clientX, py: e.clientY, x: ui.x, y: ui.y };
|
||||
};
|
||||
const onHeaderPointerMove = (e: React.PointerEvent) => {
|
||||
const o = dragOrigin.current;
|
||||
if (!o) return;
|
||||
saveUi({
|
||||
...ui,
|
||||
x: Math.max(0, Math.min(window.innerWidth - 60, o.x + e.clientX - o.px)),
|
||||
y: Math.max(0, Math.min(window.innerHeight - 40, o.y + e.clientY - o.py)),
|
||||
});
|
||||
};
|
||||
const onHeaderPointerUp = () => {
|
||||
dragOrigin.current = null;
|
||||
};
|
||||
|
||||
const copyValues = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(tunerSnapshot(), null, 2),
|
||||
);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
// Clipboard can be blocked; the values are still visible in the panel.
|
||||
}
|
||||
};
|
||||
|
||||
const ordered = Object.values(groups).sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[100] w-[300px] border border-line-strong bg-bg-raised text-ink shadow-[0_8px_32px_rgba(0,0,0,0.35)] select-none"
|
||||
style={{ left: ui.x, top: ui.y }}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-grab items-center justify-between border-b border-line bg-bg-sunken px-3 py-2 active:cursor-grabbing"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
onPointerMove={onHeaderPointerMove}
|
||||
onPointerUp={onHeaderPointerUp}
|
||||
>
|
||||
<p className="hud-label font-bold">Tuner</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<HeaderButton onClick={copyValues} label={copied ? "Copied" : "Copy"} />
|
||||
<HeaderButton onClick={resetAll} label="Reset" />
|
||||
<HeaderButton
|
||||
onClick={() => saveUi({ ...ui, collapsed: !ui.collapsed })}
|
||||
label={ui.collapsed ? "+" : "–"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!ui.collapsed && (
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
{ordered.length === 0 && (
|
||||
<p className="hud-label px-3 py-4 text-faint">
|
||||
No tunables registered
|
||||
</p>
|
||||
)}
|
||||
{ordered.map((group) => (
|
||||
<Section
|
||||
key={group.id}
|
||||
group={group}
|
||||
open={ui.open[group.id] ?? false}
|
||||
onToggle={() =>
|
||||
saveUi({
|
||||
...ui,
|
||||
open: { ...ui.open, [group.id]: !(ui.open[group.id] ?? false) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderButton({ onClick, label }: { onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="hud-label min-w-[44px] border border-line px-1.5 py-0.5 text-[10px] text-muted hover:bg-invert-bg hover:text-invert-fg"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
group,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
group: TunableGroup;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-line last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="hud-label flex w-full items-center justify-between px-3 py-2 text-muted hover:text-ink"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{group.title}
|
||||
<span aria-hidden className="text-faint">
|
||||
{open ? "–" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-col gap-2 px-3 pb-3">
|
||||
{Object.entries(group.defs).map(([key, def]) => (
|
||||
<Row key={key} groupId={group.id} tunableKey={key} def={def} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
groupId,
|
||||
tunableKey,
|
||||
def,
|
||||
}: {
|
||||
groupId: string;
|
||||
tunableKey: string;
|
||||
def: TunableGroup["defs"][string];
|
||||
}) {
|
||||
const storeKey = `${groupId}.${tunableKey}`;
|
||||
const value = useTunerStore((s) => s.values[storeKey] ?? def.default);
|
||||
const setValue = useTunerStore((s) => s.setValue);
|
||||
const modified = value !== def.default;
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="flex items-center justify-between">
|
||||
<span
|
||||
className={`font-mono text-[10px] tracking-[0.08em] uppercase ${
|
||||
modified ? "text-accent-ink" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={round(value)}
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
step={def.step}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v)) setValue(groupId, tunableKey, v);
|
||||
}}
|
||||
className="w-[72px] border border-line bg-bg px-1 py-0.5 text-right font-mono text-[11px] text-ink focus:outline-none focus:border-line-strong"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
step={def.step}
|
||||
onChange={(e) => setValue(groupId, tunableKey, Number(e.target.value))}
|
||||
className="h-1 w-full cursor-ew-resize accent-[var(--accent)]"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function round(v: number): number {
|
||||
return Math.round(v * 10000) / 10000;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Every tweakable constant of the hero wordmark, registered with the dev
|
||||
// tuner (see TUNER.md). Defaults here ARE the shipped values — tune live,
|
||||
// copy the JSON from the panel, then update the defaults.
|
||||
|
||||
import { defineTunables } from "@/lib/tuner";
|
||||
|
||||
export const formationTuner = defineTunables("formation", "Formation", {
|
||||
ballScale: { label: "Ball scale", min: 0.5, max: 2.5, step: 0.05, default: 1 },
|
||||
sizeMin: { label: "Size min ×", min: 0.05, max: 0.6, step: 0.01, default: 0.18 },
|
||||
sizeMax: { label: "Size max ×", min: 0.2, max: 1.4, step: 0.01, default: 0.66 },
|
||||
sizeNoiseScale: { label: "Size noise px", min: 30, max: 400, step: 5, default: 130 },
|
||||
sizeRoughen: { label: "Size roughen", min: 0, max: 1, step: 0.02, default: 0.4 },
|
||||
gap: { label: "Gap px", min: 0, max: 6, step: 0.1, default: 0.6 },
|
||||
depth: { label: "Depth ×", min: 0.2, max: 5, step: 0.1, default: 1.9 },
|
||||
density: { label: "Fill density", min: 0.2, max: 3, step: 0.05, default: 1 },
|
||||
edgeMargin: { label: "Edge margin", min: 0, max: 1, step: 0.05, default: 0.55 },
|
||||
seed: { label: "Seed", min: 1, max: 9999, step: 1, default: 1013 },
|
||||
});
|
||||
|
||||
export const physicsTuner = defineTunables("physics", "Physics", {
|
||||
spring: { label: "Spring", min: 5, max: 140, step: 1, default: 46 },
|
||||
damping: { label: "Damping", min: 1, max: 14, step: 0.1, default: 5.2 },
|
||||
pushRadius: { label: "Push radius", min: 40, max: 450, step: 5, default: 180 },
|
||||
pushStrength: { label: "Push strength", min: 1000, max: 80000, step: 500, default: 23000 },
|
||||
swirl: { label: "Swirl", min: 0, max: 1.5, step: 0.05, default: 0.55 },
|
||||
popZ: { label: "Pop toward viewer", min: 0, max: 2, step: 0.05, default: 0.6 },
|
||||
bobAmp: { label: "Idle bob px", min: 0, max: 8, step: 0.1, default: 1.4 },
|
||||
scatter: { label: "Intro scatter px", min: 0, max: 1400, step: 20, default: 480 },
|
||||
});
|
||||
|
||||
export const lightTuner = defineTunables("light", "Cursor & Light", {
|
||||
intensityDark: { label: "Cursor int (dark)", min: 0, max: 220000, step: 1000, default: 58000 },
|
||||
intensityLight: { label: "Cursor int (light)", min: 0, max: 220000, step: 1000, default: 30000 },
|
||||
idleDim: { label: "Idle dim", min: 0, max: 1, step: 0.05, default: 0.3 },
|
||||
lightZ: { label: "Light z", min: 30, max: 500, step: 5, default: 130 },
|
||||
lightRange: { label: "Light range", min: 100, max: 3000, step: 50, default: 900 },
|
||||
keyIntensity: { label: "Back key (dark)", min: 0, max: 8, step: 0.1, default: 2.2 },
|
||||
counterIntensity: { label: "Counter rim (dark)", min: 0, max: 4, step: 0.05, default: 0.7 },
|
||||
frontFill: { label: "Front fill (dark)", min: 0, max: 1, step: 0.01, default: 0.06 },
|
||||
ambientDark: { label: "Ambient (dark)", min: 0, max: 1, step: 0.01, default: 0.05 },
|
||||
ambientLight: { label: "Ambient (light)", min: 0, max: 2, step: 0.05, default: 0.55 },
|
||||
dirLight: { label: "Key (light)", min: 0, max: 3, step: 0.05, default: 0.75 },
|
||||
});
|
||||
|
||||
export const materialTuner = defineTunables("material", "Material", {
|
||||
roughnessDark: { label: "Roughness (dark)", min: 0, max: 1, step: 0.02, default: 0.42 },
|
||||
metalnessDark: { label: "Metalness (dark)", min: 0, max: 1, step: 0.02, default: 0.5 },
|
||||
clearcoat: { label: "Clearcoat (dark)", min: 0, max: 1, step: 0.02, default: 0.9 },
|
||||
clearcoatRoughness: { label: "Coat rough (dark)", min: 0, max: 1, step: 0.02, default: 0.22 },
|
||||
roughnessLight: { label: "Roughness (light)", min: 0, max: 1, step: 0.02, default: 0.3 },
|
||||
metalnessLight: { label: "Metalness (light)", min: 0, max: 1, step: 0.02, default: 0.15 },
|
||||
envLight: { label: "Env int (light)", min: 0, max: 2, step: 0.05, default: 0.55 },
|
||||
accentShare: { label: "Accent share", min: 0, max: 0.3, step: 0.005, default: 0.05 },
|
||||
});
|
||||
|
||||
export const haloTuner = defineTunables("halo", "Halo", {
|
||||
size: { label: "Size px", min: 200, max: 1800, step: 20, default: 720 },
|
||||
alphaDark: { label: "Alpha (dark)", min: 0, max: 1, step: 0.02, default: 0.5 },
|
||||
alphaLight: { label: "Alpha (light)", min: 0, max: 0.6, step: 0.01, default: 0.14 },
|
||||
z: { label: "Z offset", min: -400, max: 0, step: 5, default: -90 },
|
||||
falloff: { label: "Falloff", min: 0.5, max: 6, step: 0.1, default: 2.6 },
|
||||
});
|
||||
@@ -12,12 +12,19 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
|
||||
import { useTunerStore } from "@/lib/tuner";
|
||||
import {
|
||||
sampleWordmark,
|
||||
WordmarkSim,
|
||||
DEFAULT_PARAMS,
|
||||
type SampledWordmark,
|
||||
} from "./wordmark-sim";
|
||||
import {
|
||||
formationTuner,
|
||||
physicsTuner,
|
||||
lightTuner,
|
||||
materialTuner,
|
||||
haloTuner,
|
||||
} from "./hero-tunables";
|
||||
|
||||
const WORDMARK = "artcraft";
|
||||
const FONT_WEIGHT = 700;
|
||||
@@ -62,7 +69,8 @@ export default function HeroWordmark() {
|
||||
}, [ready]);
|
||||
|
||||
// Rasterize + sample once the display font is really loaded; re-sample when
|
||||
// the container width changes meaningfully (not on mobile URL-bar jitter).
|
||||
// the container width changes meaningfully (not on mobile URL-bar jitter)
|
||||
// or when a Formation tunable moves in the dev tuner.
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const container = containerRef.current;
|
||||
@@ -72,9 +80,9 @@ export default function HeroWordmark() {
|
||||
let lastWidth = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const build = async () => {
|
||||
const build = async (force = false) => {
|
||||
const width = container.clientWidth;
|
||||
if (!width || width === lastWidth) return;
|
||||
if (!width || (!force && width === lastWidth)) return;
|
||||
lastWidth = width;
|
||||
|
||||
const family = getComputedStyle(container).fontFamily;
|
||||
@@ -85,7 +93,8 @@ export default function HeroWordmark() {
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const spacing = Math.min(18, Math.max(11, width / 62));
|
||||
const { ballScale, ...formation } = formationTuner.read();
|
||||
const spacing = Math.min(18, Math.max(11, width / 62)) * ballScale;
|
||||
setSample(
|
||||
sampleWordmark({
|
||||
text: WORDMARK,
|
||||
@@ -93,7 +102,7 @@ export default function HeroWordmark() {
|
||||
fontWeight: FONT_WEIGHT,
|
||||
targetWidth: Math.min(width * 0.88, 1280),
|
||||
spacing,
|
||||
layers: width < 720 ? 2 : 3,
|
||||
formation,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -104,10 +113,22 @@ export default function HeroWordmark() {
|
||||
timer = setTimeout(build, 350);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
// Formation tunables change the arrangement itself — debounce a rebuild.
|
||||
let lastFormation = JSON.stringify(formationTuner.read());
|
||||
const unsubscribe = useTunerStore.subscribe(() => {
|
||||
const now = JSON.stringify(formationTuner.read());
|
||||
if (now === lastFormation) return;
|
||||
lastFormation = now;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => build(true), 250);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
ro.disconnect();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [ready]);
|
||||
|
||||
@@ -221,9 +242,10 @@ const HALO_FRAGMENT = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
uniform vec3 uColor;
|
||||
uniform float uAlpha;
|
||||
uniform float uFalloff;
|
||||
void main() {
|
||||
float d = distance(vUv, vec2(0.5)) * 2.0;
|
||||
float a = pow(clamp(1.0 - d, 0.0, 1.0), 2.6);
|
||||
float a = pow(clamp(1.0 - d, 0.0, 1.0), uFalloff);
|
||||
gl_FragColor = vec4(uColor, a * uAlpha);
|
||||
}
|
||||
`;
|
||||
@@ -240,9 +262,20 @@ function BallField({
|
||||
const meshRef = useRef<THREE.InstancedMesh>(null);
|
||||
const lightRef = useRef<THREE.PointLight>(null);
|
||||
const haloRef = useRef<THREE.Mesh>(null);
|
||||
const ambientRef = useRef<THREE.AmbientLight>(null);
|
||||
const keyRef = useRef<THREE.DirectionalLight>(null);
|
||||
const counterRef = useRef<THREE.DirectionalLight>(null);
|
||||
const fillRef = useRef<THREE.DirectionalLight>(null);
|
||||
const lightPos = useRef(new THREE.Vector3(0, 120, 0));
|
||||
const sim = useMemo(() => new WordmarkSim(sample), [sample]);
|
||||
const sim = useMemo(
|
||||
() => new WordmarkSim(sample, physicsTuner.read().scatter),
|
||||
[sample],
|
||||
);
|
||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||
// Reactive so recoloring reruns when the accent share slider moves.
|
||||
const accentShare = useTunerStore(
|
||||
(s) => s.values["material.accentShare"] ?? 0.05,
|
||||
);
|
||||
|
||||
const dark = useMemo(() => {
|
||||
const c = new THREE.Color(colors.bg);
|
||||
@@ -261,6 +294,7 @@ function BallField({
|
||||
value: new THREE.Color(dark ? "#a8c4ff" : colors.accent),
|
||||
},
|
||||
uAlpha: { value: dark ? 0.5 : 0.14 },
|
||||
uFalloff: { value: 2.6 },
|
||||
},
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
@@ -285,7 +319,7 @@ function BallField({
|
||||
const c = new THREE.Color();
|
||||
const rand = seeded(4242);
|
||||
for (let i = 0; i < sim.n; i++) {
|
||||
if (rand() < 0.05) {
|
||||
if (rand() < accentShare) {
|
||||
c.copy(accent);
|
||||
if (dark) c.multiplyScalar(0.8);
|
||||
} else {
|
||||
@@ -294,14 +328,20 @@ function BallField({
|
||||
mesh.setColorAt(i, c);
|
||||
}
|
||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||
}, [colors, sim, dark]);
|
||||
}, [colors, sim, dark, accentShare]);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
const mesh = meshRef.current;
|
||||
if (!mesh) return;
|
||||
const p = pointer.current;
|
||||
|
||||
sim.step(delta, p.x, p.y, p.active, DEFAULT_PARAMS);
|
||||
// Live tuner values — read every frame so slider changes apply instantly.
|
||||
const phys = physicsTuner.read();
|
||||
const lt = lightTuner.read();
|
||||
const mt = materialTuner.read();
|
||||
const ht = haloTuner.read();
|
||||
|
||||
sim.step(delta, p.x, p.y, p.active, phys);
|
||||
|
||||
for (let i = 0; i < sim.n; i++) {
|
||||
dummy.position.set(sim.x[i], sim.y[i], sim.z[i]);
|
||||
@@ -322,22 +362,50 @@ function BallField({
|
||||
lightRef.current?.position.set(
|
||||
lightPos.current.x,
|
||||
lightPos.current.y,
|
||||
130,
|
||||
lt.lightZ,
|
||||
);
|
||||
haloRef.current?.position.set(
|
||||
lightPos.current.x,
|
||||
lightPos.current.y,
|
||||
-90,
|
||||
ht.z,
|
||||
);
|
||||
|
||||
// At rest the word sits in near-eclipse; approaching with the cursor
|
||||
// brings the light up to full.
|
||||
const light = lightRef.current;
|
||||
if (light) {
|
||||
const full = dark ? 58000 : 30000;
|
||||
const target = p.active ? full : full * 0.3;
|
||||
light.distance = lt.lightRange;
|
||||
const full = dark ? lt.intensityDark : lt.intensityLight;
|
||||
const target = p.active ? full : full * lt.idleDim;
|
||||
light.intensity += (target - light.intensity) * k;
|
||||
}
|
||||
|
||||
// Rig + material + halo bindings.
|
||||
if (ambientRef.current) {
|
||||
ambientRef.current.intensity = dark ? lt.ambientDark : lt.ambientLight;
|
||||
}
|
||||
if (keyRef.current) {
|
||||
keyRef.current.intensity = dark ? lt.keyIntensity : lt.dirLight;
|
||||
}
|
||||
if (counterRef.current) counterRef.current.intensity = lt.counterIntensity;
|
||||
if (fillRef.current) fillRef.current.intensity = lt.frontFill;
|
||||
|
||||
const mat = mesh.material as THREE.MeshPhysicalMaterial &
|
||||
THREE.MeshStandardMaterial;
|
||||
if (dark) {
|
||||
mat.roughness = mt.roughnessDark;
|
||||
mat.metalness = mt.metalnessDark;
|
||||
mat.clearcoat = mt.clearcoat;
|
||||
mat.clearcoatRoughness = mt.clearcoatRoughness;
|
||||
} else {
|
||||
mat.roughness = mt.roughnessLight;
|
||||
mat.metalness = mt.metalnessLight;
|
||||
mat.envMapIntensity = mt.envLight;
|
||||
}
|
||||
|
||||
haloMaterial.uniforms.uAlpha.value = dark ? ht.alphaDark : ht.alphaLight;
|
||||
haloMaterial.uniforms.uFalloff.value = ht.falloff;
|
||||
haloRef.current?.scale.setScalar(ht.size / 720);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -353,15 +421,15 @@ function BallField({
|
||||
// rims the top edges, a faint counter-rim catches the lower-right,
|
||||
// and the cursor light does the actual revealing.
|
||||
<>
|
||||
<ambientLight intensity={0.05} />
|
||||
<directionalLight position={[-160, 340, -460]} intensity={2.2} color="#e9f1ff" />
|
||||
<directionalLight position={[430, -180, -380]} intensity={0.7} color="#7fa8e8" />
|
||||
<directionalLight position={[0, -60, 520]} intensity={0.06} />
|
||||
<ambientLight ref={ambientRef} intensity={0.05} />
|
||||
<directionalLight ref={keyRef} position={[-160, 340, -460]} intensity={2.2} color="#e9f1ff" />
|
||||
<directionalLight ref={counterRef} position={[430, -180, -380]} intensity={0.7} color="#7fa8e8" />
|
||||
<directionalLight ref={fillRef} position={[0, -60, 520]} intensity={0.06} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ambientLight intensity={0.55} />
|
||||
<directionalLight position={[-260, 340, 420]} intensity={0.75} />
|
||||
<ambientLight ref={ambientRef} intensity={0.55} />
|
||||
<directionalLight ref={keyRef} position={[-260, 340, 420]} intensity={0.75} />
|
||||
<directionalLight position={[180, 260, -420]} intensity={0.5} color="#eef3ff" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Wordmark ball field: the brand name rasterized to a mask, then filled with
|
||||
// small 3D balls on a jittered close-packed lattice a few layers deep. The
|
||||
// balls separate under the cursor's push and rejoin on their home springs; a
|
||||
// 3D collision pass keeps them from intersecting, so the text reads as a
|
||||
// solid object built from spheres rather than a flat particle billboard.
|
||||
// small 3D balls by multi-pass dart throwing — big balls land first, smaller
|
||||
// ones settle into the gaps, like a pile that collided into the letter shape
|
||||
// (a faked impression, not a physics solve; it must run on laptops/phones).
|
||||
// The balls separate under the cursor's push and rejoin on their home
|
||||
// springs; a 3D collision pass keeps them from intersecting.
|
||||
//
|
||||
// Units are CSS pixels at the text's z=0 plane — the camera is fitted so one
|
||||
// world unit projects to one pixel there, which keeps pointer math trivial.
|
||||
@@ -20,6 +21,20 @@ export type SampledWordmark = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
// All knobs of the arrangement — registered with the dev tuner in
|
||||
// hero-tunables.ts. Size factors are multiples of `spacing`.
|
||||
export type FormationParams = {
|
||||
sizeMin: number;
|
||||
sizeMax: number;
|
||||
sizeNoiseScale: number; // px wavelength of the size clusters
|
||||
sizeRoughen: number; // 0..1 per-ball white-noise contribution
|
||||
gap: number; // px clearance between resting balls
|
||||
depth: number; // slab thickness, × spacing
|
||||
density: number; // dart-attempt multiplier
|
||||
edgeMargin: number; // 0..1 — how far inside the stroke big balls stay
|
||||
seed: number;
|
||||
};
|
||||
|
||||
// ————————————————————————————— sampling —————————————————————————————
|
||||
|
||||
export function sampleWordmark(opts: {
|
||||
@@ -27,12 +42,12 @@ export function sampleWordmark(opts: {
|
||||
fontFamily: string;
|
||||
fontWeight: number;
|
||||
targetWidth: number;
|
||||
/** Lattice spacing in px — sets ball size (~spacing/2) and count. */
|
||||
/** Base unit in px — ball sizes and slab depth scale from this. */
|
||||
spacing: number;
|
||||
/** Depth layers (2–3 keeps it chunky without mush). */
|
||||
layers: number;
|
||||
formation: FormationParams;
|
||||
}): SampledWordmark {
|
||||
const { text, fontFamily, fontWeight, targetWidth, spacing, layers } = opts;
|
||||
const { text, fontFamily, fontWeight, targetWidth, spacing, formation } =
|
||||
opts;
|
||||
|
||||
// Each glyph is measured and drawn individually with a small manual gap:
|
||||
// no shaping ever runs across letters, so the font can't fuse tight pairs
|
||||
@@ -75,90 +90,106 @@ export function sampleWordmark(opts: {
|
||||
return alpha[(y * width + x) * 4 + 3] > 127;
|
||||
};
|
||||
|
||||
const rand = mulberry32(1013);
|
||||
const jitter = spacing * 0.13;
|
||||
const rowH = spacing * (Math.SQRT2 / 2) * 1.22; // hex-ish row pitch
|
||||
const balls: Ball[] = [];
|
||||
|
||||
// Organic sizing: a smooth spatial noise drifts the ball size across the
|
||||
// word (clusters of big and small), white noise roughens it per ball. The
|
||||
// neighbor cap below keeps every draw collision-safe, so variance only
|
||||
// ever shrinks balls — which is exactly what reads as hand-made.
|
||||
const sizeNoise = makeValueNoise(41);
|
||||
const desiredRadius = (x: number, y: number) =>
|
||||
spacing *
|
||||
(0.24 +
|
||||
0.36 * Math.pow(sizeNoise(x / 130, y / 130), 1.15) +
|
||||
rand() * 0.16);
|
||||
|
||||
for (let layer = 0; layer < layers; layer++) {
|
||||
const z = (layer - (layers - 1) / 2) * spacing * 0.92;
|
||||
// Alternate layers shift half a cell both ways so depth neighbors nest
|
||||
// into the gaps instead of stacking into columns.
|
||||
const layerOff = layer % 2 === 0 ? 0 : spacing * 0.5;
|
||||
let row = 0;
|
||||
for (let y = pad * 0.5; y < height - pad * 0.5; y += rowH, row++) {
|
||||
const rowOff = (row % 2 === 0 ? 0 : spacing * 0.5) + layerOff;
|
||||
for (let x = pad * 0.5 + rowOff; x < width - pad * 0.5; x += spacing) {
|
||||
const jx = x + (rand() - 0.5) * jitter * 2;
|
||||
const jy = y + (rand() - 0.5) * jitter * 2;
|
||||
if (!covered(Math.round(jx), Math.round(jy))) continue;
|
||||
balls.push({
|
||||
x: jx,
|
||||
y: jy,
|
||||
z: z + (rand() - 0.5) * jitter * 2,
|
||||
r: desiredRadius(jx, jy), // desired; capped below
|
||||
});
|
||||
}
|
||||
// Fast "random point inside the ink" sampling: collect covered pixels on a
|
||||
// coarse stride once, then darts pick from that list with subpixel jitter.
|
||||
const inkCells: number[] = [];
|
||||
for (let y = 0; y < height; y += 2) {
|
||||
for (let x = 0; x < width; x += 2) {
|
||||
if (covered(x, y)) inkCells.push(y * width + x);
|
||||
}
|
||||
}
|
||||
if (!inkCells.length) return { balls: [], width, height };
|
||||
|
||||
// Cap every radius at half its nearest-home-neighbor distance so the rest
|
||||
// pose is non-overlapping regardless of jitter — collisions then only ever
|
||||
// matter mid-shove, never in the settled wordmark.
|
||||
capRadiiToNeighbors(balls, spacing);
|
||||
const rand = mulberry32(formation.seed);
|
||||
const sizeNoise = makeValueNoise(formation.seed + 41);
|
||||
const rMin = spacing * formation.sizeMin;
|
||||
const rMax = spacing * formation.sizeMax;
|
||||
const halfDepth = (spacing * formation.depth) / 2;
|
||||
|
||||
return { balls, width, height };
|
||||
}
|
||||
// Organic sizing: smooth spatial noise drifts the size in clusters across
|
||||
// the word; white noise roughens it per ball.
|
||||
const desiredRadius = (x: number, y: number) => {
|
||||
const n = Math.pow(
|
||||
sizeNoise(x / formation.sizeNoiseScale, y / formation.sizeNoiseScale),
|
||||
1.15,
|
||||
);
|
||||
const roughened =
|
||||
n * (1 - formation.sizeRoughen) + rand() * formation.sizeRoughen;
|
||||
return rMin + (rMax - rMin) * roughened;
|
||||
};
|
||||
|
||||
function capRadiiToNeighbors(balls: Ball[], spacing: number) {
|
||||
const cell = spacing * 2;
|
||||
// A ball only counts as "inside the letter" if its center plus four cross
|
||||
// points at ~edgeMargin·r are on ink — big balls stay inside the strokes,
|
||||
// small ones may nestle right up to the edge, keeping letterforms crisp.
|
||||
const insideStroke = (x: number, y: number, r: number) => {
|
||||
const m = r * formation.edgeMargin;
|
||||
return (
|
||||
covered(Math.round(x), Math.round(y)) &&
|
||||
covered(Math.round(x + m), Math.round(y)) &&
|
||||
covered(Math.round(x - m), Math.round(y)) &&
|
||||
covered(Math.round(x), Math.round(y + m)) &&
|
||||
covered(Math.round(x), Math.round(y - m))
|
||||
);
|
||||
};
|
||||
|
||||
// Dart throwing with a 3D hash for overlap rejection. Three passes with a
|
||||
// descending size floor: the big balls claim their spots first and the
|
||||
// smaller ones settle into the leftover gaps — the arrangement reads like
|
||||
// a settled pile instead of a grid.
|
||||
const balls: Ball[] = [];
|
||||
const cell = rMax * 2 + formation.gap;
|
||||
const grid = new Map<number, number[]>();
|
||||
const key = (x: number, y: number, z: number) =>
|
||||
(Math.floor(x / cell) * 73856093) ^
|
||||
(Math.floor(y / cell) * 19349663) ^
|
||||
(Math.floor(z / cell) * 83492791);
|
||||
balls.forEach((b, i) => {
|
||||
const k = key(b.x, b.y, b.z);
|
||||
const bucket = grid.get(k);
|
||||
if (bucket) bucket.push(i);
|
||||
else grid.set(k, [i]);
|
||||
});
|
||||
|
||||
const nearest = new Float32Array(balls.length).fill(Infinity);
|
||||
for (let i = 0; i < balls.length; i++) {
|
||||
const b = balls[i];
|
||||
const fits = (x: number, y: number, z: number, r: number) => {
|
||||
for (let gz = -1; gz <= 1; gz++) {
|
||||
for (let gy = -1; gy <= 1; gy++) {
|
||||
for (let gx = -1; gx <= 1; gx++) {
|
||||
const bucket = grid.get(
|
||||
key(b.x + gx * cell, b.y + gy * cell, b.z + gz * cell),
|
||||
key(x + gx * cell, y + gy * cell, z + gz * cell),
|
||||
);
|
||||
if (!bucket) continue;
|
||||
for (const j of bucket) {
|
||||
if (j === i) continue;
|
||||
const o = balls[j];
|
||||
const d = Math.hypot(b.x - o.x, b.y - o.y, b.z - o.z);
|
||||
if (d < nearest[i]) nearest[i] = d;
|
||||
const dx = o.x - x;
|
||||
const dy = o.y - y;
|
||||
const dz = o.z - z;
|
||||
const min = o.r + r + formation.gap;
|
||||
if (dx * dx + dy * dy + dz * dz < min * min) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const passes = [0.7, 0.35, 0]; // descending floor on the size range
|
||||
const attemptsPerPass = Math.floor(
|
||||
inkCells.length * 0.9 * formation.density,
|
||||
);
|
||||
for (const floor of passes) {
|
||||
for (let a = 0; a < attemptsPerPass; a++) {
|
||||
const c = inkCells[(rand() * inkCells.length) | 0];
|
||||
const x = (c % width) + rand() * 2 - 1;
|
||||
const y = Math.floor(c / width) + rand() * 2 - 1;
|
||||
const z = (rand() * 2 - 1) * halfDepth;
|
||||
let r = desiredRadius(x, y);
|
||||
const passMin = rMin + (rMax - rMin) * floor;
|
||||
if (r < passMin) r = passMin + (r - rMin) * 0.2;
|
||||
if (!insideStroke(x, y, r)) continue;
|
||||
if (!fits(x, y, z, r)) continue;
|
||||
balls.push({ x, y, z, r });
|
||||
const k = key(x, y, z);
|
||||
const bucket = grid.get(k);
|
||||
if (bucket) bucket.push(balls.length - 1);
|
||||
else grid.set(k, [balls.length - 1]);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < balls.length; i++) {
|
||||
const fit = nearest[i] * 0.5 - 0.4;
|
||||
balls[i].r = Math.max(spacing * 0.15, Math.min(balls[i].r, fit));
|
||||
}
|
||||
|
||||
return { balls, width, height };
|
||||
}
|
||||
|
||||
// Deterministic bilinear value noise, 0..1 — smooth variation across space.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Live dev tuner (see TUNER.md).
|
||||
//
|
||||
// Any module can register a group of numeric tunables with `defineTunables`;
|
||||
// the TunerPanel renders every registered group with sliders + number inputs,
|
||||
// and consumers read live values per frame (or subscribe for rebuilds).
|
||||
// Values persist to localStorage so a refresh keeps the current tuning.
|
||||
//
|
||||
// POLICY: every new tweakable constant in this app gets registered here by
|
||||
// default — adding a slider is free, a missing one costs an iteration.
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export type TunableDef = {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
default: number;
|
||||
};
|
||||
|
||||
export type TunableGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
defs: Record<string, TunableDef>;
|
||||
};
|
||||
|
||||
type TunerState = {
|
||||
groups: Record<string, TunableGroup>;
|
||||
/** Sparse overrides, keyed "group.key". Defaults live in the defs. */
|
||||
values: Record<string, number>;
|
||||
/** Bumped on every change — cheap signal for rebuild subscribers. */
|
||||
version: number;
|
||||
registerGroup: (
|
||||
id: string,
|
||||
title: string,
|
||||
defs: Record<string, TunableDef>,
|
||||
) => void;
|
||||
setValue: (groupId: string, key: string, value: number) => void;
|
||||
resetAll: () => void;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "artcraft-tuner";
|
||||
|
||||
function loadStored(): Record<string, number> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function save(values: Record<string, number>) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
||||
} catch {
|
||||
// Storage may be unavailable (private mode); tuning just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
export const useTunerStore = create<TunerState>((set) => ({
|
||||
groups: {},
|
||||
values: loadStored(),
|
||||
version: 0,
|
||||
registerGroup: (id, title, defs) =>
|
||||
set((s) =>
|
||||
s.groups[id]
|
||||
? s
|
||||
: {
|
||||
groups: {
|
||||
...s.groups,
|
||||
[id]: { id, title, order: Object.keys(s.groups).length, defs },
|
||||
},
|
||||
},
|
||||
),
|
||||
setValue: (groupId, key, value) =>
|
||||
set((s) => {
|
||||
const values = { ...s.values, [`${groupId}.${key}`]: value };
|
||||
save(values);
|
||||
return { values, version: s.version + 1 };
|
||||
}),
|
||||
resetAll: () =>
|
||||
set((s) => {
|
||||
save({});
|
||||
return { values: {}, version: s.version + 1 };
|
||||
}),
|
||||
}));
|
||||
|
||||
export type TunableReader<T extends Record<string, TunableDef>> = {
|
||||
id: string;
|
||||
defs: T;
|
||||
/** Current values (defaults merged with overrides). Cheap; call per frame. */
|
||||
read: () => { [K in keyof T]: number };
|
||||
};
|
||||
|
||||
// Registers a tunable group (once) and returns a typed live reader.
|
||||
// Call at module scope in the file that owns the constants.
|
||||
export function defineTunables<T extends Record<string, TunableDef>>(
|
||||
id: string,
|
||||
title: string,
|
||||
defs: T,
|
||||
): TunableReader<T> {
|
||||
useTunerStore.getState().registerGroup(id, title, defs);
|
||||
return {
|
||||
id,
|
||||
defs,
|
||||
read: () => {
|
||||
const { values } = useTunerStore.getState();
|
||||
const out = {} as { [K in keyof T]: number };
|
||||
for (const key in defs) {
|
||||
const override = values[`${id}.${String(key)}`];
|
||||
out[key] = override ?? defs[key].default;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Merged snapshot of every registered group — used by the panel's copy
|
||||
// button so tuned values can be pasted back into code as new defaults.
|
||||
export function tunerSnapshot(): Record<string, Record<string, number>> {
|
||||
const { groups, values } = useTunerStore.getState();
|
||||
const out: Record<string, Record<string, number>> = {};
|
||||
for (const g of Object.values(groups)) {
|
||||
out[g.id] = {};
|
||||
for (const key in g.defs) {
|
||||
out[g.id][key] = values[`${g.id}.${key}`] ?? g.defs[key].default;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user