<div class="stage">
<canvas aria-label="A kinetic hierarchy of nested vgpu scene nodes"></canvas>
</div>
<script type="module">
import {
animate,
frame,
hover,
motionValue,
press,
springValue,
} from "motion"
import { vgpuEffect } from "motion/vgpu"
import {
draw,
effect,
frame as vgpuFrame,
geometry,
init,
sampler,
surface,
target,
} from "vgpu"
import {
box,
group,
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 runSceneMotion({ root, rings, ringSpecs, entries, core }) {
rings.forEach((ring, index) => {
const spec = ringSpecs[index]
animate(
ring,
{ rotateZ: [spec.tilt[2], spec.tilt[2] + 360 * spec.direction] },
{
duration: spec.duration,
ease: "linear",
repeat: Infinity,
}
)
})
animate(
core,
{ rotateX: [20, 380], rotateY: [35, -325] },
{ duration: 16, ease: "linear", repeat: Infinity }
)
const tiltXTarget = motionValue(-8)
const tiltYTarget = motionValue(10)
const tiltX = springValue(tiltXTarget, {
stiffness: 90,
damping: 22,
})
const tiltY = springValue(tiltYTarget, {
stiffness: 90,
damping: 22,
})
vgpuEffect(root, { rotateX: tiltX, rotateY: tiltY })
canvas.addEventListener("pointermove", (event) => {
const bounds = canvas.getBoundingClientRect()
const x = (event.clientX - bounds.left) / bounds.width
const y = (event.clientY - bounds.top) / bounds.height
tiltXTarget.set(12 - y * 30)
tiltYTarget.set(-20 + x * 40)
})
hover(canvas, () => {
return () => {
tiltXTarget.set(-8)
tiltYTarget.set(10)
}
})
function setContracted(contracted) {
entries.forEach((entry, index) => {
const distance = contracted ? 0.43 : 1
const scale = contracted ? 0.7 : 1
const delay = contracted
? index * 0.006
: (entries.length - index) * 0.004
animate(
entry.node,
{
x: entry.position[0] * distance,
y: entry.position[1] * distance,
z: contracted
? (entry.ringIndex - 1) * 0.2
: entry.position[2],
rotateZ:
entry.rotation[2] +
(contracted ? (entry.nodeIndex % 2 ? -28 : 28) : 0),
scaleX: entry.scale[0] * scale,
scaleY: entry.scale[1] * scale,
scaleZ: entry.scale[2] * scale,
},
contracted
? {
duration: 0.45,
ease: [0.32, 0.72, 0, 1],
delay,
}
: {
type: "spring",
visualDuration: 0.75,
bounce: 0.24,
delay,
}
)
})
animate(
core,
{
scaleX: contracted ? 0.58 : 0.34,
scaleY: contracted ? 0.58 : 0.34,
scaleZ: contracted ? 0.58 : 0.34,
},
{
type: "spring",
visualDuration: 0.55,
bounce: 0.3,
}
)
}
press(canvas, () => {
setContracted(true)
return () => setContracted(false)
})
}
const objectShader = `
struct Camera {
viewProjection: mat4x4f,
position: vec3f,
}
struct Model {
model: mat4x4f,
}
struct Material {
color: vec3f,
glow: 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;
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) normal: vec3f,
@location(1) worldPosition: vec3f,
}
@vertex
fn vs_main(
@location(0) position: vec3f,
@location(1) normal: vec3f
) -> VertexOut {
let world = model.model * vec4f(position, 1.0);
var out: VertexOut;
out.position = camera.viewProjection * world;
out.normal = (model.model * vec4f(normal, 0.0)).xyz;
out.worldPosition = world.xyz;
return out;
}
@fragment
fn fs_main(
@location(0) normal: vec3f,
@location(1) worldPosition: vec3f
) -> @location(0) vec4f {
let n = normalize(normal);
let view = normalize(camera.position - worldPosition);
let key = max(dot(n, normalize(vec3f(-0.45, 0.8, 0.55))), 0.0);
let fill = max(dot(n, normalize(vec3f(0.7, -0.2, 0.4))), 0.0);
let rim = pow(1.0 - max(dot(n, view), 0.0), 2.5);
let light = 0.16 + key * 1.2 + fill * 0.28 + rim * material.glow;
return vec4f(material.color * light, 1.0);
}
`
const presentShader = `
@group(0) @binding(0) var scene: texture_2d<f32>;
@group(0) @binding(1) var sceneSampler: sampler;
fn linearToSrgb(color: vec3f) -> vec3f {
let x = max(color, vec3f(0.0));
return select(
1.055 * pow(x, vec3f(1.0 / 2.4)) - 0.055,
x * 12.92,
x <= vec3f(0.0031308)
);
}
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let sceneColor = textureSampleLevel(scene, sceneSampler, uv, 0.0);
let backdrop = vec3f(0.00335, 0.00518, 0.00605);
let lit = 1.0 - exp(-sceneColor.rgb * 1.18);
let color = mix(backdrop, lit, sceneColor.a);
return vec4f(linearToSrgb(color), 1.0);
}
`
function degrees(value) {
return (value * Math.PI) / 180
}
async function start() {
if (!navigator.gpu) {
throw new Error("This example needs a browser with WebGPU")
}
const gpu = await init()
const canvasSurface = surface(gpu, canvas, { dpr: [1, 2] })
const sceneTarget = target(gpu, {
size: canvasSurface.size,
format: "rgba16float",
depth: true,
})
const camera = perspectiveCamera({
fov: 42,
aspect: canvasSurface.size[0] / canvasSurface.size[1],
position: [0, 0.35, 8.4],
target: [0, 0, 0],
})
const moduleShape = box({ size: 1 })
const moduleGeometry = geometry(gpu, moduleShape)
const materials = [
unlitMaterial({ color: srgb(token("--hue-0")) }),
unlitMaterial({ color: srgb(token("--hue-1")) }),
unlitMaterial({ color: srgb(token("--hue-5")) }),
]
const coreMaterial = unlitMaterial({ color: [1, 1, 1] })
const root = group({ label: "kinetic-root" })
const entries = []
const ringSpecs = [
{
count: 20,
radius: 2.55,
tilt: [58, 10, 0],
scale: [0.54, 0.1, 0.13],
duration: 24,
direction: 1,
},
{
count: 16,
radius: 1.85,
tilt: [-48, 38, 0],
scale: [0.44, 0.095, 0.11],
duration: 18,
direction: -1,
},
{
count: 12,
radius: 1.18,
tilt: [24, -58, 0],
scale: [0.34, 0.085, 0.1],
duration: 13,
direction: 1,
},
]
const rings = ringSpecs.map((spec, ringIndex) => {
const ring = group({
label: `ring-${ringIndex + 1}`,
rotation: spec.tilt.map(degrees),
})
const nodes = Array.from({ length: spec.count }, (_, nodeIndex) => {
const angle = (nodeIndex / spec.count) * Math.PI * 2
const position = [
Math.cos(angle) * spec.radius,
Math.sin(angle) * spec.radius,
0,
]
const rotation = [0, 0, angle + Math.PI / 2]
const node = mesh(moduleShape, materials[ringIndex], {
label: `ring-${ringIndex + 1}-module-${nodeIndex + 1}`,
position,
rotation,
scale: spec.scale,
})
entries.push({
node,
position,
rotation: rotation.map((value) => value / degrees(1)),
scale: spec.scale,
ringIndex,
nodeIndex,
})
return node
})
ring.add(...nodes)
return ring
})
root.add(...rings)
const core = mesh(moduleShape, coreMaterial, {
label: "core",
scale: [0.34, 0.34, 0.34],
rotation: [degrees(20), degrees(35), degrees(10)],
})
root.add(core)
runSceneMotion({
root,
rings,
ringSpecs,
entries,
core,
})
const moduleDraws = entries.map(() =>
draw(gpu, {
shader: objectShader,
geometry: moduleGeometry,
cull: "back",
})
)
const coreDraw = draw(gpu, {
shader: objectShader,
geometry: moduleGeometry,
cull: "back",
})
const present = effect(gpu, presentShader, {
set: {
scene: sceneTarget,
sceneSampler: sampler(gpu, {
minFilter: "linear",
magFilter: "linear",
}),
},
})
canvasSurface.onResize(({ width, height }) => {
sceneTarget.resize([width, height])
camera.set({ aspect: width / height })
})
function render() {
vgpuFrame(gpu, (currentFrame) => {
currentFrame.pass(
{
target: sceneTarget,
clear: [0, 0, 0, 0],
clearDepth: 1,
},
(pass) => {
entries.forEach((entry, index) => {
const currentDraw = moduleDraws[index]
currentDraw.set({
camera: {
viewProjection: camera.viewProjection,
position: camera.position,
},
model: { model: entry.node.worldMatrix },
material: {
color: entry.node.material.color,
glow: 1.25,
},
})
pass.draw(currentDraw)
})
coreDraw.set({
camera: {
viewProjection: camera.viewProjection,
position: camera.position,
},
model: { model: core.worldMatrix },
material: {
color: core.material.color,
glow: 1.8,
},
})
pass.draw(coreDraw)
}
)
currentFrame.pass(canvasSurface, present)
})
}
frame.render(render, true)
}
start().catch((error) => {
stage.textContent = error.message
})
</script>
<style>
.stage {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--black);
}
.stage canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
}
</style>