website new fixes (#19678)

This commit is contained in:
Thomas des Francs
2026-04-14 13:28:40 +02:00
committed by GitHub
parent fb4d037b93
commit eaf54ae02f
21 changed files with 2862 additions and 1471 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -314,7 +314,7 @@ function createTablePage({
export const HERO_DATA: HeroHomeDataType = {
heading: [
{ text: 'Build', fontFamily: 'sans' },
{ text: 'Build', fontFamily: 'serif' },
{ text: ' your Enterprise CRM ', fontFamily: 'serif' },
{ text: 'at AI Speed', fontFamily: 'sans' },
],
@@ -182,7 +182,9 @@ const halftoneFragmentShader = `
float hoverHalftoneMask = 0.0;
if (hoverHalftoneActive > 0.0) {
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);
hoverHalftoneMask =
smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *
clamp(hoverHalftoneActive, 0.0, 1.0);
}
float hoverFlowMask = 0.0;
@@ -259,6 +261,8 @@ const halftoneFragmentShader = `
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
const MAX_PREVIEW_PIXEL_RATIO = 2;
const CanvasMount = styled.div<{ $background: string }>`
@@ -322,6 +326,7 @@ type InteractionState = {
activePointerId: number | null;
autoElapsed: number;
dragging: boolean;
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
@@ -366,6 +371,7 @@ function createInteractionState(
activePointerId: null,
autoElapsed: initialPose?.autoElapsed ?? 0,
dragging: false,
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
@@ -535,6 +541,7 @@ function resetInteractionState(
) {
interactionState.activePointerId = null;
interactionState.dragging = false;
interactionState.hoverStrength = 0;
interactionState.mouseX = 0.5;
interactionState.mouseY = 0.5;
interactionState.pointerInside = false;
@@ -1570,7 +1577,17 @@ export function HalftoneCanvas({
halftoneMaterial.uniforms.cropToBounds.value = isImageMode ? 1 : 0;
if (isImageMode) {
const pointerActive = interaction.pointerInside;
const hoverEasing =
1 -
Math.exp(
-delta *
(interaction.pointerInside
? IMAGE_HOVER_FADE_IN
: IMAGE_HOVER_FADE_OUT),
);
interaction.hoverStrength +=
((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) *
hoverEasing;
interaction.smoothedMouseX +=
(interaction.mouseX - interaction.smoothedMouseX) *
@@ -1591,22 +1608,23 @@ export function HalftoneCanvas({
);
halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
halftoneMaterial.uniforms.hoverHalftoneActive.value =
pointerActive && activeSettings.animation.hoverHalftoneEnabled
? 1
activeSettings.animation.hoverHalftoneEnabled
? interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
pointerActive && activeSettings.animation.hoverHalftoneEnabled
activeSettings.animation.hoverHalftoneEnabled
? activeSettings.animation.hoverHalftonePowerShift
: 0;
halftoneMaterial.uniforms.hoverHalftoneRadius.value =
activeSettings.animation.hoverHalftoneRadius;
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
pointerActive && activeSettings.animation.hoverHalftoneEnabled
activeSettings.animation.hoverHalftoneEnabled
? activeSettings.animation.hoverHalftoneWidthShift
: 0;
halftoneMaterial.uniforms.hoverLightStrength.value =
pointerActive && activeSettings.animation.hoverLightEnabled
? activeSettings.animation.hoverLightIntensity
activeSettings.animation.hoverLightEnabled
? activeSettings.animation.hoverLightIntensity *
interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverLightRadius.value =
activeSettings.animation.hoverLightRadius;
@@ -201,7 +201,9 @@ const halftoneFragmentShader = `
float hoverHalftoneMask = 0.0;
if (hoverHalftoneActive > 0.0) {
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);
hoverHalftoneMask =
smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *
clamp(hoverHalftoneActive, 0.0, 1.0);
}
float hoverFlowMask = 0.0;
@@ -2041,6 +2043,7 @@ function createInteractionState() {
autoElapsed: initialPose.autoElapsed,
activePointerId: null,
dragging: false,
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
@@ -2884,6 +2887,8 @@ async function mountHalftoneCanvas(options) {
const interaction = createInteractionState();
const imagePointerFollow = 0.38;
const imagePointerVelocityDamping = 0.82;
const imageHoverFadeIn = 18;
const imageHoverFadeOut = 7;
const syncSize = () => {
const virtualWidth = getVirtualWidth();
@@ -3022,9 +3027,18 @@ async function mountHalftoneCanvas(options) {
animationFrameId = window.requestAnimationFrame(renderFrame);
clock.update(timestamp);
const deltaSeconds = clock.getDelta();
const elapsedTime = clock.getElapsed();
halftoneMaterial.uniforms.time.value = elapsedTime;
const pointerActive = interaction.pointerInside;
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(interaction.pointerInside ? imageHoverFadeIn : imageHoverFadeOut),
);
interaction.hoverStrength +=
((interaction.pointerInside ? 1 : 0) - interaction.hoverStrength) *
hoverEasing;
interaction.smoothedMouseX +=
(interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow;
@@ -3043,20 +3057,20 @@ async function mountHalftoneCanvas(options) {
);
halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
halftoneMaterial.uniforms.hoverHalftoneActive.value =
pointerActive && settings.animation.hoverHalftoneEnabled ? 1 : 0;
settings.animation.hoverHalftoneEnabled ? interaction.hoverStrength : 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
pointerActive && settings.animation.hoverHalftoneEnabled
settings.animation.hoverHalftoneEnabled
? settings.animation.hoverHalftonePowerShift
: 0;
halftoneMaterial.uniforms.hoverHalftoneRadius.value =
settings.animation.hoverHalftoneRadius;
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
pointerActive && settings.animation.hoverHalftoneEnabled
settings.animation.hoverHalftoneEnabled
? settings.animation.hoverHalftoneWidthShift
: 0;
halftoneMaterial.uniforms.hoverLightStrength.value =
pointerActive && settings.animation.hoverLightEnabled
? settings.animation.hoverLightIntensity
settings.animation.hoverLightEnabled
? settings.animation.hoverLightIntensity * interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverLightRadius.value =
settings.animation.hoverLightRadius;
@@ -3,7 +3,7 @@ import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA = {
heading: [
{ text: 'Become a ', fontFamily: 'serif' },
{ text: 'Twenty Partner', fontFamily: 'sans' },
{ text: 'Twenty Partner', fontFamily: 'sans', newLine: true },
],
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
@@ -7,7 +7,7 @@ const PRICING_HERO_SUBTAGLINE = {
export const HERO_DATA = {
heading: [
{ text: 'Simple', fontFamily: 'serif' },
{ text: ' Pricing', fontFamily: 'sans' },
{ text: 'Pricing', fontFamily: 'sans', newLine: true },
],
body: PRICING_HERO_SUBTAGLINE,
} satisfies HeroBaseDataType;
@@ -202,7 +202,8 @@ export const SALESFORCE_DATA: SalesforceDataType = {
body: 'become a genius!',
titleBar: SALESFORCE_POPUP_TITLE,
},
rightLabel: '+$75/user per month',
rightLabel: '+$75/user per month\nSwitch to enterprise!',
sharedCostKey: 'enterprise-plan',
},
{
cost: 75,
@@ -2,10 +2,11 @@ import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA = {
heading: [
{ text: 'The CRM that moves ', fontFamily: 'serif' },
{ text: 'as fast as you do', fontFamily: 'sans' },
{ text: 'A CRM for teams', fontFamily: 'serif' },
{ text: 'that ', fontFamily: 'serif', newLine: true },
{ text: 'moves fast', fontFamily: 'sans' },
],
body: {
text: 'Modern interface. AI assistance. All the features you need, ready from day one.',
text: 'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.',
},
} satisfies HeroBaseDataType;
@@ -27,7 +27,7 @@ import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Product — Twenty',
description:
'Modern interface. AI assistance. All the features you need, ready from day one.',
'Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.',
};
export default async function ProductPage() {
@@ -1,6 +1,7 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { Fragment } from 'react';
import { HeadingType } from './types/Heading';
const headingRootClassName = css`
@@ -86,6 +87,18 @@ const StyledSpan = styled.span`
font-family: ${theme.font.family.mono};
letter-spacing: -0.04em;
}
&[data-weight='light'] {
font-weight: ${theme.font.weight.light};
}
&[data-weight='regular'] {
font-weight: ${theme.font.weight.regular};
}
&[data-weight='medium'] {
font-weight: ${theme.font.weight.medium};
}
`;
export type HeadingAs = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
@@ -116,12 +129,21 @@ export function Heading({
<Tag className={rootClassName} data-weight={weight} data-size={size}>
{Array.isArray(segments) ? (
segments.map((segment, index) => (
<StyledSpan key={index} data-family={segment.fontFamily}>
{segment.text}
</StyledSpan>
<Fragment key={index}>
{segment.newLine ? <br /> : null}
<StyledSpan
data-family={segment.fontFamily}
data-weight={segment.fontWeight}
>
{segment.text}
</StyledSpan>
</Fragment>
))
) : (
<StyledSpan data-family={segments.fontFamily}>
<StyledSpan
data-family={segments.fontFamily}
data-weight={segments.fontWeight}
>
{segments.text}
</StyledSpan>
)}
@@ -1,4 +1,6 @@
export type HeadingType = {
fontFamily: 'sans' | 'serif' | 'mono';
text: string;
}
fontFamily: 'sans' | 'serif' | 'mono';
fontWeight?: 'light' | 'regular' | 'medium';
newLine?: boolean;
text: string;
};
@@ -2,12 +2,7 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
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/product/hero/hero.glb';
import { ProductEffect } from './ProductEffect';
const StyledContainer = styled.div`
background-color: ${theme.colors.secondary.background[5]};
@@ -16,329 +11,10 @@ const StyledContainer = styled.div`
width: 100%;
`;
const StyledGlbMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
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,
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;
};
function applyScanlineMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
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.productVisualRest = rest;
});
}
export function Product() {
const containerReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
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);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
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) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
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.85 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld);
pivot.add(modelRoot);
pivot.rotation.z = 0.9;
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.productVisualRest 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 (!containerReference.current || cancelled) {
return;
}
const nextWidth = containerReference.current.clientWidth;
const nextHeight = containerReference.current.clientHeight;
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
window.addEventListener('resize', handleResize);
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);
}
};
}, []);
return (
<StyledContainer>
<StyledGlbMount ref={containerReference} />
<ProductEffect />
</StyledContainer>
);
}
File diff suppressed because one or more lines are too long
@@ -1,366 +1,9 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { PartnerThreeCard } from './PartnerThreeCard';
const GLB_URL = '/illustrations/product/three-cards/eye.glb';
const halftoneVertexShader = /* glsl */ `
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const halftoneFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform vec2 uResolution;
uniform float uNumRows;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.0);
float lum = mix(0.35, 1.0, ndotl);
float rowH = uResolution.y / uNumRows;
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
float dy = abs(rowFrac - 0.5);
float cellW = rowH * 2.2;
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
float fill = pow(lum, 0.45) * 0.95;
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
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);
}
if (inDash < 0.01) {
discard;
}
gl_FragColor = vec4(uColor, inDash);
}
`;
function createHalftoneDashMaterial(
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uResolution: { value: resolution.clone() },
uNumRows: { value: 65 },
},
vertexShader: halftoneVertexShader,
fragmentShader: halftoneFragmentShader,
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;
};
function applyHalftoneDashMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createHalftoneDashMaterial(
lightDirection,
resolution,
);
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.eyeMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
const MODEL_URL = '/illustrations/product/three-cards/eye.glb';
export function Eye() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
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: false });
renderer.setPixelRatio(1);
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
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) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
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.75 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
const canvasResolution = new THREE.Vector2(
renderer.domElement.width,
renderer.domElement.height,
);
applyHalftoneDashMaterials(
modelRoot,
lightDirectionWorld,
canvasResolution,
);
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.eyeMeshRest 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);
const rw = renderer.domElement.width;
const rh = renderer.domElement.height;
pivot.traverse((sceneObject) => {
if (
sceneObject instanceof THREE.Mesh &&
sceneObject.material instanceof THREE.ShaderMaterial &&
sceneObject.material.uniforms.uResolution
) {
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
}
});
};
window.addEventListener('resize', handleResize);
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);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
return <PartnerThreeCard modelUrl={MODEL_URL} />;
}
@@ -1412,6 +1412,7 @@ async function mountHalftoneCanvas(options) {
initialRotationX,
initialRotationY,
initialRotationZ,
meshScaleMultiplier = 1,
modelUrl,
onError,
} = options;
@@ -1768,7 +1769,7 @@ async function mountHalftoneCanvas(options) {
let baseRotationY = resolvedInitialPose.rotationY;
let baseRotationZ = resolvedInitialPose.rotationZ;
let meshOffsetY = 0;
let meshScale = 1;
let meshScale = meshScaleMultiplier;
let lightAngle = settings.lighting.angleDegrees;
let lightHeight = settings.lighting.height;
@@ -2043,6 +2044,7 @@ type PartnerThreeCardProps = {
initialRotationX?: number;
initialRotationY?: number;
initialRotationZ?: number;
meshScaleMultiplier?: number;
modelUrl: string;
style?: CSSProperties;
};
@@ -2052,6 +2054,7 @@ export function PartnerThreeCard({
initialRotationX,
initialRotationY,
initialRotationZ,
meshScaleMultiplier = 1,
modelUrl,
style,
}: PartnerThreeCardProps) {
@@ -2070,6 +2073,7 @@ export function PartnerThreeCard({
initialRotationX,
initialRotationY,
initialRotationZ,
meshScaleMultiplier,
modelUrl,
onError: (error) => {
console.error(error);
@@ -2084,6 +2088,7 @@ export function PartnerThreeCard({
initialRotationX,
initialRotationY,
initialRotationZ,
meshScaleMultiplier,
modelUrl,
]);
@@ -1,366 +1,9 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { PartnerThreeCard } from './PartnerThreeCard';
const GLB_URL = '/illustrations/product/three-cards/single-screen.glb';
const halftoneVertexShader = /* glsl */ `
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const halftoneFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform vec2 uResolution;
uniform float uNumRows;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.0);
float lum = mix(0.35, 1.0, ndotl);
float rowH = uResolution.y / uNumRows;
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
float dy = abs(rowFrac - 0.5);
float cellW = rowH * 2.2;
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
float fill = pow(lum, 0.45) * 0.95;
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
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);
}
if (inDash < 0.01) {
discard;
}
gl_FragColor = vec4(uColor, inDash);
}
`;
function createHalftoneDashMaterial(
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uResolution: { value: resolution.clone() },
uNumRows: { value: 65 },
},
vertexShader: halftoneVertexShader,
fragmentShader: halftoneFragmentShader,
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;
};
function applyHalftoneDashMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createHalftoneDashMaterial(
lightDirection,
resolution,
);
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.singleScreenMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
const MODEL_URL = '/illustrations/product/three-cards/single-screen.glb';
export function SingleScreen() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
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: false });
renderer.setPixelRatio(1);
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
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) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
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.75 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
const canvasResolution = new THREE.Vector2(
renderer.domElement.width,
renderer.domElement.height,
);
applyHalftoneDashMaterials(
modelRoot,
lightDirectionWorld,
canvasResolution,
);
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.singleScreenMeshRest 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);
const rw = renderer.domElement.width;
const rh = renderer.domElement.height;
pivot.traverse((sceneObject) => {
if (
sceneObject instanceof THREE.Mesh &&
sceneObject.material instanceof THREE.ShaderMaterial &&
sceneObject.material.uniforms.uResolution
) {
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
}
});
};
window.addEventListener('resize', handleResize);
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);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
return <PartnerThreeCard modelUrl={MODEL_URL} />;
}
@@ -1,366 +1,15 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { PartnerThreeCard } from './PartnerThreeCard';
const GLB_URL = '/illustrations/product/three-cards/speed.glb';
const halftoneVertexShader = /* glsl */ `
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const halftoneFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform vec2 uResolution;
uniform float uNumRows;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.0);
float lum = mix(0.35, 1.0, ndotl);
float rowH = uResolution.y / uNumRows;
float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH);
float dy = abs(rowFrac - 0.5);
float cellW = rowH * 2.2;
float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW;
float fill = pow(lum, 0.45) * 0.95;
float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum));
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);
}
if (inDash < 0.01) {
discard;
}
gl_FragColor = vec4(uColor, inDash);
}
`;
function createHalftoneDashMaterial(
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uResolution: { value: resolution.clone() },
uNumRows: { value: 65 },
},
vertexShader: halftoneVertexShader,
fragmentShader: halftoneFragmentShader,
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;
};
function applyHalftoneDashMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
resolution: THREE.Vector2,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createHalftoneDashMaterial(
lightDirection,
resolution,
);
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.speedMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
const MODEL_URL = '/illustrations/product/three-cards/speed.glb';
const SPEED_MESH_SCALE_MULTIPLIER = 0.731;
export function Speed() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
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: false });
renderer.setPixelRatio(1);
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
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) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
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.75 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
const canvasResolution = new THREE.Vector2(
renderer.domElement.width,
renderer.domElement.height,
);
applyHalftoneDashMaterials(
modelRoot,
lightDirectionWorld,
canvasResolution,
);
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.speedMeshRest 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);
const rw = renderer.domElement.width;
const rh = renderer.domElement.height;
pivot.traverse((sceneObject) => {
if (
sceneObject instanceof THREE.Mesh &&
sceneObject.material instanceof THREE.ShaderMaterial &&
sceneObject.material.uniforms.uResolution
) {
sceneObject.material.uniforms.uResolution.value.set(rw, rh);
}
});
};
window.addEventListener('resize', handleResize);
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);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
return (
<PartnerThreeCard
meshScaleMultiplier={SPEED_MESH_SCALE_MULTIPLIER}
modelUrl={MODEL_URL}
/>
);
}
@@ -20,6 +20,8 @@ const HALFTONE_HOVER_POWER_SHIFT = 0.9;
const HALFTONE_HOVER_WIDTH_SHIFT = -0.2;
const HALFTONE_HOVER_LIGHT_INTENSITY = 0;
const HALFTONE_HOVER_LIGHT_RADIUS = 0.2;
const HALFTONE_HOVER_FADE_IN = 18;
const HALFTONE_HOVER_FADE_OUT = 7;
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
@@ -151,7 +153,9 @@ const halftoneFragmentShader = `
float hoverHalftoneMask = 0.0;
if (hoverHalftoneActive > 0.0) {
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);
hoverHalftoneMask =
smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *
clamp(hoverHalftoneActive, 0.0, 1.0);
}
float hoverFlowMask = 0.0;
@@ -230,6 +234,7 @@ const OverlayMount = styled.div`
`;
type PointerState = {
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
@@ -565,6 +570,7 @@ async function mountHalftoneOverlay({
});
const pointer: PointerState = {
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
@@ -651,11 +657,27 @@ async function mountHalftoneOverlay({
canvas.addEventListener('pointerleave', handlePointerLeave);
let animationFrameId = 0;
let previousTimestamp = 0;
const renderFrame = (timestamp: number) => {
animationFrameId = window.requestAnimationFrame(renderFrame);
halftoneMaterial.uniforms.time.value = timestamp / 1000;
const hoverScale = getHoverScale();
const deltaSeconds =
previousTimestamp === 0
? 1 / 60
: Math.min((timestamp - previousTimestamp) / 1000, 0.1);
previousTimestamp = timestamp;
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(pointer.pointerInside
? HALFTONE_HOVER_FADE_IN
: HALFTONE_HOVER_FADE_OUT),
);
pointer.hoverStrength +=
((pointer.pointerInside ? 1 : 0) - pointer.hoverStrength) * hoverEasing;
pointer.smoothedMouseX +=
(pointer.mouseX - pointer.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
@@ -672,18 +694,15 @@ async function mountHalftoneOverlay({
pointer.pointerVelocityX * getVirtualWidth(),
-pointer.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.hoverHalftoneActive.value = pointer.pointerInside
? 1
: 0;
halftoneMaterial.uniforms.hoverHalftoneActive.value = pointer.hoverStrength;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
pointer.pointerInside ? HALFTONE_HOVER_POWER_SHIFT : 0;
HALFTONE_HOVER_POWER_SHIFT;
halftoneMaterial.uniforms.hoverHalftoneRadius.value =
HALFTONE_HOVER_RADIUS * hoverScale;
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
pointer.pointerInside ? HALFTONE_HOVER_WIDTH_SHIFT : 0;
halftoneMaterial.uniforms.hoverLightStrength.value = pointer.pointerInside
? HALFTONE_HOVER_LIGHT_INTENSITY
: 0;
HALFTONE_HOVER_WIDTH_SHIFT;
halftoneMaterial.uniforms.hoverLightStrength.value =
HALFTONE_HOVER_LIGHT_INTENSITY * pointer.hoverStrength;
halftoneMaterial.uniforms.hoverLightRadius.value =
HALFTONE_HOVER_LIGHT_RADIUS * hoverScale;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
@@ -1,7 +1,13 @@
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import type { ReactNode } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { theme } from '@/theme';
const FRAME_MASK_PATH =
'M4 0H668a4 4 0 0 1 4 4V701a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V499L28 462V215L0 178V4a4 4 0 0 1 4-4Z';
const frameMask = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 672 705' preserveAspectRatio='none'%3E%3Cpath d='${encodeURIComponent(FRAME_MASK_PATH)}' fill='black'/%3E%3C/svg%3E")`;
const FrameRoot = styled.div`
position: relative;
@@ -11,8 +17,7 @@ const FrameRoot = styled.div`
const PatternBackdrop = styled.div`
inset: 0;
opacity: 0.55;
pointer-events: none;
opacity: 1;
position: absolute;
z-index: 0;
`;
@@ -22,17 +27,34 @@ const patternImageClassName = css`
object-position: center;
`;
const MaskedBackdrop = styled.div`
background-color: ${theme.colors.primary.text[100]};
inset: 0;
isolation: isolate;
overflow: hidden;
position: absolute;
z-index: 1;
`;
const SlideArea = styled.div`
inset: 0;
position: absolute;
z-index: 1;
z-index: 2;
`;
const ShapeOverlay = styled.div`
inset: 0;
pointer-events: none;
position: absolute;
z-index: 2;
z-index: 3;
`;
const FrameBorder = styled.svg`
inset: 0;
overflow: visible;
pointer-events: none;
position: absolute;
z-index: 4;
`;
const shapeImageClassName = css`
@@ -43,38 +65,91 @@ const shapeImageClassName = css`
`;
type StepperVisualFrameProps = {
backgroundColor?: string;
backgroundSrc: string;
backgroundOverlay?: ReactNode;
borderColor?: string;
borderWidth?: number;
children?: ReactNode;
showBackgroundImage?: boolean;
showShapeOverlay?: boolean;
shapeSrc: string;
};
function getMaskStyle(): CSSProperties {
return {
WebkitMaskImage: frameMask,
WebkitMaskPosition: 'center',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskSize: '100% 100%',
maskImage: frameMask,
maskPosition: 'center',
maskRepeat: 'no-repeat',
maskSize: '100% 100%',
} as CSSProperties;
}
export function StepperVisualFrame({
backgroundColor,
backgroundSrc,
backgroundOverlay,
borderColor,
borderWidth = 1,
children,
showBackgroundImage = true,
showShapeOverlay = true,
shapeSrc,
}: StepperVisualFrameProps) {
return (
<FrameRoot>
<PatternBackdrop aria-hidden>
<NextImage
alt=""
className={patternImageClassName}
fill
sizes="(min-width: 921px) 672px, 100vw"
src={backgroundSrc}
/>
</PatternBackdrop>
<MaskedBackdrop
style={{
...getMaskStyle(),
...(backgroundColor ? { backgroundColor } : {}),
}}
>
{showBackgroundImage ? (
<PatternBackdrop aria-hidden>
<NextImage
alt=""
className={patternImageClassName}
fill
sizes="(min-width: 921px) 672px, 100vw"
src={backgroundSrc}
/>
</PatternBackdrop>
) : null}
{backgroundOverlay}
</MaskedBackdrop>
<SlideArea>{children}</SlideArea>
<ShapeOverlay aria-hidden>
<NextImage
alt=""
className={shapeImageClassName}
fill
priority={false}
sizes="(min-width: 921px) 672px, 100vw"
src={shapeSrc}
/>
</ShapeOverlay>
{showShapeOverlay ? (
<ShapeOverlay aria-hidden>
<NextImage
alt=""
className={shapeImageClassName}
fill
priority={false}
sizes="(min-width: 921px) 672px, 100vw"
src={shapeSrc}
/>
</ShapeOverlay>
) : null}
{borderColor ? (
<FrameBorder
aria-hidden
preserveAspectRatio="none"
viewBox="0 0 672 705"
>
<path
d={FRAME_MASK_PATH}
fill="none"
stroke={borderColor}
strokeLinejoin="round"
strokeWidth={borderWidth}
vectorEffect="non-scaling-stroke"
/>
</FrameBorder>
) : null}
</FrameRoot>
);
}
@@ -0,0 +1,634 @@
'use client';
import {
VIRTUAL_RENDER_HEIGHT,
getImageFootprintScale,
getImagePreviewZoom,
} from '@/app/halftone/_lib/footprint';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
const PREVIEW_DISTANCE = 4;
const HOVER_FADE_IN = 18;
const HOVER_FADE_OUT = 7;
const HALFTONE_SETTINGS = {
animation: {
hoverHalftoneEnabled: true,
hoverHalftonePowerShift: 0.62,
hoverHalftoneRadius: 0.6,
hoverHalftoneWidthShift: -0.18,
hoverLightEnabled: false,
hoverLightIntensity: 0.12,
hoverLightRadius: 0.8,
waveAmount: 2,
waveEnabled: false,
waveSpeed: 1,
},
halftone: {
dashColor: '#dddddd',
hoverDashColor: '#FFF',
imageContrast: 1.12,
minimumTone: 0.26,
power: 0.18,
scale: 12,
width: 0.72,
},
};
const passThroughVertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const imagePassthroughFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tImage;
uniform vec2 imageSize;
uniform vec2 viewportSize;
uniform float zoom;
uniform float contrast;
varying vec2 vUv;
void main() {
float imageAspect = imageSize.x / imageSize.y;
float viewAspect = viewportSize.x / viewportSize.y;
vec2 uv = vUv;
// Cover: match the underlying NextImage background crop.
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.y = (uv.y - 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));
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
gl_FragColor = vec4(contrastColor, inBounds);
}
`;
const halftoneFragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D tScene;
uniform vec2 effectResolution;
uniform vec2 logicalResolution;
uniform float tile;
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float minimumTone;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
uniform float footprintScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform vec2 dragOffset;
uniform float hoverHalftoneActive;
uniform float hoverHalftonePowerShift;
uniform float hoverHalftoneRadius;
uniform float hoverHalftoneWidthShift;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
uniform float hoverFlowStrength;
uniform float hoverFlowRadius;
uniform float dragFlowStrength;
uniform float cropToBounds;
varying vec2 vUv;
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float denom = max(dot(ba, ba), 0.000001);
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
return length(pa - ba * h);
}
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
vec2 a = vec2(0.5) + vec2(-r, 0.0);
vec2 b = vec2(0.5) + vec2(r, 0.0);
float distToSegment = distSegment(p, a, b);
float halfThickness = thickness * r;
return distToSegment - halfThickness;
}
void main() {
if (cropToBounds > 0.5) {
vec4 boundsCheck = texture2D(tScene, vUv);
if (boundsCheck.a < 0.01) {
gl_FragColor = vec4(0.0);
return;
}
}
vec2 fragCoord =
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
vec2 pointerPx = interactionUv * logicalResolution;
vec2 fragDelta = fragCoord - 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 * logicalResolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
float hoverHalftoneMask = 0.0;
if (hoverHalftoneActive > 0.0) {
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
hoverHalftoneMask =
smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist) *
clamp(hoverHalftoneActive, 0.0, 1.0);
}
float hoverFlowMask = 0.0;
if (hoverFlowStrength > 0.0) {
float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
}
vec2 hoverDisplacement =
radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;
vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;
vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;
float bandRow = floor(effectCoord.y / halftoneSize);
float waveOffset =
waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;
effectCoord.x += waveOffset;
vec2 cellIndex = floor(effectCoord / halftoneSize);
vec2 sampleUv = clamp(
(cellIndex + 0.5) * halftoneSize / logicalResolution,
vec2(0.0),
vec2(1.0)
);
vec2 cellUv = fract(effectCoord / halftoneSize);
vec4 sceneSample = texture2D(tScene, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float localPower = clamp(
s_3 + hoverHalftonePowerShift * hoverHalftoneMask,
-1.5,
1.5
);
float localWidth = clamp(
s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,
0.05,
1.4
);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float tonalAverage = (
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
localPower * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift;
float bandRadius = clamp(
max(tonalAverage, minimumTone),
0.0,
1.0
) * 1.86 * 0.5;
float alpha = 0.0;
if (bandRadius > 0.0001) {
float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth);
float edge = 0.02;
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const OverlayRoot = styled.div`
inset: 0;
pointer-events: none;
position: absolute;
z-index: 1;
`;
type PointerState = {
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
smoothedMouseX: number;
smoothedMouseY: number;
};
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
format: THREE.RGBAFormat,
magFilter: THREE.LinearFilter,
minFilter: THREE.LinearFilter,
});
}
function createPointerState(): PointerState {
return {
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
};
}
function loadImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${src}`));
image.src = src;
});
}
async function mountHalftoneCanvas({
container,
imageUrl,
}: {
container: HTMLDivElement;
imageUrl: string;
}) {
const getHeight = () => Math.max(container.clientHeight, 1);
const getWidth = () => Math.max(container.clientWidth, 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);
if (!container.isConnected) {
return;
}
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: false,
powerPreference: 'high-performance',
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(1);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.setAttribute('aria-hidden', 'true');
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'auto';
canvas.style.width = '100%';
container.appendChild(canvas);
const interactionTarget = container.parentElement?.parentElement ?? container;
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.generateMipmaps = false;
imageTexture.magFilter = THREE.LinearFilter;
imageTexture.minFilter = THREE.LinearFilter;
imageTexture.needsUpdate = true;
const sceneTarget = 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({
fragmentShader: imagePassthroughFragmentShader,
uniforms: {
contrast: { value: HALFTONE_SETTINGS.halftone.imageContrast },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
tImage: { value: imageTexture },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(PREVIEW_DISTANCE) },
},
vertexShader: passThroughVertexShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
fragmentShader: halftoneFragmentShader,
transparent: true,
uniforms: {
cropToBounds: { value: 1 },
dashColor: {
value: new THREE.Color(HALFTONE_SETTINGS.halftone.dashColor),
},
dragFlowStrength: { value: 0 },
dragOffset: { value: new THREE.Vector2(0, 0) },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
footprintScale: { value: 1 },
hoverDashColor: {
value: new THREE.Color(HALFTONE_SETTINGS.halftone.hoverDashColor),
},
hoverFlowRadius: { value: 0.18 },
hoverFlowStrength: { value: 0 },
hoverHalftoneActive: { value: 0 },
hoverHalftonePowerShift: {
value: HALFTONE_SETTINGS.animation.hoverHalftonePowerShift,
},
hoverHalftoneRadius: {
value: HALFTONE_SETTINGS.animation.hoverHalftoneRadius,
},
hoverHalftoneWidthShift: { value: 0 },
hoverLightRadius: {
value: HALFTONE_SETTINGS.animation.hoverLightRadius,
},
hoverLightStrength: { value: 0 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
minimumTone: { value: HALFTONE_SETTINGS.halftone.minimumTone },
s_3: { value: HALFTONE_SETTINGS.halftone.power },
s_4: { value: HALFTONE_SETTINGS.halftone.width },
tScene: { value: sceneTarget.texture },
tile: { value: HALFTONE_SETTINGS.halftone.scale },
time: { value: 0 },
waveAmount: {
value: HALFTONE_SETTINGS.animation.waveEnabled
? HALFTONE_SETTINGS.animation.waveAmount
: 0,
},
waveSpeed: { value: HALFTONE_SETTINGS.animation.waveSpeed },
},
vertexShader: passThroughVertexShader,
});
const imageScene = new THREE.Scene();
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
const postScene = new THREE.Scene();
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
const updateViewportUniforms = (width: number, height: number) => {
halftoneMaterial.uniforms.effectResolution.value.set(width, height);
halftoneMaterial.uniforms.logicalResolution.value.set(width, height);
imageMaterial.uniforms.viewportSize.value.set(width, height);
};
const getHalftoneScale = () =>
getImageFootprintScale({
imageHeight: image.height,
imageWidth: image.width,
previewDistance: PREVIEW_DISTANCE,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const pointerState = createPointerState();
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
updateViewportUniforms(virtualWidth, virtualHeight);
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const updatePointerPosition = (
event: PointerEvent,
options?: { resetVelocity?: boolean },
) => {
const rect = interactionTarget.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const nextMouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
const nextMouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
const deltaX = nextMouseX - pointerState.mouseX;
const deltaY = nextMouseY - pointerState.mouseY;
pointerState.mouseX = nextMouseX;
pointerState.mouseY = nextMouseY;
pointerState.pointerInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (options?.resetVelocity) {
pointerState.pointerVelocityX = 0;
pointerState.pointerVelocityY = 0;
pointerState.smoothedMouseX = nextMouseX;
pointerState.smoothedMouseY = nextMouseY;
return;
}
pointerState.pointerVelocityX = deltaX;
pointerState.pointerVelocityY = deltaY;
};
const handlePointerMove = (event: PointerEvent) => {
const shouldResetVelocity = !pointerState.pointerInside;
updatePointerPosition(
event,
shouldResetVelocity ? { resetVelocity: true } : undefined,
);
};
const handlePointerLeave = () => {
pointerState.pointerInside = false;
pointerState.pointerVelocityX = 0;
pointerState.pointerVelocityY = 0;
};
interactionTarget.addEventListener('pointerleave', handlePointerLeave);
interactionTarget.addEventListener('pointermove', handlePointerMove);
const clock = new THREE.Timer();
clock.connect(document);
let animationFrameId = 0;
const renderFrame = (timestamp?: number) => {
animationFrameId = window.requestAnimationFrame(renderFrame);
clock.update(timestamp);
const deltaSeconds = clock.getDelta();
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(pointerState.pointerInside ? HOVER_FADE_IN : HOVER_FADE_OUT),
);
pointerState.hoverStrength +=
((pointerState.pointerInside ? 1 : 0) - pointerState.hoverStrength) *
hoverEasing;
pointerState.smoothedMouseX +=
(pointerState.mouseX - pointerState.smoothedMouseX) * 0.38;
pointerState.smoothedMouseY +=
(pointerState.mouseY - pointerState.smoothedMouseY) * 0.38;
pointerState.pointerVelocityX *= 0.82;
pointerState.pointerVelocityY *= 0.82;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
halftoneMaterial.uniforms.hoverHalftoneActive.value =
HALFTONE_SETTINGS.animation.hoverHalftoneEnabled
? pointerState.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
HALFTONE_SETTINGS.animation.hoverHalftoneEnabled
? HALFTONE_SETTINGS.animation.hoverHalftonePowerShift
: 0;
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
HALFTONE_SETTINGS.animation.hoverHalftoneEnabled
? HALFTONE_SETTINGS.animation.hoverHalftoneWidthShift
: 0;
halftoneMaterial.uniforms.hoverLightStrength.value =
HALFTONE_SETTINGS.animation.hoverLightEnabled
? HALFTONE_SETTINGS.animation.hoverLightIntensity *
pointerState.hoverStrength
: 0;
halftoneMaterial.uniforms.interactionUv.value.set(
pointerState.smoothedMouseX,
1 - pointerState.smoothedMouseY,
);
halftoneMaterial.uniforms.interactionVelocity.value.set(
pointerState.pointerVelocityX * getVirtualWidth(),
-pointerState.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.time.value = clock.getElapsed();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(PREVIEW_DISTANCE);
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
};
renderFrame(0);
return () => {
window.cancelAnimationFrame(animationFrameId);
clock.dispose();
resizeObserver.disconnect();
interactionTarget.removeEventListener('pointerleave', handlePointerLeave);
interactionTarget.removeEventListener('pointermove', handlePointerMove);
fullScreenGeometry.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
renderer.dispose();
sceneTarget.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}
type StepperBackgroundHalftoneProps = {
imageUrl?: string;
};
export function StepperBackgroundHalftone({
imageUrl = '/images/home/stepper/background.webp',
}: StepperBackgroundHalftoneProps) {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
const container = mountReference.current;
if (!container) {
return;
}
let cleanup: (() => void) | undefined;
let isDisposed = false;
void mountHalftoneCanvas({ container, imageUrl })
.then((nextCleanup) => {
if (!nextCleanup) {
return;
}
if (isDisposed) {
nextCleanup();
return;
}
cleanup = nextCleanup;
})
.catch((error: unknown) => {
console.error(error);
});
return () => {
isDisposed = true;
cleanup?.();
};
}, [imageUrl]);
return <OverlayRoot aria-hidden ref={mountReference} />;
}
@@ -1,9 +1,12 @@
'use client';
import { VisibleWhenTabActive } from '@/components/VisibleWhenTabActive';
import { StepperVisualFrame } from '../StepperVisualFrame/StepperVisualFrame';
import { StepperBackgroundHalftone } from './StepperBackgroundHalftone';
import { StepperLottie } from './StepperLottie';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/background.webp';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/gears.jpg';
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.webp';
type VisualProps = {
@@ -13,7 +16,16 @@ type VisualProps = {
export function Visual({ scrollProgress }: VisualProps) {
return (
<StepperVisualFrame
backgroundColor="#F5F5F5"
backgroundSrc={HOME_STEPPER_BACKGROUND}
backgroundOverlay={
<VisibleWhenTabActive>
<StepperBackgroundHalftone imageUrl={HOME_STEPPER_BACKGROUND} />
</VisibleWhenTabActive>
}
borderColor="#DBDBDB"
showBackgroundImage={false}
showShapeOverlay={false}
shapeSrc={HOME_STEPPER_SHAPE}
>
<StepperLottie scrollProgress={scrollProgress} />