Home

22. Trumbull · 1968

Slit-scan stargate

Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Douglas Trumbull's slit-scan: a one-dimensional strip of artwork is sampled while the camera dollies, so each pixel is a different moment. Here the slit is polar. Color streaks recede into a vanishing point. Move the pointer to shove the corridor.

Move the pointer to shift the vanishing point.

Browser APIs

  • Canvas 2D
  • ImageData
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.

Source

(function () {
  const PARAMS = {
    color: "#ff6a2a",
    speed: 1,
    streaks: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [255, 106, 42];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const BW = 280, BH = 160;
  const buf = document.createElement("canvas");
  buf.width = BW; buf.height = BH;
  const bctx = buf.getContext("2d");
  const img = bctx.createImageData(BW, BH);
  const px = img.data;

  let t = 0, lookX = 0, lookY = 0, last = 0;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    lookX = (ev.clientX - r.left) / r.width - 0.5;
    lookY = (ev.clientY - r.top) / r.height - 0.5;
  });

  function slit(u, rgb) {
    const s = (u % 1 + 1) % 1;
    const band = Math.sin(s * Math.PI * 10) * 0.5 + 0.5;
    const cool = s < 0.45;
    if (cool) {
      return [
        20 + band * 40 + rgb[0] * 0.05,
        30 + band * 90,
        80 + band * 160
      ];
    }
    return [
      rgb[0] * (0.35 + band * 0.65),
      rgb[1] * (0.15 + band * 0.5) + band * 80,
      rgb[2] * 0.12 + (1 - band) * 40
    ];
  }

  function draw() {
    const rgb = hexToRgb(PARAMS.color);
    const cx = BW * (0.5 + lookX * 0.35);
    const cy = BH * (0.5 + lookY * 0.35);
    const k = 7.5 * PARAMS.streaks;
    for (let y = 0; y < BH; y++) {
      for (let x = 0; x < BW; x++) {
        const dx = x - cx, dy = y - cy;
        const r = Math.hypot(dx, dy) / BH;
        const a = Math.atan2(dy, dx);
        const u = a / (Math.PI * 2) * 6 + t * 0.15;
        const scan = t * 1.4 * PARAMS.speed - r * k;
        const col = slit(u * 0.2 + scan, rgb);
        const glow = Math.max(0, 1 - r * 1.15);
        const i = (y * BW + x) * 4;
        px[i] = Math.min(255, col[0] * (0.25 + glow) + glow * 40);
        px[i + 1] = Math.min(255, col[1] * (0.25 + glow));
        px[i + 2] = Math.min(255, col[2] * (0.2 + glow * 0.8));
        px[i + 3] = 255;
      }
    }
    bctx.putImageData(img, 0, 0);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) t += dt;
    resize();
    draw();
    if (!reduced) requestAnimationFrame(frame);
  }

  if (reduced) { resize(); draw(); }
  else requestAnimationFrame(frame);
})();