Camera, 3D and motion blur
camera(), depth() and shake() move a camera through layers of HTML, CSS and SVG, smear() blurs a moving element in the preview, and a Trillium view draws its three.js scene once per seek.
The layers sit inside one element, the world, which takes its style from camera(t, tracks, options), and depth(z) puts each layer at a depth.
A wrapper inside the world needs its own transform-style: preserve-3d, and an opacity, filter or clip-path on the world draws every layer flat.
smear() needs an isolation: isolate group around its copies.
A camera
camera, depth and shake work on a scene whose layers sit inside one element, the world, normally the size of the stage and at its top left.
depth(z) puts each layer at a depth, and camera(t, tracks, options) gives the world the style that shows the scene as a camera at that pose sees it.
const { scene, camera, depth, shake } = wildflower.kinetic;
const CAM = {
z: { at: [0, 2.5], to: [0, 260], ease: 'power2.inOut' }, // push in
x: { at: [2.5, 5], to: [0, 300], ease: 'sine.inOut' }, // truck right
zoom: { at: [7, 8.5], to: [1, 1.35] } // lens zoom
};
scene('shot', {
computed: {
world() { return camera(this.local, CAM, { shake: shake(this.local - 9, 'impact', { seed: 7 }) }); },
far() { return depth(-1600); },
title() { return depth(0); },
near() { return depth(300); }
}
});
The perspective makes the parallax, so the layers need no math of their own. A three.js scene in Trillium has a camera of its own.
<style>
.layer { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
</style>
<div data-component="shot" data-start="0" data-duration="12" style="position:absolute;inset:0">
<div class="world" data-bind-style="world" style="position:absolute;inset:0">
<div class="layer" data-bind-style="far">…</div>
<div class="layer" data-bind-style="title">…</div>
<div class="layer" data-bind-style="near">…</div>
</div>
</div>
The world is inside the scene, so data-bind-style="world" reads the scene's own computed (a binding on the root tag follows The scene's own root).
The three functions are pure, as smear() is, so check() and render() treat a camera move as any other style.
camera(t, tracks, options): keys and options
Each key of camera(t, tracks, options) below is a track of numbers in the format styleAt() takes, read at t, or a constant number.
A key left out or undefined is at rest, as is every key when tracks is not an object.
| Key | At rest | Sets |
|---|---|---|
x | 0 | Truck in px: the camera moves right, so the scene moves left. |
y | 0 | Crane in px: a positive value moves the camera down, as y runs in CSS. |
z | 0 | Dolly in px: a positive value moves the camera into the scene. |
pan | 0 | A turn right in degrees, about the camera's own vertical axis. |
tilt | 0 | A turn up in degrees, about the camera's horizontal axis. |
roll | 0 | A clockwise turn in degrees, about the camera's line of sight. |
zoom | 1 | The lens, a factor of 0.0001 or more that scales the picture with no change in parallax. |
The directions are the camera's, so the scene moves the opposite way on screen.
x, y and z run along the scene's axes whatever the camera faces, and pan, tilt and roll turn it in place, so a dolly after a pan still moves straight into the scene.
| Option | Default | Sets |
|---|---|---|
perspective | 1000 | The eye's distance in px from the z = 0 plane, 0.0001 or more, the same as depth() takes. |
shake | None | A shake() result, or an array of them, added to x, y and roll. |
The style it returns, and errors
The result is { transform, transformStyle: 'preserve-3d', transformOrigin: '50% 50%' }, with this transform.
perspective(P) scale(zoom) translateZ(P) rotateZ(-roll) rotateX(tilt) rotateY(pan) translateZ(-P) translate3d(-x, -y, z)
CSS applies the list right to left, so the scene moves by -x, -y and z, turns about the eye (P in front of the z = 0 plane, at the world's center), and is scaled by the lens and projected.
No ancestor of the world needs the CSS perspective property.
Numbers have at most four decimals.
Bad keys, options and tracks throw.
- A key not in the table throws KN-100 with the key in the message.
The development build's message also lists the keys and suggests
pan,tiltorrollforrotateY,rotateXorrotate. - A
zoombelow 0.0001 att, aperspectivethat is not a finite number of 0.0001 or more, and ashakethat is not ashake()result throw KN-100. - A malformed track, a track of colors or text, a track with a
unit, and a value that is neither a finite number nor a track throw KN-120, since the units are fixed. - Development builds warn about a camera track whose spring the next keyframe cuts short (KN-123), as for any track.
camera() takes no layer depths, so it does not warn when a dolly reaches a layer.
A layer at depth z fills the frame as the camera's z approaches P - z, and past that it is not drawn.
Keep the camera's z below P - z for every layer on screen, or hide the layer first.
depth(z, options)
depth(z, options) returns the style for a layer at depth z px, where a negative z is farther away and a positive one nearer.
{ transform: 'translateZ(z px) scale((P - z) / P)' }
The scale cancels the perspective's at that depth, so a stage-sized layer at the top left looks at rest exactly as at z = 0, and takes its parallax from its depth.
options.perspective, 1000 by default, has to match the camera's.
options.fill: falseleaves the scale out, so a layer set back looks smaller, as an object would.- The scale is about the element's own center, so the fill holds a layer in place only when the layer is centered on the world.
Put a small element inside a full-frame layer, and give the layer
depth(). - A
zat or beyond the perspective, where the eye is, or one that is not finite, throws (KN-100).
focus() takes the same z, so a rack focus agrees with the camera.
const { depth, focus } = wildflower.kinetic;
wildflower.kinetic.scene('rack', {
keyframes: {
plane: { at: [0, 3], to: [0, -1600], ease: 'power2.inOut' } // the focal plane's depth
},
computed: {
far() { return Object.assign(depth(-1600), { filter: 'blur(' + focus(-1600, this.plane) + 'px)' }); }
}
});
// the far layer is blurred by its distance from the focal plane
A layer stays at its depth only while every element between it and the world keeps the 3D.
transform-style is not inherited, so a wrapper inside the world needs its own transform-style: preserve-3d.
- A wrapper without it draws its children flat onto its own plane, as does one with
opacitybelow 1, afilter,overflowother thanvisible,clip-path,mask,mix-blend-modeorisolation. - A layer can carry any of these itself, so a layer that fades takes the opacity, and a wrapper around layers at several depths does not.
- Kinetic's transitions write
opacity,clip-pathorfilteron a clip, so a clip with children at several depths draws flat while it transitions.
The world takes none of these properties and no background.
Any of them draws every layer flat at the size depth() scaled it to, and a background is a surface at z = 0 that hides every layer behind it.
In a render, the world's layers draw in document order: a layer that passes through another does not interleave with it as it does in the page, so each layer keeps its own depth and none crosses another.
Put the backdrop on the scene's root or the farthest layer, and crop on the root or the stage.
shake(t, profile, options)
shake(t, profile, options) returns { x, y, roll }, camera offsets in px and degrees at t seconds from layered noise, the same on every machine for the same t, profile and seed.
| Option | Default | Sets |
|---|---|---|
amount | 1 | A scale on all three offsets, 0 or more, so a track can fade the shake in or out. |
speed | 1 | A scale on the profile's frequency, above 0. |
seed | 1 | The sequence, a number or a string, as noise() takes. |
An unknown profile throws (KN-100), and the development build's message lists the three.
A t that is not a finite number, an amount below 0, a speed of 0 or less, and a seed that is neither a number nor a string throw too (KN-100).
Pass the result to the camera's shake option, or apply it to any element with translate(x px, y px) rotate(roll deg).
| Profile | Frequency | x | y | roll | Octaves | Decay |
|---|---|---|---|---|---|---|
'handheld' | 0.7 Hz | 16 px | 11 px | 1.1 deg | 4 | None |
'rumble' | 7 Hz | 5.4 px | 7.2 px | 0.36 deg | 2 | None |
'impact' | 11 Hz | 61 px | 47 px | 4.3 deg | 2 | Multiplied by e-t / 0.32, and 0 before t = 0 |
Each offset stays within its amplitude times amount, with amplitudes tuned for a 1280 × 720 stage.
For an impact, t is the seconds since the hit: shake(this.local - 9, 'impact') for a hit at 9 s.
Each offset sums octaves of noise(), each at 2.03 times the frequency and half the weight of the one before.
fbm(u, seed, n) = Σ (i < n) 0.5^i · noise(u · 2.03^i + 17.37 · i, seed + i) / Σ (i < n) 0.5^i
x = fbm(t · hz · speed, seed, octaves) · X · amount
y = fbm(t · hz · speed + 50.43, seed + 101, octaves) · Y · amount
roll = fbm(t · hz · speed + 90.71, seed + 202, octaves) · R · amount
A string seed is first turned into a number, as noise() turns it, and the octaves and axes add to that number.
noise() is 0 at every whole number, so the y and roll offsets have different fractional parts and the three axes never pass through 0 together on a fixed beat.
const HANDHELD = { at: [9, 9.4, 10.6, 11], to: [0, 1, 1, 0] };
// A handheld shake that fades in over 0.4 s and out over 0.4 s.
world() {
const amount = wildflower.kinetic.interpolate(this.local, HANDHELD);
return camera(this.local, CAM, { shake: shake(this.local, 'handheld', { seed: 3, amount }) });
}
Motion blur
A render with a shutter averages several seeks inside each frame, blurring everything that moves, footage, camera moves and Trillium views included (The shutter).
For blur in the preview, in any browser, smear(t, style, options) draws copies of one element at times inside the frame.
smear() returns rows for a data-list, and the element that holds the copies needs isolation: isolate.
const { scene, smear } = wildflower.kinetic;
const SPIN = { rotate: { at: [0, 2], to: [0, 1440], ease: 'power2.in' } };
scene('wheel', {
computed: {
copies() { return smear(this.local, SPIN); }
}
});
<div class="wheel-group" style="isolation: isolate" data-list="copies" data-key="key">
<template><div class="wheel" data-bind-style="style"></div></template>
</div>
Each copy is drawn at 1/N of its own opacity with mix-blend-mode: plus-lighter, which adds its color to the color under it.
Inside the isolated group the copies add only to each other, so where all N overlap they make the element at full opacity, and where they part they leave the streak.
- The group is then drawn over the scene as any element is.
Without it,
plus-lighteradds the scene behind the copies too, and the streak brightens the background. - Any element that makes a stacking context with a transparent backdrop works as the group, as
isolation: isolatedoes. - A group with
isolationdraws its children flat, so inside a camera's world the group is a layer, or sits inside one (A camera).
smear(t, style, options): the copies and options
style is an object of tracks, as styleAt() takes, or a function of time that returns a style object, for an element whose style is worked out some other way.
Copy k of N shows the element at t + (k / N) · (angle / 360) / fps, the times a render's shutter seeks.
copies() {
// a style function: four copies over a quarter of a frame
return smear(this.local, (s) => styleAt(s, { translateX: s * 400 }), { samples: 4, angle: 90 });
}
Each row is { key: k, style }, where style is the style at that time with mixBlendMode: 'plus-lighter' and opacity set to its own opacity divided by N.
- The own opacity comes from the style, and is 1 when it has none or it is
''. A stylesheet's opacity is not read, so give a partly transparent element its opacity in the style. - An opacity string that ends in
%is read as a percentage. - With N = 1 the one row is the style at
t, with no blend mode and its opacity unchanged.
| Option | Default | Meaning |
|---|---|---|
samples | 8 | The number of copies, N, a whole number from 1 to 32. |
angle | 180 | The shutter angle in degrees, above 0 and up to 360. At 360 the copies spread over the whole frame, and at 180 over its first half. |
fps | The clock's | The frames per second the angle is measured in. The default reads the clock store's fps, so a render at another rate uses that rate. |
smear() in a render
Inside a render with a shutter, smear() returns one row at t, as with N = 1, since the shutter's samples blur the element already and a second blur would double the streak.
- It reads the clock store's
shutterfield, above 0 while such a render runs, so a computed that calls it runs again when the render starts and ends. - The copies add N elements per smeared element to every seek.
The shutter adds nothing to the preview and makes a render about
samplestimes as long.
A t that is not a finite number, a style that is neither tracks nor a function, a style function that returns a non-object, an opacity that is not a number, such as 'inherit', and a samples, angle or fps out of range throw (KN-100).
A malformed track throws KN-120, as in styleAt().
Trails
trails(kind, at, options) gives the marks a mover leaves behind it, in three kinds, each a pure function of its time or place, so every frame seeks.
Where smear() blurs one frame's motion, a trail stays on the screen for as long as its options say.
const { trails } = wildflower.kinetic;
ghosts() { return trails('ghosts', this.local, { style: BALL, count: 8, lag: 0.022, fade: 0.9, scale: 0.09 }); }
ribbon() { return trails('ribbon', this.u, { d: ROUTE_D, length: 0.1 }); }
dots() { return trails('dots', this.u, { d: FLIGHT_D, count: 12 }); }
| Kind | at | Options | Returns |
|---|---|---|---|
'ghosts' | the time | style, tracks as styleAt() takes or a function of the time returning a style object; count (6), lag in seconds (0.04), fade (0.8), scale (0) | Rows { key, k, t, style } for a data-list: copy k styled at t - k * lag, its opacity times 1 - fade * k / count, shrunk by scale(1 - scale * k). |
'ribbon' | u, the mover's place along the path, 0 to 1 | d, length (0.1), by ('length') | Path data, trimPath(d, u - length, u, { by }). |
'dots' | u | d, count (12), by ('length') | Rows { i, key, x, y, heading, u, state }: count marks evenly along the path, as distribute('path') places them, each 'past' once u reaches it and 'future' before. |
- Ghosts blend normally and look the same in the preview and in every render, with or without a shutter. Before the scene starts, or while the mover holds still, the ghosts sit under it.
- For a ribbon or dots, give the path the mover follows and its place on it.
by: 'segment'follows a mover whose speed changes, when the path was written as one segment for each even step of time. - The ribbon is a stroke of even width.
A ribbon in three.js takes its points from the dots or from
distribute('path')over the ribbon's path, and builds its own geometry. - A trail of something that is no function of time, such as the pointer or a slider, needs earlier frames that a seek cannot rebuild, so no kind draws one. Ghosts of a style that reads a store sit on the element itself.
An unknown kind, a t or u that is not a finite number, or a missing or bad option throws (KN-100), with the option in the message.
Trillium
A three.js scene in a Trillium view has a camera of its own, aimed in seek(local), and the view is drawn once per seek; Trillium, three.js in a scene covers the view, the renderer it needs, a tag over a scene point and footage as a texture.