I have a camera class that is supposed to project a 3d point to 2d, but I don't know how to properly calculate the perspective projection for the 3d point. I've tried dividing x and y by z, but I don't think it is the correct formula for perspective projection.
I am using a form of the left handed coordinate system. (Illustrated below)
(X is left and right, Y is up and down, and Z is forward and backward.)
Here is all of the code:
function degreesToRadians(degrees) {
return degrees * Math.PI / 180;
}
function random(min, max) {
return Math.random() * (max - min) + min;
}
function clamp(min, max, value) {
if (value < min) {
value = min;
} else if (value > max) {
value = max;
}
return value;
}
class Camera {
constructor(x = 0, y = 0, z = 0, fov = 60, rotation = {
x: 0,
y: 0,
z: 0
}, zNear = 0.1, zFar = 1000) {
this.x = x;
this.y = y;
this.z = z;
this.fov = fov;
this.rotation = rotation;
this.zNear = zNear;
this.zFar = zFar;
}
project3dPointTo2d(point) {
// Fix these calculations
return {
x: point.x / point.z,
y: point.y / point.z
}
}
}
/**
* @type { HTMLCanvasElement }
*/
var scene = document.getElementById("scene");
var ctx = scene.getContext("2d");
var vWidth = window.innerWidth;
var vHeight = window.innerHeight;
var fps = 60;
var updateLoop;
var keysDown = [];
function resizeCanvas() {
vWidth = window.innerWidth;
vHeight = window.innerHeight;
scene.width = vWidth;
scene.height = vHeight;
}
resizeCanvas();
var testcamera = new Camera(0, 0, -10, 60, {
x: 0,
y: 0,
z: 0
}, 0.1, 1000);
var testpoint = {
x: 0,
y: 0,
z: 100
}
function main() {
// Logic
if (keysDown["w"]) {
testcamera.z++;
}
if (keysDown["s"]) {
testcamera.z--;
}
if (keysDown["a"]) {
testcamera.x--;
}
if (keysDown["d"]) {
testcamera.x++;
}
var proj2dPoint = testcamera.project3dPointTo2d(testpoint);
// Drawing
ctx.clearRect(0, 0, vWidth, vHeight);
ctx.save();
ctx.fillStyle = "#000000";
ctx.beginPath();
ctx.arc(proj2dPoint.x, proj2dPoint.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.closePath();
ctx.restore();
}
window.onload = function() {
updateLoop = setInterval(main, 1000 / fps);
}
window.addEventListener("keydown", (e) => {
e.preventDefault();
keysDown[e.key] = true;
});
window.addEventListener("keyup", (e) => {
e.preventDefault();
keysDown[e.key] = false;
});
window.addEventListener("resize", (e) => {
resizeCanvas();
});
*,
*:before,
*:after {
font-family: roboto, Arial, Helvetica, sans-serif, system-ui;
padding: 0px 0px;
margin: 0px 0px;
}
canvas {
display: block;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<canvas id="scene"></canvas>
</body>
</html>
(You can see the point is in the top left instead of the center of the screen)
First, the reason your projected point is at the top-left of the screen is simply that the origin (x=0 ; y=0)
of the underlying canvas' coordinate system is at the top-left. If you want something to be centered, you have to either move the origin to another position or just translate each point by a constant value e.g. half the canvas's width for a horizontal translation.
This can be done inside your project3dPointTo2d
method before returning the projected point.
Second, for a simple perspective projection you usually scale the projected points by a given factor, so that points further from the screen plane appear to be closer together. In you code this would be controlled by the fov
variable and a point's z
value. Unfortunately your calculation is incomplete.
Lastly, to see that your projection is working you of course need more than one point. A good start is always a simple cube made of 8 points.
Here's an example based on your code:
function degreesToRadians(degrees) {
return degrees * Math.PI / 180;
}
function random(min, max) {
return Math.random() * (max - min) + min;
}
function clamp(min, max, value) {
if (value < min) {
value = min;
} else if (value > max) {
value = max;
}
return value;
}
class Camera {
constructor(x = 0, y = 0, z = 0, fov = 60, rotation = {
x: 0,
y: 0,
z: 0
}, zNear = 0.1, zFar = 1000) {
this.x = x;
this.y = y;
this.z = z;
this.fov = fov;
this.rotation = rotation;
this.zNear = zNear;
this.zFar = zFar;
}
project3dPointTo2d(point) {
let scale = this.fov / (this.fov + point.z);
return {
x: point.x * scale + vWidth / 2,
y: point.y * scale + vHeight / 2
}
}
}
/**
* @type { HTMLCanvasElement }
*/
var scene = document.getElementById("scene");
var ctx = scene.getContext("2d");
var vWidth = window.innerWidth;
var vHeight = window.innerHeight;
var fps = 60;
var updateLoop;
var keysDown = [];
function resizeCanvas() {
vWidth = window.innerWidth;
vHeight = window.innerHeight;
scene.width = vWidth;
scene.height = vHeight;
}
resizeCanvas();
var testcamera = new Camera(0, 0, -10, 60, {
x: 0,
y: 0,
z: 0
}, 0.1, 1000);
let points = [{
x: -50,
y: -50,
z: 0
},
{
x: 50,
y: -50,
z: 0
},
{
x: -50,
y: 50,
z: 0
},
{
x: 50,
y: 50,
z: 0
},
{
x: -50,
y: -50,
z: 50
},
{
x: 50,
y: -50,
z: 50
},
{
x: -50,
y: 50,
z: 50
},
{
x: 50,
y: 50,
z: 50
}
];
function main() {
// Logic
if (keysDown["w"]) {
testcamera.z++;
}
if (keysDown["s"]) {
testcamera.z--;
}
if (keysDown["a"]) {
testcamera.x--;
}
if (keysDown["d"]) {
testcamera.x++;
}
let proj2dPoint;
// Drawing
ctx.clearRect(0, 0, vWidth, vHeight);
ctx.save();
ctx.fillStyle = "#000000";
points.forEach(point => {
ctx.beginPath();
proj2dPoint = testcamera.project3dPointTo2d(point);
ctx.arc(proj2dPoint.x, proj2dPoint.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.closePath();
});
ctx.restore();
}
window.onload = function() {
updateLoop = setInterval(main, 1000 / fps);
}
window.addEventListener("keydown", (e) => {
e.preventDefault();
keysDown[e.key] = true;
});
window.addEventListener("keyup", (e) => {
e.preventDefault();
keysDown[e.key] = false;
});
window.addEventListener("resize", (e) => {
resizeCanvas();
});
*,
*:before,
*:after {
font-family: roboto, Arial, Helvetica, sans-serif, system-ui;
padding: 0px 0px;
margin: 0px 0px;
}
canvas {
display: block;
}
<canvas id="scene"></canvas>