← каталог
Fluid Reveal
Снимок проступает там, где курсор размазал жидкость, и след сам зарастает. Та же ping-pong-схема, что у bloom-garden, но с обратным разменом: след затухает, поэтому цикл кадров не может заснуть, пока он жив. Маска не вырезает снимок, а преломляет его — смещение берётся из градиента, то есть из наклона поверхности.
oglmouse-moverevealadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| src? | string | "https://motiva.pages.dev/img/1015-1600x… | Снимок, который проступает под курсором. |
| radius? | number | 0.16 | Радиус кисти, доля высоты кадра (формат «0.16»). |
| decay? | number | 0.98 | Насколько след живуч: 0.9 — тает на глазах, 0.99 — стоит долго (формат «0.98»). |
| warp? | number | 0.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
Что нужно в проекте до установки
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 { 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