Cloth Drape
Полотно со снимком висит на кромке и ведёт себя как настоящая ткань: провисает под тяжестью, ходит от порывов ветра и идёт волной от касания курсора. Форма считается верле-симуляцией с жёсткими связями (соседние узлы плюс диагонали), поэтому складка, пущенная пальцем, честно доезжает до кромки и затухает, а не повторяется по кругу. Жёсткость меняет ткань от шёлка до брезента, подвес переключается между верхней кромкой, двумя углами и левым краем.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/cloth-drape.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 { createClothDrape, type ClothDrape as Engine, type ClothDrapeOptions } from "@/lib/cloth-drape-engine";
export type ClothDrapeProps = Omit<ClothDrapeOptions, "animate"> & {
/** Подложка под холст: CSS `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
};
const ROOM =
"radial-gradient(120% 100% at 50% 0%, oklch(0.3 0.02 265) 0%, oklch(0.18 0.015 262) 50%, oklch(0.11 0.01 258) 100%)";
/**
* Cloth Drape — полотно со снимком висит на кромке и ведёт себя как ткань: провисает,
* качается от ветра, идёт волной от касания курсора. Ткань считается верле-симуляцией
* с жёсткими связями, поэтому складка доезжает до кромки и затухает сама.
*
* Сцена — `createClothDrape` из соседнего файла; компонент держит холст, прокидывает
* крутилки через `set()` и разбирает всё при размонтировании.
* `prefers-reduced-motion` и `data-shot` рисуют один стоячий кадр провисшей ткани.
*/
export function ClothDrape({ backdrop = ROOM, ...options }: ClothDrapeProps = {}) {
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 = createClothDrape(canvas, { ...first.current, animate });
return () => {
engine.current?.destroy();
engine.current = null;
};
}, []);
const { image, density, gravity, wind, stiffness, touch, pins } = options;
useEffect(() => {
engine.current?.set({ image, density, gravity, wind, stiffness, touch, pins });
}, [image, density, gravity, wind, stiffness, touch, pins]);
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";
/**
* Cloth Drape — движок на голом three.js (без React). Полотно с фотографией висит
* на верхней кромке и ведёт себя как ткань: провисает под тяжестью, качается от ветра,
* идёт волной от прикосновения курсора. Ткань считается ВЕРЛЕ-симуляцией (positions +
* prev positions + жёсткие связи), а не синусами: поэтому складка, однажды пущенная
* пальцем, честно доезжает до кромки и затухает, а не повторяется по кругу.
*
* `createClothDrape(canvas, options)` → { set(options), destroy() }.
* Цикл засыпает вне зоны видимости и на скрытой вкладке; `animate: false`
* (reduced-motion / `data-shot`) рисует один стоячий кадр уже провисшей ткани.
*/
export interface ClothDrapeOptions {
/** Снимок на полотне. */
image?: string;
/** Плотность сетки по ширине (по высоте — пропорционально). */
density?: number;
/** Тяжесть ткани. */
gravity?: number;
/** Сила ветра. */
wind?: number;
/** Жёсткость: 0 — шёлк, 1 — брезент. */
stiffness?: number;
/** Радиус касания курсора. */
touch?: number;
/** Как ткань подвешена. */
pins?: "top" | "corners" | "left";
/** Слабина подвеса: 1 — натянуто в струну, 0.8 — заметные вертикальные складки. */
slack?: number;
/** false — один стоячий кадр (reduced-motion, скриншоты). */
animate?: boolean;
}
export interface ClothDrape {
set(next: Partial<ClothDrapeOptions>): void;
destroy(): void;
}
const DEFAULTS: Required<ClothDrapeOptions> = {
image: "https://motiva.pages.dev/img/1015-1600x1000.jpg",
density: 46,
gravity: 0.45,
wind: 0.5,
stiffness: 0.45,
touch: 0.55,
pins: "top",
slack: 0.8,
animate: true,
};
const VERT = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vView;
void main() {
vUv = uv;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vNormal = normalize(normalMatrix * normal);
vView = -mv.xyz;
gl_Position = projectionMatrix * mv;
}
`;
/**
* Свет у ткани простой и намеренно не PBR: ламберт от одного источника плюс мягкий
* блик и подсветка «на просвет» по краю. Снимок при этом остаётся снимком — ткань
* читается складками, а не перекрашиванием.
*/
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uTex;
uniform float uSheen;
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vView;
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(vView);
if (!gl_FrontFacing) n = -n;
// свет НАСКОЛЬЗЬ, а не в лоб: только при скользящем свете складка даёт
// контраст между гребнем и впадиной и ткань читается тканью
vec3 light = normalize(vec3(-0.78, 0.42, 0.46));
float lambert = 0.32 + 0.78 * max(dot(n, light), 0.0);
float spec = pow(max(dot(reflect(-light, n), v), 0.0), 26.0) * uSheen;
// на просвет: кромка складки, отвёрнутая от света, слегка светится
float rim = pow(1.0 - max(dot(n, v), 0.0), 3.0) * 0.25;
vec3 tex = texture2D(uTex, vUv).rgb;
vec3 col = tex * lambert + vec3(1.0, 0.98, 0.94) * spec + tex * rim;
col = vec3(1.0) - exp(-col * 1.25);
gl_FragColor = vec4(col, 1.0);
}
`;
export function createClothDrape(canvas: HTMLCanvasElement, options: ClothDrapeOptions = {}): ClothDrape {
const cfg: Required<ClothDrapeOptions> = { ...DEFAULTS, ...options };
const animate = cfg.animate;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 60);
camera.position.set(0, 0, 6.2);
const uniforms = { uTex: { value: null as THREE.Texture | null }, uSheen: { value: 0.5 } };
const material = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms,
side: THREE.DoubleSide,
});
let texture: THREE.Texture | undefined;
let disposed = false;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
const load = (src: string) => {
loader.load(src, (t) => {
if (disposed) {
t.dispose();
return;
}
t.colorSpace = THREE.SRGBColorSpace;
texture?.dispose();
texture = t;
uniforms.uTex.value = t;
frame();
});
};
load(cfg.image);
/* ------------------------------------------------------ верле-симуляция */
let COLS = 0;
let ROWS = 0;
let W = 0;
let H = 0;
let pos = new Float32Array(0);
let prev = new Float32Array(0);
let pinned = new Uint8Array(0);
let restH = 0;
let restV = 0;
let geometry: THREE.PlaneGeometry | null = null;
let mesh: THREE.Mesh | null = null;
/**
* Складки не берутся из ниоткуда: у ровно натянутой ткани их нет. Точки подвеса
* сдвигаются внутрь (слабина), полотно оказывается шире своего подвеса и обязано
* собраться вертикальными складками — так это работает и у настоящей шторы.
*/
function applyPins(): void {
pinned.fill(0);
const pin = (c: number, r: number) => {
if (c >= 0 && c < COLS && r >= 0 && r < ROWS) pinned[r * COLS + c] = 1;
};
if (cfg.pins === "corners") {
pin(0, 0);
pin(COLS - 1, 0);
} else if (cfg.pins === "left") {
for (let r = 0; r < ROWS; r++) pin(0, r);
} else {
for (let c = 0; c < COLS; c++) pin(c, 0);
}
// сжимаем закреплённые точки к центру подвеса
const k = Math.min(1, Math.max(0.6, cfg.slack));
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const i = r * COLS + c;
if (!pinned[i]) continue;
const j = i * 3;
if (cfg.pins === "left") {
pos[j + 1] = pos[j + 1]! * k;
prev[j + 1] = pos[j + 1]!;
} else {
pos[j] = pos[j]! * k;
prev[j] = pos[j]!;
}
}
}
}
function build(): void {
if (mesh) {
scene.remove(mesh);
mesh = null;
}
geometry?.dispose();
COLS = Math.max(12, Math.round(cfg.density));
ROWS = Math.max(8, Math.round(COLS * 0.68));
W = 5.0;
H = W * (ROWS / COLS) * 1.05;
restH = W / (COLS - 1);
restV = H / (ROWS - 1);
geometry = new THREE.PlaneGeometry(W, H, COLS - 1, ROWS - 1);
const attr = geometry.attributes.position as THREE.BufferAttribute;
pos = new Float32Array(attr.array.length);
pos.set(attr.array as Float32Array);
prev = new Float32Array(pos);
pinned = new Uint8Array(COLS * ROWS);
applyPins();
// лёгкая начальная неровность: без неё складки складываются симметрично
for (let i = 0; i < COLS * ROWS; i++) {
if (pinned[i]) continue;
const j = i * 3;
pos[j + 2] = Math.sin(i * 12.9898) * 0.012;
prev[j + 2] = pos[j + 2]!;
}
mesh = new THREE.Mesh(geometry, material);
mesh.frustumCulled = false;
scene.add(mesh);
}
/** Одна связь: тянет две точки к длине покоя, закреплённые не двигаются. */
function constrain(ia: number, ib: number, rest: number, k: number): void {
const ax = ia * 3;
const bx = ib * 3;
const dx = pos[bx]! - pos[ax]!;
const dy = pos[bx + 1]! - pos[ax + 1]!;
const dz = pos[bx + 2]! - pos[ax + 2]!;
const dist = Math.hypot(dx, dy, dz) || 1e-6;
const diff = ((dist - rest) / dist) * 0.5 * k;
const ox = dx * diff;
const oy = dy * diff;
const oz = dz * diff;
const wa = pinned[ia] ? 0 : 1;
const wb = pinned[ib] ? 0 : 1;
const sum = wa + wb || 1;
if (wa) {
pos[ax]! += (ox * 2 * wa) / sum;
pos[ax + 1]! += (oy * 2 * wa) / sum;
pos[ax + 2]! += (oz * 2 * wa) / sum;
}
if (wb) {
pos[bx]! -= (ox * 2 * wb) / sum;
pos[bx + 1]! -= (oy * 2 * wb) / sum;
pos[bx + 2]! -= (oz * 2 * wb) / sum;
}
}
const touch = { x: 0, y: 0, on: 0, px: 0, py: 0 };
let time = 0;
function step(dt: number): void {
const g = cfg.gravity * 6.5;
const damp = 0.982;
// Верле: скорость не хранится, она это разница с прошлым кадром. Отсюда
// устойчивость при жёстких связях — ткань не «взрывается» на большом шаге.
for (let i = 0; i < COLS * ROWS; i++) {
if (pinned[i]) continue;
const j = i * 3;
const px = pos[j]!;
const py = pos[j + 1]!;
const pz = pos[j + 2]!;
let vx = (px - prev[j]!) * damp;
let vy = (py - prev[j + 1]!) * damp;
let vz = (pz - prev[j + 2]!) * damp;
// ветер: порывами и по-разному вдоль полотна, иначе колышется как флаг из кино
const gust = Math.sin(time * 1.3 + px * 0.8 + py * 0.4) * 0.5 + Math.sin(time * 0.47 + py * 1.1) * 0.5;
vz += cfg.wind * gust * dt * 2.4;
vx += cfg.wind * gust * dt * 0.6;
vy -= g * dt * dt * 60;
// палец: отталкивает ткань от себя, складка потом уходит волной сама
if (touch.on > 0.01) {
const dx = px - touch.x;
const dy = py - touch.y;
const r = cfg.touch * 1.6;
const d2 = dx * dx + dy * dy;
if (d2 < r * r) {
const f = (1 - Math.sqrt(d2) / r) * touch.on;
vz -= f * 0.09;
vx += (touch.x - touch.px) * f * 0.35;
vy += (touch.y - touch.py) * f * 0.35;
}
}
prev[j] = px;
prev[j + 1] = py;
prev[j + 2] = pz;
pos[j] = px + vx;
pos[j + 1] = py + vy;
pos[j + 2] = pz + vz;
}
// связи: структурные (соседи) и диагональные (держат сдвиг). Жёсткость —
// это число проходов и их сила: мягкая ткань тянется, брезент почти нет
const k = 0.55 + 0.4 * cfg.stiffness;
const passes = 4 + Math.round(cfg.stiffness * 4);
for (let p = 0; p < passes; p++) {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const i = r * COLS + c;
if (c + 1 < COLS) constrain(i, i + 1, restH, k);
if (r + 1 < ROWS) constrain(i, i + COLS, restV, k);
if (c + 1 < COLS && r + 1 < ROWS) constrain(i, i + COLS + 1, Math.hypot(restH, restV), k * 0.5);
}
}
}
}
function pushToGeometry(): void {
if (!geometry) return;
const attr = geometry.attributes.position as THREE.BufferAttribute;
(attr.array as Float32Array).set(pos);
attr.needsUpdate = true;
geometry.computeVertexNormals();
}
/* -------- курсор в координатах полотна */
const onPointerMove = (e: PointerEvent) => {
if (!animate) return;
const r = canvas.getBoundingClientRect();
const nx = (e.clientX - r.left) / r.width - 0.5;
const ny = 0.5 - (e.clientY - r.top) / r.height;
const dist = camera.position.z;
const halfH = Math.tan((camera.fov * Math.PI) / 360) * dist;
touch.px = touch.x;
touch.py = touch.y;
touch.x = nx * halfH * 2 * camera.aspect;
touch.y = ny * halfH * 2;
touch.on = 1;
};
const onLeave = () => {
touch.on = 0;
};
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onLeave);
function resize(): void {
const w = canvas.clientWidth || window.innerWidth;
const h = canvas.clientHeight || window.innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
// полотно вписывается в кадр по меньшей стороне
// полотно занимает не весь кадр: должны быть видны кромка, углы и силуэт
const fit = Math.min((Math.tan((camera.fov * Math.PI) / 360) * camera.position.z * 2) / H, 1.6);
if (mesh) mesh.scale.setScalar(Math.max(0.45, fit * 0.72));
}
let raf = 0;
let last = 0;
function frame(now = 0): void {
const dt = animate && last ? Math.min((now - last) / 1000, 0.033) : 0;
last = now;
time += dt;
uniforms.uSheen.value = 0.25 + 0.5 * (1 - cfg.stiffness);
touch.on *= 0.94;
if (dt > 0) {
const sub = Math.max(1, Math.ceil(dt / 0.016));
for (let i = 0; i < sub; i++) step(dt / sub);
pushToGeometry();
}
renderer.render(scene, camera);
}
const loop = (now: number) => {
frame(now);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || !animate) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
build();
resize();
// стоп-кадр и старт: ткань уже провисла, а не висит доской
for (let i = 0; i < 90; i++) step(1 / 60);
pushToGeometry();
frame();
const ro = new ResizeObserver(() => {
resize();
frame();
});
ro.observe(canvas);
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);
start();
return {
set(next) {
// undefined в пропсах React — «не задано», а не «сбросить»
const patch = Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined)) as Partial<ClothDrapeOptions>;
const rebuild = patch.density !== undefined && Math.round(patch.density) !== Math.round(cfg.density);
const repin = patch.pins !== undefined && patch.pins !== cfg.pins;
const reload = patch.image !== undefined && patch.image !== cfg.image;
Object.assign(cfg, patch);
if (reload) load(cfg.image);
if (rebuild) {
build();
resize();
for (let i = 0; i < 90; i++) step(1 / 60);
pushToGeometry();
} else if (repin || patch.slack !== undefined) {
build();
resize();
for (let i = 0; i < 90; i++) step(1 / 60);
pushToGeometry();
}
if (!animate || rebuild || repin) frame();
},
destroy() {
disposed = true;
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
geometry?.dispose();
material.dispose();
texture?.dispose();
renderer.dispose();
renderer.forceContextLoss();
},
};
}