halftone generator v1 + new 3d shapes effect start (#19539)

## Summary
- refresh the website halftone studio with new rendering controls, UI
updates, and export/runtime plumbing
- improve halftone output quality by opening highlights, grouping
shadows, and reducing visible row artifacts
- update home/problem illustration assets, 3D model references, and
related illustration components to use the new visuals
- adjust footer and layout-related website wiring to support the updated
illustration experience

## Testing
- Not run (not requested)

---------

Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
Thomas des Francs 2026-04-10 11:37:13 +02:00 committed by GitHub
parent 63c407e2f7
commit e03ac126ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 16458 additions and 1197 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

View file

@ -46,7 +46,7 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
illustration: 'lock',
illustration: 'wheelx',
},
],
};

View file

@ -0,0 +1,20 @@
'use client';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
type FooterVisibilityGateProps = {
children: ReactNode;
};
export function FooterVisibilityGate({
children,
}: FooterVisibilityGateProps) {
const pathname = usePathname();
if (pathname === '/halftone') {
return null;
}
return <>{children}</>;
}

View file

@ -27,6 +27,11 @@ export const FOOTER_DATA: FooterDataType = {
{ label: 'FAQ', href: '#', external: false },
{ label: 'Support', href: '#', external: false },
{ label: 'Release Notes', href: '/release-notes', external: false },
{
label: 'Halftone generator',
href: '/halftone',
external: false,
},
],
},
{

View file

@ -0,0 +1,246 @@
'use client';
import { AnimationsTab } from './controls/AnimationsTab';
import { DesignTab } from './controls/DesignTab';
import { ExportTab } from './controls/ExportTab';
import { PanelShell, TabButton, TabsBar } from './controls/controls-ui';
type HalftoneTabId = 'design' | 'animations' | 'export';
type HalftoneSourceMode = 'shape' | 'image';
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
type HalftoneModelLoader = 'fbx' | 'glb';
interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
interface HalftoneMaterialSettings {
roughness: number;
metalness: number;
}
interface HalftoneEffectSettings {
enabled: boolean;
numRows: number;
contrast: number;
power: number;
shading: number;
baseInk: number;
maxBar: number;
rowMerge: number;
cellRatio: number;
cutoff: number;
highlightOpen: number;
shadowGrouping: number;
shadowCrush: number;
dashColor: string;
}
interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
interface HalftoneAnimationSettings {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverLightEnabled: boolean;
dragFlowEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
autoSpeed: number;
autoWobble: number;
breatheAmount: number;
breatheSpeed: number;
cameraParallaxAmount: number;
cameraParallaxEase: number;
driftAmount: number;
hoverRange: number;
hoverEase: number;
hoverReturn: boolean;
dragSens: number;
dragFriction: number;
dragMomentum: boolean;
rotateAxis: HalftoneRotateAxis;
rotatePreset: HalftoneRotatePreset;
rotateSpeed: number;
rotatePingPong: boolean;
floatAmplitude: number;
floatSpeed: number;
lightSweepHeightRange: number;
lightSweepRange: number;
lightSweepSpeed: number;
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
dragFlowRadius: number;
dragFlowStrength: number;
hoverWarpStrength: number;
hoverWarpRadius: number;
dragWarpStrength: number;
waveEnabled: boolean;
waveSpeed: number;
waveAmount: number;
}
interface HalftoneStudioSettings {
sourceMode: HalftoneSourceMode;
shapeKey: string;
lighting: HalftoneLightingSettings;
material: HalftoneMaterialSettings;
halftone: HalftoneEffectSettings;
background: HalftoneBackgroundSettings;
animation: HalftoneAnimationSettings;
}
interface HalftoneGeometrySpec {
key: string;
label: string;
kind: 'builtin' | 'imported';
loader?: HalftoneModelLoader;
filename?: string;
description?: string;
extensions?: readonly string[];
userProvided?: boolean;
}
type ControlsPanelProps = {
activeTab: HalftoneTabId;
defaultExportName: string;
exportName: string;
imageFileName: string | null;
onAnimationSettingsChange: (
value: Partial<HalftoneStudioSettings['animation']>,
) => void;
onBackgroundChange: (value: Partial<HalftoneBackgroundSettings>) => void;
onDashColorChange: (value: string) => void;
onExportHalftoneImage: (width: number, height: number) => void;
onExportHtml: () => void;
onExportNameChange: (value: string) => void;
onExportReact: () => void;
onImportPreset: () => void;
onHalftoneChange: (
value: Partial<HalftoneStudioSettings['halftone']>,
) => void;
onLightingChange: (
value: Partial<HalftoneStudioSettings['lighting']>,
) => void;
onMaterialChange: (
value: Partial<HalftoneStudioSettings['material']>,
) => void;
onPreviewDistanceChange: (value: number) => void;
onShapeChange: (value: string) => void;
onSourceModeChange: (value: HalftoneSourceMode) => void;
onTabChange: (value: HalftoneTabId) => void;
onUploadImage: () => void;
onUploadModel: () => void;
previewDistance: number;
selectedShape: HalftoneGeometrySpec | undefined;
settings: HalftoneStudioSettings;
shapeOptions: Array<{ label: string; value: string }>;
};
const TABS: HalftoneTabId[] = ['design', 'animations', 'export'];
const TAB_LABELS: Record<HalftoneTabId, string> = {
design: 'Design',
animations: 'Animations',
export: 'Export',
};
export function ControlsPanel({
activeTab,
defaultExportName,
exportName,
imageFileName,
onAnimationSettingsChange,
onBackgroundChange,
onDashColorChange,
onExportHalftoneImage,
onExportHtml,
onExportNameChange,
onExportReact,
onImportPreset,
onHalftoneChange,
onLightingChange,
onMaterialChange,
onPreviewDistanceChange,
onShapeChange,
onSourceModeChange,
onTabChange,
onUploadImage,
onUploadModel,
previewDistance,
selectedShape,
settings,
shapeOptions,
}: ControlsPanelProps) {
return (
<PanelShell>
<TabsBar>
{TABS.map((tab) => (
<TabButton
$active={tab === activeTab}
key={tab}
onClick={() => onTabChange(tab)}
type="button"
>
{TAB_LABELS[tab]}
</TabButton>
))}
</TabsBar>
{activeTab === 'design' ? (
<DesignTab
imageFileName={imageFileName}
onBackgroundChange={onBackgroundChange}
onDashColorChange={onDashColorChange}
onHalftoneChange={onHalftoneChange}
onLightingChange={onLightingChange}
onMaterialChange={onMaterialChange}
onPreviewDistanceChange={onPreviewDistanceChange}
onShapeChange={onShapeChange}
onSourceModeChange={onSourceModeChange}
onUploadImage={onUploadImage}
onUploadModel={onUploadModel}
previewDistance={previewDistance}
settings={settings}
shapeOptions={shapeOptions}
/>
) : null}
{activeTab === 'animations' ? (
<AnimationsTab
onAnimationSettingsChange={onAnimationSettingsChange}
settings={settings}
/>
) : null}
{activeTab === 'export' ? (
<ExportTab
defaultExportName={defaultExportName}
exportName={exportName}
onExportHalftoneImage={onExportHalftoneImage}
onExportHtml={onExportHtml}
onExportNameChange={onExportNameChange}
onExportReact={onExportReact}
onImportPreset={onImportPreset}
selectedShape={selectedShape}
settings={settings}
/>
) : null}
</PanelShell>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,824 @@
'use client';
import {
formatAngle,
formatDecimal,
formatPercent,
} from '@/app/halftone/_lib/formatters';
import {
ControlGrid,
LabelWithTooltip,
Section,
SectionTitle,
SectionToggleHeader,
SelectControl,
SliderControl,
TabContent,
ToggleControl,
} from './controls-ui';
type HalftoneSourceMode = 'shape' | 'image';
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
interface HalftoneMaterialSettings {
roughness: number;
metalness: number;
}
interface HalftoneEffectSettings {
enabled: boolean;
numRows: number;
contrast: number;
power: number;
shading: number;
baseInk: number;
maxBar: number;
rowMerge: number;
cellRatio: number;
cutoff: number;
highlightOpen: number;
shadowGrouping: number;
shadowCrush: number;
dashColor: string;
}
interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
interface HalftoneAnimationSettings {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverLightEnabled: boolean;
dragFlowEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
autoSpeed: number;
autoWobble: number;
breatheAmount: number;
breatheSpeed: number;
cameraParallaxAmount: number;
cameraParallaxEase: number;
driftAmount: number;
hoverRange: number;
hoverEase: number;
hoverReturn: boolean;
dragSens: number;
dragFriction: number;
dragMomentum: boolean;
rotateAxis: HalftoneRotateAxis;
rotatePreset: HalftoneRotatePreset;
rotateSpeed: number;
rotatePingPong: boolean;
floatAmplitude: number;
floatSpeed: number;
lightSweepHeightRange: number;
lightSweepRange: number;
lightSweepSpeed: number;
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
dragFlowRadius: number;
dragFlowStrength: number;
hoverWarpStrength: number;
hoverWarpRadius: number;
dragWarpStrength: number;
waveEnabled: boolean;
waveSpeed: number;
waveAmount: number;
}
interface HalftoneStudioSettings {
sourceMode: HalftoneSourceMode;
shapeKey: string;
lighting: HalftoneLightingSettings;
material: HalftoneMaterialSettings;
halftone: HalftoneEffectSettings;
background: HalftoneBackgroundSettings;
animation: HalftoneAnimationSettings;
}
type AnimationsTabProps = {
onAnimationSettingsChange: (
value: Partial<HalftoneStudioSettings['animation']>,
) => void;
settings: HalftoneStudioSettings;
};
function effectLabel(label: string, description: string) {
return <LabelWithTooltip description={description} label={label} />;
}
export function AnimationsTab({
onAnimationSettingsChange,
settings,
}: AnimationsTabProps) {
const animation = settings.animation;
const isImageMode = settings.sourceMode === 'image';
return (
<TabContent>
{isImageMode ? (
<>
<Section $first>
<SectionToggleHeader
checked={animation.hoverLightEnabled}
onChange={(event) =>
onAnimationSettingsChange({
hoverLightEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Hover Light',
'Turns the cursor into a moving light source so the halftone bars open up, tighten, and re-balance as you hover.',
)}
</SectionToggleHeader>
{animation.hoverLightEnabled ? (
<ControlGrid>
<SliderControl
max={2}
min={0.1}
onChange={(event) =>
onAnimationSettingsChange({
hoverLightIntensity: Number(event.target.value),
})
}
step={0.05}
value={animation.hoverLightIntensity}
valueLabel={formatDecimal(animation.hoverLightIntensity, 2)}
>
Intensity
</SliderControl>
<SliderControl
max={0.45}
min={0.06}
onChange={(event) =>
onAnimationSettingsChange({
hoverLightRadius: Number(event.target.value),
})
}
step={0.01}
value={animation.hoverLightRadius}
valueLabel={formatDecimal(animation.hoverLightRadius, 2)}
>
Radius
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.dragFlowEnabled}
onChange={(event) =>
onAnimationSettingsChange({
dragFlowEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Drag Smear',
'Click and drag to pull the halftone pattern through the pointer. The distortion trails your motion instead of sitting above the image.',
)}
</SectionToggleHeader>
{animation.dragFlowEnabled ? (
<ControlGrid>
<SliderControl
max={4}
min={0.5}
onChange={(event) =>
onAnimationSettingsChange({
dragFlowStrength: Number(event.target.value),
})
}
step={0.1}
value={animation.dragFlowStrength}
valueLabel={formatDecimal(animation.dragFlowStrength, 1)}
>
Strength
</SliderControl>
<SliderControl
max={0.5}
min={0.08}
onChange={(event) =>
onAnimationSettingsChange({
dragFlowRadius: Number(event.target.value),
})
}
step={0.01}
value={animation.dragFlowRadius}
valueLabel={formatDecimal(animation.dragFlowRadius, 2)}
>
Radius
</SliderControl>
<SliderControl
max={0.25}
min={0.02}
onChange={(event) =>
onAnimationSettingsChange({
dragFlowDecay: Number(event.target.value),
})
}
step={0.01}
value={animation.dragFlowDecay}
valueLabel={formatDecimal(animation.dragFlowDecay, 2)}
>
Decay
</SliderControl>
</ControlGrid>
) : null}
</Section>
</>
) : (
<>
<Section $first>
<SectionTitle>Rotation</SectionTitle>
<ControlGrid>
<ToggleControl
checked={animation.autoRotateEnabled}
label={effectLabel(
'Idle auto-rotate',
'Continuously rotates the model on its own, with optional wobble layered on top.',
)}
onChange={(event) =>
onAnimationSettingsChange({
autoRotateEnabled: event.target.checked,
})
}
/>
{animation.autoRotateEnabled ? (
<>
<SliderControl
max={1.5}
min={0.05}
onChange={(event) =>
onAnimationSettingsChange({
autoSpeed: Number(event.target.value),
})
}
step={0.05}
value={animation.autoSpeed}
valueLabel={formatDecimal(animation.autoSpeed, 1)}
>
Speed
</SliderControl>
<SliderControl
max={1.5}
min={0}
onChange={(event) =>
onAnimationSettingsChange({
autoWobble: Number(event.target.value),
})
}
step={0.05}
value={animation.autoWobble}
valueLabel={formatDecimal(animation.autoWobble, 1)}
>
Wobble
</SliderControl>
</>
) : null}
<ToggleControl
checked={animation.rotateEnabled}
label={effectLabel(
'Rotation preset',
'Applies a scripted motion path. Axis is simple spin, while the other presets combine multiple frequencies for more complex motion.',
)}
onChange={(event) =>
onAnimationSettingsChange({
rotateEnabled: event.target.checked,
})
}
/>
{animation.rotateEnabled ? (
<>
<SelectControl
onChange={(event) =>
onAnimationSettingsChange({
rotatePreset: event.target
.value as HalftoneStudioSettings['animation']['rotatePreset'],
})
}
options={[
{ label: 'Axis rotate', value: 'axis' },
{ label: 'Lissajous', value: 'lissajous' },
{ label: 'Orbit', value: 'orbit' },
{ label: 'Tumble', value: 'tumble' },
]}
value={animation.rotatePreset}
>
Pattern
</SelectControl>
{animation.rotatePreset === 'axis' ? (
<SelectControl
onChange={(event) =>
onAnimationSettingsChange({
rotateAxis: event.target
.value as HalftoneStudioSettings['animation']['rotateAxis'],
})
}
options={[
{ label: 'Y (horizontal)', value: 'y' },
{ label: '-Y (horizontal)', value: '-y' },
{ label: 'X (vertical)', value: 'x' },
{ label: '-X (vertical)', value: '-x' },
{ label: 'Z (roll)', value: 'z' },
{ label: '-Z (roll)', value: '-z' },
{ label: 'X + Y (diagonal)', value: 'xy' },
{ label: '-X + -Y (diagonal)', value: '-xy' },
]}
value={animation.rotateAxis}
>
Axis
</SelectControl>
) : null}
<SliderControl
max={4}
min={0.1}
onChange={(event) =>
onAnimationSettingsChange({
rotateSpeed: Number(event.target.value),
})
}
step={0.1}
value={animation.rotateSpeed}
valueLabel={formatDecimal(animation.rotateSpeed, 1)}
>
Speed
</SliderControl>
<ToggleControl
checked={animation.rotatePingPong}
label="Ping-pong"
onChange={(event) =>
onAnimationSettingsChange({
rotatePingPong: event.target.checked,
})
}
/>
</>
) : null}
</ControlGrid>
</Section>
<Section>
<SectionToggleHeader
checked={animation.floatEnabled}
onChange={(event) =>
onAnimationSettingsChange({
floatEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Float + Drift',
'Adds a gentle vertical bob plus a small rotational drift so the object feels suspended instead of locked in place.',
)}
</SectionToggleHeader>
{animation.floatEnabled ? (
<ControlGrid>
<SliderControl
max={2}
min={0.2}
onChange={(event) =>
onAnimationSettingsChange({
floatSpeed: Number(event.target.value),
})
}
step={0.05}
value={animation.floatSpeed}
valueLabel={formatDecimal(animation.floatSpeed, 2)}
>
Speed
</SliderControl>
<SliderControl
max={0.4}
min={0.02}
onChange={(event) =>
onAnimationSettingsChange({
floatAmplitude: Number(event.target.value),
})
}
step={0.01}
value={animation.floatAmplitude}
valueLabel={formatDecimal(animation.floatAmplitude, 2)}
>
Height
</SliderControl>
<SliderControl
max={24}
min={0}
onChange={(event) =>
onAnimationSettingsChange({
driftAmount: Number(event.target.value),
})
}
step={1}
value={animation.driftAmount}
valueLabel={formatAngle(animation.driftAmount)}
>
Drift
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.breatheEnabled}
onChange={(event) =>
onAnimationSettingsChange({
breatheEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Breathing Scale',
'Pulses the model scale in and out by a small amount to make static shapes feel alive.',
)}
</SectionToggleHeader>
{animation.breatheEnabled ? (
<ControlGrid>
<SliderControl
max={2}
min={0.2}
onChange={(event) =>
onAnimationSettingsChange({
breatheSpeed: Number(event.target.value),
})
}
step={0.05}
value={animation.breatheSpeed}
valueLabel={formatDecimal(animation.breatheSpeed, 2)}
>
Speed
</SliderControl>
<SliderControl
max={0.12}
min={0.01}
onChange={(event) =>
onAnimationSettingsChange({
breatheAmount: Number(event.target.value),
})
}
step={0.005}
value={animation.breatheAmount}
valueLabel={formatPercent(animation.breatheAmount)}
>
Amount
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.lightSweepEnabled}
onChange={(event) =>
onAnimationSettingsChange({
lightSweepEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Light Sweep',
'Animates the primary light angle and height so highlights travel across the halftone surface.',
)}
</SectionToggleHeader>
{animation.lightSweepEnabled ? (
<ControlGrid>
<SliderControl
max={2}
min={0.2}
onChange={(event) =>
onAnimationSettingsChange({
lightSweepSpeed: Number(event.target.value),
})
}
step={0.05}
value={animation.lightSweepSpeed}
valueLabel={formatDecimal(animation.lightSweepSpeed, 2)}
>
Speed
</SliderControl>
<SliderControl
max={60}
min={5}
onChange={(event) =>
onAnimationSettingsChange({
lightSweepRange: Number(event.target.value),
})
}
step={1}
value={animation.lightSweepRange}
valueLabel={formatAngle(animation.lightSweepRange)}
>
Angle
</SliderControl>
<SliderControl
max={1.5}
min={0}
onChange={(event) =>
onAnimationSettingsChange({
lightSweepHeightRange: Number(event.target.value),
})
}
step={0.05}
value={animation.lightSweepHeightRange}
valueLabel={formatDecimal(animation.lightSweepHeightRange, 2)}
>
Height
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.cameraParallaxEnabled}
onChange={(event) =>
onAnimationSettingsChange({
cameraParallaxEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Camera Parallax',
'Moves the camera viewpoint with the cursor to create depth without directly rotating the object.',
)}
</SectionToggleHeader>
{animation.cameraParallaxEnabled ? (
<ControlGrid>
<SliderControl
max={0.8}
min={0.05}
onChange={(event) =>
onAnimationSettingsChange({
cameraParallaxAmount: Number(event.target.value),
})
}
step={0.01}
value={animation.cameraParallaxAmount}
valueLabel={formatDecimal(animation.cameraParallaxAmount, 2)}
>
Amount
</SliderControl>
<SliderControl
max={0.2}
min={0.02}
onChange={(event) =>
onAnimationSettingsChange({
cameraParallaxEase: Number(event.target.value),
})
}
step={0.01}
value={animation.cameraParallaxEase}
valueLabel={formatDecimal(animation.cameraParallaxEase, 2)}
>
Easing
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.springReturnEnabled}
onChange={(event) =>
onAnimationSettingsChange({
springReturnEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Spring Return',
'Changes the settling behavior so hover and drag interactions rebound and ease back like a spring instead of stopping flat.',
)}
</SectionToggleHeader>
{animation.springReturnEnabled ? (
<ControlGrid>
<SliderControl
max={0.35}
min={0.05}
onChange={(event) =>
onAnimationSettingsChange({
springStrength: Number(event.target.value),
})
}
step={0.01}
value={animation.springStrength}
valueLabel={formatDecimal(animation.springStrength, 2)}
>
Strength
</SliderControl>
<SliderControl
max={0.92}
min={0.4}
onChange={(event) =>
onAnimationSettingsChange({
springDamping: Number(event.target.value),
})
}
step={0.02}
value={animation.springDamping}
valueLabel={formatDecimal(animation.springDamping, 2)}
>
Damping
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.followHoverEnabled}
onChange={(event) =>
onAnimationSettingsChange({
followHoverEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Follow Mouse (Hover)',
'Tilts the object toward the pointer while the cursor is over the canvas.',
)}
</SectionToggleHeader>
{animation.followHoverEnabled ? (
<ControlGrid>
<SliderControl
max={60}
min={5}
onChange={(event) =>
onAnimationSettingsChange({
hoverRange: Number(event.target.value),
})
}
value={animation.hoverRange}
valueLabel={formatAngle(animation.hoverRange)}
>
Range
</SliderControl>
<SliderControl
max={0.2}
min={0.01}
onChange={(event) =>
onAnimationSettingsChange({
hoverEase: Number(event.target.value),
})
}
step={0.01}
value={animation.hoverEase}
valueLabel={formatDecimal(animation.hoverEase, 2)}
>
Easing
</SliderControl>
<ToggleControl
checked={animation.hoverReturn}
label="Return to center"
onChange={(event) =>
onAnimationSettingsChange({
hoverReturn: event.target.checked,
})
}
/>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.followDragEnabled}
onChange={(event) =>
onAnimationSettingsChange({
followDragEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Follow Mouse (Drag)',
'Lets you rotate the object directly by dragging, optionally continuing with momentum after release.',
)}
</SectionToggleHeader>
{animation.followDragEnabled ? (
<ControlGrid>
<SliderControl
max={0.02}
min={0.002}
onChange={(event) =>
onAnimationSettingsChange({
dragSens: Number(event.target.value),
})
}
step={0.001}
value={animation.dragSens}
valueLabel={formatDecimal(animation.dragSens, 3)}
>
Sensitivity
</SliderControl>
<SliderControl
max={0.2}
min={0.01}
onChange={(event) =>
onAnimationSettingsChange({
dragFriction: Number(event.target.value),
})
}
step={0.01}
value={animation.dragFriction}
valueLabel={formatDecimal(animation.dragFriction, 2)}
>
Friction
</SliderControl>
<ToggleControl
checked={animation.dragMomentum}
label="Momentum"
onChange={(event) =>
onAnimationSettingsChange({
dragMomentum: event.target.checked,
})
}
/>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.waveEnabled}
onChange={(event) =>
onAnimationSettingsChange({
waveEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Wave',
'Makes the halftone rows undulate horizontally for a liquid, organic feel.',
)}
</SectionToggleHeader>
{animation.waveEnabled ? (
<ControlGrid>
<SliderControl
max={3}
min={0.1}
onChange={(event) =>
onAnimationSettingsChange({
waveSpeed: Number(event.target.value),
})
}
step={0.1}
value={animation.waveSpeed}
valueLabel={formatDecimal(animation.waveSpeed, 1)}
>
Speed
</SliderControl>
<SliderControl
max={8}
min={0.5}
onChange={(event) =>
onAnimationSettingsChange({
waveAmount: Number(event.target.value),
})
}
step={0.1}
value={animation.waveAmount}
valueLabel={formatDecimal(animation.waveAmount, 1)}
>
Amount
</SliderControl>
</ControlGrid>
) : null}
</Section>
</>
)}
</TabContent>
);
}

View file

@ -0,0 +1,533 @@
'use client';
import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters';
import {
ColorControlLabel,
ColorControlRow,
ColorField,
ControlGrid,
Section,
SectionTitle,
SectionToggleHeader,
SelectControl,
SelectInput,
ShapeRow,
SliderControl,
TabContent,
UploadButton,
ValueDisplay,
} from './controls-ui';
type HalftoneSourceMode = 'shape' | 'image';
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
interface HalftoneMaterialSettings {
roughness: number;
metalness: number;
}
interface HalftoneEffectSettings {
enabled: boolean;
numRows: number;
contrast: number;
power: number;
shading: number;
baseInk: number;
maxBar: number;
rowMerge: number;
cellRatio: number;
cutoff: number;
highlightOpen: number;
shadowGrouping: number;
shadowCrush: number;
dashColor: string;
}
interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
interface HalftoneAnimationSettings {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverLightEnabled: boolean;
dragFlowEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
autoSpeed: number;
autoWobble: number;
breatheAmount: number;
breatheSpeed: number;
cameraParallaxAmount: number;
cameraParallaxEase: number;
driftAmount: number;
hoverRange: number;
hoverEase: number;
hoverReturn: boolean;
dragSens: number;
dragFriction: number;
dragMomentum: boolean;
rotateAxis: HalftoneRotateAxis;
rotatePreset: HalftoneRotatePreset;
rotateSpeed: number;
rotatePingPong: boolean;
floatAmplitude: number;
floatSpeed: number;
lightSweepHeightRange: number;
lightSweepRange: number;
lightSweepSpeed: number;
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
dragFlowRadius: number;
dragFlowStrength: number;
hoverWarpStrength: number;
hoverWarpRadius: number;
dragWarpStrength: number;
waveEnabled: boolean;
waveSpeed: number;
waveAmount: number;
}
interface HalftoneStudioSettings {
sourceMode: HalftoneSourceMode;
shapeKey: string;
lighting: HalftoneLightingSettings;
material: HalftoneMaterialSettings;
halftone: HalftoneEffectSettings;
background: HalftoneBackgroundSettings;
animation: HalftoneAnimationSettings;
}
type DesignTabProps = {
imageFileName: string | null;
onBackgroundChange: (value: Partial<HalftoneBackgroundSettings>) => void;
onDashColorChange: (value: string) => void;
onHalftoneChange: (
value: Partial<HalftoneStudioSettings['halftone']>,
) => void;
onLightingChange: (
value: Partial<HalftoneStudioSettings['lighting']>,
) => void;
onMaterialChange: (
value: Partial<HalftoneStudioSettings['material']>,
) => void;
onPreviewDistanceChange: (value: number) => void;
onShapeChange: (value: string) => void;
onSourceModeChange: (value: HalftoneSourceMode) => void;
onUploadImage: () => void;
onUploadModel: () => void;
previewDistance: number;
settings: HalftoneStudioSettings;
shapeOptions: Array<{ label: string; value: string }>;
};
export function DesignTab({
imageFileName,
onBackgroundChange,
onDashColorChange,
onHalftoneChange,
onLightingChange,
onMaterialChange,
onPreviewDistanceChange,
onShapeChange,
onSourceModeChange,
onUploadImage,
onUploadModel,
previewDistance,
settings,
shapeOptions,
}: DesignTabProps) {
const isImageMode = settings.sourceMode === 'image';
return (
<TabContent>
<Section $first>
<SectionTitle>Source</SectionTitle>
<ControlGrid>
<SelectControl
onChange={(event) =>
onSourceModeChange(event.target.value as HalftoneSourceMode)
}
options={[
{ label: '3D Shape', value: 'shape' },
{ label: 'Image', value: 'image' },
]}
value={settings.sourceMode}
>
Mode
</SelectControl>
{isImageMode ? (
<ShapeRow>
<span>Image</span>
<ValueDisplay title={imageFileName ?? 'twenty-logo.svg'}>
{imageFileName ?? 'twenty-logo.svg'}
</ValueDisplay>
<UploadButton
onClick={onUploadImage}
title="Upload image (.png / .jpg / .webp)"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
<path d="M7 9l5 -5l5 5" />
<path d="M12 4l0 12" />
</svg>
</UploadButton>
</ShapeRow>
) : (
<ShapeRow>
<span>Shape</span>
<SelectInput
onChange={(event) => onShapeChange(event.target.value)}
value={settings.shapeKey}
>
{shapeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectInput>
<UploadButton
onClick={onUploadModel}
title="Upload model (.fbx / .glb)"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
<path d="M7 9l5 -5l5 5" />
<path d="M12 4l0 12" />
</svg>
</UploadButton>
</ShapeRow>
)}
</ControlGrid>
</Section>
{isImageMode ? (
<>
<Section>
<SectionTitle>Visualization</SectionTitle>
<ControlGrid>
<SliderControl
max={8}
min={4}
onChange={(event) =>
onPreviewDistanceChange(Number(event.target.value))
}
step={0.1}
value={previewDistance}
valueLabel={formatDecimal(previewDistance, 1)}
>
Distance
</SliderControl>
</ControlGrid>
</Section>
</>
) : (
<>
<Section>
<SectionTitle>Visualization</SectionTitle>
<ControlGrid>
<SliderControl
max={8}
min={4}
onChange={(event) =>
onPreviewDistanceChange(Number(event.target.value))
}
step={0.1}
value={previewDistance}
valueLabel={formatDecimal(previewDistance, 1)}
>
Distance
</SliderControl>
</ControlGrid>
</Section>
<Section>
<SectionTitle>Lighting</SectionTitle>
<ControlGrid>
<SliderControl
max={4}
min={0.5}
onChange={(event) =>
onLightingChange({ intensity: Number(event.target.value) })
}
step={0.1}
value={settings.lighting.intensity}
valueLabel={formatDecimal(settings.lighting.intensity, 1)}
>
Light
</SliderControl>
<SliderControl
max={1.5}
min={0}
onChange={(event) =>
onLightingChange({
fillIntensity: Number(event.target.value),
})
}
step={0.01}
value={settings.lighting.fillIntensity}
valueLabel={formatDecimal(settings.lighting.fillIntensity)}
>
Fill
</SliderControl>
<SliderControl
max={0.3}
min={0}
onChange={(event) =>
onLightingChange({
ambientIntensity: Number(event.target.value),
})
}
step={0.01}
value={settings.lighting.ambientIntensity}
valueLabel={formatDecimal(settings.lighting.ambientIntensity)}
>
Ambient
</SliderControl>
<SliderControl
max={360}
min={0}
onChange={(event) =>
onLightingChange({ angleDegrees: Number(event.target.value) })
}
value={settings.lighting.angleDegrees}
valueLabel={formatAngle(settings.lighting.angleDegrees)}
>
Angle
</SliderControl>
<SliderControl
max={8}
min={-4}
onChange={(event) =>
onLightingChange({ height: Number(event.target.value) })
}
step={0.1}
value={settings.lighting.height}
valueLabel={formatDecimal(settings.lighting.height, 1)}
>
Height
</SliderControl>
</ControlGrid>
</Section>
<Section>
<SectionTitle>Material</SectionTitle>
<ControlGrid>
<SliderControl
max={1}
min={0}
onChange={(event) =>
onMaterialChange({ roughness: Number(event.target.value) })
}
step={0.01}
value={settings.material.roughness}
valueLabel={formatDecimal(settings.material.roughness)}
>
Roughness
</SliderControl>
<SliderControl
max={1}
min={0}
onChange={(event) =>
onMaterialChange({ metalness: Number(event.target.value) })
}
step={0.01}
value={settings.material.metalness}
valueLabel={formatDecimal(settings.material.metalness)}
>
Metalness
</SliderControl>
</ControlGrid>
</Section>
</>
)}
<Section>
<SectionToggleHeader
checked={settings.halftone.enabled}
onChange={(event) =>
onHalftoneChange({ enabled: event.target.checked })
}
>
Halftone
</SectionToggleHeader>
{settings.halftone.enabled ? (
<ControlGrid>
<SliderControl
max={150}
min={30}
onChange={(event) =>
onHalftoneChange({ numRows: Number(event.target.value) })
}
value={settings.halftone.numRows}
valueLabel={String(settings.halftone.numRows)}
>
Rows
</SliderControl>
<SliderControl
max={3}
min={0.5}
onChange={(event) =>
onHalftoneChange({ contrast: Number(event.target.value) })
}
step={0.1}
value={settings.halftone.contrast}
valueLabel={formatDecimal(settings.halftone.contrast, 1)}
>
Contrast
</SliderControl>
<SliderControl
max={2.5}
min={0.5}
onChange={(event) =>
onHalftoneChange({ power: Number(event.target.value) })
}
step={0.1}
value={settings.halftone.power}
valueLabel={formatDecimal(settings.halftone.power, 1)}
>
Power
</SliderControl>
<SliderControl
max={3}
min={0}
onChange={(event) =>
onHalftoneChange({ shading: Number(event.target.value) })
}
step={0.1}
value={settings.halftone.shading}
valueLabel={formatDecimal(settings.halftone.shading, 1)}
>
Shading
</SliderControl>
<SliderControl
max={0.4}
min={0}
onChange={(event) =>
onHalftoneChange({ baseInk: Number(event.target.value) })
}
step={0.01}
value={settings.halftone.baseInk}
valueLabel={formatDecimal(settings.halftone.baseInk)}
>
Base Density
</SliderControl>
<SliderControl
max={0.4}
min={0}
onChange={(event) =>
onHalftoneChange({ highlightOpen: Number(event.target.value) })
}
step={0.01}
value={settings.halftone.highlightOpen}
valueLabel={formatDecimal(settings.halftone.highlightOpen)}
>
Highlight Open
</SliderControl>
<SliderControl
max={1}
min={0}
onChange={(event) =>
onHalftoneChange({
shadowGrouping: Number(event.target.value),
})
}
step={0.01}
value={settings.halftone.shadowGrouping}
valueLabel={formatDecimal(settings.halftone.shadowGrouping)}
>
Shadow Group
</SliderControl>
<SliderControl
max={0.48}
min={0.1}
onChange={(event) =>
onHalftoneChange({ maxBar: Number(event.target.value) })
}
step={0.01}
value={settings.halftone.maxBar}
valueLabel={formatDecimal(settings.halftone.maxBar)}
>
Thickness
</SliderControl>
<SliderControl
max={0.35}
min={0}
onChange={(event) =>
onHalftoneChange({ rowMerge: Number(event.target.value) })
}
step={0.01}
value={settings.halftone.rowMerge}
valueLabel={formatDecimal(settings.halftone.rowMerge)}
>
Row Merge
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionTitle>Colors</SectionTitle>
<ControlGrid>
<ColorControlRow>
<ColorControlLabel>Dash color</ColorControlLabel>
<ColorField
ariaLabel="Dash color"
onChange={onDashColorChange}
value={settings.halftone.dashColor}
/>
</ColorControlRow>
<ColorControlRow>
<ColorControlLabel>Background</ColorControlLabel>
<ColorField
ariaLabel="Background color"
onChange={(value) => onBackgroundChange({ color: value })}
value={settings.background.color}
/>
</ColorControlRow>
</ControlGrid>
</Section>
</TabContent>
);
}

View file

@ -0,0 +1,276 @@
'use client';
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
import { useState } from 'react';
import {
ExportButton,
ExportNameInput,
ExportNote,
ExportPreview,
Section,
SectionTitle,
SelectControl,
SmallBody,
TabContent,
} from './controls-ui';
type HalftoneSourceMode = 'shape' | 'image';
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
type HalftoneModelLoader = 'fbx' | 'glb';
interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
interface HalftoneMaterialSettings {
roughness: number;
metalness: number;
}
interface HalftoneEffectSettings {
enabled: boolean;
numRows: number;
contrast: number;
power: number;
shading: number;
baseInk: number;
maxBar: number;
rowMerge: number;
cellRatio: number;
cutoff: number;
highlightOpen: number;
shadowGrouping: number;
shadowCrush: number;
dashColor: string;
}
interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
interface HalftoneAnimationSettings {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverLightEnabled: boolean;
dragFlowEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
autoSpeed: number;
autoWobble: number;
breatheAmount: number;
breatheSpeed: number;
cameraParallaxAmount: number;
cameraParallaxEase: number;
driftAmount: number;
hoverRange: number;
hoverEase: number;
hoverReturn: boolean;
dragSens: number;
dragFriction: number;
dragMomentum: boolean;
rotateAxis: HalftoneRotateAxis;
rotatePreset: HalftoneRotatePreset;
rotateSpeed: number;
rotatePingPong: boolean;
floatAmplitude: number;
floatSpeed: number;
lightSweepHeightRange: number;
lightSweepRange: number;
lightSweepSpeed: number;
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
dragFlowRadius: number;
dragFlowStrength: number;
hoverWarpStrength: number;
hoverWarpRadius: number;
dragWarpStrength: number;
waveEnabled: boolean;
waveSpeed: number;
waveAmount: number;
}
interface HalftoneStudioSettings {
sourceMode: HalftoneSourceMode;
shapeKey: string;
lighting: HalftoneLightingSettings;
material: HalftoneMaterialSettings;
halftone: HalftoneEffectSettings;
background: HalftoneBackgroundSettings;
animation: HalftoneAnimationSettings;
}
interface HalftoneGeometrySpec {
key: string;
label: string;
kind: 'builtin' | 'imported';
loader?: HalftoneModelLoader;
filename?: string;
description?: string;
extensions?: readonly string[];
userProvided?: boolean;
}
const RESOLUTION_OPTIONS = [
{ label: '720p (1280 × 720)', value: '1280x720' },
{ label: '1080p (1920 × 1080)', value: '1920x1080' },
{ label: '2K (2560 × 1440)', value: '2560x1440' },
{ label: '4K (3840 × 2160)', value: '3840x2160' },
];
type ExportTabProps = {
defaultExportName: string;
exportName: string;
onExportHalftoneImage: (width: number, height: number) => void;
onExportHtml: () => void;
onExportNameChange: (value: string) => void;
onExportReact: () => void;
onImportPreset: () => void;
selectedShape: HalftoneGeometrySpec | undefined;
settings: HalftoneStudioSettings;
};
export function ExportTab({
defaultExportName,
exportName,
onExportHalftoneImage,
onExportHtml,
onExportNameChange,
onExportReact,
onImportPreset,
selectedShape,
settings,
}: ExportTabProps) {
const [resolution, setResolution] = useState('1920x1080');
const isImageMode = settings.sourceMode === 'image';
const animationLabel = formatAnimationName(
settings.animation,
settings.sourceMode,
);
const componentName = exportName || defaultExportName;
const handleDownloadHalftoneImage = () => {
const [widthStr, heightStr] = resolution.split('x');
const width = parseInt(widthStr, 10);
const height = parseInt(heightStr, 10);
onExportHalftoneImage(width, height);
};
return (
<TabContent>
<Section $first>
<SectionTitle>Download Image</SectionTitle>
<SmallBody>
Downloads a PNG snapshot of the current halftone effect at the selected
resolution.
</SmallBody>
<SelectControl
onChange={(event) => setResolution(event.target.value)}
options={RESOLUTION_OPTIONS}
value={resolution}
>
Resolution
</SelectControl>
<ExportButton
onClick={handleDownloadHalftoneImage}
style={{ marginTop: 12 }}
type="button"
>
Download Halftone Image
</ExportButton>
</Section>
<Section>
<SectionTitle>Export React Component</SectionTitle>
<SmallBody>
Downloads a self-contained React component with the current design and
animation settings baked in. Requires <code>three</code>.
</SmallBody>
<ExportNameInput
onChange={(event) => onExportNameChange(event.target.value)}
placeholder={defaultExportName}
type="text"
value={exportName}
/>
<ExportPreview>
<div>
<span
style={{ color: '#9d90fa' }}
>{`// ${componentName}.tsx`}</span>
</div>
<div>
<span style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{'// Auto-generated from current settings'}
</span>
</div>
<br />
<div>
<span style={{ color: '#e0a856' }}>import</span>{' '}
<span style={{ color: '#8bc58b' }}>{componentName}</span>{' '}
<span style={{ color: '#e0a856' }}>from</span>{' '}
<span style={{ color: '#8bc58b' }}>{`'./${componentName}'`}</span>
</div>
<br />
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Shape: ${selectedShape?.key ?? settings.shapeKey}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Animation: ${animationLabel}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Dash color: ${settings.halftone.dashColor}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Rows: ${settings.halftone.numRows}`}
</div>
</ExportPreview>
<ExportButton onClick={onExportReact} type="button">
Download React Component
</ExportButton>
<ExportButton onClick={onExportHtml} type="button">
Download Standalone HTML
</ExportButton>
<ExportNote>
{isImageMode
? 'The original image is downloaded alongside the export so the halftone effect can be rendered on top of it.'
: selectedShape?.kind === 'imported'
? 'The standalone HTML export includes the current lighting, material, halftone, and animation settings, and embeds the uploaded model directly in the HTML file.'
: 'The standalone HTML export includes the current lighting, material, halftone, and animation settings.'}
</ExportNote>
</Section>
<Section>
<SectionTitle>Import Preset</SectionTitle>
<SmallBody>
Loads a previously exported <code>.tsx</code> or <code>.html</code>{' '}
halftone preset. If that preset depends on a separate image or model
file, select that asset in the picker too.
</SmallBody>
<ExportButton onClick={onImportPreset} type="button">
Import Exported Preset
</ExportButton>
</Section>
</TabContent>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
export function formatAngle(value: number) {
return `${value}°`;
}
export function formatDecimal(value: number, digits = 2) {
return value.toFixed(digits);
}
export function formatPercent(value: number, digits = 0) {
return `${(value * 100).toFixed(digits)}%`;
}
export function formatAnimationName(animation: {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
dragFlowEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverLightEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
rotatePreset: string;
springReturnEnabled: boolean;
}, sourceMode: 'shape' | 'image') {
const activeModes: string[] = [];
if (sourceMode === 'image') {
if (animation.hoverLightEnabled) {
activeModes.push('hoverLight');
}
if (animation.dragFlowEnabled) {
activeModes.push('dragSmear');
}
return activeModes.length > 0 ? activeModes.join(' + ') : 'still';
}
if (animation.autoRotateEnabled) {
activeModes.push('autoRotate');
}
if (animation.floatEnabled) {
activeModes.push('float');
}
if (animation.breatheEnabled) {
activeModes.push('breathe');
}
if (animation.followHoverEnabled) {
activeModes.push('followHover');
}
if (animation.followDragEnabled) {
activeModes.push('followDrag');
}
if (animation.rotateEnabled) {
activeModes.push(
animation.rotatePreset === 'axis'
? 'rotate'
: `rotate(${animation.rotatePreset})`,
);
}
if (animation.lightSweepEnabled) {
activeModes.push('lightSweep');
}
if (animation.cameraParallaxEnabled) {
activeModes.push('cameraParallax');
}
if (animation.springReturnEnabled) {
activeModes.push('spring');
}
return activeModes.length > 0 ? activeModes.join(' + ') : 'still';
}
export function getBackgroundTone(background: {
color: string;
transparent: boolean;
}) {
if (background.transparent) {
return 'light';
}
const normalized = background.color.replace('#', '');
const expanded =
normalized.length === 3
? normalized
.split('')
.map((character) => `${character}${character}`)
.join('')
: normalized;
const value = Number.parseInt(expanded, 16);
const red = (value >> 16) & 255;
const green = (value >> 8) & 255;
const blue = value & 255;
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
return luminance < 0.5 ? 'dark' : 'light';
}

View file

@ -0,0 +1,737 @@
import * as THREE from 'three';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
type HalftoneModelLoader = 'fbx' | 'glb';
interface HalftoneGeometrySpec {
key: string;
label: string;
kind: 'builtin' | 'imported';
loader?: HalftoneModelLoader;
filename?: string;
description?: string;
extensions?: readonly string[];
userProvided?: boolean;
}
type GeometryCacheEntry = THREE.BufferGeometry | Promise<THREE.BufferGeometry>;
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
if (geometries.length === 1) {
return geometries[0];
}
let totalVertices = 0;
let totalIndices = 0;
let hasUv = false;
const geometryInfos = geometries.map((geometry) => {
const position = geometry.attributes.position;
const normal = geometry.attributes.normal;
const uv = geometry.attributes.uv ?? null;
const index = geometry.index;
const indexCount = index ? index.count : position.count;
totalVertices += position.count;
totalIndices += indexCount;
hasUv = hasUv || uv !== null;
return {
index,
indexCount,
normal,
position,
uv,
vertexCount: position.count,
};
});
const positions = new Float32Array(totalVertices * 3);
const normals = new Float32Array(totalVertices * 3);
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
const indices = new Uint32Array(totalIndices);
let vertexOffset = 0;
let indexOffset = 0;
for (const geometryInfo of geometryInfos) {
for (
let vertexIndex = 0;
vertexIndex < geometryInfo.vertexCount;
vertexIndex += 1
) {
const positionOffset = (vertexOffset + vertexIndex) * 3;
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
if (uvs !== null) {
const uvOffset = (vertexOffset + vertexIndex) * 2;
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
}
}
if (geometryInfo.index) {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] =
geometryInfo.index.getX(localIndex) + vertexOffset;
}
} else {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] = localIndex + vertexOffset;
}
}
vertexOffset += geometryInfo.vertexCount;
indexOffset += geometryInfo.indexCount;
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
if (uvs !== null) {
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
}
merged.setIndex(new THREE.BufferAttribute(indices, 1));
return merged;
}
const DRACO_DECODER_PATH =
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
const EMPTY_TEXTURE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
geometry.computeBoundingBox();
let boundingBox = geometry.boundingBox;
let center = new THREE.Vector3();
let size = new THREE.Vector3();
boundingBox?.getCenter(center);
boundingBox?.getSize(size);
geometry.translate(-center.x, -center.y, -center.z);
const dimensions = [size.x, size.y, size.z];
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
if (thinnestAxis === 0) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
} else if (thinnestAxis === 1) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const radius = geometry.boundingSphere?.radius || 1;
const scale = 1.6 / radius;
geometry.scale(scale, scale, scale);
geometry.computeBoundingBox();
boundingBox = geometry.boundingBox;
center = new THREE.Vector3();
boundingBox?.getCenter(center);
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
function createLoadingManager() {
const loadingManager = new THREE.LoadingManager();
loadingManager.setURLModifier((url) =>
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
);
return loadingManager;
}
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
root.updateMatrixWorld(true);
const geometries: THREE.BufferGeometry[] = [];
root.traverse((object) => {
if (!(object instanceof THREE.Mesh) || !object.geometry) {
return;
}
const geometry = object.geometry.clone();
if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
}
geometry.applyMatrix4(object.matrixWorld);
geometries.push(geometry);
});
if (geometries.length === 0) {
throw new Error(emptyMessage);
}
return normalizeImportedGeometry(mergeGeometries(geometries));
}
function parseFbxGeometry(
buffer: ArrayBuffer,
resourcePath: string,
label: string,
) {
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
return;
}
originalWarn(...args);
};
try {
const root = new FBXLoader(createLoadingManager()).parse(
buffer,
resourcePath,
);
return extractMergedGeometry(
root,
`${label} did not contain any mesh geometry.`,
);
} finally {
console.warn = originalWarn;
}
}
function parseGlbGeometry(
buffer: ArrayBuffer,
resourcePath: string,
label: string,
) {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
const gltfLoader = new GLTFLoader(createLoadingManager());
gltfLoader.setDRACOLoader(dracoLoader);
return new Promise<THREE.BufferGeometry>((resolve, reject) => {
gltfLoader.parse(
buffer,
resourcePath,
(gltf) => {
try {
resolve(
extractMergedGeometry(
gltf.scene,
`${label} did not contain any mesh geometry.`,
),
);
} catch (error) {
reject(error);
}
},
reject,
);
}).finally(() => {
dracoLoader.dispose();
});
}
async function loadImportedGeometry(
loader: HalftoneModelLoader,
file: File,
label: string,
) {
const buffer = await file.arrayBuffer();
if (loader === 'fbx') {
return parseFbxGeometry(buffer, '', label);
}
return parseGlbGeometry(buffer, '', label);
}
function makePolarShape(
radiusFunction: (angle: number) => number,
segments = 320,
) {
const shape = new THREE.Shape();
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
const angle = (segmentIndex / segments) * Math.PI * 2;
const radius = radiusFunction(angle);
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
if (segmentIndex === 0) {
shape.moveTo(x, y);
} else {
shape.lineTo(x, y);
}
}
return shape;
}
function makeReliefGeometry(
shape: THREE.Shape,
options: {
depth?: number;
bevelSize?: number;
bevelThickness?: number;
bevelSegments?: number;
waves?: number;
waveDepth?: number;
} = {},
) {
const {
bevelSegments = 8,
bevelSize = 0.08,
bevelThickness = 0.1,
depth = 0.58,
waveDepth = 0.016,
waves = 8,
} = options;
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
steps: 2,
bevelEnabled: true,
bevelThickness,
bevelSize,
bevelSegments,
curveSegments: 96,
});
geometry.center();
const position = geometry.attributes.position;
let maxRadius = 0;
for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
maxRadius = Math.max(
maxRadius,
Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)),
);
}
const fullDepth = depth + bevelThickness * 2;
for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
const x = position.getX(vertexIndex);
const y = position.getY(vertexIndex);
const z = position.getZ(vertexIndex);
const radius = Math.hypot(x, y) / maxRadius;
const angle = Math.atan2(y, x);
const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5));
const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2));
const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2));
const wave =
Math.cos(angle * waves) *
Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) *
waveDepth;
const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave);
position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief);
}
position.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
function makeArrowTarget() {
const targetParts: THREE.BufferGeometry[] = [];
const arrowParts: THREE.BufferGeometry[] = [];
const baseRadius = 1.35;
const baseDepth = 0.32;
const bevel = 0.12;
const points: THREE.Vector2[] = [];
const segments = 16;
points.push(new THREE.Vector2(0, -baseDepth / 2));
points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2);
points.push(
new THREE.Vector2(
baseRadius - bevel + Math.cos(angle) * bevel,
-baseDepth / 2 + bevel + Math.sin(angle) * bevel,
),
);
}
points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel));
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
const angle = (segmentIndex / segments) * (Math.PI / 2);
points.push(
new THREE.Vector2(
baseRadius - bevel + Math.cos(angle) * bevel,
baseDepth / 2 - bevel + Math.sin(angle) * bevel,
),
);
}
points.push(new THREE.Vector2(0, baseDepth / 2));
const disc = new THREE.LatheGeometry(points, 64);
disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
targetParts.push(disc);
for (const radius of [0.45, 0.85, 1.22]) {
const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64);
ring.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04),
);
targetParts.push(ring);
}
const bump = new THREE.SphereGeometry(
0.32,
32,
24,
0,
Math.PI * 2,
0,
Math.PI / 2,
);
bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2));
bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2));
targetParts.push(bump);
const shaftLength = 1.5;
const shaftRadius = 0.05;
const shaft = new THREE.CylinderGeometry(
shaftRadius,
shaftRadius,
shaftLength,
10,
1,
);
shaft.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0),
);
arrowParts.push(shaft);
const head = new THREE.ConeGeometry(0.12, 0.35, 10);
head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0));
arrowParts.push(head);
for (let finIndex = 0; finIndex < 3; finIndex += 1) {
const finShape = new THREE.Shape();
finShape.moveTo(0, 0);
finShape.lineTo(0.22, 0.25);
finShape.lineTo(0, 0.5);
finShape.lineTo(0, 0);
const finGeometry = new THREE.ExtrudeGeometry(finShape, {
depth: 0.012,
bevelEnabled: false,
});
finGeometry.applyMatrix4(
new THREE.Matrix4().makeTranslation(0.05, 0, -0.006),
);
finGeometry.applyMatrix4(
new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3),
);
finGeometry.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0),
);
arrowParts.push(finGeometry);
}
const nock = new THREE.SphereGeometry(0.065, 8, 8);
nock.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0),
);
arrowParts.push(nock);
const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15);
const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5);
const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12);
for (const geometry of arrowParts) {
geometry.applyMatrix4(aim);
geometry.applyMatrix4(tilt);
geometry.applyMatrix4(shift);
}
const merged = mergeGeometries([...targetParts, ...arrowParts]);
merged.computeVertexNormals();
merged.computeBoundingSphere();
return merged;
}
function makeDollarCoin() {
const parts: THREE.BufferGeometry[] = [];
const baseRadius = 1.3;
const baseDepth = 0.45;
const bevel = 0.18;
const points: THREE.Vector2[] = [];
const segments = 20;
points.push(new THREE.Vector2(0, -baseDepth / 2));
points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI;
points.push(
new THREE.Vector2(
baseRadius - bevel + Math.cos(angle) * bevel,
Math.sin(angle) * (baseDepth / 2),
),
);
}
points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2));
points.push(new THREE.Vector2(0, baseDepth / 2));
const disc = new THREE.LatheGeometry(points, 64);
disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
parts.push(disc);
const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
frontRim.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01),
);
parts.push(frontRim);
const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
backRim.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)),
);
parts.push(backRim);
const createDollarSign = () => {
const geometries: THREE.BufferGeometry[] = [];
const tubeRadius = 0.1;
const curveRadius = 0.28;
const verticalOffset = 0.22;
const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12);
geometries.push(bar);
const topArc = new THREE.TorusGeometry(
curveRadius,
tubeRadius,
16,
32,
Math.PI,
);
topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
topArc.applyMatrix4(
new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0),
);
geometries.push(topArc);
const bottomArc = new THREE.TorusGeometry(
curveRadius,
tubeRadius,
16,
32,
Math.PI,
);
bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2));
bottomArc.applyMatrix4(
new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0),
);
geometries.push(bottomArc);
const topSerif = new THREE.CylinderGeometry(
tubeRadius,
tubeRadius,
0.22,
12,
);
topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
topSerif.applyMatrix4(
new THREE.Matrix4().makeTranslation(
0.16,
verticalOffset + curveRadius,
0,
),
);
geometries.push(topSerif);
const bottomSerif = new THREE.CylinderGeometry(
tubeRadius,
tubeRadius,
0.22,
12,
);
bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
bottomSerif.applyMatrix4(
new THREE.Matrix4().makeTranslation(
-0.16,
-verticalOffset - curveRadius,
0,
),
);
geometries.push(bottomSerif);
const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2);
const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1);
const diagonal = new THREE.CylinderGeometry(
tubeRadius,
tubeRadius,
diagonalLength + 0.12,
12,
);
diagonal.applyMatrix4(
new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2),
);
geometries.push(diagonal);
return geometries;
};
for (const geometry of createDollarSign()) {
geometry.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01),
);
parts.push(geometry);
}
for (const geometry of createDollarSign()) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI));
geometry.applyMatrix4(
new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)),
);
parts.push(geometry);
}
const merged = mergeGeometries(parts);
merged.computeVertexNormals();
merged.computeBoundingSphere();
return merged;
}
const BUILTIN_GEOMETRIES: Record<string, () => THREE.BufferGeometry> = {
torusKnot: () => new THREE.TorusKnotGeometry(1, 0.35, 200, 32),
sphere: () => new THREE.SphereGeometry(1.4, 64, 64),
torus: () => new THREE.TorusGeometry(1, 0.45, 64, 100),
icosahedron: () => new THREE.IcosahedronGeometry(1.4, 4),
box: () => new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6),
cone: () => new THREE.ConeGeometry(1.2, 2.4, 64, 10),
cylinder: () => new THREE.CylinderGeometry(1, 1, 2.3, 64, 10),
octahedron: () => new THREE.OctahedronGeometry(1.5, 2),
dodecahedron: () => new THREE.DodecahedronGeometry(1.35, 1),
tetrahedron: () => new THREE.TetrahedronGeometry(1.7, 1),
sunCoin: () =>
makeReliefGeometry(
makePolarShape(
(angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5),
),
{ depth: 0.62, waves: 12, waveDepth: 0.018 },
),
lotusCoin: () =>
makeReliefGeometry(
makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)),
{ depth: 0.64, waves: 8, waveDepth: 0.014 },
),
arrowTarget: () => makeArrowTarget(),
dollarCoin: () => makeDollarCoin(),
};
async function createGeometry(
spec: HalftoneGeometrySpec,
file: File | undefined,
) {
if (spec.kind === 'imported') {
if (!file || !spec.loader) {
throw new Error(`No file is available for ${spec.label}.`);
}
return loadImportedGeometry(
spec.loader as HalftoneModelLoader,
file,
spec.label,
);
}
const factory = BUILTIN_GEOMETRIES[spec.key];
if (!factory) {
throw new Error(`Unsupported geometry: ${spec.key}.`);
}
return factory();
}
export function createFallbackGeometry() {
return BUILTIN_GEOMETRIES.torusKnot();
}
export async function getGeometryForSpec(
spec: HalftoneGeometrySpec,
file: File | undefined,
cache: Map<string, GeometryCacheEntry>,
) {
const cached = cache.get(spec.key);
if (cached) {
return cached instanceof Promise ? cached : Promise.resolve(cached);
}
const created = createGeometry(spec, file);
if (created instanceof Promise) {
const pending = created
.then((geometry) => {
cache.set(spec.key, geometry);
return geometry;
})
.catch((error) => {
cache.delete(spec.key);
throw error;
});
cache.set(spec.key, pending);
return pending;
}
cache.set(spec.key, created);
return Promise.resolve(created);
}
export function disposeGeometryCache(cache: Map<string, GeometryCacheEntry>) {
for (const value of cache.values()) {
if (!(value instanceof Promise)) {
value.dispose();
}
}
cache.clear();
}

View file

@ -0,0 +1,482 @@
type HalftoneTabId = 'design' | 'animations' | 'export';
type HalftoneSourceMode = 'shape' | 'image';
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
type HalftoneModelLoader = 'fbx' | 'glb';
interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
interface HalftoneMaterialSettings {
roughness: number;
metalness: number;
}
interface HalftoneEffectSettings {
enabled: boolean;
numRows: number;
contrast: number;
power: number;
shading: number;
baseInk: number;
maxBar: number;
rowMerge: number;
cellRatio: number;
cutoff: number;
highlightOpen: number;
shadowGrouping: number;
shadowCrush: number;
dashColor: string;
}
interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
interface HalftoneAnimationSettings {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverLightEnabled: boolean;
dragFlowEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
autoSpeed: number;
autoWobble: number;
breatheAmount: number;
breatheSpeed: number;
cameraParallaxAmount: number;
cameraParallaxEase: number;
driftAmount: number;
hoverRange: number;
hoverEase: number;
hoverReturn: boolean;
dragSens: number;
dragFriction: number;
dragMomentum: boolean;
rotateAxis: HalftoneRotateAxis;
rotatePreset: HalftoneRotatePreset;
rotateSpeed: number;
rotatePingPong: boolean;
floatAmplitude: number;
floatSpeed: number;
lightSweepHeightRange: number;
lightSweepRange: number;
lightSweepSpeed: number;
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
dragFlowRadius: number;
dragFlowStrength: number;
hoverWarpStrength: number;
hoverWarpRadius: number;
dragWarpStrength: number;
waveEnabled: boolean;
waveSpeed: number;
waveAmount: number;
}
interface HalftoneStudioSettings {
sourceMode: HalftoneSourceMode;
shapeKey: string;
lighting: HalftoneLightingSettings;
material: HalftoneMaterialSettings;
halftone: HalftoneEffectSettings;
background: HalftoneBackgroundSettings;
animation: HalftoneAnimationSettings;
}
interface HalftoneGeometrySpec {
key: string;
label: string;
kind: 'builtin' | 'imported';
loader?: HalftoneModelLoader;
filename?: string;
description?: string;
extensions?: readonly string[];
userProvided?: boolean;
}
interface HalftoneStudioState {
activeTab: HalftoneTabId;
geometrySpecs: HalftoneGeometrySpec[];
importedFiles: Record<string, File>;
settings: HalftoneStudioSettings;
showHint: boolean;
statusMessage: string;
statusIsError: boolean;
}
type HalftoneStudioAction =
| { type: 'setTab'; value: HalftoneTabId }
| { type: 'setSourceMode'; value: HalftoneSourceMode }
| { type: 'setShapeKey'; value: string }
| { type: 'replaceSettings'; value: HalftoneStudioSettings }
| { type: 'patchLighting'; value: Partial<HalftoneLightingSettings> }
| { type: 'patchMaterial'; value: Partial<HalftoneMaterialSettings> }
| { type: 'patchHalftone'; value: Partial<HalftoneEffectSettings> }
| { type: 'patchBackground'; value: Partial<HalftoneBackgroundSettings> }
| { type: 'patchAnimation'; value: Partial<HalftoneAnimationSettings> }
| {
type: 'registerImportedFile';
spec: HalftoneGeometrySpec;
file: File;
activate: boolean;
}
| { type: 'setImportedFile'; key: string; file: File }
| { type: 'setStatus'; message: string; isError?: boolean }
| { type: 'clearStatus' }
| { type: 'hideHint' };
function upsertGeometrySpec(
geometrySpecs: HalftoneGeometrySpec[],
spec: HalftoneGeometrySpec,
) {
const existingIndex = geometrySpecs.findIndex(
(geometrySpec) => geometrySpec.key === spec.key,
);
if (existingIndex === -1) {
return [...geometrySpecs, spec];
}
return geometrySpecs.map((geometrySpec, index) =>
index === existingIndex ? spec : geometrySpec,
);
}
export const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [
{ key: 'torusKnot', label: 'Torus Knot', kind: 'builtin' },
{ key: 'sphere', label: 'Sphere', kind: 'builtin' },
{ key: 'torus', label: 'Torus', kind: 'builtin' },
{ key: 'icosahedron', label: 'Icosahedron', kind: 'builtin' },
{ key: 'box', label: 'Box', kind: 'builtin' },
{ key: 'cone', label: 'Cone', kind: 'builtin' },
{ key: 'cylinder', label: 'Cylinder', kind: 'builtin' },
{ key: 'octahedron', label: 'Octahedron', kind: 'builtin' },
{ key: 'dodecahedron', label: 'Dodecahedron', kind: 'builtin' },
{ key: 'tetrahedron', label: 'Tetrahedron', kind: 'builtin' },
{ key: 'sunCoin', label: 'Sun Coin', kind: 'builtin' },
{ key: 'lotusCoin', label: 'Lotus Coin', kind: 'builtin' },
{ key: 'arrowTarget', label: 'Arrow Target', kind: 'builtin' },
{ key: 'dollarCoin', label: 'Dollar Coin', kind: 'builtin' },
{
key: 'wheel',
label: 'Wheel.fbx',
kind: 'imported',
loader: 'fbx',
filename: 'Wheel.fbx',
description: 'FBX model',
extensions: ['.fbx'],
},
{
key: 'twoGlb',
label: 'two.glb',
kind: 'imported',
loader: 'glb',
filename: 'two.glb',
description: 'GLB model',
extensions: ['.glb'],
},
];
export const DEFAULT_SHAPE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
enabled: true,
numRows: 45,
contrast: 1.3,
power: 1.1,
shading: 1.6,
baseInk: 0.12,
maxBar: 0.24,
rowMerge: 0.06,
cellRatio: 2.2,
cutoff: 0.02,
highlightOpen: 0.05,
shadowGrouping: 0.18,
shadowCrush: 0.14,
dashColor: '#4A38F5',
};
export const DEFAULT_IMAGE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
enabled: true,
numRows: 80,
contrast: 1.5,
power: 1.2,
shading: 0.8,
baseInk: 0.06,
maxBar: 0.32,
rowMerge: 0.18,
cellRatio: 2.0,
cutoff: 0.02,
highlightOpen: 0.14,
shadowGrouping: 0.38,
shadowCrush: 0.24,
dashColor: '#4A38F5',
};
export function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) {
return sourceMode === 'image'
? DEFAULT_IMAGE_HALFTONE_SETTINGS
: DEFAULT_SHAPE_HALFTONE_SETTINGS;
}
export const DEFAULT_HALFTONE_SETTINGS: HalftoneStudioSettings = {
sourceMode: 'shape' as HalftoneSourceMode,
shapeKey: 'torusKnot',
lighting: {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
},
material: {
roughness: 0.42,
metalness: 0.16,
},
halftone: DEFAULT_SHAPE_HALFTONE_SETTINGS,
background: {
transparent: true,
color: '#ffffff',
},
animation: {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: false,
floatEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.3,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.7,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
},
};
export function normalizeHalftoneStudioSettings(
settings?: Partial<HalftoneStudioSettings>,
): HalftoneStudioSettings {
const sourceMode =
settings?.sourceMode ?? DEFAULT_HALFTONE_SETTINGS.sourceMode;
return {
...DEFAULT_HALFTONE_SETTINGS,
...settings,
sourceMode,
lighting: {
...DEFAULT_HALFTONE_SETTINGS.lighting,
...settings?.lighting,
},
material: {
...DEFAULT_HALFTONE_SETTINGS.material,
...settings?.material,
},
halftone: {
...getDefaultHalftoneSettings(sourceMode),
...settings?.halftone,
},
background: {
...DEFAULT_HALFTONE_SETTINGS.background,
...settings?.background,
},
animation: {
...DEFAULT_HALFTONE_SETTINGS.animation,
...settings?.animation,
},
};
}
export function createInitialHalftoneStudioState(): HalftoneStudioState {
return {
activeTab: 'design',
geometrySpecs: [...DEFAULT_GEOMETRY_SPECS],
importedFiles: {},
settings: normalizeHalftoneStudioSettings(DEFAULT_HALFTONE_SETTINGS),
showHint: true,
statusMessage: '',
statusIsError: false,
};
}
export function halftoneStudioReducer(
state: HalftoneStudioState,
action: HalftoneStudioAction,
): HalftoneStudioState {
switch (action.type) {
case 'setTab':
return {
...state,
activeTab: action.value,
};
case 'setSourceMode':
return {
...state,
settings: {
...state.settings,
sourceMode: action.value,
},
};
case 'setShapeKey':
return {
...state,
settings: {
...state.settings,
shapeKey: action.value,
},
};
case 'replaceSettings':
return {
...state,
settings: normalizeHalftoneStudioSettings(action.value),
};
case 'patchLighting':
return {
...state,
settings: {
...state.settings,
lighting: {
...state.settings.lighting,
...action.value,
},
},
};
case 'patchMaterial':
return {
...state,
settings: {
...state.settings,
material: {
...state.settings.material,
...action.value,
},
},
};
case 'patchHalftone':
return {
...state,
settings: {
...state.settings,
halftone: {
...state.settings.halftone,
...action.value,
},
},
};
case 'patchBackground':
return {
...state,
settings: {
...state.settings,
background: {
...state.settings.background,
...action.value,
},
},
};
case 'patchAnimation':
return {
...state,
settings: {
...state.settings,
animation: {
...state.settings.animation,
...action.value,
},
},
};
case 'registerImportedFile':
return {
...state,
geometrySpecs: action.spec.userProvided
? upsertGeometrySpec(state.geometrySpecs, action.spec)
: state.geometrySpecs,
importedFiles: {
...state.importedFiles,
[action.spec.key]: action.file,
},
settings: action.activate
? {
...state.settings,
shapeKey: action.spec.key,
}
: state.settings,
};
case 'setImportedFile':
return {
...state,
importedFiles: {
...state.importedFiles,
[action.key]: action.file,
},
};
case 'setStatus':
return {
...state,
statusMessage: action.message,
statusIsError: action.isError ?? false,
};
case 'clearStatus':
return {
...state,
statusMessage: '',
statusIsError: false,
};
case 'hideHint':
return state.showHint
? {
...state,
showHint: false,
}
: state;
default:
return state;
}
}

View file

@ -0,0 +1,11 @@
import { HalftoneStudio } from '@/app/halftone/_components/HalftoneStudio';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Halftone Generator — Twenty',
description: 'Interactive halftone generator exported from Twenty.',
};
export default function HalftonePage() {
return <HalftoneStudio />;
}

View file

@ -1,4 +1,5 @@
import { FOOTER_DATA } from '@/app/_constants/footer';
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { Footer } from '@/sections/Footer/components';
@ -85,14 +86,16 @@ export default async function RootLayout({
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
>
<StyledMain>{children}</StyledMain>
<Footer.Root illustration={FOOTER_DATA.illustration}>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
links={footerSocialLinks}
/>
</Footer.Root>
<FooterVisibilityGate>
<Footer.Root illustration={FOOTER_DATA.illustration}>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
links={footerSocialLinks}
/>
</Footer.Root>
</FooterVisibilityGate>
</body>
</html>
);

View file

@ -1,5 +1,6 @@
'use client';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
@ -7,82 +8,306 @@ import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLB_URL = '/illustrations/common/faq/faq.glb';
const VIRTUAL_RENDER_HEIGHT = 768;
// World spin axis (x, y, z). Camera on +Z: (0,0,1) = spinner in the view plane.
// Try (0,1,0) or (1,0,0) if motion or framing looks wrong.
const scanlineVertexShader = /* glsl */ `
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
const passThroughVertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const scanlineFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform float uStripeScale;
const blurFragmentShader = /* glsl */ `
precision highp float;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.06);
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
float shadowWeight = mix(1.0, 0.5, ndotl);
float lineWidth = 0.58 * shadowWeight;
float edge = 0.035;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
sum += texture2D(tInput, vUv) * w[0];
float highlight = pow(ndotl, 1.35);
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
float dashMask = mix(
1.0,
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
highlight
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
if (band < 0.015) {
discard;
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
gl_FragColor = vec4(lit, band);
gl_FragColor = sum;
}
`;
function createScanlineMaterial(lightDirection: THREE.Vector3) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 16.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
const halftoneFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tScene;
uniform sampler2D tGlow;
uniform vec2 resolution;
uniform float numRows;
uniform float glowStr;
uniform float contrast;
uniform float power;
uniform float shading;
uniform float baseInk;
uniform float maxBar;
uniform float rowMerge;
uniform float cellRatio;
uniform float cutoff;
uniform float highlightOpen;
uniform float shadowGrouping;
uniform float shadowCrush;
uniform vec3 dashColor;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
uniform float distanceScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform vec2 dragOffset;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
uniform float hoverFlowStrength;
uniform float hoverFlowRadius;
uniform float dragFlowStrength;
uniform float dragFlowRadius;
uniform float cropToBounds;
varying vec2 vUv;
void main() {
// Crop to image bounds: discard fragments outside source image (image mode only)
if (cropToBounds > 0.5) {
vec4 boundsCheck = texture2D(tScene, vUv);
if (boundsCheck.a < 0.01) {
gl_FragColor = vec4(0.0);
return;
}
}
float baseRowH = resolution.y / (numRows * distanceScale);
vec2 pointerPx = interactionUv * resolution;
vec2 fragDelta = gl_FragCoord.xy - pointerPx;
float fragDist = length(fragDelta);
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
float velocityMagnitude = length(interactionVelocity);
vec2 motionDir = velocityMagnitude > 0.001
? interactionVelocity / velocityMagnitude
: vec2(0.0, 0.0);
float motionBias = velocityMagnitude > 0.001
? dot(-radialDir, motionDir) * 0.5 + 0.5
: 0.5;
float hoverLightMask = 0.0;
if (hoverLightStrength > 0.0) {
float lightRadiusPx = hoverLightRadius * resolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
float hoverFlowMask = 0.0;
if (hoverFlowStrength > 0.0) {
float hoverRadiusPx = hoverFlowRadius * resolution.y;
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
}
float dragFlowMask = 0.0;
if (dragFlowStrength > 0.0) {
float dragRadiusPx = dragFlowRadius * resolution.y;
dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
}
vec2 hoverDisplacement =
radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
float densityBoost =
hoverFlowStrength * hoverFlowMask * 0.22 +
dragFlowStrength * dragFlowMask * 0.16;
float rowH = baseRowH / (1.0 + densityBoost);
float offsetY = effectCoord.y;
float row = floor(offsetY / rowH);
float rowFrac = offsetY / rowH - row;
float rowV = (row + 0.5) * rowH / resolution.y;
float dy = abs(rowFrac - 0.5);
float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
float effectiveX = effectCoord.x + waveOffset;
float localCellRatio = cellRatio * (
1.0 +
hoverFlowStrength * hoverFlowMask * 0.08 +
dragFlowStrength * dragFlowMask * 0.1 * motionBias
);
float cellW = rowH * localCellRatio;
float cellIdx = floor(effectiveX / cellW);
float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
float cellU = (cellIdx + 0.5) * cellW / resolution.x;
vec2 sampleUv = vec2(
clamp(cellU, 0.0, 1.0),
clamp(rowV, 0.0, 1.0)
);
vec4 sceneSample = texture2D(tScene, sampleUv);
vec4 glowCell = texture2D(tGlow, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
float detail = lum - avgLum;
float litLum = lum + max(detail, 0.0) * shading
- max(-detail, 0.0) * shading * 0.55;
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
litLum = clamp(litLum + lightLift, 0.0, 1.0);
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
litLum = pow(litLum, max(contrast - lightFocus, 0.25));
float darkness = 1.0 - litLum;
float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
float groupedDarkness = 1.0 - groupedLum;
darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
darkness = clamp(
(darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
0.0,
1.0
);
float shadowMask = smoothstep(0.42, 0.96, darkness);
darkness = mix(
darkness,
mix(darkness, 1.0, shadowMask),
shadowCrush
);
float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
float ink = mix(inkBase, 1.0, darkness);
float fill = pow(ink, 1.05) * power;
fill = clamp(fill, 0.0, 1.0) * mask;
float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
float dynamicBarHalfY = min(
dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
0.78
);
float dx2 = abs(cellFrac - 0.5);
float halfFill = fill * 0.5;
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
float capRX = dynamicBarHalf * rowH;
float capRY = dynamicBarHalfY * rowH;
float inDash = 0.0;
if (dx2 <= bodyHalfW) {
float edgeDist = dynamicBarHalfY - dy;
inDash = smoothstep(-0.03, 0.03, edgeDist);
} else {
float cdx = (dx2 - bodyHalfW) * cellW;
float cdy = dy * rowH;
float ellipseDist = sqrt(
(cdx * cdx) / max(capRX * capRX, 0.0001) +
(cdy * cdy) / max(capRY * capRY, 0.0001)
);
inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
}
inDash *= step(0.001, ink) * mask;
inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
vec4 glow = texture2D(tGlow, vUv);
float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
float sharp = smoothstep(0.3, 0.5, inDash + halo);
vec3 color = dashColor * sharp;
gl_FragColor = vec4(color, sharp);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const DRACO_DECODER_PATH =
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
const GLB_URL = '/illustrations/common/faq/faq.glb';
const LIGHT_INTENSITY = 3.1;
const FILL_LIGHT_INTENSITY = 0.15;
const AMBIENT_LIGHT_INTENSITY = 0.08;
const LIGHT_ANGLE_DEGREES = 45;
const LIGHT_HEIGHT = 2;
const MATERIAL_ROUGHNESS = 0.42;
const MATERIAL_METALNESS = 0.16;
const HALFTONE_ROWS = 127;
const HALFTONE_CONTRAST = 1.6;
const HALFTONE_POWER = 1.2;
const HALFTONE_SHADING = 1.6;
const HALFTONE_BASE_INK = 0.16;
const HALFTONE_MAX_BAR = 0.24;
const HALFTONE_CELL_RATIO = 2.2;
const HALFTONE_CUTOFF = 0.02;
const HALFTONE_DASH_COLOR = '#4A38F5';
const ROTATE_AXIS: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy' = '-z';
const ROTATE_SPEED = 0.1;
const ROTATE_PING_PONG = false;
const INITIAL_ROTATE_ELAPSED = 275.10000000007847;
const INITIAL_TIME_ELAPSED = 249.21849999998116;
const BASE_CAMERA_DISTANCE = 4;
const MODEL_OFFSET_Y = 0.52;
const EMPTY_TEXTURE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTexture = pmremGenerator.fromScene(
new RoomEnvironment(),
0.04,
).texture;
pmremGenerator.dispose();
return environmentTexture;
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
return;
}
material.dispose();
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
@ -90,26 +315,243 @@ function disposeObjectSubtree(root: THREE.Object3D) {
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
disposeMaterial(sceneObject.material);
});
}
function applyScanlineMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
if (geometries.length === 1) {
return geometries[0];
}
let totalVertices = 0;
let totalIndices = 0;
let hasUv = false;
const geometryInfos = geometries.map((geometry) => {
const position = geometry.attributes.position;
const normal = geometry.attributes.normal;
const uv = geometry.attributes.uv ?? null;
const index = geometry.index;
const indexCount = index ? index.count : position.count;
totalVertices += position.count;
totalIndices += indexCount;
hasUv = hasUv || uv !== null;
return {
index,
indexCount,
normal,
position,
uv,
vertexCount: position.count,
};
});
const positions = new Float32Array(totalVertices * 3);
const normals = new Float32Array(totalVertices * 3);
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
const indices = new Uint32Array(totalIndices);
let vertexOffset = 0;
let indexOffset = 0;
for (const geometryInfo of geometryInfos) {
for (
let vertexIndex = 0;
vertexIndex < geometryInfo.vertexCount;
vertexIndex += 1
) {
const positionOffset = (vertexOffset + vertexIndex) * 3;
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
if (uvs !== null) {
const uvOffset = (vertexOffset + vertexIndex) * 2;
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
}
}
if (geometryInfo.index) {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] =
geometryInfo.index.getX(localIndex) + vertexOffset;
}
} else {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] = localIndex + vertexOffset;
}
}
vertexOffset += geometryInfo.vertexCount;
indexOffset += geometryInfo.indexCount;
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
if (uvs !== null) {
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
}
merged.setIndex(new THREE.BufferAttribute(indices, 1));
return merged;
}
function setPrimaryLightPosition(
light: THREE.DirectionalLight,
angleDegrees: number,
height: number,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
const lightAngle = (angleDegrees * Math.PI) / 180;
light.position.set(
Math.cos(lightAngle) * 5,
height,
Math.sin(lightAngle) * 5,
);
}
function createLoadingManager() {
const loadingManager = new THREE.LoadingManager();
loadingManager.setURLModifier((url) =>
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
);
return loadingManager;
}
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
geometry.computeBoundingBox();
let boundingBox = geometry.boundingBox;
let center = new THREE.Vector3();
let size = new THREE.Vector3();
boundingBox?.getCenter(center);
boundingBox?.getSize(size);
geometry.translate(-center.x, -center.y, -center.z);
const dimensions = [size.x, size.y, size.z];
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
if (thinnestAxis === 0) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
} else if (thinnestAxis === 1) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const radius = geometry.boundingSphere?.radius || 1;
const scale = 1.6 / radius;
geometry.scale(scale, scale, scale);
geometry.computeBoundingBox();
boundingBox = geometry.boundingBox;
center = new THREE.Vector3();
boundingBox?.getCenter(center);
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
root.updateMatrixWorld(true);
const geometries: THREE.BufferGeometry[] = [];
root.traverse((object) => {
if (!(object instanceof THREE.Mesh) || !object.geometry) {
return;
}
sceneObject.material = createScanlineMaterial(lightDirection);
const geometry = object.geometry.clone();
if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
}
geometry.applyMatrix4(object.matrixWorld);
geometries.push(geometry);
});
if (geometries.length === 0) {
throw new Error(emptyMessage);
}
return normalizeImportedGeometry(mergeGeometries(geometries));
}
async function loadFaqGeometry(modelUrl: string) {
const response = await fetch(modelUrl);
if (!response.ok) {
throw new Error(`Unable to load FAQ model from ${modelUrl}.`);
}
const buffer = await response.arrayBuffer();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
const gltfLoader = new GLTFLoader(createLoadingManager());
gltfLoader.setDRACOLoader(dracoLoader);
return await new Promise<THREE.BufferGeometry>((resolve, reject) => {
gltfLoader.parse(
buffer,
'',
(gltf) => {
try {
resolve(
extractMergedGeometry(
gltf.scene,
'FAQ model did not contain any mesh geometry.',
),
);
} catch (error) {
reject(error);
} finally {
disposeObjectSubtree(gltf.scene);
}
},
reject,
);
});
}
function createFaqMaterial(environmentTexture: THREE.Texture) {
return new THREE.MeshPhysicalMaterial({
color: 0xd4d0c8,
roughness: MATERIAL_ROUGHNESS,
metalness: MATERIAL_METALNESS,
envMap: environmentTexture,
envMapIntensity: 0.25,
clearcoat: 0,
clearcoatRoughness: 0.08,
reflectivity: 0.5,
transmission: 0,
});
}
@ -145,111 +587,301 @@ export function FaqBackground() {
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
let rotateElapsed = INITIAL_ROTATE_ELAPSED;
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(
getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)),
),
1,
);
const scene = new THREE.Scene();
const width = container.clientWidth;
const height = container.clientHeight;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.width = '100%';
container.appendChild(canvas);
const wheel = new THREE.Group();
scene.add(wheel);
const environmentTexture = createEnvironmentTexture(renderer);
const scene3d = new THREE.Scene();
scene3d.background = null;
const spinAxisWorld = new THREE.Vector3(0, 0, 1).normalize();
const camera = new THREE.PerspectiveCamera(
45,
getWidth() / getHeight(),
0.1,
100,
);
camera.position.z = BASE_CAMERA_DISTANCE;
const clock = new THREE.Clock();
const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY);
setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
scene3d.add(primaryLight);
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
const fillLight = new THREE.DirectionalLight(
0xffffff,
FILL_LIGHT_INTENSITY,
);
fillLight.position.set(-3, -1, 1);
scene3d.add(fillLight);
const ambientLight = new THREE.AmbientLight(
0xffffff,
AMBIENT_LIGHT_INTENSITY,
);
scene3d.add(ambientLight);
const material = createFaqMaterial(environmentTexture);
const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
mesh.visible = false;
scene3d.add(mesh);
const sceneTarget = createRenderTarget(
getVirtualWidth(),
getVirtualHeight(),
);
const blurTargetA = createRenderTarget(
getVirtualWidth(),
getVirtualHeight(),
);
const blurTargetB = createRenderTarget(
getVirtualWidth(),
getVirtualHeight(),
);
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(1, 0) },
res: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(0, 1) },
res: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
transparent: true,
uniforms: {
tScene: { value: sceneTarget.texture },
tGlow: { value: blurTargetB.texture },
resolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
numRows: { value: HALFTONE_ROWS },
glowStr: { value: 0 },
contrast: { value: HALFTONE_CONTRAST },
power: { value: HALFTONE_POWER },
shading: { value: HALFTONE_SHADING },
baseInk: { value: HALFTONE_BASE_INK },
maxBar: { value: HALFTONE_MAX_BAR },
cellRatio: { value: HALFTONE_CELL_RATIO },
cutoff: { value: HALFTONE_CUTOFF },
dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
time: { value: 0 },
waveAmount: { value: 0 },
waveSpeed: { value: 1 },
distanceScale: { value: 1 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
dragOffset: { value: new THREE.Vector2(0, 0) },
hoverLightStrength: { value: 0 },
hoverLightRadius: { value: 0.2 },
hoverFlowStrength: { value: 0 },
hoverFlowRadius: { value: 0.18 },
dragFlowStrength: { value: 0 },
dragFlowRadius: { value: 0.24 },
cropToBounds: { value: 0 },
},
vertexShader: passThroughVertexShader,
fragmentShader: halftoneFragmentShader,
});
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
);
loader.load(
GLB_URL,
(gltf) => {
const postScene = new THREE.Scene();
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
const syncSize = () => {
const width = getWidth();
const height = getHeight();
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
blurHorizontalMaterial.uniforms.res.value.set(
virtualWidth,
virtualHeight,
);
blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
halftoneMaterial.uniforms.resolution.value.set(
virtualWidth,
virtualHeight,
);
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
loadFaqGeometry(GLB_URL)
.then((geometry) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
geometry.dispose();
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 4 / maxAxis;
mesh.geometry.dispose();
mesh.geometry = geometry;
mesh.visible = true;
})
.catch((error) => {
console.error(error);
});
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
const clock = new THREE.Clock();
applyScanlineMaterials(modelRoot, lightDirectionWorld);
wheel.add(modelRoot);
wheel.position.y = 0.52;
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
wheel.rotateOnWorldAxis(spinAxisWorld, -delta * 0.5);
renderer.render(scene, camera);
};
renderFrame();
},
undefined,
undefined,
);
const handleResize = () => {
if (!mountReference.current || cancelled) {
const renderFrame = () => {
if (cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
if (nextWidth < 1 || nextHeight < 1) {
return;
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = 1 / 60;
const elapsedTime = INITIAL_TIME_ELAPSED + clock.getElapsedTime();
halftoneMaterial.uniforms.time.value = elapsedTime;
rotateElapsed += delta;
const rotateProgress = ROTATE_PING_PONG
? Math.sin(rotateElapsed * ROTATE_SPEED) * Math.PI
: rotateElapsed * ROTATE_SPEED;
const axisDirection = ROTATE_AXIS.startsWith('-') ? -1 : 1;
const axisProgress = rotateProgress * axisDirection;
let rotationX = 0;
let rotationY = 0;
let rotationZ = 0;
if (
ROTATE_AXIS === 'x' ||
ROTATE_AXIS === 'xy' ||
ROTATE_AXIS === '-x' ||
ROTATE_AXIS === '-xy'
) {
rotationX += axisProgress;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
if (
ROTATE_AXIS === 'y' ||
ROTATE_AXIS === 'xy' ||
ROTATE_AXIS === '-y' ||
ROTATE_AXIS === '-xy'
) {
rotationY += axisProgress;
}
if (ROTATE_AXIS === 'z' || ROTATE_AXIS === '-z') {
rotationZ += axisProgress;
}
mesh.rotation.set(rotationX, rotationY, rotationZ);
mesh.position.y = MODEL_OFFSET_Y;
mesh.scale.setScalar(1);
camera.position.x += (0 - camera.position.x) * 0.12;
camera.position.y += (0 - camera.position.y) * 0.12;
camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12;
camera.lookAt(0, MODEL_OFFSET_Y * 0.2, 0);
setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
renderer.setRenderTarget(sceneTarget);
renderer.clear();
renderer.render(scene3d, camera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
};
window.addEventListener('resize', handleResize);
renderFrame();
return () => {
cancelled = true;
window.removeEventListener('resize', handleResize);
resizeObserver.disconnect();
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
fullScreenGeometry.dispose();
mesh.geometry.dispose();
material.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
environmentTexture.dispose();
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);

View file

@ -1,129 +1,479 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useLayoutEffect, useRef } from 'react';
import * as THREE from 'three';
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useState } from 'react';
import * as THREE from 'three';
type HalftoneSourceMode = 'shape' | 'image';
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
interface HalftoneMaterialSettings {
roughness: number;
metalness: number;
}
interface HalftoneEffectSettings {
enabled: boolean;
numRows: number;
contrast: number;
power: number;
shading: number;
baseInk: number;
maxBar: number;
rowMerge: number;
cellRatio: number;
cutoff: number;
highlightOpen: number;
shadowGrouping: number;
shadowCrush: number;
dashColor: string;
}
interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
interface HalftoneAnimationSettings {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverLightEnabled: boolean;
dragFlowEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
autoSpeed: number;
autoWobble: number;
breatheAmount: number;
breatheSpeed: number;
cameraParallaxAmount: number;
cameraParallaxEase: number;
driftAmount: number;
hoverRange: number;
hoverEase: number;
hoverReturn: boolean;
dragSens: number;
dragFriction: number;
dragMomentum: boolean;
rotateAxis: HalftoneRotateAxis;
rotatePreset: HalftoneRotatePreset;
rotateSpeed: number;
rotatePingPong: boolean;
floatAmplitude: number;
floatSpeed: number;
lightSweepHeightRange: number;
lightSweepRange: number;
lightSweepSpeed: number;
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
dragFlowRadius: number;
dragFlowStrength: number;
hoverWarpStrength: number;
hoverWarpRadius: number;
dragWarpStrength: number;
waveEnabled: boolean;
waveSpeed: number;
waveAmount: number;
}
interface HalftoneExportPose {
autoElapsed: number;
rotateElapsed: number;
rotationX: number;
rotationY: number;
rotationZ: number;
targetRotationX: number;
targetRotationY: number;
timeElapsed: number;
}
interface HalftoneStudioSettings {
sourceMode: HalftoneSourceMode;
shapeKey: string;
lighting: HalftoneLightingSettings;
material: HalftoneMaterialSettings;
halftone: HalftoneEffectSettings;
background: HalftoneBackgroundSettings;
animation: HalftoneAnimationSettings;
}
type HalftoneModelLoader = 'fbx' | 'glb';
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
if (geometries.length === 1) {
return geometries[0];
}
let totalVertices = 0;
let totalIndices = 0;
let hasUv = false;
const geometryInfos = geometries.map((geometry) => {
const position = geometry.attributes.position;
const normal = geometry.attributes.normal;
const uv = geometry.attributes.uv ?? null;
const index = geometry.index;
const indexCount = index ? index.count : position.count;
totalVertices += position.count;
totalIndices += indexCount;
hasUv = hasUv || uv !== null;
return {
index,
indexCount,
normal,
position,
uv,
vertexCount: position.count,
};
});
const positions = new Float32Array(totalVertices * 3);
const normals = new Float32Array(totalVertices * 3);
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
const indices = new Uint32Array(totalIndices);
let vertexOffset = 0;
let indexOffset = 0;
for (const geometryInfo of geometryInfos) {
for (
let vertexIndex = 0;
vertexIndex < geometryInfo.vertexCount;
vertexIndex += 1
) {
const positionOffset = (vertexOffset + vertexIndex) * 3;
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
if (uvs !== null) {
const uvOffset = (vertexOffset + vertexIndex) * 2;
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
}
}
if (geometryInfo.index) {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] =
geometryInfo.index.getX(localIndex) + vertexOffset;
}
} else {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] = localIndex + vertexOffset;
}
}
vertexOffset += geometryInfo.vertexCount;
indexOffset += geometryInfo.indexCount;
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
if (uvs !== null) {
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
}
merged.setIndex(new THREE.BufferAttribute(indices, 1));
return merged;
}
const DRACO_DECODER_PATH =
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
const EMPTY_TEXTURE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
geometry.computeBoundingBox();
let boundingBox = geometry.boundingBox;
let center = new THREE.Vector3();
let size = new THREE.Vector3();
boundingBox?.getCenter(center);
boundingBox?.getSize(size);
geometry.translate(-center.x, -center.y, -center.z);
const dimensions = [size.x, size.y, size.z];
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
if (thinnestAxis === 0) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
} else if (thinnestAxis === 1) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const radius = geometry.boundingSphere?.radius || 1;
const scale = 1.6 / radius;
geometry.scale(scale, scale, scale);
geometry.computeBoundingBox();
boundingBox = geometry.boundingBox;
center = new THREE.Vector3();
boundingBox?.getCenter(center);
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
function createLoadingManager() {
const loadingManager = new THREE.LoadingManager();
loadingManager.setURLModifier((url) =>
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
);
return loadingManager;
}
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
root.updateMatrixWorld(true);
const geometries: THREE.BufferGeometry[] = [];
root.traverse((object) => {
if (!(object instanceof THREE.Mesh) || !object.geometry) {
return;
}
const geometry = object.geometry.clone();
if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
}
geometry.applyMatrix4(object.matrixWorld);
geometries.push(geometry);
});
if (geometries.length === 0) {
throw new Error(emptyMessage);
}
return normalizeImportedGeometry(mergeGeometries(geometries));
}
function parseFbxGeometry(
buffer: ArrayBuffer,
resourcePath: string,
label: string,
) {
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
return;
}
originalWarn(...args);
};
try {
const root = new FBXLoader(createLoadingManager()).parse(
buffer,
resourcePath,
);
return extractMergedGeometry(
root,
`${label} did not contain any mesh geometry.`,
);
} finally {
console.warn = originalWarn;
}
}
function parseGlbGeometry(
buffer: ArrayBuffer,
resourcePath: string,
label: string,
) {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
const gltfLoader = new GLTFLoader(createLoadingManager());
gltfLoader.setDRACOLoader(dracoLoader);
return new Promise<THREE.BufferGeometry>((resolve, reject) => {
gltfLoader.parse(
buffer,
resourcePath,
(gltf) => {
try {
resolve(
extractMergedGeometry(
gltf.scene,
`${label} did not contain any mesh geometry.`,
),
);
} catch (error) {
reject(error);
}
},
reject,
);
}).finally(() => {
dracoLoader.dispose();
});
}
async function loadImportedGeometry(
loader: HalftoneModelLoader,
file: File,
label: string,
) {
const buffer = await file.arrayBuffer();
if (loader === 'fbx') {
return parseFbxGeometry(buffer, '', label);
}
return parseGlbGeometry(buffer, '', label);
}
const GLB_URL = '/illustrations/home/testimonials/hourglass.glb';
const HOURGLASS_PREVIEW_DISTANCE = 4;
const scanlineVertexShader = /* glsl */ `
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const scanlineFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform float uStripeScale;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.06);
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
float shadowWeight = mix(1.0, 0.5, ndotl);
float lineWidth = 0.58 * shadowWeight;
float edge = 0.035;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
float highlight = pow(ndotl, 1.35);
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
float dashMask = mix(
1.0,
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
highlight
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
if (band < 0.015) {
discard;
}
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
gl_FragColor = vec4(lit, band);
}
`;
function createScanlineMaterial(lightDirection: THREE.Vector3) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 16.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
const HOURGLASS_SETTINGS: HalftoneStudioSettings = {
sourceMode: 'shape',
shapeKey: 'hourglass',
lighting: {
intensity: 3.4,
fillIntensity: 1.5,
ambientIntensity: 0.06,
angleDegrees: 260,
height: 0.8,
},
material: {
roughness: 0.59,
metalness: 0.02,
},
halftone: {
enabled: true,
numRows: 150,
contrast: 1.2,
power: 1.1,
shading: 3,
baseInk: 0.19,
maxBar: 0.35,
rowMerge: 0.08,
cellRatio: 2.2,
cutoff: 0.02,
highlightOpen: 0.04,
shadowGrouping: 0.18,
shadowCrush: 0.14,
dashColor: '#4A38F5',
},
background: {
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
type MeshRestPose = {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
wobblePhase: number;
color: 'transparent',
},
animation: {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
floatEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0.05,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.003,
dragFriction: 0.02,
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.7,
lightSweepRange: 29,
lightSweepSpeed: 0.2,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
},
};
function applyScanlineMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const HOURGLASS_INITIAL_POSE: HalftoneExportPose = {
autoElapsed: 51.68333333333168,
rotateElapsed: 0,
rotationX: 3.5421542652497933,
rotationY: 10.105974316283143,
rotationZ: 0,
targetRotationX: 3.575782604433917,
targetRotationY: 5.019307649616612,
timeElapsed: 411.9648000000736,
};
sceneObject.material = createScanlineMaterial(lightDirection);
const mesh = sceneObject;
const rest: MeshRestPose = {
position: mesh.position.clone(),
quaternion: mesh.quaternion.clone(),
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
};
mesh.userData.hourglassVisualRest = rest;
});
}
const NO_OP = () => {};
const VisualFrame = styled.div`
background-color: transparent;
@ -139,218 +489,61 @@ const VisualFrame = styled.div`
}
`;
const CanvasMount = styled.div`
display: block;
height: 100%;
inset: 0;
min-width: 0;
position: absolute;
width: 100%;
`;
export function Hourglass() {
const mountReference = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
useEffect(() => {
let cancelled = false;
let animationFrameId = 0;
let loadedGeometry: THREE.BufferGeometry | null = null;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
void fetch(GLB_URL)
.then(async (response) => {
if (!response.ok) {
throw new Error('Could not load the hourglass model.');
}
const scene = new THREE.Scene();
const width = container.clientWidth;
const height = container.clientHeight;
const blob = await response.blob();
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.cursor = 'pointer';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const clock = new THREE.Clock();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load(
GLB_URL,
(gltf) => {
return new File([blob], 'hourglass.glb', {
type: blob.type || 'model/gltf-binary',
});
})
.then((file) => loadImportedGeometry('glb', file, 'hourglass.glb'))
.then((nextGeometry) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
nextGeometry.dispose();
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 2.85 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld);
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * influence;
targetRotation.x = pointer.y * 0.62 * influence;
pivot.rotation.y = THREE.MathUtils.damp(
pivot.rotation.y,
targetRotation.y,
rotationDamp,
delta,
);
pivot.rotation.x = THREE.MathUtils.damp(
pivot.rotation.x,
targetRotation.x,
rotationDamp,
delta,
);
const hoverLift = pointer.inside ? 1 : 0;
pivot.scale.setScalar(
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta),
);
const mx = pointer.x * (pointer.inside ? 1 : 0.32);
const my = pointer.y * (pointer.inside ? 1 : 0.32);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.hourglassVisualRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
const wobble = pointer.inside ? 1 : 0.36;
sceneObject.position.x =
rest.position.x + mx * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5);
const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
renderer.render(scene, camera);
};
renderFrame();
},
undefined,
undefined,
);
const setPointerFromEvent = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
};
const handlePointerEnter = () => {
pointer.inside = true;
};
const handlePointerLeave = () => {
pointer.inside = false;
pointer.x = 0;
pointer.y = 0;
};
const handlePointerMove = (event: PointerEvent) => {
setPointerFromEvent(event);
};
canvas.addEventListener('pointerenter', handlePointerEnter);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointermove', handlePointerMove);
const handleResize = () => {
if (!mountReference.current || cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
if (nextWidth < 1 || nextHeight < 1) {
return;
}
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
window.addEventListener('resize', handleResize);
loadedGeometry = nextGeometry;
setGeometry(nextGeometry);
})
.catch((error) => {
console.error(error);
});
return () => {
cancelled = true;
window.removeEventListener('resize', handleResize);
canvas.removeEventListener('pointerenter', handlePointerEnter);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
if (loadedGeometry) {
loadedGeometry.dispose();
}
};
}, []);
return (
<VisualFrame>
<CanvasMount aria-hidden ref={mountReference} />
{geometry ? (
<HalftoneCanvas
geometry={geometry}
imageElement={null}
initialPose={HOURGLASS_INITIAL_POSE}
onFirstInteraction={NO_OP}
onPoseChange={NO_OP}
previewDistance={HOURGLASS_PREVIEW_DISTANCE}
settings={HOURGLASS_SETTINGS}
/>
) : null}
</VisualFrame>
);
}

View file

@ -0,0 +1,753 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const DRACO_DECODER_PATH =
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
const GLB_URL = '/illustrations/home/three-cards/sun.glb';
const VIRTUAL_RENDER_HEIGHT = 768;
const LIGHTING = {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
};
const MATERIAL = {
roughness: 0.42,
metalness: 0.16,
};
const HALFTONE = {
numRows: 99,
contrast: 1.3,
power: 1.1,
shading: 1.6,
baseInk: 0.16,
maxBar: 0.24,
cellRatio: 2.2,
cutoff: 0.02,
dashColor: '#4A38F5',
};
const AUTO_ROTATE = {
speed: 0.3,
wobble: 0.3,
};
// Start from the same hero angle used in the exported homepage asset.
const INITIAL_POSE = {
autoElapsed: 4,
rotationX: 0.215,
rotationY: 1.2,
rotationZ: 0,
targetRotationX: 0,
targetRotationY: 0,
};
const passThroughVertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const blurFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
sum += texture2D(tInput, vUv) * w[0];
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
gl_FragColor = sum;
}
`;
const halftoneFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tScene;
uniform sampler2D tGlow;
uniform vec2 resolution;
uniform float numRows;
uniform float glowStr;
uniform float contrast;
uniform float power;
uniform float shading;
uniform float baseInk;
uniform float maxBar;
uniform float cellRatio;
uniform float cutoff;
uniform vec3 dashColor;
uniform float time;
varying vec2 vUv;
void main() {
float rowH = resolution.y / numRows;
float row = floor(gl_FragCoord.y / rowH);
float rowFrac = gl_FragCoord.y / rowH - row;
float rowV = (row + 0.5) * rowH / resolution.y;
float dy = abs(rowFrac - 0.5);
float cellW = rowH * cellRatio;
float cellIdx = floor(gl_FragCoord.x / cellW);
float cellFrac = (gl_FragCoord.x - cellIdx * cellW) / cellW;
float cellU = (cellIdx + 0.5) * cellW / resolution.x;
vec2 sampleUv = vec2(
clamp(cellU, 0.0, 1.0),
clamp(rowV, 0.0, 1.0)
);
vec4 sceneSample = texture2D(tScene, sampleUv);
vec4 glowCell = texture2D(tGlow, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
float detail = lum - avgLum;
float litLum = lum + max(detail, 0.0) * shading
- max(-detail, 0.0) * shading * 0.55;
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
litLum = pow(litLum, contrast);
float ink = mix(baseInk, 1.0, 1.0 - litLum);
float fill = pow(ink, 1.05) * power;
fill = clamp(fill, 0.0, 1.0) * mask;
float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
float dx2 = abs(cellFrac - 0.5);
float halfFill = fill * 0.5;
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
float capR = dynamicBarHalf * rowH;
float inDash = 0.0;
if (dx2 <= bodyHalfW) {
float edgeDist = dynamicBarHalf - dy;
inDash = smoothstep(-0.03, 0.03, edgeDist);
} else {
float cdx = (dx2 - bodyHalfW) * cellW;
float cdy = dy * rowH;
float d = sqrt(cdx * cdx + cdy * cdy);
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
}
inDash *= step(0.001, ink) * mask;
inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
vec4 glow = texture2D(tGlow, vUv);
float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
float sharp = smoothstep(0.3, 0.5, inDash + halo);
vec3 color = dashColor * sharp;
gl_FragColor = vec4(color, sharp);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const EMPTY_TEXTURE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
function createLoadingManager() {
const loadingManager = new THREE.LoadingManager();
loadingManager.setURLModifier((url) =>
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
);
return loadingManager;
}
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
if (geometries.length === 1) {
return geometries[0];
}
let totalVertices = 0;
let totalIndices = 0;
let hasUv = false;
const geometryInfos = geometries.map((geometry) => {
const position = geometry.attributes.position as THREE.BufferAttribute;
const normal = geometry.attributes.normal as THREE.BufferAttribute;
const uv = (geometry.attributes.uv as THREE.BufferAttribute) ?? null;
const index = geometry.index;
const indexCount = index ? index.count : position.count;
totalVertices += position.count;
totalIndices += indexCount;
hasUv = hasUv || uv !== null;
return {
index,
indexCount,
normal,
position,
uv,
vertexCount: position.count,
};
});
const positions = new Float32Array(totalVertices * 3);
const normals = new Float32Array(totalVertices * 3);
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
const indices = new Uint32Array(totalIndices);
let vertexOffset = 0;
let indexOffset = 0;
for (const geometryInfo of geometryInfos) {
for (
let vertexIndex = 0;
vertexIndex < geometryInfo.vertexCount;
vertexIndex += 1
) {
const positionOffset = (vertexOffset + vertexIndex) * 3;
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
if (uvs !== null) {
const uvOffset = (vertexOffset + vertexIndex) * 2;
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
}
}
if (geometryInfo.index) {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] =
geometryInfo.index.getX(localIndex) + vertexOffset;
}
} else {
for (
let localIndex = 0;
localIndex < geometryInfo.indexCount;
localIndex += 1
) {
indices[indexOffset + localIndex] = localIndex + vertexOffset;
}
}
vertexOffset += geometryInfo.vertexCount;
indexOffset += geometryInfo.indexCount;
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
if (uvs !== null) {
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
}
merged.setIndex(new THREE.BufferAttribute(indices, 1));
return merged;
}
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
geometry.computeBoundingBox();
let boundingBox = geometry.boundingBox;
let center = new THREE.Vector3();
let size = new THREE.Vector3();
boundingBox?.getCenter(center);
boundingBox?.getSize(size);
geometry.translate(-center.x, -center.y, -center.z);
const dimensions = [size.x, size.y, size.z];
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
if (thinnestAxis === 0) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
} else if (thinnestAxis === 1) {
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
const radius = geometry.boundingSphere?.radius || 1;
const scale = 1.6 / radius;
geometry.scale(scale, scale, scale);
geometry.computeBoundingBox();
boundingBox = geometry.boundingBox;
center = new THREE.Vector3();
boundingBox?.getCenter(center);
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
function extractMergedGeometry(root: THREE.Object3D) {
root.updateMatrixWorld(true);
const geometries: THREE.BufferGeometry[] = [];
root.traverse((object) => {
if (!(object instanceof THREE.Mesh) || !object.geometry) {
return;
}
const geometry = object.geometry.clone();
if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
}
geometry.applyMatrix4(object.matrixWorld);
geometries.push(geometry);
});
if (geometries.length === 0) {
throw new Error('Model did not contain any mesh geometry.');
}
return normalizeImportedGeometry(mergeGeometries(geometries));
}
function parseGlbGeometry(buffer: ArrayBuffer): Promise<THREE.BufferGeometry> {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
const gltfLoader = new GLTFLoader(createLoadingManager());
gltfLoader.setDRACOLoader(dracoLoader);
return new Promise<THREE.BufferGeometry>((resolve, reject) => {
gltfLoader.parse(
buffer,
'',
(gltf) => {
try {
resolve(extractMergedGeometry(gltf.scene));
} catch (error) {
reject(error);
}
},
reject,
);
}).finally(() => {
dracoLoader.dispose();
});
}
async function loadGeometry(modelUrl: string) {
const response = await fetch(modelUrl);
if (!response.ok) {
throw new Error('Unable to load model from ' + modelUrl);
}
const buffer = await response.arrayBuffer();
return parseGlbGeometry(buffer);
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
type InteractionState = {
autoElapsed: number;
dragging: boolean;
mouseX: number;
mouseY: number;
pointerX: number;
pointerY: number;
rotationX: number;
rotationY: number;
rotationZ: number;
targetRotationX: number;
targetRotationY: number;
velocityX: number;
velocityY: number;
};
function createInteractionState(): InteractionState {
return {
autoElapsed: INITIAL_POSE.autoElapsed,
dragging: false,
mouseX: 0.5,
mouseY: 0.5,
pointerX: 0,
pointerY: 0,
rotationX: INITIAL_POSE.rotationX,
rotationY: INITIAL_POSE.rotationY,
rotationZ: INITIAL_POSE.rotationZ,
targetRotationX: INITIAL_POSE.targetRotationX,
targetRotationY: INITIAL_POSE.targetRotationY,
velocityX: 0,
velocityY: 0,
};
}
async function mountSunCanvas(container: HTMLDivElement) {
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(
getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)),
),
1,
);
let geometry: THREE.BufferGeometry;
try {
geometry = await loadGeometry(GLB_URL);
} catch (error) {
console.error(error);
geometry = new THREE.TorusKnotGeometry(1, 0.35, 200, 32);
}
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = 'grab';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTexture = pmremGenerator.fromScene(
new RoomEnvironment(),
0.04,
).texture;
pmremGenerator.dispose();
const scene3d = new THREE.Scene();
scene3d.background = null;
const camera = new THREE.PerspectiveCamera(
45,
getWidth() / getHeight(),
0.1,
100,
);
camera.position.z = 4;
const lightAngle = (LIGHTING.angleDegrees * Math.PI) / 180;
const primaryLight = new THREE.DirectionalLight(
0xffffff,
LIGHTING.intensity,
);
primaryLight.position.set(
Math.cos(lightAngle) * 5,
LIGHTING.height,
Math.sin(lightAngle) * 5,
);
scene3d.add(primaryLight);
const fillLight = new THREE.DirectionalLight(
0xffffff,
LIGHTING.fillIntensity,
);
fillLight.position.set(-3, -1, 1);
scene3d.add(fillLight);
const ambientLight = new THREE.AmbientLight(
0xffffff,
LIGHTING.ambientIntensity,
);
scene3d.add(ambientLight);
const material = new THREE.MeshPhysicalMaterial({
color: 0xd4d0c8,
roughness: MATERIAL.roughness,
metalness: MATERIAL.metalness,
envMap: environmentTexture,
envMapIntensity: 0.25,
clearcoat: 0,
clearcoatRoughness: 0.08,
reflectivity: 0.5,
transmission: 0,
});
const mesh = new THREE.Mesh(geometry, material);
scene3d.add(mesh);
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(1, 0) },
res: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(0, 1) },
res: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
transparent: true,
uniforms: {
tScene: { value: sceneTarget.texture },
tGlow: { value: blurTargetB.texture },
resolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
numRows: { value: HALFTONE.numRows },
glowStr: { value: 0 },
contrast: { value: HALFTONE.contrast },
power: { value: HALFTONE.power },
shading: { value: HALFTONE.shading },
baseInk: { value: HALFTONE.baseInk },
maxBar: { value: HALFTONE.maxBar },
cellRatio: { value: HALFTONE.cellRatio },
cutoff: { value: HALFTONE.cutoff },
dashColor: { value: new THREE.Color(HALFTONE.dashColor) },
time: { value: 0 },
},
vertexShader: passThroughVertexShader,
fragmentShader: halftoneFragmentShader,
});
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
);
const postScene = new THREE.Scene();
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
const interaction = createInteractionState();
const syncSize = () => {
const width = getWidth();
const height = getHeight();
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
blurHorizontalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
halftoneMaterial.uniforms.resolution.value.set(
virtualWidth,
virtualHeight,
);
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const handlePointerDown = (event: PointerEvent) => {
interaction.dragging = true;
interaction.pointerX = event.clientX;
interaction.pointerY = event.clientY;
interaction.velocityX = 0;
interaction.velocityY = 0;
canvas.style.cursor = 'grabbing';
};
const handlePointerMove = (event: PointerEvent) => {
interaction.mouseX = event.clientX / window.innerWidth;
interaction.mouseY = event.clientY / window.innerHeight;
};
const handlePointerUp = () => {
interaction.dragging = false;
canvas.style.cursor = 'grab';
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
canvas.addEventListener('pointerdown', handlePointerDown);
const clock = new THREE.Clock();
let animationFrameId = 0;
const renderFrame = () => {
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = 1 / 60;
const elapsedTime = clock.getElapsedTime();
halftoneMaterial.uniforms.time.value = elapsedTime;
if (!interaction.dragging) {
interaction.autoElapsed += delta;
interaction.targetRotationX += interaction.velocityX;
interaction.targetRotationY += interaction.velocityY;
interaction.velocityX *= 0.92;
interaction.velocityY *= 0.92;
}
const baseRotationY =
interaction.autoElapsed * AUTO_ROTATE.speed;
const baseRotationX =
Math.sin(interaction.autoElapsed * 0.2) * AUTO_ROTATE.wobble;
const targetX = baseRotationX + interaction.targetRotationX;
const targetY = baseRotationY + interaction.targetRotationY;
const easing = 0.08;
interaction.rotationX += (targetX - interaction.rotationX) * easing;
interaction.rotationY += (targetY - interaction.rotationY) * easing;
mesh.rotation.set(
interaction.rotationX,
interaction.rotationY,
interaction.rotationZ,
);
renderer.setRenderTarget(sceneTarget);
renderer.render(scene3d, camera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
};
renderFrame();
return () => {
window.cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
canvas.removeEventListener('pointerdown', handlePointerDown);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
fullScreenGeometry.dispose();
material.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
environmentTexture.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
export function Sun() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
const unmount = mountSunCanvas(container);
return () => {
void Promise.resolve(unmount).then((dispose) => dispose?.());
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
}

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,8 @@ import { Lock } from './ThreeCards/Lock';
import { Programming } from './ThreeCards/Programming';
import { SingleScreen } from './ThreeCards/SingleScreen';
import { Speed } from './ThreeCards/Speed';
import { Sun } from './ThreeCards/Sun';
import { Wheelx } from './ThreeCards/Wheelx';
import { Logo as WhyTwentyStepperLogo } from './WhyTwentyStepper/Logo';
export const THREE_CARDS_ILLUSTRATIONS = {
@ -34,6 +36,8 @@ export const THREE_CARDS_ILLUSTRATIONS = {
programming: Programming,
singleScreen: SingleScreen,
speed: Speed,
sun: Sun,
wheelx: Wheelx,
} as const satisfies Record<string, ComponentType>;
export type ThreeCardsIllustrationId = keyof typeof THREE_CARDS_ILLUSTRATIONS;

View file

@ -0,0 +1,695 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
const VIRTUAL_RENDER_HEIGHT = 768;
const passThroughVertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const blurFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
sum += texture2D(tInput, vUv) * w[0];
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
gl_FragColor = sum;
}
`;
const imagePassthroughFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tImage;
uniform vec2 imageSize;
uniform vec2 viewportSize;
uniform float zoom;
varying vec2 vUv;
void main() {
float imageAspect = imageSize.x / imageSize.y;
float viewAspect = viewportSize.x / viewportSize.y;
vec2 uv = vUv;
// Contain: show full image, letterbox/pillarbox as needed
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.y = (uv.y - 0.5) / scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.x = (uv.x - 0.5) / scale + 0.5;
}
uv = (uv - 0.5) / zoom + 0.5;
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
* step(0.0, uv.y) * step(uv.y, 1.0);
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
gl_FragColor = vec4(color.rgb, inBounds);
}
`;
const halftoneFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tScene;
uniform sampler2D tGlow;
uniform vec2 resolution;
uniform float numRows;
uniform float glowStr;
uniform float contrast;
uniform float power;
uniform float shading;
uniform float baseInk;
uniform float maxBar;
uniform float rowMerge;
uniform float cellRatio;
uniform float cutoff;
uniform float highlightOpen;
uniform float shadowGrouping;
uniform float shadowCrush;
uniform vec3 dashColor;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
uniform float distanceScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform vec2 dragOffset;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
uniform float hoverFlowStrength;
uniform float hoverFlowRadius;
uniform float dragFlowStrength;
uniform float dragFlowRadius;
uniform float cropToBounds;
varying vec2 vUv;
void main() {
// Crop to image bounds: discard fragments outside source image (image mode only)
if (cropToBounds > 0.5) {
vec4 boundsCheck = texture2D(tScene, vUv);
if (boundsCheck.a < 0.01) {
gl_FragColor = vec4(0.0);
return;
}
}
float baseRowH = resolution.y / (numRows * distanceScale);
vec2 pointerPx = interactionUv * resolution;
vec2 fragDelta = gl_FragCoord.xy - pointerPx;
float fragDist = length(fragDelta);
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
float velocityMagnitude = length(interactionVelocity);
vec2 motionDir = velocityMagnitude > 0.001
? interactionVelocity / velocityMagnitude
: vec2(0.0, 0.0);
float motionBias = velocityMagnitude > 0.001
? dot(-radialDir, motionDir) * 0.5 + 0.5
: 0.5;
float hoverLightMask = 0.0;
if (hoverLightStrength > 0.0) {
float lightRadiusPx = hoverLightRadius * resolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
float hoverFlowMask = 0.0;
if (hoverFlowStrength > 0.0) {
float hoverRadiusPx = hoverFlowRadius * resolution.y;
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
}
float dragFlowMask = 0.0;
if (dragFlowStrength > 0.0) {
float dragRadiusPx = dragFlowRadius * resolution.y;
dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
}
vec2 hoverDisplacement =
radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
float densityBoost =
hoverFlowStrength * hoverFlowMask * 0.22 +
dragFlowStrength * dragFlowMask * 0.16;
float rowH = baseRowH / (1.0 + densityBoost);
float offsetY = effectCoord.y;
float row = floor(offsetY / rowH);
float rowFrac = offsetY / rowH - row;
float rowV = (row + 0.5) * rowH / resolution.y;
float dy = abs(rowFrac - 0.5);
float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
float effectiveX = effectCoord.x + waveOffset;
float localCellRatio = cellRatio * (
1.0 +
hoverFlowStrength * hoverFlowMask * 0.08 +
dragFlowStrength * dragFlowMask * 0.1 * motionBias
);
float cellW = rowH * localCellRatio;
float cellIdx = floor(effectiveX / cellW);
float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
float cellU = (cellIdx + 0.5) * cellW / resolution.x;
vec2 sampleUv = vec2(
clamp(cellU, 0.0, 1.0),
clamp(rowV, 0.0, 1.0)
);
vec4 sceneSample = texture2D(tScene, sampleUv);
vec4 glowCell = texture2D(tGlow, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
float detail = lum - avgLum;
float litLum = lum + max(detail, 0.0) * shading
- max(-detail, 0.0) * shading * 0.55;
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
litLum = clamp(litLum + lightLift, 0.0, 1.0);
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
litLum = pow(litLum, max(contrast - lightFocus, 0.25));
float darkness = 1.0 - litLum;
float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
float groupedDarkness = 1.0 - groupedLum;
darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
darkness = clamp(
(darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
0.0,
1.0
);
float shadowMask = smoothstep(0.42, 0.96, darkness);
darkness = mix(
darkness,
mix(darkness, 1.0, shadowMask),
shadowCrush
);
float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
float ink = mix(inkBase, 1.0, darkness);
float fill = pow(ink, 1.05) * power;
fill = clamp(fill, 0.0, 1.0) * mask;
float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
float dynamicBarHalfY = min(
dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
0.78
);
float dx2 = abs(cellFrac - 0.5);
float halfFill = fill * 0.5;
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
float capRX = dynamicBarHalf * rowH;
float capRY = dynamicBarHalfY * rowH;
float inDash = 0.0;
if (dx2 <= bodyHalfW) {
float edgeDist = dynamicBarHalfY - dy;
inDash = smoothstep(-0.03, 0.03, edgeDist);
} else {
float cdx = (dx2 - bodyHalfW) * cellW;
float cdy = dy * rowH;
float ellipseDist = sqrt(
(cdx * cdx) / max(capRX * capRX, 0.0001) +
(cdy * cdy) / max(capRY * capRY, 0.0001)
);
inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
}
inDash *= step(0.001, ink) * mask;
inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
vec4 glow = texture2D(tGlow, vUv);
float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
float sharp = smoothstep(0.3, 0.5, inDash + halo);
vec3 color = dashColor * sharp;
gl_FragColor = vec4(color, sharp);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const IMAGE_SRC = '/images/home/problem/arthur-roblet.png';
const IMAGE_ZOOM = 1;
const NUM_ROWS = 45;
const CONTRAST = 1.3;
const POWER = 1.1;
const SHADING = 1.6;
const BASE_INK = 0.16;
const MAX_BAR = 0.24;
const CELL_RATIO = 2.2;
const CUTOFF = 0.02;
const DASH_COLOR = '#4A38F5';
const HOVER_EASE = 0.08;
const DRAG_SENS = 0.008;
const DRAG_FRICTION = 0.08;
const HOVER_WARP_STRENGTH = 3;
const HOVER_WARP_RADIUS = 0.15;
const DRAG_WARP_STRENGTH = 5;
const FOLLOW_HOVER_ENABLED = true;
const FOLLOW_DRAG_ENABLED = false;
type InteractionState = {
dragging: boolean;
mouseX: number;
mouseY: number;
pointerX: number;
pointerY: number;
rotationX: number;
rotationY: number;
targetRotationX: number;
targetRotationY: number;
velocityX: number;
velocityY: number;
};
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
function createInteractionState(): InteractionState {
return {
dragging: false,
mouseX: 0.5,
mouseY: 0.5,
pointerX: 0,
pointerY: 0,
rotationX: 0,
rotationY: 0,
targetRotationX: 0,
targetRotationY: 0,
velocityX: 0,
velocityY: 0,
};
}
async function loadImage(imageUrl: string) {
return await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () =>
reject(new Error(`Failed to load image: ${imageUrl}`));
image.src = imageUrl;
});
}
async function mountHalftoneCanvas(
container: HTMLDivElement,
imageUrl: string,
) {
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
1,
);
const image = await loadImage(imageUrl);
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.needsUpdate = true;
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const imageMaterial = new THREE.ShaderMaterial({
uniforms: {
tImage: { value: imageTexture },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: IMAGE_ZOOM },
},
vertexShader: passThroughVertexShader,
fragmentShader: imagePassthroughFragmentShader,
});
const imageScene = new THREE.Scene();
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(1, 0) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
tInput: { value: null },
dir: { value: new THREE.Vector2(0, 1) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
transparent: true,
uniforms: {
tScene: { value: sceneTarget.texture },
tGlow: { value: blurTargetB.texture },
resolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
numRows: { value: NUM_ROWS },
glowStr: { value: 0 },
contrast: { value: CONTRAST },
power: { value: POWER },
shading: { value: SHADING },
baseInk: { value: BASE_INK },
maxBar: { value: MAX_BAR },
cellRatio: { value: CELL_RATIO },
cutoff: { value: CUTOFF },
dashColor: { value: new THREE.Color(DASH_COLOR) },
time: { value: 0 },
waveAmount: { value: 0 },
waveSpeed: { value: 1 },
distanceScale: { value: 1 },
mouseUv: { value: new THREE.Vector2(-1, -1) },
warpStrength: { value: 0 },
warpRadius: { value: HOVER_WARP_RADIUS },
cropToBounds: { value: 1 },
},
vertexShader: passThroughVertexShader,
fragmentShader: halftoneFragmentShader,
});
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
);
const postScene = new THREE.Scene();
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
const interaction = createInteractionState();
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
blurHorizontalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
halftoneMaterial.uniforms.resolution.value.set(virtualWidth, virtualHeight);
imageMaterial.uniforms.viewportSize.value.set(virtualWidth, virtualHeight);
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const updatePointerPosition = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
interaction.mouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
interaction.mouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
};
const handlePointerDown = (event: PointerEvent) => {
updatePointerPosition(event);
interaction.dragging = true;
interaction.pointerX = event.clientX;
interaction.pointerY = event.clientY;
interaction.velocityX = 0;
interaction.velocityY = 0;
};
const handlePointerMove = (event: PointerEvent) => {
updatePointerPosition(event);
};
const handleWindowPointerMove = (event: PointerEvent) => {
updatePointerPosition(event);
if (!interaction.dragging || !FOLLOW_DRAG_ENABLED) {
return;
}
const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENS;
const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENS;
interaction.velocityX = deltaY;
interaction.velocityY = deltaX;
interaction.targetRotationY += deltaX;
interaction.targetRotationX += deltaY;
interaction.pointerX = event.clientX;
interaction.pointerY = event.clientY;
};
const handlePointerLeave = () => {
if (interaction.dragging) {
return;
}
interaction.mouseX = 0.5;
interaction.mouseY = 0.5;
};
const handlePointerUp = () => {
interaction.dragging = false;
};
const handleWindowBlur = () => {
handlePointerUp();
handlePointerLeave();
};
canvas.addEventListener('pointermove', handlePointerMove);
canvas.addEventListener('pointerleave', handlePointerLeave);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointermove', handleWindowPointerMove);
window.addEventListener('blur', handleWindowBlur);
canvas.addEventListener('pointerdown', handlePointerDown);
const clock = new THREE.Clock();
let animationFrameId = 0;
const renderFrame = () => {
animationFrameId = window.requestAnimationFrame(renderFrame);
halftoneMaterial.uniforms.time.value = clock.getElapsedTime();
let warpActive = false;
let currentWarpStrength = 0;
if (FOLLOW_HOVER_ENABLED && !interaction.dragging) {
warpActive = true;
currentWarpStrength = HOVER_WARP_STRENGTH;
}
if (FOLLOW_DRAG_ENABLED && interaction.dragging) {
warpActive = true;
currentWarpStrength = DRAG_WARP_STRENGTH;
}
if (FOLLOW_DRAG_ENABLED && !interaction.dragging) {
interaction.targetRotationX *= 1 - DRAG_FRICTION;
interaction.targetRotationY *= 1 - DRAG_FRICTION;
const dragRemaining =
Math.abs(interaction.targetRotationX) +
Math.abs(interaction.targetRotationY);
if (dragRemaining > 0.001) {
warpActive = true;
currentWarpStrength =
DRAG_WARP_STRENGTH * Math.min(dragRemaining * 20, 1);
}
}
interaction.rotationX +=
(interaction.mouseX - interaction.rotationX) * HOVER_EASE;
interaction.rotationY +=
(interaction.mouseY - interaction.rotationY) * HOVER_EASE;
if (warpActive) {
halftoneMaterial.uniforms.mouseUv.value.set(
interaction.rotationX,
interaction.rotationY,
);
halftoneMaterial.uniforms.warpStrength.value = currentWarpStrength;
} else {
halftoneMaterial.uniforms.warpStrength.value = 0;
}
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
};
renderFrame();
return () => {
window.cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
canvas.removeEventListener('pointermove', handlePointerMove);
canvas.removeEventListener('pointerleave', handlePointerLeave);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointermove', handleWindowPointerMove);
window.removeEventListener('blur', handleWindowBlur);
canvas.removeEventListener('pointerdown', handlePointerDown);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
fullScreenGeometry.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}
export default function LolOverlay() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
const unmountPromise = mountHalftoneCanvas(container, IMAGE_SRC).catch(
(error) => {
console.error(error);
return undefined;
},
);
return () => {
void unmountPromise.then((dispose) => dispose?.());
};
}, []);
return (
<div
ref={mountReference}
style={{
background: 'transparent',
height: '100%',
width: '100%',
}}
/>
);
}

View file

@ -1,5 +1,6 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import Monolith from './monolith';
const DESKTOP_PATH =
'M672 395.498V701a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h664a4 4 0 0 1 4 4v65.155c0 2.363-.837 4.65-2.361 6.454l-23.603 27.936a10 10 0 0 0-2.362 6.453v242.614c0 2.245.756 4.424 2.145 6.188l24.036 30.51a10 10 0 0 1 2.145 6.188';
@ -21,34 +22,39 @@ const StyledVisual = styled.div`
`;
const StyledMasked = styled.div`
background-color: ${theme.colors.secondary.background[100]};
background-color: ${theme.colors.primary.background[100]};
height: 100%;
isolation: isolate;
-webkit-mask-image: ${mobileMask};
-webkit-mask-position: center;
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-image: ${mobileMask};
mask-position: center;
mask-repeat: no-repeat;
mask-size: 100% 100%;
overflow: hidden;
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
-webkit-mask-image: ${desktopMask};
mask-image: ${desktopMask};
}
`;
const StyledImage = styled.img`
display: block;
height: 100%;
object-fit: cover;
width: 100%;
const StyledHalftoneLayer = styled.div`
inset: 0;
position: absolute;
`;
export function Visual() {
return (
<StyledVisual>
<StyledMasked>
<StyledImage
src="/images/home/problem/problem-visual.svg"
alt="Abstract black-and-white image of a tall pillar with mountains in the background."
aria-hidden="true"
/>
<StyledHalftoneLayer>
<Monolith />
</StyledHalftoneLayer>
</StyledMasked>
</StyledVisual>
);

View file

@ -0,0 +1,310 @@
'use client';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { useEffect, useRef } from 'react';
const IMAGE_SRC = '/images/home/problem/monolith2.jpg';
const PATTERN_SRC = '/images/home/problem/halftone-pattern.png';
const BASE_SCALE = 1.08;
const HOVER_SCALE = 1.12;
const PATTERN_SCALE = 1.15;
const EASE = 0.12;
const BASE_RANGE_X = 12;
const BASE_RANGE_Y = 10;
const PATTERN_RANGE_X = 18;
const PATTERN_RANGE_Y = 14;
const PATTERN_IDLE_OPACITY = 0.52;
const PATTERN_ACTIVE_OPACITY = 0.72;
type MotionState = {
baseOffsetX: number;
baseOffsetY: number;
baseScale: number;
glowOpacity: number;
glowX: number;
glowY: number;
patternOffsetX: number;
patternOffsetY: number;
patternOpacity: number;
patternScale: number;
};
const StyledRoot = styled.div`
--base-offset-x: 0px;
--base-offset-y: 0px;
--base-scale: ${BASE_SCALE};
--pattern-offset-x: 0px;
--pattern-offset-y: 0px;
--pattern-opacity: ${PATTERN_IDLE_OPACITY};
--pattern-scale: ${PATTERN_SCALE};
--glow-opacity: 0;
--glow-x: 50%;
--glow-y: 38%;
background:
radial-gradient(
circle at 50% 22%,
rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0) 38%
),
linear-gradient(
180deg,
rgba(24, 10, 14, 0.04) 0%,
rgba(24, 10, 14, 0.02) 42%,
rgba(24, 10, 14, 0.12) 100%
);
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`;
const StyledBaseLayer = styled.div`
inset: -8%;
position: absolute;
transform: translate3d(var(--base-offset-x), var(--base-offset-y), 0)
scale(var(--base-scale));
transform-origin: center center;
will-change: transform;
`;
const StyledPatternLayer = styled.div`
inset: -8%;
opacity: var(--pattern-opacity);
pointer-events: none;
position: absolute;
transform: translate3d(var(--pattern-offset-x), var(--pattern-offset-y), 0)
scale(var(--pattern-scale));
transform-origin: center center;
will-change: transform, opacity;
`;
const StyledGlow = styled.div`
background: radial-gradient(
circle at var(--glow-x) var(--glow-y),
rgba(255, 255, 255, 0.26) 0%,
rgba(255, 255, 255, 0.08) 16%,
rgba(255, 255, 255, 0) 38%
);
inset: -16%;
mix-blend-mode: screen;
opacity: var(--glow-opacity);
pointer-events: none;
position: absolute;
will-change: opacity;
`;
const StyledShade = styled.div`
background:
linear-gradient(
180deg,
rgba(18, 7, 10, 0.02) 0%,
rgba(18, 7, 10, 0.02) 30%,
rgba(18, 7, 10, 0.16) 100%
),
linear-gradient(
90deg,
rgba(18, 7, 10, 0.1) 0%,
rgba(18, 7, 10, 0) 18%,
rgba(18, 7, 10, 0) 82%,
rgba(18, 7, 10, 0.08) 100%
);
inset: 0;
pointer-events: none;
position: absolute;
`;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function easeValue(current: number, target: number) {
return current + (target - current) * EASE;
}
function applyMotionStyles(element: HTMLDivElement, state: MotionState) {
element.style.setProperty(
'--base-offset-x',
`${state.baseOffsetX.toFixed(2)}px`,
);
element.style.setProperty(
'--base-offset-y',
`${state.baseOffsetY.toFixed(2)}px`,
);
element.style.setProperty('--base-scale', state.baseScale.toFixed(4));
element.style.setProperty(
'--pattern-offset-x',
`${state.patternOffsetX.toFixed(2)}px`,
);
element.style.setProperty(
'--pattern-offset-y',
`${state.patternOffsetY.toFixed(2)}px`,
);
element.style.setProperty('--pattern-scale', state.patternScale.toFixed(4));
element.style.setProperty(
'--pattern-opacity',
state.patternOpacity.toFixed(4),
);
element.style.setProperty('--glow-x', `${state.glowX.toFixed(2)}%`);
element.style.setProperty('--glow-y', `${state.glowY.toFixed(2)}%`);
element.style.setProperty('--glow-opacity', state.glowOpacity.toFixed(4));
}
export default function Monolith() {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = rootRef.current;
if (!element) {
return;
}
const current: MotionState = {
baseOffsetX: 0,
baseOffsetY: 0,
baseScale: BASE_SCALE,
glowOpacity: 0,
glowX: 50,
glowY: 38,
patternOffsetX: 0,
patternOffsetY: 0,
patternOpacity: PATTERN_IDLE_OPACITY,
patternScale: PATTERN_SCALE,
};
const target: MotionState = { ...current };
let frameId = 0;
const animate = () => {
current.baseOffsetX = easeValue(current.baseOffsetX, target.baseOffsetX);
current.baseOffsetY = easeValue(current.baseOffsetY, target.baseOffsetY);
current.baseScale = easeValue(current.baseScale, target.baseScale);
current.patternOffsetX = easeValue(
current.patternOffsetX,
target.patternOffsetX,
);
current.patternOffsetY = easeValue(
current.patternOffsetY,
target.patternOffsetY,
);
current.patternOpacity = easeValue(
current.patternOpacity,
target.patternOpacity,
);
current.patternScale = easeValue(
current.patternScale,
target.patternScale,
);
current.glowX = easeValue(current.glowX, target.glowX);
current.glowY = easeValue(current.glowY, target.glowY);
current.glowOpacity = easeValue(current.glowOpacity, target.glowOpacity);
applyMotionStyles(element, current);
frameId = window.requestAnimationFrame(animate);
};
const updateTargetsFromPointer = (clientX: number, clientY: number) => {
const rect = element.getBoundingClientRect();
const normalizedX = clamp(
(clientX - rect.left) / Math.max(rect.width, 1),
0,
1,
);
const normalizedY = clamp(
(clientY - rect.top) / Math.max(rect.height, 1),
0,
1,
);
target.baseOffsetX = (0.5 - normalizedX) * BASE_RANGE_X;
target.baseOffsetY = (0.5 - normalizedY) * BASE_RANGE_Y;
target.baseScale = HOVER_SCALE;
target.patternOffsetX = (0.5 - normalizedX) * PATTERN_RANGE_X;
target.patternOffsetY = (0.5 - normalizedY) * PATTERN_RANGE_Y;
target.patternOpacity = PATTERN_ACTIVE_OPACITY;
target.patternScale = PATTERN_SCALE + 0.02;
target.glowX = normalizedX * 100;
target.glowY = normalizedY * 100;
target.glowOpacity = 1;
};
const resetTargets = () => {
target.baseOffsetX = 0;
target.baseOffsetY = 0;
target.baseScale = BASE_SCALE;
target.patternOffsetX = 0;
target.patternOffsetY = 0;
target.patternOpacity = PATTERN_IDLE_OPACITY;
target.patternScale = PATTERN_SCALE;
target.glowX = 50;
target.glowY = 38;
target.glowOpacity = 0;
};
const handlePointerEnter = (event: PointerEvent) => {
updateTargetsFromPointer(event.clientX, event.clientY);
};
const handlePointerMove = (event: PointerEvent) => {
updateTargetsFromPointer(event.clientX, event.clientY);
};
const handlePointerLeave = () => {
resetTargets();
};
const handleWindowBlur = () => {
resetTargets();
};
element.addEventListener('pointerenter', handlePointerEnter);
element.addEventListener('pointermove', handlePointerMove);
element.addEventListener('pointerleave', handlePointerLeave);
window.addEventListener('blur', handleWindowBlur);
frameId = window.requestAnimationFrame(animate);
return () => {
window.cancelAnimationFrame(frameId);
element.removeEventListener('pointerenter', handlePointerEnter);
element.removeEventListener('pointermove', handlePointerMove);
element.removeEventListener('pointerleave', handlePointerLeave);
window.removeEventListener('blur', handleWindowBlur);
};
}, []);
return (
<StyledRoot ref={rootRef}>
<StyledBaseLayer>
<NextImage
alt=""
fill
priority
sizes="(min-width: 768px) 50vw, 100vw"
src={IMAGE_SRC}
style={{
objectFit: 'cover',
objectPosition: 'center center',
}}
/>
</StyledBaseLayer>
<StyledPatternLayer>
<NextImage
alt=""
aria-hidden
fill
sizes="(min-width: 768px) 50vw, 100vw"
src={PATTERN_SRC}
style={{
objectFit: 'cover',
objectPosition: 'center center',
}}
/>
</StyledPatternLayer>
<StyledGlow />
<StyledShade />
</StyledRoot>
);
}