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

Floating Blob

Парящий деформирующийся 3D-блоб с физическим материалом: рельеф дописан в вершинную программу штатного PBR-материала через onBeforeCompile — голый three.js, без drei.

threeautospringadvanced~160 КБreduced-motion ✓
↗

Пропсы

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

ПропТипПо умолчаниюЧто это
color?string"#8b5cf6"Цвет блоба.
distort?number0.4Глубина деформации поверхности.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/floating-blob.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 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 }} />;
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0