Last active
January 16, 2026 23:24
-
-
Save nitori/37e0d18bc7a57328a9bd6683a66966d3 to your computer and use it in GitHub Desktop.
quick and messy 3d projection to canvas in pure javascript.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @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