{"slug":"voxel","number":"13","title":"Voxel Space","era":"Comanche · 1992","summary":"A heightmap ray-column renderer. Pointer steers.","description":"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.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["voxel","3d"],"hasGlsl":false,"interaction":"Move the pointer left or right to steer.","url":"https://3d-retro.com/experiments/voxel","example_url":"https://3d-retro.com/examples/voxel.html","api_url":"https://3d-retro.com/api/v1/experiments/voxel","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=\"Comanche-style voxel heightmap. Pointer steers.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/voxel\" />\n  <title>Voxel Space · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0c0e; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; image-rendering: pixelated; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #0b0c0e; }\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: \"#6ee7b7\",\n    speed: 1,\n    hills: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\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 MAP = 512;\n  const MASK = MAP - 1;\n  const height = new Uint8Array(MAP * MAP);\n  const shade = new Float32Array(MAP * MAP);\n  for (let z = 0; z < MAP; z++) {\n    for (let x = 0; x < MAP; x++) {\n      const dx = x - 280, dz = z - 260;\n      const h =\n        68 +\n        7 * Math.sin(x * 0.035) +\n        7 * Math.sin(z * 0.04) +\n        22 * Math.exp(-(dx * dx + dz * dz) / 9000) +\n        14 * Math.exp(-((x - 140) * (x - 140) + (z - 400) * (z - 400)) / 7000);\n      height[z * MAP + x] = Math.max(48, Math.min(110, h));\n    }\n  }\n  for (let z = 0; z < MAP; z++) {\n    for (let x = 0; x < MAP; x++) {\n      const h0 = height[z * MAP + x];\n      const h1 = height[z * MAP + ((x + 1) & MASK)];\n      shade[z * MAP + x] = Math.max(0.35, Math.min(1.15, 0.72 + (h0 - h1) * 0.035));\n    }\n  }\n\n  const buf = document.createElement(\"canvas\");\n  const BW = 320, BH = 160;\n  buf.width = BW; buf.height = BH;\n  const bctx = buf.getContext(\"2d\");\n  const img = bctx.createImageData(BW, BH);\n  const px = img.data;\n\n  let camX = 40, camZ = 40, yaw = 0.7;\n  let look = 0;\n  let last = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    look = ((ev.clientX - r.left) / r.width - 0.5) * 0.9;\n  });\n  canvas.addEventListener(\"pointerleave\", function () { look = 0; });\n\n  function put(x, y0, y1, r, g, b) {\n    y0 = y0 | 0; y1 = y1 | 0;\n    if (y0 < 0) y0 = 0;\n    if (y1 > BH) y1 = BH;\n    for (let y = y0; y < y1; y++) {\n      const i = (y * BW + x) * 4;\n      px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255;\n    }\n  }\n\n  function draw() {\n    const rgb = hexToRgb(PARAMS.color);\n    for (let i = 0; i < px.length; i += 4) {\n      const y = (i / 4 / BW) | 0;\n      const t = y / BH;\n      px[i] = 18 + t * 22 + rgb[0] * 0.04;\n      px[i + 1] = 22 + t * 28 + rgb[1] * 0.05;\n      px[i + 2] = 38 + t * 20;\n      px[i + 3] = 255;\n    }\n    const sunX = BW * 0.72, sunY = BH * 0.22;\n    for (let y = 0; y < BH * 0.48; y++) {\n      for (let x = 0; x < BW; x++) {\n        const d = Math.hypot(x - sunX, y - sunY);\n        if (d < 7) {\n          const i = (y * BW + x) * 4;\n          px[i] = 255; px[i + 1] = 230; px[i + 2] = 160;\n        }\n      }\n    }\n\n    const view = yaw + look;\n    const fov = 0.9;\n    const horizon = BH * 0.3;\n    const camH = 86;\n    const scale = 170;\n    const far = 380;\n    for (let col = 0; col < BW; col++) {\n      const ang = view - fov * 0.5 + fov * (col / BW);\n      const dx = Math.cos(ang), dz = Math.sin(ang);\n      let maxY = BH;\n      let dist = 3;\n      while (dist < far && maxY > 0) {\n        const mx = (camX + dx * dist) & MASK;\n        const mz = (camZ + dz * dist) & MASK;\n        const idx = mz * MAP + mx;\n        const hh = 48 + (height[idx] - 48) * PARAMS.hills;\n        const sy = ((camH - hh) / dist) * scale + horizon;\n        if (sy < maxY) {\n          const fog = Math.max(0.12, 1 - dist / far);\n          const sh = shade[idx];\n          const ht = (hh - 48) / 70;\n          const r = (rgb[0] * (0.25 + ht * 0.75) * sh) * fog + 18 * (1 - fog);\n          const g = (rgb[1] * (0.35 + ht * 0.55) * sh) * fog + 24 * (1 - fog);\n          const b = (rgb[2] * (0.2 + (1 - ht) * 0.35) * sh) * fog + 36 * (1 - fog);\n          put(col, sy, maxY, r, g, b);\n          maxY = sy;\n        }\n        dist += 0.7 + dist * 0.014;\n      }\n    }\n    bctx.putImageData(img, 0, 0);\n    ctx.imageSmoothingEnabled = false;\n    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);\n  }\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    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (!reduced) {\n      yaw += look * dt * 1.4;\n      camX += Math.cos(yaw) * 38 * PARAMS.speed * dt;\n      camZ += Math.sin(yaw) * 38 * PARAMS.speed * dt;\n    }\n    resize();\n    draw();\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) { resize(); draw(); }\n  else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}