Home

03. GL workstation · 1993

Retro torus

Colors and knobs

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

About

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.

Drag on the preview to orbit. Auto-rotation stops after the first drag.

Browser APIs

  • WebGL 1
  • GLSL ES 1.0
  • 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: "#c7cccf",
    accent: "#6ee7b7",
    speed: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [0.43, 0.91, 0.72];
    return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  function fail(message) {
    fallback.hidden = false;
    fallback.textContent = message;
  }

  const gl = canvas.getContext("webgl", { alpha: false, antialias: true });
  if (!gl) {
    fail("WebGL is not available in this browser. Try a current Firefox, Chrome, Safari, or Edge.");
    return;
  }

  const VERT = `attribute vec3 a_pos;
attribute vec3 a_nrm;
uniform mat4 u_mvp;
uniform mat4 u_model;
varying vec3 v_nrm;
varying vec3 v_pos;
void main() {
  vec4 world = u_model * vec4(a_pos, 1.0);
  v_pos = world.xyz;
  v_nrm = mat3(u_model) * a_nrm;
  gl_Position = u_mvp * vec4(a_pos, 1.0);
}`;

  const FRAG = `precision mediump float;
varying vec3 v_nrm;
varying vec3 v_pos;
uniform vec3 u_cam;
uniform vec3 u_color;
uniform vec3 u_accent;
void main() {
  vec3 n = normalize(v_nrm);
  vec3 v = normalize(u_cam - v_pos);
  vec3 l1 = normalize(vec3(0.55, 0.85, 0.35));
  vec3 l2 = normalize(vec3(-0.75, 0.25, 0.2));
  float d1 = max(dot(n, l1), 0.0);
  float d2 = max(dot(n, l2), 0.0);
  float rim = pow(1.0 - max(dot(n, v), 0.0), 2.8);
  vec3 col = u_color * (0.10 + 0.78 * d1);
  col += u_accent * d2 * 0.38;
  col += u_accent * rim * 0.22;
  gl_FragColor = vec4(col, 1.0);
}`;

  const LINE_VERT = `attribute vec3 a_pos;
uniform mat4 u_mvp;
void main() {
  gl_Position = u_mvp * vec4(a_pos, 1.0);
}`;
  const LINE_FRAG = `precision mediump float;
uniform vec3 u_accent;
void main() {
  gl_FragColor = vec4(u_accent, 0.28);
}`;

  function compile(type, src) {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      throw new Error(gl.getShaderInfoLog(sh) || "Shader compile failed");
    }
    return sh;
  }
  function program(vs, fs) {
    const p = gl.createProgram();
    gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
    gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
    gl.linkProgram(p);
    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
      throw new Error(gl.getProgramInfoLog(p) || "Program link failed");
    }
    return p;
  }

  function mul(a, b) {
    const o = new Float32Array(16);
    for (let c = 0; c < 4; c++) {
      for (let r = 0; r < 4; r++) {
        o[c * 4 + r] =
          a[r] * b[c * 4] +
          a[4 + r] * b[c * 4 + 1] +
          a[8 + r] * b[c * 4 + 2] +
          a[12 + r] * b[c * 4 + 3];
      }
    }
    return o;
  }
  function perspective(fovy, aspect, near, far) {
    const f = 1 / Math.tan(fovy / 2);
    const nf = 1 / (near - far);
    const o = new Float32Array(16);
    o[0] = f / aspect;
    o[5] = f;
    o[10] = (far + near) * nf;
    o[11] = -1;
    o[14] = 2 * far * near * nf;
    return o;
  }
  function translate(x, y, z) {
    const o = new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]);
    o[12] = x; o[13] = y; o[14] = z;
    return o;
  }
  function rotateXY(ax, ay) {
    const cx = Math.cos(ax), sx = Math.sin(ax);
    const cy = Math.cos(ay), sy = Math.sin(ay);
    const rx = new Float32Array([1,0,0,0, 0,cx,sx,0, 0,-sx,cx,0, 0,0,0,1]);
    const ry = new Float32Array([cy,0,-sy,0, 0,1,0,0, sy,0,cy,0, 0,0,0,1]);
    return mul(ry, rx);
  }

  function torus(major, minor, R, r) {
    const pos = [];
    const nrm = [];
    const lines = [];
    function point(i, j) {
      const u = (i / major) * Math.PI * 2;
      const v = (j / minor) * Math.PI * 2;
      const cx = Math.cos(u), sx = Math.sin(u);
      const cy = Math.cos(v), sy = Math.sin(v);
      return [
        (R + r * cy) * cx,
        r * sy,
        (R + r * cy) * sx,
      ];
    }
    function tri(a, b, c) {
      const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
      const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
      let nx = uy * vz - uz * vy;
      let ny = uz * vx - ux * vz;
      let nz = ux * vy - uy * vx;
      const len = Math.hypot(nx, ny, nz) || 1;
      nx /= len; ny /= len; nz /= len;
      for (const p of [a, b, c]) {
        pos.push(p[0], p[1], p[2]);
        nrm.push(nx, ny, nz);
      }
    }
    for (let i = 0; i < major; i++) {
      for (let j = 0; j < minor; j++) {
        const a = point(i, j);
        const b = point(i + 1, j);
        const c = point(i + 1, j + 1);
        const d = point(i, j + 1);
        tri(a, b, c);
        tri(a, c, d);
        lines.push(a[0], a[1], a[2], b[0], b[1], b[2]);
        lines.push(a[0], a[1], a[2], d[0], d[1], d[2]);
      }
    }
    return {
      pos: new Float32Array(pos),
      nrm: new Float32Array(nrm),
      count: pos.length / 3,
      lines: new Float32Array(lines),
      lineCount: lines.length / 3,
    };
  }

  function grid(size, step) {
    const pts = [];
    for (let i = -size; i <= size; i += step) {
      pts.push(-size, 0, i, size, 0, i);
      pts.push(i, 0, -size, i, 0, size);
    }
    return { data: new Float32Array(pts), count: pts.length / 3 };
  }

  const mesh = torus(16, 10, 1.05, 0.42);
  const floor = grid(4.5, 0.5);
  const meshProg = program(VERT, FRAG);
  const lineProg = program(LINE_VERT, LINE_FRAG);

  function bindAttrib(prog, name, buffer, size) {
    const loc = gl.getAttribLocation(prog, name);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
  }
  function makeBuf(data) {
    const b = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, b);
    gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
    return b;
  }

  const posBuf = makeBuf(mesh.pos);
  const nrmBuf = makeBuf(mesh.nrm);
  const lineBuf = makeBuf(mesh.lines);
  const gridBuf = makeBuf(floor.data);

  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  gl.clearColor(0.043, 0.047, 0.055, 1);

  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  let yaw = 0.7;
  let pitch = 0.45;
  let auto = true;
  let dragging = false;
  let lastX = 0, lastY = 0;
  let last = 0;
  let raf = 0;

  canvas.addEventListener("pointerdown", function (e) {
    dragging = true;
    auto = false;
    lastX = e.clientX;
    lastY = e.clientY;
    canvas.setPointerCapture(e.pointerId);
  });
  canvas.addEventListener("pointerup", function () { dragging = false; });
  canvas.addEventListener("pointermove", function (e) {
    if (!dragging) return;
    yaw += (e.clientX - lastX) * 0.008;
    pitch += (e.clientY - lastY) * 0.008;
    pitch = Math.max(-0.2, Math.min(1.2, pitch));
    lastX = e.clientX;
    lastY = e.clientY;
  });

  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;
    }
    gl.viewport(0, 0, canvas.width, canvas.height);
  }

  function frame(now) {
    const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (auto && !reduced) yaw += dt * 0.45 * PARAMS.speed;
    resize();
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    const aspect = canvas.width / canvas.height;
    const proj = perspective(Math.PI / 4, aspect, 0.1, 40);
    const view = mul(translate(0, -0.15, -5.2), rotateXY(-0.35, 0));
    const model = mul(translate(0, 0.55, 0), rotateXY(pitch, yaw));
    const mvp = mul(mul(proj, view), model);
    const gridModel = translate(0, -0.85, 0);
    const gridMvp = mul(mul(proj, view), gridModel);
    const cam = [0, 1.6, 5.2];

    gl.useProgram(meshProg);
    gl.uniformMatrix4fv(gl.getUniformLocation(meshProg, "u_mvp"), false, mvp);
    gl.uniformMatrix4fv(gl.getUniformLocation(meshProg, "u_model"), false, model);
    gl.uniform3fv(gl.getUniformLocation(meshProg, "u_cam"), cam);
    gl.uniform3fv(gl.getUniformLocation(meshProg, "u_color"), hexToRgb(PARAMS.color));
    gl.uniform3fv(gl.getUniformLocation(meshProg, "u_accent"), hexToRgb(PARAMS.accent));
    bindAttrib(meshProg, "a_pos", posBuf, 3);
    bindAttrib(meshProg, "a_nrm", nrmBuf, 3);
    gl.enable(gl.CULL_FACE);
    gl.drawArrays(gl.TRIANGLES, 0, mesh.count);

    gl.useProgram(lineProg);
    gl.uniform3fv(gl.getUniformLocation(lineProg, "u_accent"), hexToRgb(PARAMS.accent));
    gl.uniformMatrix4fv(gl.getUniformLocation(lineProg, "u_mvp"), false, mvp);
    bindAttrib(lineProg, "a_pos", lineBuf, 3);
    gl.disable(gl.CULL_FACE);
    gl.drawArrays(gl.LINES, 0, mesh.lineCount);

    gl.uniformMatrix4fv(gl.getUniformLocation(lineProg, "u_mvp"), false, gridMvp);
    bindAttrib(lineProg, "a_pos", gridBuf, 3);
    gl.drawArrays(gl.LINES, 0, floor.count);

    if (!reduced) raf = requestAnimationFrame(frame);
  }

  canvas.addEventListener("webglcontextlost", function (e) {
    e.preventDefault();
    cancelAnimationFrame(raf);
    fail("The WebGL context was lost. Reload to restore the demo.");
  });

  try {
    if (reduced) frame(performance.now());
    else raf = requestAnimationFrame(frame);
  } catch (err) {
    fail(err && err.message ? err.message : String(err));
  }
})();