Home

11. Vector CRT · 1979

Wireframe terrain

Colors and knobs

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

About

A height field of summed sines, projected with a 1/z divide, stroked as a mesh that recedes into phosphor fog. The camera flies forward. Move the pointer to yaw. This is the Battlezone / Evans & Sutherland look: no textures, no hidden-line removal, just a wire landscape on a CRT.

Move the pointer left or right to steer.

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,
    hills: 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 COLS = 24;
  const ROWS = 16;
  const CELL = 1.05;
  const CAM_Y = 2.2;
  const stars = [];
  for (let i = 0; i < 48; i++) {
    stars.push({ x: Math.random(), y: Math.random() * 0.46, a: 0.25 + Math.random() * 0.55 });
  }

  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  let z0 = 12;
  let look = 0;
  let targetLook = 0;
  let last = 0;
  let raf = 0;

  function height(x, z) {
    const a = PARAMS.hills;
    return (
      Math.sin(x * 0.42 + z * 0.18) * 1.35 +
      Math.sin(x * 0.19 - z * 0.33) * 0.85 +
      Math.sin(z * 0.51 + x * 0.07) * 0.55
    ) * a;
  }

  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    targetLook = ((ev.clientX - r.left) / r.width - 0.5) * 1.15;
  });
  canvas.addEventListener("pointerleave", function () {
    targetLook = 0;
  });

  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 project(x, y, z, w, h) {
    if (z < 0.7) return null;
    const nx = x / z;
    const ny = y / z;
    if (nx < -2.2 || nx > 2.2) return null;
    const fov = Math.min(w, h) * 0.86;
    return {
      x: w * 0.5 + nx * fov,
      y: h * 0.56 - ny * fov
    };
  }

  function sample(i, j, w, h) {
    const x = (i - (COLS - 1) * 0.5) * CELL + look * 1.8;
    const z = 1.7 + j * CELL;
    const y = height(x + look * 5, z0 + z) - CAM_Y;
    return project(x, y, z, w, h);
  }

  function onScreen(p, w, h) {
    return p && p.x > -w * 0.2 && p.x < w * 1.2 && p.y > -h * 0.2 && p.y < h * 1.2;
  }

  function strokeSeg(a, b, alpha, width, w, h) {
    if (!onScreen(a, w, h) || !onScreen(b, w, h)) return;
    const rgb = hexToRgb(PARAMS.color);
    ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + alpha + ")";
    ctx.lineWidth = width;
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }

  function draw(w, h) {
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);
    const rgb = hexToRgb(PARAMS.color);

    for (let s = 0; s < stars.length; s++) {
      const st = stars[s];
      ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (st.a * 0.45) + ")";
      ctx.fillRect(st.x * w, st.y * h * 0.52, 1.4, 1.4);
    }

    ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0.22)";
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(0, h * 0.56);
    ctx.lineTo(w, h * 0.56);
    ctx.stroke();

    const grid = [];
    for (let j = 0; j < ROWS; j++) {
      const row = [];
      for (let i = 0; i < COLS; i++) row.push(sample(i, j, w, h));
      grid.push(row);
    }

    ctx.lineJoin = "round";
    ctx.lineCap = "round";
    for (let j = 0; j < ROWS; j++) {
      const near = j / (ROWS - 1);
      const alpha = 0.18 + near * 0.78;
      const width = 0.7 + near * 1.35;
      for (let i = 0; i < COLS - 1; i++) strokeSeg(grid[j][i], grid[j][i + 1], alpha, width, w, h);
      if (j < ROWS - 1) {
        for (let i = 0; i < COLS; i++) strokeSeg(grid[j][i], grid[j + 1][i], alpha * 0.85, width, w, h);
      }
    }
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) {
      z0 += dt * 4.2 * PARAMS.speed;
      look += (targetLook - look) * Math.min(1, dt * 6);
    }
    resize();
    draw(canvas.width, canvas.height);
    if (!reduced) raf = requestAnimationFrame(frame);
  }

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