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

scan-reveal.tsx
"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>
  );
}
renderer.ts
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>>;
reveal.ts
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;
    },
  };
}
scan-reveal.module.css
.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;
  }
}
shader.ts
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);
}
`;
shots.ts
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,
  },
];