Iridescence
Бензиновая плёнка: радужные разводы текут по кадру, и цвет тут не назначен, а вычислен из физики тонкой плёнки. Одно скалярное поле толщины, и для каждого канала RGB своя фаза cos(толщина / λ) — спектр, порядок колец и их сгущение с толщиной получаются из формулы сами. Один ползунок меняет характер целиком: тонкая плёнка — перламутр, толстая — частые бензиновые кольца.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| scale? | number | 1.4 | Крупность разводов: 1 — широкие разливы, 4 — мелкая рябь. |
| thickness? | number | 2.4 | Толщина плёнки: сколько интерференционных порядков видно в кадре. |
| purity? | number | 0.75 | Чистота цвета: 0 — перламутровая дымка, 1 — бензиновые кольца. |
| intensity? | number | 1 | Яркость. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/iridescence.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 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" }} />;
}