Install
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:index.html
<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>
data-start. Put it on a different timeline row with
data-track-index. See 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 to1.2.--farfocus— Far focal distance in metres (where the pull ends and the far subject sits). Defaults to80.--focallength— Focal length in mm (longer lens = shallower depth of field). Defaults to85.--aperture— Aperture f-number (lower = bigger bokeh). Defaults to1.8.--blades— Aperture blade count, the bokeh polygon’s sides. Defaults to6.--catseye— Cat’s-eye clipping at the frame corners (mechanical vignetting). Defaults to0.62.--pullstart— Seconds held on the near subject before the pull starts. Defaults to1.2.--pullduration— Pull duration in seconds. Defaults to3.2.--pullease— Pull easing (any GSAP ease name). Defaults topower2.inOut.--bokeh— Bokeh exposure. Defaults to1.--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 |
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:
<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
rack-focus.html
rack-focus.html
<!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>
webgl shader camera depth cinematic showcase.