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

Grid Distortion

Картинка под резиновой сеткой: курсор оставляет расходящуюся волну, которая гаснет сама. Возмущение живёт в отдельной сетке-состоянии 22×22, а смещение берётся из её градиента — поэтому у волны есть инерция и след.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
grid?number22Плотность сетки (формат «22»): крупнее клетка — грубее волна.
strength?number0.22Сила смещения (формат «0.22»).
radius?number0.28Радиус влияния курсора, доля кадра (формат «0.28»).
aberration?number0.5Расщепление каналов по краю волны (формат «0.5»).
showGrid?booleanfalseПоказывать линии сетки.

Установка

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

зависимости: ogl

Что нужно в проекте до установки
  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 { useEffect, useRef } from "react";
import { Mesh, Program, Renderer, Texture, Triangle } from "ogl";

export interface GridDistortionProps {
  /** Адрес картинки; должен отдавать CORS — иначе текстуру не создать. */
  src?: string;
  /** Плотность сетки (формат «22»): крупнее клетка — грубее волна. */
  grid?: number;
  /** Сила смещения (формат «0.22»). */
  strength?: number;
  /** Радиус влияния курсора, доля кадра (формат «0.28»). */
  radius?: number;
  /** Как быстро возмущение затухает, 0–1 (формат «0.94»); ближе к 1 — след живёт дольше. */
  relax?: number;
  /** Расщепление каналов по краю волны (формат «0.5»). */
  aberration?: number;
  /** Показывать линии сетки. */
  showGrid?: boolean;
}

const VERT = /* glsl */ `
  attribute vec2 uv;
  attribute vec2 position;
  varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;

/**
 * Волна живёт в ОТДЕЛЬНОЙ текстуре-состоянии, а не пересчитывается из позиции курсора.
 *
 * Если смещать пиксели прямо по расстоянию до мыши, след не получится: убрал курсор —
 * картинка мгновенно распрямилась. Здесь возмущение накапливается в маленькой сетке
 * (22×22 значения) и каждый кадр умножается на коэффициент затухания. Курсор только
 * ДОБАВЛЯЕТ в неё энергию, а расходится и гаснет она сама — отсюда инерция и шлейф.
 *
 * Сетка нарочно крошечная: она не про разрешение картинки, а про форму волны. Билинейная
 * фильтрация текстуры растянет её плавно, и клетка 22×22 обходится в 484 числа на кадр
 * вместо миллиона пикселей.
 */
const FRAG = /* glsl */ `
  precision highp float;
  uniform sampler2D uImage, uField;
  uniform float uStrength, uAberr, uGridLines, uGrid;
  uniform vec2 uRes, uImgRes;
  varying vec2 vUv;

  void main() {
    // cover-вписывание: масштаб по большей стороне, лишнее уходит за край
    float rs = uRes.x / uRes.y;
    float ri = uImgRes.x / uImgRes.y;
    vec2 st = vUv;
    if (rs > ri) st.y = (st.y - 0.5) * (ri / rs) + 0.5;
    else st.x = (st.x - 0.5) * (rs / ri) + 0.5;

    float f = texture2D(uField, vUv).r;
    // направление смещения — градиент поля, поэтому волна расходится кольцом
    float e = 1.0 / uGrid;
    vec2 grad = vec2(
      texture2D(uField, vUv + vec2(e, 0.0)).r - texture2D(uField, vUv - vec2(e, 0.0)).r,
      texture2D(uField, vUv + vec2(0.0, e)).r - texture2D(uField, vUv - vec2(0.0, e)).r
    );
    vec2 off = grad * uStrength;

    // расщепление каналов пропорционально силе волны — на спокойных участках его нет
    float ab = uAberr * f * 0.02;
    vec3 col;
    col.r = texture2D(uImage, st + off * (1.0 + ab)).r;
    col.g = texture2D(uImage, st + off).g;
    col.b = texture2D(uImage, st + off * (1.0 - ab)).b;

    if (uGridLines > 0.5) {
      vec2 g = abs(fract((vUv + off) * uGrid) - 0.5);
      float line = 1.0 - smoothstep(0.0, 0.06, min(g.x, g.y));
      col = mix(col, vec3(1.0), line * 0.14 * (0.25 + f));
    }

    gl_FragColor = vec4(col, 1.0);
  }
`;

/**
 * Картинка под резиновой сеткой: курсор оставляет расходящуюся волну, которая гаснет сама.
 *
 * Отличие от `image-distortion` — там линза, привязанная к курсору «здесь и сейчас».
 * Здесь возмущение живёт в собственной сетке-состоянии и продолжает расходиться, когда
 * курсор уже ушёл: волна имеет инерцию, а не повторяет положение мыши.
 *
 * Смещение берётся из ГРАДИЕНТА поля, а не из его величины — поэтому пиксели разбегаются
 * от гребня в стороны, как от брошенного камня, а не тянутся к одной точке.
 */
export function GridDistortion({
  src = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
  grid = 22,
  strength = 0.22,
  radius = 0.28,
  relax = 0.94,
  aberration = 0.5,
  showGrid = false,
}: GridDistortionProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ grid, strength, radius, relax, aberration, showGrid });
  cfg.current = { grid, strength, radius, relax, aberration, showGrid };

  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 Renderer({ alpha: false, antialias: false, dpr: Math.min(2, window.devicePixelRatio || 1) });
    const gl = renderer.gl;
    Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
    el.appendChild(gl.canvas);

    const N = Math.max(8, Math.min(64, Math.round(cfg.current.grid)));
    // поле возмущения: одно число на клетку, живёт между кадрами
    const field = new Float32Array(N * N);
    const bytes = new Uint8Array(N * N);
    // Одноканальная текстура называется по-разному в двух версиях WebGL, и промах молчаливый:
    // в WebGL2 пара LUMINANCE/LUMINANCE даёт GL_INVALID_OPERATION на КАЖДОЙ загрузке (1282),
    // поле до видеокарты не доезжает вовсе, шейдер читает нули — градиент нулевой, картинка
    // не искажается ничем. Замер: поле на стороне процессора живое (382 пустых клетки,
    // 66 промежуточных, 36 насыщенных), а кадр при курсоре слева, справа и в центре
    // байт в байт одинаковый. OGL берёт WebGL2, когда он есть, поэтому выбираем по контексту.
    const gl2 = typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext;
    // строка поля в 22 байта не кратна четырём — без выравнивания в 1 байт WebGL2 читает
    // данные со сдвигом, и поле «косит»
    gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
    const fieldTex = new Texture(gl, {
      image: bytes,
      width: N,
      height: N,
      format: gl2 ? gl.RED : gl.LUMINANCE,
      internalFormat: gl2 ? gl.R8 : gl.LUMINANCE,
      generateMipmaps: false,
      minFilter: gl.LINEAR,
      magFilter: gl.LINEAR,
      // По умолчанию OGL ставит flipY=true и переворачивает строки при загрузке,
      // а my уже считается от низа (1 - clientY/height) — вместе выходил ДВОЙНОЙ
      // переворот: волна вставала зеркально по вертикали от курсора.
      flipY: false,
    });

    const imgTex = new Texture(gl, { generateMipmaps: false });
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      imgTex.image = img;
      program.uniforms.uImgRes!.value = [img.width, img.height];
      draw();
      start();
    };
    img.src = src;

    const program = new Program(gl, {
      vertex: VERT,
      fragment: FRAG,
      uniforms: {
        uImage: { value: imgTex },
        uField: { value: fieldTex },
        uRes: { value: [1, 1] },
        uImgRes: { value: [1, 1] },
        uStrength: { value: cfg.current.strength },
        uAberr: { value: cfg.current.aberration },
        uGridLines: { value: cfg.current.showGrid ? 1 : 0 },
        uGrid: { value: N },
      },
    });
    const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });

    const resize = () => {
      const { clientWidth: w, clientHeight: h } = el;
      if (!w || !h) return;
      renderer.setSize(w, h);
      program.uniforms.uRes!.value = [w, h];
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);

    let mx = -1;
    let my = -1;
    const onMove = (e: PointerEvent) => {
      const r = el.getBoundingClientRect();
      mx = (e.clientX - r.left) / r.width;
      my = 1 - (e.clientY - r.top) / r.height;
      start();
    };
    const onLeave = () => {
      mx = my = -1;
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);

    let raf = 0;
    let energy = 0;

    const step = () => {
      const c = cfg.current;
      // курсор ДОБАВЛЯЕТ энергию в поле
      if (mx >= 0) {
        const r = Math.max(0.02, c.radius);
        for (let y = 0; y < N; y++) {
          for (let x = 0; x < N; x++) {
            const dx = x / (N - 1) - mx;
            const dy = y / (N - 1) - my;
            const d = Math.hypot(dx, dy);
            if (d < r) field[y * N + x]! += (1 - d / r) ** 2 * 0.35;
          }
        }
      }
      // затухание + лёгкое расплывание на соседей: волна расходится кольцом
      const k = Math.min(0.995, Math.max(0.5, c.relax));
      energy = 0;
      for (let i = 0; i < field.length; i++) {
        const v = field[i]! * k;
        field[i] = v;
        energy += v;
        bytes[i] = Math.min(255, v * 255) | 0;
      }
      fieldTex.image = bytes;
      fieldTex.needsUpdate = true;
    };

    const draw = () => {
      const c = cfg.current;
      program.uniforms.uStrength!.value = c.strength;
      program.uniforms.uAberr!.value = c.aberration;
      program.uniforms.uGridLines!.value = c.showGrid ? 1 : 0;
      renderer.render({ scene: mesh });
    };

    const loop = () => {
      step();
      draw();
      // поле погасло и курсор ушёл — цикл останавливается сам
      if (energy < 0.01 && mx < 0) {
        raf = 0;
        return;
      }
      raf = requestAnimationFrame(loop);
    };
    function start() {
      if (raf || frozen) return;
      raf = requestAnimationFrame(loop);
    }
    const stop = () => {
      cancelAnimationFrame(raf);
      raf = 0;
    };

    const io = new IntersectionObserver(([e]) => {
      if (!e!.isIntersecting || document.hidden) stop();
    });
    io.observe(el);
    const onVis = () => document.hidden && stop();
    document.addEventListener("visibilitychange", onVis);

    return () => {
      stop();
      io.disconnect();
      ro.disconnect();
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      document.removeEventListener("visibilitychange", onVis);
      img.onload = null;
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      gl.canvas.remove();
    };
  }, [src]);

  return (
    <div
      ref={host}
      className="gdi"
      style={{ position: "absolute", inset: 0, cursor: "crosshair", background: "var(--ds-bg, oklch(0.11 0.02 275))" }}
    />
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0