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

Image Distortion

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

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

Пропсы

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

ПропТипПо умолчаниюЧто это
image?string"https://motiva.pages.dev/img/1015-1600x…Путь к фотографии.
strength?number1.5Сила линзы и ряби.
shift?number1.4Хроматическая аберрация.
decay?number0.95Затухание ряби за кадр: ближе к 1 — след живёт дольше.
radius?number4Радиус линзы: больше число — пятно меньше.

Установка

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

зависимости: three@^0.185.1 · @types/three@^0.185.0

Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

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