15. PlayStation · 1994 · Retro techniques
PS1 affine warp
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
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();
})();