Home

02. Screensaver and demo staple · c. 1992 · Retro techniques

Retro 3d starfield

Demos are animating. Following your system setting.
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. The look is the one every 1990s screensaver and demo intro had, Windows 3.1's Starfield Simulation included; no single original is reproduced. Move the pointer to shift the vanishing point.

Move the pointer over the preview to shift the vanishing point.

Reference

Interpretation: this evokes a period look and does not reproduce one specific original.

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. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

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 motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.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;
    wake();
  }
  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);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

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