Home

18. Demoscene · 1991

Dot/square tunnel

Colors and knobs

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

About

Rings recede along Z. Each ring is a square outlined with dots, corners drawn as squares. A 1/z divide is the tunnel. Twist is a spin that grows with depth. Move the pointer to drag the vanishing point.

Move the pointer to shift the vanishing point.

Browser APIs

  • Canvas 2D
  • 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: "#6ee7b7",
    speed: 1,
    twist: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    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 RINGS = 36;
  const SIDES = 4;
  const PER = 7;
  const rings = [];
  for (let i = 0; i < RINGS; i++) rings.push((i / RINGS) * 18 + 0.55);

  let t = 0, lookX = 0, lookY = 0, targetX = 0, targetY = 0, last = 0;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    targetX = (ev.clientX - r.left) / r.width - 0.5;
    targetY = (ev.clientY - r.top) / r.height - 0.5;
  });
  canvas.addEventListener("pointerleave", function () { targetX = 0; targetY = 0; });

  function onSquare(k, n, spin) {
    const u = (k / n) * 4;
    const side = u | 0;
    const f = u - side;
    let x = 0, y = 0;
    if (side === 0) { x = -1 + 2 * f; y = -1; }
    else if (side === 1) { x = 1; y = -1 + 2 * f; }
    else if (side === 2) { x = 1 - 2 * f; y = 1; }
    else { x = -1; y = 1 - 2 * f; }
    const c = Math.cos(spin), s = Math.sin(spin);
    return [x * c - y * s, x * s + y * c];
  }

  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 draw(w, h) {
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);
    const rgb = hexToRgb(PARAMS.color);
    const cx = w * 0.5 + lookX * w * 0.42;
    const cy = h * 0.5 + lookY * h * 0.42;
    const fov = Math.min(w, h) * 0.55;
    const n = SIDES * PER;

    for (let i = 0; i < RINGS; i++) {
      const z = rings[i];
      const spin = t * 0.35 + z * 0.28 * PARAMS.twist;
      const fog = Math.max(0.08, 1 - z / 19);
      const s = (1.15 / z) * fov;
      const size = Math.max(1.2, 5.5 / z);
      ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + fog + ")";
      ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (fog * 0.55) + ")";
      ctx.lineWidth = Math.max(1, 2.2 / z);
      ctx.beginPath();
      for (let k = 0; k <= n; k++) {
        const p = onSquare(k % n, n, spin);
        const x = cx + p[0] * s;
        const y = cy + p[1] * s;
        if (k === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();
      for (let k = 0; k < n; k++) {
        const p = onSquare(k, n, spin);
        const x = cx + p[0] * s;
        const y = cy + p[1] * s;
        const corner = k % PER === 0;
        if (corner) ctx.fillRect(x - size, y - size, size * 2, size * 2);
        else {
          ctx.beginPath();
          ctx.arc(x, y, size * 0.45, 0, Math.PI * 2);
          ctx.fill();
        }
      }
    }
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    lookX += (targetX - lookX) * Math.min(1, dt * 6);
    lookY += (targetY - lookY) * Math.min(1, dt * 6);
    if (!reduced) {
      t += dt * PARAMS.speed;
      const dz = dt * 6.5 * PARAMS.speed;
      for (let i = 0; i < RINGS; i++) {
        rings[i] -= dz;
        if (rings[i] < 0.5) rings[i] += 18;
      }
    }
    resize();
    draw(canvas.width, canvas.height);
    if (!reduced) requestAnimationFrame(frame);
  }

  if (reduced) { resize(); draw(canvas.width, canvas.height); }
  else requestAnimationFrame(frame);
})();