← каталог
Image Distortion
WebGL-дисторсия фотографии: под курсором «капля»-линза, движение гонит рябь с хроматической аберрацией (кастомный шейдер; React и vanilla three).
threemouse-movemorphadvanced~160 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| image? | string | "https://motiva.pages.dev/img/1015-1600x… | URL картинки (демо-снимок каталога — замени на свой; хост должен отдавать CORS). |
| strength? | number | 1.5 | Сила искажения (формат «1.5»). |
| shift? | number | 1.4 | Множитель RGB-сдвига (формат «1.4»). |
| decay? | number | 0.95 | Затухание ряби за кадр, 0.9–0.99 (формат «0.95»). |
| radius? | number | 4 | Компактность линзы: больше — уже пятно (формат «4»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/image-distortion.jsonзависимости: three@^0.185.1 · @react-three/fiber@^9.6.1
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
import { Suspense, useMemo, useRef } from "react";
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; // курсор в 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);
}
`;
export interface ImageDistortionProps {
/** URL картинки (демо-снимок каталога — замени на свой; хост должен отдавать CORS). */
image?: string;
/** Сила искажения (формат «1.5»). */
strength?: number;
/** Множитель RGB-сдвига (формат «1.4»). */
shift?: number;
/** Затухание ряби за кадр, 0.9–0.99 (формат «0.95»). */
decay?: number;
/** Компактность линзы: больше — уже пятно (формат «4»). */
radius?: number;
}
type Pointer = { x: number; y: number; tx: number; ty: number; hover: number; thover: number; energy: number };
type Config = Required<Omit<ImageDistortionProps, "image">>;
function DistortionPlane({
pointer,
cfg,
image,
frozen,
}: {
pointer: { current: Pointer };
cfg: { current: Config };
image: string;
frozen: boolean;
}) {
const mat = useRef<THREE.ShaderMaterial>(null);
const { viewport, size } = useThree();
const invalidate = useThree((s) => s.invalidate);
const texture = useLoader(THREE.TextureLoader, image); // Suspense: кадр не рисуется, пока фото не пришло
texture.colorSpace = THREE.SRGBColorSpace;
const uniforms = useMemo(
() => ({
uTex: { value: texture },
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: 1.5 },
uShift: { value: 0.084 },
uRadius: { value: 4 },
uTime: { value: 0 },
}),
[texture],
);
useFrame((_, delta) => {
// ГРАБЛИ R3F 9.6: проп `uniforms` КОПИРУЕТСЯ в собственный объект материала
// (Object.assign на каждый ключ). Мутировать надо uniforms ЖИВОГО материала,
// иначе значения не доедут до GPU и эффекта не будет вообще.
const u = mat.current?.uniforms;
if (!u) return;
const p = pointer.current;
const c = cfg.current;
const img = u.uTex!.value?.image as { width?: number; height?: number } | undefined;
const ia = img?.width && img?.height ? img.width / img.height : 1;
const ca = size.width / Math.max(1, size.height);
if (ia > ca) u.uCover!.value.set(ca / ia, 1);
else u.uCover!.value.set(1, ia / ca);
u.uAspect!.value = ca;
p.x += (p.tx - p.x) * 0.16;
p.y += (p.ty - p.y) * 0.16;
p.hover += (p.thover - p.hover) * 0.1;
p.energy *= c.decay;
u.uTime!.value += delta;
u.uMouse!.value.set(p.x, p.y);
u.uHover!.value = p.hover;
u.uEnergy!.value = p.energy;
u.uStrength!.value = c.strength;
u.uShift!.value = c.shift * 0.06;
u.uRadius!.value = c.radius;
// frameloop="demand": кадры заказываем сами, пока есть движение → в покое сцена спит (0 rAF)
const busy =
p.energy > 0.002 ||
Math.abs(p.thover - p.hover) > 0.002 ||
Math.abs(p.tx - p.x) > 0.0005 ||
Math.abs(p.ty - p.y) > 0.0005;
if (busy && !frozen) invalidate();
});
return (
<mesh scale={[viewport.width, viewport.height, 1]}>
<planeGeometry args={[1, 1]} />
<shaderMaterial ref={mat} uniforms={uniforms} vertexShader={vertex} fragmentShader={fragment} />
</mesh>
);
}
/**
* WebGL-дисторсия фотографии: под курсором «капля»-линза, движение гонит по картинке рябь
* с хроматической аберрацией. Рендер по требованию — в покое сцена спит (0 rAF).
* `?noanim`/`data-shot` и `prefers-reduced-motion` → стоп-кадр.
*/
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 frozen =
typeof document !== "undefined" &&
(document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const cfg = useRef<Config>({ strength, shift, decay, radius });
cfg.current = { strength, shift, decay, radius };
const pointer = useRef<Pointer>({ x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, hover: 0, thover: 0, energy: 0 });
const wrapRef = useRef<HTMLDivElement>(null);
const invalidateRef = useRef<() => void>(() => {});
// курсор берём из прямоугольника контейнера — без raycast'а на каждое движение
const onMove = (e: React.PointerEvent) => {
if (frozen) return;
const r = wrapRef.current?.getBoundingClientRect();
if (!r) return;
const p = pointer.current;
const nx = (e.clientX - r.left) / r.width;
const ny = 1 - (e.clientY - r.top) / r.height;
p.energy = Math.min(1.2, p.energy + Math.hypot(nx - p.tx, ny - p.ty) * 10);
p.tx = nx;
p.ty = ny;
p.thover = 1;
invalidateRef.current();
};
return (
<div
ref={wrapRef}
onPointerMove={onMove}
onPointerLeave={() => {
pointer.current.thover = 0;
invalidateRef.current();
}}
style={{ position: "absolute", inset: 0, cursor: "crosshair", background: "#0b0b12" }}
>
<Canvas
orthographic
frameloop="demand"
dpr={[1, 2]}
onCreated={({ invalidate }) => {
invalidateRef.current = invalidate;
}}
>
<Suspense fallback={null}>
<DistortionPlane pointer={pointer} cfg={cfg} image={image} frozen={frozen} />
</Suspense>
</Canvas>
<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",
}}
>
двигай курсором по изображению
</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();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Codrops WebGL distortionv2.0.0