<div class="stage">
<canvas aria-label="A vgpu torus knot controlled by Motion values"></canvas>
</div>
<script type="module">
import { animate, frame, motionValue } from "motion"
import { vgpuEffect } from "motion/vgpu"
import {
draw,
effect,
frame as vgpuFrame,
geometry,
init,
sampler,
surface,
target,
uniforms,
} from "vgpu"
import { group, perspectiveCamera } from "vgpu/scene"
animate.addEffect(vgpuEffect)
const stage = document.querySelector(".stage")
const canvas = document.querySelector("canvas")
function runMotionValues(node, material) {
/**
* One motion value drives the node position. Two derived values fan it
* out to the rotation and a shader uniform, so everything moves together.
*/
const x = motionValue(0)
const rotateY = motionValue(0)
const progress = motionValue(0.5)
x.on("change", (latest) => {
rotateY.set(latest * 40)
progress.set((latest + 1.5) / 3)
})
vgpuEffect(node, { x, rotateY })
vgpuEffect(material, { progress })
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect()
const target = ((event.clientX - rect.left) / rect.width - 0.5) * 3
animate(x, target, {
type: "spring",
stiffness: 160,
damping: 20,
})
})
}
function add(a, b) {
return a.map((value, index) => value + b[index])
}
function subtract(a, b) {
return a.map((value, index) => value - b[index])
}
function scale(vector, amount) {
return vector.map((value) => value * amount)
}
function cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
function normalize(vector) {
const length = Math.hypot(...vector)
return vector.map((value) => value / length)
}
function torusKnotPoint(u, radius = 0.8) {
const p = 2
const q = 3
const contour = (2 + Math.cos((q / p) * u)) * 0.5
return [
radius * contour * Math.cos(u),
radius * contour * Math.sin(u),
radius * Math.sin((q / p) * u) * 0.5,
]
}
function torusKnotGeometry({
tube = 0.26,
tubularSegments = 160,
radialSegments = 24,
} = {}) {
const vertices = []
const indices = []
for (let i = 0; i <= tubularSegments; i++) {
const u = (i / tubularSegments) * Math.PI * 4
const point = torusKnotPoint(u)
const nextPoint = torusKnotPoint(u + 0.01)
const tangent = subtract(nextPoint, point)
let normal = add(nextPoint, point)
const binormal = normalize(cross(tangent, normal))
normal = normalize(cross(binormal, tangent))
for (let j = 0; j <= radialSegments; j++) {
const angle = (j / radialSegments) * Math.PI * 2
const offset = add(
scale(normal, -tube * Math.cos(angle)),
scale(binormal, tube * Math.sin(angle))
)
const position = add(point, offset)
vertices.push(...position, ...normalize(offset))
}
}
const row = radialSegments + 1
for (let i = 1; i <= tubularSegments; i++) {
for (let j = 1; j <= radialSegments; j++) {
const a = row * (i - 1) + (j - 1)
const b = row * i + (j - 1)
const c = row * i + j
const d = row * (i - 1) + j
indices.push(a, b, d, b, c, d)
}
}
return {
buffers: [
{
attributes: {
position: "float32x3",
normal: "float32x3",
},
data: new Float32Array(vertices),
},
],
indices: new Uint32Array(indices),
}
}
const objectShader = `
struct Camera {
viewProjection: mat4x4f,
}
struct Model {
model: mat4x4f,
}
struct Material {
progress: 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,
}
@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 pink = vec3f(1.0, 0.0, 0.53);
let cyan = vec3f(0.05, 0.86, 0.97);
let light =
0.55 +
0.45 *
dot(normalize(normal), normalize(vec3f(0.4, 0.8, 1.0)));
let color = mix(pink, cyan, material.progress) * light;
return vec4f(color, 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 color = textureSampleLevel(scene, sceneSampler, uv, 0.0);
return vec4f(linearToSrgb(color.rgb), color.a);
}
`
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: 35,
aspect: canvasSurface.size[0] / canvasSurface.size[1],
position: [0, 0, 6],
target: [0, 0, 0],
})
const knotGeometry = geometry(gpu, torusKnotGeometry())
const node = group({
rotation: [Math.PI * 0.12, -0.2, 0.16],
})
const model = uniforms(gpu, { model: node.worldMatrix })
const material = uniforms(gpu, { progress: 0.5 })
const knotDraw = draw(gpu, {
shader: objectShader,
geometry: knotGeometry,
cull: "back",
set: {
camera: { viewProjection: camera.viewProjection },
model,
material,
},
})
runMotionValues(node, material)
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 })
knotDraw.set({
camera: { viewProjection: camera.viewProjection },
})
})
function render() {
model.set({ model: node.worldMatrix })
vgpuFrame(gpu, (currentFrame) => {
currentFrame.pass(
{
target: sceneTarget,
clear: [0, 0, 0, 0],
clearDepth: 1,
},
(pass) => pass.draw(knotDraw)
)
currentFrame.pass(canvasSurface, present)
})
}
frame.render(render, true)
}
start().catch((error) => {
stage.textContent = error.message
})
</script>
<style>
.stage {
width: 480px;
max-width: 100vw;
}
.stage canvas {
display: block;
width: 100%;
aspect-ratio: 3 / 2;
cursor: crosshair;
}
</style>