← каталог
Bloom Garden
Сад, который накапливается: по клику распускается цветок и остаётся навсегда. Состояние живёт не в массиве объектов, а в текстуре — два буфера меняются местами каждый кадр, и распустившийся цветок дальше не стоит ничего. Поэтому цикл кадров засыпает, как только вырос последний.
oglclickloadrevealadvanced~11 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| duration? | number | 1.1 | Секунд на один цветок (формат «1.1»). |
| size? | number | 0.14 | Радиус цветка, доля меньшей стороны кадра (формат «0.14»). |
| ink? | number | 0.55 | Насыщенность мазка: выше — сад заполняется быстрее (формат «0.55»). |
| seedCount? | number | 3 | Сколько цветков распустить при появлении (формат «3»). |
| backdrop? | string | false | BACKDROP | Подложка: CSS-значение `background` или `false`, если её задаёт секция. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/bloom-garden.jsonзависимости: ogl
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef } from "react";
import { Mesh, Program, RenderTarget, Renderer, Triangle } from "ogl";
/** Подложка по умолчанию — как в витрине. */
const BACKDROP = "var(--ds-bg, oklch(0.11 0.02 275))";
/** Больше одновременно РАСПУСКАЮЩИХСЯ цветков не бывает: распустившиеся живут в текстуре. */
const MAX_LIVE = 8;
export interface BloomGardenProps {
/** Секунд на один цветок (формат «1.1»). */
duration?: number;
/** Радиус цветка, доля меньшей стороны кадра (формат «0.14»). */
size?: number;
/** Насыщенность мазка: выше — сад заполняется быстрее (формат «0.55»). */
ink?: number;
/** Тон в круге, 0–1; каждый цветок отходит от него случайно (формат «0.86»). */
hue?: number;
/** Сколько цветков распустить при появлении (формат «3»). */
seedCount?: number;
/** Подложка: CSS-значение `background` или `false`, если её задаёт секция. */
backdrop?: string | false;
}
const VERT = /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
/**
* Накопление. Кадр читает ПРЕДЫДУЩИЙ кадр и дописывает в него то, что выросло за это время.
* Кладётся при этом не весь цветок, а разность двух его состояний — кольцо, появившееся
* именно сейчас. Иначе цветок, отрисованный шестьдесят раз за секунду роста, шестьдесят
* раз в текстуру бы и лёг.
*/
const ACCUM = /* glsl */ `
precision highp float;
uniform sampler2D tPrev;
uniform vec2 uRes;
uniform float uMaxR;
uniform int uCount;
uniform vec4 uF[${MAX_LIVE}]; // xy — центр в px, z — рост сейчас, w — рост в прошлом кадре
uniform vec4 uC[${MAX_LIVE}]; // rgb — цвет, a — семя
varying vec2 vUv;
// роза в полярных координатах: лепестков от 5 до 8, поворот — от семени
float shapeOf(vec2 q, float seed) {
float n = 5.0 + floor(mod(seed * 97.0, 4.0));
float a = atan(q.y, q.x) + seed * 6.2831;
return 0.34 + 0.66 * (0.5 + 0.5 * cos(n * a));
}
// какая часть цветка закрашена при росте g
float body(vec2 q, float seed, float g) {
float r = length(q) / max(g, 0.0001);
return step(0.0001, g) * smoothstep(shapeOf(q, seed), shapeOf(q, seed) - 0.22, r);
}
void main() {
vec3 prev = texture2D(tPrev, vUv).rgb;
vec2 px = vUv * uRes;
vec3 add = vec3(0.0);
for (int i = 0; i < ${MAX_LIVE}; i++) {
if (i >= uCount) break;
vec4 f = uF[i];
float seed = uC[i].a;
vec2 q = (px - f.xy) / uMaxR;
float ring = clamp(body(q, seed, f.z) - body(q, seed, f.w), 0.0, 1.0);
// сердцевина кладётся ровно один раз — в первый кадр цветка
float core = smoothstep(0.17, 0.0, length(q)) * step(f.w, 0.0);
add += uC[i].rgb * ring + vec3(1.0, 0.94, 0.78) * core * 0.55;
}
gl_FragColor = vec4(prev + add, 1.0);
}
`;
/**
* Показ. Он же переносит сад в новую текстуру при смене размера: `uVignette` = 0 — честная
* копия пиксель в пиксель, 1 — кадр на экран, с виньеткой.
*/
const PRESENT = /* glsl */ `
precision highp float;
uniform sampler2D tMap;
uniform float uVignette;
varying vec2 vUv;
void main() {
vec3 c = texture2D(tMap, vUv).rgb;
float d = length(vUv - 0.5);
c *= mix(1.0, smoothstep(0.86, 0.28, d), uVignette);
gl_FragColor = vec4(c, uVignette > 0.5 ? max(max(c.r, c.g), c.b) : 1.0);
}
`;
/**
* Сад, который НАКАПЛИВАЕТСЯ: по клику распускается цветок и остаётся навсегда.
*
* Приём здесь не в цветке, а в том, ГДЕ живёт состояние сада. Обычный путь — массив
* объектов и перерисовка всех с нуля каждый кадр: сотый цветок стоит ровно в сто раз
* дороже первого, и рано или поздно кадр перестаёт укладываться в бюджет. Здесь состояние
* живёт в ТЕКСТУРЕ. Два буфера меняются местами каждый кадр (ping-pong): кадр читает
* предыдущий и дописывает поверх только то, что выросло за это время. Распустившийся
* цветок после этого не стоит ничего — он больше не объект, он пиксели.
*
* Отсюда два следствия, и оба ощутимы:
*
* - **цикл кадров засыпает.** Рисовать надо, только пока что-то РАСТЁТ; когда последний
* цветок раскрылся, сад уже целиком лежит в текстуре, и следующий кадр не изменил бы
* ни одного пикселя. Замер: 0 вызовов rAF через секунду после клика;
* - **затухания у сада нет намеренно.** Медленное угасание накопленного — первое, что
* просится добавить, и оно же убивает главное свойство: гаснущая картинка меняется
* каждый кадр, значит цикл не заснёт больше никогда. Сад копится, а сбрасывается
* двойным щелчком.
*
* Кладётся в текстуру РАЗНОСТЬ двух состояний цветка — кольцо, выросшее за этот кадр,
* а не весь цветок. С «весь цветок каждый кадр» накопление сложило бы его шестьдесят раз
* за секунду роста и выжгло бы белое пятно на первом же щелчке.
*
* `preserveDrawingBuffer` включён осознанно, хотя обычно это лишняя плата: запись
* большую часть времени НЕ рисует, а браузер вправе выбросить содержимое холста после
* компоновки — без него уснувший сад мог бы моргнуть в пустоту.
*/
export function BloomGarden({
duration = 1.1,
size = 0.14,
ink = 0.55,
hue = 0.86,
seedCount = 3,
backdrop = BACKDROP,
}: BloomGardenProps = {}) {
const host = useRef<HTMLDivElement>(null);
const cfg = useRef({ duration, size, ink, hue, seedCount });
cfg.current = { duration, size, ink, hue, seedCount };
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,
antialias: false,
// см. заголовок: запись спит, а холст без этого флага вправе опустеть
preserveDrawingBuffer: true,
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 geometry = new Triangle(gl);
const empty = new RenderTarget(gl, { width: 2, height: 2 });
const accum = new Program(gl, {
vertex: VERT,
fragment: ACCUM,
uniforms: {
tPrev: { value: empty.texture },
uRes: { value: [1, 1] },
uMaxR: { value: 60 },
uCount: { value: 0 },
// ОБЫЧНЫЙ массив, не Float32Array. Массив uniform'ов приходит из драйвера под
// именем первого элемента (`uF[0]`), и OGL разбирает его на имя и индекс; на
// индексе он проверяет ровно `Array.isArray(value)`, а типизированный массив эту
// проверку не проходит. Данные тогда молча не доезжают до видеокарты: ошибки GL
// нет, экран пустой, и единственный след — строка в консоли
// «Active uniform uF[0] has not been supplied».
uF: { value: new Array(MAX_LIVE * 4).fill(0) as number[] },
uC: { value: new Array(MAX_LIVE * 4).fill(0) as number[] },
},
});
const present = new Program(gl, {
vertex: VERT,
fragment: PRESENT,
transparent: true,
uniforms: { tMap: { value: empty.texture }, uVignette: { value: 1 } },
});
const accumMesh = new Mesh(gl, { geometry, program: accum });
const presentMesh = new Mesh(gl, { geometry, program: present });
let a = new RenderTarget(gl, { width: 2, height: 2 });
let b = new RenderTarget(gl, { width: 2, height: 2 });
type Flower = { x: number; y: number; start: number; prev: number; color: [number, number, number]; seed: number };
let live: Flower[] = [];
let raf = 0;
/** Показать накопленное: один проход, нужен и после сна, и после смены размера. */
const show = () => {
present.uniforms.tMap!.value = a.texture;
present.uniforms.uVignette!.value = 1;
renderer.render({ scene: presentMesh });
};
const resize = () => {
const { clientWidth: w, clientHeight: h } = el;
if (!w || !h) return;
renderer.setSize(w, h);
const W = gl.canvas.width;
const H = gl.canvas.height;
if (W === a.width && H === a.height) return;
// сад переносим в новые буферы, а не сбрасываем: смена размера окна не повод
// отнимать у человека то, что он нащёлкал
const na = new RenderTarget(gl, { width: W, height: H });
const nb = new RenderTarget(gl, { width: W, height: H });
present.uniforms.tMap!.value = a.texture;
present.uniforms.uVignette!.value = 0;
renderer.render({ scene: presentMesh, target: na });
a = na;
b = nb;
accum.uniforms.uRes!.value = [W, H];
accum.uniforms.uMaxR!.value = Math.min(W, H) * cfg.current.size;
show();
};
const plant = (ux: number, uy: number, delay = 0) => {
if (live.length >= MAX_LIVE) live.shift();
const seed = Math.random();
const h = (cfg.current.hue + (Math.random() - 0.5) * 0.22 + 1) % 1;
// тот же перевод тона в rgb, что в шейдерах каталога: сдвиги 0 / 4 / 2 по шестёрке
const ch = (shift: number) => {
const k = (shift + h * 6) % 6;
return Math.min(1, Math.max(0, Math.abs(k - 3) - 1));
};
const k = cfg.current.ink * (0.8 + Math.random() * 0.35);
live.push({
x: ux * gl.canvas.width,
y: (1 - uy) * gl.canvas.height,
start: performance.now() + delay,
prev: 0,
color: [ch(0) * k, ch(4) * k, ch(2) * k],
seed,
});
start();
};
const step = (now: number) => {
const dur = Math.max(0.15, cfg.current.duration) * 1000;
const F = accum.uniforms.uF!.value as number[];
const C = accum.uniforms.uC!.value as number[];
let n = 0;
const alive: Flower[] = [];
for (const f of live) {
const t = (now - f.start) / dur;
if (t < 0) {
alive.push(f); // ещё не начался: цветки сеются с задержкой друг за другом
continue;
}
// ease-out: цветок распускается быстро и доводится мягко
const g = frozen ? 1 : 1 - Math.pow(1 - Math.min(1, t), 3);
if (g <= f.prev) {
if (g < 1) alive.push(f);
continue;
}
F[n * 4] = f.x;
F[n * 4 + 1] = f.y;
F[n * 4 + 2] = g;
F[n * 4 + 3] = f.prev;
C[n * 4] = f.color[0];
C[n * 4 + 1] = f.color[1];
C[n * 4 + 2] = f.color[2];
C[n * 4 + 3] = f.seed;
f.prev = g;
n++;
if (g < 1) alive.push(f); // дорос до конца — снимаем: дальше он живёт в текстуре
}
accum.uniforms.uCount!.value = n;
if (n > 0) {
accum.uniforms.tPrev!.value = a.texture;
renderer.render({ scene: accumMesh, target: b });
const t = a;
a = b;
b = t;
}
show();
live = alive;
};
const loop = (now: number) => {
step(now);
// рисовать надо, только пока что-то растёт: сад уже лежит в текстуре
raf = live.length ? requestAnimationFrame(loop) : 0;
};
function start() {
if (raf) return;
raf = requestAnimationFrame(loop);
}
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const onClick = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
plant((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
/** Обнулить буфер напрямую: рисовать «ничего» поверх накопленного нечем. */
const wipe = (rt: RenderTarget) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, rt.buffer);
// viewport НЕ трогать: clear чистит весь фреймбуфер и без него, а ручной вызов
// мимо OGL рассинхронизирует его кеш — следующий show() рисовал бы кадр
// в четверти холста (грабля найдена на fluid-reveal).
gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
};
const onDouble = () => {
live = [];
wipe(a);
wipe(b);
show();
};
el.addEventListener("pointerdown", onClick);
el.addEventListener("dblclick", onDouble);
resize();
const ro = new ResizeObserver(resize);
ro.observe(el);
// первые цветки — чтобы карточка в каталоге не была пустой; в статичном режиме
// они распускаются мгновенно, одним кадром, и цикл сразу засыпает
const seeds = Math.max(0, Math.min(MAX_LIVE, Math.round(cfg.current.seedCount)));
for (let i = 0; i < seeds; i++) {
plant(0.24 + Math.random() * 0.52, 0.28 + Math.random() * 0.44, frozen ? 0 : i * 260);
}
const onVis = () => (document.hidden ? stop() : live.length && start());
document.addEventListener("visibilitychange", onVis);
return () => {
stop();
ro.disconnect();
el.removeEventListener("pointerdown", onClick);
el.removeEventListener("dblclick", onDouble);
document.removeEventListener("visibilitychange", onVis);
gl.getExtension("WEBGL_lose_context")?.loseContext();
gl.canvas.remove();
};
}, []);
return (
<div
ref={host}
className="blg"
style={{
position: "absolute",
inset: 0,
cursor: "crosshair",
touchAction: "manipulation",
...(backdrop === false ? null : { background: backdrop }),
}}
/>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — https://codepen.io/ksenia-k/pen/RwqrxBG (лицензия не указана, код не заимствован)v1.0.0