Lens and effects

The lens films a flat page like a real camera, with depth of field, bloom, grain and colour fringes made in image space.

The lensThe lens is the camera in core/surfaces/lens.js that films a flat page like a real object, with depth of field, bloom, grain and more. is one WebGL2 camera in core/surfaces/lens.js. Your page draws a flat screen. The lens films that screen like a camera pointed at a real object: it tilts it, blurs it by depth, adds bloom, grain and colour fringes, and writes the result to a canvas. A lookA look is a named set of lens settings, such as film-halation or crt, passed as lens(source, { look }). is a named set of lens settings.

Why it exists

A CSS filter: blur() or a radial-gradient glow looks like a drawing of an effect. A real lens spreads light: bright points bloom, the far side of a tilted screen goes soft, red and blue move apart at the frame edge. The rule real-optics says that every optical effect in a film is a lens effect, never a CSS fake. The lens works in linear light, so its effects match what a camera does.

The lens is also deterministicDeterminism means that the same page gives the same frames on every render, in any order.. All its noise is hashed from seed and floor(t * fps). A frame is therefore a pure function of t, and two renders match byte for byte.

How it works

The source is plain HTML or a canvas. Labels in HTML above the canvas stay sharp.

The steps of one frame:

  1. The renderer seeks the page and calls your vawe.onFrame hook with the time t.
  2. The hook calls cam.draw(t). The lens reads each option at t and uploads the source as a texture. Then it draws the main pass: perspective, depth of field, palette, grid, aberration and tear.
  3. The lens runs the blur chains for bloom, halation, streaks, diffusion and soft focus, only for effects with a strength above 0.
  4. The final pass adds grain, vignette, exposure, lift and fade, and writes the canvas.

The hook returns the promise of draw, and the seek waits for it. See Time and seek.

Turn the lens on

A minimal page. The element #screen is plain HTML and CSS at the size of the frame.

<div id="screen"> ... </div>
<script type="module">
import '../../core/engine/page-api.js';
import { lens, htmlSource } from '../../core/surfaces/lens.js';

const cam = lens(htmlSource(document.getElementById('screen'), { scale: 2 }), { look: 'film-halation' });
vawe.onFrame((t) => cam.draw(t));
</script>
  • htmlSource(element, { scale }) turns an element into a source. It inlines the computed styles, fonts, images and url() backgrounds into an SVG image and uploads it on every seek. scale is texture pixels per source pixel. Use 2 for small text. Scripts, iframes, video and counters do not carry over.
  • canvasSource(canvas, { scale, depth }) takes a 2D canvas or a three.js canvas. depth is an optional depth map for the depth of field.
  • lens(source, options) needs WebGL2. It throws lens: WebGL2 is not available without it.

Each option is a number, an array or a function of the seek time. A function animates the option:

const cam = lens(src, {
  look: 'anamorphic',
  zoom: (t) => 1 + 0.07 * Math.min(1, t / 3),
  aberration: { amount: (t) => 3 + 5 * Math.min(1, t / 2) },
});

What is on by default

lens(source) with no look starts from the “filmed screen” defaults: bloom, LED grid, colour fringes, depth of field, grain and vignette are all on. With look: 'name' the lens starts from a flat lens, applies the look, then your own options. The look names every effect it uses and nothing else is on. OFF is the flat lens: import it and spread it to turn on one effect at a time.

import { lens, htmlSource, OFF } from '../../core/surfaces/lens.js';
lens(src, { ...OFF, bloom: { strength: 0.9, radius: 42 } });

The looks

LOOKS in core/surfaces/lens.js has ten looks.

lookwhat it does
cleanA flat lens. Nothing is on.
dream-bloomStrong, wide bloom and a little soft focus.
film-halationA warm halo where a bright edge meets dark, light bloom and grain.
anamorphicBlue horizontal streaks from bright points, light bloom and fringe.
pro-mistA low, wide diffusion glow that lifts the blacks.
tilt-shiftA sharp band with blur above and below it.
soft-focusThe image mixed with a blurred copy.
vintageA warm palette, halation, heavy grain, gate weave and a strong vignette.
cyanotypeA blue-grey palette with a cold halo and grain.
crtA phosphor grid, tear, fringe, bloom and vignette.

To compare looks before you choose, run frames with --looks. It writes one labelled grid for each second you name, and it does not edit the page.

bin/vawe frames films/<name>/page.html --looks --at 4.5,9 --only anamorphic,crt

An unknown look name throws an error that lists the valid names.

The effects

Each effect is one option. A value of 0 turns the effect off.

optionwhat it doesmain fields
perspectiveTilts and zooms the plane. The far side is smaller and softer by geometry.tiltX, tiltY, roll, zoom (up to about 3), aim, focal
dofDepth of field. Blur grows with the distance to the focus. The bokeh has the shape of the aperture.blur, focus, max, bokeh, blades, rotation, roundness, catEye, soft
paletteMaps brightness through 2 to 8 colour stops.stops, amount, keepColor
bloomA soft-knee threshold, then a six-level blur chain: a tight core and a wide tail.strength, radius, threshold, knee
halationThe warm halo of film at a bright edge. Off by default. Use 0.5 to 1.2.strength, radius, threshold, tint
gridAn LED or panel mask: one soft dot for each subpixel.amount, cell, layout (stripe, triad, dot), softness
aberrationRed and blue move apart toward the frame edge.amount (px at the edge)
tearRows slide sideways, seeded.amount, prob, band
streaksAnamorphic streaks. Use 0.3 to 1.strength, length, threshold, tint
diffusionA wide low-threshold glow. Use 0.2 to 0.5.strength, radius
softFocusThe image mixed with a blurred copy. Use amount 0.2 to 0.6.amount, radius
tiltShiftA sharp band with blur above and below it.center, width, blur
distortionA radial remap: above 0 is barrel, below 0 is pincushion. Use -0.12 to 0.12.amount
weaveGate weave: the whole image moves by up to amount px, with a new seeded offset each frame.amount
grain, vignette, exposure, lift, fadeThe last pass.one number each

The names seed and fps set the noise. The same seed and time give the same grain.

Blur

The lens has three blurs, and each has a job:

  • dof blurs by depth. The far side of a tilted screen and anything away from focus goes soft. Pass a function as focus to follow a target.
  • softFocus keeps the detail and softens it, like a filter on a lens.
  • tiltShift blurs by screen row. A source with its own depth keeps that depth and gets the band blur as a floor.

Blur from fast movement is a different thing. See Motion blur.

Palettes

PALETTES has seven sets of stops: thermal, phosphor, amber, navy, paper, mono and cyanotype. Set keepColor to a value up to 1 to leave saturated marks, such as a red lock box, in their own colour.

lens(src, { palette: { stops: PALETTES.thermal, keepColor: 0.9 } });

Camera views and paths

core/surfaces/lens-views.js holds named poses for filming a screen as a product shot. A view is a pose of the plane in degrees. A camera path in MOVES is a start pose and an end pose, drawn from seeded ranges.

  • VIEWS: flat, side-angle, isometric, top-diagonal and reverse-isometric.
  • MOVES: ten camera paths, from focus-push and side-drift to isometric-reveal.
  • moveBetween(name, seed, { base, scale }) returns { from, to }. The same name and seed give the same path. Rotation about x and y is clamped to 25 degrees.
  • lensPose(pose) turns a pose into the lens options tiltX, tiltY, roll and zoom.
import { easeFn } from '../../core/motion/presets.js';
import { moveBetween, lensPose } from '../../core/surfaces/lens-views.js';

const { from, to } = moveBetween('side-drift', 7, { scale: 0.6 });
const a = lensPose(from), b = lensPose(to);
const glide = easeFn('glide');
const at = (k) => (t) => a[k] + (b[k] - a[k]) * glide(Math.min(1, t / 4));
const cam = lens(src, { tiltX: at('tiltX'), tiltY: at('tiltY'), roll: at('roll'), zoom: at('zoom') });

Put a label on the screen

cam.project(x, y, t) is the same camera on the CPU. It returns { x, y, scale, depth, visible } in CSS pixels for a point of the source. Draw labels, brackets and links in HTML above the canvas (z-index above 0), and place them with project. They stay sharp and sit on real source coordinates. cam.quad(t) gives the four corners of the screen.

What dev tells you

The draft prints lens lines. They are advice, not refusals.

  • lens texel: the focused region is drawn more than 1.05 times its source pixels, so text reads soft. Use a sharper source, a higher scale, or a smaller zoom.
  • lens tilt: the tilt in degrees for each world. A tilt over 10 degrees is hard to read, and 25 degrees is the clamp.

Limits

  • The lens needs WebGL2 and a source of fixed size. htmlSource reads the size of the element when you create it.
  • One frame costs about 17 ms on the GPU at 1080p, and about 12 ms in a draft.
  • Text in the source is a texture. Keep it large, or keep small text in HTML above the canvas.
  • The flat look is a valid choice. real-optics bans fake optics, not a clean frame.

From the blogDepth of field in CSS and WebGL: a measured test · CSS motion blur for video: blend 1 to 32 subframes

On this page