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

Distortion Sphere

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

threemouse-moveautomorphadvanced~160 КБreduced-motion ✓

Установка

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

зависимости: three@^0.185.1 · @react-three/fiber@^9.6.1

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { Canvas, useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";

const vertex = /* 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 fragment = /* 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);
  }
`;

function Blob({ frozen }: { frozen: boolean }) {
  const mat = useRef<THREE.ShaderMaterial>(null);
  const mesh = useRef<THREE.Mesh>(null);
  const targetAmp = useRef(0.18);
  const uniforms = useMemo(
    () => ({
      uTime: { value: 0 },
      uAmp: { value: 0.18 },
    }),
    [],
  );

  useFrame((_, delta) => {
    if (!mat.current || !mesh.current) return;
    // ВАЖНО: пишем в uniforms ЖИВОГО материала. R3F копирует объект из пропа `uniforms`
    // в свой (Object.assign на каждый ключ) — мутация исходного объекта до GPU не доедет.
    const u = mat.current.uniforms;
    u.uTime!.value += delta;
    u.uAmp!.value += (targetAmp.current - u.uAmp!.value) * 0.06;
    targetAmp.current += (0.18 - targetAmp.current) * 0.02; // спад к покою
    mesh.current.rotation.y += delta * 0.15;
  });

  return (
    <mesh
      ref={mesh}
      onPointerMove={(e) => {
        if (frozen) return;
        // амплитуда растёт от скорости курсора по сфере
        targetAmp.current = Math.min(0.65, targetAmp.current + (Math.abs(e.movementX) + Math.abs(e.movementY)) * 0.002);
      }}
    >
      <icosahedronGeometry args={[1.35, 48]} />
      <shaderMaterial ref={mat} uniforms={uniforms} vertexShader={vertex} fragmentShader={fragment} />
    </mesh>
  );
}

/** Дышащая дисторсия-сфера (vertex-шум в шейдере); движение курсора по ней раскачивает амплитуду. */
export function DistortionSphere() {
  const frozen =
    typeof document !== "undefined" &&
    (document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches);

  return (
    <div style={{ position: "absolute", inset: 0 }}>
      <Canvas camera={{ position: [0, 0, 4], fov: 50 }} frameloop={frozen ? "demand" : "always"} dpr={[1, 2]}>
        <Blob frozen={frozen} />
      </Canvas>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0