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