Image Distortion
WebGL-дисторсия фотографии: под курсором «капля»-линза, движение гонит рябь с хроматической аберрацией (кастомный шейдер на голом three.js; React и vanilla).
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| image? | string | "https://motiva.pages.dev/img/1015-1600x… | Путь к фотографии. |
| strength? | number | 1.5 | Сила линзы и ряби. |
| shift? | number | 1.4 | Хроматическая аберрация. |
| decay? | number | 0.95 | Затухание ряби за кадр: ближе к 1 — след живёт дольше. |
| radius? | number | 4 | Радиус линзы: больше число — пятно меньше. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/image-distortion.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
export interface ImageDistortionProps {
/** Путь к фотографии. */
image?: string;
/** Сила линзы и ряби. */
strength?: number;
/** Хроматическая аберрация. */
shift?: number;
/** Затухание ряби за кадр: ближе к 1 — след живёт дольше. */
decay?: number;
/** Радиус линзы: больше число — пятно меньше. */
radius?: number;
}
const VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */ `
uniform sampler2D uTex;
uniform vec2 uMouse; // курсор в uv
uniform vec2 uCover; // масштаб uv под object-fit: cover
uniform float uAspect; // ширина/высота сцены — чтобы линза была круглой
uniform float uHover; // 0..1 — курсор над картинкой
uniform float uEnergy; // энергия от скорости курсора, затухает
uniform float uStrength;
uniform float uShift;
uniform float uRadius;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 p = vUv - uMouse;
p.x *= uAspect; // расстояние считаем в «круглых» координатах
float d = length(p);
float R = 0.9 / uRadius; // радиус линзы в uv
float t = clamp(d / R, 0.0, 1.0);
float fall = (1.0 - t) * (1.0 - t); // строго локально: ровно 0 за границей линзы
vec2 dir = d > 0.0001 ? p / d : vec2(0.0);
dir.x /= uAspect; // обратно в uv
float lens = uHover * fall * 0.05 * uStrength; // «капля» под курсором
float wave = sin(d * 55.0 - uTime * 6.0) * fall * uEnergy * 0.010 * uStrength; // рябь от движения
vec2 offset = dir * (lens + wave);
vec2 ca = dir * (abs(lens) + abs(wave)) * uShift; // хром. аберрация по величине смещения
vec2 base = (vUv - 0.5) * uCover + 0.5; // cover-fit: картинка не растягивается
vec3 col = vec3(
texture2D(uTex, base + offset + ca).r,
texture2D(uTex, base + offset).g,
texture2D(uTex, base + offset - ca).b
);
col += vec3(0.5, 0.55, 0.75) * wave * 3.0; // блик по гребню волны
gl_FragColor = vec4(col, 1.0);
}
`;
/**
* WebGL-дисторсия фотографии на голом three.js (без React Three Fiber): под курсором
* «капля»-линза, движение гонит по картинке рябь с хроматической аберрацией.
*
* Кадры заказываются ПО ТРЕБОВАНИЮ: пока курсор не двигается и рябь догасла, цикл
* не крутится вовсе (0 rAF в покое). `?noanim`/`data-shot` и `prefers-reduced-motion`
* дают стоп-кадр. Зеркальная версия без React — `image-distortion.vanilla.js`,
* математика и тайминги там те же.
*/
export function ImageDistortion({
image = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
strength = 1.5,
shift = 1.4,
decay = 0.95,
radius = 4,
}: ImageDistortionProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ strength, shift, decay, radius });
cfg.current = { strength, shift, decay, radius };
useEffect(() => {
const el = host.current;
if (!el) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
Object.assign(renderer.domElement.style, { display: "block", width: "100%", height: "100%" });
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0, 1);
const uniforms = {
uTex: { value: null as THREE.Texture | null },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uCover: { value: new THREE.Vector2(1, 1) },
uAspect: { value: 1 },
uHover: { value: 0 },
uEnergy: { value: 0 },
uStrength: { value: strength },
uShift: { value: shift * 0.06 },
uRadius: { value: radius },
uTime: { value: 0 },
};
const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.ShaderMaterial({ uniforms, vertexShader: VERT, fragmentShader: FRAG });
scene.add(new THREE.Mesh(geometry, material));
let texture: THREE.Texture | undefined;
let disposed = false;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
loader.load(image, (t) => {
if (disposed) {
t.dispose();
return;
}
t.colorSpace = THREE.SRGBColorSpace;
texture = t;
uniforms.uTex.value = t;
request();
});
const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, hover: 0, thover: 0, energy: 0 };
let raf = 0;
let running = false;
let last = 0;
let width = 1;
let height = 1;
const resize = () => {
const r = el.getBoundingClientRect();
width = Math.max(1, Math.round(r.width));
height = Math.max(1, Math.round(r.height));
renderer.setSize(width, height, false);
request();
};
const draw = (now: number) => {
const delta = last ? Math.min(0.05, (now - last) / 1000) : 0.016;
last = now;
const c = cfg.current;
uniforms.uStrength.value = c.strength;
uniforms.uShift.value = c.shift * 0.06;
uniforms.uRadius.value = c.radius;
const img = uniforms.uTex.value?.image as { width?: number; height?: number } | undefined;
const ia = img?.width && img.height ? img.width / img.height : 1;
const ca = width / height;
if (ia > ca) uniforms.uCover.value.set(ca / ia, 1);
else uniforms.uCover.value.set(1, ia / ca);
uniforms.uAspect.value = ca;
pointer.x += (pointer.tx - pointer.x) * 0.16;
pointer.y += (pointer.ty - pointer.y) * 0.16;
pointer.hover += (pointer.thover - pointer.hover) * 0.1;
pointer.energy *= c.decay;
uniforms.uTime.value += delta;
uniforms.uMouse.value.set(pointer.x, pointer.y);
uniforms.uHover.value = pointer.hover;
uniforms.uEnergy.value = pointer.energy;
if (uniforms.uTex.value) renderer.render(scene, camera);
const busy =
pointer.energy > 0.002 ||
Math.abs(pointer.thover - pointer.hover) > 0.002 ||
Math.abs(pointer.tx - pointer.x) > 0.0005 ||
Math.abs(pointer.ty - pointer.y) > 0.0005;
if (!busy || frozen) {
running = false; // сцена успокоилась — спим до следующего движения курсора
return;
}
raf = requestAnimationFrame(draw);
};
function request() {
if (running) return;
running = true;
last = 0;
raf = requestAnimationFrame(draw);
}
// курсор берём из прямоугольника контейнера — без raycast'а на каждое движение
const onMove = (e: PointerEvent) => {
if (frozen) return;
const r = el.getBoundingClientRect();
const nx = (e.clientX - r.left) / r.width;
const ny = 1 - (e.clientY - r.top) / r.height;
pointer.energy = Math.min(1.2, pointer.energy + Math.hypot(nx - pointer.tx, ny - pointer.ty) * 10);
pointer.tx = nx;
pointer.ty = ny;
pointer.thover = 1;
request();
};
const onLeave = () => {
pointer.thover = 0;
request();
};
const ro = new ResizeObserver(resize);
ro.observe(el);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
resize();
return () => {
disposed = true;
cancelAnimationFrame(raf);
ro.disconnect();
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
geometry.dispose();
material.dispose();
texture?.dispose();
renderer.dispose();
renderer.forceContextLoss(); // явно отпускаем WebGL-контекст (их лимит ~16 на вкладку)
renderer.domElement.remove();
};
}, [image]);
return (
<div ref={host} style={{ position: "absolute", inset: 0, cursor: "crosshair", background: "#0b0b12" }}>
<div
style={{
position: "absolute",
left: 14,
bottom: 12,
fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem",
color: "rgba(255,255,255,0.8)",
textShadow: "0 1px 6px rgba(0,0,0,0.7)",
pointerEvents: "none",
zIndex: 1,
}}
>
двигай курсором по изображению
</div>
</div>
);
}
/**
* Image Distortion — vanilla three.js (без React/R3F). Под курсором «капля»-линза,
* движение гонит по фото рябь с хроматической аберрацией.
* Рендер по требованию: в покое кадры не заказываются (0 rAF), в destroy освобождаются все ресурсы.
*
* <div id="hero" style="position:relative;width:100%;height:420px"></div>
* <script type="module">
* import { initImageDistortion } from './image-distortion.vanilla.js';
* const destroy = initImageDistortion(document.querySelector('#hero'), {
* image: 'https://motiva.pages.dev/img/1015-1600x1000.jpg', strength: 1.5,
* });
* </script>
*
* @param {HTMLElement} container — контейнер (position:relative), канвас растягивается по нему
* @param {{ image?: string, strength?: number, shift?: number, decay?: number, radius?: number }} [options]
* @returns {() => void} destroy
*/
import * as THREE from "three";
const vertex = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragment = /* glsl */ `
uniform sampler2D uTex;
uniform vec2 uMouse;
uniform vec2 uCover;
uniform float uAspect;
uniform float uHover;
uniform float uEnergy;
uniform float uStrength;
uniform float uShift;
uniform float uRadius;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 p = vUv - uMouse;
p.x *= uAspect; // расстояние считаем в «круглых» координатах
float d = length(p);
float R = 0.9 / uRadius; // радиус линзы в uv
float t = clamp(d / R, 0.0, 1.0);
float fall = (1.0 - t) * (1.0 - t); // строго локально: ровно 0 за границей линзы
vec2 dir = d > 0.0001 ? p / d : vec2(0.0);
dir.x /= uAspect; // обратно в uv
float lens = uHover * fall * 0.05 * uStrength;
float wave = sin(d * 55.0 - uTime * 6.0) * fall * uEnergy * 0.010 * uStrength;
vec2 offset = dir * (lens + wave);
vec2 ca = dir * (abs(lens) + abs(wave)) * uShift;
vec2 base = (vUv - 0.5) * uCover + 0.5;
vec3 col = vec3(
texture2D(uTex, base + offset + ca).r,
texture2D(uTex, base + offset).g,
texture2D(uTex, base + offset - ca).b
);
col += vec3(0.5, 0.55, 0.75) * wave * 3.0;
gl_FragColor = vec4(col, 1.0);
}
`;
export function initImageDistortion(
container,
{
image = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
strength = 1.5,
shift = 1.4,
decay = 0.95,
radius = 4,
} = {},
) {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.domElement.style.cssText = "display:block;width:100%;height:100%;cursor:crosshair;";
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0, 1);
const uniforms = {
uTex: { value: null },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uCover: { value: new THREE.Vector2(1, 1) },
uAspect: { value: 1 },
uHover: { value: 0 },
uEnergy: { value: 0 },
uStrength: { value: strength },
uShift: { value: shift * 0.06 },
uRadius: { value: radius },
uTime: { value: 0 },
};
const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.ShaderMaterial({ uniforms, vertexShader: vertex, fragmentShader: fragment });
scene.add(new THREE.Mesh(geometry, material));
let texture;
let disposed = false;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
loader.load(image, (t) => {
if (disposed) {
t.dispose();
return;
}
t.colorSpace = THREE.SRGBColorSpace;
texture = t;
uniforms.uTex.value = t;
request();
});
const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, hover: 0, thover: 0, energy: 0 };
let raf = 0;
let running = false;
let last = 0;
let width = 1;
let height = 1;
const resize = () => {
const r = container.getBoundingClientRect();
width = Math.max(1, Math.round(r.width));
height = Math.max(1, Math.round(r.height));
renderer.setSize(width, height, false);
request();
};
const draw = (t) => {
const delta = last ? Math.min(0.05, (t - last) / 1000) : 0.016;
last = t;
const img = uniforms.uTex.value?.image;
const ia = img && img.width && img.height ? img.width / img.height : 1;
const ca = width / height;
if (ia > ca) uniforms.uCover.value.set(ca / ia, 1);
else uniforms.uCover.value.set(1, ia / ca);
uniforms.uAspect.value = ca;
pointer.x += (pointer.tx - pointer.x) * 0.16;
pointer.y += (pointer.ty - pointer.y) * 0.16;
pointer.hover += (pointer.thover - pointer.hover) * 0.1;
pointer.energy *= decay;
uniforms.uTime.value += delta;
uniforms.uMouse.value.set(pointer.x, pointer.y);
uniforms.uHover.value = pointer.hover;
uniforms.uEnergy.value = pointer.energy;
if (uniforms.uTex.value) renderer.render(scene, camera);
const busy =
pointer.energy > 0.002 ||
Math.abs(pointer.thover - pointer.hover) > 0.002 ||
Math.abs(pointer.tx - pointer.x) > 0.0005 ||
Math.abs(pointer.ty - pointer.y) > 0.0005;
if (!busy || frozen) {
running = false; // сцена успокоилась — спим до следующего движения курсора
return;
}
raf = requestAnimationFrame(draw);
};
const request = () => {
if (running) return;
running = true;
last = 0;
raf = requestAnimationFrame(draw);
};
const onMove = (e) => {
if (frozen) return;
const r = container.getBoundingClientRect();
const nx = (e.clientX - r.left) / r.width;
const ny = 1 - (e.clientY - r.top) / r.height;
pointer.energy = Math.min(1.2, pointer.energy + Math.hypot(nx - pointer.tx, ny - pointer.ty) * 10);
pointer.tx = nx;
pointer.ty = ny;
pointer.thover = 1;
request();
};
const onLeave = () => {
pointer.thover = 0;
request();
};
const ro = new ResizeObserver(resize);
ro.observe(container);
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerleave", onLeave);
resize();
return () => {
disposed = true;
cancelAnimationFrame(raf);
ro.disconnect();
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerleave", onLeave);
geometry.dispose();
material.dispose();
texture?.dispose();
renderer.dispose();
renderer.forceContextLoss(); // явно отпускаем WebGL-контекст (их лимит ~16 на вкладку)
renderer.domElement.remove();
};
}