{"slug":"terrain","number":"11","title":"Wireframe terrain","era":"Vector CRT · 1979","summary":"Scrolling vector hills. Pointer steers.","description":"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.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["wireframe","3d"],"hasGlsl":false,"interaction":"Move the pointer left or right to steer.","url":"https://3d-retro.com/experiments/terrain","example_url":"https://3d-retro.com/examples/terrain.html","api_url":"https://3d-retro.com/api/v1/experiments/terrain","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=\"Scrolling vector hills, 1979 CRT flight-sim style. Pointer steers.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/terrain\" />\n  <title>Wireframe terrain · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n      touch-action: none;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\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 COLS = 24;\n  const ROWS = 16;\n  const CELL = 1.05;\n  const CAM_Y = 2.2;\n  const stars = [];\n  for (let i = 0; i < 48; i++) {\n    stars.push({ x: Math.random(), y: Math.random() * 0.46, a: 0.25 + Math.random() * 0.55 });\n  }\n\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  let z0 = 12;\n  let look = 0;\n  let targetLook = 0;\n  let last = 0;\n  let raf = 0;\n\n  function height(x, z) {\n    const a = PARAMS.hills;\n    return (\n      Math.sin(x * 0.42 + z * 0.18) * 1.35 +\n      Math.sin(x * 0.19 - z * 0.33) * 0.85 +\n      Math.sin(z * 0.51 + x * 0.07) * 0.55\n    ) * a;\n  }\n\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    targetLook = ((ev.clientX - r.left) / r.width - 0.5) * 1.15;\n  });\n  canvas.addEventListener(\"pointerleave\", function () {\n    targetLook = 0;\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 project(x, y, z, w, h) {\n    if (z < 0.7) return null;\n    const nx = x / z;\n    const ny = y / z;\n    if (nx < -2.2 || nx > 2.2) return null;\n    const fov = Math.min(w, h) * 0.86;\n    return {\n      x: w * 0.5 + nx * fov,\n      y: h * 0.56 - ny * fov\n    };\n  }\n\n  function sample(i, j, w, h) {\n    const x = (i - (COLS - 1) * 0.5) * CELL + look * 1.8;\n    const z = 1.7 + j * CELL;\n    const y = height(x + look * 5, z0 + z) - CAM_Y;\n    return project(x, y, z, w, h);\n  }\n\n  function onScreen(p, w, h) {\n    return p && p.x > -w * 0.2 && p.x < w * 1.2 && p.y > -h * 0.2 && p.y < h * 1.2;\n  }\n\n  function strokeSeg(a, b, alpha, width, w, h) {\n    if (!onScreen(a, w, h) || !onScreen(b, w, h)) return;\n    const rgb = hexToRgb(PARAMS.color);\n    ctx.strokeStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + alpha + \")\";\n    ctx.lineWidth = width;\n    ctx.beginPath();\n    ctx.moveTo(a.x, a.y);\n    ctx.lineTo(b.x, b.y);\n    ctx.stroke();\n  }\n\n  function draw(w, h) {\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n    const rgb = hexToRgb(PARAMS.color);\n\n    for (let s = 0; s < stars.length; s++) {\n      const st = stars[s];\n      ctx.fillStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + (st.a * 0.45) + \")\";\n      ctx.fillRect(st.x * w, st.y * h * 0.52, 1.4, 1.4);\n    }\n\n    ctx.strokeStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",0.22)\";\n    ctx.lineWidth = 1;\n    ctx.beginPath();\n    ctx.moveTo(0, h * 0.56);\n    ctx.lineTo(w, h * 0.56);\n    ctx.stroke();\n\n    const grid = [];\n    for (let j = 0; j < ROWS; j++) {\n      const row = [];\n      for (let i = 0; i < COLS; i++) row.push(sample(i, j, w, h));\n      grid.push(row);\n    }\n\n    ctx.lineJoin = \"round\";\n    ctx.lineCap = \"round\";\n    for (let j = 0; j < ROWS; j++) {\n      const near = j / (ROWS - 1);\n      const alpha = 0.18 + near * 0.78;\n      const width = 0.7 + near * 1.35;\n      for (let i = 0; i < COLS - 1; i++) strokeSeg(grid[j][i], grid[j][i + 1], alpha, width, w, h);\n      if (j < ROWS - 1) {\n        for (let i = 0; i < COLS; i++) strokeSeg(grid[j][i], grid[j + 1][i], alpha * 0.85, width, w, h);\n      }\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      z0 += dt * 4.2 * PARAMS.speed;\n      look += (targetLook - look) * Math.min(1, dt * 6);\n    }\n    resize();\n    draw(canvas.width, canvas.height);\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  if (reduced) {\n    resize();\n    draw(canvas.width, canvas.height);\n  } else {\n    raf = requestAnimationFrame(frame);\n  }\n})();\n</script>\n</body>\n</html>\n"}