Nested Cages
The code of the Nested Cages 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">
<title>Nested Cages | WildflowerJS Demo</title>
<script defer src="/js/analytics.js"></script>
<!-- Cages nested at a fixed ratio, in 2.5D. Each cycle everything tumbles
a quarter turn while each cage grows into the place of the one around
it; the outermost flies past, and the last frame is the first. The
faces are flat shapes projected from 3D by kinetic.25d.js's solids(),
lit by their angle; the top half is mirrored at the horizon.
The framework, then Kinetic with its preview bar, then solids(), then
the scene. -->
<script defer src="/js/dist/wildflower.lite.min.js"></script>
<script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
<script defer src="/js/extensions/kinetic.25d.min.js"></script>
<script defer src="cages.js"></script>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; background: #07061a; color: #e6e6f0; font-family: system-ui, sans-serif; color-scheme: dark; }
.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 1080 by 1080. With data-preview, Kinetic scales
the stage down to the window. Hidden until Kinetic marks the root
ready: before that, the stage holds unbound markup. */
.frame { padding: 24px; }
#stage { position: relative; width: 1080px; height: 1080px; overflow: hidden; background: #100F2D; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.layer { position: absolute; left: 0; top: 0; width: 1080px; height: 1080px; }
.about { max-width: 1080px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
.about p { margin: 0 0 10px; }
.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>Nested Cages</strong>: solids in 2.5D</div>
</div>
<div class="frame">
<div id="stage" data-composition-id="cages" data-start="0" data-duration="2.1" data-width="1080" data-height="1080" data-fps="30">
<div data-component="cages" data-start="0" data-duration="2.1" style="position:absolute;inset:0">
<svg class="layer" viewBox="0 0 1080 1080" aria-hidden="true">
<defs>
<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#06051A"/><stop offset="0.5" stop-color="#121044"/><stop offset="1" stop-color="#06051A"/>
</linearGradient>
<!-- The dark at the cages' heart. -->
<radialGradient id="heart"><stop offset="0" stop-color="#000000" stop-opacity="0.85"/><stop offset="0.35" stop-color="#000000" stop-opacity="0.3"/><stop offset="1" stop-color="#000000" stop-opacity="0"/></radialGradient>
<!-- Face glows, clipped to their face (gK, gM, gC), and their bleeds past the edges, one flat color each (bK, bM, bC). -->
<radialGradient id="gK"><stop offset="0" stop-color="#F4F2FF" stop-opacity="0.95"/><stop offset="0.45" stop-color="#D9D6FF" stop-opacity="0.45"/><stop offset="1" stop-color="#D9D6FF" stop-opacity="0"/></radialGradient>
<radialGradient id="gM"><stop offset="0" stop-color="#FF5CE6" stop-opacity="1"/><stop offset="0.5" stop-color="#D94BF0" stop-opacity="0.55"/><stop offset="1" stop-color="#D94BF0" stop-opacity="0"/></radialGradient>
<radialGradient id="gC"><stop offset="0" stop-color="#4FA2FF" stop-opacity="1"/><stop offset="0.5" stop-color="#3C6CFF" stop-opacity="0.55"/><stop offset="1" stop-color="#3C6CFF" stop-opacity="0"/></radialGradient>
<radialGradient id="bK"><stop offset="0" stop-color="#E8E6FF" stop-opacity="0.8"/><stop offset="0.5" stop-color="#E8E6FF" stop-opacity="0.35"/><stop offset="1" stop-color="#E8E6FF" stop-opacity="0"/></radialGradient>
<radialGradient id="bM"><stop offset="0" stop-color="#F06CEB" stop-opacity="1"/><stop offset="0.5" stop-color="#F06CEB" stop-opacity="0.5"/><stop offset="1" stop-color="#F06CEB" stop-opacity="0"/></radialGradient>
<radialGradient id="bC"><stop offset="0" stop-color="#5E96FF" stop-opacity="1"/><stop offset="0.5" stop-color="#5E96FF" stop-opacity="0.5"/><stop offset="1" stop-color="#5E96FF" stop-opacity="0"/></radialGradient>
<radialGradient id="vignette" r="0.72"><stop offset="0.45" stop-color="#05040F" stop-opacity="0"/><stop offset="1" stop-color="#05040F" stop-opacity="0.85"/></radialGradient>
<clipPath id="upper"><rect x="0" y="0" width="1080" height="540"/></clipPath>
<!-- The look, one filter over the whole scene, so the scene is drawn once:
a soft focus (the scene blurred, 12% over itself), then the bloom (the
brights, blurred, screened on at 60%). -->
<filter id="look" filterUnits="userSpaceOnUse" x="-100" y="-100" width="1280" height="1280" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" color-interpolation-filters="linearRGB" result="soft"/>
<feComponentTransfer in="soft" result="soft12"><feFuncA type="linear" slope="0.12"/></feComponentTransfer>
<feMerge result="base"><feMergeNode in="SourceGraphic"/><feMergeNode in="soft12"/></feMerge>
<feComponentTransfer in="SourceGraphic" result="brights"><feFuncR type="linear" slope="2.6" intercept="-1.4"/><feFuncG type="linear" slope="2.6" intercept="-1.4"/><feFuncB type="linear" slope="2.6" intercept="-1.4"/></feComponentTransfer>
<feGaussianBlur in="brights" stdDeviation="26" result="glow"/>
<feComponentTransfer in="glow" result="glow60"><feFuncA type="linear" slope="0.6"/></feComponentTransfer>
<feBlend mode="screen" in="glow60" in2="base"/>
</filter>
<!-- The bleeds stack dozens of gradients, and Chrome dithers each with the same
pixel pattern, so stacked they show a fine mesh; a blur over the layer
melts it. The blur is in the picture's units, and the homepage shows the
picture at under half its size, so it is 8: about 4 screen pixels there,
which melts the mesh on a 1x display too. The bleeds are soft already,
so the blur changes little else. -->
<filter id="melt" filterUnits="userSpaceOnUse" x="0" y="0" width="1080" height="1080"><feGaussianBlur stdDeviation="8"/></filter>
</defs>
<g filter="url(#look)">
<rect width="1080" height="1080" fill="url(#ground)"/>
<!-- The cages are drawn into the top half and mirrored at the horizon. The filter
is on the group around both: one inside the mirrored copy redraws slowly. -->
<g id="world" clip-path="url(#upper)">
<circle cx="540" cy="540" r="300" fill="url(#heart)" opacity="0.6"/>
<g id="cages"></g>
</g>
<use href="#world" transform="matrix(1 0 0 -1 0 1080)"/>
<!-- The glows' bleeds, above the cages and their mirror, blurred once as a pair. -->
<g filter="url(#melt)">
<g id="worldBleed" clip-path="url(#upper)"><g id="bleeds"></g></g>
<use href="#worldBleed" transform="matrix(1 0 0 -1 0 1080)"/>
</g>
</g>
<rect width="1080" height="1080" fill="url(#vignette)"/>
</svg>
</div>
</div>
</div>
<div class="about">
<p>Six glass cages, each drawn by <code>solids()</code> from <code>kinetic.25d.js</code>. Every cage tumbles a quarter turn while it grows into the place of the one around it, so the last frame is the first and the piece loops with no seam.</p>
</div>
</body>
</html>
cages.js
// Nested cages, in 2.5D. Six cages are nested at the ratio K.
// Over one cycle every cage tumbles a quarter turn (alternate cages the
// other way) while each grows by 1 / K, into the place of the cage around
// it, so the frame at the end is the frame at the start and the clip loops
// with no seam.
//
// The cages are solids() from kinetic.25d.js: one cage shape added six
// times, each placed in seek() by its scale, turn and opacity, then drawn.
// The page has no 3D context. solids() projects every face's corners, drops
// the faces turned away, clips the rest where they pass the eye, sorts them
// far to near and draws each as an SVG path, lit by its angle, with soft
// glows that slide as it turns and a bright rim.
//
// How visible a cage is depends on its size alone, never on which cage it
// is, and solids() writes the same SVG for the same values whatever it drew
// before, so the last frame matches the first.
(function () {
'use strict';
var k = wildflower.kinetic, solids = k.solids;
// Drawn at 1080 by 1080, so the finish's sizes are in those pixels.
k.config({
shutter: 180,
finish: { grain: { amount: 0.05, size: 2, fps: 30 }, bloom: { amount: 0.75, threshold: 0.7, radius: 60 }, aberration: 1.4 },
loop: { hold: 0 },
});
var K = 0.42; // each cage is K of the one around it
var LEVELS = [-1, 0, 1, 2, 3, 4]; // cage n has half size K^(n - p): from flying past the eye to lost in the dark heart
var HEART = 0.3; // the dark heart's radius: a cage smaller than it has faded out
// How visible a cage of half size s is: it comes out of the heart, and goes as it passes the eye.
function visible(s) {
return k.ramp(s, HEART * 1.15, HEART * 1.8, 'sine.inOut') * (1 - k.ramp(s, 3.2, 5.6, 'sine.in'));
}
k.scene('cages', {
init: function () {
var stage = this.stage = solids(this.element.querySelector('#cages'), {
// The frame is rolled an eighth of a turn; a face brushing past the eye thins out.
camera: { distance: 5, focal: 900, roll: 45, near: 0.2, fade: 1.4 },
// Dark glass, midnight to violet to lavender-white by each face's angle; the bright color comes from the glows and rims.
light: { dir: [-0.35, 0.45, 0.85], ramp: ['#04040F', '#0E0D36', '#1F1D78', '#3C39C4', '#7F6CF5', '#E2E0FF'], gamma: 2.4, floor: 0.03, gain: 0.7, across: [0.22, 0.2] },
rim: { color: '#EAF0FF', width: 2, base: 0.2, edge: 0.45, power: 1.5 },
// Each glow's gradient, and its bleed's, is one of the page's own.
glows: [
{ dir: [-0.35, 0.45, 0.85], gradient: 'gK', size: 0.55, strength: 0.9, sharp: 8, bleed: { gradient: 'bK', size: 1.4, strength: 0.1, cap: 90 } }, // the key's white sheen
{ dir: [-0.85, 0.3, 0.45], gradient: 'gM', size: 0.85, strength: 1.2, sharp: 1.2, bleed: { gradient: 'bM', size: 1.4, strength: 0.1, cap: 90 } }, // magenta from the left
{ dir: [0.7, -0.4, 0.55], gradient: 'gC', size: 0.85, strength: 1.2, sharp: 1.2, bleed: { gradient: 'bC', size: 1.4, strength: 0.1, cap: 90 } }, // electric blue from below right
],
});
// solids() keeps each light's bleeds in a group of its own, after
// its defs and its faces. Here they are moved to a group the page
// blurs (index.html, #melt), which solids() has no option for;
// draw() goes on writing to the same elements. This relies on the
// order of the groups solids() makes, which its API does not
// promise, and dispose() would leave the moved groups behind.
var drawn = this.element.querySelector('#cages > g[data-solids]');
var bleeds = this.element.querySelector('#bleeds');
Array.prototype.slice.call(drawn.children, 2).forEach(function (layer) { bleeds.appendChild(layer); });
var cage = solids.cage({ size: 2, beam: 0.24 });
LEVELS.forEach(function (n) { stage.add('cage' + n, cage); });
},
seek: function (t) {
var stage = this.stage;
if (!stage) return;
// One eased move, a quarter turn and a step of K deeper, with a
// half-second rest across the loop point (0.25 s at each end) so
// the nested shape reads.
var p = k.ramp(t, 0.25, 1.85, 'sine.inOut');
LEVELS.forEach(function (n) {
var size = Math.pow(K, n - p);
stage.set('cage' + n, { scale: size, rotate: [(n % 2 ? -90 : 90) * p, 0, 0], opacity: visible(size) });
});
stage.draw();
},
});
})();