> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes.heygen.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Rack Focus

> A focus pull with real aperture bokeh: the focal plane travels from a near subject to a far one, so one resolves as the other blows into hard-edged polygon discs that clip to a cat's eye toward the corners. A focus pull only reads against content at two clearly separated depths, so this block carries its own depth-layered night exterior and puts both subject depths on variables.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="rack-focus preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/blocks/rack-focus.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/rack-focus.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add rack-focus" />

That writes one file: `compositions/rack-focus.html`.

## Add it to your video

It runs for 6 seconds at 1920×1080. Paste this into your composition:

```html index.html theme={null}
<div
  data-composition-id="rack-focus"
  data-composition-src="compositions/rack-focus.html"
  data-start="0"
  data-duration="6"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.

## Change how it looks

Set these CSS variables on the block:

* `--nearfocus` — Near focal distance in metres (where the pull starts and the near subject sits). Defaults to `1.2`.
* `--farfocus` — Far focal distance in metres (where the pull ends and the far subject sits). Defaults to `80`.
* `--focallength` — Focal length in mm (longer lens = shallower depth of field). Defaults to `85`.
* `--aperture` — Aperture f-number (lower = bigger bokeh). Defaults to `1.8`.
* `--blades` — Aperture blade count, the bokeh polygon's sides. Defaults to `6`.
* `--catseye` — Cat's-eye clipping at the frame corners (mechanical vignetting). Defaults to `0.62`.
* `--pullstart` — Seconds held on the near subject before the pull starts. Defaults to `1.2`.
* `--pullduration` — Pull duration in seconds. Defaults to `3.2`.
* `--pullease` — Pull easing (any GSAP ease name). Defaults to `power2.inOut`.
* `--bokeh` — Bokeh exposure. Defaults to `1`.
* `--backdrop` — Backdrop. Defaults to `#05060a`.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable       | Default        | Accepts                  | What it does |
| -------------- | -------------- | ------------------------ | ------------ |
| `nearfocus`    | `1.2`          | 0.2m to 100m, step 0.05m |              |
| `farfocus`     | `80`           | 0.3m to 400m, step 0.5m  |              |
| `focallength`  | `85`           | 12mm to 300mm, step 1mm  |              |
| `aperture`     | `1.8`          | 0.95 to 22, step 0.05    |              |
| `blades`       | `6`            | 3 to 14, step 1          |              |
| `catseye`      | `0.62`         | 0 to 1, step 0.02        |              |
| `pullstart`    | `1.2`          | 0s to 30s, step 0.05s    |              |
| `pullduration` | `3.2`          | 0.1s to 30s, step 0.05s  |              |
| `pullease`     | `power2.inOut` | string                   |              |
| `bokeh`        | `1`            | 0 to 3, step 0.05        |              |
| `backdrop`     | `#05060a`      | color                    |              |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap theme={null}
<div
  data-composition-id="rack-focus"
  data-composition-src="compositions/rack-focus.html"
  data-variable-values='{"nearfocus":1.2,"farfocus":80,"focallength":85,"aperture":1.8,"blades":6,"catseye":0.62,"pullstart":1.2,"pullduration":3.2,"pullease":"power2.inOut","bokeh":1,"backdrop":"#05060a"}'
></div>
```

## Source

<Accordion title={`rack-focus.html`}>
  ```html theme={null}
  <!doctype html>
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=1920, height=1080" />
      <title>Rack Focus</title>
      <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
      <!--
        RACK FOCUS: a focus pull with real aperture bokeh.

        WHAT THIS NEEDS TO READ AT ALL
        ------------------------------
        A focus pull is a depth effect. It only reads when the frame holds
        content at two clearly separated depths: something near the lens and
        something far behind it. Point it at flat, single-plane content and
        nothing happens, exactly like a dolly zoom on a flat card.

        This block therefore carries its own depth-layered scene, a night
        exterior built from light sources spread from 1.15 m to 90 m, so it
        works standalone. Every depth is a variable: `nearfocus` is where the
        pull starts, `farfocus` is where it ends, and the scene's two subjects
        sit at those depths. Change them and the subjects move with them.

        WHY THIS IS NOT A BLUR
        ----------------------
        A CSS/Gaussian blur softens everything uniformly. A real defocus turns
        each point of light into an image of the APERTURE, scaled by its circle
        of confusion, so a bright point becomes a hard-edged polygon disc whose
        size grows with distance from the focal plane, and which clips to a
        cat's-eye toward the frame corners. Every light in this scene is
        splatted as an aperture-shaped sprite at its own circle of confusion.

        CIRCLE OF CONFUSION, from three.js BokehShader2 (MIT), Martins Upitis
        --------------------------------------------------------------------
        Read from examples/jsm/shaders/BokehShader2.js:

            float CoC = 0.03;              // circle of confusion in mm
                                           // (35mm film = 0.03mm)
            float f = focalLength;         // mm
            float d = fDepth * 1000.0;     // focal plane in mm
            float o = depth  * 1000.0;     // object depth in mm
            float a = (o * f) / (o - f);
            float b = (d * f) / (d - f);
            float c = (d - f) / (d * fstop * CoC);
            blur = abs(a - b) * c;

        `blur` comes out in units of that 0.03 mm acceptable-sharpness circle,
        so the defocus DIAMETER on the sensor is `blur * 0.03` mm, which this
        block converts to pixels with the sensor width. Same constants, same
        formula, independently written, no code copied. This agrees with the
        Zeiss thin-lens form CoC = (f²/N)·|1/S - 1/U| for S >> f.

        Aperture shape is the community-standard regular-polygon boundary
        d(θ) = cos(π/n) / cos(mod(θ, 2π/n) - π/n), n = blade count (real
        irises ship 5, 6, 8 or 9 blades). Cat's-eye clipping is the aperture
        intersected with two barrel openings offset along the radial direction,
        which is the actual mechanism of mechanical vignetting.

        DETERMINISM
        -----------
        State at frame N is computed from N. The focal distance is a closed-form
        function of t, every circle of confusion follows from a static depth and
        that focal distance, and the scene point cloud is built once from a
        seeded PRNG. No accumulation, no clocks, no unseeded randomness.
      -->
      <style>
        *,
        *::before,
        *::after {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
        }
        body {
          background: #000;
          overflow: hidden;
        }
        #rf-root {
          position: relative;
          width: 1920px;
          height: 1080px;
          overflow: hidden;
        }
        #rf-backdrop {
          position: absolute;
          inset: 0;
          background: #05060a;
        }
        #rf-canvas {
          position: absolute;
          top: 0;
          left: 0;
          width: 1920px;
          height: 1080px;
        }
      </style>
    </head>
    <body>
      <div
        id="rf-root"
        data-composition-id="rack-focus"
        data-root="true"
        data-width="1920"
        data-height="1080"
        data-start="0"
        data-duration="6"
        data-composition-variables='[
          {"id":"nearfocus","type":"number","label":"Near focal distance","default":1.2,"min":0.2,"max":100,"step":0.05,"unit":"m"},
          {"id":"farfocus","type":"number","label":"Far focal distance","default":80,"min":0.3,"max":400,"step":0.5,"unit":"m"},
          {"id":"focallength","type":"number","label":"Focal length","default":85,"min":12,"max":300,"step":1,"unit":"mm"},
          {"id":"aperture","type":"number","label":"Aperture (f-number)","default":1.8,"min":0.95,"max":22,"step":0.05},
          {"id":"blades","type":"number","label":"Aperture blades (bokeh shape)","default":6,"min":3,"max":14,"step":1},
          {"id":"catseye","type":"number","label":"Cat eye clipping at the corners","default":0.62,"min":0,"max":1,"step":0.02},
          {"id":"pullstart","type":"number","label":"Pull start","default":1.2,"min":0,"max":30,"step":0.05,"unit":"s"},
          {"id":"pullduration","type":"number","label":"Pull duration","default":3.2,"min":0.1,"max":30,"step":0.05,"unit":"s"},
          {"id":"pullease","type":"string","label":"Pull easing (GSAP ease)","default":"power2.inOut","placeholder":"power2.inOut"},
          {"id":"bokeh","type":"number","label":"Bokeh exposure","default":1,"min":0,"max":3,"step":0.05},
          {"id":"backdrop","type":"color","label":"Backdrop","default":"#05060a"}
        ]'
      >
        <div id="rf-backdrop"></div>
        <canvas id="rf-canvas" width="1920" height="1080"></canvas>

        <!-- Driver clip: gives HyperFrames a timed element to own on track 0. -->
        <div
          id="rf-drv"
          class="clip"
          data-start="0"
          data-duration="6"
          data-track-index="0"
          style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
        ></div>
      </div>

      <script>
        (function () {
          var DUR = 6;
          var W = 1920;
          var H = 1080;

          // 36mm-wide sensor, 16:9 active area. Pixels per millimetre is the
          // only thing the projection needs, and it is the same on both axes.
          var SENSOR_W_MM = 36;
          var PX_PER_MM = W / SENSOR_W_MM;

          // BokehShader2's acceptable-sharpness circle, in mm (35mm film).
          var COC_MM = 0.03;

          // Defocus is clamped so a wildly out-of-range focus setting cannot
          // splat sprites the size of the frame. BokehShader2 clamps the same
          // quantity with its `maxblur` uniform.
          var MAX_COC_R_PX = 0.1 * H;
          // Smallest sprite half-width. Below roughly one pixel a splat is an
          // aliasing machine, so points in focus bottom out here.
          var MIN_R_PX = 0.75;

          var V = (window.__hyperframes && window.__hyperframes.getVariables()) || {};
          var CS = getComputedStyle(document.getElementById("rf-root"));

          // The runtime defines every declared variable as `--<slug>` on the
          // root (packages/core/src/tokenSlug.ts), so a host stylesheet can
          // override one there too. Read the custom property first, fall back
          // to the declared value when it is unset.
          function raw(id) {
            var css = CS.getPropertyValue("--" + id.toLowerCase()).trim();
            return css !== "" ? css : V[id];
          }
          function num(id, fallback) {
            var n = parseFloat(raw(id));
            return isFinite(n) ? n : fallback;
          }

          var NEAR = num("nearfocus", 1.2);
          var FAR = num("farfocus", 80);
          var FOCAL = num("focallength", 85);
          var FSTOP = num("aperture", 1.8);
          var BLADES = Math.max(3, Math.round(num("blades", 6)));
          var CATSEYE = num("catseye", 0.62);
          var PULL_START = num("pullstart", 1.2);
          var PULL_DUR = Math.max(0.001, num("pullduration", 3.2));
          var BOKEH = num("bokeh", 1);
          var EASE_NAME = String(raw("pullease") || "power2.inOut");
          var BACKDROP =
            typeof raw("backdrop") === "string" && raw("backdrop") ? raw("backdrop") : "#05060a";

          document.getElementById("rf-backdrop").style.background = BACKDROP;

          var EASE = gsap.parseEase(EASE_NAME) || gsap.parseEase("power2.inOut");

          // A focus ring is roughly linear in dioptres, not in metres: a rack
          // from 1.2m to 80m spends its first millimetre of barrel rotation
          // crossing most of the distance. Interpolating 1/distance is what
          // makes the pull travel evenly instead of snapping to the far plane.
          function focusAt(t) {
            var u = Math.min(1, Math.max(0, (t - PULL_START) / PULL_DUR));
            var e = EASE(u);
            var inv = 1 / NEAR + (1 / FAR - 1 / NEAR) * e;
            return 1 / inv;
          }

          // ── Scene ────────────────────────────────────────────────────────
          // A night exterior: a string of practical lights right in front of
          // the lens, a lit city block far behind it, and scattered lights
          // through every depth between so the pull reads as a continuous
          // travel rather than a cut between two planes.

          function mulberry32(a) {
            return function () {
              a |= 0;
              a = (a + 0x6d2b79f5) | 0;
              var t = Math.imul(a ^ (a >>> 15), 1 | a);
              t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
              return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
            };
          }
          var rnd = mulberry32(0x5eed1a3);
          function rr(lo, hi) {
            return lo + (hi - lo) * rnd();
          }

          // x, y in metres (y up, origin on the optical axis), z in metres,
          // r0 = the light's own physical radius in metres, b = peak
          // brightness when perfectly in focus (values above 1 are highlights
          // that clip, which is exactly why they stay visible once spread
          // across a bokeh disc), rgb = colour.
          var P = [];
          function light(x, y, z, r0, b, c) {
            P.push(x, y, z, r0, b, c[0], c[1], c[2]);
          }

          var TUNGSTEN = [1.0, 0.74, 0.45];
          var FILAMENT = [1.0, 0.9, 0.74];
          var WIRE = [0.86, 0.74, 0.6];
          var WARM_WIN = [1.0, 0.79, 0.52];
          var COOL_WIN = [0.6, 0.75, 1.0];
          var SIGN_A = [0.35, 0.95, 1.0];
          var SIGN_B = [1.0, 0.42, 0.72];

          // Half the frame's width, in metres, at depth z. Both subjects are
          // laid out against the reference framing (85mm, 1.2m / 80m) and then
          // scaled by this, so retuning the lens or either focal distance moves
          // the subjects with the frame instead of pushing them out of it.
          function halfW(z) {
            return (0.5 * SENSOR_W_MM * z) / FOCAL;
          }

          // Near subject: a catenary string of bulbs at `nearfocus`. The wire is
          // what makes "sharp" unmistakable, a one-pixel line either resolves
          // or it does not, and the filament inside each bulb is the second cue.
          var NEAR_Z = NEAR;
          var NS = halfW(NEAR_Z) / 0.25412;
          var X_END = 0.4;
          var SAG_A = 0.3;
          var SAG = 0.1;
          var COSH_END = Math.cosh(X_END / SAG_A);
          function stringY(x) {
            var s = (COSH_END - Math.cosh(x / SAG_A)) / (COSH_END - 1);
            return 0.075 - SAG * s - 0.035 * (x / X_END);
          }
          function stringZ(x) {
            return NEAR_Z + 0.05 * (x / X_END);
          }
          for (var i = 0; i < 1100; i++) {
            var wx = -0.42 + (0.84 * i) / 1099;
            light(wx * NS, stringY(wx) * NS, stringZ(wx), 0.0006 * NS, 1.4, WIRE);
          }
          for (var k = -5; k <= 5; k++) {
            var bx = k * 0.085;
            var by = stringY(bx) - 0.011;
            var bz = stringZ(bx);
            light(bx * NS, by * NS, bz, 0.006 * NS, 26, TUNGSTEN);
            light((bx - 0.0022) * NS, by * NS, bz, 0.0006 * NS, 7, FILAMENT);
            light(bx * NS, (by - 0.0022) * NS, bz, 0.0006 * NS, 7, FILAMENT);
            light((bx + 0.0022) * NS, by * NS, bz, 0.0006 * NS, 7, FILAMENT);
          }

          // Far subject: three lit towers plus a dense LED sign strip, sitting
          // around `farfocus`. The sign's pitch is fine enough that it only
          // resolves into separate lamps when focus actually arrives.
          var FAR_Z = FAR;
          var FS = halfW(FAR_Z) / 16.941;
          function tower(cx, hw, topY, botY, cols, rows, z, lit) {
            for (var c = 0; c < cols; c++) {
              for (var r = 0; r < rows; r++) {
                if (rnd() > lit) continue;
                var x = cx - hw + (2 * hw * (c + 0.5)) / cols;
                var y = botY + ((topY - botY) * (r + 0.5)) / rows;
                var cool = rnd() < 0.28;
                light(x, y, z + rr(-0.4, 0.4) * FS, 0.24 * FS, rr(9, 20), cool ? COOL_WIN : WARM_WIN);
              }
            }
          }
          tower(-9.5 * FS, 3.5 * FS, 9.6 * FS, -4.0 * FS, 6, 14, FAR_Z * 1.03, 0.34);
          tower(2.0 * FS, 4.5 * FS, 6.4 * FS, -4.0 * FS, 8, 12, FAR_Z * 0.98, 0.3);
          tower(12.5 * FS, 3.0 * FS, 11.0 * FS, -4.0 * FS, 5, 15, FAR_Z * 1.08, 0.36);
          for (var s = 0; s < 40; s++) {
            var sx = (-5.5 + (11 * s) / 39) * FS;
            var mixc = s / 39;
            var sc = [
              SIGN_A[0] + (SIGN_B[0] - SIGN_A[0]) * mixc,
              SIGN_A[1] + (SIGN_B[1] - SIGN_A[1]) * mixc,
              SIGN_A[2] + (SIGN_B[2] - SIGN_A[2]) * mixc,
            ];
            light(sx, -5.0 * FS, FAR_Z * 0.99, 0.1 * FS, 7, sc);
            light(sx, -5.55 * FS, FAR_Z * 0.99, 0.1 * FS, 7, sc);
          }

          // Everything between. Depth is drawn log-uniform between the two
          // subjects and the screen position is uniform, so the mid-ground
          // stays evenly spread whatever the two focal distances are.
          var Z_LO = Math.min(NEAR_Z, FAR_Z) * 1.9;
          var Z_HI = Math.max(NEAR_Z, FAR_Z) * 0.85;
          for (var m = 0; m < 90; m++) {
            var z = Z_LO * Math.pow(Z_HI / Z_LO, rnd());
            var halfWm = (0.5 * SENSOR_W_MM * z) / FOCAL;
            var halfHm = (halfWm * H) / W;
            var warm = rnd() < 0.66;
            light(
              rr(-1.05, 1.05) * halfWm,
              rr(-1.0, 0.75) * halfHm,
              z,
              rr(0.006, 0.05) * (z / 12),
              rr(5, 18),
              warm ? WARM_WIN : COOL_WIN,
            );
          }

          // ── GL ───────────────────────────────────────────────────────────
          var canvas = document.getElementById("rf-canvas");
          var gl =
            canvas.getContext("webgl", {
              alpha: true,
              antialias: false,
              depth: false,
              stencil: false,
              preserveDrawingBuffer: true,
              powerPreference: "high-performance",
            }) ||
            canvas.getContext("experimental-webgl", {
              alpha: true,
              preserveDrawingBuffer: true,
            });

          var VERT = [
            "precision highp float;",
            "attribute vec2 aCorner;", // -1..1 quad corner
            "attribute vec3 aPos;", // metres, y up, z away from the lens
            "attribute vec2 aSize;", // x = own radius (m), y = in-focus peak
            "attribute vec3 aColor;",
            "uniform vec2 uRes;",
            "uniform float uPxPerMm;",
            "uniform float uFocal;", // mm
            "uniform float uFocus;", // metres
            "uniform float uFstop;",
            "uniform float uCoCmm;",
            "uniform float uMaxR;",
            "uniform float uMinR;",
            "uniform float uCatsEye;",
            "uniform float uBokeh;",
            "varying vec2 vQ;",
            "varying vec3 vColor;",
            "varying float vGain;",
            "varying float vShape;",
            "varying float vAA;",
            "varying vec2 vRadial;",
            "varying float vCat;",
            "void main() {",
            "  float z = max(aPos.z, 0.001);",
            "  float ppm = uPxPerMm * uFocal / z;", // pixels per metre at this depth
            "  vec2 centre = uRes * 0.5 + aPos.xy * ppm;",
            "  float r0 = max(aSize.x * ppm, uMinR);",
            "",
            "  // BokehShader2's circle of confusion, in units of uCoCmm.",
            "  float f = uFocal;",
            "  float d = uFocus * 1000.0;",
            "  float o = z * 1000.0;",
            "  float a = (o * f) / max(o - f, 1e-4);",
            "  float b = (d * f) / max(d - f, 1e-4);",
            "  float c = (d - f) / max(d * uFstop * uCoCmm, 1e-6);",
            "  float blur = abs(a - b) * c;",
            "  // -> defocus diameter in mm -> pixels -> radius.",
            "  float cocR = min(blur * uCoCmm * uPxPerMm * 0.5, uMaxR);",
            "",
            "  // A finite source convolved with the defocus disc: radii add in",
            "  // quadrature. Flux is conserved, so peak brightness falls as the",
            "  // inverse square of the radius. That single term is why an",
            "  // in-focus lamp clips to white and a defocused one is a readable",
            "  // disc instead of a smear.",
            "  float R = sqrt(r0 * r0 + cocR * cocR);",
            "  vGain = aSize.y * uBokeh * (r0 * r0) / (R * R);",
            "  // Near focus the sprite is the lamp (round); far from it the",
            "  // sprite is an image of the aperture (polygonal).",
            "  vShape = (cocR * cocR) / (cocR * cocR + r0 * r0);",
            "  vAA = 1.0 / max(R, 0.5);",
            "  vColor = aColor;",
            "  vQ = aCorner;",
            "",
            "  // Mechanical vignetting: the barrel openings clip the aperture",
            "  // harder the further the sprite sits from the optical axis.",
            "  vec2 off = centre - uRes * 0.5;",
            "  float rad = length(off);",
            "  vRadial = rad > 1.0 ? off / rad : vec2(1.0, 0.0);",
            "  vCat = uCatsEye * min(1.0, rad / (length(uRes) * 0.5));",
            "",
            "  // A light spread thin enough to land under half a display code",
            "  // value contributes nothing but fill rate. Culling it here is what",
            "  // keeps a 900-point wire from splatting 900 invisible discs the",
            "  // moment it goes out of focus.",
            "  if (vGain < 0.0015) {",
            "    gl_Position = vec4(2.0, 2.0, 2.0, 1.0);",
            "    return;",
            "  }",
            "",
            "  vec2 p = centre + aCorner * R;",
            "  gl_Position = vec4((p / uRes) * 2.0 - 1.0, 0.0, 1.0);",
            "}",
          ].join("\n");

          var FRAG = [
            "precision highp float;",
            "varying vec2 vQ;",
            "varying vec3 vColor;",
            "varying float vGain;",
            "varying float vShape;",
            "varying float vAA;",
            "varying vec2 vRadial;",
            "varying float vCat;",
            "uniform float uBlades;",
            "const float PI = 3.14159265;",
            "void main() {",
            "  float r = length(vQ);",
            "  if (r > 1.0) discard;",
            "  float th = r > 1e-5 ? atan(vQ.y, vQ.x) : 0.0;",
            "",
            "  // Regular-polygon aperture boundary: circumradius 1 at a blade",
            "  // vertex, cos(PI/n) at a blade midpoint.",
            "  float n = uBlades;",
            "  float seg = 2.0 * PI / n;",
            "  float poly = cos(PI / n) / cos(mod(th, seg) - PI / n);",
            "  float bound = mix(1.0, poly, vShape);",
            "",
            "  float cov = smoothstep(bound, bound - vAA, r);",
            "  // Two offset barrel openings cut the disc from opposite sides,",
            "  // which is what turns a corner bokeh into a cat's eye.",
            "  float cut = vCat * vShape;",
            "  cov *= smoothstep(1.0, 1.0 - vAA, length(vQ - vRadial * cut));",
            "  cov *= smoothstep(1.0, 1.0 - vAA, length(vQ + vRadial * cut));",
            "",
            "  gl_FragColor = vec4(vColor * (vGain * cov), 1.0);",
            "}",
          ].join("\n");

          var uni = {};
          var ready = false;
          var count = 0;

          function compile(type, src) {
            var sh = gl.createShader(type);
            gl.shaderSource(sh, src);
            gl.compileShader(sh);
            if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
              throw new Error("rack-focus shader: " + gl.getShaderInfoLog(sh));
            }
            return sh;
          }

          function hexToRgb(hex) {
            var h = String(hex).trim().replace("#", "");
            if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
            var v = parseInt(h, 16);
            if (!isFinite(v)) return [0.02, 0.024, 0.039];
            return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];
          }
          var BG = hexToRgb(BACKDROP);

          if (gl) {
            var prog = gl.createProgram();
            gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
            gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
            gl.linkProgram(prog);
            if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
              throw new Error("rack-focus link: " + gl.getProgramInfoLog(prog));
            }
            gl.useProgram(prog);

            // Six vertices per light: two triangles carrying the same point
            // payload and four distinct corner offsets.
            var CORNERS = [
              [-1, -1],
              [1, -1],
              [1, 1],
              [-1, -1],
              [1, 1],
              [-1, 1],
            ];
            var n = P.length / 8;
            count = n * 6;
            var STRIDE = 10;
            var data = new Float32Array(count * STRIDE);
            var w = 0;
            for (var pi = 0; pi < n; pi++) {
              var o = pi * 8;
              for (var ci = 0; ci < 6; ci++) {
                data[w++] = CORNERS[ci][0];
                data[w++] = CORNERS[ci][1];
                data[w++] = P[o];
                data[w++] = P[o + 1];
                data[w++] = P[o + 2];
                data[w++] = P[o + 3];
                data[w++] = P[o + 4];
                data[w++] = P[o + 5];
                data[w++] = P[o + 6];
                data[w++] = P[o + 7];
              }
            }

            var buf = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, buf);
            gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
            var BYTES = STRIDE * 4;
            [
              ["aCorner", 2, 0],
              ["aPos", 3, 8],
              ["aSize", 2, 20],
              ["aColor", 3, 28],
            ].forEach(function (spec) {
              var loc = gl.getAttribLocation(prog, spec[0]);
              gl.enableVertexAttribArray(loc);
              gl.vertexAttribPointer(loc, spec[1], gl.FLOAT, false, BYTES, spec[2]);
            });

            [
              "uRes",
              "uPxPerMm",
              "uFocal",
              "uFocus",
              "uFstop",
              "uCoCmm",
              "uMaxR",
              "uMinR",
              "uCatsEye",
              "uBlades",
              "uBokeh",
            ].forEach(function (nm) {
              uni[nm] = gl.getUniformLocation(prog, nm);
            });

            gl.viewport(0, 0, W, H);
            gl.uniform2f(uni.uRes, W, H);
            gl.uniform1f(uni.uPxPerMm, PX_PER_MM);
            gl.uniform1f(uni.uFocal, FOCAL);
            gl.uniform1f(uni.uFstop, FSTOP);
            gl.uniform1f(uni.uCoCmm, COC_MM);
            gl.uniform1f(uni.uMaxR, MAX_COC_R_PX);
            gl.uniform1f(uni.uMinR, MIN_R_PX);
            gl.uniform1f(uni.uCatsEye, CATSEYE);
            gl.uniform1f(uni.uBlades, BLADES);
            gl.uniform1f(uni.uBokeh, BOKEH);

            // Light adds to light. Overlapping bokeh discs are brighter where
            // they cross, which is the whole texture of a bokeh field.
            gl.disable(gl.DEPTH_TEST);
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.ONE, gl.ONE);
            ready = true;
          }

          // Every frame is computed from t alone: the focal distance is a
          // closed-form function of t, and each sprite's circle of confusion
          // falls out of its own static depth and that distance.
          function draw(t) {
            if (!ready) return;
            gl.uniform1f(uni.uFocus, focusAt(t));
            gl.clearColor(BG[0], BG[1], BG[2], 1);
            gl.clear(gl.COLOR_BUFFER_BIT);
            gl.drawArrays(gl.TRIANGLES, 0, count);
            gl.flush();
          }

          window.__timelines = window.__timelines || {};
          var tl = gsap.timeline({ paused: true });

          // The canvas is repainted from a property SETTER, not from onUpdate:
          // gsap's seek(t) suppresses events by default, so an onUpdate callback
          // silently never fires on a scrub and the canvas freezes on frame 0.
          // Tweened values are always written during render, suppressed or not,
          // so this fires on every seek, and hands us the frame time directly.
          var driver = { _t: 0 };
          Object.defineProperty(driver, "t", {
            get: function () {
              return this._t;
            },
            set: function (v) {
              this._t = v;
              draw(v);
            },
          });
          tl.to(driver, { t: DUR, duration: DUR, ease: "none" }, 0);
          window.__timelines["rack-focus"] = tl;

          draw(0);
        })();
      </script>
    </body>
  </html>
  ```
</Accordion>

Tagged `webgl` `shader` `camera` `depth` `cinematic` `showcase`.

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Focus Rack](/catalog/components/focus-rack.md)
- [Focus Swap](/catalog/components/focus-swap.md)
- [Focus Blur Resolve](/catalog/components/focus-blur-resolve.md)
