{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caustics",
  "type": "registry:block",
  "title": "Caustics",
  "description": "Sunlight focused through moving water onto a tiled pool floor: a dense mesh of rays is refracted and landed, so the light gathers into a real, sharp caustic network with a faint colour split. Moving the pointer leaves a wake; a click splashes rings.",
  "docs": "Caustics was added to public/vividsites/caustics.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/caustics",
  "categories": [
    "3d",
    "shader-backgrounds"
  ],
  "files": [
    {
      "path": "components/vividsites/caustics.html",
      "type": "registry:file",
      "target": "public/vividsites/caustics.html",
      "content": "<!--\n  vs-caustics — sunlight through moving water, focused into caustics on a pool floor.\n  Real caustics, not a noise pattern: a dense grid of light rays is refracted through the water\n  surface and each ray's brightness is how much the landing squeezes the area around it, so the\n  light gathers into the sharp moving network you see on a pool floor, and the brightest lines\n  split faintly into colour. The floor is also seen through the surface, so its tile lines wobble.\n  Moving the pointer drops ripples; a click or tap drops a bigger one.\n\n  Fills its nearest positioned parent (position:absolute; inset:0). Put content above it.\n  CSS variables (set on .vs-caustics):\n    --vs-caustics-floor      floor colour\n    --vs-caustics-grout      grout / tile-line colour\n    --vs-caustics-water      water tint (absorbs toward this)\n    --vs-caustics-light      sunlight colour\n    --vs-caustics-tiles      1 = mosaic tiles, 0 = plain floor (sand)\n    --vs-caustics-tile-size  tiles across the short side (default 9)\n    --vs-caustics-depth      water depth, 1 = default (deeper = softer, wider caustics)\n    --vs-caustics-strength   caustic brightness, 1 = default\n    --vs-caustics-speed      wave speed, 1 = default\n-->\n<div class=\"vs-caustics\" aria-hidden=\"true\"><canvas class=\"vs-caustics-canvas\"></canvas></div>\n<style>\n  .vs-caustics {\n    --vs-caustics-floor: #eef6f7;\n    --vs-caustics-grout: #c9d9dd;\n    --vs-caustics-water: #00b3c4;\n    --vs-caustics-light: #fff4dc;\n    --vs-caustics-tiles: 1;\n    --vs-caustics-tile-size: 14;\n    --vs-caustics-depth: 1;\n    --vs-caustics-strength: 1;\n    --vs-caustics-speed: 1;\n    position: absolute; inset: 0; overflow: hidden; pointer-events: none;\n    background: radial-gradient(80% 70% at 40% 35%, color-mix(in oklab, var(--vs-caustics-floor) 60%, var(--vs-caustics-water)), color-mix(in oklab, var(--vs-caustics-floor) 25%, var(--vs-caustics-water)));\n  }\n  .vs-caustics-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1s cubic-bezier(.16, 1, .3, 1); }\n  .vs-caustics-ready .vs-caustics-canvas { opacity: 1; }\n</style>\n<script>\n(() => {\n  /* ---- shared runtime: WebGL2 context, resize, visibility, pointer spring, CSS-variable props ---- */\n  const VS = (root, spec) => {\n    const P = spec.prefix, canvas = root.querySelector('canvas');\n    const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n    const S = { root, canvas, gl: null, w: 1, h: 1, cssW: 1, cssH: 1, dpr: 1, t: spec.t0 || 0, rt: 0, speed: 1, frame: 0, visible: false, lost: false, raf: 0, reduced: reduce.matches, css: {},\n      ptr: { x: .5, y: .5, tx: .5, ty: .5, vx: 0, vy: 0, px: .5, py: .5, down: false, inside: false, active: 0, seen: false } };\n    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: false, stencil: false, premultipliedAlpha: false, powerPreference: 'high-performance' });\n    if (!gl) { root.classList.add(P + '-fallback'); return null; }\n    S.gl = gl;\n    /* colours: any CSS colour string -> linear rgb */\n    const cc = document.createElement('canvas').getContext('2d', { willReadFrequently: true });\n    const toLin = (v) => (v <= .04045 ? v / 12.92 : Math.pow((v + .055) / 1.055, 2.4));\n    const color = (str) => { cc.clearRect(0, 0, 1, 1); cc.fillStyle = '#000'; cc.fillStyle = str; cc.fillRect(0, 0, 1, 1); const d = cc.getImageData(0, 0, 1, 1).data; return [toLin(d[0] / 255), toLin(d[1] / 255), toLin(d[2] / 255)]; };\n    const readVars = () => {\n      const cs = getComputedStyle(root);\n      for (const [k, def] of Object.entries(spec.vars || {})) {\n        const raw = cs.getPropertyValue(`--${P}-${k}`).trim() || String(def);\n        S.css[k] = typeof def === 'number' ? (parseFloat(raw) || 0) : color(raw);\n      }\n      S.speed = S.css.speed != null ? S.css.speed : 1;\n      spec.vars && spec.onVars && spec.onVars(S);\n    };\n    /* GL helpers */\n    const VERT = `#version 300 es\nvoid main(){ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); gl_Position = vec4(p * 2. - 1., 0., 1.); }`;\n    S.program = (frag, vert) => {\n      const mk = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS) && !gl.isContextLost()) console.warn(P, gl.getShaderInfoLog(s)); return s; };\n      const p = gl.createProgram(); gl.attachShader(p, mk(gl.VERTEX_SHADER, vert || VERT)); gl.attachShader(p, mk(gl.FRAGMENT_SHADER, '#version 300 es\\nprecision highp float;\\nprecision highp int;\\n' + frag)); gl.linkProgram(p);\n      const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS) || 0;\n      for (let i = 0; i < n; i++) { const a = gl.getActiveUniform(p, i); u[a.name.replace(/\\[0\\]$/, '')] = gl.getUniformLocation(p, a.name); }\n      return { p, u };\n    };\n    S.target = (w, h, o = {}) => {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      const half = o.half !== false && S.hf;\n      gl.texImage2D(gl.TEXTURE_2D, 0, half ? gl.RGBA16F : gl.RGBA8, w, h, 0, gl.RGBA, half ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null);\n      const f = o.nearest ? gl.NEAREST : gl.LINEAR;\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, o.mip ? gl.LINEAR_MIPMAP_LINEAR : f); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, f);\n      const wrap = o.repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);\n      const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);\n      gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      return { t, fb, w, h, mip: !!o.mip };\n    };\n    S.pair = (w, h, o) => { const a = S.target(w, h, o), b = S.target(w, h, o); return { a, b, w, h, swap() { const x = this.a; this.a = this.b; this.b = x; } }; };\n    S.free = (...ts) => ts.forEach((x) => { if (!x) return; if (x.a) { S.free(x.a, x.b); return; } gl.deleteTexture(x.t); gl.deleteFramebuffer(x.fb); });\n    /* draw a program into a target (null = screen); tex = {name: target|texture} bound to units in order */\n    S.draw = (pr, out, uni = {}, tex = {}) => {\n      gl.useProgram(pr.p);\n      if (out) { gl.bindFramebuffer(gl.FRAMEBUFFER, out.fb); gl.viewport(0, 0, out.w, out.h); } else { gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, S.w, S.h); }\n      let unit = 0;\n      for (const [k, v] of Object.entries(tex)) { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, v && v.t !== undefined ? v.t : v); if (pr.u[k]) gl.uniform1i(pr.u[k], unit); unit++; }\n      for (const [k, v] of Object.entries(uni)) {\n        const l = pr.u[k]; if (!l) continue;\n        if (typeof v === 'number') gl.uniform1f(l, v); else if (v.length === 2) gl.uniform2fv(l, v); else if (v.length === 3) gl.uniform3fv(l, v); else if (v.length === 4) gl.uniform4fv(l, v); else gl.uniform3fv(l, v);\n      }\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n      if (out && out.mip) { gl.bindTexture(gl.TEXTURE_2D, out.t); gl.generateMipmap(gl.TEXTURE_2D); }\n    };\n    const setup = () => {\n      S.hf = !!gl.getExtension('EXT_color_buffer_float') || !!gl.getExtension('EXT_color_buffer_half_float');\n      gl.getExtension('OES_texture_float_linear');\n      gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND);\n      spec.init(S); S.sized = false; readVars(); resize();\n    };\n    const resize = () => {\n      const r = root.getBoundingClientRect(); const scale = (spec.scale ? spec.scale(S) : 1);\n      S.cssW = Math.max(1, r.width); S.cssH = Math.max(1, r.height);\n      S.dpr = Math.min(window.devicePixelRatio || 1, 2) * scale;\n      const w = Math.max(1, Math.round(S.cssW * S.dpr)), h = Math.max(1, Math.round(S.cssH * S.dpr));\n      if (w === S.w && h === S.h && S.sized) return;\n      S.w = canvas.width = w; S.h = canvas.height = h; S.aspect = w / h; S.sized = true;\n      spec.resize && spec.resize(S);\n      if (S.reduced) still();\n    };\n    /* pointer: a spring toward the finger/mouse, in root-normalised coords (0..1, y up) */\n    const P_ = S.ptr;\n    const at = (cx, cy) => { const r = root.getBoundingClientRect(); P_.tx = (cx - r.left) / Math.max(1, r.width); P_.ty = 1 - (cy - r.top) / Math.max(1, r.height); P_.inside = P_.tx >= 0 && P_.tx <= 1 && P_.ty >= 0 && P_.ty <= 1; if (!P_.seen) { P_.x = P_.px = P_.tx; P_.y = P_.py = P_.ty; P_.seen = true; } P_.active = 1; P_.last = performance.now(); };\n    addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') at(e.clientX, e.clientY); }, { passive: true });\n    addEventListener('pointerdown', (e) => { at(e.clientX, e.clientY); if (P_.inside) { P_.down = true; spec.press && spec.press(S, true); } }, { passive: true });\n    addEventListener('pointerup', () => { if (P_.down) { P_.down = false; spec.press && spec.press(S, false); } }, { passive: true });\n    addEventListener('pointercancel', () => { if (P_.down) { P_.down = false; spec.press && spec.press(S, false); } }, { passive: true });\n    addEventListener('touchstart', (e) => { const t = e.touches[0]; if (t) { P_.seen = false; at(t.clientX, t.clientY); } }, { passive: true });\n    addEventListener('touchmove', (e) => { const t = e.touches[0]; if (t) at(t.clientX, t.clientY); }, { passive: true });\n    const stepPtr = (dt) => {\n      P_.px = P_.x; P_.py = P_.y;\n      const k = spec.ptrSpring || [170, 26]; const n = Math.ceil(dt / (1 / 120));\n      for (let i = 0; i < n; i++) { const h = dt / n; P_.vx += ((P_.tx - P_.x) * k[0] - P_.vx * k[1]) * h; P_.vy += ((P_.ty - P_.y) * k[0] - P_.vy * k[1]) * h; P_.x += P_.vx * h; P_.y += P_.vy * h; }\n      P_.active = Math.max(0, P_.active - dt * .6);\n    };\n    /* loop: only while on screen, tab visible and context alive */\n    let last = 0;\n    const loop = (now) => {\n      S.raf = 0; if (!canRun()) return;\n      const dt = last ? Math.min(.05, (now - last) / 1000) : 1 / 60; last = now;\n      stepPtr(dt); S.t += dt * S.speed; S.rt += dt; S.frame++;\n      spec.frame(S, dt);\n      schedule();\n    };\n    const canRun = () => S.visible && !document.hidden && !S.lost && !S.reduced;\n    const schedule = () => { if (!S.raf && canRun()) { S.raf = requestAnimationFrame(loop); } else if (!canRun()) last = 0; };\n    const still = () => { if (S.lost) return; (spec.still || ((s) => spec.frame(s, 1 / 60)))(S); };\n    new ResizeObserver(resize).observe(root);\n    new IntersectionObserver((es) => { S.visible = es[es.length - 1].isIntersecting; schedule(); }, { rootMargin: '64px' }).observe(root);\n    document.addEventListener('visibilitychange', schedule);\n    reduce.addEventListener('change', () => { S.reduced = reduce.matches; S.reduced ? still() : schedule(); });\n    new MutationObserver(() => { readVars(); if (S.reduced) still(); }).observe(root, { attributes: true, attributeFilter: ['style', 'class'] });\n    root.addEventListener(P + ':refresh', () => { readVars(); if (S.reduced) still(); });\n    canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); S.lost = true; if (S.raf) cancelAnimationFrame(S.raf); S.raf = 0; });\n    canvas.addEventListener('webglcontextrestored', () => { S.lost = false; setup(); schedule(); });\n    S.api = { press: (on) => spec.press && spec.press(S, on) };\n    setup();\n    if (S.reduced) still();\n    root.classList.add(P + '-ready');\n    return S;\n  };\n\n  const WAVES = `\nuniform float uTime, uAmp; uniform vec4 uRip[8];\nvec2 h22(vec2 p){ vec3 a = fract(p.xyx * vec3(.1031, .103, .0973)); a += dot(a, a.yzx + 33.33); return fract((a.xx + a.yz) * a.zy); }\nfloat gn(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3. - 2. * f);\n  return mix(mix(dot(h22(i) * 2. - 1., f), dot(h22(i + vec2(1, 0)) * 2. - 1., f - vec2(1, 0)), u.x), mix(dot(h22(i + vec2(0, 1)) * 2. - 1., f - vec2(0, 1)), dot(h22(i + 1.) * 2. - 1., f - 1.), u.x), u.y); }\n// the water surface: a slow swell, wind chop that drifts, and ripple rings from the pointer\nfloat H(vec2 p){\n  float t = uTime;\n  float h = .42 * sin(dot(p, vec2(.8, .6)) * 3.1 + t * .9) + .3 * sin(dot(p, vec2(-.62, .78)) * 4.3 + t * 1.15);\n  vec2 q = p * 3.3 + vec2(t * .22, -t * .15);\n  h += .55 * gn(q) + .2 * gn(q * 1.9 + vec2(-t * .31, t * .27) + 7.) + .05 * gn(q * 3.7 + vec2(t * .4, t * .1) + 3.);\n  for (int i = 0; i < 8; i++){\n    vec4 r = uRip[i]; float age = t - r.z; if (r.w <= 0. || age < 0. || age > 6.) continue;\n    float d = length(p - r.xy); float front = age * .55;\n    h += r.w * sin(d * 26. - age * 15.) * exp(-pow((d - front) * 7., 2.)) * exp(-age * .7) / (1. + d * 3.);\n  }\n  return h * uAmp;\n}\nvec2 dH(vec2 p){ float e = .004; return vec2(H(p + vec2(e, 0.)) - H(p - vec2(e, 0.)), H(p + vec2(0., e)) - H(p - vec2(0., e))) / (2. * e); }`;\n  /* each vertex is a ray: refract it through the surface above it and land it on the floor. Its brightness is how\n     much the landing squeezes the area around it (1 / det of the landing map's Jacobian), computed per vertex so it\n     interpolates smoothly across the triangles: sharp filaments without the beading of per-triangle area. */\n  const CAUS_V = `#version 300 es\nprecision highp float; precision highp int;\nuniform vec2 uGrid, uView; uniform float uDepth;\n${WAVES}\nout float vB;\nvec2 land(vec2 p){\n  vec3 n = normalize(vec3(-dH(p) * 2.7, 1.));\n  vec3 r = refract(vec3(0., 0., -1.), n, 1. / 1.333);\n  return p + r.xy * (uDepth / max(-r.z, .2));\n}\nvoid main(){\n  int gx1 = int(uGrid.x) + 1;\n  vec2 g = vec2(float(gl_VertexID % gx1), float(gl_VertexID / gx1)) / uGrid;\n  g = g * 1.2 - .1;   // overscan so displaced edges still cover the frame\n  vec2 p = g * uView;\n  float e = uView.y * 1.2 / uGrid.y * .5;\n  vec2 q = land(p), qx = land(p + vec2(e, 0.)) - q, qy = land(p + vec2(0., e)) - q;\n  float det = (qx.x * qy.y - qx.y * qy.x) / (e * e);\n  // a soft singularity instead of a clamp: where the map folds (det -> 0) the ray bundle focuses to a bright line;\n  // softening it keeps grid vertices that land on the fold from flaring into beads\n  vB = 1. / sqrt(det * det + .012);\n  gl_Position = vec4(q / uView * 2. - 1., 0., 1.);\n}`;\n  const CAUS_F = `uniform float uGain; in float vB; out vec4 o;\nvoid main(){ o = vec4(vec3(vB * uGain), 1.); }`;\n  /* the floor seen through the water, lit by the caustics */\n  const VIEW = `${WAVES}\nuniform sampler2D uCaus; uniform vec2 uRes, uView; uniform vec3 uFloor, uGrout, uWater, uLight; uniform float uTiles, uTileN, uStrength, uFrame; out vec4 o;\nfloat h12(vec2 p){ vec3 a = fract(p.xyx * .1031); a += dot(a, a.yzx + 33.33); return fract((a.x + a.y) * a.z); }\nvec3 toSRGB(vec3 c){ c = max(c, 0.); return mix(c * 12.92, 1.055 * pow(c, vec3(1. / 2.4)) - .055, step(.0031308, c)); }\nvec3 aces(vec3 x){ return clamp((x * (2.51 * x + .03)) / (x * (2.43 * x + .59) + .14), 0., 1.); }\nvoid main(){\n  vec2 uv = gl_FragCoord.xy / uRes; vec2 p = uv * uView;\n  // the floor is seen through the moving surface: its lines wobble\n  vec2 g = dH(p); vec2 fp = p - g * .012;\n  vec3 alb = uFloor;\n  if (uTiles > .5){\n    vec2 tp = fp * uTileN / min(uView.x, uView.y); vec2 id = floor(tp), f = fract(tp);\n    float edge = min(min(f.x, 1. - f.x), min(f.y, 1. - f.y));\n    float gr = 1. - smoothstep(.022, .05, edge);\n    float tv = h12(id); alb *= .94 + .1 * tv;\n    // a pale glaze: each tile a touch lighter toward its centre\n    alb *= 1. + .05 * smoothstep(.1, .5, edge);\n    alb = mix(alb, uGrout, gr);\n  } else {\n    // sand: warm, fine grain and soft ribs\n    float rib = sin(fp.x * 18. + fp.y * 6. + gn(fp * 3.) * 4.) * .5 + .5;\n    alb *= vec3(1., .86, .64) * (.86 + .14 * rib + .08 * (h12(floor(fp * 700.)) - .5));\n  }\n  // dispersion: red and blue focus a hair apart, so the brightest lines split slightly along the slope of the water\n  vec2 dsp = (g * .5 + vec2(.6, .4)) * .002;\n  vec3 C = vec3(textureLod(uCaus, uv - dsp, .6).r, textureLod(uCaus, uv, .6).r, textureLod(uCaus, uv + dsp, .6).r);\n  vec3 soft = vec3(textureLod(uCaus, uv, 3.).r);\n  vec3 lightOnFloor = uLight * (.4 + C * 1.05 * uStrength) + uLight * soft * .12 * uStrength;\n  // water between the viewer and the floor absorbs red first\n  vec3 absorb = mix(vec3(1.), uWater, .8);\n  vec3 col = alb * lightOnFloor * absorb;\n  // the brightest filaments bloom a little\n  col += uLight * max(C - 1.1, 0.) * .12 * uStrength;\n  // sky glint off the surface slope\n  col += vec3(.9, .97, 1.) * pow(clamp(g.x * .5 + g.y * .3, 0., 1.), 3.) * .05;\n  col = aces(col * 1.15);\n  col = toSRGB(col);\n  float l = dot(col, vec3(.2126, .7152, .0722)); col = mix(vec3(l), col, 1.3);\n  vec2 v = abs(uv - .5) * 2.; col *= 1. - .2 * smoothstep(0., 1., length(max(v - .5, 0.)) / .7);\n  col += (h12(gl_FragCoord.xy + floor(uFrame) * vec2(17.3, 41.9)) - .5) * .02;\n  o = vec4(clamp(col, 0., 1.), 1.);\n}`;\n  document.querySelectorAll('.vs-caustics:not(.vs-caustics-ready)').forEach((root) => {\n    let pc, pv, caus, ib = null, nIdx = 0, gx = 1, gy = 1, rip = new Float32Array(32), ri = 0, lastRip = 0;\n    const addRip = (S, x, y, a) => { rip.set([x * S.aspect * 1.6, y * 1.6, S.t, a], (ri % 8) * 4); ri++; };\n    VS(root, {\n      prefix: 'vs-caustics', t0: 12,\n      vars: { floor: '#eef6f7', grout: '#c9d9dd', water: '#00b3c4', light: '#fff4dc', tiles: 1, 'tile-size': 14, depth: 1, strength: 1, speed: 1 },\n      init(S) { pc = S.program(CAUS_F, CAUS_V); pv = S.program(VIEW); ib = null; gx = gy = 0; },\n      resize(S) {\n        S.free(caus);\n        const cs = Math.min(.7, 1500 / Math.max(S.w, S.h)), cw = Math.max(2, Math.round(S.w * cs)), ch = Math.max(2, Math.round(S.h * cs));\n        caus = S.target(cw, ch, { mip: true });\n        const cells = S.cssW < 700 ? 190 : 300, nx = Math.round(cells * Math.max(1, S.aspect)), ny = Math.round(cells * Math.max(1, 1 / S.aspect));\n        if (nx !== gx || ny !== gy || !ib) {\n          // an index buffer over the (gx+1) x (gy+1) grid points, so each ray is traced once, not six times\n          gx = nx; gy = ny; const gl = S.gl, idx = new Uint32Array(gx * gy * 6); let k = 0;\n          for (let y = 0; y < gy; y++) for (let x = 0; x < gx; x++) { const a = y * (gx + 1) + x, b = a + 1, c = a + gx + 1, d = c + 1; idx.set([a, b, c, c, b, d], k); k += 6; }\n          if (ib) gl.deleteBuffer(ib); ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW); nIdx = k;\n        }\n      },\n      press(S, on) { if (on && S.ptr.inside) addRip(S, S.ptr.tx, S.ptr.ty, 1.8); },\n      frame(S) {\n        const gl = S.gl, P = S.ptr, view = [S.aspect * 1.6, 1.6], now = S.t;\n        const sp = Math.hypot(P.x - P.px, P.y - P.py);\n        if (P.inside && sp > .002 && now - lastRip > .09) { lastRip = now; addRip(S, P.x, P.y, Math.min(.5, sp * 18)); }\n        const d = .4 * S.css.depth, common = { uTime: now, uAmp: .1, uGrid: [gx, gy], uView: view, uGain: .33 };\n        gl.useProgram(pc.p); gl.uniform4fv(pc.u.uRip, rip);\n        gl.bindFramebuffer(gl.FRAMEBUFFER, caus.fb); gl.viewport(0, 0, caus.w, caus.h); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT);\n        gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);\n        // one dense pass of rays; the dispersion is added when the floor samples it (see VIEW)\n        for (const [n, v] of Object.entries({ ...common, uDepth: d })) { const l = pc.u[n]; if (!l) continue; typeof v === 'number' ? gl.uniform1f(l, v) : gl.uniform2fv(l, v); }\n        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.drawElements(gl.TRIANGLES, nIdx, gl.UNSIGNED_INT, 0);\n        gl.disable(gl.BLEND);\n        gl.bindTexture(gl.TEXTURE_2D, caus.t); gl.generateMipmap(gl.TEXTURE_2D);\n        gl.useProgram(pv.p); gl.uniform4fv(pv.u.uRip, rip);\n        S.draw(pv, null, { uRes: [S.w, S.h], uView: view, uTime: now, uAmp: .1, uFloor: S.css.floor, uGrout: S.css.grout, uWater: S.css.water, uLight: S.css.light, uTiles: S.css.tiles, uTileN: S.css['tile-size'], uStrength: S.css.strength, uFrame: Math.floor(S.rt * 24) }, { uCaus: caus });\n      },\n    });\n  });\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "Shader backgrounds",
    "tags": [
      "caustics",
      "water",
      "pool",
      "light",
      "refraction",
      "ripple",
      "webgl",
      "shader",
      "background",
      "aqua"
    ],
    "tech": [
      "WebGL2",
      "GLSL",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#00b3c4",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/caustics/index.html",
    "thumbnail": "https://vividsites.app/api/component/caustics/thumb.jpg",
    "video": "https://vividsites.app/api/component/caustics/thumb.mp4",
    "page": "https://vividsites.app/c/caustics"
  }
}