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

Aurora Field

Северное сияние одним фрагментным шейдером: у занавеса резкая нижняя кромка и вертикальные лучи разной длины над ней, а цвет переливается по высоте — розовая кромка, зелёное ядро, фиолетовые верхушки.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
bands?number3Число занавесов.
height?number0.55Длина лучей вверх от кромки, доля кадра.
intensity?number1Яркость.
spread?number0.2Насколько тон уходит вверх по занавесу: зелёный → бирюза → фиолет.
backdrop?string | falseNIGHTПодложка-небо: любое CSS-значение `background` или `false`, чтобы не рисовать её вовсе. `false` нужен, когда запись стоит фоном СЕКЦИИ и небо задаёт она — иначе две подложки спорят за один слой. В витрине небо своё: сияние без ночи не читается.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/aurora-field.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 AuroraFieldProps {
  /** Число занавесов. */
  bands?: number;
  /** Скорость течения; 0 — стоп-кадр. */
  speed?: number;
  /** Длина лучей вверх от кромки, доля кадра. */
  height?: number;
  /** Мягкость нижней кромки; 0 — резкая линия. */
  softness?: number;
  /** Контраст вертикальных лучей; 0 — сплошная лента без структуры. */
  rays?: number;
  /** Сила дымки вокруг занавеса; 0 — только лучи, без ореола. */
  glow?: number;
  /** Яркость. */
  intensity?: number;
  /** Тон нижней кромки, 0–1 по кругу; 0.36 — зелёный. */
  hue?: number;
  /** Насколько тон уходит вверх по занавесу: зелёный → бирюза → фиолет. */
  spread?: number;
  /**
   * Подложка-небо: любое CSS-значение `background` или `false`, чтобы не рисовать её вовсе.
   * `false` нужен, когда запись стоит фоном СЕКЦИИ и небо задаёт она — иначе две подложки
   * спорят за один слой. В витрине небо своё: сияние без ночи не читается.
   */
  backdrop?: string | false;
}

/**
 * Ночное небо намеренно НЕ берёт `--ds-bg`: сияние бывает только ночью, и на светлой теме
 * занавесы висели над белым полем.
 */
const NIGHT =
  "linear-gradient(to bottom, oklch(0.15 0.045 272) 0%, oklch(0.11 0.035 268) 45%, oklch(0.07 0.02 262) 100%)";

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

/**
 * Анатомия настоящего сияния, а не «светящаяся полоса»: у занавеса есть НИЗ и ВЕРХ, и они
 * устроены по-разному. Внизу — резкая кромка (там частицы врезаются в плотный воздух),
 * вверх от неё тянутся вертикальные лучи разной длины и постепенно растворяются. Поэтому
 * профиль здесь асимметричный: вниз от кромки — узкий гаусс, вверх — длинный экспоненциальный
 * хвост. Симметричный гаусс (расстояние до линии) даёт ровно тот «бесструктурный светящийся
 * ком», ради ухода от которого всё и переписано.
 *
 * Структуру дают ДВА независимых шума от одной координаты вдоль занавеса: один правит яркость
 * луча, второй — его длину. Разная длина соседних лучей и есть та рваная верхняя граница,
 * по которой глаз опознаёт сияние. Значение шума (hash + сглаженная интерполяция) взято вместо
 * Перлина сознательно: тут нужна 1D-функция, и она обходится в пару операций.
 *
 * Цвет — функция ВЫСОТЫ внутри занавеса, а не номера занавеса: розовая кромка снизу
 * (свечение азота), зелёное ядро (кислород 557.7 нм), к верхушкам — бирюза и фиолет.
 * Это и есть «переливы»: один занавес переливается сам по себе.
 */
const FRAG = /* glsl */ `
  precision highp float;
  uniform float uTime, uHeight, uSoft, uIntensity, uHue, uSpread, uGlow, uRays;
  uniform int uBands;
  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 hash11(float p) {
    p = fract(p * 0.1031);
    p *= p + 33.33;
    return fract((p + p) * p);
  }
  // значение-шум: та же цена, что синус, но без периода — лучи не выстраиваются в частокол
  float vnoise(float x) {
    float i = floor(x), f = fract(x);
    f = f * f * (3.0 - 2.0 * f);
    return mix(hash11(i), hash11(i + 1.0), f);
  }
  float fbm(float x) {
    return vnoise(x) * 0.55 + vnoise(x * 2.07 + 9.1) * 0.29 + vnoise(x * 4.13 + 31.7) * 0.16;
  }

  void main() {
    vec2 uv = vUv;
    float aspect = uRes.x / uRes.y;
    float x = uv.x * aspect;
    vec3 col = vec3(0.0);

    for (int i = 0; i < 6; i++) {
      if (i >= uBands) break;
      float fi = float(i);
      float t = uTime + fi * 17.3;
      // дальние занавесы: выше в кадре, бледнее, складки мельче. Отсюда ощущение глубины
      // без второго прохода и без единого лишнего пикселя
      float depth = fi / max(1.0, float(uBands) - 1.0);
      float near = 1.0 - depth * 0.5;

      // нижняя кромка занавеса — складки драпировки. Размах намеренно небольшой: сияние
      // читается лучами, а крупная волна превращает его в ленту-змею. Шумовое слагаемое
      // обязательно: на одних синусах кромка идёт через кадр безупречной волной и выдаёт
      // математику
      float ridge = 0.22 + depth * 0.22
        + sin(x * (1.15 + depth * 0.5) + t * 0.5 + fi) * 0.06 * near
        + sin(x * (2.6 + depth * 0.9) - t * 0.33 + fi * 2.3) * 0.03
        + fbm(x * 1.9 + t * 0.25 + fi * 23.0) * 0.06 - 0.03;

      // лучи идут по магнитным линиям и к верху расходятся веером — координата луча берётся
      // с поправкой на высоту над кромкой, иначе они стоят как забор из одинаковых палок
      float above = max(uv.y - ridge, 0.0);
      float xs = aspect * 0.5 + (x - aspect * 0.5) / (1.0 + above * 0.45);
      // координата ВДОЛЬ занавеса: лучи медленно текут по нему, не сдвигая саму драпировку
      float rc = xs * (19.0 + depth * 7.0) + t * 0.8 + fi * 53.0;
      // тонкий луч = резкий пик: степень 3 сжимает гладкий шум в узкие гребни с тёмными
      // промежутками. Без неё лучи расплываются в вертикальные мазки
      float rayLit = pow(vnoise(rc) * 0.62 + fbm(rc * 0.21 + 5.0) * 0.5, 3.0) * 2.9;
      float rayLen = fbm(rc * 0.17 + 71.0);   // длина луча — рваный верхний край

      float h = max(0.05, uHeight * (0.4 + 0.95 * rayLen) * (1.0 - depth * 0.2));
      float v = (uv.y - ridge) / h;           // 0 на кромке, 1 — на верхушках лучей

      // асимметрия профиля: вниз обрыв, вверх длинный хвост
      float lowW = 0.03 + uSoft * 0.09;
      float down = exp(-pow(max(-v, 0.0) / lowW, 2.0));
      float up = exp(-max(v, 0.0) * (3.5 - uSoft * 0.9));
      float body = mix(up, down, step(v, 0.0));
      // самая яркая часть занавеса — тонкая линия по нижней кромке: там поток тормозится
      // о плотный воздух. Без неё занавес выглядит одинаково светящимся по всей высоте.
      // Разрывы (patch) обязательны: сплошная кромка через весь кадр читается как обводка,
      // проведённая инструментом, а не как свет
      float patch = 0.12 + 1.25 * fbm(x * 3.2 + t * 0.35 + fi * 7.0);
      body *= 1.0 + exp(-pow((v - 0.03) / (0.045 + uSoft * 0.05), 2.0)) * 0.6 * patch * near;

      // у самой кромки лучи сливаются в сплошную линию и расходятся только выше — так же,
      // как в небе: снизу занавес выглядит светящимся швом, сверху распадается на прутья
      float emerge = smoothstep(0.0, 0.28, v);
      float streaks = mix(1.0, 0.18 + rayLit, emerge * uRays);

      // занавес живёт не по всей длине: где-то он яркий, где-то почти гаснет. Без этой
      // медленной волны получается ровная лента через весь кадр — самый нежизненный вариант
      float alive = 0.25 + 0.85 * fbm(x * 0.8 + t * 0.22 + fi * 11.0);
      // к краям кадра занавес истончается — уходит за горизонт
      float ends = smoothstep(0.0, 0.16, uv.x) * smoothstep(1.0, 0.84, uv.x);
      float w = body * streaks * alive * near * (0.3 + 0.7 * ends);

      // цвет по высоте: −0.3 (под кромкой) → розовый, 0 → базовый тон, 1.4 → фиолет
      float g = clamp(v, -0.3, 1.4);
      float fringe = smoothstep(0.02, -0.14, v);           // 1 только под кромкой
      float hue = fract(uHue + uSpread * g * 1.5 + fringe * 0.55 + depth * uSpread * 0.3);
      // насыщенность обязательно с потолком: hsv2rgb при s > 1 уводит боковые каналы
      // в минус, и такой занавес начинал ВЫЧИТАТЬ свет из соседнего
      float sat = min(1.0, 0.68 + 0.24 * smoothstep(-0.1, 1.0, g) + fringe * 0.15);
      col += hsv2rgb(vec3(hue, sat, 1.0)) * w * 0.95;

      // дымка: ореол БЕЗ лучей — подсвеченный воздух у самой кромки, где занавес плотнее.
      // Вклад насыщается (uGlow/(1+uGlow)), поэтому ползунок меняет характер свечения,
      // а не общую яркость — за неё отвечает отдельная крутилка
      float haloW = h * 0.5 + 0.07;
      float halo = exp(-pow((uv.y - ridge - h * 0.18) / haloW, 2.0));
      col += hsv2rgb(vec3(fract(uHue + uSpread * 0.35 + depth * uSpread * 0.3), 0.7, 1.0))
           * halo * (uGlow / (1.0 + uGlow)) * 0.19 * alive * patch * near * (0.3 + 0.7 * ends);
    }

    col *= uIntensity;
    col *= smoothstep(-0.02, 0.3, uv.y);   // к низу кадра сияние гаснет
    // тональная компрессия вместо clamp: пересветы сохраняют ЦВЕТ, а не выгорают в белое
    // пятно — сложение трёх занавесов раньше давало ровно его
    col = vec3(1.0) - exp(-col * 1.35);
    float a = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0);
    gl_FragColor = vec4(col, a);   // premultiplied: свет складывается, а не гасит небо
  }
`;

/**
 * Северное сияние: занавесы света с вертикальными лучами текут по небу, переливаясь по высоте
 * от зелёного к фиолетовому.
 *
 * Реализация — один полноэкранный фрагментный шейдер на OGL, ни одного полигона сверх
 * обязательного треугольника. Занавес задан нижней кромкой (складки) и лучами над ней;
 * яркость, длина луча и цвет — производные одной вертикальной координаты внутри занавеса.
 *
 * Сцена засыпает: кадры считаются только когда холст в зоне видимости и вкладка активна.
 * `prefers-reduced-motion` и `data-shot` рисуют один кадр — картинка остаётся, движение нет.
 */
export function AuroraField({
  bands = 3,
  speed = 0.22,
  height = 0.55,
  softness = 0.5,
  rays = 0.8,
  glow = 0.9,
  intensity = 1,
  hue = 0.36,
  spread = 0.2,
  backdrop = NIGHT,
}: AuroraFieldProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ bands, speed, height, softness, rays, glow, intensity, hue, spread });
  cfg.current = { bands, speed, height, softness, rays, glow, intensity, hue, spread };

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // premultipliedAlpha: свет занавесов СКЛАДЫВАЕТСЯ с небом. При обычной альфе тусклые
    // области умножали фон на (1 - a) и затемняли его — по кадру шла грязная вуаль
    const renderer = new Renderer({
      alpha: true,
      premultipliedAlpha: true,
      antialias: false,
      dpr: Math.min(2, window.devicePixelRatio || 1),
    });
    const gl = renderer.gl;
    gl.clearColor(0, 0, 0, 0);
    Object.assign(gl.canvas.style, { display: "block", width: "100%", height: "100%" });
    el.appendChild(gl.canvas);

    const program = new Program(gl, {
      vertex: VERT,
      fragment: FRAG,
      transparent: true,
      uniforms: {
        uTime: { value: 0 },
        uRes: { value: [1, 1] },
        uBands: { value: cfg.current.bands },
        uHeight: { value: cfg.current.height },
        uSoft: { value: cfg.current.softness },
        uRays: { value: cfg.current.rays },
        uGlow: { value: cfg.current.glow },
        uIntensity: { value: cfg.current.intensity },
        uHue: { value: cfg.current.hue },
        uSpread: { value: cfg.current.spread },
      },
    });
    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.uBands!.value = Math.round(c.bands);
      program.uniforms.uHeight!.value = c.height;
      program.uniforms.uSoft!.value = c.softness;
      program.uniforms.uRays!.value = c.rays;
      program.uniforms.uGlow!.value = c.glow;
      program.uniforms.uIntensity!.value = c.intensity;
      program.uniforms.uHue!.value = c.hue;
      program.uniforms.uSpread!.value = c.spread;
      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="aur"
      style={{
        // заполняет РОДИТЕЛЯ, а не вьюпорт, и не назначает себе z-index: стопкой управляет
        // тот, кто ставит запись фоном. Без этого правила фон нельзя переиспользовать в секции
        position: "absolute",
        inset: 0,
        pointerEvents: "none",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    />
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0