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

Aurora Field

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

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

Пропсы

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

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

Установка

npx shadcn@latest add https://motiva.pages.dev/r/aurora-field.json

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

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

export interface AuroraFieldProps {
  /** Число занавесов (формат «3»). */
  bands?: number;
  /** Скорость течения (формат «0.22»); 0 — стоп-кадр. */
  speed?: number;
  /** Длина лучей вверх от кромки, доля кадра (формат «0.55»). */
  height?: number;
  /** Мягкость нижней кромки (формат «0.5»); 0 — резкая линия. */
  softness?: number;
  /** Контраст вертикальных лучей (формат «0.8»); 0 — сплошная лента без структуры. */
  rays?: number;
  /** Сила дымки вокруг занавеса (формат «0.9»); 0 — только лучи, без ореола. */
  glow?: number;
  /** Яркость (формат «1»). */
  intensity?: number;
  /** Тон нижней кромки, 0–1 по кругу (формат «0.36» — зелёный). */
  hue?: number;
  /** Насколько тон уходит вверх по занавесу: зелёный → бирюза → фиолет (формат «0.2»). */
  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