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

Bloom Garden

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

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

Пропсы

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

ПропТипПо умолчаниюЧто это
duration?number1.1Секунд на один цветок (формат «1.1»).
size?number0.14Радиус цветка, доля меньшей стороны кадра (формат «0.14»).
ink?number0.55Насыщенность мазка: выше — сад заполняется быстрее (формат «0.55»).
seedCount?number3Сколько цветков распустить при появлении (формат «3»).
backdrop?string | falseBACKDROPПодложка: CSS-значение `background` или `false`, если её задаёт секция.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/bloom-garden.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, 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