Home

15. PlayStation · 1994 · Retro techniques

PS1 affine warp

Demos are animating. Following your system setting.
Colors and knobs

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

About

The PlayStation's GPU interpolated texture coordinates in screen space, not 1/z, so large triangles swim, and its GTE handed over integer vertex positions, so edges wobble. This rasterizer does both on a 320-wide buffer. The floor is split into small cells because, like the original, it has no near-plane clipping: a triangle that reaches behind the eye is dropped whole. Drag to orbit. Auto-rotation stops after the first drag.

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

Reference

Sony PlayStation (1994): the GTE's integer vertex output and the GPU's affine, screen-space texture mapping

Browser APIs

  • Canvas 2D
  • ImageData
  • 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: "#6ee7b7",
    speed: 1,
    snap: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const BW = 320, BH = 180;
  const buf = document.createElement("canvas");
  buf.width = BW; buf.height = BH;
  const bctx = buf.getContext("2d");
  const img = bctx.createImageData(BW, BH);
  const px = img.data;
  const zbuf = new Float32Array(BW * BH);

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

  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.01;
    pitch += (e.clientY - lastY) * 0.01;
    pitch = Math.max(-0.2, Math.min(1.1, pitch));
    lastX = e.clientX; lastY = e.clientY;
    wake();
  });

  // Yaw the scene about its vertical axis, then tilt it so a positive pitch
  // means the eye looks down on it.
  function rot(p, ax, ay) {
    const cx = Math.cos(ax), sx = Math.sin(ax), cy = Math.cos(ay), sy = Math.sin(ay);
    let x = p[0], y = p[1], z = p[2];
    const x2 = x * cy + z * sy; z = -x * sy + z * cy; x = x2;
    const y2 = y * cx + z * sx; z = -y * sx + z * cx; y = y2;
    return [x, y, z];
  }

  function project(p) {
    const z = p[2] + 5.2;
    if (z < 0.2) return null;
    const f = 155 / z;
    let x = BW * 0.5 + p[0] * f;
    let y = BH * 0.42 - p[1] * f;
    if (PARAMS.snap >= 0.5) { x = (x + 0.5) | 0; y = (y + 0.5) | 0; }
    return { x: x, y: y, z: z };
  }

  // Signed screen area: negative for a face that is counter-clockwise in
  // world space seen from the eye, because screen y runs downward.
  function signedArea(a, b, c) {
    return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
  }

  function baryFill(a, b, c, uvs, tint) {
    const minx = Math.max(0, Math.min(a.x, b.x, c.x) | 0);
    const maxx = Math.min(BW - 1, Math.max(a.x, b.x, c.x) | 0);
    const miny = Math.max(0, Math.min(a.y, b.y, c.y) | 0);
    const maxy = Math.min(BH - 1, Math.max(a.y, b.y, c.y) | 0);
    const area = signedArea(a, b, c);
    if (Math.abs(area) <= 1) return;
    const inv = 1 / area;
    for (let y = miny; y <= maxy; y++) {
      for (let x = minx; x <= maxx; x++) {
        const w0 = ((b.x - x) * (c.y - y) - (b.y - y) * (c.x - x)) * inv;
        const w1 = ((c.x - x) * (a.y - y) - (c.y - y) * (a.x - x)) * inv;
        const w2 = 1 - w0 - w1;
        if (w0 < 0 || w1 < 0 || w2 < 0) continue;
        const z = w0 * a.z + w1 * b.z + w2 * c.z;
        const zi = y * BW + x;
        if (z >= zbuf[zi]) continue;
        zbuf[zi] = z;
        const u = w0 * uvs[0] + w1 * uvs[2] + w2 * uvs[4];
        const v = w0 * uvs[1] + w1 * uvs[3] + w2 * uvs[5];
        const chk = (((u * 8) | 0) ^ ((v * 8) | 0)) & 1;
        const fog = 1 / (1 + (z - 2.2) * 0.18);
        const k = (chk ? 1 : 0.22) * fog;
        const i = zi * 4;
        px[i] = tint[0] * k;
        px[i + 1] = tint[1] * k;
        px[i + 2] = tint[2] * k;
        px[i + 3] = 255;
      }
    }
  }

  // Faces are wound counter-clockwise seen from outside. A vertex closer than
  // the near limit projects to null and drops the whole triangle; the PS1 had
  // no near-plane clipping either.
  function tri(pts, i0, i1, i2, uvs, tint) {
    const a = pts[i0], b = pts[i1], c = pts[i2];
    if (!a || !b || !c) return;
    if (signedArea(a, b, c) >= 0) return;
    baryFill(a, b, c, uvs, tint);
  }

  function draw() {
    const rgb = hexToRgb(PARAMS.color);
    px.fill(0);
    for (let i = 0; i < px.length; i += 4) {
      const y = (i / 4 / BW) | 0;
      px[i] = 12; px[i + 1] = 14; px[i + 2] = 18; px[i + 3] = 255;
      if (y < BH * 0.48) {
        px[i] = 18; px[i + 1] = 22; px[i + 2] = 32;
      }
    }
    zbuf.fill(1e9);

    // The floor is a 12x12 grid of quads rather than one big one: triangles
    // that reach behind the eye are dropped whole, so small cells keep the
    // hole off screen while the large-triangle swim stays visible.
    const FLOOR = 6, CELLS = 12, cell = (FLOOR * 2) / CELLS;
    const floor = [];
    for (let j = 0; j <= CELLS; j++) {
      for (let i = 0; i <= CELLS; i++) {
        floor.push(project(rot([-FLOOR + i * cell, -1.35, -FLOOR + j * cell], pitch, yaw)));
      }
    }
    const floorTint = [rgb[0] * 0.85, rgb[1] * 0.85, rgb[2] * 0.7];
    for (let j = 0; j < CELLS; j++) {
      for (let i = 0; i < CELLS; i++) {
        const a = j * (CELLS + 1) + i, b = a + 1, c = a + CELLS + 2, d = a + CELLS + 1;
        const u0 = i / CELLS * 4, u1 = (i + 1) / CELLS * 4, v0 = j / CELLS * 4, v1 = (j + 1) / CELLS * 4;
        tri(floor, a, b, c, [u0,v0, u1,v0, u1,v1], floorTint);
        tri(floor, a, c, d, [u0,v0, u1,v1, u0,v1], floorTint);
      }
    }

    const cube = [
      [-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],
      [-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]
    ];
    const sp = cube.map(function (p) {
      return project(rot([p[0] * 0.85, p[1] * 0.85 - 0.3, p[2] * 0.85], pitch, yaw));
    });
    const UV = [0,0, 1,0, 1,1];
    const faces = [
      [0,1,2, 0,2,3], [5,4,7, 5,7,6], [4,0,3, 4,3,7],
      [1,5,6, 1,6,2], [3,2,6, 3,6,7], [4,5,1, 4,1,0]
    ];
    const tints = [
      rgb,
      [rgb[0]*0.55, rgb[1]*0.55, rgb[2]*0.55],
      [rgb[0]*0.75, rgb[1]*0.7, rgb[2]*0.55],
      [rgb[0]*0.7, rgb[1]*0.8, rgb[2]*0.75],
      [Math.min(255, rgb[0]*1.1), Math.min(255, rgb[1]*1.1), rgb[2]],
      [rgb[0]*0.4, rgb[1]*0.4, rgb[2]*0.45]
    ];
    for (let f = 0; f < faces.length; f++) {
      const id = faces[f];
      tri(sp, id[0], id[1], id[2], [0,0, 1,0, 1,1], tints[f]);
      tri(sp, id[3], id[4], id[5], [0,0, 1,1, 0,1], tints[f]);
    }

    bctx.putImageData(img, 0, 0);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);
  }

  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;
    }
  }

  function frame(now) {
    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
    last = now;
    if (auto && !reduced) yaw += dt * 0.45 * PARAMS.speed;
    resize();
    draw();
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // 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.__ps1Test = {
    rot: rot, project: project, signedArea: signedArea,
    setView: function (y, p) { yaw = y; pitch = p; auto = false; wake(); },
    pixel: function (x, y) { const i = (y * BW + x) * 4; return [px[i], px[i + 1], px[i + 2]]; }
  };

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();