halftone generator v1 + new 3d shapes effect start (#19539)
## Summary - refresh the website halftone studio with new rendering controls, UI updates, and export/runtime plumbing - improve halftone output quality by opening highlights, grouping shadows, and reducing visible row artifacts - update home/problem illustration assets, 3D model references, and related illustration components to use the new visuals - adjust footer and layout-related website wiring to support the updated illustration experience ## Testing - Not run (not requested) --------- Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
63c407e2f7
commit
e03ac126ab
@@ -0,0 +1,695 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
const passThroughVertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const blurFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float zoom;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Contain: show full image, letterbox/pillarbox as needed
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.y = (uv.y - 0.5) / scale + 0.5;
|
||||
} else {
|
||||
float scale = imageAspect / viewAspect;
|
||||
uv.x = (uv.x - 0.5) / scale + 0.5;
|
||||
}
|
||||
|
||||
uv = (uv - 0.5) / zoom + 0.5;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
|
||||
gl_FragColor = vec4(color.rgb, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform sampler2D tGlow;
|
||||
uniform vec2 resolution;
|
||||
uniform float numRows;
|
||||
uniform float glowStr;
|
||||
uniform float contrast;
|
||||
uniform float power;
|
||||
uniform float shading;
|
||||
uniform float baseInk;
|
||||
uniform float maxBar;
|
||||
uniform float rowMerge;
|
||||
uniform float cellRatio;
|
||||
uniform float cutoff;
|
||||
uniform float highlightOpen;
|
||||
uniform float shadowGrouping;
|
||||
uniform float shadowCrush;
|
||||
uniform vec3 dashColor;
|
||||
uniform float time;
|
||||
uniform float waveAmount;
|
||||
uniform float waveSpeed;
|
||||
uniform float distanceScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform vec2 dragOffset;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
uniform float hoverFlowStrength;
|
||||
uniform float hoverFlowRadius;
|
||||
uniform float dragFlowStrength;
|
||||
uniform float dragFlowRadius;
|
||||
uniform float cropToBounds;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
// Crop to image bounds: discard fragments outside source image (image mode only)
|
||||
if (cropToBounds > 0.5) {
|
||||
vec4 boundsCheck = texture2D(tScene, vUv);
|
||||
if (boundsCheck.a < 0.01) {
|
||||
gl_FragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
float baseRowH = resolution.y / (numRows * distanceScale);
|
||||
vec2 pointerPx = interactionUv * resolution;
|
||||
vec2 fragDelta = gl_FragCoord.xy - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * resolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float hoverFlowMask = 0.0;
|
||||
if (hoverFlowStrength > 0.0) {
|
||||
float hoverRadiusPx = hoverFlowRadius * resolution.y;
|
||||
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float dragFlowMask = 0.0;
|
||||
if (dragFlowStrength > 0.0) {
|
||||
float dragRadiusPx = dragFlowRadius * resolution.y;
|
||||
dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 hoverDisplacement =
|
||||
radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
|
||||
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
|
||||
vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
|
||||
vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
|
||||
|
||||
float densityBoost =
|
||||
hoverFlowStrength * hoverFlowMask * 0.22 +
|
||||
dragFlowStrength * dragFlowMask * 0.16;
|
||||
float rowH = baseRowH / (1.0 + densityBoost);
|
||||
|
||||
float offsetY = effectCoord.y;
|
||||
float row = floor(offsetY / rowH);
|
||||
float rowFrac = offsetY / rowH - row;
|
||||
float rowV = (row + 0.5) * rowH / resolution.y;
|
||||
float dy = abs(rowFrac - 0.5);
|
||||
|
||||
float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
|
||||
float effectiveX = effectCoord.x + waveOffset;
|
||||
|
||||
float localCellRatio = cellRatio * (
|
||||
1.0 +
|
||||
hoverFlowStrength * hoverFlowMask * 0.08 +
|
||||
dragFlowStrength * dragFlowMask * 0.1 * motionBias
|
||||
);
|
||||
float cellW = rowH * localCellRatio;
|
||||
float cellIdx = floor(effectiveX / cellW);
|
||||
float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
|
||||
float cellU = (cellIdx + 0.5) * cellW / resolution.x;
|
||||
|
||||
vec2 sampleUv = vec2(
|
||||
clamp(cellU, 0.0, 1.0),
|
||||
clamp(rowV, 0.0, 1.0)
|
||||
);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
vec4 glowCell = texture2D(tGlow, sampleUv);
|
||||
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float detail = lum - avgLum;
|
||||
|
||||
float litLum = lum + max(detail, 0.0) * shading
|
||||
- max(-detail, 0.0) * shading * 0.55;
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
|
||||
float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
|
||||
litLum = clamp(litLum + lightLift, 0.0, 1.0);
|
||||
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
|
||||
litLum = pow(litLum, max(contrast - lightFocus, 0.25));
|
||||
|
||||
float darkness = 1.0 - litLum;
|
||||
float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
|
||||
groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
|
||||
float groupedDarkness = 1.0 - groupedLum;
|
||||
darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
|
||||
darkness = clamp(
|
||||
(darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
|
||||
0.0,
|
||||
1.0
|
||||
);
|
||||
|
||||
float shadowMask = smoothstep(0.42, 0.96, darkness);
|
||||
darkness = mix(
|
||||
darkness,
|
||||
mix(darkness, 1.0, shadowMask),
|
||||
shadowCrush
|
||||
);
|
||||
|
||||
float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
|
||||
float ink = mix(inkBase, 1.0, darkness);
|
||||
float fill = pow(ink, 1.05) * power;
|
||||
fill = clamp(fill, 0.0, 1.0) * mask;
|
||||
|
||||
float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
|
||||
float dynamicBarHalfY = min(
|
||||
dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
|
||||
0.78
|
||||
);
|
||||
float dx2 = abs(cellFrac - 0.5);
|
||||
float halfFill = fill * 0.5;
|
||||
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
|
||||
float capRX = dynamicBarHalf * rowH;
|
||||
float capRY = dynamicBarHalfY * rowH;
|
||||
|
||||
float inDash = 0.0;
|
||||
if (dx2 <= bodyHalfW) {
|
||||
float edgeDist = dynamicBarHalfY - dy;
|
||||
inDash = smoothstep(-0.03, 0.03, edgeDist);
|
||||
} else {
|
||||
float cdx = (dx2 - bodyHalfW) * cellW;
|
||||
float cdy = dy * rowH;
|
||||
float ellipseDist = sqrt(
|
||||
(cdx * cdx) / max(capRX * capRX, 0.0001) +
|
||||
(cdy * cdy) / max(capRY * capRY, 0.0001)
|
||||
);
|
||||
inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
|
||||
}
|
||||
|
||||
inDash *= step(0.001, ink) * mask;
|
||||
inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
|
||||
|
||||
vec4 glow = texture2D(tGlow, vUv);
|
||||
float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
|
||||
float sharp = smoothstep(0.3, 0.5, inDash + halo);
|
||||
vec3 color = dashColor * sharp;
|
||||
|
||||
gl_FragColor = vec4(color, sharp);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const IMAGE_SRC = '/images/home/problem/arthur-roblet.png';
|
||||
|
||||
const IMAGE_ZOOM = 1;
|
||||
const NUM_ROWS = 45;
|
||||
const CONTRAST = 1.3;
|
||||
const POWER = 1.1;
|
||||
const SHADING = 1.6;
|
||||
const BASE_INK = 0.16;
|
||||
const MAX_BAR = 0.24;
|
||||
const CELL_RATIO = 2.2;
|
||||
const CUTOFF = 0.02;
|
||||
const DASH_COLOR = '#4A38F5';
|
||||
|
||||
const HOVER_EASE = 0.08;
|
||||
const DRAG_SENS = 0.008;
|
||||
const DRAG_FRICTION = 0.08;
|
||||
const HOVER_WARP_STRENGTH = 3;
|
||||
const HOVER_WARP_RADIUS = 0.15;
|
||||
const DRAG_WARP_STRENGTH = 5;
|
||||
const FOLLOW_HOVER_ENABLED = true;
|
||||
const FOLLOW_DRAG_ENABLED = false;
|
||||
|
||||
type InteractionState = {
|
||||
dragging: boolean;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
targetRotationX: number;
|
||||
targetRotationY: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
};
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractionState(): InteractionState {
|
||||
return {
|
||||
dragging: false,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerX: 0,
|
||||
pointerY: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
targetRotationX: 0,
|
||||
targetRotationY: 0,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadImage(imageUrl: string) {
|
||||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountHalftoneCanvas(
|
||||
container: HTMLDivElement,
|
||||
imageUrl: string,
|
||||
) {
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
|
||||
1,
|
||||
);
|
||||
|
||||
const image = await loadImage(imageUrl);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'default';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const imageTexture = new THREE.Texture(image);
|
||||
imageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
imageTexture.needsUpdate = true;
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const imageMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tImage: { value: imageTexture },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
zoom: { value: IMAGE_ZOOM },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
});
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tGlow: { value: blurTargetB.texture },
|
||||
resolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
numRows: { value: NUM_ROWS },
|
||||
glowStr: { value: 0 },
|
||||
contrast: { value: CONTRAST },
|
||||
power: { value: POWER },
|
||||
shading: { value: SHADING },
|
||||
baseInk: { value: BASE_INK },
|
||||
maxBar: { value: MAX_BAR },
|
||||
cellRatio: { value: CELL_RATIO },
|
||||
cutoff: { value: CUTOFF },
|
||||
dashColor: { value: new THREE.Color(DASH_COLOR) },
|
||||
time: { value: 0 },
|
||||
waveAmount: { value: 0 },
|
||||
waveSpeed: { value: 1 },
|
||||
distanceScale: { value: 1 },
|
||||
mouseUv: { value: new THREE.Vector2(-1, -1) },
|
||||
warpStrength: { value: 0 },
|
||||
warpRadius: { value: HOVER_WARP_RADIUS },
|
||||
cropToBounds: { value: 1 },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
});
|
||||
|
||||
const blurHorizontalScene = new THREE.Scene();
|
||||
blurHorizontalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
|
||||
);
|
||||
|
||||
const blurVerticalScene = new THREE.Scene();
|
||||
blurVerticalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
|
||||
);
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
|
||||
|
||||
const interaction = createInteractionState();
|
||||
|
||||
const syncSize = () => {
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetA.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetB.setSize(virtualWidth, virtualHeight);
|
||||
blurHorizontalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
|
||||
blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
|
||||
halftoneMaterial.uniforms.resolution.value.set(virtualWidth, virtualHeight);
|
||||
imageMaterial.uniforms.viewportSize.value.set(virtualWidth, virtualHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const updatePointerPosition = (event: PointerEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
|
||||
interaction.mouseX = THREE.MathUtils.clamp(
|
||||
(event.clientX - rect.left) / width,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
interaction.mouseY = THREE.MathUtils.clamp(
|
||||
(event.clientY - rect.top) / height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
interaction.dragging = true;
|
||||
interaction.pointerX = event.clientX;
|
||||
interaction.pointerY = event.clientY;
|
||||
interaction.velocityX = 0;
|
||||
interaction.velocityY = 0;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
};
|
||||
|
||||
const handleWindowPointerMove = (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
|
||||
if (!interaction.dragging || !FOLLOW_DRAG_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENS;
|
||||
const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENS;
|
||||
interaction.velocityX = deltaY;
|
||||
interaction.velocityY = deltaX;
|
||||
interaction.targetRotationY += deltaX;
|
||||
interaction.targetRotationX += deltaY;
|
||||
interaction.pointerX = event.clientX;
|
||||
interaction.pointerY = event.clientY;
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (interaction.dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
interaction.mouseX = 0.5;
|
||||
interaction.mouseY = 0.5;
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
interaction.dragging = false;
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
handlePointerUp();
|
||||
handlePointerLeave();
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointermove', handleWindowPointerMove);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let animationFrameId = 0;
|
||||
|
||||
const renderFrame = () => {
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
|
||||
halftoneMaterial.uniforms.time.value = clock.getElapsedTime();
|
||||
|
||||
let warpActive = false;
|
||||
let currentWarpStrength = 0;
|
||||
|
||||
if (FOLLOW_HOVER_ENABLED && !interaction.dragging) {
|
||||
warpActive = true;
|
||||
currentWarpStrength = HOVER_WARP_STRENGTH;
|
||||
}
|
||||
|
||||
if (FOLLOW_DRAG_ENABLED && interaction.dragging) {
|
||||
warpActive = true;
|
||||
currentWarpStrength = DRAG_WARP_STRENGTH;
|
||||
}
|
||||
|
||||
if (FOLLOW_DRAG_ENABLED && !interaction.dragging) {
|
||||
interaction.targetRotationX *= 1 - DRAG_FRICTION;
|
||||
interaction.targetRotationY *= 1 - DRAG_FRICTION;
|
||||
const dragRemaining =
|
||||
Math.abs(interaction.targetRotationX) +
|
||||
Math.abs(interaction.targetRotationY);
|
||||
|
||||
if (dragRemaining > 0.001) {
|
||||
warpActive = true;
|
||||
currentWarpStrength =
|
||||
DRAG_WARP_STRENGTH * Math.min(dragRemaining * 20, 1);
|
||||
}
|
||||
}
|
||||
|
||||
interaction.rotationX +=
|
||||
(interaction.mouseX - interaction.rotationX) * HOVER_EASE;
|
||||
interaction.rotationY +=
|
||||
(interaction.mouseY - interaction.rotationY) * HOVER_EASE;
|
||||
|
||||
if (warpActive) {
|
||||
halftoneMaterial.uniforms.mouseUv.value.set(
|
||||
interaction.rotationX,
|
||||
interaction.rotationY,
|
||||
);
|
||||
halftoneMaterial.uniforms.warpStrength.value = currentWarpStrength;
|
||||
} else {
|
||||
halftoneMaterial.uniforms.warpStrength.value = 0;
|
||||
}
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(imageScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||
canvas.removeEventListener('pointerleave', handlePointerLeave);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointermove', handleWindowPointerMove);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
canvas.removeEventListener('pointerdown', handlePointerDown);
|
||||
blurHorizontalMaterial.dispose();
|
||||
blurVerticalMaterial.dispose();
|
||||
halftoneMaterial.dispose();
|
||||
imageMaterial.dispose();
|
||||
imageTexture.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
sceneTarget.dispose();
|
||||
blurTargetA.dispose();
|
||||
blurTargetB.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function LolOverlay() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unmountPromise = mountHalftoneCanvas(container, IMAGE_SRC).catch(
|
||||
(error) => {
|
||||
console.error(error);
|
||||
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
void unmountPromise.then((dispose) => dispose?.());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountReference}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import Monolith from './monolith';
|
||||
|
||||
const DESKTOP_PATH =
|
||||
'M672 395.498V701a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h664a4 4 0 0 1 4 4v65.155c0 2.363-.837 4.65-2.361 6.454l-23.603 27.936a10 10 0 0 0-2.362 6.453v242.614c0 2.245.756 4.424 2.145 6.188l24.036 30.51a10 10 0 0 1 2.145 6.188';
|
||||
@@ -21,34 +22,39 @@ const StyledVisual = styled.div`
|
||||
`;
|
||||
|
||||
const StyledMasked = styled.div`
|
||||
background-color: ${theme.colors.secondary.background[100]};
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
height: 100%;
|
||||
isolation: isolate;
|
||||
-webkit-mask-image: ${mobileMask};
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-image: ${mobileMask};
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
-webkit-mask-image: ${desktopMask};
|
||||
mask-image: ${desktopMask};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
const StyledHalftoneLayer = styled.div`
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export function Visual() {
|
||||
return (
|
||||
<StyledVisual>
|
||||
<StyledMasked>
|
||||
<StyledImage
|
||||
src="/images/home/problem/problem-visual.svg"
|
||||
alt="Abstract black-and-white image of a tall pillar with mountains in the background."
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<StyledHalftoneLayer>
|
||||
<Monolith />
|
||||
</StyledHalftoneLayer>
|
||||
</StyledMasked>
|
||||
</StyledVisual>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const IMAGE_SRC = '/images/home/problem/monolith2.jpg';
|
||||
const PATTERN_SRC = '/images/home/problem/halftone-pattern.png';
|
||||
|
||||
const BASE_SCALE = 1.08;
|
||||
const HOVER_SCALE = 1.12;
|
||||
const PATTERN_SCALE = 1.15;
|
||||
const EASE = 0.12;
|
||||
const BASE_RANGE_X = 12;
|
||||
const BASE_RANGE_Y = 10;
|
||||
const PATTERN_RANGE_X = 18;
|
||||
const PATTERN_RANGE_Y = 14;
|
||||
const PATTERN_IDLE_OPACITY = 0.52;
|
||||
const PATTERN_ACTIVE_OPACITY = 0.72;
|
||||
|
||||
type MotionState = {
|
||||
baseOffsetX: number;
|
||||
baseOffsetY: number;
|
||||
baseScale: number;
|
||||
glowOpacity: number;
|
||||
glowX: number;
|
||||
glowY: number;
|
||||
patternOffsetX: number;
|
||||
patternOffsetY: number;
|
||||
patternOpacity: number;
|
||||
patternScale: number;
|
||||
};
|
||||
|
||||
const StyledRoot = styled.div`
|
||||
--base-offset-x: 0px;
|
||||
--base-offset-y: 0px;
|
||||
--base-scale: ${BASE_SCALE};
|
||||
--pattern-offset-x: 0px;
|
||||
--pattern-offset-y: 0px;
|
||||
--pattern-opacity: ${PATTERN_IDLE_OPACITY};
|
||||
--pattern-scale: ${PATTERN_SCALE};
|
||||
--glow-opacity: 0;
|
||||
--glow-x: 50%;
|
||||
--glow-y: 38%;
|
||||
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 22%,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0) 38%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(24, 10, 14, 0.04) 0%,
|
||||
rgba(24, 10, 14, 0.02) 42%,
|
||||
rgba(24, 10, 14, 0.12) 100%
|
||||
);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledBaseLayer = styled.div`
|
||||
inset: -8%;
|
||||
position: absolute;
|
||||
transform: translate3d(var(--base-offset-x), var(--base-offset-y), 0)
|
||||
scale(var(--base-scale));
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
`;
|
||||
|
||||
const StyledPatternLayer = styled.div`
|
||||
inset: -8%;
|
||||
opacity: var(--pattern-opacity);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translate3d(var(--pattern-offset-x), var(--pattern-offset-y), 0)
|
||||
scale(var(--pattern-scale));
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
`;
|
||||
|
||||
const StyledGlow = styled.div`
|
||||
background: radial-gradient(
|
||||
circle at var(--glow-x) var(--glow-y),
|
||||
rgba(255, 255, 255, 0.26) 0%,
|
||||
rgba(255, 255, 255, 0.08) 16%,
|
||||
rgba(255, 255, 255, 0) 38%
|
||||
);
|
||||
inset: -16%;
|
||||
mix-blend-mode: screen;
|
||||
opacity: var(--glow-opacity);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
will-change: opacity;
|
||||
`;
|
||||
|
||||
const StyledShade = styled.div`
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(18, 7, 10, 0.02) 0%,
|
||||
rgba(18, 7, 10, 0.02) 30%,
|
||||
rgba(18, 7, 10, 0.16) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(18, 7, 10, 0.1) 0%,
|
||||
rgba(18, 7, 10, 0) 18%,
|
||||
rgba(18, 7, 10, 0) 82%,
|
||||
rgba(18, 7, 10, 0.08) 100%
|
||||
);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function easeValue(current: number, target: number) {
|
||||
return current + (target - current) * EASE;
|
||||
}
|
||||
|
||||
function applyMotionStyles(element: HTMLDivElement, state: MotionState) {
|
||||
element.style.setProperty(
|
||||
'--base-offset-x',
|
||||
`${state.baseOffsetX.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty(
|
||||
'--base-offset-y',
|
||||
`${state.baseOffsetY.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty('--base-scale', state.baseScale.toFixed(4));
|
||||
element.style.setProperty(
|
||||
'--pattern-offset-x',
|
||||
`${state.patternOffsetX.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty(
|
||||
'--pattern-offset-y',
|
||||
`${state.patternOffsetY.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty('--pattern-scale', state.patternScale.toFixed(4));
|
||||
element.style.setProperty(
|
||||
'--pattern-opacity',
|
||||
state.patternOpacity.toFixed(4),
|
||||
);
|
||||
element.style.setProperty('--glow-x', `${state.glowX.toFixed(2)}%`);
|
||||
element.style.setProperty('--glow-y', `${state.glowY.toFixed(2)}%`);
|
||||
element.style.setProperty('--glow-opacity', state.glowOpacity.toFixed(4));
|
||||
}
|
||||
|
||||
export default function Monolith() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = rootRef.current;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current: MotionState = {
|
||||
baseOffsetX: 0,
|
||||
baseOffsetY: 0,
|
||||
baseScale: BASE_SCALE,
|
||||
glowOpacity: 0,
|
||||
glowX: 50,
|
||||
glowY: 38,
|
||||
patternOffsetX: 0,
|
||||
patternOffsetY: 0,
|
||||
patternOpacity: PATTERN_IDLE_OPACITY,
|
||||
patternScale: PATTERN_SCALE,
|
||||
};
|
||||
|
||||
const target: MotionState = { ...current };
|
||||
let frameId = 0;
|
||||
|
||||
const animate = () => {
|
||||
current.baseOffsetX = easeValue(current.baseOffsetX, target.baseOffsetX);
|
||||
current.baseOffsetY = easeValue(current.baseOffsetY, target.baseOffsetY);
|
||||
current.baseScale = easeValue(current.baseScale, target.baseScale);
|
||||
current.patternOffsetX = easeValue(
|
||||
current.patternOffsetX,
|
||||
target.patternOffsetX,
|
||||
);
|
||||
current.patternOffsetY = easeValue(
|
||||
current.patternOffsetY,
|
||||
target.patternOffsetY,
|
||||
);
|
||||
current.patternOpacity = easeValue(
|
||||
current.patternOpacity,
|
||||
target.patternOpacity,
|
||||
);
|
||||
current.patternScale = easeValue(
|
||||
current.patternScale,
|
||||
target.patternScale,
|
||||
);
|
||||
current.glowX = easeValue(current.glowX, target.glowX);
|
||||
current.glowY = easeValue(current.glowY, target.glowY);
|
||||
current.glowOpacity = easeValue(current.glowOpacity, target.glowOpacity);
|
||||
|
||||
applyMotionStyles(element, current);
|
||||
frameId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const updateTargetsFromPointer = (clientX: number, clientY: number) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const normalizedX = clamp(
|
||||
(clientX - rect.left) / Math.max(rect.width, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const normalizedY = clamp(
|
||||
(clientY - rect.top) / Math.max(rect.height, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
target.baseOffsetX = (0.5 - normalizedX) * BASE_RANGE_X;
|
||||
target.baseOffsetY = (0.5 - normalizedY) * BASE_RANGE_Y;
|
||||
target.baseScale = HOVER_SCALE;
|
||||
target.patternOffsetX = (0.5 - normalizedX) * PATTERN_RANGE_X;
|
||||
target.patternOffsetY = (0.5 - normalizedY) * PATTERN_RANGE_Y;
|
||||
target.patternOpacity = PATTERN_ACTIVE_OPACITY;
|
||||
target.patternScale = PATTERN_SCALE + 0.02;
|
||||
target.glowX = normalizedX * 100;
|
||||
target.glowY = normalizedY * 100;
|
||||
target.glowOpacity = 1;
|
||||
};
|
||||
|
||||
const resetTargets = () => {
|
||||
target.baseOffsetX = 0;
|
||||
target.baseOffsetY = 0;
|
||||
target.baseScale = BASE_SCALE;
|
||||
target.patternOffsetX = 0;
|
||||
target.patternOffsetY = 0;
|
||||
target.patternOpacity = PATTERN_IDLE_OPACITY;
|
||||
target.patternScale = PATTERN_SCALE;
|
||||
target.glowX = 50;
|
||||
target.glowY = 38;
|
||||
target.glowOpacity = 0;
|
||||
};
|
||||
|
||||
const handlePointerEnter = (event: PointerEvent) => {
|
||||
updateTargetsFromPointer(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
updateTargetsFromPointer(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
resetTargets();
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
resetTargets();
|
||||
};
|
||||
|
||||
element.addEventListener('pointerenter', handlePointerEnter);
|
||||
element.addEventListener('pointermove', handlePointerMove);
|
||||
element.addEventListener('pointerleave', handlePointerLeave);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
frameId = window.requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
element.removeEventListener('pointerenter', handlePointerEnter);
|
||||
element.removeEventListener('pointermove', handlePointerMove);
|
||||
element.removeEventListener('pointerleave', handlePointerLeave);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledRoot ref={rootRef}>
|
||||
<StyledBaseLayer>
|
||||
<NextImage
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
src={IMAGE_SRC}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
</StyledBaseLayer>
|
||||
<StyledPatternLayer>
|
||||
<NextImage
|
||||
alt=""
|
||||
aria-hidden
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
src={PATTERN_SRC}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
</StyledPatternLayer>
|
||||
<StyledGlow />
|
||||
<StyledShade />
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user