Nested Cages

The code of the Nested Cages demo, 2 files.

Run the demo All demos

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();
        },
    });
})();