<div class="stage">
<canvas aria-label="Hold to tighten the rainbow particle cloud"></canvas>
<output class="speedometer" aria-label="Animation speed">1.00×</output>
</div>
<script type="module">
import { animate, frame, motionValue, press, springValue } from "motion"
import { vgpuEffect } from "motion/vgpu"
import { draw, frame as vgpuFrame, init, surface, uniforms } from "vgpu"
animate.addEffect(vgpuEffect)
const stage = document.querySelector(".stage")
const canvas = document.querySelector("canvas")
const speedometer = document.querySelector(".speedometer")
function runSharedUniforms(gpu, canvasSurface, globals, particles) {
const speedTarget = motionValue(1)
const speed = springValue(speedTarget, { stiffness: 80, damping: 20 })
const tiltTarget = motionValue(0.88)
const tilt = springValue(tiltTarget, { stiffness: 80, damping: 20 })
const spreadTarget = motionValue(1)
const spread = springValue(spreadTarget, { stiffness: 140, damping: 22 })
let elapsed = 0
let displayedSpeed = ""
canvasSurface.onResize(({ width, height }) => {
globals.set({ resolution: [width, height] })
})
let isReady = false
function render({ delta }) {
const currentSpeed = speed.get()
elapsed += (Math.min(delta, 40) / 1000) * currentSpeed
globals.set({
time: elapsed,
tilt: tilt.get(),
spread: spread.get(),
})
const nextDisplayedSpeed = currentSpeed.toFixed(2) + "×"
if (nextDisplayedSpeed !== displayedSpeed) {
displayedSpeed = nextDisplayedSpeed
speedometer.textContent = displayedSpeed
}
vgpuFrame(gpu, (currentFrame) => {
currentFrame.pass(
{ target: canvasSurface, clear: [0, 0, 0, 1] },
(pass) => {
pass.draw(particles.dust)
pass.draw(particles.core)
},
)
})
if (!isReady) {
isReady = true
canvas.classList.add("is-ready")
}
}
frame.render(render, true)
animate(
globals,
{ intensity: [0.75, 1] },
{ duration: 1.8, ease: "easeOut" },
)
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect()
const xProgress = (event.clientX - rect.left) / rect.width
const yProgress = (event.clientY - rect.top) / rect.height
speedTarget.set(0.15 + xProgress * 2.85)
tiltTarget.set(0.35 + yProgress * 1.05)
})
press(canvas, () => {
spreadTarget.set(0.18)
return () => spreadTarget.set(1)
})
}
const globalsStruct = `
struct Globals {
time: f32,
intensity: f32,
resolution: vec2f,
tilt: f32,
spread: f32,
}
@group(0) @binding(0) var<uniform> globals: Globals;
`
const particleShader = `
${globalsStruct}
override PARTICLE_SIZE: f32 = 1.0;
override CLOUD_WIDTH: f32 = 1.0;
override OPACITY: f32 = 1.0;
override SEED_OFFSET: f32 = 0.0;
fn palette(t: f32) -> vec3f {
let phase = vec3f(0.08, 0.42, 0.76);
return 0.5 + 0.5 * cos(6.283185 * (t + phase));
}
fn hash11(value: f32) -> f32 {
return fract(sin(value * 12.9898) * 43758.5453);
}
fn centeredRandom(id: f32, offset: f32) -> f32 {
return (
hash11(id + offset) +
hash11(id + offset + 19.19) +
hash11(id + offset + 41.73) -
1.5
) / 1.5;
}
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) local: vec2f,
@location(1) colour: vec3f,
@location(2) alpha: f32,
}
@vertex
fn vs_main(
@builtin(vertex_index) vertexIndex: u32,
@builtin(instance_index) instanceIndex: u32
) -> VertexOut {
let corners = array<vec2f, 6>(
vec2f(-1.0, -1.0),
vec2f(1.0, -1.0),
vec2f(-1.0, 1.0),
vec2f(-1.0, 1.0),
vec2f(1.0, -1.0),
vec2f(1.0, 1.0)
);
let id = f32(instanceIndex) + SEED_OFFSET;
let seed = hash11(id + 1.0);
let speed = 0.24 + hash11(id + 2.0) * 0.18;
let phase = seed * 6.283185 + globals.time * speed;
let radialOffset = centeredRandom(id, 3.0);
let axialOffset = centeredRandom(id, 7.0);
let tiltOffset = centeredRandom(id, 11.0);
let rotationOffset = centeredRandom(id, 15.0);
let radius =
0.72 +
radialOffset * 0.16 * CLOUD_WIDTH * globals.spread;
let axial =
axialOffset * 0.2 * CLOUD_WIDTH * globals.spread;
let angle = phase + centeredRandom(id, 17.0) * 0.04;
let tilt = globals.tilt + tiltOffset * 0.18;
let cylinder = vec3f(
cos(angle) * radius,
sin(angle) * radius,
axial
);
let tilted = vec3f(
cylinder.x,
cylinder.y * cos(tilt) + cylinder.z * sin(tilt),
-cylinder.y * sin(tilt) + cylinder.z * cos(tilt)
);
let rotation = -0.24 + rotationOffset * 0.16;
let rotated = vec2f(
tilted.x * cos(rotation) - tilted.y * sin(rotation),
tilted.x * sin(rotation) + tilted.y * cos(rotation)
);
let near = clamp(0.5 + tilted.z * 0.68, 0.0, 1.0);
let perspective = 1.0 / (1.0 - tilted.z * 0.48);
let aspect = globals.resolution.x / globals.resolution.y;
let center = vec2f(0.04, -0.01);
let particleCenter =
vec2f(rotated.x / aspect, rotated.y) * perspective + center;
let depthScale = mix(0.65, 1.9, near);
let viewportScale = clamp(
min(globals.resolution.x, globals.resolution.y) / 700.0,
0.8,
2.75
);
let size =
(0.6 + hash11(id + 5.0) * 0.8) *
PARTICLE_SIZE *
depthScale *
viewportScale;
let corner = corners[vertexIndex];
let pixelSize = vec2f(
2.0 / globals.resolution.x,
2.0 / globals.resolution.y
);
var out: VertexOut;
out.position = vec4f(
particleCenter + corner * pixelSize * size,
0.0,
1.0
);
out.local = corner;
out.colour = palette(
phase / 6.283185 + seed * 0.08 + globals.time * 0.012
);
out.alpha =
OPACITY * mix(0.38, 1.0, near) * globals.intensity;
return out;
}
@fragment
fn fs_main(
@location(0) local: vec2f,
@location(1) colour: vec3f,
@location(2) alpha: f32
) -> @location(0) vec4f {
let distance = length(local);
if (distance > 1.0) {
discard;
}
let point = 1.0 - smoothstep(0.35, 1.0, distance);
let redDominance = smoothstep(
0.05,
0.55,
colour.r - max(colour.g, colour.b)
);
let brightness = 1.16 + redDominance * 0.52;
let brightColour =
colour * brightness + vec3f(redDominance * 0.12, 0.0, 0.0);
return vec4f(brightColour * point * alpha, point * alpha);
}
`
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 globals = uniforms(gpu, {
time: 0,
intensity: 0.75,
resolution: canvasSurface.size,
tilt: 0.88,
spread: 1,
})
const dust = draw(gpu, {
shader: particleShader,
instances: 2_500,
vertices: 6,
blend: "additive",
set: { globals },
constants: {
PARTICLE_SIZE: 1.3,
CLOUD_WIDTH: 1.5,
OPACITY: 0.12,
SEED_OFFSET: 20_000,
},
})
const core = draw(gpu, {
shader: particleShader,
instances: 9_000,
vertices: 6,
blend: "additive",
set: { globals },
})
runSharedUniforms(gpu, canvasSurface, globals, { dust, core })
}
start().catch((error) => {
console.error(error)
stage.querySelector(".hint").textContent = "WebGPU could not start"
})
</script>
<style>
#sandbox {
align-items: stretch;
}
#example-container {
display: flex;
flex: 1;
width: 100%;
min-height: 0;
}
.stage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--black);
}
.stage canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
opacity: 0;
cursor: ew-resize;
touch-action: none;
}
.stage canvas.is-ready {
opacity: 1;
}
.speedometer {
position: absolute;
bottom: 24px;
left: 24px;
margin: 0;
pointer-events: none;
font-family: var(--font-mono);
font-size: 15px;
font-variant-numeric: tabular-nums;
color: var(--white);
}
.hint {
position: absolute;
right: 0;
bottom: 28px;
left: 0;
margin: 0;
pointer-events: none;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--white-feint);
}
</style>