← каталог
Grid Distortion
Картинка под резиновой сеткой: курсор оставляет расходящуюся волну, которая гаснет сама. Возмущение живёт в отдельной сетке-состоянии 22×22, а смещение берётся из её градиента — поэтому у волны есть инерция и след.
oglmouse-movemorphadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| grid? | number | 22 | Плотность сетки (формат «22»): крупнее клетка — грубее волна. |
| strength? | number | 0.22 | Сила смещения (формат «0.22»). |
| radius? | number | 0.28 | Радиус влияния курсора, доля кадра (формат «0.28»). |
| aberration? | number | 0.5 | Расщепление каналов по краю волны (формат «0.5»). |
| showGrid? | boolean | false | Показывать линии сетки. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/grid-distortion.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, 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