Motiva
Мои коллекции
← каталог

Image Distortion

WebGL-дисторсия фотографии: под курсором «капля»-линза, движение гонит рябь с хроматической аберрацией (кастомный шейдер; React и vanilla three).

threemouse-movemorphadvanced~160 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
image?string"https://motiva.pages.dev/img/1015-1600x…URL картинки (демо-снимок каталога — замени на свой; хост должен отдавать CORS).
strength?number1.5Сила искажения (формат «1.5»).
shift?number1.4Множитель RGB-сдвига (формат «1.4»).
decay?number0.95Затухание ряби за кадр, 0.9–0.99 (формат «0.95»).
radius?number4Компактность линзы: больше — уже пятно (формат «4»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/image-distortion.json

зависимости: three@^0.185.1 · @react-three/fiber@^9.6.1

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Codrops WebGL distortionv2.0.0