{"slug":"starfield","number":"02","title":"Retro 3d starfield","era":"Hyperspace · 1993","summary":"Perspective starfield that stretches into a hyperspace tunnel.","description":"700 points in a unit cube, projected with a 1/z divide. Depth is decremented each frame; when a star passes the camera it is recycled at the far plane. Motion blur is a cheap trail fade on the 2D canvas. Move the pointer to shift the vanishing point.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["canvas","particles"],"hasGlsl":false,"interaction":"Move the pointer over the preview to shift the vanishing point.","url":"https://3d-retro.com/experiments/starfield","example_url":"https://3d-retro.com/examples/starfield.html","api_url":"https://3d-retro.com/api/v1/experiments/starfield","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=\"Perspective starfield that stretches into a hyperspace tunnel.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/starfield\" />\n  <title>Retro 3d starfield · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n      touch-action: none;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\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: \"#baffe6\",\n    speed: 1,\n    trail: 0.2\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [186, 255, 230];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n  function rgbCss(hex, a) {\n    const r = hexToRgb(hex);\n    return \"rgba(\" + r[0] + \", \" + r[1] + \", \" + r[2] + \", \" + a + \")\";\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 COUNT = 720;\n  const stars = new Array(COUNT);\n  for (let i = 0; i < COUNT; i++) {\n    stars[i] = {\n      x: Math.random() * 2 - 1,\n      y: Math.random() * 2 - 1,\n      z: Math.random(),\n    };\n  }\n\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const look = { x: 0, y: 0 };\n  const target = { x: 0, y: 0 };\n  let last = 0;\n  let raf = 0;\n\n  function onPointer(e) {\n    const rect = canvas.getBoundingClientRect();\n    target.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;\n    target.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;\n  }\n  canvas.addEventListener(\"pointermove\", onPointer, { passive: true });\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 frame(now) {\n    resize();\n    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    const t = now * 0.001;\n\n    look.x += (target.x - look.x) * (1 - Math.exp(-dt * 6));\n    look.y += (target.y - look.y) * (1 - Math.exp(-dt * 6));\n\n    const w = canvas.width;\n    const h = canvas.height;\n    const warp = reduced ? 0.12 : 0.18 + 0.82 * (0.5 + 0.5 * Math.sin(t * 0.32));\n    const speed = (reduced ? 0 : 0.22 + warp * 1.55) * PARAMS.speed;\n\n    if (reduced) {\n      ctx.fillStyle = \"#0b0c0e\";\n      ctx.fillRect(0, 0, w, h);\n    } else {\n      ctx.fillStyle = \"rgba(11, 12, 14, \" + PARAMS.trail + \")\";\n      ctx.fillRect(0, 0, w, h);\n    }\n\n    const cx = w * 0.5 + look.x * w * 0.1;\n    const cy = h * 0.5 + look.y * h * 0.1;\n    const fov = Math.min(w, h) * 0.52;\n\n    for (let i = 0; i < COUNT; i++) {\n      const s = stars[i];\n      const pz = s.z;\n      s.z -= speed * dt;\n      if (s.z <= 0.02) {\n        s.x = Math.random() * 2 - 1;\n        s.y = Math.random() * 2 - 1;\n        s.z = 1;\n      }\n      const sx = cx + (s.x / s.z) * fov;\n      const sy = cy + (s.y / s.z) * fov;\n      const px = cx + (s.x / pz) * fov;\n      const py = cy + (s.y / pz) * fov;\n      const bright = (1 - s.z);\n      const a = 0.25 + bright * (0.45 + warp * 0.5);\n      ctx.strokeStyle = rgbCss(PARAMS.color, a);\n      ctx.lineWidth = Math.max(1, bright * (1.1 + warp * 2.4));\n      ctx.beginPath();\n      ctx.moveTo(px, py);\n      ctx.lineTo(sx, sy);\n      ctx.stroke();\n    }\n\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  if (reduced) frame(performance.now());\n  else raf = requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}