Showreel
The code of the Showreel demo, 2 files.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Showreel | WildflowerJS Demo</title>
<script defer src="/js/analytics.js"></script>
<!-- The framework loads, then Trillium for the one three.js shot, then
Kinetic with its preview bar. Kinetic runs the clock and the render
itself. The scenes come last, as a module that imports three.js. -->
<script defer src="/js/dist/wildflower.lite.min.js"></script>
<script defer src="/js/extensions/three.wf.min.js"></script>
<script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
}
}
</script>
<script type="module" src="app.js"></script>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
.demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
.demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; font-size: 1rem; }
.demo-toolbar .logo { height: 32px; }
.demo-toolbar .brand-wf { font-weight: 520; }
.demo-toolbar .brand-js { font-weight: 700; }
.demo-toolbar .demo-info { font-size: 11px; color: rgba(255,255,255,0.6); text-align: right; }
.demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }
/* The composition is 1280 by 720. With data-preview, Kinetic scales
the stage down to the window; the stage's layout stays 1280 by
720, so a render captures the layout the preview shows. */
/* Hidden until Kinetic marks the root ready: before that, the stage
holds unbound markup and every clip at once. */
#stage:not([data-kinetic-ready]) { visibility: hidden; }
.frame { padding: 24px; }
#stage {
--ink: #0c0c10; --paper: #f2eee6; --violet: #aa3de6; --deep: #892dbf; --lime: #d9ff43;
--grotesk: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--serif: Didot, "Bodoni 72", "Bodoni MT", Georgia, "Times New Roman", serif;
--mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* Outlined type uses a static font, because a variable font's
overlapping contours show through -webkit-text-stroke. */
--outline: "Arial Black", "Helvetica Neue", Arial, sans-serif;
position: relative; width: 1280px; height: 720px; overflow: hidden; background: var(--ink);
font-family: var(--grotesk);
}
.layer { position: absolute; inset: 0; overflow: hidden; }
.ch { display: inline-block; white-space: pre; }
/* 00 Open */
.s1 { background: var(--ink); }
.s1 .floor { position: absolute; left: 0; top: 470px; width: 1280px; height: 2px; background: rgba(242,238,230,0.38); }
.s1 .guide { position: absolute; left: 639px; width: 0; border-left: 1.5px dashed rgba(242,238,230,0.38); }
.s1 .cap { position: absolute; top: 490px; font: 600 12px/1 var(--mono); letter-spacing: 0.16em; color: rgba(242,238,230,0.6); }
.s1 .cap.l { left: 160px; }
.s1 .cap.r { right: 160px; }
.s1 .iris { position: absolute; inset: 0; background: var(--violet); }
.s1 .open { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--ink); }
.s1 .line { display: flex; overflow: hidden; padding: 0.07em 0.04em 0.01em; font-size: 200px; font-weight: 900; line-height: 0.82; letter-spacing: -0.05em; }
/* 01 Type */
.s2 .zoom { position: absolute; inset: 0; transform-origin: 640px 360px; }
.s2 .band { position: absolute; left: 0; width: 1280px; height: 144px; background: var(--paper); overflow: hidden; }
.s2 .run { position: absolute; left: 0; top: 50%; white-space: pre; line-height: 1; color: var(--ink); }
.s2 .run.g { font-size: 116px; font-weight: 900; letter-spacing: -0.035em; }
.s2 .run.outline { font-family: var(--outline); font-size: 104px; letter-spacing: -0.02em; color: transparent; -webkit-text-stroke: 2.5px var(--ink); }
/* Didot's italic descenders run about 0.3 em below the baseline, so
the serif line is set smaller and a little high to keep them
inside its 144 px band. */
.s2 .run.serif { top: calc(50% - 4px); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 120px; letter-spacing: -0.01em; }
.s2 .hl { position: absolute; left: 0; top: 288px; width: 1280px; height: 144px; background: var(--violet); overflow: hidden; }
.s2 .scr { position: absolute; top: 26px; display: flex; font: 700 92px/92px var(--mono); color: var(--paper); }
.s2 .scr .ch { width: 0.6em; text-align: center; }
.s2 .dot { position: absolute; left: 594px; top: 314px; width: 92px; height: 92px; border-radius: 50%; background: var(--ink); }
/* 02 Pattern. Each cell holds two quarter-circle arcs, so neighbours
join at the middle of each edge whichever way a cell is turned. A
cell is 82 px around an 80 px tile, so its arcs run 1 px under its
neighbours and no seam shows where they meet. */
.s3 { background: var(--ink); }
/* The grid is its own layer, so its cells are drawn square to the
pixels and the camera's turn applies to the drawn layer as a whole. */
.s3 .grid { position: absolute; inset: 0; transform-origin: 640px 360px; will-change: transform; }
.s3 .cell {
position: absolute; width: 82px; height: 82px; margin: -1px 0 0 -1px; --c: var(--paper);
background:
radial-gradient(circle at 1px 1px, transparent 30.5px, var(--c) 31.5px 48.5px, transparent 49.5px),
radial-gradient(circle at 81px 81px, transparent 30.5px, var(--c) 31.5px 48.5px, transparent 49.5px);
}
/* 03 Easing */
.s4 { color: var(--paper); }
.s4 .gl { position: absolute; }
.s4 .gl i { position: absolute; inset: 0; background: rgba(242,238,230,0.16); }
.s4 .gl span { position: absolute; font: 500 13px/1 var(--mono); color: rgba(242,238,230,0.5); white-space: nowrap; }
.s4 .gl.v span { top: 100%; left: 0; transform: translate(-50%, 12px); }
.s4 .gl.h span { left: -14px; top: 0; transform: translate(-100%, -50%); }
.s4 svg { position: absolute; left: 0; top: 0; width: 1280px; height: 720px; overflow: visible; }
.s4 .curve { fill: none; stroke: var(--paper); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1 1; }
.s4 .linear { fill: none; stroke: rgba(242,238,230,0.24); stroke-width: 1.5; stroke-dasharray: 5 8; }
.s4 .kicker { position: absolute; left: 250px; top: 46px; font: 600 12px/1 var(--mono); letter-spacing: 0.16em; color: rgba(242,238,230,0.55); }
.s4 .names { position: absolute; left: 250px; top: 66px; width: 600px; height: 56px; overflow: hidden; }
.s4 .names .nm { position: absolute; left: 0; top: 0; font: 600 44px/56px var(--mono); letter-spacing: -0.01em; }
.s4 .proj { position: absolute; left: 0; top: 0; }
.s4 .proj.v { width: 0; border-left: 1.5px dashed rgba(242,238,230,0.35); }
.s4 .proj.h { height: 0; border-top: 1.5px dashed rgba(242,238,230,0.35); }
.s4 .track { position: absolute; left: 1039px; top: 290px; width: 2px; height: 300px; background: rgba(242,238,230,0.2); }
.s4 .marker { position: absolute; left: 1031px; top: 0; width: 18px; height: 18px; margin-top: -9px; background: var(--violet); }
.s4 .read { position: absolute; left: 1066px; top: 0; margin-top: -8px; font: 600 16px/16px var(--mono); white-space: pre; }
.s4 .tread { position: absolute; top: 612px; margin-left: -40px; width: 80px; text-align: center; font: 600 14px/1 var(--mono); color: rgba(242,238,230,0.7); }
.s4 .trail { position: absolute; left: 0; top: 0; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: var(--paper); }
.s4 .ball, .s6 .core { position: absolute; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border-radius: 50%; background: var(--violet); }
/* 04 Focus. The phrases sit in a 3D field, each on a full-frame plane
at its own depth; the lights behind them are flat discs, sized and
softened by their own depth. */
.s5 { background: var(--ink); }
.s5 .light { position: absolute; left: 0; top: 0; border-radius: 50%; }
.s5 .field { position: absolute; inset: 0; perspective: 900px; transform-style: preserve-3d; }
.s5 .plane { position: absolute; inset: 0; }
.s5 .phrase { position: absolute; transform: translate(-50%, -50%); white-space: nowrap; line-height: 1; color: var(--paper); }
.s5 .phrase.big { font-weight: 900; letter-spacing: -0.045em; }
.s5 .phrase.serif { font-family: var(--serif); font-style: italic; font-weight: 400; letter-spacing: -0.01em; }
.s5 .phrase.word { font-family: var(--mono); font-weight: 600; letter-spacing: 0.16em; color: rgba(242,238,230,0.6); }
.s5 .cap { position: absolute; top: 628px; font: 600 12px/1 var(--mono); letter-spacing: 0.16em; color: rgba(242,238,230,0.6); }
.s5 .cap.l { left: 160px; }
.s5 .cap.r { right: 160px; }
/* 05 Space */
.s6 { background: var(--ink); }
.s6 canvas { position: absolute; left: 0; top: 0; width: 1280px; height: 720px; }
/* 06 Rhythm */
.s7 .cut { position: absolute; inset: 0; display: flex; align-items: center; color: var(--fg); }
.s7 .acc { position: absolute; }
.s7 .mword { position: relative; display: flex; white-space: pre; line-height: 0.8; font-weight: 900; letter-spacing: -0.05em; }
.s7 .mword.outline { font-family: var(--outline); letter-spacing: -0.02em; color: transparent; -webkit-text-stroke: 4px var(--fg); }
.s7 .mword.serif { font-family: var(--serif); font-style: italic; font-weight: 400; letter-spacing: -0.02em; }
/* 07 End. The lockup is flush right, and the ball is its full stop. */
.s8 { background: var(--ink); color: var(--paper); }
.s8 .drift { overflow: visible; transform-origin: 1100px 300px; }
.s8 .ln { position: absolute; right: 180px; display: flex; align-items: baseline; justify-content: flex-end; white-space: pre; }
.s8 .big { font-size: 150px; font-weight: 900; line-height: 1; letter-spacing: -0.045em; }
.s8 .l1 { top: 104px; }
.s8 .l2 { top: 262px; font: italic 400 62px/1 var(--serif); color: rgba(242,238,230,0.78); }
.s8 .l3 { top: 330px; }
.s8 .stop { display: inline-block; width: 32px; height: 32px; margin-left: 10px; border-radius: 50%; background: var(--violet); }
.s8 .ring { position: absolute; left: 0; top: 0; width: 80px; height: 80px; margin: -40px 0 0 -40px; border-radius: 50%; border: 2px solid var(--violet); }
.s8 .meta { position: absolute; left: 160px; font: 600 13px/1 var(--mono); letter-spacing: 0.14em; white-space: pre; }
.s8 .c1 { top: 566px; }
.s8 .c2 { top: 590px; color: rgba(242,238,230,0.6); }
.s8 .c3 { top: 614px; color: rgba(242,238,230,0.6); letter-spacing: 0.04em; }
/* The overlay */
.hud { font: 600 11px/1 var(--mono); letter-spacing: 0.16em; }
.hud .mark { position: absolute; width: 18px; height: 18px; border: 0 solid currentColor; }
.hud .mark.tl { left: 28px; top: 28px; border-width: 1.5px 0 0 1.5px; transform-origin: 0 0; }
.hud .mark.tr { right: 28px; top: 28px; border-width: 1.5px 1.5px 0 0; transform-origin: 100% 0; }
.hud .mark.bl { left: 28px; bottom: 28px; border-width: 0 0 1.5px 1.5px; transform-origin: 0 100%; }
.hud .mark.br { right: 28px; bottom: 28px; border-width: 0 1.5px 1.5px 0; transform-origin: 100% 100%; }
.hud .label { position: absolute; left: 56px; top: 36px; }
.hud .chapters { position: absolute; right: 56px; top: 34px; width: 220px; height: 15px; overflow: hidden; }
.hud .chapters span { position: absolute; right: 0; top: 2px; white-space: pre; }
.hud .tc { position: absolute; left: 56px; bottom: 36px; white-space: pre; }
.hud .fc { position: absolute; right: 56px; bottom: 36px; white-space: pre; }
.about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
.about p { margin: 0 0 10px; }
.about b { color: #e6e6f0; font-weight: 600; }
.about code { color: #e6e6f0; font-size: 13px; }
</style>
</head>
<body>
<div class="demo-toolbar">
<a href="/demos/">
<img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
<span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
</a>
<div class="demo-info"><strong>Showreel</strong>: a motion reel</div>
</div>
<div class="frame">
<div id="stage" data-composition-id="showreel" data-start="0" data-duration="29" data-width="1280" data-height="720" data-fps="30">
<div class="layer s1" data-component="reel-open" data-start="0" data-duration="3.46666667">
<div class="floor" data-bind-style="floor"></div>
<div class="guide" data-bind-style="guide"></div>
<div class="cap l" data-bind-style="caption">FIG. 01</div>
<div class="cap r" data-bind-style="caption">SQUASH AND STRETCH</div>
<div class="iris" data-bind-style="iris"></div>
<div class="open" data-bind-style="block">
<div class="line" data-list="line1" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
<div class="line" data-list="line2" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
</div>
</div>
<div class="layer s2" data-component="reel-type" data-start="3" data-duration="3.56666667">
<div class="zoom" data-bind-style="zoom">
<div data-list="bands" data-key="key">
<template><div class="band" data-bind-style="band"><div class="run" data-bind-class="cls" data-bind="text" data-bind-style="run"></div></div></template>
</div>
<div class="hl" data-bind-style="highlight">
<div class="scr" data-bind-style="leftWord" data-list="leftLetters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
<div class="scr" data-bind-style="rightWord" data-list="rightLetters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
</div>
<div class="dot" data-bind-style="dot"></div>
</div>
</div>
<div class="layer s3" data-component="reel-pattern" data-start="6.5" data-duration="3.06666667">
<div class="grid" data-bind-style="camera" data-list="cells" data-key="key"><template><i class="cell" data-bind-style="style"></i></template></div>
</div>
<div class="layer s4" data-component="reel-ease" data-start="9.26666667" data-duration="3.73333333">
<div data-list="grid" data-key="key"><template><div data-bind-class="cls" data-bind-style="style"><i data-bind-style="line"></i><span data-bind="label" data-bind-style="labelStyle"></span></div></template></div>
<svg viewBox="0 0 1280 720" aria-hidden="true">
<path class="linear" d="M250 590 L930 290" data-bind-style="faint"></path>
<path class="curve" pathLength="1" data-bind-attr="curve" data-bind-style="stroke"></path>
</svg>
<div class="kicker" data-bind-style="faint">EASE</div>
<div class="names" data-list="names" data-key="key"><template><span class="nm" data-bind="text" data-bind-style="style"></span></template></div>
<div class="proj v" data-bind-style="dropLine"></div>
<div class="proj h" data-bind-style="reachLine"></div>
<div class="track" data-bind-style="faint"></div>
<div class="marker" data-bind-style="marker"></div>
<div class="read" data-bind="valueText" data-bind-style="valueRead"></div>
<div class="tread" data-bind="timeText" data-bind-style="timeRead"></div>
<div data-list="trail" data-key="key"><template><i class="trail" data-bind-style="style"></i></template></div>
<div class="ball" data-bind-style="ball"></div>
</div>
<div class="layer s5" data-component="reel-focus" data-start="13" data-duration="4">
<div data-pool="lights" data-key="id"><template><i class="light" data-bind-style="style"></i></template></div>
<i class="light" data-bind-style="core"></i>
<div class="field" data-bind-style="lens" data-list="rows" data-key="key">
<template><div class="plane" data-bind-style="layer"><div class="phrase" data-bind-class="cls" data-bind="text" data-bind-style="style"></div></div></template>
</div>
<div class="cap l" data-bind-style="caption">FIG. 05</div>
<div class="cap r" data-bind-style="caption">RACK FOCUS</div>
</div>
<div class="layer s6" data-component="reel-space" data-start="17" data-duration="5">
<canvas width="1280" height="720"></canvas>
</div>
<div class="layer s7" data-component="reel-rhythm" data-start="22" data-duration="3.4">
<div class="cut" data-bind-style="card">
<div class="acc" data-bind-style="accent"></div>
<div class="mword" data-bind-class="wordClass" data-bind-style="wordStyle" data-list="letters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
</div>
</div>
<div class="layer s8" data-component="reel-end" data-start="25.4" data-duration="3.6">
<div class="layer drift" data-bind-style="drift">
<div class="ln big l1" data-list="line1" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
<div class="ln l2" data-list="line2" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
<div class="ln big l3"><span data-list="line3" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></span><span class="stop" data-bind-style="stop"></span></div>
<div data-list="rings" data-key="key"><template><i class="ring" data-bind-style="style"></i></template></div>
</div>
<div class="meta c1" data-bind-style="credit1">SHOWREEL 2026</div>
<div class="meta c2" data-bind-style="credit2">TYPE PATTERN EASING FOCUS SPACE RHYTHM</div>
<div class="meta c3" data-bind-style="credit3">Made with WildflowerJS, the Kinetic extension, and the Trillium (three.js) extension</div>
</div>
<div class="layer hud" data-component="reel-hud" data-start="0" data-duration="29">
<div class="layer" data-bind-style="ink">
<i class="mark tl" data-bind-style="mark"></i>
<i class="mark tr" data-bind-style="mark"></i>
<i class="mark bl" data-bind-style="mark"></i>
<i class="mark br" data-bind-style="mark"></i>
<div class="label">SHOWREEL 2026</div>
<div class="chapters" data-list="chapters" data-key="key"><template><span data-bind="text" data-bind-style="style"></span></template></div>
<div class="tc" data-bind="timecode"></div>
<div class="fc" data-bind="frameCount"></div>
</div>
</div>
</div>
</div>
<div class="about">
<p>Eight scenes in 29 seconds. Play, scrub or step through it with the bar at the bottom.</p>
</div>
</body>
</html>
app.js
// The showreel runs 29 seconds in eight scenes, with an overlay on top. Each
// scene is a component on its own clip of the composition, and every value
// it shows is computed from the clip's local time, so any frame renders on
// its own and in any order. The space scene draws with three.js through
// Trillium, with a depth-of-field pass. In a render, or with ?embed, Kinetic
// shows the stage alone.
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const {
scene, styleAt, interpolate, ramp, mix, ease, spring, focus, depth, shake, trails,
random, split, scramble, format, textMetrics, moment,
} = wildflower.kinetic;
wildflower.three.use(THREE);
// Film grain over the whole frame, in the preview and in every render.
wildflower.kinetic.config({ finish: { grain: 0.2 } });
// ---- Shared -----------------------------------------------------------------
const INK = '#0c0c10';
const PAPER = '#f2eee6';
const VIOLET = '#aa3de6';
const DEEP = '#892dbf';
const LIME = '#d9ff43';
const fx = (v, digits) => v.toFixed(digits === undefined ? 2 : digits);
// ---- 00 Open ------------------------------------------------------------------
const FLOOR = 470; // px, the line the ball lands on
const BALL = 22; // px, the ball's radius
const HANG = 150; // px, where the ball's centre is held on the first frame
// Seeks land on whole frames, so every landing is put on one: the ball is
// let go at frame 9, falls for 10 frames, and bounces for 6, 3 and 2. Each
// arc's launch speed comes from its length, so it lands exactly at the end
// and the contact frame shows the ball on the floor.
const FRAME = 1 / 30;
const DROP = 9 * FRAME;
const FALL = 10 * FRAME;
const ARCS = [6, 3, 2];
const REST_Y = FLOOR - BALL;
const G = 2 * (REST_Y - HANG) / (FALL * FALL);
const V0 = G * FALL;
const LANDS = [DROP + FALL]; // s, each landing
const UPS = [V0]; // px/s, the speed the ball meets each landing with
for (const n of ARCS) { LANDS.push(LANDS[LANDS.length - 1] + n * FRAME); UPS.push(G * n * FRAME / 2); }
const EPS = 1e-6; // a frame that is a landing counts as landed
// The ball's centre and speed at t. It is held, falls, then bounces three
// times.
function bounce(t) {
if (t <= DROP) return { y: HANG, v: 0 };
if (t < LANDS[0] - EPS) { const u = t - DROP; return { y: HANG + 0.5 * G * u * u, v: G * u }; }
for (let k = 1; k < LANDS.length; k++) {
if (t < LANDS[k] - EPS) {
const u = t - LANDS[k - 1], up = UPS[k];
return { y: REST_Y - (up * u - 0.5 * G * u * u), v: G * u - up };
}
}
return { y: REST_Y, v: 0 };
}
const RISE = {
translateY: { at: [0, 0.55], to: [112, 0], ease: 'power4.out', unit: '%' },
rotate: { at: [0, 0.55], to: [8, 0], ease: 'power3.out' },
};
// Builds letter rows for words that each start at their own time, with a
// space row between words.
function wordRows(words, starts, t, gap, tracks) {
const rows = [];
words.forEach((w, wi) => {
if (wi) rows.push({ key: rows.length, text: ' ', style: { width: '0.3em' } });
for (const r of split(w)) rows.push({ key: rows.length, text: r.text, style: styleAt(t - starts[wi] - r.i * gap, tracks) });
});
return rows;
}
scene('reel-open', {
computed: {
t() { return this.local; },
floor() { return { opacity: fx(1 - ramp(this.t, 1.4, 1.6)) }; },
caption() { return { opacity: fx(1 - ramp(this.t, 1.35, 1.5)) }; },
// A dashed line runs from the ball to the floor until the first
// landing.
guide() {
const y = bounce(this.t).y;
return { top: fx(y) + 'px', height: fx(Math.max(0, FLOOR - y)) + 'px', opacity: fx(1 - ramp(this.t, LANDS[0] - 0.08, LANDS[0])) };
},
// The ball is the violet layer seen through an ellipse. Its speed
// stretches it, each landing squashes it with its bottom on the
// floor, and from 1.45 s the ellipse opens into an iris.
iris() {
const t = this.t;
const b = bounce(t);
let squash = 0;
// Each landing squashes in proportion to the speed it is met with.
LANDS.forEach((at, k) => { if (t >= at - EPS) squash += 0.42 * Math.sqrt(UPS[k] / V0) * Math.max(0, 1 - (t - at) / 0.09); });
squash += 0.3 * ramp(t, 1.3, 1.45, 'power2.inOut') * (1 - ramp(t, 1.45, 1.52));
const sy = (1 + 0.3 * Math.min(1, Math.abs(b.v) / V0)) * (1 - squash);
const open = ramp(t, 1.45, 1.8, 'power2.in');
const rx = mix(BALL / sy, 1500, open), ry = mix(BALL * sy, 1500, open);
const cy = mix(Math.min(b.y + BALL, FLOOR) - BALL * sy, 360, ease('power3.inOut')(open));
return { clipPath: 'ellipse(' + fx(rx) + 'px ' + fx(ry) + 'px at 640px ' + fx(cy) + 'px)' };
},
line1() { return wordRows(['MAKE', 'IT'], [1.72, 1.9], this.t, 0.035, RISE); },
line2() { return wordRows(['MOVE.'], [2.06], this.t, 0.035, RISE); },
// The last word lands with a shake and a swell that die away, then
// the block slides left under the next scene's bands. The shake's
// roll is left out, so the block stays level.
block() {
const t = this.t;
const hit = t > 2.12 ? Math.exp(-(t - 2.12) * 8) : 0;
const s = shake(t - 2.12, 'impact', { amount: 0.3, speed: 2.7, seed: 3 });
const out = ramp(t, 2.97, 3.45, 'power3.in');
return styleAt(t, { translateX: s.x - 320 * out, translateY: s.y, scale: 1 + 0.04 * hit });
},
},
});
// ---- 01 Type ------------------------------------------------------------------
const RUNS = [
{ text: 'MOTION DESIGN • ', cls: 'g', speed: -150, x: -60 },
{ text: 'TYPE IN MOTION • ', cls: 'g outline', speed: 140, x: -1700 },
{ text: 'KINETIC • ', cls: 'g', speed: -190, x: -240 },
{ text: 'every frame ', cls: 'serif', speed: 120, x: -1500 },
{ text: 'A FUNCTION OF TIME • ', cls: 'g', speed: -170, x: -100 },
].map((r, i) => Object.assign({}, r, { key: i, text: r.text.repeat(5) }));
const GLYPHS = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&*+=/<>';
// The two words scramble as one line, 'KINETICTYPE'. Letter k settles at
// 1.3 + 0.065k s, left to right, and until then changes glyph 24 times a
// second. A settled letter pops from 1.3 times its size.
const SCRAMBLED = 'KINETICTYPE';
const SETTLE = 0.065;
const POP = { opacity: 1, scale: { at: [0, 0.2], to: [1.3, 1], ease: 'power3.out' } };
function scrambleRows(word, first, t, shown) {
return split(word).map((r) => {
const k = first + r.i, at = 1.3 + k * SETTLE;
return { key: r.key, text: shown[k], style: t < at ? styleAt(t, { opacity: 0.45, scale: 1 }) : styleAt(t - at, POP) };
});
}
scene('reel-type', {
keyframes: {
// The scene scales about the dot's centre until the dot covers the
// frame, which is where the next scene starts.
zoom: {
rotate: { at: [2.97, 3.5], to: [0, 8], ease: 'expo.in' },
scale: { at: [2.97, 3.5], to: [1, 28], ease: 'expo.in' },
},
},
computed: {
t() { return this.local; },
// Five bands open from the right one after another, each carrying a
// line of type at its own speed.
bands() {
const t = this.t;
return RUNS.map((r, i) => {
const open = ramp(t, i * 0.06, 0.5 + i * 0.06, 'power4.out');
return {
key: r.key, text: r.text, cls: r.cls,
band: { top: i * 144 + 'px', clipPath: 'inset(0 0 0 ' + fx(100 * (1 - open)) + '%)' },
run: { transform: 'translate(' + fx(r.x + r.speed * t + 260 * (1 - open)) + 'px, -50%)' },
};
});
},
highlight() { return { clipPath: 'inset(0 ' + fx(100 * (1 - ramp(this.t, 0.95, 1.35, 'power3.inOut'))) + '% 0 0)' }; },
gap() { return 26 + 44 * ramp(this.t, 2.12, 2.45, 'power3.inOut'); },
leftWord() { return { right: fx(640 + this.gap) + 'px' }; },
rightWord() { return { left: fx(640 + this.gap) + 'px' }; },
// scramble() settles letter k of 11 at duration * (k + 1) / 11 from
// its time 0, so a start 0.065 s before 1.3 s settles letter k at
// 1.3 + 0.065k.
scrambled() { return scramble(this.t - 1.3 + SETTLE, SCRAMBLED, { duration: SETTLE * SCRAMBLED.length, fps: 24, chars: GLYPHS }); },
leftLetters() { return scrambleRows('KINETIC', 0, this.t, this.scrambled); },
rightLetters() { return scrambleRows('TYPE', 7, this.t, this.scrambled); },
dot() { return styleAt(this.t, { scale: Math.max(0, spring(this.t - 2.2, { stiffness: 200, damping: 14 })) }); },
},
});
// ---- 02 Pattern ---------------------------------------------------------------
const COLS = 18, ROWS = 11, CELL = 80; // the grid runs one cell past each edge, for the camera's turn
const WAVES = [
{ at: 0.8, x: -80, y: -80, speed: 1900, tint: VIOLET },
{ at: 1.5, x: 1360, y: 800, speed: 1900, tint: LIME },
{ at: 2.0, x: 640, y: 360, speed: 1300, tint: null },
];
const CELLS = [];
{
const rnd = random(23);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = c * CELL - 40, y = r * CELL - 40; // the cell's centre
const fromCentre = Math.hypot(x - 640, y - 360);
CELLS.push({
key: r * COLS + c,
left: (x - 40) + 'px', top: (y - 40) + 'px',
turn: rnd() < 0.5 ? 0 : 1,
bloom: 0.04 + fromCentre / 760 * 0.55,
leave: 2.2 + (c + r) / (COLS + ROWS - 2) * 0.36,
hits: WAVES.map((w) => w.at + Math.hypot(x - w.x, y - w.y) / w.speed),
core: fromCentre < 250,
});
}
}
}
scene('reel-pattern', {
keyframes: {
// The camera turns and settles. It does not scale, so the cells stay
// on whole pixels.
camera: { rotate: { at: [0, 3.05], to: [-6, 0], ease: 'power3.out' } },
},
computed: {
t() { return this.local; },
// Each cell grows in from the centre outward, turns a quarter when a
// wave reaches it, flashes the wave's colour as it turns, and shrinks
// away on a diagonal at the end. The last wave, a ring from the
// centre, leaves the cells inside it violet.
cells() {
const t = this.t;
return CELLS.map((c) => {
const size = ramp(t, c.bloom, c.bloom + 0.42, 'back.out') * (1 - ramp(t, c.leave, c.leave + 0.38, 'back.in'));
let turns = c.turn;
let colour = PAPER;
WAVES.forEach((w, i) => {
const hit = c.hits[i];
turns += ramp(t, hit, hit + 0.34, 'back.out');
if (w.tint) colour = mix(colour, w.tint, Math.max(0, 1 - Math.abs(t - hit - 0.12) / 0.22));
else if (c.core) colour = mix(colour, VIOLET, ramp(t, hit, hit + 0.25));
});
return {
key: c.key,
style: Object.assign({ left: c.left, top: c.top, '--c': colour }, styleAt(t, { rotate: turns * 90, scale: size })),
};
});
},
},
});
// ---- 03 Easing ----------------------------------------------------------------
const GX0 = 250, GX1 = 930, GY0 = 590, GK = 300; // t runs 0 to 1 across; a value of 1 is 300 px up
const gx = (p) => GX0 + p * (GX1 - GX0);
const gy = (v) => GY0 - v * GK;
const NAMES = ['power4.inOut', 'elastic.out', 'bounce.out'];
const CURVES = NAMES.map((n) => ease(n));
// The ball rides each curve, forward, back, then forward, and between rides
// the curve on the graph becomes the next one.
const RIDE = { at: [0.55, 1.3, 1.48, 2.23, 2.41, 3.1], to: [0, 1, 1, 0, 0, 1] };
const MORPHS = [1.3, 1.48, 2.23, 2.41];
// The curve on the graph as a fractional index into CURVES, and the names
// list's place, which moves on a steeper ease.
const SHAPE = { at: MORPHS, to: [0, 1, 1, 2], ease: 'power2.inOut' };
const NAME = { at: MORPHS, to: [0, 1, 1, 2], ease: 'power3.inOut' };
const OUT = 3; // s, when the graph starts to clear
// shapeAt() gives the curve on the graph at t as two neighbouring eases and
// the share of the second.
function shapeAt(t) {
const s = interpolate(t, SHAPE), a = Math.floor(s);
return { a, b: Math.min(a + 1, CURVES.length - 1), m: s - a };
}
const valueOn = (p, s) => mix(CURVES[s.a](p), CURVES[s.b](p), s.m);
// rider() gives the ball's progress along the ride at t, its value on the
// curve and its place in the frame. From 3.1 s it flies to the centre, where
// the focus scene starts from a violet dot of the same size.
function rider(t) {
const p = interpolate(t, RIDE);
const v = valueOn(p, shapeAt(t));
let x = gx(p), y = gy(v);
const fly = ramp(t, OUT + 0.1, OUT + 0.5, 'power3.inOut');
if (fly > 0) { x = mix(x, 640, fly); y = mix(y, 360, fly) - Math.sin(fly * Math.PI) * 70; }
return { p, v, x, y };
}
// Fades in and out, as tracks of the scene's t.
const FAINT = { opacity: { at: [0.1, 0.4, OUT, OUT + 0.3], to: [0, 1, 1, 0] } };
const NAMES_SHOWN = { at: [0.25, 0.5, OUT, OUT + 0.25], to: [0, 1, 1, 0] };
const GUIDES = { at: [0.5, 0.65, OUT, OUT + 0.15], to: [0, 1, 1, 0] };
const TRAIL_SHOWN = { at: [0.55, 0.65, OUT + 0.1, OUT + 0.3], to: [0, 1, 1, 0] };
const BALL_IN = { at: [0.4, 0.55], to: [0, 1] };
const GRID = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ across: true, at: p, label: p === 0 || p === 1 ? String(p) : '' }))
.concat([0, 0.5, 1].map((v) => ({ across: false, at: v, label: String(v) })))
.map((g, i) => Object.assign(g, { key: i }));
// The clip starts 0.25 s before the 9.5 s chapter mark, so the grid draws
// over the last of the pattern, and t counts from that mark.
const EASE_LEAD = 0.25;
scene('reel-ease', {
computed: {
t() { return this.local - EASE_LEAD; },
// Grid lines draw in one after another and retract at the end.
grid() {
const t = this.t;
return GRID.map((g) => {
const i = g.key;
const draw = ramp(t, i * 0.04 - EASE_LEAD, 0.12 + i * 0.04, 'power3.out') * (1 - ramp(t, OUT - 0.05 + i * 0.02, OUT + 0.25 + i * 0.02, 'power3.in'));
return {
key: g.key, label: g.label, cls: g.across ? 'gl v' : 'gl h',
style: g.across
? { left: gx(g.at) + 'px', top: gy(1.4) + 'px', width: '1px', height: (GY0 + 16 - gy(1.4)) + 'px' }
: { left: (GX0 - 16) + 'px', top: gy(g.at) + 'px', width: (GX1 - GX0 + 32) + 'px', height: '1px' },
line: Object.assign({ transformOrigin: g.across ? '50% 100%' : '0 50%' }, styleAt(t, g.across ? { scaleY: draw } : { scaleX: draw })),
labelStyle: { opacity: fx(draw) },
};
});
},
shape() { return shapeAt(this.t); },
curve() {
const s = this.shape;
let d = '';
for (let i = 0; i <= 120; i++) {
const p = i / 120;
d += (i ? ' L' : 'M') + fx(gx(p), 1) + ' ' + fx(gy(valueOn(p, s)), 1);
}
return { d };
},
// The path has pathLength 1 and a dash pattern of 1 1, so an offset
// of 1 hides it, 0 shows all of it, and 2 - e erases the first e.
stroke() {
const drawn = ramp(this.t, 0.2, 0.7, 'power2.inOut'), erased = ramp(this.t, OUT, OUT + 0.35, 'power2.inOut');
return { strokeDashoffset: fx(erased > 0 ? 2 - erased : 1 - drawn, 4) };
},
faint() { return styleAt(this.t, FAINT); },
names() {
const t = this.t;
const at = interpolate(t, NAME), shown = interpolate(t, NAMES_SHOWN);
return NAMES.map((n, i) => ({
key: i, text: n,
style: styleAt(t, { translateY: (i - at) * 56, opacity: Math.max(0, 1 - Math.abs(i - at)) * shown }),
}));
},
ride() { return rider(this.t); },
guides() { return interpolate(this.t, GUIDES); },
dropLine() { const r = this.ride; return { left: fx(r.x) + 'px', top: fx(Math.min(r.y, GY0)) + 'px', height: fx(Math.abs(GY0 - r.y)) + 'px', opacity: fx(this.guides) }; },
reachLine() { const r = this.ride; return { left: fx(r.x) + 'px', top: fx(r.y) + 'px', width: fx(Math.max(0, 1040 - r.x)) + 'px', opacity: fx(this.guides) }; },
marker() { return { top: fx(this.ride.y) + 'px', opacity: fx(this.guides) }; },
valueRead() { return { top: fx(this.ride.y) + 'px', opacity: fx(this.guides) }; },
valueText() { return format(this.ride.v, { minimumFractionDigits: 3, maximumFractionDigits: 3 }); },
timeRead() { return { left: fx(this.ride.x) + 'px', opacity: fx(this.guides) }; },
timeText() { return 't ' + format(this.ride.p, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); },
ball() { const r = this.ride; return styleAt(this.t, { translateX: r.x, translateY: r.y, opacity: BALL_IN }); },
// The trail is eight ghosts of the ball at 22 ms steps into the past,
// and while the ball is still they sit under it. Ghost k is
// 1 - 0.09k of the ball's size at 0.5 - 0.055k opacity.
trail() {
const shown = interpolate(this.t, TRAIL_SHOWN);
const style = (s) => { const r = rider(s); return styleAt(s, { translateX: r.x, translateY: r.y, opacity: 0.5 * shown }); };
return trails('ghosts', this.t, { style, count: 8, lag: 0.022, fade: 0.88, scale: 0.09 });
},
},
});
// ---- 04 Focus -----------------------------------------------------------------
// The reel's sentence hangs in a 3D field, far to near, and the focal plane
// racks from the back to the front, so each fragment comes sharp in turn and
// the sentence reads in order. Depth is in CSS translateZ pixels, 0 at the
// stage and negative away from the camera.
const LENS = 900; // px, the field's perspective
const FOCUS_LEN = 4; // s, the clip's length
// How much smaller a thing at depth z is drawn, measured from the vanishing
// point.
const depthScale = (z) => LENS / (LENS - z);
// Each phrase is placed by where it lands on the stage and how big its type
// looks there. It sits on a full-frame plane that depth() puts at its depth
// and scales back up to fill the frame, so its place, its size and its blur
// are all in stage pixels.
const PHRASES = [
{ text: 'EVERY FRAME', cls: 'big', z: -1200, at: [-200, -150], size: 96 },
{ text: 'IS A FUNCTION', cls: 'serif', z: -560, at: [60, 6], size: 82 },
{ text: 'OF TIME', cls: 'big', z: 0, at: [200, 168], size: 132 },
{ text: 'depth', cls: 'word', z: -900, at: [420, -232], size: 22 },
{ text: 'plane', cls: 'word', z: -330, at: [-430, 104], size: 22 },
{ text: 'lens', cls: 'word', z: -130, at: [-250, 236], size: 22 },
].map((p, i) => Object.assign({}, p, { key: i, layer: depth(p.z, { perspective: LENS }) }));
// bokeh() draws a point of light at (x, y) on the stage, `base` px across in
// focus. blur runs 0 to 1, from focus(). Out of focus the light spreads into
// a disc eight times as wide, its edge softens and it dims, which is how a
// lens draws a point. The disc is a radial gradient, so no light has a filter.
function bokeh(x, y, base, blur, alpha, colour) {
const size = base * (1 + 7 * blur), edge = 96 - 44 * blur;
return {
width: fx(size, 1) + 'px', height: fx(size, 1) + 'px',
transform: 'translate(' + fx(x - size / 2, 1) + 'px, ' + fx(y - size / 2, 1) + 'px)',
opacity: fx((1 - 0.75 * blur) * alpha, 3),
background: 'radial-gradient(circle closest-side, ' + colour + ' ' + fx(edge, 0) + '%, transparent 100%)',
};
}
scene('reel-focus', {
keyframes: {
// The plane starts on the easing scene's ball, at the stage, racks to
// the back as the phrases fade in, then pulls forward one phrase at
// a time.
plane: { at: [0.1, 0.5, 1.25, 1.85, 2.45, 2.85], to: [0, -1200, -1200, -560, -560, 0], ease: 'power2.inOut' },
},
pools: { lights: {} },
computed: {
t() { return this.local; },
// The vanishing point drifts across the shot, so far things slide
// against near ones.
drift() { return mix(-40, 40, this.t / FOCUS_LEN); },
lens() { return { perspectiveOrigin: fx(640 + this.drift) + 'px 360px' }; },
// The ball goes soft as the plane leaves it, and fades.
core() { return bokeh(640, 360, 26, focus(0, this.plane, { range: 250, max: 1 }), 1 - ramp(this.t, 0.3, 0.75), VIOLET); },
rows() {
const plane = this.plane, shown = ramp(this.t, 0.35, 0.8);
return PHRASES.map((p) => {
const blur = focus(p.z, plane, { range: 250, max: 14 });
return { key: p.key, text: p.text, cls: p.cls, layer: p.layer, style: {
left: (640 + p.at[0]) + 'px', top: (360 + p.at[1]) + 'px', fontSize: p.size + 'px',
filter: 'blur(' + fx(blur) + 'px)',
opacity: fx((1 - blur / 28) * shown, 3),
} };
});
},
caption() { return { opacity: fx(ramp(this.t, 0.4, 0.7)) }; },
},
// 300 lights at depths through the field, one in eight of them paper and
// the rest violet.
init() {
const rnd = random(23);
for (let i = 0; i < 300; i++) {
const paper = rnd() < 0.125;
this.pools.lights.add({ id: i, x: rnd() * 1280, y: rnd() * 720, z: -rnd() * 1000, base: 3 + rnd() * 3, tint: paper ? PAPER : VIOLET, alpha: paper ? 0.5 : 0.75, style: {} });
}
},
// The lights are a static pool, written here on every seek. Each takes
// its blur from its own depth, and shifts with the vanishing point by as
// much as a phrase at that depth would. Kinetic skips this seek when
// local has not changed, so a seek that changes nothing writes nothing.
seek(local) {
const lights = this.pools.lights, plane = this.plane, drift = this.drift, shown = ramp(local, 0.1, 0.6);
lights.forEach((l) => {
const blur = focus(l.z, plane, { range: 250, max: 1 });
lights.update(l.id, { style: bokeh(l.x + drift * (1 - depthScale(l.z)), l.y, l.base, blur, l.alpha * shown, l.tint) });
});
},
});
// ---- 05 Space -----------------------------------------------------------------
const BEADS = 600;
const SPHERE = 2.2, RING = 2.4, TUBE = 0.6; // world units
// The logo, built as the flower it is rather than sampled from the image:
// eight light petals in front, eight dark ones behind at half a step
// round, the disc, and the yellow centre. Each petal is an ellipse from
// the disc's edge to the rim, filled with beads on a grid tight enough to
// read as a solid, with the gaps between petals left clear. Classes: 0 a
// front petal, 1 a back petal, 2 the disc, 3 the centre. The dark classes
// take the reel's darker purple rather than the logo's own, so the two
// layers read apart at bead size.
const LOGO_COLOURS = [0xaa3de6, 0x521a78, 0x521a78, 0xffd21e];
// Depth toward the camera per class, so the front layer and the centre
// catch the key light over the back layer, and bead size per class.
const LOGO_DEPTH = [0.16, 0, 0.08, 0.24];
const LOGO_SIZE = [0.068, 0.068, 0.066, 0.082];
// 600 points of the logo, [x, y, class], sorted by angle round the centre.
function logoPoints() {
const pts = [];
const S = 0.148; // bead spacing
const petal = (angle, k, r0, r1, halfWidth) => {
const len = r1 - r0, rows = Math.round(len / S);
for (let i = 0; i <= rows; i++) {
// The tip bead sits a little short of the ellipse's end, so the
// petal ends rounded rather than in a point.
const u = i / rows, r = r0 + len * u - (i === rows ? S * 0.45 : 0);
const w = halfWidth * Math.sqrt(Math.max(0, 1 - Math.pow(2 * u - 1, 2)));
const across = Math.max(1, Math.round(2 * w / S));
for (let j = 0; j < across; j++) {
const off = across === 1 ? 0 : -w + (2 * w) * j / (across - 1);
pts.push([r * Math.cos(angle) - off * Math.sin(angle), r * Math.sin(angle) + off * Math.cos(angle), k]);
}
}
};
for (let p = 0; p < 8; p++) {
petal(p * Math.PI / 4, 0, 0.55, 2.25, 0.3);
petal(p * Math.PI / 4 + Math.PI / 8, 1, 0.6, 2.05, 0.28);
}
const ring = (r0, r1, k) => {
for (let r = r0; r <= r1 + 1e-6; r += S) {
const n = Math.max(1, Math.round(2 * Math.PI * r / S));
for (let j = 0; j < n; j++) pts.push([r * Math.cos(j / n * 2 * Math.PI), r * Math.sin(j / n * 2 * Math.PI), k]);
}
};
ring(0.3, 0.72, 2);
pts.push([0, 0, 3]);
ring(0.13, 0.27, 3);
// Exactly 600: drop or repeat disc points, which the petals overlap.
const disc = () => pts.findIndex((q) => q[2] === 2);
while (pts.length > BEADS) pts.splice(disc(), 1);
while (pts.length < BEADS) pts.push(pts[disc()].slice());
return pts.sort((a, b) => Math.atan2(a[1], a[0]) - Math.atan2(b[1], b[0]));
}
const LOGO_POINTS = logoPoints();
// Each bead has a place on a sphere and a place on a torus around the same
// axis, and the morph moves it from one to the other.
const BEAD_SHAPES = [];
{
const rnd = random(41);
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < BEADS; i++) {
const y = 1 - 2 * (i + 0.5) / BEADS, r = Math.sqrt(1 - y * y), around = i * golden;
const tube = 2 * Math.acos(y);
const pick = rnd();
BEAD_SHAPES.push({
sphere: [Math.cos(around) * r * SPHERE, y * SPHERE, Math.sin(around) * r * SPHERE],
torus: [(RING + TUBE * Math.cos(tube)) * Math.cos(around), TUBE * Math.sin(tube), (RING + TUBE * Math.cos(tube)) * Math.sin(around)],
height: y,
delay: rnd() * 0.22,
size: 0.05 + rnd() * 0.07,
colour: pick < 0.74 ? 0x521a78 : pick < 0.9 ? 0xaa3de6 : 0xf2eee6,
lag: (1 - y) / 2 * 0.3, // the morph runs from the top down
angle: around % (2 * Math.PI),
});
}
// The third shape is the logo (logoPoints(), 600 points sorted by
// angle). Beads are paired with points by their angle rank, so the
// petals form out of the ring rather than across it. The points are
// laid in the formation's own x-z plane, which the tip turns to face
// the camera, with z the negative of the logo's y so it stands upright.
const byAngle = BEAD_SHAPES.map((b, i) => i).sort((a, b) => BEAD_SHAPES[a].angle - BEAD_SHAPES[b].angle);
byAngle.forEach((bead, rank) => {
const [lx, ly, k] = LOGO_POINTS[rank];
BEAD_SHAPES[bead].logo = [lx, LOGO_DEPTH[k], -ly];
BEAD_SHAPES[bead].logoColour = LOGO_COLOURS[k];
BEAD_SHAPES[bead].logoSize = LOGO_SIZE[k];
});
}
// A colour between two, as a hex number, for the beads taking the logo's
// colours as they arrive.
function mixHex(a, b, p) {
if (p <= 0) return a;
if (p >= 1) return b;
const ch = (s) => [(s >> 16) & 255, (s >> 8) & 255, s & 255];
const [ar, ag, ab] = ch(a), [br, bg, bb] = ch(b);
return (Math.round(ar + (br - ar) * p) << 16) | (Math.round(ag + (bg - ag) * p) << 8) | Math.round(ab + (bb - ab) * p);
}
// pulse() is a damped sine from `at`, for an outward push that fades over
// a few oscillations.
const pulse = (t, at) => (t < at ? 0 : Math.exp(-(t - at) * 5) * Math.sin((t - at) * 16));
// beadsAt() gives the beads at t in world space. They burst from the centre
// on a spring, spin about the formation's axis, take a pulse that runs from
// the top of the sphere to the bottom, become the torus, tip over until the
// ring faces the camera, become the logo and take its colours, hold with a
// slow turn, then fly out past the camera.
function beadsAt(t) {
const spin = 0.4 + 1.1 * (t <= 3 ? t : 3 + (t - 3) * 0.2);
const tip = mix(0.35, Math.PI / 2, ramp(t, 1.75, 2.95, 'power3.inOut'));
const cs = Math.cos(spin), sn = Math.sin(spin), ct = Math.cos(tip), st = Math.sin(tip);
// The fly-out spreads the beads a little and pushes them down the z
// axis toward the camera, each at its own speed, so they stream past the
// lens on every side and are behind it before the cut.
const flyOut = ramp(t, 4.3, 5, 'power3.in');
const fly = 1 + 2 * flyOut;
const push = 14 * flyOut;
const gone = 1 - ramp(t, 4.7, 4.97);
return BEAD_SHAPES.map((b, i) => {
const out = spring(t - b.delay, { stiffness: 120, damping: 11 }) * (1 + 0.22 * pulse(t, 0.95 + (1 - b.height) * 0.18));
const m = ramp(t, 1.6 + b.lag, 2.35 + b.lag, 'power3.inOut');
const m3 = ramp(t, 3.0 + b.lag * 0.5, 3.7 + b.lag * 0.5, 'power3.inOut');
const x = mix(mix(b.sphere[0], b.torus[0], m), b.logo[0], m3) * out * fly;
const y = mix(mix(b.sphere[1], b.torus[1], m), b.logo[1], m3) * out * fly;
const z = mix(mix(b.sphere[2], b.torus[2], m), b.logo[2], m3) * out * fly;
const x1 = x * cs + z * sn, z1 = z * cs - x * sn;
return { id: i, x: x1, y: y * ct - z1 * st, z: y * st + z1 * ct + push * (0.7 + 0.6 * b.height), scale: mix(b.size, b.logoSize, m3) * Math.min(1, out * 3) * gone, colour: mixHex(b.colour, b.logoColour, m3) };
});
}
// The dust stays where it is, so the camera's flight shows against it, and
// none of it is near the camera's path down the z axis.
const DUST = [];
{
const rnd = random(57);
while (DUST.length < 260) {
const x = (rnd() - 0.5) * 30, y = (rnd() - 0.5) * 18, z = 8 - rnd() * 40;
if (z > -3 && Math.hypot(x, y) < 3.5) continue;
DUST.push({ x, y, z, scale: 0.02 + rnd() * 0.035 });
}
}
const FOV = 40; // degrees, vertical
const CAM_START = 9.5; // world units from the centre
// CORE is the violet dot the shot opens on after the cut, 13 px in radius
// on a 720 px frame, as a radius in world units at the camera's starting
// distance.
const CORE = 13 / 360 * CAM_START * Math.tan(FOV / 2 * Math.PI / 180);
// The camera pushes in slowly, holds, and pushes in a little more while the
// logo holds.
const CAM_Z = { at: [0, 3, 3.3, 4.6], to: [CAM_START, 8.2, 8.2, 5.9], ease: ['linear', 'linear', 'power3.inOut'] };
scene('reel-space', {
computed: {
t() { return this.local; },
beads() { return beadsAt(this.t); },
// The stars fade as the beads fly out, so the last frame is the
// plain dark ground the TYPE card opens on.
shot() {
const t = this.t;
return {
camZ: interpolate(t, CAM_Z),
core: CORE * (1 - ramp(t, 0.05, 0.4, 'power3.in')),
stars: 0.5 * (1 - ramp(t, 4.2, 4.9, 'power3.inOut')),
// The aperture opens for the fly-out, once the logo has let
// go, so the beads passing the lens blur as a camera would
// blur them while the formation stayed sharp before.
aperture: mix(0.0004, 0.004, ramp(t, 4.3, 4.65, 'power3.inOut')),
// The beads are drawn in flat cel bands while they burst,
// spin and become the ring, and turn to polished metal as
// the logo forms, with the studio's reflections arriving
// with the metal.
look: ramp(t, 2.95, 3.85, 'power3.inOut'),
};
},
},
init() {
const canvas = this.element.querySelector('canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(1280, 720, false);
// The ground is the scene's background. A clear colour on the
// renderer would be left in screen values by the pass that draws to
// the screen, and the first pass of the next frame would clear its
// linear target with it.
const world = new THREE.Scene();
world.background = new THREE.Color(0x0c0c10);
world.fog = new THREE.Fog(0x0c0c10, 8, 15);
world.add(new THREE.HemisphereLight(0xfff6ea, 0x101018, 0.5));
const key = new THREE.DirectionalLight(0xffffff, 3.2);
key.position.set(3, 5, 6);
const fill = new THREE.DirectionalLight(0xf2eee6, 0.6);
fill.position.set(-6, 1, 3);
const rim = new THREE.DirectionalLight(0xaa3de6, 4);
rim.position.set(-5, -2, -4);
world.add(key, fill, rim);
// One material carries both looks. three.js's standard shader is
// patched with a `uLook` uniform and a cel version of its own lit
// result, the direct light on the pixel quantised to three bands of
// the bead's colour, and the pixel is a mix of the two by uLook: 0
// is cel, 1 is the polished metal with its reflections. Per-instance
// colours feed both.
const look = { value: 1 };
const beadMaterial = new THREE.MeshStandardMaterial({ roughness: 0, metalness: 0.55 });
beadMaterial.onBeforeCompile = (shader) => {
shader.uniforms.uLook = look;
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>', '#include <common>\nuniform float uLook;')
.replace('#include <opaque_fragment>', [
'float celLight = dot(reflectedLight.directDiffuse, vec3(0.3333)) / max(dot(diffuseColor.rgb, vec3(0.3333)), 1e-4);',
'float celBand = floor(clamp(celLight, 0.0, 1.0) * 3.0 + 0.5) / 3.0;',
'vec3 cel = diffuseColor.rgb * (0.28 + 0.72 * celBand);',
'outgoingLight = mix(cel, outgoingLight, uLook);',
'#include <opaque_fragment>',
].join('\n'));
};
const beads = new THREE.InstancedMesh(new THREE.SphereGeometry(1, 20, 14), beadMaterial, BEADS);
const core = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 20), new THREE.MeshBasicMaterial({ color: 0xaa3de6, fog: false }));
const dust = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1, 1), new THREE.MeshBasicMaterial({ color: 0xf2eee6, fog: false, transparent: true, opacity: 0.5 }), DUST.length);
world.add(beads, core, dust);
// The beads are polished metal, and what they reflect is a studio
// built here rather than an HDR file fetched from the network, so
// frame 0 never waits on a download and every render sees the same
// room. It is a black room with a wide white softbox above and in
// front, a tall violet strip to one side for the rim, and a dim warm
// panel behind, baked once into an environment map. The panels
// streak across the beads as the formation turns. Only the
// environment is set, so the background stays the dark ground and
// the stars.
const studio = new THREE.Scene();
const panel = (w, h, color, x, y, z) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }));
m.position.set(x, y, z);
m.lookAt(0, 0, 0);
studio.add(m);
};
panel(7, 3.5, new THREE.Color(3.5, 3.5, 3.5), 0, 5, 4);
panel(1.4, 9, new THREE.Color(0xaa3de6).multiplyScalar(3), -7, 0, -2);
panel(3, 3, new THREE.Color(0.45, 0.32, 0.5), 6, -2, -5);
const pmrem = new THREE.PMREMGenerator(renderer);
world.environment = pmrem.fromScene(studio).texture;
pmrem.dispose();
const camera = new THREE.PerspectiveCamera(FOV, 1280 / 720, 0.05, 100);
// The frame goes through three.js's bokeh pass for depth of field.
// The pass blurs each pixel by its distance from `focus` along the
// camera's view. The blur grows with aperture times that distance and
// stops at maxblur. The aperture is small enough that the formation,
// about 4.4 units deep as a sphere, stays sharp, and the stars, 10 to
// 40 units from the focus, soften into discs.
// The passes work in linear colour, and the output pass converts to
// the screen's, as a plain render does.
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(world, camera));
const bokeh = new BokehPass(world, camera, { focus: CAM_START, aperture: 0.0004, maxblur: 0.006 });
composer.addPass(bokeh);
composer.addPass(new OutputPass());
const ctx = this;
const view = wildflower.three.view('reel-space-view', {
renderer, scene: world, camera, render: 'change',
draw: () => composer.render(),
before() {
const s = ctx.shot;
camera.position.set(0, 0, s.camZ);
camera.lookAt(0, 0, 0);
// The focus follows the camera's push, so it stays on the
// formation at the centre.
bokeh.uniforms.focus.value = s.camZ;
bokeh.uniforms.aperture.value = s.aperture;
core.scale.setScalar(Math.max(s.core, 1e-4));
dust.material.opacity = s.stars;
look.value = s.look;
beadMaterial.envMapIntensity = s.look;
},
});
view.instanced(() => ctx.beads, beads, { position: ['x', 'y', 'z'], scale: 'scale', color: 'colour', pickable: false });
view.instanced(() => DUST, dust, { position: ['x', 'y', 'z'], scale: 'scale', pickable: false });
// Shaders compile now, ahead of the first seek that draws.
// renderer.compile() covers the scene's, and one render through the
// composer covers the passes' own.
renderer.compile(world, camera);
composer.render();
},
});
// ---- 06 Rhythm ----------------------------------------------------------------
// The cuts fall on whole frames and shorten from 14 frames to 4, 102 frames
// in all.
const CUTS = [
{ at: 0, word: 'TYPE', bg: INK, fg: PAPER, size: 300, move: 'zoom' },
{ at: 14, word: 'SHAPE', bg: DEEP, fg: PAPER, size: 240, left: 90, acc: 'shape' },
{ at: 27, word: 'TIME', bg: INK, fg: VIOLET, size: 300, acc: 'hand' },
{ at: 39, word: 'SPACE', bg: VIOLET, fg: INK, size: 400, kind: 'outline', move: 'slide' },
{ at: 50, word: 'COLOR', bg: INK, fg: PAPER, size: 260, acc: 'bars' },
{ at: 60, word: 'RHYTHM', bg: PAPER, fg: INK, size: 220, move: 'hop' },
{ at: 69, word: 'SPRING', bg: LIME, fg: INK, size: 230, move: 'spring' },
{ at: 77, word: 'ease', bg: DEEP, fg: PAPER, size: 330, kind: 'serif' },
{ at: 84, word: 'LOOP', bg: PAPER, fg: VIOLET, size: 300, move: 'zoom' },
{ at: 90, word: '', bg: VIOLET, fg: INK },
{ at: 94, word: '', bg: DEEP, fg: PAPER },
{ at: 98, word: '', bg: INK, fg: PAPER },
];
// cutAt() gives the cut on screen at a local time and the seconds since it
// started. The cuts are timed in frames, so the time is read as its frame.
function cutAt(local) {
const cut = moment(Math.round(local / FRAME), CUTS).row;
return { cut, u: local - cut.at * FRAME };
}
// The square turns a quarter as its corners sharpen.
const SHAPE_IN = {
rotate: { at: [0, 0.26], to: [0, 90], ease: 'power3.out' },
scale: { at: [0, 0.26], to: [0.6, 1], ease: 'power3.out' },
borderRadius: { at: [0, 0.26], to: [50, 0], ease: 'power3.out', unit: '%' },
};
function accentOf(kind, u) {
if (kind === 'shape') {
return Object.assign({ left: '820px', top: '190px', width: '340px', height: '340px', background: VIOLET }, styleAt(u, SHAPE_IN));
}
if (kind === 'hand') {
return Object.assign({ left: '637px', top: '40px', width: '6px', height: '320px', background: PAPER, transformOrigin: '50% 100%' }, styleAt(u, { rotate: u * 900 }));
}
if (kind === 'bars') {
return { left: '0px', top: '520px', width: '1280px', height: '28px', background: 'linear-gradient(90deg, ' + VIOLET + ' 0 33.3%, ' + DEEP + ' 0 66.6%, ' + LIME + ' 0)', clipPath: 'inset(0 ' + fx(100 * (1 - ramp(u, 0, 0.16, 'power3.out'))) + '% 0 0)' };
}
return { display: 'none' };
}
const WORD_ZOOM = { scale: { at: [0, 0.25], to: [1.14, 1], ease: 'power3.out' } };
const LETTER_UP = { translateY: { at: [0, 0.12], to: [40, 0], ease: 'power4.out' } };
scene('reel-rhythm', {
computed: {
t() { return this.local; },
now() { return cutAt(this.t); },
card() {
const c = this.now.cut;
return { background: c.bg, '--fg': c.fg, justifyContent: c.left ? 'flex-start' : 'center', paddingLeft: (c.left || 0) + 'px' };
},
accent() { return accentOf(this.now.cut.acc, this.now.u); },
wordClass() { return this.now.cut.kind || ''; },
wordStyle() {
const { cut, u } = this.now;
const move = cut.move === 'zoom' ? styleAt(u, WORD_ZOOM) : cut.move === 'slide' ? styleAt(u, { translateX: 70 - 600 * u }) : { transform: 'none' };
return Object.assign({ fontSize: (cut.size || 200) + 'px' }, move);
},
letters() {
const { cut, u } = this.now;
return split(cut.word).map((r) => {
const style = cut.move === 'hop' ? styleAt(u, { translateY: (r.i % 2 ? -1 : 1) * 34 * Math.sin(u / 0.2 * Math.PI) })
: cut.move === 'spring' ? styleAt(u, { scaleY: spring(u - r.i * 0.015, { stiffness: 320, damping: 9, from: 0.4 }) })
: styleAt(u - r.i * 0.012, LETTER_UP);
style.transformOrigin = cut.move === 'spring' ? '50% 100%' : '50% 50%';
return { key: r.key, text: r.text, style };
});
},
},
});
// ---- 07 End -------------------------------------------------------------------
const L1 = 104, L2 = 262, L3 = 330; // the lines' tops, as in the CSS
const RIGHT = 1100; // the lockup's right edge
const STOP = 16; // the full stop's radius
// The full stop's centre at rest, estimated from the line's metrics. The
// ball's path is drawn relative to it, so at rest the ball sits where the
// baseline falls.
const STOP_X = RIGHT - STOP, STOP_Y = L3 + 128 - STOP;
const GRAVITY = 4200; // px/s²
// Each hop is a chord with a lift, and it takes the time a ball under
// GRAVITY needs to rise that far above the chord and come back.
const HOPS = [];
{
const legs = [
{ to: [700, L1 + 6], lift: 40 },
{ to: [1030, L2 + 8], lift: 90 },
{ to: [STOP_X, STOP_Y], lift: 80 },
{ to: [STOP_X, STOP_Y], lift: 12 },
{ to: [STOP_X, STOP_Y], lift: 4 },
];
let from = [380, -70], a = 0.95;
for (const leg of legs) {
const b = a + Math.sqrt(8 * leg.lift / GRAVITY);
HOPS.push({ a, b, from, to: leg.to, lift: leg.lift });
from = leg.to;
a = b;
}
}
// hopAt() gives the ball's centre in the frame at t and how hard it is
// squashed.
function hopAt(t) {
let squash = 0;
HOPS.forEach((h, k) => { if (t >= h.b) squash += (k < 3 ? 0.4 : 0.15) * Math.max(0, 1 - (t - h.b) / 0.08); });
if (t <= HOPS[0].a) return { x: HOPS[0].from[0], y: HOPS[0].from[1], squash };
for (const h of HOPS) {
if (t < h.b) {
const u = (t - h.a) / (h.b - h.a);
return { x: mix(h.from[0], h.to[0], u), y: mix(h.from[1], h.to[1], u) - 4 * h.lift * u * (1 - u), squash };
}
}
return { x: STOP_X, y: STOP_Y, squash };
}
// dip() is how far a letter at x dips when the ball lands near it, a damped
// wobble that is largest under the ball.
function dip(t, impact, x) {
if (t < impact.at) return 0;
const dt = t - impact.at, dx = (x - impact.x) / 130;
return impact.depth * Math.exp(-dx * dx) * Math.exp(-dt * 9) * Math.sin(dt * 30);
}
const IMPACTS = HOPS.slice(0, 3).map((h, k) => ({ at: h.b, x: h.to[0], depth: [22, 14, 10][k] }));
// The lines' fonts, as in the CSS, for measuring where each letter sits.
const BIG_FONT = '900 150px system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
const BIG_TRACKING = -0.045; // em, the CSS letter-spacing
const SERIF_FONT = 'italic 400 62px Didot, "Bodoni 72", "Bodoni MT", Georgia, "Times New Roman", serif';
const LINE2 = 'is a function of';
// The last line ends at the full stop, 32 px wide with a 10 px margin.
const TIME_RIGHT = RIGHT - 2 * STOP - 10;
// droppedRows() drops letters in on a spring. The lines are flush right, so
// each letter's centre is measured back from the line's right edge. CSS
// letter-spacing follows every letter, the last too, so the line is one
// spacing wider than textMetrics() measures it.
function droppedRows(text, start, t, impact, right) {
const m = textMetrics(text, BIG_FONT, { letters: true, tracking: BIG_TRACKING });
const left = right - m.width - BIG_TRACKING * 150;
return m.letters.map((r) => {
const at = start + r.i * 0.045;
const k = spring(t - at, { stiffness: 170, damping: 13 });
const y = -120 * (1 - k) + dip(t, impact, left + r.x + r.width / 2);
return { key: r.key, text: r.text, style: styleAt(t, { translateY: y, opacity: ramp(t, at, at + 0.12) }) };
});
}
const FADE_UP = {
opacity: { at: [0, 0.35], to: [0, 1] },
translateY: { at: [0, 0.45], to: [12, 0], ease: 'power3.out' },
};
scene('reel-end', {
keyframes: {
// The lockup pushes in slowly for the whole scene.
drift: { scale: { at: [0, 4], to: [1, 1.035], ease: 'sine.inOut' } },
},
computed: {
t() { return this.local; },
line1() { return droppedRows('MOTION', 0.05, this.t, IMPACTS[0], RIGHT); },
line2() {
const t = this.t;
const m = textMetrics(LINE2, SERIF_FONT, { letters: true });
const left = RIGHT - m.width;
let first = 0;
return split(LINE2, 'word').map((w, i) => {
// The word's centre, from its first letter's left edge to its
// last letter's right edge.
const a = m.letters[first], b = m.letters[first + w.text.length - 1];
first += w.text.length;
const at = 0.3 + i * 0.05;
const y = 26 * (1 - ramp(t, at, at + 0.5, 'power3.out')) + dip(t, IMPACTS[1], left + (a.x + b.x + b.width) / 2);
return { key: w.key, text: w.text, style: styleAt(t, { translateY: y, opacity: ramp(t, at, at + 0.3) }) };
});
},
line3() { return droppedRows('TIME', 0.5, this.t, IMPACTS[2], TIME_RIGHT); },
stop() {
const h = hopAt(this.t);
const style = styleAt(this.t, { translateX: h.x - STOP_X, translateY: h.y - STOP_Y, scaleX: 1 + h.squash * 0.8, scaleY: 1 - h.squash });
return Object.assign({ transformOrigin: '50% 100%' }, style);
},
// A ring spreads from each landing.
rings() {
const t = this.t;
return IMPACTS.map((im, k) => {
const p = ramp(t, im.at, im.at + 0.5), s = 0.2 + 2 * ease('power3.out')(p);
return { key: k, style: styleAt(t, { translateX: im.x, translateY: HOPS[k].to[1] + STOP, scaleX: s, scaleY: s * 0.35, opacity: p > 0 && p < 1 ? 0.9 * (1 - p) : 0 }) };
});
},
credit1() { return styleAt(this.t - 2.3, FADE_UP); },
credit2() { return styleAt(this.t - 2.38, FADE_UP); },
credit3() { return styleAt(this.t - 2.46, FADE_UP); },
},
});
// ---- The overlay -------------------------------------------------------------
const CHAPTERS = ['00 INTRO', '01 TYPE', '02 PATTERN', '03 EASING', '04 FOCUS', '05 SPACE', '06 RHYTHM', '07 END'];
const CHAPTER_AT = [0, 3.2, 6.5, 9.5, 13, 17, 22, 25.4];
// The chapter list's place: it moves on one name over 0.3 s around each
// mark after the first.
const CHAPTER_PLACE = {
at: CHAPTER_AT.slice(1).flatMap((c) => [c - 0.08, c + 0.22]),
to: CHAPTER_AT.slice(1).flatMap((c, i) => [i, i + 1]),
ease: 'power3.inOut',
};
// The overlay is light on dark grounds and dark on light ones, and it
// switches when a scene's background covers the corner marks. During the
// cuts it takes each cut's colour.
const INK_KEYS = { at: [0, 1.66, 1.74, 6.42, 6.5], to: [PAPER, PAPER, INK, INK, PAPER] };
function overlayInk(time) {
if (time >= 25.4) return PAPER;
if (time >= 22) return cutAt(time - 22).cut.fg;
return interpolate(time, INK_KEYS.at, INK_KEYS.to);
}
const pad = (n, w) => String(n).padStart(w, '0');
scene('reel-hud', {
keyframes: {
mark: { scale: { at: [0.05, 0.5], to: [0, 1], ease: 'power3.out' } },
},
computed: {
ink() { return { color: overlayInk(this.local) }; },
chapters() {
const at = interpolate(this.local, CHAPTER_PLACE);
return CHAPTERS.map((text, i) => ({ key: i, text, style: styleAt(this.local, { translateY: (i - at) * 16 }) }));
},
timecode() { const f = this.clipFrame; return '00:00:' + pad(Math.floor(f / 30), 2) + ':' + pad(f % 30, 2); },
frameCount() { return 'FRAME ' + pad(Math.min(this.clipFrame, 869), 3) + ' / 870'; },
},
});