labScan Reveal
Scan Reveal
A yellow scanner that grows a block grid out from your cursor, revealing a portrait, a close-up and an X-ray in a halftone scanline. It cuts between shots on its own; click to cut sooner. Inspired by Oakley's Inner Spark campaign; photos by Marlon Alves, Wallace Chuck and Pixabay on Pexels.
Source
"use client";
import { type PointerEvent, useEffect, useRef, useState } from "react";
import { createRenderer, type Renderer } from "./renderer";
import { createReveal } from "./reveal";
import styles from "./scan-reveal.module.css";
import { shots } from "./shots";
const GRID = { columns: 8, rows: 10 };
const MAX_PIXEL_RATIO = 2;
const GROW_INTERVAL = 0.045;
const HOLD = 1.1;
const CUT = 0.18;
async function loadImage(src: string) {
const image = new Image();
image.src = src;
await image.decode();
return image;
}
export function ScanReveal() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointerRef = useRef<{ x: number; y: number } | null>(null);
const skipRef = useRef(false);
const pausedRef = useRef(false);
const [paused, setPaused] = useState(false);
const [shotIndex, setShotIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<"loading" | "ready" | "failed">(
"loading",
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const reveal = createReveal(GRID);
const total = GRID.columns * GRID.rows;
let renderer: Renderer | null = null;
let images: HTMLImageElement[] = [];
let disposed = false;
let frame = 0;
let visible = true;
let last = 0;
let time = 0;
let shot = 0;
let phase: "grow" | "hold" | "cut" = "grow";
let phaseTime = 0;
let growClock = 0;
let wander = { x: 0.5, y: 0.45 };
const cross = { x: 0.5, y: 0.45 };
const resize = () => {
const { width, height } = canvas.getBoundingClientRect();
renderer?.resize(
width,
height,
Math.min(devicePixelRatio, MAX_PIXEL_RATIO),
);
};
function target() {
return pointerRef.current ?? wander;
}
function startShot(index: number) {
shot = index;
setShotIndex(index);
renderer?.setShot(images[index]);
const { x, y } = target();
reveal.reset(x, y);
if (reduceMotion) while (reveal.grow(x, y));
renderer?.setMask(reveal.cells, GRID.columns, GRID.rows);
phase = reduceMotion ? "hold" : "grow";
phaseTime = 0;
}
function step(delta: number) {
if (pausedRef.current && !skipRef.current) return;
phaseTime += delta;
wander = {
x: 0.5 + Math.sin(time * 0.7) * 0.18,
y: 0.45 + Math.cos(time * 0.53) * 0.16,
};
const { x, y } = target();
if (skipRef.current && phase !== "cut") {
skipRef.current = false;
phase = "cut";
phaseTime = 0;
}
if (phase === "grow") {
growClock += delta;
while (growClock > GROW_INTERVAL) {
growClock -= GROW_INTERVAL;
if (!reveal.grow(x, y) || reveal.size > total * 0.82) {
phase = "hold";
phaseTime = 0;
break;
}
}
renderer?.setMask(reveal.cells, GRID.columns, GRID.rows);
} else if (phase === "hold" && phaseTime > HOLD && !reduceMotion) {
phase = "cut";
phaseTime = 0;
} else if (phase === "cut" && phaseTime > CUT) {
startShot((shot + 1) % shots.length);
}
setProgress(reveal.size / total);
}
function tick(now: number) {
const delta = Math.min((now - last) / 1000, 0.1);
last = now;
time += reduceMotion || pausedRef.current ? 0 : delta;
step(delta);
const { zoomFrom, zoomTo, focus } = shots[shot];
const reveal01 = reveal.size / total;
const zoom = zoomFrom + (zoomTo - zoomFrom) * reveal01;
const cut = phase === "cut" ? 1 - phaseTime / CUT : 0;
const aim = target();
const follow = reduceMotion ? 1 : 1 - Math.exp(-delta * 10);
cross.x += (aim.x - cross.x) * follow;
cross.y += (aim.y - cross.y) * follow;
renderer?.draw({
time,
cut: reduceMotion ? 0 : cut,
zoom,
focusX: focus[0],
focusY: focus[1],
crossX: cross.x,
crossY: cross.y,
});
frame = visible ? requestAnimationFrame(tick) : 0;
}
function start() {
if (frame || !renderer) return;
last = performance.now();
frame = requestAnimationFrame(tick);
}
const resizeObserver = new ResizeObserver(resize);
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) start();
});
Promise.all(shots.map(({ image }) => loadImage(image)))
.then((loaded) => {
if (disposed) return;
images = loaded;
renderer = createRenderer(canvas);
if (!renderer) throw new Error("WebGL2 is not available");
resize();
startShot(0);
resizeObserver.observe(canvas);
intersectionObserver.observe(canvas);
setStatus("ready");
start();
})
.catch(() => {
if (!disposed) setStatus("failed");
});
return () => {
disposed = true;
cancelAnimationFrame(frame);
resizeObserver.disconnect();
intersectionObserver.disconnect();
renderer?.dispose();
};
}, []);
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
const rect = event.currentTarget.getBoundingClientRect();
pointerRef.current = {
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height,
};
}
function handlePointerLeave() {
pointerRef.current = null;
}
function togglePaused() {
pausedRef.current = !pausedRef.current;
setPaused(pausedRef.current);
}
const current = shots[shotIndex];
return (
<div
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className={styles.stage}
>
{status === "failed" ? (
// biome-ignore lint/performance/noImgElement: plain fallback when WebGL2 is unavailable
<img src={current.image} alt="" className={styles.fallback} />
) : (
<canvas
ref={canvasRef}
role="img"
aria-label={current.alt}
className={styles.canvas}
data-ready={status === "ready"}
/>
)}
<button
type="button"
className={styles.skip}
onClick={() => {
skipRef.current = true;
}}
>
<span className={styles.srOnly}>Next shot</span>
</button>
<button
type="button"
aria-pressed={paused}
onClick={togglePaused}
className={styles.pause}
>
{paused ? "Play" : "Pause"}
</button>
<div className={styles.hud} aria-hidden="true">
<p className={styles.name}>{current.label}</p>
<p className={styles.log}>
Lab · Scan sequence · {String(shotIndex + 1).padStart(2, "0")}/
{String(shots.length).padStart(2, "0")}
<br />
{progress < 0.82 ? "Loading collection…" : "Locked"}
<br />
Calculating…{" "}
{Math.round(progress * 100)
.toString()
.padStart(3, "0")}
%
</p>
</div>
</div>
);
}
import { fragmentShader, vertexShader } from "./shader";
export type Frame = {
time: number;
cut: number;
zoom: number;
focusX: number;
focusY: number;
crossX: number;
crossY: number;
};
function compile(gl: WebGL2RenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) throw new Error("Could not create shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) ?? "Shader failed to compile");
}
return shader;
}
function link(gl: WebGL2RenderingContext) {
const program = gl.createProgram();
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertexShader));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragmentShader));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) ?? "Program failed to link");
}
return program;
}
function createTexture(
gl: WebGL2RenderingContext,
unit: number,
filter: number,
) {
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
export function createRenderer(canvas: HTMLCanvasElement) {
const gl = canvas.getContext("webgl2", { antialias: false });
if (!gl) return null;
const program = link(gl);
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API, not a React hook
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]),
gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, "aPosition");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const shotTexture = createTexture(gl, 0, gl.LINEAR);
const maskTexture = createTexture(gl, 1, gl.NEAREST);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const uniform = (name: string) => gl.getUniformLocation(program, name);
const uResolution = uniform("uResolution");
const uGrid = uniform("uGrid");
const uTime = uniform("uTime");
const uCut = uniform("uCut");
const uZoom = uniform("uZoom");
const uFocus = uniform("uFocus");
const uCrosshair = uniform("uCrosshair");
gl.uniform1i(uniform("uShot"), 0);
gl.uniform1i(uniform("uMask"), 1);
return {
resize(width: number, height: number, pixelRatio: number) {
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(uResolution, canvas.width, canvas.height);
},
setShot(image: HTMLImageElement) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, shotTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image,
);
},
setMask(cells: Uint8Array, columns: number, rows: number) {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R8,
columns,
rows,
0,
gl.RED,
gl.UNSIGNED_BYTE,
cells,
);
gl.uniform2f(uGrid, columns, rows);
},
draw({ time, cut, zoom, focusX, focusY, crossX, crossY }: Frame) {
gl.uniform1f(uTime, time);
gl.uniform1f(uCut, cut);
gl.uniform1f(uZoom, zoom);
gl.uniform2f(uFocus, focusX, focusY);
gl.uniform2f(uCrosshair, crossX, crossY);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
dispose() {
gl.deleteTexture(shotTexture);
gl.deleteTexture(maskTexture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
},
};
}
export type Renderer = NonNullable<ReturnType<typeof createRenderer>>;
export type Grid = { columns: number; rows: number };
export function createReveal({ columns, rows }: Grid, seed = 1) {
const cells = new Uint8Array(columns * rows);
const order: number[] = [];
let random = seed;
function next() {
random = (random * 16807) % 2147483647;
return random / 2147483647;
}
function neighbors(index: number) {
const x = index % columns;
const y = Math.floor(index / columns);
const result: number[] = [];
if (x > 0) result.push(index - 1);
if (x < columns - 1) result.push(index + 1);
if (y > 0) result.push(index - columns);
if (y < rows - 1) result.push(index + columns);
return result;
}
return {
cells,
get size() {
return order.length;
},
reset(x: number, y: number) {
cells.fill(0);
order.length = 0;
const column = Math.min(
columns - 1,
Math.max(0, Math.floor(x * columns)),
);
const row = Math.min(rows - 1, Math.max(0, Math.floor(y * rows)));
const start = row * columns + column;
cells[start] = 255;
order.push(start);
},
grow(towardX: number, towardY: number) {
const frontier = new Set<number>();
for (const index of order) {
for (const neighbor of neighbors(index)) {
if (!cells[neighbor]) frontier.add(neighbor);
}
}
if (frontier.size === 0) return false;
let best = -1;
let bestScore = Number.POSITIVE_INFINITY;
for (const index of frontier) {
const dx = (index % columns) + 0.5 - towardX * columns;
const dy = Math.floor(index / columns) + 0.5 - towardY * rows;
const score = Math.hypot(dx, dy * 1.2) + next() * 2.2;
if (score < bestScore) {
bestScore = score;
best = index;
}
}
cells[best] = 255;
order.push(best);
return true;
},
shrink() {
const last = order.pop();
if (last === undefined) return false;
cells[last] = 0;
return true;
},
};
}
.stage {
--ink: #f9e600;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
cursor: crosshair;
}
.canvas,
.fallback {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.canvas {
opacity: 0;
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.canvas[data-ready="true"] {
opacity: 1;
}
.fallback {
object-fit: cover;
filter: grayscale(1) sepia(1) saturate(6) hue-rotate(5deg);
}
.skip {
position: absolute;
inset: 0;
border: 0;
background: none;
cursor: crosshair;
}
.skip:focus-visible {
outline: 2px solid var(--ink);
outline-offset: -6px;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.hud {
position: absolute;
inset: auto 0 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
padding: 0 24px 22px;
color: var(--ink);
font:
400 11px / 1.55 var(--font-mono),
monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
pointer-events: none;
}
.name {
margin: 0;
padding: 3px 8px;
background: #000;
font-size: 13px;
letter-spacing: 0.08em;
}
.log {
align-self: flex-start;
margin: 0;
padding: 4px 8px;
background: #000;
font-variant-numeric: tabular-nums;
}
.pause {
position: absolute;
top: 16px;
right: 16px;
min-width: 64px;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--ink);
background: #000;
color: var(--ink);
font:
400 11px / 1 var(--font-mono),
monospace;
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
transition-property: scale, background-color, color;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.pause::before {
content: "";
position: absolute;
inset: -6px;
}
.pause:active {
scale: 0.96;
}
.pause:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
@media (hover: hover) and (pointer: fine) {
.pause:hover {
background: var(--ink);
color: #000;
}
}
export const vertexShader = /* glsl */ `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;
export const fragmentShader = /* glsl */ `#version 300 es
precision highp float;
uniform sampler2D uShot;
uniform sampler2D uMask;
uniform vec2 uResolution;
uniform vec2 uGrid;
uniform float uTime;
uniform float uCut;
uniform float uZoom;
uniform vec2 uFocus;
uniform vec2 uCrosshair;
out vec4 outColor;
const vec3 INK = vec3(0.98, 0.9, 0.0);
const vec3 BLACK = vec3(0.0);
const vec3 LINE = vec3(0.55, 0.5, 0.05);
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
vec2 cover(vec2 uv, float canvasAspect) {
float imageAspect = 0.8;
vec2 scale = canvasAspect > imageAspect
? vec2(1.0, imageAspect / canvasAspect)
: vec2(canvasAspect / imageAspect, 1.0);
return uFocus + (uv - 0.5) * scale / uZoom;
}
void main() {
vec2 frag = gl_FragCoord.xy;
vec2 uv = frag / uResolution;
uv.y = 1.0 - uv.y;
float canvasAspect = uResolution.x / uResolution.y;
float slice = floor(uv.y * 38.0);
float jitter = (hash(vec2(slice, floor(uTime * 24.0))) - 0.5) * uCut;
vec2 shotUv = cover(uv + vec2(jitter * 0.12, 0.0), canvasAspect);
float pitch = max(4.0, floor(uResolution.x / 170.0));
vec2 dotCell = floor(frag / pitch);
vec2 dotLocal = fract(frag / pitch) - 0.5;
vec2 dotUv = cover(((dotCell + 0.5) * pitch / uResolution) * vec2(1.0, -1.0) + vec2(0.0, 1.0) + vec2(jitter * 0.12, 0.0), canvasAspect);
float lum = texture(uShot, shotUv).r;
float dotLum = texture(uShot, dotUv).r;
float split = uCut * 0.012;
float lumR = texture(uShot, shotUv + vec2(split, 0.0)).r;
float lumB = texture(uShot, shotUv - vec2(split, 0.0)).r;
float tone = smoothstep(0.12, 0.62, dotLum);
float radius = sqrt(tone) * 0.62;
float dots = 1.0 - smoothstep(radius - 0.08, radius + 0.08, length(dotLocal));
float solid = smoothstep(0.55, 0.85, lum);
vec3 lit = INK * max(dots * smoothstep(0.02, 0.12, tone), solid);
lit += vec3(lumR - lum, 0.0, lumB - lum) * 1.6;
vec2 cell = floor(uv * uGrid);
float mask = texture(uMask, (cell + 0.5) / uGrid).r;
vec2 local = fract(uv * uGrid);
vec2 px = 1.5 / (uResolution / uGrid);
float leftOpen = texture(uMask, (cell + vec2(-0.5, 0.5)) / uGrid).r;
float rightOpen = texture(uMask, (cell + vec2(1.5, 0.5)) / uGrid).r;
float upOpen = texture(uMask, (cell + vec2(0.5, -0.5)) / uGrid).r;
float downOpen = texture(uMask, (cell + vec2(0.5, 1.5)) / uGrid).r;
float border = mask * max(
max(step(local.x, px.x) * step(leftOpen, 0.5), step(1.0 - px.x, local.x) * step(rightOpen, 0.5)),
max(step(local.y, px.y) * step(upOpen, 0.5), step(1.0 - px.y, local.y) * step(downOpen, 0.5))
);
vec2 guide = abs(fract(uv * uGrid / 2.0 + 0.5) - 0.5) * uResolution / uGrid * 2.0;
float grid = (1.0 - smoothstep(0.0, 1.2, min(guide.x, guide.y))) * 0.35;
vec3 color = mix(LINE * grid, lit, mask);
color = mix(color, INK, border * 0.9);
color += INK * uCut * 0.25 * step(0.6, hash(vec2(slice, floor(uTime * 30.0))));
vec2 toCross = abs(uv - uCrosshair) * uResolution;
float arm = 14.0 * uResolution.y / 900.0;
float thick = max(1.0, uResolution.y / 600.0);
float cross = max(
step(toCross.x, thick) * step(toCross.y, arm),
step(toCross.y, thick) * step(toCross.x, arm)
);
color = mix(color, INK, cross);
outColor = vec4(color, 1.0);
}
`;
export type Shot = {
label: string;
alt: string;
image: string;
focus: [number, number];
zoomFrom: number;
zoomTo: number;
};
const base = "/experiments/scan-reveal";
export const shots: Shot[] = [
{
label: "Subject 01",
alt: "Halftone scan of a man in sunglasses",
image: `${base}/portrait.webp`,
focus: [0.5, 0.48],
zoomFrom: 1.08,
zoomTo: 0.92,
},
{
label: "Detail 02",
alt: "Halftone scan of a close-up of lips",
image: `${base}/closeup.webp`,
focus: [0.5, 0.45],
zoomFrom: 1.2,
zoomTo: 1.0,
},
{
label: "X-ray 03",
alt: "Halftone scan of an X-ray of a hand",
image: `${base}/xray.webp`,
focus: [0.5, 0.5],
zoomFrom: 1.08,
zoomTo: 0.9,
},
];