Home

13. Comanche · 1992

Voxel Space

Colors and knobs

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

About

NovaLogic's Voxel Space: each screen column walks a height map, drawing a vertical strip whenever the terrain rises above what is already filled. Color comes from height and a cheap slope light. No polygons. The camera flies forward. Move the pointer to yaw.

Move the pointer left or right to steer.

Browser APIs

  • Canvas 2D
  • ImageData
  • 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 MAP = 512;
  const MASK = MAP - 1;
  const height = new Uint8Array(MAP * MAP);
  const shade = new Float32Array(MAP * MAP);
  for (let z = 0; z < MAP; z++) {
    for (let x = 0; x < MAP; x++) {
      const dx = x - 280, dz = z - 260;
      const h =
        68 +
        7 * Math.sin(x * 0.035) +
        7 * Math.sin(z * 0.04) +
        22 * Math.exp(-(dx * dx + dz * dz) / 9000) +
        14 * Math.exp(-((x - 140) * (x - 140) + (z - 400) * (z - 400)) / 7000);
      height[z * MAP + x] = Math.max(48, Math.min(110, h));
    }
  }
  for (let z = 0; z < MAP; z++) {
    for (let x = 0; x < MAP; x++) {
      const h0 = height[z * MAP + x];
      const h1 = height[z * MAP + ((x + 1) & MASK)];
      shade[z * MAP + x] = Math.max(0.35, Math.min(1.15, 0.72 + (h0 - h1) * 0.035));
    }
  }

  const buf = document.createElement("canvas");
  const BW = 320, BH = 160;
  buf.width = BW; buf.height = BH;
  const bctx = buf.getContext("2d");
  const img = bctx.createImageData(BW, BH);
  const px = img.data;

  let camX = 40, camZ = 40, yaw = 0.7;
  let look = 0;
  let last = 0;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

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

  function put(x, y0, y1, r, g, b) {
    y0 = y0 | 0; y1 = y1 | 0;
    if (y0 < 0) y0 = 0;
    if (y1 > BH) y1 = BH;
    for (let y = y0; y < y1; y++) {
      const i = (y * BW + x) * 4;
      px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255;
    }
  }

  function draw() {
    const rgb = hexToRgb(PARAMS.color);
    for (let i = 0; i < px.length; i += 4) {
      const y = (i / 4 / BW) | 0;
      const t = y / BH;
      px[i] = 18 + t * 22 + rgb[0] * 0.04;
      px[i + 1] = 22 + t * 28 + rgb[1] * 0.05;
      px[i + 2] = 38 + t * 20;
      px[i + 3] = 255;
    }
    const sunX = BW * 0.72, sunY = BH * 0.22;
    for (let y = 0; y < BH * 0.48; y++) {
      for (let x = 0; x < BW; x++) {
        const d = Math.hypot(x - sunX, y - sunY);
        if (d < 7) {
          const i = (y * BW + x) * 4;
          px[i] = 255; px[i + 1] = 230; px[i + 2] = 160;
        }
      }
    }

    const view = yaw + look;
    const fov = 0.9;
    const horizon = BH * 0.3;
    const camH = 86;
    const scale = 170;
    const far = 380;
    for (let col = 0; col < BW; col++) {
      const ang = view - fov * 0.5 + fov * (col / BW);
      const dx = Math.cos(ang), dz = Math.sin(ang);
      let maxY = BH;
      let dist = 3;
      while (dist < far && maxY > 0) {
        const mx = (camX + dx * dist) & MASK;
        const mz = (camZ + dz * dist) & MASK;
        const idx = mz * MAP + mx;
        const hh = 48 + (height[idx] - 48) * PARAMS.hills;
        const sy = ((camH - hh) / dist) * scale + horizon;
        if (sy < maxY) {
          const fog = Math.max(0.12, 1 - dist / far);
          const sh = shade[idx];
          const ht = (hh - 48) / 70;
          const r = (rgb[0] * (0.25 + ht * 0.75) * sh) * fog + 18 * (1 - fog);
          const g = (rgb[1] * (0.35 + ht * 0.55) * sh) * fog + 24 * (1 - fog);
          const b = (rgb[2] * (0.2 + (1 - ht) * 0.35) * sh) * fog + 36 * (1 - fog);
          put(col, sy, maxY, r, g, b);
          maxY = sy;
        }
        dist += 0.7 + dist * 0.014;
      }
    }
    bctx.putImageData(img, 0, 0);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);
  }

  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) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (!reduced) {
      yaw += look * dt * 1.4;
      camX += Math.cos(yaw) * 38 * PARAMS.speed * dt;
      camZ += Math.sin(yaw) * 38 * PARAMS.speed * dt;
    }
    resize();
    draw();
    if (!reduced) requestAnimationFrame(frame);
  }

  if (reduced) { resize(); draw(); }
  else requestAnimationFrame(frame);
})();