{"slug":"ruttetra","number":"23","title":"Rutt-Etra scan","era":"Rutt/Etra · 1973","summary":"A video raster displaced by luminance. Drag to orbit.","description":"The Rutt-Etra scan processor took a video signal and bent each scanline in Z by brightness. This one synthesizes a cheap 'video' of blobs and rings, then draws it as a wire mesh. Drag to orbit. It keeps a slow auto-spin until you take over.","apis":["Canvas 2D","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["video","3d"],"hasGlsl":false,"interaction":"Drag on the preview to orbit. Auto-rotation stops after the first drag.","url":"https://3d-retro.com/experiments/ruttetra","example_url":"https://3d-retro.com/examples/ruttetra.html","api_url":"https://3d-retro.com/api/v1/experiments/ruttetra","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=\"1973 Rutt-Etra: a raster displaced by luminance. Drag to orbit.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/ruttetra\" />\n  <title>Rutt-Etra scan · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #050806; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }\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: #050806; }\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    displace: 1,\n    speed: 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 = 72, ROWS = 48;\n  let t = 0, yaw = 0.45, pitch = 0.55, auto = true, dragging = false, lastX = 0, lastY = 0, last = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    dragging = true; auto = false; lastX = e.clientX; lastY = e.clientY;\n    canvas.setPointerCapture(e.pointerId);\n  });\n  canvas.addEventListener(\"pointerup\", function () { dragging = false; });\n  canvas.addEventListener(\"pointermove\", function (e) {\n    if (!dragging) return;\n    yaw += (e.clientX - lastX) * 0.008;\n    pitch += (e.clientY - lastY) * 0.008;\n    pitch = Math.max(0.15, Math.min(1.2, pitch));\n    lastX = e.clientX; lastY = e.clientY;\n  });\n\n  function luma(u, v, time) {\n    const x = u * 2 - 1, y = v * 2 - 1;\n    let L = 0.42 + 0.22 * Math.sin(u * 9 + time) * Math.cos(v * 7 - time * 0.7);\n    const b1 = Math.hypot(x - Math.sin(time * 0.7) * 0.45, y - Math.cos(time * 0.5) * 0.3);\n    const b2 = Math.hypot(x + Math.cos(time * 0.4) * 0.4, y + Math.sin(time * 0.9) * 0.35);\n    L += 0.55 * Math.exp(-b1 * b1 * 7) + 0.4 * Math.exp(-b2 * b2 * 9);\n    const ring = Math.abs(Math.hypot(x, y) - (0.55 + 0.08 * Math.sin(time * 1.3)));\n    L += 0.28 * Math.exp(-ring * ring * 40);\n    return Math.max(0, Math.min(1, L));\n  }\n\n  function project(x, y, z, w, h) {\n    const cy = Math.cos(yaw), sy = Math.sin(yaw);\n    const cx = Math.cos(pitch), sx = Math.sin(pitch);\n    const x1 = x * cy + z * sy;\n    const z1 = -x * sy + z * cy;\n    const y1 = y * cx - z1 * sx;\n    const z2 = y * sx + z1 * cx + 3.4;\n    if (z2 < 0.2) return null;\n    const f = Math.min(w, h) * 0.9 / z2;\n    return { x: w * 0.5 + x1 * f, y: h * 0.52 - y1 * f };\n  }\n\n  function draw(w, h) {\n    ctx.fillStyle = \"#050806\";\n    ctx.fillRect(0, 0, w, h);\n    const rgb = hexToRgb(PARAMS.color);\n    const amp = 1.15 * PARAMS.displace;\n    for (let j = 0; j < ROWS; j++) {\n      const v = j / (ROWS - 1);\n      ctx.beginPath();\n      let pen = false;\n      for (let i = 0; i < COLS; i++) {\n        const u = i / (COLS - 1);\n        const L = luma(u, v, t);\n        const p = project((u - 0.5) * 2.2, (0.5 - v) * 1.5, (L - 0.35) * amp, w, h);\n        if (!p) { pen = false; continue; }\n        if (!pen) { ctx.moveTo(p.x, p.y); pen = true; }\n        else ctx.lineTo(p.x, p.y);\n      }\n      const a = 0.22 + 0.7 * (1 - v * 0.45);\n      ctx.strokeStyle = \"rgba(\" + rgb[0] + \",\" + rgb[1] + \",\" + rgb[2] + \",\" + a + \")\";\n      ctx.lineWidth = 1.15;\n      ctx.stroke();\n    }\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) { canvas.width = w; canvas.height = h; }\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      t += dt * PARAMS.speed;\n      if (auto) yaw += dt * 0.18;\n    }\n    resize();\n    draw(canvas.width, canvas.height);\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) { resize(); draw(canvas.width, canvas.height); }\n  else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n"}