<div class="stage">
<canvas aria-label="A refractive lens moving over a photograph"></canvas>
</div>
<script type="module">
import { frame, motionValue, springValue, transformValue } from "motion"
import { vgpuEffect } from "motion/vgpu"
import { effect, frame as vgpuFrame, init, sampler, surface } from "vgpu"
const PHOTO_URL = "/photos/japan/4.jpg"
const stage = document.querySelector(".stage")
const canvas = document.querySelector("canvas")
function runLensMotion(gpu, canvasSurface, lens, imageAspect) {
const mouseFollowSpring = { stiffness: 120, damping: 20 }
const x = motionValue(0.5)
const y = motionValue(0.46)
const aspect = motionValue(canvasSurface.size[0] / canvasSurface.size[1])
const springX = springValue(x, mouseFollowSpring)
const springY = springValue(y, mouseFollowSpring)
vgpuEffect(lens, {
"params.lens": transformValue(() => springX.get() + " " + springY.get()),
"params.aspect": aspect,
"params.imageAspect": motionValue(imageAspect),
})
canvasSurface.onResize(({ width, height }) => {
aspect.set(width / height)
})
let isReady = false
function render() {
vgpuFrame(gpu, (currentFrame) => {
currentFrame.pass(canvasSurface, lens)
})
if (!isReady) {
isReady = true
canvas.classList.add("is-ready")
}
}
frame.render(render, true)
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect()
x.set((event.clientX - rect.left) / rect.width)
y.set((event.clientY - rect.top) / rect.height)
})
}
const shader = `
struct Params {
lens: vec2f,
aspect: f32,
imageAspect: f32,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var photo: texture_2d<f32>;
@group(0) @binding(2) var samp: sampler;
fn sampleScene(canvasUv: vec2f) -> vec3f {
var imageSize = vec2f(
min(params.aspect * 0.72, 0.96),
0.0
);
imageSize.y = imageSize.x / params.imageAspect;
if (imageSize.y > 0.72) {
imageSize = vec2f(0.72 * params.imageAspect, 0.72);
}
let imageSizeUv = imageSize / vec2f(params.aspect, 1.0);
let imageCenter = vec2f(0.5, 0.46);
let imageMin = imageCenter - imageSizeUv * 0.5;
let uv = (canvasUv - imageMin) / imageSizeUv;
let onPhoto =
uv.x >= 0.0 && uv.x <= 1.0 &&
uv.y >= 0.0 && uv.y <= 1.0;
let texel = textureSampleLevel(
photo,
samp,
clamp(uv, vec2f(0.0), vec2f(1.0)),
0.0
).rgb;
return select(vec3f(1.0), texel, onPhoto);
}
fn refractedUv(
canvasUv: vec2f,
local: vec2f,
ior: f32
) -> vec2f {
let r2 = dot(local, local);
let angle = atan2(local.y, local.x);
let distortion =
sin(angle * 6.0 + r2 * 8.0) * 0.65;
let distorted = local * (1.0 + distortion * r2 * 0.16);
let normal = normalize(vec3f(
distorted,
sqrt(max(0.0, 1.0 - r2))
));
let ray = refract(vec3f(0.0, 0.0, -1.0), normal, 1.0 / ior);
let edgeMagnification =
mix(0.75, 2.4, smoothstep(0.2, 1.0, r2));
return canvasUv +
ray.xy * 0.075 * edgeMagnification /
vec2f(params.aspect, 1.0);
}
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let scale = vec2f(params.aspect, 1.0);
let local = (uv - params.lens) * scale / 0.14;
let r2 = dot(local, local);
let radius = sqrt(r2);
let mask = 1.0 - smoothstep(0.985, 1.0, radius);
let base = sampleScene(uv);
let chromaticAberration = 0.65;
let spread = chromaticAberration * 0.08;
let red = sampleScene(refractedUv(uv, local, 1.14 - spread));
let green = sampleScene(refractedUv(uv, local, 1.14));
let blue = sampleScene(refractedUv(uv, local, 1.14 + spread));
var glass = vec3f(red.r, green.g, blue.b);
let normalZ = sqrt(max(0.0, 1.0 - r2));
let fresnel = pow(1.0 - normalZ, 3.0);
let highlight = pow(max(
dot(
normalize(vec3f(local, normalZ)),
normalize(vec3f(-0.45, -0.65, 1.0))
),
0.0
), 24.0);
glass += vec3f(fresnel * 0.1 + highlight * 0.12);
let color = mix(base, glass, mask);
return vec4f(pow(color, vec3f(1.0 / 2.2)), 1.0);
}
`
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 aspect = bitmap.width / bitmap.height
bitmap.close()
return {
texture,
aspect,
}
}
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 photo = await loadPhoto(gpu, PHOTO_URL)
const lens = effect(gpu, shader, {
set: {
photo: photo.texture,
samp: sampler(gpu, {
minFilter: "linear",
magFilter: "linear",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
}),
},
})
runLensMotion(gpu, canvasSurface, lens, photo.aspect)
}
start().catch((error) => {
console.error(error)
stage.querySelector(".hint").textContent = "WebGPU could not start"
})
</script>
<style>
#sandbox {
align-items: stretch;
background: #fff;
}
#example-container {
display: flex;
flex: 1;
width: 100%;
min-height: 0;
background: #fff;
}
.stage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background: #fff;
}
.stage canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
opacity: 0;
cursor: none;
touch-action: none;
}
.stage canvas.is-ready {
opacity: 1;
}
.hint {
position: absolute;
bottom: clamp(24px, 4vw, 44px);
left: 0;
right: 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(--black);
}
</style>