Search code examples
javascript3dhtml5-canvas2dprojection

How to project 3d vertex to 2d vertex in JavaScript using 2d canvas?


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)

enter image description here

(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)


Solution

  • 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>