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

Silk

Атласная ткань: складки текут, и по их склонам бежит блик. Единственный фон каталога с моделью освещения — высотное поле складок (fbm с доменным искажением) даёт нормаль, дальше честный ламберт и два блика Блинна: широкий атласный перелив и узкая искра на гребнях. Повернёшь свет — перелив уедет за ним.

oglautoadvanced~11 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
scale?number1.6Крупность складок: 1 — широкие волны, 4 — мелкая рябь.
sheen?number0.75Сила блика: 0 — матовая ткань, 1 — атлас.
lightAngle?number35Направление света, градусы.
intensity?number1Общая яркость.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/silk.json

зависимости: ogl

Что нужно в проекте до установки
  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 { Mesh, Program, Renderer, Triangle } from "ogl";

export interface SilkProps {
  /** Крупность складок: 1 — широкие волны, 4 — мелкая рябь. */
  scale?: number;
  /** Скорость течения; 0 — стоп-кадр. */
  speed?: number;
  /** Тон ткани, 0–1 по кругу; 0.78 — сирень. */
  hue?: number;
  /** Сила блика: 0 — матовая ткань, 1 — атлас. */
  sheen?: number;
  /** Направление света, градусы. */
  lightAngle?: number;
  /** Общая яркость. */
  intensity?: number;
}

const VERT = /* glsl */ `
  attribute vec2 uv;
  attribute vec2 position;
  varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;

/**
 * Разность приёма со всеми фонами каталога: shader-flow и aurora-field СВЕТЯТСЯ —
 * цвет там назначен яркости напрямую. Здесь ткань ОСВЕЩЕНА: шейдер строит высотное
 * поле складок, берёт из его градиента нормаль и считает честное освещение —
 * ламберт + блик Блинна. Перелив атласа — это не палитра, а блик, бегущий по
 * склонам складок; повернёшь свет — перелив уедет за ним.
 *
 * Складки — доменное искажение: fbm читается в точке, сдвинутой другим fbm.
 * Прямой fbm даёт мятую бумагу (равномерные бугры); искажение вытягивает бугры
 * в длинные жгуты — так и лежит ткань. Анизотропия по той же причине: поле сжато
 * вдоль одной оси, у складок появляется направление.
 */
const FRAG = /* glsl */ `
  precision highp float;
  uniform float uTime, uScale, uHue, uSheen, uLightAngle, uIntensity;
  uniform vec2 uRes;
  varying vec2 vUv;

  vec3 hsv2rgb(vec3 c) {
    vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0);
    return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);
  }

  float hash21(vec2 p) {
    p = fract(p * vec2(234.34, 435.345));
    p += dot(p, p + 34.23);
    return fract(p.x * p.y);
  }
  float vnoise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash21(i), b = hash21(i + vec2(1.0, 0.0));
    float c = hash21(i + vec2(0.0, 1.0)), d = hash21(i + vec2(1.0, 1.0));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
  }
  // затухание октав 0.42, а не 0.5: мелкая рябь глушится, остаются крупные жгуты
  float fbm(vec2 p) {
    float v = 0.0, a = 0.54;
    for (int i = 0; i < 4; i++) {
      v += vnoise(p) * a;
      p = p * 2.03 + vec2(7.3, 3.1);
      a *= 0.42;
    }
    return v;
  }

  // высота ткани в точке: fbm, прочитанный со сдвигом на другой fbm.
  // сжатие по y ДО искажения — у складок появляется направление вдоль кадра.
  // искажение НЕБОЛЬШОЕ (1.1): сильное рвёт складки в турбулентность, и вместо
  // лежащей ткани выходит жидкий мрамор — ткань это ДЛИННЫЕ гладкие жгуты
  float height(vec2 p, float t) {
    vec2 q = p * vec2(0.85, 2.5);
    vec2 warp = vec2(fbm(q + vec2(t * 0.5, 0.0)), fbm(q + vec2(5.2, t * 0.35)));
    return fbm(q + warp * 1.1 - vec2(t * 0.22, 0.0));
  }

  void main() {
    float aspect = uRes.x / uRes.y;
    vec2 p = vec2(vUv.x * aspect, vUv.y) * uScale;
    float t = uTime;

    // нормаль из градиента высоты; шаг производной в НОРМИРОВАННЫХ координатах,
    // чтобы рельеф не менял крутизну от размера окна
    float e = 0.014;
    float h = height(p, t);
    float hx = height(p + vec2(e, 0.0), t) - h;
    float hy = height(p + vec2(0.0, e), t) - h;
    // амплитуда рельефа: складки достаточно крутые для блика, но без чёрных провалов
    vec3 n = normalize(vec3(-hx * 3.6, -hy * 3.6, e * 7.0));

    // свет ПОЛОГИЙ (z 0.5): скользящее освещение — то, что делает атлас атласом,
    // отвесный свет заливает складки ровно и убивает перелив
    float la = radians(uLightAngle);
    vec3 L = normalize(vec3(cos(la), sin(la), 0.5));
    vec3 V = vec3(0.0, 0.0, 1.0);
    vec3 H = normalize(L + V);

    float diff = clamp(dot(n, L), 0.0, 1.0);
    // два блика на одной нормали: широкий мягкий даёт атласный перелив,
    // узкий — влажную искру на гребнях. Показатели степени и есть «материал»
    float specWide = pow(clamp(dot(n, H), 0.0, 1.0), 13.0);
    float specTight = pow(clamp(dot(n, H), 0.0, 1.0), 140.0);

    // сама ткань: тон один, светлота от освещения, в провалах складок глубже и чуть
    // насыщеннее — краситель в тени всегда выглядит гуще
    float shade = 0.16 + 0.5 * diff;
    float sat = 0.42 + 0.2 * (1.0 - diff);
    vec3 cloth = hsv2rgb(vec3(uHue, sat, shade));
    // блик тянет к белому с лёгким сдвигом тона — перелив, а не белая клякса
    vec3 sheenCol = hsv2rgb(vec3(fract(uHue + 0.06), 0.18, 1.0));
    // узкая «мокрая» искра приглушена: она даёт влажный пластик, атлас держится широким
    vec3 col = cloth + sheenCol * (specWide * 1.0 + specTight * 0.22) * uSheen;

    col *= uIntensity;
    // тональная компрессия: блик поверх светлой складки не выгорает в плоское пятно
    col = vec3(1.0) - exp(-col * 1.9);
    gl_FragColor = vec4(col, 1.0);
  }
`;

/**
 * Атласная ткань: складки текут, и по их склонам бежит блик.
 *
 * Единственный фон каталога с моделью освещения: высотное поле складок (fbm с доменным
 * искажением) → нормаль из градиента → ламберт + два блика Блинна на одной нормали
 * (широкий — атласный перелив, узкий — искра на гребнях). Кадр непрозрачный: ткань
 * закрывает всё, альфа-канала и подложки нет.
 *
 * Сцена засыпает вне зоны видимости и на неактивной вкладке. `prefers-reduced-motion`
 * и `data-shot` рисуют один кадр.
 */
export function Silk({
  scale = 1.6,
  speed = 0.3,
  hue = 0.78,
  sheen = 0.75,
  lightAngle = 35,
  intensity = 1,
}: SilkProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ scale, speed, hue, sheen, lightAngle, intensity });
  cfg.current = { scale, speed, hue, sheen, lightAngle, intensity };

  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 Renderer({ alpha: false, antialias: false, dpr: Math.min(2, window.devicePixelRatio || 1) });
    const gl = renderer.gl;
    Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
    el.appendChild(gl.canvas);

    const program = new Program(gl, {
      vertex: VERT,
      fragment: FRAG,
      uniforms: {
        uTime: { value: 0 },
        uRes: { value: [1, 1] },
        uScale: { value: scale },
        uHue: { value: hue },
        uSheen: { value: sheen },
        uLightAngle: { value: lightAngle },
        uIntensity: { value: intensity },
      },
    });
    const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });

    const resize = () => {
      const { clientWidth: w, clientHeight: h } = el;
      if (!w || !h) return;
      renderer.setSize(w, h);
      program.uniforms.uRes!.value = [w, h];
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(el);

    let raf = 0;
    let last = performance.now();
    let time = 0;

    const draw = () => {
      const c = cfg.current;
      program.uniforms.uScale!.value = c.scale;
      program.uniforms.uHue!.value = c.hue;
      program.uniforms.uSheen!.value = c.sheen;
      program.uniforms.uLightAngle!.value = c.lightAngle;
      program.uniforms.uIntensity!.value = c.intensity;
      program.uniforms.uTime!.value = time;
      renderer.render({ scene: mesh });
    };

    const loop = (now: number) => {
      time += Math.min(0.05, (now - last) / 1000) * cfg.current.speed;
      last = now;
      draw();
      raf = requestAnimationFrame(loop);
    };
    const start = () => {
      if (raf || frozen) return;
      last = performance.now();
      raf = requestAnimationFrame(loop);
    };
    const stop = () => {
      cancelAnimationFrame(raf);
      raf = 0;
    };

    draw();
    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);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      gl.canvas.remove();
    };
  }, []);

  return <div ref={host} className="slk" style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />;
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — ReactBits Silk (MIT + Commons Clause, код не заимствован)v1.0.0