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

Iridescence

Бензиновая плёнка: радужные разводы текут по кадру, и цвет тут не назначен, а вычислен из физики тонкой плёнки. Одно скалярное поле толщины, и для каждого канала RGB своя фаза cos(толщина / λ) — спектр, порядок колец и их сгущение с толщиной получаются из формулы сами. Один ползунок меняет характер целиком: тонкая плёнка — перламутр, толстая — частые бензиновые кольца.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
scale?number1.4Крупность разводов: 1 — широкие разливы, 4 — мелкая рябь.
thickness?number2.4Толщина плёнки: сколько интерференционных порядков видно в кадре.
purity?number0.75Чистота цвета: 0 — перламутровая дымка, 1 — бензиновые кольца.
intensity?number1Яркость.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/iridescence.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 IridescenceProps {
  /** Крупность разводов: 1 — широкие разливы, 4 — мелкая рябь. */
  scale?: number;
  /** Скорость дрейфа плёнки; 0 — стоп-кадр. */
  speed?: number;
  /** Толщина плёнки: сколько интерференционных порядков видно в кадре. */
  thickness?: number;
  /** Чистота цвета: 0 — перламутровая дымка, 1 — бензиновые кольца. */
  purity?: 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); }
`;

/**
 * Цвет здесь НЕ назначается — он вычисляется из физики тонкой плёнки. У мыльного пузыря
 * и бензиновой лужи нет краски: свет отражается от верхней и нижней граней плёнки, и две
 * волны складываются или гасятся в зависимости от отношения толщины к длине волны.
 * Шейдер делает ровно это: одно поле ТОЛЩИНЫ, и для каждого канала RGB — своя фаза
 * `cos(толщина / длина_волны)`. Радуга, порядок колец и их сужение с ростом толщины
 * получаются сами, их никто не рисовал.
 *
 * В этом разность приёма с остальными фонами: у aurora-field цвет — hsv по высоте,
 * у silk — освещённая ткань; здесь спектр порождается интерференцией из ОДНОГО
 * скалярного поля. Один ползунок толщины меняет весь характер картинки: тонкая
 * плёнка почти бесцветна (перламутр), толстая идёт частыми бензиновыми кольцами.
 */
const FRAG = /* glsl */ `
  precision highp float;
  uniform float uTime, uScale, uThickness, uPurity, uIntensity;
  uniform vec2 uRes;
  varying vec2 vUv;

  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);
  }
  // поле НАМЕРЕННО гладкое: три октавы с быстрым затуханием. Кольца — это линии
  // уровня толщины, и рыхлое поле размазывает их в цветные кляксы без структуры
  float fbm(vec2 p) {
    float v = 0.0, a = 0.6;
    for (int i = 0; i < 3; i++) {
      v += vnoise(p) * a;
      p = p * 2.03 + vec2(7.3, 3.1);
      a *= 0.38;
    }
    return v;
  }

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

    // поле толщины плёнки: медленный дрейф с мягким доменным искажением — разводы
    // текут, как масло по воде. Искажение небольшое: кольца — линии уровня толщины,
    // сильный варп рвёт их в кляксы
    vec2 warp = vec2(fbm(p + vec2(t * 0.4, 0.0)), fbm(p + vec2(3.7, t * 0.3)));
    float film = fbm(p * 0.8 + warp * 0.9 + vec2(0.0, t * 0.12));
    // плёнка стекает: книзу кадра толще — кольца там чаще, как на настоящем пузыре
    float thick = film + (1.0 - vUv.y) * 0.3;

    // интерференция: для каждого канала своя длина волны (относительные λ красного,
    // зелёного, синего), фаза = толщина / λ. Это ВСЯ физика — и вся радуга
    vec3 lambda = vec3(0.612, 0.549, 0.464);
    vec3 phase = vec3(6.28318) * (thick * uThickness) / lambda;
    vec3 fringe = 0.5 + 0.5 * cos(phase);

    // контраст полос: purity растит видимость колец, перламутр остаётся при малой
    vec3 pearl = vec3(0.62, 0.64, 0.7);
    vec3 col = mix(pearl, fringe, 0.25 + uPurity * 0.75);

    // плёнка на тёмной воде: в тонких местах отражать нечего — темнеет.
    // глубина берётся из САМОГО поля, не из вертикали: иначе низ кадра всегда светлый
    float depth = smoothstep(-0.05, 0.85, film);
    col *= 0.16 + 0.84 * depth;

    col *= uIntensity;
    col = vec3(1.0) - exp(-col * 2.1);
    gl_FragColor = vec4(col, 1.0);
  }
`;

/**
 * Бензиновая плёнка: радужные разводы текут по кадру, цвет рождается интерференцией.
 *
 * Один фрагментный шейдер на OGL: скалярное поле толщины плёнки (fbm с доменным
 * искажением) и для каждого канала RGB своя фаза cos(толщина / λ). Спектр, порядок
 * колец и их сгущение с толщиной — следствия формулы, а не палитра художника.
 *
 * Сцена засыпает вне зоны видимости и на неактивной вкладке. `prefers-reduced-motion`
 * и `data-shot` рисуют один кадр.
 */
export function Iridescence({
  scale = 1.4,
  speed = 0.35,
  thickness = 2.4,
  purity = 0.75,
  intensity = 1,
}: IridescenceProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ scale, speed, thickness, purity, intensity });
  cfg.current = { scale, speed, thickness, purity, 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 },
        uThickness: { value: thickness },
        uPurity: { value: purity },
        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.uThickness!.value = c.thickness;
      program.uniforms.uPurity!.value = c.purity;
      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="ird" style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />;
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — ReactBits Iridescence (MIT + Commons Clause, код не заимствован)v1.0.0