Light Rays
Объёмные лучи из источника над кадром: веер прутьев света в запылённом воздухе. Кадр пересчитан в полярные координаты, и каждый луч — значение углового шума: один угол задаёт яркость прута, его длину и медленное дыхание. У источника лучи слиты в сплошное ядро, с расстоянием распадаются и гаснут каждый на своей длине.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| density? | number | 42 | Плотность лучей: сколько прутьев укладывается в веер. |
| spread? | number | 0.55 | Ширина веера, доля полукруга: 0.2 — узкий прожектор, 0.9 — почти полнеба. |
| reach? | number | 0.85 | Дальнобойность лучей, доля высоты кадра. |
| intensity? | number | 1 | Яркость. |
| sourceX? | number | 0.5 | Положение источника по горизонтали, 0–1. |
| backdrop? | string | false | DUSK | Подложка-сумрак: CSS-значение `background` или `false`, если её задаёт секция. Свет читается только на тёмном, поэтому подложка намеренно НЕ `--ds-bg`. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/light-rays.jsonзависимости: ogl
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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 }),
}}
/>
);
}