{"slug":"tunnel","number":"18","title":"Dot/square tunnel","era":"Demoscene · 1991","summary":"A twisting square tunnel of dots. Pointer shifts the vanishing point.","description":"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.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["demoscene","3d"],"hasGlsl":false,"interaction":"Move the pointer to shift the vanishing point.","url":"https://3d-retro.com/experiments/tunnel","example_url":"https://3d-retro.com/examples/tunnel.html","api_url":"https://3d-retro.com/api/v1/experiments/tunnel","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"A twisting square tunnel of dots. Pointer shifts the vanishing point.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/tunnel\" />\n  <title>Dot/square tunnel · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0c0e; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #0b0c0e; }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    speed: 1,\n    twist: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const RINGS = 36;\n  const SIDES = 4;\n  const PER = 7;\n  const rings = [];\n  for (let i = 0; i < RINGS; i++) rings.push((i / RINGS) * 18 + 0.55);\n\n  let t = 0, lookX = 0, lookY = 0, targetX = 0, targetY = 0, last = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    targetX = (ev.clientX - r.left) / r.width - 0.5;\n    targetY = (ev.clientY - r.top) / r.height - 0.5;\n  });\n  canvas.addEventListener(\"pointerleave\", function () { targetX = 0; targetY = 0; });\n\n  function onSquare(k, n, spin) {\n    const u = (k / n) * 4;\n    const side = u | 0;\n    const f = u - side;\n    let x = 0, y = 0;\n    if (side === 0) { x = -1 + 2 * f; y = -1; }\n    else if (side === 1) { x = 1; y = -1 + 2 * f; }\n    else if (side === 2) { x = 1 - 2 * f; y = 1; }\n    else { x = -1; y = 1 - 2 * f; }\n    const c = Math.cos(spin), s = Math.sin(spin);\n    return [x * c - y * s, x * s + y * c];\n  }\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n  }\n\n  function draw(w, h) {\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n    const rgb = hexToRgb(PARAMS.color);\n    const cx = w * 0.5 + lookX * w * 0.42;\n    const cy = h * 0.5 + lookY * h * 0.42;\n    const fov = Math.min(w, h) * 0.55;\n    const n = SIDES * PER;\n\n    for (let i = 0; i < RINGS; i++) {\n      const z = rings[i];\n      const spin = t * 0.35 + z * 0.28 * PARAMS.twist;\n      const fog = Math.max(0.08, 1 - z / 19);\n      const s = (1.15 / z) * fov;\n      const size = Math.max(1.2, 5.5 / z);\n      ctx.fillStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + fog + \")\";\n      ctx.strokeStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + (fog * 0.55) + \")\";\n      ctx.lineWidth = Math.max(1, 2.2 / z);\n      ctx.beginPath();\n      for (let k = 0; k <= n; k++) {\n        const p = onSquare(k % n, n, spin);\n        const x = cx + p[0] * s;\n        const y = cy + p[1] * s;\n        if (k === 0) ctx.moveTo(x, y);\n        else ctx.lineTo(x, y);\n      }\n      ctx.stroke();\n      for (let k = 0; k < n; k++) {\n        const p = onSquare(k, n, spin);\n        const x = cx + p[0] * s;\n        const y = cy + p[1] * s;\n        const corner = k % PER === 0;\n        if (corner) ctx.fillRect(x - size, y - size, size * 2, size * 2);\n        else {\n          ctx.beginPath();\n          ctx.arc(x, y, size * 0.45, 0, Math.PI * 2);\n          ctx.fill();\n        }\n      }\n    }\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    lookX += (targetX - lookX) * Math.min(1, dt * 6);\n    lookY += (targetY - lookY) * Math.min(1, dt * 6);\n    if (!reduced) {\n      t += dt * PARAMS.speed;\n      const dz = dt * 6.5 * PARAMS.speed;\n      for (let i = 0; i < RINGS; i++) {\n        rings[i] -= dz;\n        if (rings[i] < 0.5) rings[i] += 18;\n      }\n    }\n    resize();\n    draw(canvas.width, canvas.height);\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) { resize(); draw(canvas.width, canvas.height); }\n  else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}