Motiva
Мои коллекции
← каталог

Distortion Sphere

Дышащая сфера с вершинной дисторсией (слоистый шум в vertex-шейдере) и фейк-френелем; курсор раскачивает амплитуду.

threemouse-moveautomorphadvanced~160 КБreduced-motion ✓
↗

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
calm?number0.18Амплитуда покоя: насколько сфера «дышит» без курсора.
peak?number0.65Предел амплитуды, до которого её раскачивает курсор.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/distortion-sphere.json

зависимости: three@^0.185.1 · @types/three@^0.185.0

Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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 }} />;
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0