<div class="stage">
<canvas
role="button"
tabindex="0"
aria-label="Hold to swirl the image particles into a globe"
></canvas>
</div>
<script type="module">
import {
animate,
frame,
motionValue,
press,
} from "motion"
import { vgpuEffect } from "motion/vgpu"
import {
draw,
effect,
frame as vgpuFrame,
init,
sampler,
surface,
target,
uniforms,
} from "vgpu"
animate.addEffect(vgpuEffect)
const JAPAN_PHOTOS = Array.from(
{ length: 13 },
(_, index) => `/photos/japan/${index + 1}.jpg`
)
const previousPhoto = sessionStorage.getItem("vgpu-effect-photo")
const photoChoices = JAPAN_PHOTOS.filter(
(photo) => photo !== previousPhoto
)
const PHOTO_URL =
photoChoices[Math.floor(Math.random() * photoChoices.length)]
sessionStorage.setItem("vgpu-effect-photo", PHOTO_URL)
const COLUMNS = 200
const stage = document.querySelector(".stage")
const canvas = document.querySelector("canvas")
function runParticleMotion(
gpu,
canvasSurface,
sceneTarget,
params,
particles,
present
) {
const globe = motionValue(1)
let elapsed = 0
vgpuEffect(params, { globe })
animate(globe, 0, {
type: "spring",
stiffness: 55,
damping: 16,
mass: 1,
delay: 0.8,
})
press(canvas, () => {
animate(globe, 1, {
type: "spring",
stiffness: 120,
damping: 18,
mass: 0.9,
})
return () => {
animate(globe, 0, {
type: "spring",
stiffness: 70,
damping: 16,
mass: 1,
})
}
})
canvasSurface.onResize(({ width, height }) => {
sceneTarget.resize([width, height])
params.set({ resolution: [width, height] })
})
function render({ delta }) {
elapsed += Math.min(delta, 40) / 1000
params.set({ time: elapsed })
vgpuFrame(gpu, (currentFrame) => {
currentFrame.pass(
{
target: sceneTarget,
clear: [1, 1, 1, 1],
clearDepth: 1,
},
(pass) => pass.draw(particles)
)
currentFrame.pass(canvasSurface, present)
})
}
frame.render(render, true)
}
const particleShader = `
struct Params {
time: f32,
globe: f32,
imageAspect: f32,
resolution: vec2f,
grid: vec2f,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var photo: texture_2d<f32>;
@group(0) @binding(2) var photoSampler: sampler;
fn hash11(value: f32) -> f32 {
return fract(sin(value * 12.9898) * 43758.5453);
}
struct VertexOut {
@builtin(position) position: vec4f,
@location(0) colour: vec3f,
@location(1) light: 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);
let count = params.grid.x * params.grid.y;
let column = f32(instanceIndex % u32(params.grid.x));
let row = floor(id / params.grid.x);
let imageUv = (vec2f(column, row) + vec2f(0.5)) / params.grid;
let y = 1.0 - (id / max(count - 1.0, 1.0)) * 2.0;
let ringRadius = sqrt(max(0.0, 1.0 - y * y));
let angle = id * 2.39996323;
let sphere = vec3f(
cos(angle) * ringRadius,
y,
sin(angle) * ringRadius
);
let randomA = hash11(id + 7.0);
let randomB = hash11(id + 41.0);
let localSpeed = 0.58 + (randomA - 0.5) * 0.24;
let spin =
params.time * localSpeed + (randomB - 0.5) * 0.24;
let spun = vec3f(
sphere.x * cos(spin) + sphere.z * sin(spin),
sphere.y,
-sphere.x * sin(spin) + sphere.z * cos(spin)
);
let reference = select(
vec3f(0.0, 1.0, 0.0),
vec3f(1.0, 0.0, 0.0),
abs(spun.y) > 0.9
);
let tangent = normalize(cross(reference, spun));
let bitangent = normalize(cross(spun, tangent));
let turbulence =
tangent *
sin(params.time * (1.1 + randomA * 0.7) + randomB * 6.283185) *
0.08 +
bitangent *
cos(params.time * (0.8 + randomB * 0.6) + randomA * 6.283185) *
0.055;
let turbulent = normalize(spun + turbulence);
let tilt = -0.28;
let rotated = vec3f(
turbulent.x,
turbulent.y * cos(tilt) - turbulent.z * sin(tilt),
turbulent.y * sin(tilt) + turbulent.z * cos(tilt)
);
let aspect = params.resolution.x / params.resolution.y;
let imageHalfHeight = min(
0.72,
0.86 * aspect / params.imageAspect
);
let imageHalfWidth =
imageHalfHeight * params.imageAspect / aspect;
let imageCenter = vec2f(
(imageUv.x - 0.5) * imageHalfWidth * 2.0,
(0.5 - imageUv.y) * imageHalfHeight * 2.0
);
let globeRadius = 0.52;
let globeCenter = vec2f(
rotated.x * globeRadius / aspect,
rotated.y * globeRadius
);
let curvedTrajectory =
sin(params.globe * 3.14159265) *
vec2f(
(randomA - 0.5) / aspect,
randomB - 0.5
) *
0.13;
let center =
mix(imageCenter, globeCenter, params.globe) +
curvedTrajectory;
let globeDepth = clamp(
0.5 - rotated.z * 0.24,
0.02,
0.98
);
let particleHalfSize = vec2f(
imageHalfWidth / params.grid.x,
imageHalfHeight / params.grid.y
);
let corner = corners[vertexIndex];
let depth = mix(0.5, globeDepth, params.globe);
let near = clamp(0.5 + rotated.z * 0.5, 0.0, 1.0);
let sampled = textureSampleLevel(
photo,
photoSampler,
imageUv,
0.0
).rgb;
var out: VertexOut;
out.position = vec4f(
center + corner * particleHalfSize * 1.01,
depth,
1.0
);
out.colour = sampled;
out.light = mix(1.0, mix(0.5, 1.45, near), params.globe);
return out;
}
@fragment
fn fs_main(
@location(0) colour: vec3f,
@location(1) light: f32
) -> @location(0) vec4f {
return vec4f(colour * light, 1.0);
}
`
const presentShader = `
struct Params {
time: f32,
globe: f32,
imageAspect: f32,
resolution: vec2f,
grid: vec2f,
}
@group(0) @binding(0) var scene: texture_2d<f32>;
@group(0) @binding(1) var sceneSampler: sampler;
@group(0) @binding(2) var<uniform> params: Params;
@group(0) @binding(3) var photo: texture_2d<f32>;
@group(0) @binding(4) var photoSampler: sampler;
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
var colour = textureSampleLevel(scene, sceneSampler, uv, 0.0);
let aspect = params.resolution.x / params.resolution.y;
let imageHalfHeight = min(
0.72,
0.86 * aspect / params.imageAspect
);
let imageHalfWidth =
imageHalfHeight * params.imageAspect / aspect;
let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
let imageUv = vec2f(
ndc.x / (imageHalfWidth * 2.0) + 0.5,
0.5 - ndc.y / (imageHalfHeight * 2.0)
);
let inside =
imageUv.x >= 0.0 && imageUv.x <= 1.0 &&
imageUv.y >= 0.0 && imageUv.y <= 1.0;
if (inside) {
let highResolution = textureSampleLevel(
photo,
photoSampler,
imageUv,
0.0
);
let highResolutionMix =
1.0 - smoothstep(0.005, 0.035, max(params.globe, 0.0));
colour = mix(colour, highResolution, highResolutionMix);
}
return vec4f(
pow(colour.rgb, vec3f(1.0 / 2.2)),
colour.a
);
}
`
async function loadPhoto(gpu, url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error("Could not load the photograph")
}
const bitmap = await createImageBitmap(await response.blob())
const texture = gpu.device.createTexture({
size: [bitmap.width, bitmap.height],
format: "rgba8unorm-srgb",
usage: ["texture_binding", "copy_dst", "render_attachment"],
})
gpu.gpu.queue.copyExternalImageToTexture(
{ source: bitmap },
{ texture: texture.gpu },
[bitmap.width, bitmap.height]
)
const imageAspect = bitmap.width / bitmap.height
bitmap.close()
return { texture, imageAspect }
}
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,
depth: true,
})
const { texture, imageAspect } = await loadPhoto(gpu, PHOTO_URL)
const rows = Math.round(COLUMNS / imageAspect)
const params = uniforms(gpu, {
time: 0,
globe: 1,
imageAspect,
resolution: canvasSurface.size,
grid: [COLUMNS, rows],
})
const imageSampler = sampler(gpu, {
minFilter: "linear",
magFilter: "linear",
})
const particles = draw(gpu, {
shader: particleShader,
instances: COLUMNS * rows,
vertices: 6,
depth: { write: true, compare: "less" },
set: {
params,
photo: texture,
photoSampler: imageSampler,
},
})
const present = effect(gpu, presentShader, {
set: {
scene: sceneTarget,
sceneSampler: sampler(gpu, {
minFilter: "linear",
magFilter: "linear",
}),
params,
photo: texture,
photoSampler: imageSampler,
},
})
runParticleMotion(
gpu,
canvasSurface,
sceneTarget,
params,
particles,
present
)
}
start().catch((error) => {
console.error(error)
stage.textContent = error.message
})
</script>
<style>
.stage {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: var(--white);
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
}
</style>