Home

02. Hyperspace · 1993

Retro 3d starfield

Colors and knobs

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

About

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.

Move the pointer over the preview 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: "#baffe6",
    speed: 1,
    trail: 0.2
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [186, 255, 230];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function rgbCss(hex, a) {
    const r = hexToRgb(hex);
    return "rgba(" + r[0] + ", " + r[1] + ", " + r[2] + ", " + a + ")";
  }

  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 COUNT = 720;
  const stars = new Array(COUNT);
  for (let i = 0; i < COUNT; i++) {
    stars[i] = {
      x: Math.random() * 2 - 1,
      y: Math.random() * 2 - 1,
      z: Math.random(),
    };
  }

  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const look = { x: 0, y: 0 };
  const target = { x: 0, y: 0 };
  let last = 0;
  let raf = 0;

  function onPointer(e) {
    const rect = canvas.getBoundingClientRect();
    target.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
    target.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
  }
  canvas.addEventListener("pointermove", onPointer, { passive: true });

  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) {
    resize();
    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);
    last = now;
    const t = now * 0.001;

    look.x += (target.x - look.x) * (1 - Math.exp(-dt * 6));
    look.y += (target.y - look.y) * (1 - Math.exp(-dt * 6));

    const w = canvas.width;
    const h = canvas.height;
    const warp = reduced ? 0.12 : 0.18 + 0.82 * (0.5 + 0.5 * Math.sin(t * 0.32));
    const speed = (reduced ? 0 : 0.22 + warp * 1.55) * PARAMS.speed;

    if (reduced) {
      ctx.fillStyle = "#0b0c0e";
      ctx.fillRect(0, 0, w, h);
    } else {
      ctx.fillStyle = "rgba(11, 12, 14, " + PARAMS.trail + ")";
      ctx.fillRect(0, 0, w, h);
    }

    const cx = w * 0.5 + look.x * w * 0.1;
    const cy = h * 0.5 + look.y * h * 0.1;
    const fov = Math.min(w, h) * 0.52;

    for (let i = 0; i < COUNT; i++) {
      const s = stars[i];
      const pz = s.z;
      s.z -= speed * dt;
      if (s.z <= 0.02) {
        s.x = Math.random() * 2 - 1;
        s.y = Math.random() * 2 - 1;
        s.z = 1;
      }
      const sx = cx + (s.x / s.z) * fov;
      const sy = cy + (s.y / s.z) * fov;
      const px = cx + (s.x / pz) * fov;
      const py = cy + (s.y / pz) * fov;
      const bright = (1 - s.z);
      const a = 0.25 + bright * (0.45 + warp * 0.5);
      ctx.strokeStyle = rgbCss(PARAMS.color, a);
      ctx.lineWidth = Math.max(1, bright * (1.1 + warp * 2.4));
      ctx.beginPath();
      ctx.moveTo(px, py);
      ctx.lineTo(sx, sy);
      ctx.stroke();
    }

    if (!reduced) raf = requestAnimationFrame(frame);
  }

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
})();