Floating Blob
Парящий деформирующийся 3D-блоб с физическим материалом: рельеф дописан в вершинную программу штатного PBR-материала через onBeforeCompile — голый three.js, без drei.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| color? | string | "#8b5cf6" | Цвет блоба. |
| distort? | number | 0.4 | Глубина деформации поверхности. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/floating-blob.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 * as THREE from "three";
export interface FloatingBlobProps {
/** Цвет блоба. */
color?: string;
/** Глубина деформации поверхности. */
distort?: number;
/** Скорость дыхания и парения; 0 — стоп-кадр. */
speed?: number;
}
/**
* Шум для смещения вершин: три синуса вместо simplex — на сфере читается так же.
* Рядом — наклон поверхности: без него свет считает шар гладким и рельефа не видно
* (ровно так выглядела версия на drei-материале). Нормаль перекашивается на градиент
* высоты по двум касательным, конечными разностями.
*/
const NOISE = /* glsl */ `
uniform float uTime;
uniform float uDistort;
float blobNoise(vec3 p) {
// частоты выше радиуса сферы: иначе получается ровное яйцо, а не блоб с наплывами
return (
sin(p.x * 3.1 + uTime * 0.9) * 0.34 +
sin(p.y * 4.3 - uTime * 0.7) * 0.26 +
sin(p.z * 3.7 + uTime * 1.1) * 0.22 +
sin((p.x + p.y - p.z) * 6.2 + uTime * 1.4) * 0.14
);
}
vec3 blobNormal(vec3 p, vec3 n) {
// касательный базис берём от менее сонаправленной оси — иначе вырождается на полюсах
vec3 up = abs(n.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
vec3 t = normalize(cross(up, n));
vec3 b = cross(n, t);
float e = 0.06;
float h = blobNoise(p);
float dt = (blobNoise(p + t * e) - h) / e;
float db = (blobNoise(p + b * e) - h) / e;
return normalize(n - uDistort * (dt * t + db * b));
}
`;
/**
* Парящий деформирующийся блоб на голом three.js (без React Three Fiber и drei).
*
* Материал — штатный физический (`MeshPhysicalMaterial`), в его вершинную программу
* через `onBeforeCompile` дописано смещение по нормали на слоистый синус-шум: так
* сохраняется настоящий PBR-блик от двух источников, а поверхность при этом течёт.
* Парение — не анимация материала, а качание самого меша по двум осям с разными
* периодами, поэтому оно не повторяется на глаз. Цикл засыпает вне зоны видимости
* и на скрытой вкладке; `prefers-reduced-motion` и `data-shot` рисуют один кадр.
*/
export function FloatingBlob({ color = "#8b5cf6", distort = 0.4, speed = 1 }: FloatingBlobProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ speed });
cfg.current = { speed };
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 THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
Object.assign(renderer.domElement.style, { display: "block", width: "100%", height: "100%" });
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 4);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const key = new THREE.DirectionalLight(0xffffff, 1.4);
key.position.set(3, 3, 4);
const fill = new THREE.DirectionalLight(0x38bdf8, 0.5);
fill.position.set(-4, -2, 2);
scene.add(key, fill);
const uTime = { value: 0 };
const uDistort = { value: distort };
const geometry = new THREE.SphereGeometry(1.1, 64, 64);
const material = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(color),
roughness: 0.15,
metalness: 0.2,
});
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = uTime;
shader.uniforms.uDistort = uDistort;
// смещение вершины и перекос нормали дописываются в РАЗНЫЕ чанки штатной программы
const spots: [string, string][] = [
["#include <begin_vertex>", "transformed += normal * blobNoise(position) * uDistort;"],
["#include <beginnormal_vertex>", "objectNormal = blobNormal(position, objectNormal);"],
];
let patched = NOISE + shader.vertexShader;
for (const [anchor, code] of spots) {
if (!patched.includes(anchor)) {
// якорь чанка сменился в новой three: лучше знать об этом, чем тихо потерять рельеф
console.warn("[floating-blob] не найден", anchor, "— рельеф будет неполным");
continue;
}
patched = patched.replace(anchor, `${anchor}\n ${code}`);
}
shader.vertexShader = patched;
};
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const resize = () => {
const { clientWidth: w, clientHeight: h } = el;
if (!w || !h) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
renderer.render(scene, camera);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
let raf = 0;
let last = 0;
const loop = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
uTime.value += dt * cfg.current.speed;
const t = uTime.value;
// парение: два периода по вертикали и наклон третьим — узора не слышно
mesh.position.y = Math.sin(t * 0.9) * 0.12 + Math.sin(t * 0.37) * 0.06;
mesh.rotation.x = Math.sin(t * 0.31) * 0.22;
mesh.rotation.y = t * 0.18;
mesh.rotation.z = Math.sin(t * 0.23) * 0.14;
renderer.render(scene, camera);
raf = requestAnimationFrame(loop);
};
const start = () => {
if (raf || frozen) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
renderer.render(scene, camera);
const io = new IntersectionObserver(([e]) => (e!.isIntersecting && !document.hidden ? start() : stop()));
io.observe(el);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
return () => {
stop();
io.disconnect();
ro.disconnect();
document.removeEventListener("visibilitychange", onVis);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
};
}, [color, distort]);
return <div ref={host} style={{ position: "absolute", inset: 0 }} />;
}