website new fixes (#19678)
This commit is contained in:
committed by
GitHub
parent
fb4d037b93
commit
eaf54ae02f
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+28
-9
@@ -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();
|
||||
|
||||
+99
-24
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+634
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user