Depth Scan
Фотография разобрана на облако точек, и по кадру идёт медленная волна глубины: на гребне точки выходят из плоскости тем дальше, чем ярче пиксель, за гребнем снимок снова ложится плоско. Курсор поворачивает облако и поднимает его под собой. Движение намеренно спокойное: точки не дрожат покадрово, размер и яркость на гребне не пульсируют, поэтому кадр не рябит; reduced-motion останавливает гребень посреди снимка. Глубина берётся из яркости — это приём рельефа, а не карта глубины.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/depth-scan.jsonзависимости: three@^0.185.1 · @types/three@^0.185.0
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import { createDepthScan, type DepthScan as Engine, type DepthScanOptions } from "@/lib/depth-scan-engine";
export type DepthScanProps = Omit<DepthScanOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const NIGHT = "radial-gradient(110% 90% at 50% 40%, oklch(0.19 0.02 255) 0%, oklch(0.11 0.015 250) 60%, oklch(0.07 0.01 245) 100%)";
/**
* Depth Scan — фотография как облако точек, по которому идёт волна глубины: на гребне
* точки выходят из плоскости тем дальше, чем ярче пиксель, за гребнем кадр снова ложится
* плоско. Курсор поворачивает облако и делает местный наплыв.
*
* Сцена — `createDepthScan` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` останавливают гребень посреди кадра.
*/
export function DepthScan({ backdrop = NIGHT, ...options }: DepthScanProps = {}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engine = useRef<Engine | null>(null);
const first = useRef(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const animate = !(
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
engine.current = createDepthScan(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { image, density, depth, speed, band, pointSize, parallax, laser } = options;
useEffect(() => {
engine.current?.set({ image, density, depth, speed, band, pointSize, parallax, laser });
}, [image, density, depth, speed, band, pointSize, parallax, laser]);
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
...(backdrop === false ? null : { background: backdrop }),
}}
>
<canvas ref={canvasRef} aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
import * as THREE from "three";
/**
* Depth Scan — движок на голом three.js (без React). Фотография разбирается на облако
* точек, и по кадру идёт ВОЛНА ГЛУБИНЫ: на гребне точки выходят из плоскости тем дальше,
* чем ярче пиксель, за гребнем кадр снова ложится плоско. Курсор даёт параллакс и местный
* наплыв, так что на гребне видно настоящий объём, а не просто дрожь.
*
* `createDepthScan(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) ставит гребень посреди кадра и рисует один кадр.
*/
export interface DepthScanOptions {
/** Путь к снимку (тот же домен: пиксели читаются с canvas). */
image?: string;
/** Плотность: точек по горизонтали. */
density?: number;
/** Глубина: как далеко уходят точки на гребне. */
depth?: number;
/** Скорость волны, проходов в секунду; 0 — стоп. */
speed?: number;
/** Ширина гребня, доля высоты кадра. */
band?: number;
/** Размер точки в пикселях. */
pointSize?: number;
/** Отклик на курсор: поворот облака и местный наплыв. */
parallax?: number;
/** Цвет гребня. */
laser?: string;
/** false — стоячий кадр с гребнем посреди снимка. */
animate?: boolean;
}
export interface DepthScan {
set(next: Partial<DepthScanOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<DepthScanOptions> = {
image: "https://motiva.pages.dev/img/1024-900x700.jpg",
density: 260,
depth: 0.5,
speed: 0.14,
band: 0.24,
pointSize: 4.6,
parallax: 0.55,
laser: "#7dd3fc",
animate: true,
};
const VERT = /* glsl */ `
attribute float aLum;
attribute float aRnd;
uniform float uScan;
uniform float uDepth;
uniform float uBand;
uniform float uSize;
uniform float uBulge;
uniform vec2 uMouse;
uniform vec2 uHalf;
uniform float uPixelRatio;
varying vec3 vColor;
varying float vCrest;
void main() {
vec3 p = position;
float v = p.y / (uHalf.y * 2.0) + 0.5; // 0..1 по высоте кадра
// знаковое расстояние до линии скана: > 0 — кадр ещё не пройден
float d = v - uScan;
// гребень: узкая полоса, где точки выходят из плоскости
float crest = exp(-(d * d) / (uBand * uBand));
// за гребнем остаточный объём быстро спадает — кадр снова ложится плоско
float resid = exp(-max(-d, 0.0) * 4.5) * 0.32;
// ЯРКОСТЬ КАК ГЛУБИНА: приём рельефа, а не настоящая карта глубины —
// светлое выходит на зрителя, тёмное уходит назад
float z = uDepth * (aLum - 0.45) * (crest + resid);
// местный наплыв под курсором — объём можно потрогать, не дожидаясь волны
vec2 m = uMouse * uHalf;
float md = length((p.xy - m) / uHalf.y);
z += uDepth * (aLum - 0.45) * exp(-md * md * 6.0) * uBulge;
// НИКАКОГО дрожания по времени: точка размером в 2–4 пикселя, сдвинутая
// покадрово, перескакивает по пикселям и даёт рябь — именно от неё кадр
// «блымает». Разброс облака задан РАЗ, статическим джиттером решётки в sample().
p.z += z;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
// размер в пикселях с перспективным затуханием: ближние точки крупнее.
// На гребне размер НЕ раздувается: пульсация размера — это тоже мерцание.
gl_PointSize = uSize * uPixelRatio * (2.2 / max(-mv.z, 0.001));
vColor = color;
vCrest = crest;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform vec3 uLaser;
varying vec3 vColor;
varying float vCrest;
void main() {
// круглая точка с ДЛИННОЙ мягкой кромкой: соседние точки перекрываются
// полупрозрачными краями, и облако остаётся сплошным вместо «сетки искр»
vec2 q = gl_PointCoord - 0.5;
float r = dot(q, q);
if (r > 0.25) discard;
float a = smoothstep(0.25, 0.0, r);
// квадрат гребня: подкрашивается только самая его середина, снимок остаётся собой.
// Ни яркость, ни прозрачность точки при этом не прыгают — гребень виден
// смещением и подкраской, а не вспышкой.
float glow = vCrest * vCrest;
vec3 col = mix(vColor * 1.1, uLaser, min(glow * 0.32, 0.28));
col += uLaser * glow * 0.08;
gl_FragColor = vec4(col, a * 0.92);
}
`;
interface Sampled {
positions: Float32Array;
colors: Float32Array;
lum: Float32Array;
rnd: Float32Array;
cols: number;
rows: number;
aspect: number;
}
/**
* Сглаживает поле яркости, по которому потом считается глубина (цвет остаётся резким).
*
* Зачем: если глубину брать из СЫРОЙ яркости, соседние точки уезжают в разные стороны
* на полный размах — облако не поднимается поверхностью, а рассыпается шумом, и кадр
* рябит. Мелкая деталь снимка должна жить в цвете, а геометрия обязана быть гладкой.
* Размытие раздельное (сначала по строкам, потом по столбцам), радиус в клетках решётки.
*/
function smooth(src: Float32Array, cols: number, rows: number, radius = 3): Float32Array {
const tmp = new Float32Array(src.length);
const out = new Float32Array(src.length);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
let sum = 0;
let n = 0;
for (let k = -radius; k <= radius; k++) {
const xx = x + k;
if (xx < 0 || xx >= cols) continue;
sum += src[y * cols + xx]!;
n++;
}
tmp[y * cols + x] = sum / n;
}
}
for (let x = 0; x < cols; x++) {
for (let y = 0; y < rows; y++) {
let sum = 0;
let n = 0;
for (let k = -radius; k <= radius; k++) {
const yy = y + k;
if (yy < 0 || yy >= rows) continue;
sum += tmp[yy * cols + x]!;
n++;
}
out[y * cols + x] = sum / n;
}
}
return out;
}
/** Читает снимок в решётку точек: цвет, яркость (сглаженную — она задаёт глубину) и зерно. */
function sample(img: HTMLImageElement, density: number, halfH: number): Sampled {
const aspect = img.naturalWidth / img.naturalHeight || 1;
const cols = Math.max(24, Math.round(density));
const rows = Math.max(16, Math.round(cols / aspect));
const c = document.createElement("canvas");
c.width = cols;
c.height = rows;
const ctx = c.getContext("2d", { willReadFrequently: true })!;
ctx.drawImage(img, 0, 0, cols, rows);
const data = ctx.getImageData(0, 0, cols, rows).data;
const n = cols * rows;
const positions = new Float32Array(n * 3);
const colors = new Float32Array(n * 3);
const lum = new Float32Array(n);
const rnd = new Float32Array(n);
const halfW = halfH * aspect;
let seed = 20260912;
const rand = () => ((seed = (16807 * seed) % 0x7fffffff) - 1) / 0x7ffffffe;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = y * cols + x;
const s = i * 4;
const r = data[s]! / 255;
const g = data[s + 1]! / 255;
const b = data[s + 2]! / 255;
// точка сдвинута внутри своей клетки: ровная решётка даёт муар «сетки от комаров»
const jx = (rand() - 0.5) * 0.7;
const jy = (rand() - 0.5) * 0.7;
positions[i * 3] = ((x + 0.5 + jx) / cols - 0.5) * halfW * 2;
// строки картинки идут сверху вниз, ось Y сцены — снизу вверх
positions[i * 3 + 1] = (0.5 - (y + 0.5 + jy) / rows) * halfH * 2;
positions[i * 3 + 2] = 0;
colors[i * 3] = r;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
lum[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
rnd[i] = rand();
}
}
return { positions, colors, lum: smooth(lum, cols, rows), rnd, cols, rows, aspect };
}
export function createDepthScan(canvas: HTMLCanvasElement, options: DepthScanOptions = {}): DepthScan {
const cfg: Required<DepthScanOptions> = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const HALF_H = 1; // половина высоты кадра в единицах сцены
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: "high-performance" });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(dpr);
renderer.setClearColor(0, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 40);
const cloud = new THREE.Group();
scene.add(cloud);
const uniforms = {
uScan: { value: 0.5 },
uDepth: { value: cfg.depth },
uBand: { value: cfg.band },
uSize: { value: cfg.pointSize },
uBulge: { value: 0 },
uTime: { value: 0 },
uMouse: { value: new THREE.Vector2(0, 0) },
uHalf: { value: new THREE.Vector2(HALF_H, HALF_H) },
uPixelRatio: { value: dpr },
uLaser: { value: new THREE.Color(cfg.laser) },
};
const material = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms,
transparent: true,
depthWrite: false,
vertexColors: true,
});
let geometry: THREE.BufferGeometry | null = null;
let points: THREE.Points | null = null;
let img: HTMLImageElement | null = null;
let imgAspect = 1;
let disposed = false;
function build(): void {
if (!img || disposed) return;
if (points) {
cloud.remove(points);
points = null;
}
geometry?.dispose();
const s = sample(img, cfg.density, HALF_H);
imgAspect = s.aspect;
geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(s.positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(s.colors, 3));
geometry.setAttribute("aLum", new THREE.BufferAttribute(s.lum, 1));
geometry.setAttribute("aRnd", new THREE.BufferAttribute(s.rnd, 1));
uniforms.uHalf.value.set(HALF_H * s.aspect, HALF_H);
points = new THREE.Points(geometry, material);
points.frustumCulled = false; // точки уезжают по z дальше габарита решётки
cloud.add(points);
fit();
}
/** Камера отодвигается так, чтобы снимок вписался в кадр целиком. */
function fit(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
const viewAspect = w / h;
const fovRad = (camera.fov * Math.PI) / 180;
const byHeight = HALF_H / Math.tan(fovRad / 2);
const byWidth = (HALF_H * imgAspect) / (Math.tan(fovRad / 2) * viewAspect);
camera.position.set(0, 0, Math.max(byHeight, byWidth) * 1.04);
camera.aspect = viewAspect;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
}
const loader = new THREE.ImageLoader();
loader.setCrossOrigin("anonymous");
loader.load(
cfg.image,
(loaded) => {
if (disposed) return;
img = loaded;
build();
frame();
start();
},
undefined,
() => console.warn("[depth-scan] снимок не загрузился:", cfg.image),
);
const pointer = { x: 0, y: 0, tx: 0, ty: 0, over: 0, tover: 0 };
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
pointer.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.ty = -(((e.clientY - r.top) / r.height) * 2 - 1);
pointer.tover = 1;
};
const onPointerLeave = () => {
pointer.tover = 0;
pointer.tx = 0;
pointer.ty = 0;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onPointerLeave);
let raf = 0;
let last = 0;
let scan = 0.7; // ход волны, 0…1.35 по кругу
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
uniforms.uTime.value += dt;
uniforms.uDepth.value = cfg.depth;
uniforms.uBand.value = cfg.band;
uniforms.uSize.value = cfg.pointSize;
uniforms.uLaser.value.set(cfg.laser);
if (animate) {
// Волна идёт снизу вверх и уходит за кромку, прежде чем начать заново.
// Ход накапливается в ОТДЕЛЬНОЙ переменной: если гонять по кругу сам uniform,
// из которого уже вычтено смещение, линия скана прыгает на это смещение
// КАЖДЫЙ кадр — вместо волны получается стробоскоп (так и было в первой версии).
scan = (scan + dt * cfg.speed) % 1.35;
uniforms.uScan.value = scan - 0.18;
} else {
uniforms.uScan.value = 0.52;
}
pointer.x += (pointer.tx - pointer.x) * 0.06;
pointer.y += (pointer.ty - pointer.y) * 0.06;
pointer.over += (pointer.tover - pointer.over) * 0.06;
uniforms.uMouse.value.set(pointer.x, pointer.y);
uniforms.uBulge.value = pointer.over * cfg.parallax * 0.7;
// облако поворачивается за курсором: параллакс проявляет объём на гребне
cloud.rotation.y = pointer.x * cfg.parallax * 0.32;
cloud.rotation.x = -pointer.y * cfg.parallax * 0.18;
renderer.render(scene, camera);
}
const loop = (now: number) => {
frame(now);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate || !points) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const ro = new ResizeObserver(() => {
fit();
frame();
});
ro.observe(canvas);
fit();
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(canvas);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
const onLost = (e: Event) => {
e.preventDefault();
stop();
};
canvas.addEventListener("webglcontextlost", onLost);
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<DepthScanOptions>;
const rebuild = patch.density !== undefined && Math.round(patch.density) !== Math.round(cfg.density);
const reload = patch.image !== undefined && patch.image !== cfg.image;
Object.assign(cfg, patch);
if (reload) {
loader.load(cfg.image, (loaded) => {
if (disposed) return;
img = loaded;
build();
frame();
});
return;
}
if (rebuild) build();
if (!animate) frame();
},
destroy() {
disposed = true;
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onPointerLeave);
canvas.removeEventListener("webglcontextlost", onLost);
geometry?.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}