Home

15. PlayStation · 1995

PS1 affine warp

Colors and knobs

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

About

The PlayStation interpolated texture coordinates in screen space, not 1/z. Large triangles then swim. This rasterizer does the same, and snaps vertices to integer pixels the way the GTE did. Drag to orbit. Auto-rotation stops after the first drag.

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

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. prefers-reduced-motion freezes the first still.

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;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").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("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;
  });

  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 y2 = y * cx - z * sx; z = y * sx + z * cx; y = y2;
    const x2 = x * cy + z * sy; z = -x * sy + z * cy; x = x2;
    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 };
  }

  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 = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
    if (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;
      }
    }
  }

  function tri(pts, i0, i1, i2, uvs, tint) {
    const a = pts[i0], b = pts[i1], c = pts[i2];
    if (!a || !b || !c) return;
    if ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) <= 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);

    const floor = [
      [-5.5, -1.35, -5.5], [5.5, -1.35, -5.5], [5.5, -1.35, 5.5], [-5.5, -1.35, 5.5]
    ].map(function (p) { return project(rot(p, 0.38, yaw * 0.2)); });
    const floorTint = [rgb[0] * 0.85, rgb[1] * 0.85, rgb[2] * 0.7];
    tri(floor, 0, 1, 2, [0,0, 2,0, 2,2], floorTint);
    tri(floor, 0, 2, 3, [0,0, 2,2, 0,2], 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.55, 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) requestAnimationFrame(frame);
  }

  if (reduced) { resize(); draw(); }
  else requestAnimationFrame(frame);
})();