Skip to example

vgpu sequences and stagger

An example of sequencing vgpu scene nodes alongside DOM elements and staggering an array of nodes with animate() and vgpuEffect in Motion.

JavaScript

Source code

<div class="stage">
    <h2 class="title">Sequenced with the DOM</h2>
    <canvas></canvas>
</div>

<script type="module">
    import { animate, frame, stagger } from "motion"
    import { vgpuEffect } from "motion/vgpu"
    import {
        draw,
        effect,
        frame as vgpuFrame,
        geometry,
        init,
        sampler,
        surface,
        target,
    } from "vgpu"
    import {
        box,
        directionalLight,
        mesh,
        perspectiveCamera,
        srgb,
        unlitMaterial,
    } from "vgpu/scene"

    animate.addEffect(vgpuEffect)

    const token = (name) =>
        getComputedStyle(document.body).getPropertyValue(name).trim()

    const stage = document.querySelector(".stage")
    const canvas = document.querySelector("canvas")

    function runSequenceMotion(nodes) {
        animate([
            [".title", { opacity: [0, 1], y: [16, 0] }, { duration: 0.5 }],
            [
                nodes,
                { y: [-3, 0], rotateY: [0, 360], scale: [0.6, 1] },
                {
                    at: "-0.2",
                    delay: stagger(0.08),
                    type: "spring",
                    visualDuration: 0.7,
                    bounce: 0.3,
                },
            ],
        ])
    }

    const shader = `
        struct Camera { viewProjection: mat4x4f }
        struct Model { model: mat4x4f }
        struct Material { color: vec3f }
        struct Light { direction: vec3f, intensity: f32 }

        @group(0) @binding(0) var<uniform> camera: Camera;
        @group(0) @binding(1) var<uniform> model: Model;
        @group(0) @binding(2) var<uniform> material: Material;
        @group(0) @binding(3) var<uniform> light: Light;

        struct VertexOut {
            @builtin(position) position: vec4f,
            @location(0) normal: vec3f,
        }

        @vertex
        fn vs_main(
            @location(0) position: vec3f,
            @location(1) normal: vec3f
        ) -> VertexOut {
            var out: VertexOut;
            out.position = camera.viewProjection * model.model * vec4f(position, 1.0);
            out.normal = (model.model * vec4f(normal, 0.0)).xyz;
            return out;
        }

        @fragment
        fn fs_main(@location(0) normal: vec3f) -> @location(0) vec4f {
            let n = normalize(normal);
            let l = normalize(-light.direction);
            let diffuse = max(dot(n, l), 0.0) * light.intensity;
            let shade = material.color * (0.18 + diffuse);
            return vec4f(pow(shade, vec3f(1.0 / 2.2)), 1.0);
        }
    `

    const presentShader = `
        @group(0) @binding(0) var scene: texture_2d<f32>;
        @group(0) @binding(1) var sceneSampler: sampler;

        @fragment
        fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
            return textureSampleLevel(scene, sceneSampler, uv, 0.0);
        }
    `

    async function start() {
        if (!navigator.gpu) {
            throw new Error("This example needs a browser with WebGPU")
        }

        const gpu = await init()

        /**
         * A layout-backed surface measures the canvas every frame. DOM
         * elements animate in the same frames here, so that read would force
         * layout after every style write. Size it from a ResizeObserver
         * instead, which reports sizes without a forced reflow.
         */
        const canvasSurface = surface(gpu, canvas, {
            dpr: [1, 2],
            autoResize: false,
        })
        const sceneTarget = target(gpu, {
            size: canvasSurface.size,
            depth: true,
        })
        new ResizeObserver(([entry]) => {
            const { inlineSize, blockSize } = entry.contentBoxSize[0]
            const size = [
                Math.round(inlineSize * canvasSurface.dpr),
                Math.round(blockSize * canvasSurface.dpr),
            ]
            canvasSurface.resize(size)
            sceneTarget.resize(size)
        }).observe(canvas)

        const cubeGeometry = geometry(gpu, box({ size: 1 }))

        const light = directionalLight({ direction: [-1, -2, -1], intensity: 1.2 })
        const camera = perspectiveCamera({
            fov: 32,
            aspect: canvasSurface.size[0] / canvasSurface.size[1],
            position: [0, 1.5, 8],
            target: [0, 0, 0],
        })

        /**
         * A row of cubes. Each mesh node pairs with its own draw so the
         * shader can read a different model matrix per cube.
         */
        const cubes = [1, 2, 3, 4, 5].map((hue, i) => {
            const material = unlitMaterial({ color: srgb(token(`--hue-${hue}`)) })
            const node = mesh(box({ size: 0.9 }), material, {
                position: [(i - 2) * 1.2, -3, 0],
            })
            const cubeDraw = draw(gpu, {
                shader,
                geometry: cubeGeometry,
                cull: "back",
            })
            return { node, material, draw: cubeDraw }
        })
        const nodes = cubes.map((cube) => cube.node)
        const present = effect(gpu, presentShader, {
            set: {
                scene: sceneTarget,
                sceneSampler: sampler(gpu, {
                    minFilter: "linear",
                    magFilter: "linear",
                }),
            },
        })

        canvasSurface.onResize(({ width, height }) => {
            camera.set({ aspect: width / height })
        })

        function render() {
            for (const { node, material, draw } of cubes) {
                draw.set({
                    camera: { viewProjection: camera.viewProjection },
                    model: { model: node.worldMatrix },
                    material: { color: material.color },
                    light: { direction: light.direction, intensity: light.intensity },
                })
            }

            vgpuFrame(gpu, (currentFrame) => {
                currentFrame.pass(
                    {
                        target: sceneTarget,
                        clear: [0.043, 0.063, 0.067, 1],
                        clearDepth: 1,
                    },
                    (pass) => cubes.forEach((cube) => pass.draw(cube.draw))
                )
                currentFrame.pass(canvasSurface, present)
            })
        }

        frame.render(render, true)

        runSequenceMotion(nodes)
    }

    start().catch((error) => {
        stage.innerHTML = `<p class="hint">${error.message}</p>`
    })
</script>

<style>
    .stage {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 16px;
        width: 480px;
        max-width: 100vw;
    }

    .title {
        margin: 0;
        font-size: 28px;
        letter-spacing: -0.02em;
        text-wrap: balance;
        opacity: 0;
    }

    .stage canvas {
        display: block;
        width: 100%;
        aspect-ratio: 3 / 2;
    }

    .hint {
        margin: 0;
        font-family: var(--font-mono);
        font-size: 11px;
        letter-spacing: 0.1em;
        text-transform: uppercase;
        color: var(--white-feint);
    }
</style>