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

Light Rays

Объёмные лучи из источника над кадром: веер прутьев света в запылённом воздухе. Кадр пересчитан в полярные координаты, и каждый луч — значение углового шума: один угол задаёт яркость прута, его длину и медленное дыхание. У источника лучи слиты в сплошное ядро, с расстоянием распадаются и гаснут каждый на своей длине.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
density?number42Плотность лучей: сколько прутьев укладывается в веер.
spread?number0.55Ширина веера, доля полукруга: 0.2 — узкий прожектор, 0.9 — почти полнеба.
reach?number0.85Дальнобойность лучей, доля высоты кадра.
intensity?number1Яркость.
sourceX?number0.5Положение источника по горизонтали, 0–1.
backdrop?string | falseDUSKПодложка-сумрак: CSS-значение `background` или `false`, если её задаёт секция. Свет читается только на тёмном, поэтому подложка намеренно НЕ `--ds-bg`.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/light-rays.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 LightRaysProps {
  /** Плотность лучей: сколько прутьев укладывается в веер. */
  density?: number;
  /** Ширина веера, доля полукруга: 0.2 — узкий прожектор, 0.9 — почти полнеба. */
  spread?: number;
  /** Дальнобойность лучей, доля высоты кадра. */
  reach?: number;
  /** Скорость мерцания; 0 — стоп-кадр. */
  speed?: number;
  /** Тон света, 0–1 по кругу; 0.16 — тёплый янтарь. */
  hue?: number;
  /** Яркость. */
  intensity?: number;
  /** Положение источника по горизонтали, 0–1. */
  sourceX?: number;
  /**
   * Подложка-сумрак: CSS-значение `background` или `false`, если её задаёт секция.
   * Свет читается только на тёмном, поэтому подложка намеренно НЕ `--ds-bg`.
   */
  backdrop?: string | false;
}

const DUSK =
  "linear-gradient(to bottom, oklch(0.14 0.025 265) 0%, oklch(0.1 0.02 262) 55%, oklch(0.07 0.015 258) 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); }
`;

/**
 * Разность приёма с aurora-field, ради которой запись существует: там свет — функция
 * ВЫСОТЫ над кромкой занавеса, здесь — функция НАПРАВЛЕНИЯ на источник. Весь кадр
 * пересчитывается в полярные координаты вокруг точки над верхней кромкой, и дальше
 * каждый луч — это значение углового шума: одна координата (угол) задаёт и яркость
 * прута, и его длину, и его медленное дыхание.
 *
 * Асимметрия — по ходу луча, а не поперёк: у источника прутья сливаются в сплошное
 * ядро (там воздух засвечен целиком), с расстоянием распадаются на отдельные лучи
 * и гаснут каждый на своей длине. Симметричного профиля тут нет ни в одном месте —
 * ровно по правилу «симметричный профиль = бесструктурный ком».
 */
const FRAG = /* glsl */ `
  precision highp float;
  uniform float uTime, uDensity, uSpread, uReach, uHue, uIntensity, uSrcX;
  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() {
    float aspect = uRes.x / uRes.y;
    // источник чуть ВЫШЕ кадра: видна не лампа, а только её свет — так лучи
    // входят в кадр уже расчёсанными, без засвеченного пятна в углу
    vec2 src = vec2(uSrcX * aspect, 1.12);
    vec2 d = vec2(vUv.x * aspect, vUv.y) - src;
    float dist = length(d);
    // угол от вертикали вниз; веер режется по нему
    float ang = atan(d.x, -d.y);
    float cone = smoothstep(uSpread * 1.62, uSpread * 1.62 * 0.55, abs(ang));

    // координата ПОПЕРЁК веера. Всё, что делает луч лучом, — функции одного угла:
    // так прутья честно сходятся в точку источника без единой матрицы поворота
    float rc = ang * uDensity;
    float t = uTime;

    // два слоя прутьев на разной угловой частоте, текут в разные стороны —
    // веер живёт, но сами лучи не «плывут» по кадру (угол у пикселя неизменен)
    float r1 = vnoise(rc + t * 0.55);
    float r2 = vnoise(rc * 2.31 + 7.7 - t * 0.34);
    // степень сжимает гладкий шум в узкие гребни с тёмными промежутками —
    // без неё вместо прутьев выходит равномерная пелена
    float ray = pow(clamp(r1 * 0.72 + r2 * 0.52, 0.0, 1.0), 3.2);

    // длина — СВОЙ шум, не тот, что яркость: рваная кромка веера берётся из
    // разной длины соседних лучей, а не из общего затухания
    float len = fbm(rc * 0.47 + 61.0);
    float reach = uReach * (0.5 + 1.0 * len);
    // асимметрия по ходу луча: подъём у источника крутой, хвост вниз длинный
    float fall = exp(-max(dist - 0.12, 0.0) * 2.4 / reach);
    fall *= smoothstep(0.0, 0.1, dist + 0.05);

    // у источника прутья сливаются в сплошное ядро и распадаются с расстоянием
    float emerge = smoothstep(0.1, 0.55, dist);
    float body = mix(0.85, 0.14 + ray * 1.9, emerge);

    // медленное дыхание: луч то набирает силу, то почти гаснет
    float alive = 0.45 + 0.55 * fbm(rc * 0.21 + t * 0.16 + 3.0);

    float w = body * fall * cone * alive;

    // засвеченный воздух вокруг источника — ореол без прутьев
    float halo = exp(-dist * 3.4) * 0.5;

    // тон чуть уходит по дальности: у источника теплее, к хвостам холоднее
    float hue = fract(uHue + dist * 0.045);
    vec3 col = hsv2rgb(vec3(hue, 0.42, 1.0)) * w
             + hsv2rgb(vec3(fract(uHue + 0.03), 0.3, 1.0)) * halo * cone;

    col *= uIntensity;
    // тональная компрессия вместо clamp: пересечение ядра и ореола сохраняет цвет
    col = vec3(1.0) - exp(-col * 1.5);
    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 LightRays({
  density = 42,
  spread = 0.55,
  reach = 0.85,
  speed = 0.5,
  hue = 0.16,
  intensity = 1,
  sourceX = 0.5,
  backdrop = DUSK,
}: LightRaysProps = {}) {
  const host = useRef<HTMLDivElement>(null);
  const cfg = useRef({ density, spread, reach, speed, hue, intensity, sourceX });
  cfg.current = { density, spread, reach, speed, hue, intensity, sourceX };

  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: 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] },
        uDensity: { value: density },
        uSpread: { value: spread },
        uReach: { value: reach },
        uHue: { value: hue },
        uIntensity: { value: intensity },
        uSrcX: { value: sourceX },
      },
    });
    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.uDensity!.value = c.density;
      program.uniforms.uSpread!.value = c.spread;
      program.uniforms.uReach!.value = c.reach;
      program.uniforms.uHue!.value = c.hue;
      program.uniforms.uIntensity!.value = c.intensity;
      program.uniforms.uSrcX!.value = c.sourceX;
      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="lrs"
      style={{
        position: "absolute",
        inset: 0,
        pointerEvents: "none",
        ...(backdrop === false ? null : { background: backdrop }),
      }}
    />
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — ReactBits Light Rays (MIT + Commons Clause, код не заимствован)v1.0.0