Skip to example

vgpu cameras, lights and controls

An example of animating a vgpu camera, directional light, material and orbit controls with animate() and vgpuEffect in Motion.

JavaScript

Source code

<div class="stage">
    <canvas></canvas>
    <div class="controls">
        <button data-action="light">Light</button>
        <button data-action="material">Material</button>
        <button data-action="camera">Camera</button>
    </div>
</div>

<script type="module">
    import { animate, frame, press } from "motion"
    import { vgpuEffect } from "motion/vgpu"
    import { clock, draw, frame as vgpuFrame, geometry, init, surface } from "vgpu"
    import {
        box,
        directionalLight,
        mesh,
        orbitControls,
        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")

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

        @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 * light.color;
            let shade = material.color * (vec3f(0.15) + diffuse);
            let encoded = pow(shade, vec3f(1.0 / 2.2));
            return vec4f(encoded * material.opacity, material.opacity);
        }
    `

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

        const gpu = await init()
        const time = clock(gpu)
        const canvasSurface = surface(gpu, canvas, { dpr: [1, 2] })

        const material = unlitMaterial({ color: srgb(token("--hue-3")) })
        const cube = mesh(box({ size: 1.6 }), material)
        const light = directionalLight({
            direction: [-1, -2, -1],
            intensity: 1.2,
            color: [1, 1, 1],
        })
        const camera = perspectiveCamera({
            fov: 40,
            aspect: canvasSurface.size[0] / canvasSurface.size[1],
            position: [2.5, 2, 5],
            target: [0, 0, 0],
        })
        const controls = orbitControls(camera, { element: canvas, damping: 0.1 })

        const cubeDraw = draw(gpu, {
            shader,
            geometry: geometry(gpu, box({ size: 1 })),
            cull: "back",
            blend: "premultiplied",
        })

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

        /**
         * Motion owns the timeline, so it advances vgpu's clock and the orbit
         * controls damping from its update step
         */
        function update({ delta }) {
            time.advance(Math.max(0, delta) / 1000)
            controls.update(time.deltaTime)
        }

        function render() {
            cubeDraw.set({
                camera: { viewProjection: camera.viewProjection },
                model: { model: cube.worldMatrix },
                material: { color: material.color, opacity: material.opacity },
                light: {
                    direction: light.direction,
                    intensity: light.intensity,
                    color: light.color,
                },
            })

            vgpuFrame(gpu, (currentFrame) => {
                currentFrame.pass(
                    { target: canvasSurface, clear: [0.043, 0.063, 0.067, 1] },
                    (pass) => pass.draw(cubeDraw)
                )
            })
        }

        frame.update(update, true)
        frame.render(render, true)

        /**
         * Anything with a readable property and a set() works, with no
         * keyframes needed
         */
        const state = { light: false, material: false, camera: false }
        const actions = {
            light: () =>
                animate(
                    light,
                    state.light
                        ? { directionX: 1, directionZ: 1, intensity: 2, color: token("--hue-0") }
                        : { directionX: -1, directionZ: -1, intensity: 1.2, color: token("--white") },
                    { duration: 1.2, ease: "easeInOut" }
                ),
            material: () =>
                animate(
                    material,
                    state.material
                        ? { color: token("--hue-1"), opacity: 0.65 }
                        : { color: token("--hue-3"), opacity: 1 },
                    { duration: 0.8, ease: "easeInOut" }
                ),
            camera: () => {
                animate(
                    controls,
                    state.camera
                        ? { yaw: controls.yaw + Math.PI, pitch: 0.9, distance: 9 }
                        : { yaw: controls.yaw - Math.PI, pitch: 0.4, distance: 6 },
                    { duration: 1.4, ease: "easeInOut" }
                )
                animate(camera, { fov: state.camera ? 70 : 40 }, { duration: 1.4 })
            },
        }

        stage.querySelectorAll("button").forEach((button) => {
            const action = button.dataset.action
            press(button, () => {
                state[action] = !state[action]
                actions[action]()
            })
        })
    }

    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;
    }

    .stage canvas {
        display: block;
        width: 100%;
        aspect-ratio: 3 / 2;
        cursor: grab;
        touch-action: none;
    }

    .controls {
        display: flex;
        gap: 10px;
    }

    .controls button {
        background-color: var(--hue-1);
        color: var(--white);
        font-size: 15px;
        padding: 10px 20px;
        border-radius: 0;
        border: none;
        cursor: pointer;
    }

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