Search code examples
scalageometryprojectionperspective

My unworking implementation of perspective projection


I wrote a program that takes in entry some points, expressed in 3D coordinates and that must be drawn in a 2D canvas. I use perspective projection, homogeneous coordinates and similar triangles to do that. However, my program does not work and I actually don't know why.

I followed two tutorials. I really understood the geometrical definitions and properties I have read. However, my implementation fails... I will write references to these both courses little by little, to make your reading more confortable :).

Overview : geometrical reminders

The perspective projection is done following this workflow (cf. these 2 courses - I wrote pertinent links (HTML anchors) further down, in this post) :

  1. Definition of the points to draw, expressed according to the world's coordinates system ; Definition of the matrix of projection, which is a matrix of transformation that "converts" a point expressed according to the world coordinates system into a point expressed according to the camera's coordinates system (NB : this matrix also can be understood as being the camera)

  2. Product of these points with this matrix (as defined in the adequat part, below) : the product these points results in the conversion of these points to the camera's coordinates system. Note that points and matrix are expressed in 4D (concept of homogenous coordinates).

  3. Use of similar triangles concept to project (only computing is done at this step) on the canvas the in-camera-expressed points (using their 4D coordinates) : they are now expressed in 3D (the third coordinate is computed but not actually used on the canvas)

  4. Last step : rasterization, to actually draw the pixels on the canvas (other computing AND displaying are done at this step).

First, the problem

Well, I want to draw a cube but it doesn't appear. The projected points seem to be drawn on the same coordinates.

Instead of my cube, only one black pixel is visible.

enter image description here

The Scastie (snippet)

NB : since X11 is not activated on Scastie, the window I want to create won't be shown.

https://scastie.scala-lang.org/2LQ1wSMBTWqQQ7hql35sOg

Entries

Perhaps the problem is bound to the entries ? Well, I give you them.

Cube's points

Ref. : myself

val world_cube_points : Seq[Seq[Double]] = Seq(
  Seq(0, 40, 0, 1),
  Seq(0, 40, 10, 1),
  Seq(0, 0, 0, 1),
  Seq(0, 0, 10, 1),
  Seq(20, 40, 0, 1),
  Seq(20, 40, 10, 1),
  Seq(20, 0, 0, 1),
  Seq(20, 0, 10, 1)
)

Transformation (Projection) matrix

Ref. : https://github.com/ssloy/tinyrenderer/wiki/Lesson-4:-Perspective-projection#time-to-work-in-full-3d

val matrix_world_to_camera : Matrix = new Matrix(Seq(
  Seq(1, 0, 0, 0),
  Seq(0, 1, 0, 0),
  Seq(0, 0, 1, 0),
  Seq(0, 0, -1, 1)
))

Second, the first operation my program does : a simple product of a point with a matrix.

Ref. : https://github.com/ssloy/tinyrenderer/wiki/Lesson-4:-Perspective-projection#homogeneous-coordinates

/**
  * Matrix in the shape of (use of homogeneous coordinates) :
  * c00 c01 c02 c03
  * c10 c11 c12 c13
  * c20 c21 c22 c23
  *   0   0   0   1
  *
  * @param content the content of the matrix
  */
class Matrix(val content : Seq[Seq[Double]]) {

  /**
    * Computes the product between a point P(x ; y ; z) and the matrix.
    *
    * @param point a point P(x ; y ; z ; 1)
    * @return a new point P'(
    *         x * c00 + y * c10 + z * c20
    *         ;
    *         x * c01 + y * c11 + z * c21
    *         ;
    *         x * c02 + y * c12 + z * c22
    *         ;
    *         1
    *         )
    */
  def product(point : Seq[Double]) : Seq[Double] = {
    (0 to 3).map(
      i => content(i).zip(point).map(couple2 => couple2._1 * couple2._2).sum
    )
  }

}

Then, use of similar triangles

Ref. 1/2 : Part. "Of the Importance of Converting Points to Camera Space " of https://www.scratchapixel.com/lessons/3d-basic-rendering/computing-pixel-coordinates-of-3d-point/mathematics-computing-2d-coordinates-of-3d-points

Ref. 2/2 : https://github.com/ssloy/tinyrenderer/wiki/Lesson-4:-Perspective-projection#time-to-work-in-full-3d

NB : at this step, the entries are points expressed according to the camera (i.e. : they are the result of the precedently defined product with the precedently defined matrix).

class Projector {

  /**
    * Computes the coordinates of the projection of the point P on the canvas.
    * The canvas is assumed to be 1 unit forward the camera.
    * The computation uses the definition of the similar triangles.
    *
    * @param points the point P we want to project on the canvas. Its coordinates must be expressed in the coordinates
    *          system of the camera before using this function.
    * @return the point P', projection of P.
    */
  def drawPointsOnCanvas(points : Seq[Seq[Double]]) : Seq[Seq[Double]] = {
    points.map(point => {
      point.map(coordinate => {
        coordinate / -point(3)
      }).dropRight(1)
    })

  }

}

Finally, the drawing of the projected points, onto the canvas.

Ref. : Part. "From Screen Space to Raster Space" of https://www.scratchapixel.com/lessons/3d-basic-rendering/computing-pixel-coordinates-of-3d-point/mathematics-computing-2d-coordinates-of-3d-points

import java.awt.Graphics
import javax.swing.JFrame

/**
  * Assumed to be 1 unit forward the camera.
  * Contains the drawn points.
  */
class Canvas(val drawn_points : Seq[Seq[Double]]) extends JFrame {

  val CANVAS_WIDTH = 60
  val CANVAS_HEIGHT = 60
  val IMAGE_WIDTH = 55
  val IMAGE_HEIGHT = 55

  def display = {
    setTitle("Perlin")
    setSize(CANVAS_WIDTH, CANVAS_HEIGHT)
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)
    setVisible(true)
  }

  override def paint(graphics : Graphics): Unit = {
    super.paint(graphics)
    drawn_points.foreach(point => {

      if(!(Math.abs(point.head) <= CANVAS_WIDTH / 2 || Math.abs(point(1)) <= CANVAS_HEIGHT / 2)) {
        println("WARNING : the point (" + point.head + " ; " + point(1) + ") can't be drawn in this canvas.")
      } else {
        val normalized_drawn_point = Seq((point.head + (CANVAS_WIDTH / 2)) / CANVAS_WIDTH, (point(1) + (CANVAS_HEIGHT / 2)) / CANVAS_HEIGHT)
        graphics.drawRect(normalized_drawn_point.head.toInt * IMAGE_WIDTH, (1 - normalized_drawn_point(1).toInt) * IMAGE_HEIGHT, 1, 1)
      }
    })
  }

}

... and the launcher

object Main {
  def main(args : Array[String]) : Unit = {
    val projector = new Projector()

    val world_cube_points : Seq[Seq[Double]] = Seq(
      Seq(0, 40, 0, 1),
      Seq(0, 40, 10, 1),
      Seq(0, 0, 0, 1),
      Seq(0, 0, 10, 1),
      Seq(20, 40, 0, 1),
      Seq(20, 40, 10, 1),
      Seq(20, 0, 0, 1),
      Seq(20, 0, 10, 1)
    )

    val matrix_world_to_camera : Matrix = new Matrix(Seq(
      Seq(1, 0, 0, 0),
      Seq(0, 1, 0, 0),
      Seq(0, 0, 1, 0),
      Seq(0, 0, -1, 1)
    ))

    val points_to_draw_on_canvas = projector.drawPointsOnCanvas(world_cube_points.map(point => {
      matrix_world_to_camera.product(point)
    }))
    new Canvas(points_to_draw_on_canvas).display

  }
}

Question

What's wrong with my program ? I understood the geometrical concepts explained by these both tutorials that I read carefully. I'm pretty sure my product works. I think either the rasterization, or the entries (the matrix) could be wrong...


Solution

  • You called toInt on a normalized device coordinate (meaning that the valid range is [0, 1]):

    normalized_drawn_point.head.toInt * IMAGE_WIDTH
                                ----- 
    

    This will round it to either 0 or 1 so all points will be on the border of the screen. Only round after you multiply by the screen resolution:

    (normalized_drawn_point.head * IMAGE_WIDTH).toInt
    

    (Technically it should be * (IMAGE_WIDTH - 1) if screen coordinates start from zero, which is very common. Similarly for the vertical.)