{"slug":"lowpoly","number":"03","title":"Retro torus","era":"GL workstation · 1993","summary":"Flat-shaded torus, wire overlay, and a ground grid. Drag to orbit.","description":"A torus tessellated into chunky triangles with per-face normals, lit by a mint key and a cool fill. Edges are drawn as a second line pass. A ground grid locks it to the SGI demo-reel floor. Matrix math is inline, no three.js. Drag to orbit. It auto-rotates until you take over.","apis":["WebGL 1","GLSL ES 1.0","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["webgl","3d"],"hasGlsl":true,"interaction":"Drag on the preview to orbit. Auto-rotation stops after the first drag.","url":"https://3d-retro.com/experiments/lowpoly","example_url":"https://3d-retro.com/examples/lowpoly.html","api_url":"https://3d-retro.com/api/v1/experiments/lowpoly","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=\"Flat-shaded torus, wire overlay, and a ground grid. Drag to orbit.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/lowpoly\" />\n  <title>Retro torus · 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: \"#c7cccf\",\n    accent: \"#6ee7b7\",\n    speed: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [0.43, 0.91, 0.72];\n    return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  function fail(message) {\n    fallback.hidden = false;\n    fallback.textContent = message;\n  }\n\n  const gl = canvas.getContext(\"webgl\", { alpha: false, antialias: true });\n  if (!gl) {\n    fail(\"WebGL is not available in this browser. Try a current Firefox, Chrome, Safari, or Edge.\");\n    return;\n  }\n\n  const VERT = `attribute vec3 a_pos;\nattribute vec3 a_nrm;\nuniform mat4 u_mvp;\nuniform mat4 u_model;\nvarying vec3 v_nrm;\nvarying vec3 v_pos;\nvoid main() {\n  vec4 world = u_model * vec4(a_pos, 1.0);\n  v_pos = world.xyz;\n  v_nrm = mat3(u_model) * a_nrm;\n  gl_Position = u_mvp * vec4(a_pos, 1.0);\n}`;\n\n  const FRAG = `precision mediump float;\nvarying vec3 v_nrm;\nvarying vec3 v_pos;\nuniform vec3 u_cam;\nuniform vec3 u_color;\nuniform vec3 u_accent;\nvoid main() {\n  vec3 n = normalize(v_nrm);\n  vec3 v = normalize(u_cam - v_pos);\n  vec3 l1 = normalize(vec3(0.55, 0.85, 0.35));\n  vec3 l2 = normalize(vec3(-0.75, 0.25, 0.2));\n  float d1 = max(dot(n, l1), 0.0);\n  float d2 = max(dot(n, l2), 0.0);\n  float rim = pow(1.0 - max(dot(n, v), 0.0), 2.8);\n  vec3 col = u_color * (0.10 + 0.78 * d1);\n  col += u_accent * d2 * 0.38;\n  col += u_accent * rim * 0.22;\n  gl_FragColor = vec4(col, 1.0);\n}`;\n\n  const LINE_VERT = `attribute vec3 a_pos;\nuniform mat4 u_mvp;\nvoid main() {\n  gl_Position = u_mvp * vec4(a_pos, 1.0);\n}`;\n  const LINE_FRAG = `precision mediump float;\nuniform vec3 u_accent;\nvoid main() {\n  gl_FragColor = vec4(u_accent, 0.28);\n}`;\n\n  function compile(type, src) {\n    const sh = gl.createShader(type);\n    gl.shaderSource(sh, src);\n    gl.compileShader(sh);\n    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {\n      throw new Error(gl.getShaderInfoLog(sh) || \"Shader compile failed\");\n    }\n    return sh;\n  }\n  function program(vs, fs) {\n    const p = gl.createProgram();\n    gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));\n    gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));\n    gl.linkProgram(p);\n    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {\n      throw new Error(gl.getProgramInfoLog(p) || \"Program link failed\");\n    }\n    return p;\n  }\n\n  function mul(a, b) {\n    const o = new Float32Array(16);\n    for (let c = 0; c < 4; c++) {\n      for (let r = 0; r < 4; r++) {\n        o[c * 4 + r] =\n          a[r] * b[c * 4] +\n          a[4 + r] * b[c * 4 + 1] +\n          a[8 + r] * b[c * 4 + 2] +\n          a[12 + r] * b[c * 4 + 3];\n      }\n    }\n    return o;\n  }\n  function perspective(fovy, aspect, near, far) {\n    const f = 1 / Math.tan(fovy / 2);\n    const nf = 1 / (near - far);\n    const o = new Float32Array(16);\n    o[0] = f / aspect;\n    o[5] = f;\n    o[10] = (far + near) * nf;\n    o[11] = -1;\n    o[14] = 2 * far * near * nf;\n    return o;\n  }\n  function translate(x, y, z) {\n    const o = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]);\n    o[12] = x; o[13] = y; o[14] = z;\n    return o;\n  }\n  function rotateXY(ax, ay) {\n    const cx = Math.cos(ax), sx = Math.sin(ax);\n    const cy = Math.cos(ay), sy = Math.sin(ay);\n    const rx = new Float32Array([1,0,0,0, 0,cx,sx,0, 0,-sx,cx,0, 0,0,0,1]);\n    const ry = new Float32Array([cy,0,-sy,0, 0,1,0,0, sy,0,cy,0, 0,0,0,1]);\n    return mul(ry, rx);\n  }\n\n  function torus(major, minor, R, r) {\n    const pos = [];\n    const nrm = [];\n    const lines = [];\n    function point(i, j) {\n      const u = (i / major) * Math.PI * 2;\n      const v = (j / minor) * Math.PI * 2;\n      const cx = Math.cos(u), sx = Math.sin(u);\n      const cy = Math.cos(v), sy = Math.sin(v);\n      return [\n        (R + r * cy) * cx,\n        r * sy,\n        (R + r * cy) * sx,\n      ];\n    }\n    function tri(a, b, c) {\n      const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];\n      const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];\n      let nx = uy * vz - uz * vy;\n      let ny = uz * vx - ux * vz;\n      let nz = ux * vy - uy * vx;\n      const len = Math.hypot(nx, ny, nz) || 1;\n      nx /= len; ny /= len; nz /= len;\n      for (const p of [a, b, c]) {\n        pos.push(p[0], p[1], p[2]);\n        nrm.push(nx, ny, nz);\n      }\n    }\n    for (let i = 0; i < major; i++) {\n      for (let j = 0; j < minor; j++) {\n        const a = point(i, j);\n        const b = point(i + 1, j);\n        const c = point(i + 1, j + 1);\n        const d = point(i, j + 1);\n        tri(a, b, c);\n        tri(a, c, d);\n        lines.push(a[0], a[1], a[2], b[0], b[1], b[2]);\n        lines.push(a[0], a[1], a[2], d[0], d[1], d[2]);\n      }\n    }\n    return {\n      pos: new Float32Array(pos),\n      nrm: new Float32Array(nrm),\n      count: pos.length / 3,\n      lines: new Float32Array(lines),\n      lineCount: lines.length / 3,\n    };\n  }\n\n  function grid(size, step) {\n    const pts = [];\n    for (let i = -size; i <= size; i += step) {\n      pts.push(-size, 0, i, size, 0, i);\n      pts.push(i, 0, -size, i, 0, size);\n    }\n    return { data: new Float32Array(pts), count: pts.length / 3 };\n  }\n\n  const mesh = torus(16, 10, 1.05, 0.42);\n  const floor = grid(4.5, 0.5);\n  const meshProg = program(VERT, FRAG);\n  const lineProg = program(LINE_VERT, LINE_FRAG);\n\n  function bindAttrib(prog, name, buffer, size) {\n    const loc = gl.getAttribLocation(prog, name);\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.enableVertexAttribArray(loc);\n    gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);\n  }\n  function makeBuf(data) {\n    const b = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, b);\n    gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);\n    return b;\n  }\n\n  const posBuf = makeBuf(mesh.pos);\n  const nrmBuf = makeBuf(mesh.nrm);\n  const lineBuf = makeBuf(mesh.lines);\n  const gridBuf = makeBuf(floor.data);\n\n  gl.enable(gl.DEPTH_TEST);\n  gl.enable(gl.BLEND);\n  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);\n  gl.clearColor(0.043, 0.047, 0.055, 1);\n\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  let yaw = 0.7;\n  let pitch = 0.45;\n  let auto = true;\n  let dragging = false;\n  let lastX = 0, lastY = 0;\n  let last = 0;\n  let raf = 0;\n\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    dragging = true;\n    auto = false;\n    lastX = e.clientX;\n    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.2, Math.min(1.2, pitch));\n    lastX = e.clientX;\n    lastY = e.clientY;\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    gl.viewport(0, 0, canvas.width, canvas.height);\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (auto && !reduced) yaw += dt * 0.45 * PARAMS.speed;\n    resize();\n    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);\n\n    const aspect = canvas.width / canvas.height;\n    const proj = perspective(Math.PI / 4, aspect, 0.1, 40);\n    const view = mul(translate(0, -0.15, -5.2), rotateXY(-0.35, 0));\n    const model = mul(translate(0, 0.55, 0), rotateXY(pitch, yaw));\n    const mvp = mul(mul(proj, view), model);\n    const gridModel = translate(0, -0.85, 0);\n    const gridMvp = mul(mul(proj, view), gridModel);\n    const cam = [0, 1.6, 5.2];\n\n    gl.useProgram(meshProg);\n    gl.uniformMatrix4fv(gl.getUniformLocation(meshProg, \"u_mvp\"), false, mvp);\n    gl.uniformMatrix4fv(gl.getUniformLocation(meshProg, \"u_model\"), false, model);\n    gl.uniform3fv(gl.getUniformLocation(meshProg, \"u_cam\"), cam);\n    gl.uniform3fv(gl.getUniformLocation(meshProg, \"u_color\"), hexToRgb(PARAMS.color));\n    gl.uniform3fv(gl.getUniformLocation(meshProg, \"u_accent\"), hexToRgb(PARAMS.accent));\n    bindAttrib(meshProg, \"a_pos\", posBuf, 3);\n    bindAttrib(meshProg, \"a_nrm\", nrmBuf, 3);\n    gl.enable(gl.CULL_FACE);\n    gl.drawArrays(gl.TRIANGLES, 0, mesh.count);\n\n    gl.useProgram(lineProg);\n    gl.uniform3fv(gl.getUniformLocation(lineProg, \"u_accent\"), hexToRgb(PARAMS.accent));\n    gl.uniformMatrix4fv(gl.getUniformLocation(lineProg, \"u_mvp\"), false, mvp);\n    bindAttrib(lineProg, \"a_pos\", lineBuf, 3);\n    gl.disable(gl.CULL_FACE);\n    gl.drawArrays(gl.LINES, 0, mesh.lineCount);\n\n    gl.uniformMatrix4fv(gl.getUniformLocation(lineProg, \"u_mvp\"), false, gridMvp);\n    bindAttrib(lineProg, \"a_pos\", gridBuf, 3);\n    gl.drawArrays(gl.LINES, 0, floor.count);\n\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  canvas.addEventListener(\"webglcontextlost\", function (e) {\n    e.preventDefault();\n    cancelAnimationFrame(raf);\n    fail(\"The WebGL context was lost. Reload to restore the demo.\");\n  });\n\n  try {\n    if (reduced) frame(performance.now());\n    else raf = requestAnimationFrame(frame);\n  } catch (err) {\n    fail(err && err.message ? err.message : String(err));\n  }\n})();\n</script>\n</body>\n</html>\n"}