Skip to content

Instantly share code, notes, and snippets.

@nitori
Last active January 16, 2026 23:24
Show Gist options
  • Select an option

  • Save nitori/37e0d18bc7a57328a9bd6683a66966d3 to your computer and use it in GitHub Desktop.

Select an option

Save nitori/37e0d18bc7a57328a9bd6683a66966d3 to your computer and use it in GitHub Desktop.
quick and messy 3d projection to canvas in pure javascript.
/**
* @typedef {{x:number, y:number}} Point
* @typedef {{x:number, y:number, z:number}} Vec3
* @typedef {{x:number, y:number, z:number, w:number}} Vec4
* @typedef {number[][]} Mat4
*/
/** @type {HTMLCanvasElement} */
let canvas = document.getElementById('mycanvas');
let ctx = canvas.getContext('2d');
function clear() {
ctx.fillStyle = '#101010';
ctx.fillRect(0, 0, 1280, 720);
}
/**
* @param p1 {Point}
* @param p2 {Point}
*/
function drawLine(p1, p2) {
ctx.lineWidth = 2;
ctx.strokeStyle = 'red';
ctx.beginPath()
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
ctx.closePath();
}
/**
* @param p {Point}
*/
function drawPoint(p) {
ctx.fillStyle = 'red';
ctx.fillRect(p.x - 1, p.y - 1, 2, 2);
}
/**
* @param v {Vec3}
* @return {Point}
*/
function toScreen(v) {
return {
x: (v.x * 0.5 + 0.5) * 1280,
y: (1 - (v.y * 0.5 + 0.5)) * 720,
};
}
function P(x, y, z, w) {
if (w !== undefined) {
return {x, y, z, w};
}
return {x, y, z};
}
function or(...args) {
for (let i = 0; i < args.length; i++) {
if (args[i] !== undefined) {
return args[i];
}
}
return undefined;
}
function printMat(m) {
console.log(`[\n ${m[0].join(', ')},\n ${m[1].join(', ')},\n ${m[2].join(', ')},\n ${m[3].join(', ')}\n]`);
}
function row(m, index) {
return P(...m[index]);
}
function col(m, index) {
return P(m[0][index], m[1][index], m[2][index], m[3][index]);
}
/**
* @param v1 {Vec3|Vec4}
* @param v2 {Vec3|Vec4}
* @return {number}
*/
function dot(v1, v2) {
return v1.x * v2.x
+ v1.y * v2.y
+ v1.z * v2.z
+ (v1.w !== undefined || v2.w !== undefined ? (or(v1.w, 1) * or(v2.w, 1)) : 0);
}
function newMat() {
return [
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
];
}
/**
* @param m1 {Mat4}
* @param m2 {Mat4}
*/
function matMul(m1, m2) {
const r0 = row(m1, 0);
const r1 = row(m1, 1);
const r2 = row(m1, 2);
const r3 = row(m1, 3);
const c0 = col(m2, 0);
const c1 = col(m2, 1);
const c2 = col(m2, 2);
const c3 = col(m2, 3);
return [
[dot(r0, c0), dot(r0, c1), dot(r0, c2), dot(r0, c3)],
[dot(r1, c0), dot(r1, c1), dot(r1, c2), dot(r1, c3)],
[dot(r2, c0), dot(r2, c1), dot(r2, c2), dot(r2, c3)],
[dot(r3, c0), dot(r3, c1), dot(r3, c2), dot(r3, c3)],
];
}
/**
* @param m {Mat4}
* @param v {Vec3|Vec4}
* @return {Vec4}
*/
function vecMatMul(m, v) {
const r0 = row(m, 0);
const r1 = row(m, 1);
const r2 = row(m, 2);
const r3 = row(m, 3);
return {
x: dot(r0, v),
y: dot(r1, v),
z: dot(r2, v),
w: dot(r3, v),
}
}
/**
* @param v {Vec3}
* @return {Mat4}
*/
function translation(v) {
return [
[1, 0, 0, v.x],
[0, 1, 0, v.y],
[0, 0, 1, v.z],
[0, 0, 0, 1],
];
}
/**
* @param v {Vec3}
* @return {Mat4}
*/
function scaling(v) {
return [
[v.x, 0, 0, 0],
[0, v.y, 0, 0],
[0, 0, v.z, 0],
[0, 0, 0, 1],
];
}
/**
* @param axis {Vec3}
* @param angle {number}
* @return {Mat4}
*/
function rotation(axis, angle) {
let {x, y, z} = normalize(axis);
const c = Math.cos(angle);
const s = Math.sin(angle);
const t = 1 - c;
return [
[t * x * x + c, t * x * y - s * z, t * x * z + s * y, 0],
[t * x * y + s * z, t * y * y + c, t * y * z - s * x, 0],
[t * x * z - s * y, t * y * z + s * x, t * z * z + c, 0],
[0, 0, 0, 1],
];
}
/**
* @param v {Vec3}
*/
function normalize(v) {
let {x, y, z} = v;
const len = Math.hypot(x, y, z);
x /= len;
y /= len;
z /= len;
return {x, y, z};
}
/**
* @param v {Vec3|number}
* @param k {string}
* @return {number}
*/
function val(v, k) {
return v[k] !== undefined ? v[k] : v;
}
/**
* @param v1 {Vec3}
* @param v2 {Vec3|number}
*/
function sub(v1, v2) {
return {
x: v1.x - val(v2, 'x'),
y: v1.y - val(v2, 'y'),
z: v1.z - val(v2, 'z'),
}
}
/**
* @param v1 {Vec3}
* @param v2 {Vec3|number}
*/
function div(v1, v2) {
return {
x: v1.x / val(v2, 'x'),
y: v1.y / val(v2, 'y'),
z: v1.z / val(v2, 'z'),
}
}
/**
* @param v1 {Vec3}
* @param v2 {Vec3}
*/
function cross(v1, v2) {
return {
x: v1.y * v2.z - v1.z * v2.y,
y: v1.z * v2.x - v1.x * v2.z,
z: v1.x * v2.y - v1.y * v2.x,
};
}
function radians(deg) {
return deg / 180 * Math.PI;
}
/**
* @param {{translate: Mat4, scale: Mat4, rotate: Mat4}} t
*/
function transform(t) {
// M = T * R * S
let translate = t.translate || newMat();
let rotate = t.rotate || newMat();
let scale = t.scale || newMat();
return matMul(translate, matMul(rotate, scale));
}
function perspective(fov, aspect, near, far) {
const f = 1 / Math.tan(fov / 2);
const nf = 1 / (near - far);
return [
[f / aspect, 0, 0, 0],
[0, f, 0, 0],
[0, 0, (far + near) * nf, (2 * far * near) * nf],
[0, 0, -1, 0],
];
}
function lookAt(eye, target, up) {
const f = normalize(sub(target, eye));
const s = normalize(cross(f, up));
const u = cross(s, f);
return [
[s.x, s.y, s.z, -dot(s, eye)],
[u.x, u.y, u.z, -dot(u, eye)],
[-f.x, -f.y, -f.z, dot(f, eye)],
[0, 0, 0, 1],
];
}
/** @type {Vec3[]} */
const box = [
P(-1, -1, -1), //0
P(-1, -1, 1), //1
P(-1, 1, -1), //2
P(-1, 1, 1), //3
P(1, -1, -1), //4
P(1, -1, 1), //5
P(1, 1, -1), //6
P(1, 1, 1), //7
];
const faces = [
[0, 1, 3, 2],
[4, 5, 7, 6],
[0, 4],
[1, 5],
[3, 7],
[2, 6],
];
let zoomLevels = [20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120];
let zoom = 4;
let cam_pos = P(10, 5, 10);
let cam_target = P(0, 0, 0);
let cam_up = P(0, 1, 0);
let proj = perspective(radians(zoomLevels[zoom]), 1280 / 720, 0.1, 1000.0);
let view = lookAt(cam_pos, cam_target, cam_up);
canvas.addEventListener('wheel', ev => {
if (ev.deltaY > 0) {
zoom = Math.min(zoom + 1, zoomLevels.length - 1);
} else if (ev.deltaY < 0) {
zoom = Math.max(zoom - 1, 0);
}
proj = perspective(radians(zoomLevels[zoom]), 1280 / 720, 0.1, 1000.0);
});
const forward = normalize(sub(cam_target, cam_pos));
let yaw = Math.atan2(forward.x, -forward.z);
let pitch = Math.asin(forward.y);
let sensitivity = 0.001;
let drag = false;
canvas.addEventListener('mousedown', ev => {
if (ev.button !== 0) return;
drag = true;
});
canvas.addEventListener('mouseup', ev => {
if (ev.button !== 0) return;
drag = false;
});
canvas.addEventListener('mousemove', ev => {
if (!drag) return;
yaw += ev.movementX * -sensitivity;
pitch += ev.movementY * sensitivity;
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
const forward = P(sy * cp, sp, -cy * cp);
let d = 1;
const target = P(
cam_pos.x + forward.x * d,
cam_pos.y + forward.y * d,
cam_pos.z + forward.z * d
);
view = lookAt(cam_pos, target, P(0, 1, 0));
});
function worldToScreen(mvp, v) {
let clip = vecMatMul(mvp, v);
let ndc = div(clip, clip.w);
return toScreen(ndc);
}
let angle = 0;
let start = null;
function update(time) {
clear();
if (start === null) {
start = time;
}
let progress = (time - start) / 1000.0;
angle = radians((90 * progress) % 360);
const model = transform({
translate: translation(P(-2, 0, 0)),
rotate: rotation(P(0, 1, 0), angle),
});
const model2 = transform({
translate: translation(P(2, 1, 0)),
rotate: rotation(P(0, 1, 0), -angle),
scale: scaling(P(1, 2, 1)),
});
const mvp = matMul(proj, matMul(view, model));
const mvp2 = matMul(proj, matMul(view, model2));
faces.forEach(face => {
for (let f = 0; f < face.length; f++) {
let p1 = worldToScreen(mvp, box[face[f]]);
let p2 = worldToScreen(mvp, box[face[(f + 1) % face.length]]);
drawLine(p1, p2);
let p3 = worldToScreen(mvp2, box[face[f]]);
let p4 = worldToScreen(mvp2, box[face[(f + 1) % face.length]]);
drawLine(p3, p4);
}
});
window.requestAnimationFrame(update);
}
window.requestAnimationFrame(update);
// related HTML:
/*
<!doctype html>
<html>
<head>
<title>MVP test</title>
<style>
*, *:before, *:after {
box-sizing: border-box;
}
body {
font-family: sans-serif;
font-size: 20px;
background: black;
color: white;
padding: 0;
}
.main {
max-width: 1280px;
margin: 50px auto;
}
</style>
</head>
<body>
<div class="main">
<canvas id="mycanvas" width="1280" height="720"></canvas>
</div>
<script src="script.js"></script>
</body>
</html>
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment