{"slug":"ps1","number":"15","title":"PS1 affine warp","era":"PlayStation · 1994","section":"historical","provenance":"documented","reference":{"label":"Sony PlayStation (1994): the GTE's integer vertex output and the GPU's affine, screen-space texture mapping","url":"https://en.wikipedia.org/wiki/PlayStation_technical_specifications"},"summary":"A checker cube and floor with screen-space UVs. Drag to orbit.","description":"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.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["raster","3d"],"hasGlsl":false,"interaction":"Drag on the preview to orbit. Auto-rotation stops after the first drag.","url":"https://3d-retro.com/experiments/ps1","example_url":"https://3d-retro.com/examples/ps1.html","api_url":"https://3d-retro.com/api/v1/experiments/ps1","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=\"Affine-mapped checkerboard cube and floor. The warp is the point.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/ps1\" />\n  <title>PS1 affine warp · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0c0e; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; image-rendering: pixelated; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #0b0c0e; }\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: \"#6ee7b7\",\n    speed: 1,\n    snap: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const BW = 320, BH = 180;\n  const buf = document.createElement(\"canvas\");\n  buf.width = BW; buf.height = BH;\n  const bctx = buf.getContext(\"2d\");\n  const img = bctx.createImageData(BW, BH);\n  const px = img.data;\n  const zbuf = new Float32Array(BW * BH);\n\n  let yaw = 0.4, pitch = 0.35, auto = true, dragging = false, lastX = 0, lastY = 0, last = 0;\n  let raf = 0;\n  const motionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  let reduced = motionQuery.matches;\n\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    dragging = true; auto = false; lastX = e.clientX; lastY = e.clientY;\n    canvas.setPointerCapture(e.pointerId);\n  });\n  canvas.addEventListener(\"pointerup\", function () { dragging = false; });\n  canvas.addEventListener(\"pointercancel\", function () { dragging = false; });\n  canvas.addEventListener(\"pointermove\", function (e) {\n    if (!dragging) return;\n    yaw += (e.clientX - lastX) * 0.01;\n    pitch += (e.clientY - lastY) * 0.01;\n    pitch = Math.max(-0.2, Math.min(1.1, pitch));\n    lastX = e.clientX; lastY = e.clientY;\n    wake();\n  });\n\n  // Yaw the scene about its vertical axis, then tilt it so a positive pitch\n  // means the eye looks down on it.\n  function rot(p, ax, ay) {\n    const cx = Math.cos(ax), sx = Math.sin(ax), cy = Math.cos(ay), sy = Math.sin(ay);\n    let x = p[0], y = p[1], z = p[2];\n    const x2 = x * cy + z * sy; z = -x * sy + z * cy; x = x2;\n    const y2 = y * cx + z * sx; z = -y * sx + z * cx; y = y2;\n    return [x, y, z];\n  }\n\n  function project(p) {\n    const z = p[2] + 5.2;\n    if (z < 0.2) return null;\n    const f = 155 / z;\n    let x = BW * 0.5 + p[0] * f;\n    let y = BH * 0.42 - p[1] * f;\n    if (PARAMS.snap >= 0.5) { x = (x + 0.5) | 0; y = (y + 0.5) | 0; }\n    return { x: x, y: y, z: z };\n  }\n\n  // Signed screen area: negative for a face that is counter-clockwise in\n  // world space seen from the eye, because screen y runs downward.\n  function signedArea(a, b, c) {\n    return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);\n  }\n\n  function baryFill(a, b, c, uvs, tint) {\n    const minx = Math.max(0, Math.min(a.x, b.x, c.x) | 0);\n    const maxx = Math.min(BW - 1, Math.max(a.x, b.x, c.x) | 0);\n    const miny = Math.max(0, Math.min(a.y, b.y, c.y) | 0);\n    const maxy = Math.min(BH - 1, Math.max(a.y, b.y, c.y) | 0);\n    const area = signedArea(a, b, c);\n    if (Math.abs(area) <= 1) return;\n    const inv = 1 / area;\n    for (let y = miny; y <= maxy; y++) {\n      for (let x = minx; x <= maxx; x++) {\n        const w0 = ((b.x - x) * (c.y - y) - (b.y - y) * (c.x - x)) * inv;\n        const w1 = ((c.x - x) * (a.y - y) - (c.y - y) * (a.x - x)) * inv;\n        const w2 = 1 - w0 - w1;\n        if (w0 < 0 || w1 < 0 || w2 < 0) continue;\n        const z = w0 * a.z + w1 * b.z + w2 * c.z;\n        const zi = y * BW + x;\n        if (z >= zbuf[zi]) continue;\n        zbuf[zi] = z;\n        const u = w0 * uvs[0] + w1 * uvs[2] + w2 * uvs[4];\n        const v = w0 * uvs[1] + w1 * uvs[3] + w2 * uvs[5];\n        const chk = (((u * 8) | 0) ^ ((v * 8) | 0)) & 1;\n        const fog = 1 / (1 + (z - 2.2) * 0.18);\n        const k = (chk ? 1 : 0.22) * fog;\n        const i = zi * 4;\n        px[i] = tint[0] * k;\n        px[i + 1] = tint[1] * k;\n        px[i + 2] = tint[2] * k;\n        px[i + 3] = 255;\n      }\n    }\n  }\n\n  // Faces are wound counter-clockwise seen from outside. A vertex closer than\n  // the near limit projects to null and drops the whole triangle; the PS1 had\n  // no near-plane clipping either.\n  function tri(pts, i0, i1, i2, uvs, tint) {\n    const a = pts[i0], b = pts[i1], c = pts[i2];\n    if (!a || !b || !c) return;\n    if (signedArea(a, b, c) >= 0) return;\n    baryFill(a, b, c, uvs, tint);\n  }\n\n  function draw() {\n    const rgb = hexToRgb(PARAMS.color);\n    px.fill(0);\n    for (let i = 0; i < px.length; i += 4) {\n      const y = (i / 4 / BW) | 0;\n      px[i] = 12; px[i + 1] = 14; px[i + 2] = 18; px[i + 3] = 255;\n      if (y < BH * 0.48) {\n        px[i] = 18; px[i + 1] = 22; px[i + 2] = 32;\n      }\n    }\n    zbuf.fill(1e9);\n\n    // The floor is a 12x12 grid of quads rather than one big one: triangles\n    // that reach behind the eye are dropped whole, so small cells keep the\n    // hole off screen while the large-triangle swim stays visible.\n    const FLOOR = 6, CELLS = 12, cell = (FLOOR * 2) / CELLS;\n    const floor = [];\n    for (let j = 0; j <= CELLS; j++) {\n      for (let i = 0; i <= CELLS; i++) {\n        floor.push(project(rot([-FLOOR + i * cell, -1.35, -FLOOR + j * cell], pitch, yaw)));\n      }\n    }\n    const floorTint = [rgb[0] * 0.85, rgb[1] * 0.85, rgb[2] * 0.7];\n    for (let j = 0; j < CELLS; j++) {\n      for (let i = 0; i < CELLS; i++) {\n        const a = j * (CELLS + 1) + i, b = a + 1, c = a + CELLS + 2, d = a + CELLS + 1;\n        const u0 = i / CELLS * 4, u1 = (i + 1) / CELLS * 4, v0 = j / CELLS * 4, v1 = (j + 1) / CELLS * 4;\n        tri(floor, a, b, c, [u0,v0, u1,v0, u1,v1], floorTint);\n        tri(floor, a, c, d, [u0,v0, u1,v1, u0,v1], floorTint);\n      }\n    }\n\n    const cube = [\n      [-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],\n      [-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]\n    ];\n    const sp = cube.map(function (p) {\n      return project(rot([p[0] * 0.85, p[1] * 0.85 - 0.3, p[2] * 0.85], pitch, yaw));\n    });\n    const UV = [0,0, 1,0, 1,1];\n    const faces = [\n      [0,1,2, 0,2,3], [5,4,7, 5,7,6], [4,0,3, 4,3,7],\n      [1,5,6, 1,6,2], [3,2,6, 3,6,7], [4,5,1, 4,1,0]\n    ];\n    const tints = [\n      rgb,\n      [rgb[0]*0.55, rgb[1]*0.55, rgb[2]*0.55],\n      [rgb[0]*0.75, rgb[1]*0.7, rgb[2]*0.55],\n      [rgb[0]*0.7, rgb[1]*0.8, rgb[2]*0.75],\n      [Math.min(255, rgb[0]*1.1), Math.min(255, rgb[1]*1.1), rgb[2]],\n      [rgb[0]*0.4, rgb[1]*0.4, rgb[2]*0.45]\n    ];\n    for (let f = 0; f < faces.length; f++) {\n      const id = faces[f];\n      tri(sp, id[0], id[1], id[2], [0,0, 1,0, 1,1], tints[f]);\n      tri(sp, id[3], id[4], id[5], [0,0, 1,1, 0,1], tints[f]);\n    }\n\n    bctx.putImageData(img, 0, 0);\n    ctx.imageSmoothingEnabled = false;\n    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);\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  }\n\n  function frame(now) {\n    const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (auto && !reduced) yaw += dt * 0.45 * PARAMS.speed;\n    resize();\n    draw();\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  // Motion. The system's reduced-motion preference decides by default. A\n  // standalone copy animates anyway with \"#motion=play\" in its URL (or holds a\n  // frame with \"#motion=still\"); an embedding page can post {motion: \"play\"}.\n  let motionMode = \"auto\";\n  function applyMotion() {\n    const next = motionMode === \"still\" || (motionMode === \"auto\" && motionQuery.matches);\n    if (next === reduced) return;\n    reduced = next;\n    last = 0;\n    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }\n    else if (!raf) raf = requestAnimationFrame(frame);\n  }\n  function setMotion(mode) {\n    if (mode === \"play\" || mode === \"still\" || mode === \"auto\") { motionMode = mode; applyMotion(); }\n  }\n  function readMotionHash() {\n    const m = /motion=(play|still|auto)/.exec(location.hash);\n    let mode = m ? m[1] : \"auto\";\n    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.\n    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === \"string\") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }\n    setMotion(mode);\n  }\n  function wake() { if (reduced) frame(performance.now()); }\n  if (motionQuery.addEventListener) motionQuery.addEventListener(\"change\", applyMotion);\n  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);\n  window.addEventListener(\"hashchange\", readMotionHash);\n  window.addEventListener(\"message\", function (e) { if (e.data && typeof e.data.motion === \"string\") setMotion(e.data.motion); });\n  window.addEventListener(\"resize\", wake);\n\n  window.__ps1Test = {\n    rot: rot, project: project, signedArea: signedArea,\n    setView: function (y, p) { yaw = y; pitch = p; auto = false; wake(); },\n    pixel: function (x, y) { const i = (y * BW + x) * 4; return [px[i], px[i + 1], px[i + 2]]; }\n  };\n\n  if (reduced) frame(performance.now());\n  else raf = requestAnimationFrame(frame);\n  readMotionHash();\n})();\n</script>\n</body>\n</html>\n"}