我不成功的透视投影实现

My unworking implementation of perspective projection

我写了一个程序,输入一些点,用 3D 坐标表示,必须用 2D 绘制 canvas。我使用透视投影、齐次坐标和相似三角形来做到这一点。但是,我的程序无法运行,我实际上不知道为什么。

我学习了两个教程。我真的理解了我读过的几何定义和属性。然而,我的实现失败了……我会一点一点地写对这两门课程的参考,让你阅读起来更舒服:)。

概览:几何提醒

透视投影是按照这个工作流程完成的(参见这两个课程 - 我在下面写了相关链接(HTML 锚点),在这个 post 中):

  1. 绘制点的定义,按照世界坐标系表示;投影矩阵的定义,它是"converts"一个按世界坐标系表示的点变换成按相机坐标系表示的一个点的变换矩阵(注意:这个矩阵也可以理解为相机)

  2. 这些点与此矩阵的乘积(如下面的适当部分中所定义):这些点的乘积导致将这些点转换为相机的坐标系。注意点和矩阵是用4D表示的(齐次坐标的概念)

  3. 使用相似三角形的概念投影(仅在这一步进行计算)canvas 相机内表达的点(使用它们的 4D 坐标):它们现在以 3D 表示(第三个坐标是计算出来的,但实际上没有用在 canvas)

  4. 最后一步:光栅化,在canvas上实际绘制像素(其他计算和显示在这一步完成)。

一、问题

好吧,我想画一个立方体,但是没有出现。投影点似乎绘制在相同的坐标上。

而不是我的立方体,只有一个黑色像素可见。

Scastie(片段)

注意:由于 X11 未在 Scastie 上激活,因此不会显示我要创建的 window。

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

条目

也许问题出在条目上?好吧,我给你。

方块的点数

参考。 : 我自己

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

变换(投影)矩阵

参考。 : 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)
))

其次,我的程序执行的第一个操作:点与矩阵的简单乘积。

参考。 : 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
    )
  }

}

然后,使用相似三角形

参考。 1/2:部分。 “关于将点转换为相机的重要性 Space ” https://www.scratchapixel.com/lessons/3d-basic-rendering/computing-pixel-coordinates-of-3d-point/mathematics-computing-2d-coordinates-of-3d-points

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

注意:在这一步,条目是根据相机表示的点(即:它们是预先定义的乘积与预先定义的矩阵的结果)。

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

  }

}

最后,将投影点绘制到 canvas。

参考。 : 部分。 "From Screen Space to Raster Space" 个 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)
      }
    })
  }

}

...和启动器

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

  }
}

问题

我的程序有什么问题?我仔细阅读了这两个教程所解释的几何概念。我很确定我的产品有效。我认为光栅化或条目(矩阵)可能是错误的...

您在标准化设备坐标上调用了 toInt(意味着有效范围是 [0, 1]):

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

这会将其四舍五入为 0 或 1,这样所有的点都将位于屏幕的边界上。在 乘以屏幕分辨率后仅 轮:

(normalized_drawn_point.head * IMAGE_WIDTH).toInt

(从技术上讲,如果屏幕坐标从零开始,它应该是 * (IMAGE_WIDTH - 1),这很常见。对于垂直方向也是如此。)