Home

03. Flat-shaded workstation demo · c. 1990 · Retro techniques

Retro torus

Demos are animating. Following your system setting.
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, over a ground grid. Edges are drawn as a second line pass. It is the flat-shaded, gridded-floor look of early 1990s graphics-workstation demos in general, not a port of any one demo reel. 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.

Reference

Interpretation: this evokes a period look and does not reproduce one specific original.

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. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

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) {
      // Counter-clockwise seen from outside; the flat normal is (b-a) x (c-a).
      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);
        // u runs around the ring and v around the tube, so a->b->c is clockwise
        // from outside. Emit the quad the other way round to face outward.
        tri(a, c, b);
        tri(a, d, c);
        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 motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.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("pointercancel", 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;
    wake();
  });

  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);
    // Camera 5.2 units back, tilted down 0.35 rad so it looks over the grid at
    // the torus. The eye position below is that view matrix inverted.
    const TILT = 0.35;
    const view = mul(translate(0, -0.15, -5.2), rotateXY(TILT, 0));
    const cam = [0, 0.15 * Math.cos(TILT) + 5.2 * Math.sin(TILT), 5.2 * Math.cos(TILT) - 0.15 * Math.sin(TILT)];
    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);

    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);
    // Push the faces back a hair so the wire pass, at the same depth, wins.
    gl.enable(gl.POLYGON_OFFSET_FILL);
    gl.polygonOffset(1, 1);
    gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
    gl.disable(gl.POLYGON_OFFSET_FILL);

    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.");
  });

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  window.__torusTest = { torus: torus, getView: function () { return { yaw: yaw, pitch: pitch }; } };

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