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

Fluid Reveal

Снимок проступает там, где курсор размазал жидкость, и след сам зарастает. Та же ping-pong-схема, что у bloom-garden, но с обратным разменом: след затухает, поэтому цикл кадров не может заснуть, пока он жив. Маска не вырезает снимок, а преломляет его — смещение берётся из градиента, то есть из наклона поверхности.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
src?string"https://motiva.pages.dev/img/1015-1600x…Снимок, который проступает под курсором.
radius?number0.16Радиус кисти, доля высоты кадра (формат «0.16»).
decay?number0.98Насколько след живуч: 0.9 — тает на глазах, 0.99 — стоит долго (формат «0.98»).
warp?number0.06Насколько след искажает снимок, доля кадра (формат «0.06»).
backdrop?string | false"var(--ds-bg, oklch(0.11 0.02 275))"Подложка: CSS-значение `background` или `false`, если её задаёт секция.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/fluid-reveal.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, RenderTarget, Renderer, Texture, Triangle } from "ogl";

export interface FluidRevealProps {
  /** Снимок, который проступает под курсором. */
  src?: string;
  /** Радиус кисти, доля высоты кадра (формат «0.16»). */
  radius?: number;
  /** Насколько след живуч: 0.9 — тает на глазах, 0.99 — стоит долго (формат «0.98»). */
  decay?: number;
  /** Насколько след искажает снимок, доля кадра (формат «0.06»). */
  warp?: number;
  /** Подложка: CSS-значение `background` или `false`, если её задаёт секция. */
  backdrop?: string | false;
}

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

/**
 * Шаг жидкости. Две строки, ради которых запись и существует:
 * АДВЕКЦИЯ — предыдущее состояние читается со сдвигом ПРОТИВ скорости курсора, поэтому
 * след не просто стоит, а тянется за рукой; ЗАТУХАНИЕ — состояние умножается на число
 * меньше единицы, и след сам зарастает.
 */
const SIM = /* glsl */ `
  precision highp float;
  uniform sampler2D tPrev;
  uniform vec2 uPointer, uVel;
  uniform float uRadius, uDecay, uAspect, uStamp;
  varying vec2 vUv;

  void main() {
    vec2 d = (vUv - uPointer) * vec2(uAspect, 1.0);
    // Адвекция ЛОКАЛЬНА — гаснет с расстоянием от кисти. Глобальный сдвиг (одна
    // скорость на весь буфер) возил ВЕСЬ накопленный след за резким движением руки:
    // при хаотичном вождении смещалось само проявление, а не только свежий мазок.
    float near = smoothstep(uRadius * 2.5, 0.0, length(d));
    vec2 src = vUv - uVel * 0.85 * near;
    float prev = texture2D(tPrev, src).r * uDecay;
    float brush = smoothstep(uRadius, 0.0, length(d)) * uStamp;
    gl_FragColor = vec4(max(prev, brush), 0.0, 0.0, 1.0);
  }
`;

const SHOW = /* glsl */ `
  precision highp float;
  uniform sampler2D tMask, uImage;
  uniform vec2 uRes, uImgRes;
  uniform float uWarp;
  varying vec2 vUv;

  vec2 cover(vec2 uv) {
    float rs = uRes.x / uRes.y;
    float ri = uImgRes.x / uImgRes.y;
    vec2 s = vec2(min(rs / ri, 1.0), min(ri / rs, 1.0));
    return (uv - 0.5) * s + 0.5;
  }

  void main() {
    vec2 px = 1.0 / uRes;
    float m = texture2D(tMask, vUv).r;
    // градиент маски = наклон поверхности «жидкости»; по нему и уводим выборку,
    // поэтому кромка следа преломляет снимок, а не просто вырезает его
    float gx = texture2D(tMask, vUv + vec2(px.x, 0.0)).r - texture2D(tMask, vUv - vec2(px.x, 0.0)).r;
    float gy = texture2D(tMask, vUv + vec2(0.0, px.y)).r - texture2D(tMask, vUv - vec2(0.0, px.y)).r;
    vec3 photo = texture2D(uImage, cover(vUv) + vec2(gx, gy) * uWarp * 8.0).rgb;
    // под слоем — тот же снимок, но обесцвеченный и притушенный: так видно, что открывают
    // ИМЕННО его, а не выводят из пустоты
    vec3 hidden = vec3(dot(photo, vec3(0.299, 0.587, 0.114))) * 0.22;
    float k = smoothstep(0.03, 0.3, m);
    gl_FragColor = vec4(mix(hidden, photo, k), 1.0);
  }
`;

/**
 * Снимок проступает там, где курсор «размазал» жидкость, и след сам зарастает.
 *
 * Состояние следа живёт в ТЕКСТУРЕ, и два буфера меняются местами каждый кадр — та же
 * ping-pong-схема, что у `bloom-garden`. Но размен здесь ПРОТИВОПОЛОЖНЫЙ, и пара записей
 * ровно об этом:
 *
 * - у `bloom-garden` накопленное остаётся навсегда, поэтому когда дорисовывать нечего,
 *   цикл кадров засыпает и запись не стоит ничего;
 * - здесь след ЗАТУХАЕТ, то есть картинка меняется сама по себе. Значит цикл не может
 *   заснуть, пока след жив, — и это не недоработка, а цена затухания. Всё, что можно
 *   сделать честно, — заснуть СРАЗУ, как только след стал невидимым, а не крутиться
 *   «на всякий случай»: запись ведёт отдельный счётчик энергии, который гаснет по тому же
 *   закону, что и сама текстура, и по нему знает момент, когда рисовать уже нечего.
 *
 * Второе, ради чего запись стоит читать, — АДВЕКЦИЯ. Наивный след читает предыдущий кадр
 * в той же точке и получает просто гаснущее пятно. Здесь выборка берётся со сдвигом против
 * скорости курсора: содержимое буфера каждый кадр немного едет, и получается тянущийся
 * мазок, а не цепочка кружков.
 *
 * Маска не вырезает снимок, а ПРЕЛОМЛЯЕТ его: смещение выборки берётся из градиента маски,
 * то есть из наклона поверхности. Поэтому у кромки следа картинка отклоняется, как за
 * толщей воды, а не обрывается ножницами.
 */
export function FluidReveal({
  src = "https://motiva.pages.dev/img/1015-1600x1000.jpg",
  radius = 0.16,
  decay = 0.98,
  warp = 0.06,
  backdrop = "var(--ds-bg, oklch(0.11 0.02 275))",
}: FluidRevealProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ radius, decay, warp });
  cfg.current = { radius, decay, warp };

  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 geometry = new Triangle(gl);
    const empty = new RenderTarget(gl, { width: 2, height: 2 });
    const imgTex = new Texture(gl, { generateMipmaps: false, wrapS: gl.CLAMP_TO_EDGE, wrapT: gl.CLAMP_TO_EDGE });

    const sim = new Program(gl, {
      vertex: VERT,
      fragment: SIM,
      uniforms: {
        tPrev: { value: empty.texture },
        uPointer: { value: [-1, -1] },
        uVel: { value: [0, 0] },
        uRadius: { value: radius },
        uDecay: { value: decay },
        uAspect: { value: 1 },
        uStamp: { value: 0 },
      },
    });
    const show = new Program(gl, {
      vertex: VERT,
      fragment: SHOW,
      uniforms: {
        tMask: { value: empty.texture },
        uImage: { value: imgTex },
        uRes: { value: [1, 1] },
        uImgRes: { value: [1, 1] },
        uWarp: { value: warp },
      },
    });
    const simMesh = new Mesh(gl, { geometry, program: sim });
    const showMesh = new Mesh(gl, { geometry, program: show });

    let a = new RenderTarget(gl, { width: 2, height: 2 });
    let b = new RenderTarget(gl, { width: 2, height: 2 });

    const wipe = (rt: RenderTarget) => {
      gl.bindFramebuffer(gl.FRAMEBUFFER, rt.buffer);
      // gl.viewport здесь НЕ трогать: clear чистит весь фреймбуфер независимо от
      // viewport, а ручной вызов мимо OGL ломает его кеш состояния — следующий
      // present() «восстанавливал» полный размер вхолостую, и кадр после засыпания
      // рисовался в четверти холста (картинка «съезжала в левый угол»).
      gl.clearColor(0, 0, 0, 1);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    };

    const present = () => {
      show.uniforms.tMask!.value = a.texture;
      show.uniforms.uWarp!.value = cfg.current.warp;
      renderer.render({ scene: showMesh });
    };

    const resize = () => {
      const { clientWidth: w, clientHeight: h } = el;
      if (!w || !h) return;
      renderer.setSize(w, h);
      show.uniforms.uRes!.value = [w, h];
      sim.uniforms.uAspect!.value = w / h;
      // симуляцию держим ВДВОЕ грубее показа: след мягкий, лишнего разрешения ему
      // не нужно, а стоит оно вчетверо дороже
      const W = Math.max(2, Math.round(gl.canvas.width / 2));
      const H = Math.max(2, Math.round(gl.canvas.height / 2));
      if (W === a.width && H === a.height) return present();
      a = new RenderTarget(gl, { width: W, height: H });
      b = new RenderTarget(gl, { width: W, height: H });
      wipe(a);
      wipe(b);
      present();
    };

    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      imgTex.image = img;
      show.uniforms.uImgRes!.value = [img.width, img.height];
      present();
    };
    img.src = src;

    let raf = 0;
    let px = -1;
    let py = -1;
    let vx = 0;
    let vy = 0;
    let stamp = 0;
    /** Верхняя оценка того, что осталось в буфере: гаснет по тому же закону. */
    let energy = 0;

    const step = () => {
      const d = Math.min(0.999, Math.max(0.8, cfg.current.decay));
      sim.uniforms.uRadius!.value = cfg.current.radius;
      sim.uniforms.uDecay!.value = d;
      sim.uniforms.uPointer!.value = [px, py];
      sim.uniforms.uVel!.value = [vx, vy];
      sim.uniforms.uStamp!.value = stamp;
      sim.uniforms.tPrev!.value = a.texture;
      renderer.render({ scene: simMesh, target: b });
      const t = a;
      a = b;
      b = t;
      present();

      energy = Math.max(energy * d, stamp);
      // мазок ставится один раз на движение, скорость гасим — иначе адвекция
      // продолжит тащить след, когда рука уже стоит
      stamp = 0;
      vx *= 0.86;
      vy *= 0.86;
    };

    const loop = () => {
      step();
      if (energy > 0.004) {
        raf = requestAnimationFrame(loop);
      } else {
        raf = 0;
        // след стал невидимым (порог показа 0.04) — обнуляем буферы и уходим спать
        wipe(a);
        wipe(b);
        present();
      }
    };
    const wake = () => {
      if (raf || frozen) return;
      raf = requestAnimationFrame(loop);
    };

    const onMove = (e: PointerEvent) => {
      const r = el.getBoundingClientRect();
      const nx = (e.clientX - r.left) / r.width;
      const ny = 1 - (e.clientY - r.top) / r.height;
      const dx = px >= 0 ? nx - px : 0;
      const dy = px >= 0 ? ny - py : 0;
      // Адвекция берётся ДОЛЕЙ перемещения, и доля маленькая. При сильной весь буфер
      // за кадр уезжает на десятки пикселей, след догоняет голову и слипается с ней
      // в одно пятно: путь, по которому вели, пропадает.
      vx = dx * 0.18;
      vy = dy * 0.18;
      px = nx;
      py = ny;
      // сильнее мажет тот, кто ведёт быстрее: медленное касание оставляет лёгкий след
      stamp = Math.min(1, 0.35 + Math.hypot(dx, dy) * 8);
      energy = Math.max(energy, stamp);
      wake();
    };
    const onLeave = () => {
      px = -1;
      py = -1;
      stamp = 0;
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);

    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);
    const io = new IntersectionObserver(([e]) => {
      if (!e!.isIntersecting) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    });
    io.observe(el);
    const onVis = () => {
      if (document.hidden) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    };
    document.addEventListener("visibilitychange", onVis);

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

  return (
    <div
      ref={host}
      className="flr"
      style={{
        position: "absolute",
        inset: 0,
        touchAction: "none",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    />
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — https://codepen.io/ksenia-k/pen/dyaeGgO (лицензия не указана, код не заимствован)v1.0.0