{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "picture-cloud",
  "title": "Picture Cloud",
  "description": "Thousands of small picture tiles that form a ring of petals, gather into a pile and grow into a turning, ghosting ball as their progress is driven, drawn in one call and idle when still. Raw WebGL 2, no dependencies.",
  "files": [
    {
      "path": "registry/mewo/ui/picture-cloud.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface PictureCloudControls {\n  /** Moves the cloud to a point in its sequence, 0 to 1, without a React render (call it from a scroll handler). */\n  setProgress: (progress: number) => void\n}\n\nexport interface PictureCloudProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Pictures for the tiles, cycled. Left out, the tiles are painted in the warm stone tones of old museum objects. */\n  images?: string[]\n  /** Where the cloud is in its sequence, 0 to 1: a ring of petals forms, drifts into a pile, a second ring flies in\n   * and follows, the pile grows into a turning ball that ghosts, pulls back, slides aside and fades. */\n  progress?: number\n  /** `setProgress(p)`, to drive it every frame (from a scroll position, say) without re-rendering. */\n  controlsRef?: React.Ref<PictureCloudControls>\n  /** How many tiles make the ball; the rings scale with it. */\n  count?: number\n  /** Petals in the first ring. */\n  petals?: number\n  /** Faint outlined squares around the ball in the later part of the sequence. */\n  outlines?: boolean\n  /** Override the device pixel ratio, which is capped at 2. */\n  dpr?: number\n}\n\ntype Settings = { images: string[]; count: number; petals: number; outlines: boolean; dpr?: number }\ntype Keys = readonly (readonly [number, number])[]\n\n// The sequence, in the scroll positions it was measured at (viewport heights of a long story): progress 0 to 1 maps\n// onto START to END. Every length is in heights of the cloud's box from its centre, y down.\nconst START = 50.4\nconst END = 84\nconst PIXEL_BUDGET = 4.5e6\nconst SPACING = 0.7\nconst SIZE = { ring: 0.0156, ball: 0.0133 }\nconst ASPECTS = [0.667, 0.833, 1, 1.2, 1.667]\nconst MOTION = {\n  converge: [51.8, 57.4, 0.15, 1.6], // the first ring drifts into the centre pile: start, end, stagger share, ease power\n  core: 0.075, // the pile's radius\n  keep: 0.12, // share of tiles that stay visible in the pile\n  ringIn: [57.4, 58.8, 0.3, 2], // the second ring flies in from outside\n  ringOut: [58.6, 61.4, 0.15, 1.6], // and drifts into the centre\n  ringFrom: 1.8, // how far out it starts, × its place\n  form: [60.2, 61.8, 0.35, 2], // the pile grows into the ball\n  grow: [60.2, 61.8, 0.2], // the ball's scale as it forms: from, to, starting scale\n  pull: [70.6, 71.5, 0.76], // the ball pulls back once: from, to, final scale\n  turnFrom: 60.2, // the ball turns about its vertical axis from here\n  turn: 0.35, // radians per viewport height of scroll\n  ghost: [63.6, 67.8], // the shell ghosts, the core stays\n  ball: { share: 0.1, rc: 0.13, rin: 0.16, R: 0.3, faint: 0.113 },\n  fog: { centre: 0.4, slope: 0.75, max: 0.75 }, // the far side fades towards the page colour\n} as const\nconst SPREAD: Keys = [[50.4, 1], [50.45, 1], [50.6, 1.7], [50.7, 1.661], [51.05, 1.385], [51.2, 1.309], [51.45, 1.034], [51.6, 1]]\nconst OPACITY: Keys = [[50.4, 0.135], [50.5, 0.279], [50.65, 0.378], [50.8, 0.548], [50.9, 0.636], [51.2, 0.794], [51.45, 0.958], [51.6, 1], [82.85, 0.967], [83.05, 0.706], [83.25, 0.31], [83.4, 0.16], [83.55, 0]]\nconst GHOST: Keys = [[63.7, 0.005], [63.9, 0.064], [64.05, 0.166], [64.2, 0.36], [64.4, 0.711], [64.55, 0.886], [64.65, 0.955], [64.85, 1], [66.65, 1], [66.85, 0.958], [66.95, 0.885], [67.05, 0.777], [67.2, 0.549], [67.3, 0.361], [67.45, 0.172], [67.6, 0.064], [67.85, 0]]\nconst CENTRE_X: Keys = [[75.4, 0], [75.75, 0.078], [76.35, 0.288], [76.7, 0.339], [83.35, 0.335], [83.45, 0.27], [83.55, 0.063], [83.6, 0.016]]\nconst SQUARES: Keys = [[64.35, 0], [64.55, 0.097], [66.95, 0.097], [67.15, 0], [70.65, 0], [70.95, 0.452], [71.4, 1], [82.75, 1], [83.15, 0.624], [83.55, 0]]\n// tiles near the centre of a ring are faint: opacity by distance from the centre\nconst FADE_A: Keys = [[0.02, 1], [0.06, 0.072], [0.1, 0.338], [0.14, 0.603], [0.18, 0.898], [0.22, 0.961], [0.26, 0.906], [0.3, 0.942], [0.34, 0.929], [0.38, 1], [0.42, 0.974]]\nconst FADE_B: Keys = [[0.02, 1], [0.06, 1], [0.1, 0.412], [0.14, 0.636], [0.18, 0.87], [0.22, 0.904], [0.26, 0.936], [0.3, 0.932], [0.34, 0.929], [0.38, 0.93], [0.42, 0.89]]\n// the generated tiles' grounds, by quantile: warm greys and browns of old stone and clay\nconst TONES: [number, [number, number, number]][] = [[0, [65, 58, 53]], [0.1, [81, 73, 66]], [0.5, [124, 117, 97]], [0.9, [148, 130, 125]], [1, [166, 151, 146]]]\nconst TONE_CELLS = 24\nconst CELL = 128\nconst OUTLINE_ALPHA = 0.05\n// reduced motion: the sequence jumps between its resting shapes instead of flowing\nconst RESTS: Keys = [[56, 51.7], [59.5, 58.7], [63, 62], [68, 65.5], [73, 72], [83, 78], [Infinity, 84]]\n\nconst clamp01 = (x: number) => Math.min(1, Math.max(0, x))\nconst ease = (x: number, p: number) => (x < 0.5 ? (2 * x) ** p / 2 : 1 - (2 - 2 * x) ** p / 2)\n\n/** A keyframed value at v, linear between keys and held past either end. */\nexport function valueAt(keys: Keys, v: number) {\n  if (v <= keys[0]![0]) return keys[0]![1]\n  for (let i = 1; i < keys.length; i++) {\n    const [b, vb] = keys[i]!\n    if (v <= b) {\n      const [a, va] = keys[i - 1]!\n      return va + ((vb - va) * (v - a)) / (b - a)\n    }\n  }\n  return keys[keys.length - 1]![1]\n}\n\n/** How strongly a point at angle theta (radians, y down) belongs to a ring of petals, one petal centred at the top:\n * 1 inside a petal, 0 in the gaps between them, with `gap` the share of each petal's span that fades. */\nexport function petalWeight(theta: number, petals: number, gap: number) {\n  const span = (2 * Math.PI) / Math.max(1, petals)\n  const phase = ((((theta + Math.PI / 2) / span + 0.5) % 1) + 1) % 1\n  const edge = (x: number) => (x >= 1 ? 1 : x <= 0 ? 0 : x * x * (3 - 2 * x))\n  return edge(phase / gap) * edge((1 - phase) / gap)\n}\n\n/** The scroll position the sequence shows at progress p (0 to 1), snapped to its resting shapes for reduced motion. */\nexport function sequenceAt(p: number, reduced = false) {\n  const v = START + clamp01(p) * (END - START)\n  if (!reduced) return v\n  for (const [below, rest] of RESTS) if (v < below) return rest\n  return END\n}\n\nconst rng = (seed: number) => () => {\n  seed = (seed + 0x6d2b79f5) | 0\n  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)\n  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n  return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n}\nconst fadeAt = (profile: Keys, r: number) => {\n  let a = profile[0]![1]\n  for (const [rr, v] of profile) if (r >= rr - 0.02) a = v\n  return a\n}\n\ntype Ring = { petals: number; gap: number; radius: number; width: number; inner: number; innerRadius: number }\n// the share `inner` is of the places drawn; a petal's edges reject ring draws (on average `gap` of them), so the\n// chance of drawing inside is set to keep that share among the accepted places\n\n// n places in a ring of petals, kept apart by SPACING × the tile size where possible, plus a share scattered inside\nfunction ringPlaces(n: number, r: () => number, ring: Ring) {\n  const out: [number, number][] = []\n  const dmin = SPACING * SIZE.ring\n  const grid = new Map<string, [number, number][]>()\n  const cellOf = (x: number, y: number, a = 0, b = 0) => `${Math.floor(x / dmin) + a}:${Math.floor(y / dmin) + b}`\n  const near = (x: number, y: number) => {\n    for (let a = -1; a <= 1; a++)\n      for (let b = -1; b <= 1; b++) for (const [px, py] of grid.get(cellOf(x, y, a, b)) ?? []) if (Math.hypot(px - x, py - y) < dmin) return true\n    return false\n  }\n  const kept = 1 - ring.gap\n  const innerChance = (ring.inner * kept) / (1 - ring.inner + ring.inner * kept)\n  for (let tries = 0; out.length < n && tries < n * 400; tries++) {\n    const theta = r() * Math.PI * 2\n    let rad: number\n    if (r() < innerChance) rad = ring.innerRadius * r()\n    else {\n      if (r() > petalWeight(theta, ring.petals, ring.gap)) continue\n      rad = ring.radius + ring.width * Math.sqrt(-2 * Math.log(1 - r())) * Math.cos(2 * Math.PI * r())\n    }\n    const x = Math.cos(theta) * rad\n    const y = Math.sin(theta) * rad\n    if (tries < n * 60 && near(x, y)) continue\n    out.push([x, y])\n    const k = cellOf(x, y)\n    grid.set(k, [...(grid.get(k) ?? []), [x, y]])\n  }\n  return out.sort((p, q) => Math.atan2(p[1], p[0]) - Math.atan2(q[1], q[0]))\n}\n\ntype Tiles = {\n  n: number\n  ax: Float32Array; ay: Float32Array; bx: Float32Array; by: Float32Array; cx: Float32Array; cy: Float32Array\n  sx: Float32Array; sy: Float32Array; sz: Float32Array\n  fa: Float32Array; fb: Float32Array; delay: Float32Array; aspect: Float32Array; cell: Uint16Array\n  core: Uint8Array; keep: Uint8Array; ring: Uint8Array\n}\n\n// the ball's tiles (which also make the first ring and the pile), then the second ring's own tiles\nfunction buildTiles(count: number, petals: number, cells: number): Tiles {\n  const r = rng(5)\n  const nS = Math.max(50, Math.round(count))\n  const A = ringPlaces(Math.round((nS * 470) / 1766), r, { petals, gap: 0.3, radius: 0.365, width: 0.022, inner: 0.25, innerRadius: 0.3 })\n  const B = ringPlaces(Math.round((nS * 806) / 1766), r, { petals: 5, gap: 0.3, radius: 0.35, width: 0.04, inner: 0.21, innerRadius: 0.28 })\n  const { share, rc, rin, R } = MOTION.ball\n  const inBall = (r0: number, r1: number): [number, number, number] => {\n    const s = Math.cbrt(r0 ** 3 + r() * (r1 ** 3 - r0 ** 3))\n    const ct = 2 * r() - 1\n    const ph = 2 * Math.PI * r()\n    const st = Math.sqrt(1 - ct * ct)\n    return [s * st * Math.cos(ph), s * st * Math.sin(ph), s * ct]\n  }\n  const nCore = Math.round(nS * share)\n  const n = nS + B.length\n  const f = () => new Float32Array(n)\n  const t: Tiles = {\n    n, ax: f(), ay: f(), bx: f(), by: f(), cx: f(), cy: f(), sx: f(), sy: f(), sz: f(), fa: f(), fb: f(), delay: f(), aspect: f(),\n    cell: new Uint16Array(n), core: new Uint8Array(n), keep: new Uint8Array(n), ring: new Uint8Array(n),\n  }\n  for (let k = 0; k < n; k++) {\n    const ring = k >= nS\n    const pa = A[k % Math.max(1, A.length)] ?? [0, 0]\n    const pb = (ring ? B[k - nS] : B[k % Math.max(1, B.length)]) ?? [0, 0]\n    const ks = k % nS\n    const s = ks < nCore ? inBall(0, rc) : inBall(rin, R)\n    const ang = r() * Math.PI * 2\n    const rad = MOTION.core * Math.sqrt(r())\n    t.ax[k] = pa[0]; t.ay[k] = pa[1]; t.bx[k] = pb[0]; t.by[k] = pb[1]\n    t.cx[k] = Math.cos(ang) * rad; t.cy[k] = Math.sin(ang) * rad\n    t.sx[k] = s[0]; t.sy[k] = s[1]; t.sz[k] = s[2]\n    t.core[k] = ks < nCore ? 1 : 0\n    t.keep[k] = r() < MOTION.keep ? 1 : 0\n    t.ring[k] = ring ? 1 : 0\n    t.fa[k] = fadeAt(FADE_A, Math.hypot(pa[0], pa[1]))\n    t.fb[k] = fadeAt(FADE_B, Math.hypot(pb[0], pb[1]))\n    t.delay[k] = r()\n    t.aspect[k] = ASPECTS[Math.floor(r() * ASPECTS.length)]!\n    t.cell[k] = Math.floor(r() * cells)\n  }\n  return t\n}\n\n// a tile's own progress through a staggered move: its delay takes up `share` of the window\nconst staggered = (v: number, [v0, v1, share, power]: readonly number[], delay: number) =>\n  ease(clamp01(((v - v0!) / (v1! - v0!) - share! * delay) / Math.max(0.05, 1 - share!)), power!)\n\ntype Placed = { x: Float32Array; y: Float32Array; w: Float32Array; h: Float32Array; alpha: Float32Array; depth: Float32Array; fog: Float32Array }\n\n// every tile's box, opacity, depth and fog at scroll position v\nfunction place(T: Tiles, v: number, out: Placed) {\n  const M = MOTION\n  const inFlow = v >= M.converge[0] && v < M.form[1]\n  const inGhost = v >= M.ghost[0] && v <= M.ghost[1]\n  const spread = inFlow || inGhost ? 1 : valueAt(SPREAD, v)\n  const opacity = v < M.converge[0] || v >= 82.5 ? valueAt(OPACITY, v) : 1\n  const ghost = valueAt(GHOST, v)\n  const shiftX = valueAt(CENTRE_X, v)\n  const ball = v >= M.form[0]\n  const th = Math.max(0, v - M.turnFrom) * M.turn\n  const cs = Math.cos(th)\n  const sn = Math.sin(th)\n  const grow = M.grow[2] + (1 - M.grow[2]) * ease(clamp01((v - M.grow[0]) / (M.grow[1] - M.grow[0])), 2)\n  const sizeScale = 1 - (1 - M.pull[2]) * ease(clamp01((v - M.pull[0]) / (M.pull[1] - M.pull[0])), 2)\n  const ballScale = sizeScale * grow\n  for (let i = 0; i < T.n; i++) {\n    let x: number, y: number, size: number, alpha: number\n    let depth = 0\n    let fog = 0\n    if (T.ring[i]) {\n      // the second ring: in from outside fading in, then into the centre fading out\n      const qi = v < M.ringIn[0] ? 0 : staggered(v, M.ringIn, T.delay[i]!)\n      const qo = v < M.ringOut[0] ? 0 : staggered(v, M.ringOut, T.delay[i]!)\n      const k = 1 + (M.ringFrom - 1) * (1 - qi)\n      const rx = T.bx[i]! * k\n      const ry = T.by[i]! * k\n      x = rx + (T.cx[i]! - rx) * qo\n      y = ry + (T.cy[i]! - ry) * qo\n      size = SIZE.ring\n      alpha = v < M.ringIn[0] || v >= M.ringOut[1] ? 0 : T.fb[i]! * qi * (1 - ease(clamp01((qo - 0.6) / 0.4), 2))\n    } else if (v < M.converge[0]) {\n      // the first ring, forming from further out\n      x = T.ax[i]! * spread\n      y = T.ay[i]! * spread\n      size = SIZE.ring\n      alpha = opacity * T.fa[i]!\n    } else if (!ball) {\n      // the first ring drifts into the pile; all but a few fade as they arrive, so the pile stays light\n      const p = staggered(v, M.converge, T.delay[i]!)\n      x = T.ax[i]! + (T.cx[i]! - T.ax[i]!) * p\n      y = T.ay[i]! + (T.cy[i]! - T.ay[i]!) * p\n      size = SIZE.ring\n      alpha = (T.fa[i]! + (1 - T.fa[i]!) * p) * (T.keep[i] ? 1 : 1 - ease(clamp01((p - 0.6) / 0.4), 2))\n    } else {\n      // the pile grows into the ball, turned about its vertical axis without perspective, so every tile keeps its\n      // size; depth shows as draw order and the far side's fog\n      const X = T.sx[i]! * cs + T.sz[i]! * sn\n      const Z = -T.sx[i]! * sn + T.sz[i]! * cs\n      const p = v < M.form[1] ? staggered(v, M.form, T.delay[i]!) : 1\n      const tx = X * ballScale + shiftX\n      const ty = T.sy[i]! * ballScale\n      x = T.cx[i]! + (tx - T.cx[i]!) * p\n      y = T.cy[i]! + (ty - T.cy[i]!) * p\n      size = SIZE.ring + (SIZE.ball * sizeScale - SIZE.ring) * p\n      alpha = opacity * (T.keep[i] ? 1 : ease(clamp01(p / 0.5), 2))\n      if (ghost > 0 && !T.core[i]) alpha *= 1 - ghost * (1 - M.ball.faint)\n      fog = Math.min(M.fog.max, Math.max(0, M.fog.centre - (M.fog.slope * Z) / M.ball.R)) * p\n      depth = Z * p\n    }\n    const sq = Math.sqrt(T.aspect[i]!)\n    out.x[i] = x\n    out.y[i] = y\n    out.w[i] = size * sq\n    out.h[i] = size / sq\n    out.alpha[i] = alpha\n    out.depth[i] = depth\n    out.fog[i] = fog\n  }\n  return ball\n}\n\nconst VERTEX = `#version 300 es\nin vec4 aBox;\nin vec4 aUv;\nin vec3 aStyle;\nuniform vec2 uView;\nout vec2 vUv;\nout vec2 vLocal;\nout vec3 vStyle;\nvoid main() {\n  vec2 q = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));\n  vLocal = q;\n  vUv = aUv.xy + q * aUv.zw;\n  vStyle = aStyle;\n  vec2 p = aBox.xy + (q - 0.5) * aBox.zw;\n  gl_Position = vec4(p.x / uView.x * 2.0 - 1.0, 1.0 - p.y / uView.y * 2.0, 0.0, 1.0);\n}`\n\n// kind 0 samples the atlas, kind 1 draws a one-pixel outline in the ink colour; fog mixes towards the page colour\nconst FRAGMENT = `#version 300 es\nprecision mediump float;\nuniform sampler2D uMap;\nuniform vec3 uFog;\nuniform vec3 uInk;\nin vec2 vUv;\nin vec2 vLocal;\nin vec3 vStyle;\nout vec4 outColor;\nvoid main() {\n  vec4 tex = texture(uMap, vUv);\n  vec2 d = min(vLocal, 1.0 - vLocal) / fwidth(vLocal);\n  vec4 c = vStyle.x < 0.5 ? tex : vec4(uInk, step(min(d.x, d.y), 1.0));\n  float a = c.a * vStyle.y;\n  if (a < 0.003) discard;\n  outColor = vec4(mix(c.rgb, uFog, vStyle.z) * a, a);\n}`\n\nconst compile = (gl: WebGL2RenderingContext, type: number, source: string) => {\n  const shader = gl.createShader(type)\n  if (!shader) return null\n  gl.shaderSource(shader, source)\n  gl.compileShader(shader)\n  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader\n  gl.deleteShader(shader)\n  return null\n}\n\nconst toneAt = (q: number) => {\n  let k = 0\n  while (k < TONES.length - 2 && q > TONES[k + 1]![0]) k++\n  const [qa, a] = TONES[k]!\n  const [qb, b] = TONES[k + 1]!\n  const t = (q - qa) / (qb - qa)\n  return a.map((v, i) => Math.round(v + (b[i]! - v) * t)) as [number, number, number]\n}\n\n// a generated tile: a ground at its tone's quantile, with a small figure lighter or darker than it\nfunction paintTone(g: CanvasRenderingContext2D, r: () => number, x: number, y: number, q: number) {\n  const [R, G, B] = toneAt(q)\n  const shade = (d: number) => `rgb(${Math.max(0, Math.min(255, R + d))},${Math.max(0, Math.min(255, G + d))},${Math.max(0, Math.min(255, B + d))})`\n  g.fillStyle = shade(0)\n  g.fillRect(x, y, CELL, CELL)\n  for (let k = 0; k < 3; k++) {\n    g.fillStyle = shade((r() - 0.5) * 70)\n    g.beginPath()\n    g.ellipse(x + CELL * (0.3 + r() * 0.4), y + CELL * (0.3 + r() * 0.4), CELL * (0.08 + r() * 0.12), CELL * (0.08 + r() * 0.12), 0, 0, Math.PI * 2)\n    g.fill()\n  }\n}\n\n/**\n * A cloud of small picture tiles that moves through a sequence as it is driven: a ring of petals forms and drifts\n * into a pile, a second ring flies in and follows, the pile grows into a ball that turns, ghosts its outer shell,\n * pulls back, slides aside and fades. One WebGL 2 draw call from one atlas; it draws only when its progress, size\n * or pictures change, and never off screen. Fills its parent.\n */\nexport function PictureCloud({ images = [], progress, controlsRef, count = 1766, petals = 10, outlines = true, dpr, className, ...props }: PictureCloudProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const settings: Settings = { images, count, petals, outlines, dpr }\n  const settingsRef = React.useRef(settings)\n  const progressRef = React.useRef(clamp01(progress ?? 0.1))\n  const lastProgressPropRef = React.useRef(progress)\n  const wakeRef = React.useRef<(() => void) | null>(null)\n\n  React.useImperativeHandle(\n    controlsRef,\n    () => ({\n      setProgress: (p: number) => {\n        progressRef.current = clamp01(p)\n        wakeRef.current?.()\n      },\n    }),\n    []\n  )\n\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n    const canvas = document.createElement(\"canvas\")\n    canvas.className = \"absolute inset-0 block h-full w-full\"\n    canvas.setAttribute(\"aria-hidden\", \"true\")\n    const gl = canvas.getContext(\"webgl2\", { alpha: true, premultipliedAlpha: true, antialias: true, depth: false, stencil: false })\n    if (!gl) return\n    root.appendChild(canvas)\n\n    const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX)\n    const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)\n    const program = gl.createProgram()\n    if (!vertex || !fragment || !program) return\n    gl.attachShader(program, vertex)\n    gl.attachShader(program, fragment)\n    gl.linkProgram(program)\n    gl.deleteShader(vertex)\n    gl.deleteShader(fragment)\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return\n    const uView = gl.getUniformLocation(program, \"uView\")\n    const uFog = gl.getUniformLocation(program, \"uFog\")\n    const uInk = gl.getUniformLocation(program, \"uInk\")\n    const uMap = gl.getUniformLocation(program, \"uMap\")\n\n    // one instance buffer: box (x, y, w, h in pixels), atlas rectangle, style (kind, alpha, fog)\n    const STRIDE = 11\n    const vao = gl.createVertexArray()\n    const buffer = gl.createBuffer()\n    gl.bindVertexArray(vao)\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n    const attrib = (name: string, size: number, offset: number) => {\n      const loc = gl.getAttribLocation(program, name)\n      if (loc < 0) return\n      gl.enableVertexAttribArray(loc)\n      gl.vertexAttribPointer(loc, size, gl.FLOAT, false, STRIDE * 4, offset * 4)\n      gl.vertexAttribDivisor(loc, 1)\n    }\n    attrib(\"aBox\", 4, 0)\n    attrib(\"aUv\", 4, 4)\n    attrib(\"aStyle\", 3, 8)\n    gl.bindVertexArray(null)\n\n    // the atlas: one square cell per picture (or per generated tone), drawn edge to edge\n    const atlas = document.createElement(\"canvas\")\n    const g = atlas.getContext(\"2d\")\n    const texture = gl.createTexture()\n    let grid = 1\n    let cells = TONE_CELLS\n    let imagesKey = \"\\u0000\"\n    let atlasDirty = false\n    let loads: HTMLImageElement[] = []\n    const buildAtlas = (sources: string[]) => {\n      for (const img of loads) img.onload = img.onerror = null\n      loads = []\n      cells = sources.length ? Math.min(64, sources.length) : TONE_CELLS\n      grid = Math.ceil(Math.sqrt(cells))\n      atlas.width = atlas.height = grid * CELL\n      if (!g) return\n      const r = rng(3)\n      for (let i = 0; i < cells; i++) {\n        const x = (i % grid) * CELL\n        const y = Math.floor(i / grid) * CELL\n        paintTone(g, r, x, y, (i + 0.5) / cells)\n        const src = sources[i]\n        if (!src) continue\n        const img = new Image()\n        img.crossOrigin = \"anonymous\"\n        img.decoding = \"async\"\n        img.onload = () => {\n          const iw = img.naturalWidth\n          const ih = img.naturalHeight\n          if (!iw || !ih) return\n          const s = Math.min(iw, ih)\n          g.drawImage(img, (iw - s) / 2, (ih - s) / 2, s, s, x, y, CELL, CELL)\n          atlasDirty = true\n          wake()\n        }\n        img.src = src\n        loads.push(img)\n      }\n      atlasDirty = true\n    }\n    const uploadAtlas = () => {\n      gl.bindTexture(gl.TEXTURE_2D, texture)\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)\n      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)\n      try {\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas)\n      } catch {\n        // a picture served without CORS taints the atlas: keep the last good upload\n      }\n      gl.generateMipmap(gl.TEXTURE_2D)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n      atlasDirty = false\n    }\n\n    // the page's colours, read through a probe so the fog and the outlines follow the theme\n    const probe = document.createElement(\"span\")\n    probe.style.cssText = \"position:absolute;width:0;height:0;overflow:hidden;visibility:hidden\"\n    root.appendChild(probe)\n    const swatch = document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true })\n    const themeColor = (css: string, fallback: [number, number, number]): [number, number, number] => {\n      probe.style.color = css\n      const value = getComputedStyle(probe).color\n      if (!swatch || !value) return fallback\n      swatch.clearRect(0, 0, 1, 1)\n      swatch.fillStyle = value\n      swatch.fillRect(0, 0, 1, 1)\n      const [r, gg, b] = swatch.getImageData(0, 0, 1, 1).data\n      return [r! / 255, gg! / 255, b! / 255]\n    }\n    let fogRgb: [number, number, number] = [1, 1, 1]\n    let inkRgb: [number, number, number] = [0, 0, 0]\n    const readTheme = () => {\n      fogRgb = themeColor(\"var(--background, #fff)\", [1, 1, 1])\n      inkRgb = themeColor(\"var(--foreground, #000)\", [0, 0, 0])\n    }\n\n    const reduced = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches ?? false\n    let tiles: Tiles | null = null\n    let tilesKey = \"\"\n    let placed: Placed | null = null\n    let order = new Uint32Array(0)\n    let squares: Float32Array = new Float32Array(0)\n    let data = new Float32Array(0)\n    let width = 1\n    let height = 1\n    let raf = 0\n    let visible = true\n    let dirty = true\n    let drawn = -1\n\n    const ensureTiles = () => {\n      const s = settingsRef.current\n      const key = `${s.count}:${s.petals}:${cells}`\n      if (key === tilesKey && tiles) return\n      tilesKey = key\n      tiles = buildTiles(s.count, s.petals, cells)\n      const n = tiles.n\n      const f = () => new Float32Array(n)\n      placed = { x: f(), y: f(), w: f(), h: f(), alpha: f(), depth: f(), fog: f() }\n      order = new Uint32Array(n)\n      // outlined squares over a band wide enough for a 3.2 : 1 box, measured sizes, seeded\n      const r = rng(21)\n      squares = new Float32Array(366 * 3)\n      for (let k = 0; k < 366; k++) {\n        squares[k * 3] = (r() - 0.5) * 3.2\n        squares[k * 3 + 1] = r() - 0.5\n        squares[k * 3 + 2] = 0.03 + r() * 0.08\n      }\n      data = new Float32Array((n + squares.length / 3) * STRIDE)\n      // the GPU buffer is sized once per tile set; each frame writes only what it draws\n      gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n      gl.bufferData(gl.ARRAY_BUFFER, data.byteLength, gl.DYNAMIC_DRAW)\n    }\n\n    const draw = () => {\n      const s = settingsRef.current\n      const key = s.images.join(\"\\u0001\")\n      if (key !== imagesKey) {\n        imagesKey = key\n        buildAtlas(s.images)\n      }\n      ensureTiles()\n      if (atlasDirty) uploadAtlas()\n      const T = tiles!\n      const P = placed!\n      const v = sequenceAt(progressRef.current, reduced)\n      drawn = progressRef.current\n      gl.viewport(0, 0, canvas.width, canvas.height)\n      gl.clearColor(0, 0, 0, 0)\n      gl.clear(gl.COLOR_BUFFER_BIT)\n      dirty = false\n      if (v <= START || v >= END) return\n      const ball = place(T, v, P)\n      // the cloud scales with its box's height, and shrinks to fit a narrow box\n      const fit = Math.min(1, width / height / 0.95)\n      const unit = height * fit\n      const ox = width / 2\n      const oy = height / 2\n      for (let i = 0; i < T.n; i++) order[i] = i\n      if (ball) order.sort((a, b) => P.depth[a]! - P.depth[b]!)\n      const cell = 1 / grid\n      let k = 0\n      for (let j = 0; j < T.n; j++) {\n        const i = order[j]!\n        const alpha = P.alpha[i]!\n        if (alpha <= 0.002) continue\n        const a = T.aspect[i]!\n        const c = T.cell[i]!\n        const u0 = (c % grid) * cell\n        const v0 = Math.floor(c / grid) * cell\n        // the centre of the cell at the tile's proportions, inset a little so small mipmaps keep to the cell\n        const du = (a >= 1 ? cell : cell * a) * 0.92\n        const dv = (a >= 1 ? cell / a : cell) * 0.92\n        const o = k * STRIDE\n        data[o] = ox + P.x[i]! * unit\n        data[o + 1] = oy + P.y[i]! * unit\n        data[o + 2] = P.w[i]! * unit\n        data[o + 3] = P.h[i]! * unit\n        data[o + 4] = u0 + (cell - du) / 2\n        data[o + 5] = v0 + (cell - dv) / 2\n        data[o + 6] = du\n        data[o + 7] = dv\n        data[o + 8] = 0\n        data[o + 9] = alpha\n        data[o + 10] = P.fog[i]!\n        k++\n      }\n      const sq = s.outlines ? valueAt(SQUARES, v) : 0\n      if (sq > 0.002) {\n        const shift = valueAt(CENTRE_X, v) * 1.27\n        const reach = width / height / 2 + 0.1\n        for (let q = 0; q < squares.length; q += 3) {\n          const x = squares[q]! + shift\n          if (Math.abs(x) > reach) continue\n          const size = squares[q + 2]! * height\n          const o = k * STRIDE\n          data[o] = ox + x * height\n          data[o + 1] = oy + squares[q + 1]! * height\n          data[o + 2] = size\n          data[o + 3] = size\n          data[o + 8] = 1\n          data[o + 9] = sq * OUTLINE_ALPHA\n          data[o + 10] = 0\n          k++\n        }\n      }\n      if (!k) return\n      gl.useProgram(program)\n      gl.bindVertexArray(vao)\n      gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n      gl.bufferSubData(gl.ARRAY_BUFFER, 0, data, 0, k * STRIDE)\n      gl.activeTexture(gl.TEXTURE0)\n      gl.bindTexture(gl.TEXTURE_2D, texture)\n      gl.uniform1i(uMap, 0)\n      gl.uniform2f(uView, width, height)\n      gl.uniform3f(uFog, fogRgb[0], fogRgb[1], fogRgb[2])\n      gl.uniform3f(uInk, inkRgb[0], inkRgb[1], inkRgb[2])\n      gl.enable(gl.BLEND)\n      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)\n      gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, k)\n      gl.bindVertexArray(null)\n    }\n\n    // one frame per change: nothing here moves by itself, so the loop never keeps running\n    const frame = () => {\n      raf = 0\n      if (!visible || document.hidden) return\n      if (dirty || atlasDirty || drawn !== progressRef.current) draw()\n    }\n    const wake = () => {\n      if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame)\n    }\n\n    const resize = () => {\n      const s = settingsRef.current\n      width = Math.max(1, root.clientWidth)\n      height = Math.max(1, root.clientHeight)\n      const ratio = Math.min(s.dpr || window.devicePixelRatio || 1, 2, Math.sqrt(PIXEL_BUDGET / (width * height)))\n      canvas.width = Math.max(1, Math.round(width * ratio))\n      canvas.height = Math.max(1, Math.round(height * ratio))\n      dirty = true\n      wake()\n    }\n    const onVisibility = () => wake()\n    const resizeObserver = new ResizeObserver(resize)\n    resizeObserver.observe(root)\n    const intersection = new IntersectionObserver((entries) => {\n      visible = entries.some((entry) => entry.isIntersecting)\n      if (visible) wake()\n    })\n    intersection.observe(root)\n    const themeObserver = new MutationObserver(() => {\n      readTheme()\n      dirty = true\n      wake()\n    })\n    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] })\n    document.addEventListener(\"visibilitychange\", onVisibility)\n    readTheme()\n    resize()\n    wakeRef.current = () => {\n      dirty = true\n      wake()\n    }\n\n    return () => {\n      cancelAnimationFrame(raf)\n      wakeRef.current = null\n      for (const img of loads) img.onload = img.onerror = null\n      resizeObserver.disconnect()\n      intersection.disconnect()\n      themeObserver.disconnect()\n      document.removeEventListener(\"visibilitychange\", onVisibility)\n      gl.deleteBuffer(buffer)\n      gl.deleteVertexArray(vao)\n      gl.deleteTexture(texture)\n      gl.deleteProgram(program)\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext()\n      canvas.remove()\n      probe.remove()\n    }\n  }, [])\n\n  // props reach the loop through refs written here, never in render; a change only wakes it for one frame\n  React.useEffect(() => {\n    settingsRef.current = settings\n    if (progress !== lastProgressPropRef.current) {\n      lastProgressPropRef.current = progress\n      if (progress !== undefined) progressRef.current = clamp01(progress)\n    }\n    wakeRef.current?.()\n  })\n\n  return <div ref={rootRef} data-slot=\"picture-cloud\" className={cn(\"relative h-full w-full overflow-hidden\", className)} {...props} />\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "added": "2026-10-27"
  },
  "categories": [
    "background",
    "scroll"
  ],
  "type": "registry:ui"
}