Distortion Sphere
Дышащая сфера с вершинной дисторсией (слоистый шум в vertex-шейдере) и фейк-френелем; курсор раскачивает амплитуду.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| calm? | number | 0.18 | Амплитуда покоя: насколько сфера «дышит» без курсора. |
| peak? | number | 0.65 | Предел амплитуды, до которого её раскачивает курсор. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/distortion-sphere.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 DistortionSphereProps {
/** Амплитуда покоя: насколько сфера «дышит» без курсора. */
calm?: number;
/** Предел амплитуды, до которого её раскачивает курсор. */
peak?: number;
}
const VERT = /* glsl */ `
uniform float uTime;
uniform float uAmp;
varying float vNoise;
varying vec3 vNormal;
// компактный слоистый trig-шум — без тяжёлого simplex, для сферы выглядит органично
float noise3(vec3 p) {
return (
sin(p.x * 2.1 + uTime * 0.9) * 0.45 +
sin(p.y * 3.3 + uTime * 0.7) * 0.3 +
sin(p.z * 4.2 + uTime * 1.2) * 0.25 +
sin((p.x + p.y + p.z) * 5.0 - uTime * 1.5) * 0.18
);
}
void main() {
vNormal = normal;
float n = noise3(position);
vNoise = n;
vec3 displaced = position + normal * n * uAmp;
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
`;
const FRAG = /* glsl */ `
varying float vNoise;
varying vec3 vNormal;
void main() {
// окраска по «высоте» шума + фейк-френель по нормали
float fresnel = pow(1.0 - abs(vNormal.z), 2.0);
vec3 base = mix(vec3(0.32, 0.16, 0.62), vec3(0.94, 0.42, 0.62), vNoise * 0.5 + 0.5);
vec3 color = base + fresnel * vec3(0.35, 0.5, 0.9);
gl_FragColor = vec4(color, 1.0);
}
`;
/**
* Дышащая сфера с вершинной дисторсией на голом three.js (без React Three Fiber).
*
* Шум считается в вершинном шейдере от исходной позиции вершины и смещает её вдоль
* нормали, поэтому рельеф «живёт» на поверхности, а не плывёт по кадру. Движение
* курсора ПО САМОЙ СФЕРЕ (проверяется лучом) подкачивает амплитуду, после чего она
* сама спадает к покою. Цикл засыпает вне зоны видимости и на скрытой вкладке;
* `prefers-reduced-motion` и `data-shot` рисуют один кадр.
*/
export function DistortionSphere({ calm = 0.18, peak = 0.65 }: DistortionSphereProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ calm, peak });
cfg.current = { calm, peak };
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));
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(50, 1, 0.1, 100);
camera.position.set(0, 0, 4);
const uniforms = { uTime: { value: 0 }, uAmp: { value: cfg.current.calm } };
const geometry = new THREE.IcosahedronGeometry(1.35, 48);
const material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms });
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);
// курсор считается попавшим по сфере лучом из камеры: пролёт мимо блоба
// амплитуду не качает — ровно как в версии на событиях меша
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let target = cfg.current.calm;
const onPointerMove = (e: PointerEvent) => {
if (frozen) return;
const r = renderer.domElement.getBoundingClientRect();
ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
if (!raycaster.intersectObject(mesh, false).length) return;
const move = Math.abs(e.movementX) + Math.abs(e.movementY);
target = Math.min(cfg.current.peak, target + move * 0.002);
};
renderer.domElement.addEventListener("pointermove", onPointerMove);
let raf = 0;
let last = 0;
const loop = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
uniforms.uTime.value += dt;
uniforms.uAmp.value += (target - uniforms.uAmp.value) * 0.06;
target += (cfg.current.calm - target) * 0.02; // спад к покою
mesh.rotation.y += dt * 0.15;
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);
renderer.domElement.removeEventListener("pointermove", onPointerMove);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
};
}, []);
return <div ref={host} style={{ position: "absolute", inset: 0 }} />;
}