5.三角形光栅化

5.三角形光栅化


5.1 概述

主要内容:

  • 完成观测变换(Finishing up Viewing)
    • 视口变换(Viewport Transformation)
  • 光栅化(Rasterization)
    • 不同的光栅显示设备
    • 对三角形进行光栅化
  • 遮挡与可见性(Occlusions and Visibility)

前面的模型变换、视图变换和投影变换已经把三维场景映射到规范立方体 $[-1,1]^3$。接下来需要把规范立方体映射到屏幕,再判断三角形最终覆盖哪些像素。

整个过程可以理解为:

MVP 变换 → 视口变换 → 三角形光栅化


5.2 观测变换和视口变换

透视投影的视场角与宽高比

透视投影中,相机能够看到的范围可以用一个视锥体(View Frustum)表示。

视锥靠近相机的一面叫做近平面(Near Plane)。从相机正面看,它就是一个矩形:

  • $l$(left):近平面的左边界
  • $r$(right):近平面的右边界
  • $b$(bottom):近平面的下边界
  • $t$(top):近平面的上边界
  • $n$(near):近平面所在的深度位置

所以 $l、r、b、t$ 描述的其实就是:近平面这张矩形有多宽、多高,以及四条边分别在哪里。

不过实际设置透视相机时,一般不会直接去填写 $l、r、b、t$,更常见的是使用:

  • 垂直视场角(fovY):相机在竖直方向上能看到多大的角度。
  • 宽高比(Aspect Ratio):画面的宽高比例。

$$
aspect=\frac{width}{height}
$$

对于一个左右、上下对称的视锥,近平面的中心就在相机正前方,因此:

$$
l=-r,\qquad b=-t
$$

也就是说,只要求出上边界 $t$ 和右边界 $r$,另外两个就能直接得到。

先从侧面看视锥。

$fovY$ 是上下两个方向形成的完整夹角,所以只看上半部分时,角度为:

$$
\frac{fovY}{2}
$$

此时相机、近平面中心和近平面上边界正好构成一个直角三角形:

  • 邻边是相机到近平面的距离 $|n|$
  • 对边是近平面高度的一半 $t$

因此:

$$
\tan\frac{fovY}{2}=\frac{t}{|n|}
$$

得到近平面的半高:

$$
t=|n|\tan\frac{fovY}{2}
$$

上下对称,所以:

$$
b=-t
$$

接下来求近平面的宽度。

对于对称视锥,近平面的半宽为 $r$,半高为 $t$,它们的比例就是画面的宽高比:

$$
aspect=\frac{r}{t}
$$

因此:

$$
r=t\cdot aspect
$$

左右对称:

$$
l=-r
$$

所以描述一个对称透视视锥时,只要知道:

  • $|n|$:相机到近平面的距离
  • $fovY$:垂直视场角
  • $aspect$:画面宽高比

就可以求出近平面的四条边界:

$$
t=|n|\tan\frac{fovY}{2}
$$

$$
b=-|n|\tan\frac{fovY}{2}
$$

$$
r=aspect\cdot|n|\tan\frac{fovY}{2}
$$

$$
l=-aspect\cdot|n|\tan\frac{fovY}{2}
$$

整理到一起就是:

\[
\boxed{
\begin{aligned}
l&=-aspect\cdot|n|\tan\frac{fovY}{2}\\
r&=\phantom{-}aspect\cdot|n|\tan\frac{fovY}{2}\\
b&=-|n|\tan\frac{fovY}{2}\\
t&=\phantom{-}|n|\tan\frac{fovY}{2}
\end{aligned}
}
\]

也就是:

$|n|、fovY、aspect \rightarrow l、r、b、t$

其中 $f$(Far Plane)决定视锥在深度方向延伸到哪里,不参与这里 $l、r、b、t$ 的计算。

观测变换小结

从模型到规范立方体,前面已经完成:

  • 模型变换(Model Transformation):把物体摆到场景中的正确位置。
  • 视图变换(View Transformation):把世界变换到相机坐标系。
  • 投影变换(Projection Transformation):
    • 正交投影将长方体映射到规范立方体。
    • 透视投影将视锥体映射到规范立方体。

完成 MVP 后,物体已经位于统一的 $[-1,1]^3$ 空间中。

还差最后一步:把规范立方体的 $x、y$ 坐标映射到真正的屏幕坐标。

视口变换(Viewport Transformation)

视口变换用来把规范立方体映射到屏幕。

屏幕空间(Screen Space)

屏幕可以看作由像素(Pixel)组成的二维数组,数组的宽度和高度就是屏幕分辨率。像素暂时理解为颜色均匀的小方块,其颜色由红、绿、蓝三个分量组成。

我们用这样的坐标约定,屏幕原点位于左下角,$x$ 向右、$y$ 向上:

  • 像素索引:$(x,y)$,其中 $x$、$y$ 为整数。
  • 索引范围:$(0,0)$ 到 $(width-1,height-1)$。
  • 像素中心:像素 $(x,y)$ 的中心为 $(x+0.5,y+0.5)$。
  • 屏幕范围:$[0,width]\times[0,height]$。

注意区分像素索引与像素中心坐标,后续判断像素是否被三角形覆盖时,要使用像素中心进行采样。

从规范立方体到屏幕

视口变换只处理 $x、y$,将 $[-1,1]^2$ 映射到 $[0,width]\times[0,height]$,此处不改变 $z$。

经过投影变换和透视除法后,顶点已经落在规范立方体 $[-1,1]^3$ 中,这时的坐标通常称为 NDC(Normalized Device Coordinates,规范设备坐标)。

这里先只看 $x、y$:

$$
x_{ndc},y_{ndc}\in[-1,1]
$$

NDC 的好处是和屏幕分辨率无关。不管最后是 $1920\times1080$ 还是 $2560\times1440$,都先统一到 $[-1,1]$ 这一套坐标里。

真正显示时,还要把它换成屏幕坐标。

假设屏幕宽度为 $w$、高度为 $h$,屏幕范围是:

$$
[0,w]\times[0,h]
$$

所以视口变换要做的就是:

\[
[-1,1]^2 \rightarrow [0,w]\times[0,h]
\]

以 $x$ 为例。

NDC 中:

$$
-1\le x_{ndc}\le1
$$

希望变换后:

$$
0\le x_{screen}\le w
$$

先把区间长度从 $2$ 缩放成 $w$:

$$
x^{\prime}=\frac{w}{2}x_{ndc}
$$

这时范围变成:

$$
\left[-\frac{w}{2},\frac{w}{2}\right]
$$

范围大小已经对了,但中心还在 $0$。再整体向右移动 $w/2$:

\[
x_{screen} = \frac{w}{2}x_{ndc} + \frac{w}{2}
\]

也就是:

\[
x_{screen} = \frac{w}{2}(x_{ndc}+1)
\]

$y$ 方向同理:

\[
y_{screen} = \frac{h}{2}(y_{ndc}+1)
\]

可以简单代几个值检查:

$$
x_{ndc}=-1\rightarrow x_{screen}=0
$$

$$
x_{ndc}=0\rightarrow x_{screen}=\frac{w}{2}
$$

$$
x_{ndc}=1\rightarrow x_{screen}=w
$$

正好对应屏幕的最左边、中间和最右边。

所以这一步其实还是前面学过的缩放 + 平移。

缩放:

\[
S=
\begin{pmatrix}
\frac{w}{2}&0&0&0\\
0&\frac{h}{2}&0&0\\
0&0&1&0\\
0&0&0&1
\end{pmatrix}
\]

再平移:

\[
T=
\begin{pmatrix}
1&0&0&\frac{w}{2}\\
0&1&0&\frac{h}{2}\\
0&0&1&0\\
0&0&0&1
\end{pmatrix}
\]

因此:

$$
M_{viewport}=TS
$$

展开后就是:

\[
M_{viewport}=
\begin{pmatrix}
\frac{w}{2}&0&0&\frac{w}{2}\\
0&\frac{h}{2}&0&\frac{h}{2}\\
0&0&1&0\\
0&0&0&1
\end{pmatrix}
\]

所以 Viewport Matrix 没有引入什么新的变换,本质就是:

把 NDC 中的 $[-1,1]^2$ 缩放并平移到实际屏幕的 $[0,w]\times[0,h]$。

经过 MVP + Viewport 后,三角形三个顶点终于有了真正的屏幕坐标。

下一步光栅化要做的,就是根据这三个点判断:这个三角形到底覆盖了哪些像素。


5.3 光栅化

光栅与像素

光栅(Raster)可以理解为按照一定规则排列的像素网格,光栅化就是将几何图形绘制到这样的像素网格上。

绘图设备


可以对比不同的图形输出方式,比如数控绘图机、激光切割机等绘图设备。这个是在真实世界绘制,而屏幕显示则需要将图像转换为离散的像素信息。

不同的光栅显示设备

示波器

示波器可以通过控制电子束在水平和竖直方向上的偏转,在屏幕上绘制曲线。

例如分别给水平、竖直方向输入不同的正弦信号,就可以让电子束沿特定轨迹运动。只要控制得足够精确,也能画出复杂的图形。

这种方式更接近“控制一支看不见的笔在屏幕上画线”,和后面的光栅扫描还不完全一样,但二者都建立在电子束成像的基础上。

阴极射线管(CRT)

CRT(Cathode Ray Tube,阴极射线管)是早期电视和电脑显示器常用的显示技术。

内部的电子枪产生电子束,经过聚焦和偏转后打到荧光屏的指定位置,使这个位置发光。

所以控制一束电子束,主要就是控制两件事:

  • 打在哪里:决定屏幕上的位置。
  • 有多强:决定这个位置的亮度。

电子束移动得足够快,就可以连续点亮屏幕上的大量位置,形成完整画面。

光栅扫描

CRT 显示器常用光栅扫描(Raster Scan)生成图像。

电子束按照固定顺序一行一行扫过屏幕,同时根据图像内容不断改变亮度:

第 1 行  → → → → → →
第 2 行  → → → → → →
第 3 行  → → → → → →
...

扫描完整个屏幕后,再从头开始。

只要刷新速度足够快,人眼看到的就是稳定的完整画面。

早期电视还经常使用隔行扫描(Interlaced Scan):一次只扫描奇数行,下一次再扫描偶数行。

这样每次只需要处理一半的扫描线,可以减少传输和刷新压力,但运动较快时容易出现交错、撕裂一类的问题。

帧缓冲(Frame Buffer)

显示器需要知道每个位置应该显示什么颜色,这些颜色通常先保存在一块内存中,也就是帧缓冲(Frame Buffer)。

一张图像可以直接理解成:

Image = 2D Array of Colors

例如一个 $1920\times1080$ 的画面,本质上就是保存了 $1920\times1080$ 个像素的颜色信息。

从程序这一侧来看,可以简单理解为:

先计算每个像素的颜色 → 写入 Frame Buffer → 显示设备读取并显示。

光栅化后面真正要解决的,就是这个二维数组里的像素值应该怎么得到。

平板显示设备

现在常见的显示器基本都属于平板显示设备。

早期计算器上的 LCD 分辨率很低,可以直接看到一个个显示单元;手机上的 LCD、OLED 分辨率已经非常高,但从图像表示上看,仍然可以把它们理解成大量像素组成的二维阵列。

不同设备真正的区别,在于一个像素最后是怎么产生颜色和亮度的。

LCD 液晶显示器

LCD(Liquid Crystal Display)通常需要背光提供光源。

液晶本身主要负责改变光的偏振状态,再配合偏振片控制最终能够通过多少光:

背光
 ↓
偏振片
 ↓
液晶
 ↓
偏振片
 ↓
看到的亮度

通过改变液晶状态,就可以控制一个像素的亮暗。

彩色 LCD 再将像素细分为红、绿、蓝等子像素,通过控制 RGB 三个分量得到最终颜色。

LED 阵列显示器

LED 阵列更加直观:把大量发光二极管(LED)排列成二维阵列,每个位置可以直接发光。

控制不同 LED 的亮灭和亮度,就能组成文字和图像。

大型户外屏、点阵屏上尤其容易直接看到这种“一个发光点对应一个显示单元”的结构。

电子墨水

Kindle 一类设备常用电泳显示(Electrophoretic Display),也就是电子墨水。

一个显示单元中存在带不同电荷的黑色、白色粒子,通过改变电场方向,可以让不同颜色的粒子移动到表面:

黑色粒子到上方 → 看起来是黑色
白色粒子到上方 → 看起来是白色

因此电子墨水不需要像 LCD 那样持续依赖背光来形成画面,显示方式也更接近纸张。

小结

这些设备的物理原理虽然不同,但对于接下来的光栅化,可以统一做一个简化:

把屏幕看成一个二维像素数组,光栅化负责决定每个像素最后应该显示什么。

光栅化:在像素显示设备上绘制

使用三角形作为基本图元

为什么使用三角形

三维模型通常由多边形网格组成,而多边形又可以拆分为三角形。使用三角形有几个重要原因:

  • 最基本的多边形:只有三个顶点,结构简单,复杂多边形可以被拆分为多个三角形。
  • 一定共面:三个不共线的顶点可以确定一个平面。
  • 内部容易判断:可以通过叉积判断一个点是否在三角形内。
  • 便于插值:可以根据顶点信息计算三角形内部的属性,后续会进一步介绍。
三角形如何对应屏幕像素

投影与视口变换后,三角形的三个顶点已经位于屏幕空间,但这些顶点坐标通常不是整数。

光栅化要解决的不是如何连接三个顶点,而是屏幕上的哪些像素应该表示这个三角形。

采样(Sampling)

什么是采样

采样(Sampling)就是在指定的位置计算函数值。对于连续函数,只在有限的位置取值,就可以得到离散结果。

屏幕上的三角形是连续的几何区域,而像素是离散的,因此可以把三角形光栅化理解为:在二维像素网格上,对三角形的覆盖情况进行采样。

像素中心采样

先使用一个最简单的规则:如果像素中心位于三角形内部,就认为该像素被三角形覆盖。

像素中心的坐标为 $(x+0.5,y+0.5)$,因此实际检查的不是像素左下角的整数位置,而是每个小方块的中心。

inside 函数

定义二维指示函数 $inside(t,x,y)$:

  • 返回 $1$:点 $(x,y)$ 位于三角形 $t$ 内部。
  • 返回 $0$:点 $(x,y)$ 位于三角形外部。

因此像素 $(x,y)$ 的覆盖结果为:

$$
I(x,y)=inside(t,x+0.5,y+0.5)
$$

最直接的实现是遍历屏幕上的每个像素,再调用 inside 函数判断其中心是否位于三角形内部。

利用叉积判断内外

设三角形的三个顶点按逆时针顺序排列为 $P_0$、$P_1$、$P_2$,待判断的点为 $Q$。

依次判断 $Q$ 位于三条有向边的哪一侧:

$$
(\overrightarrow{P_0P_1}\times\overrightarrow{P_0Q})_z
$$

$$
(\overrightarrow{P_1P_2}\times\overrightarrow{P_1Q})_z
$$

$$
(\overrightarrow{P_2P_0}\times\overrightarrow{P_2Q})_z
$$

对于逆时针顶点顺序:

  • 三个叉积的 $z$ 分量均大于 $0$:点在三角形内部。
  • 三个结果均大于或等于 $0$:点在三角形内部或边界上。
  • 出现正负混合:点在三角形外部。

如果顶点按照顺时针排列,则判断符号相反。

核心是利用叉积的正负,判断点是否始终位于三条有向边的同一侧。

边界情况

当采样点恰好位于三角形的一条边上时,对应的叉积为 $0$。如果两个三角形共享这条边,需要约定这个采样点由哪一个三角形负责。

否则可能出现两个三角形重复覆盖同一个采样点,或者都不覆盖而留下缝隙的问题。

优化光栅化范围

包围盒(Bounding Box)

逐个检查整张屏幕虽然可行,但一个三角形通常只占据屏幕的一小部分,全屏遍历会产生大量无效计算。

可以先利用三个顶点的坐标,确定三角形的轴对齐包围盒:

$$
x_{min}=\min(x_0,x_1,x_2),\qquad x_{max}=\max(x_0,x_1,x_2)
$$

$$
y_{min}=\min(y_0,y_1,y_2),\qquad y_{max}=\max(y_0,y_1,y_2)
$$

只需要遍历包围盒内的像素中心,并把遍历范围限制在屏幕内,再执行 inside 判断。

增量式三角形遍历

对于细长、倾斜的三角形,即使使用包围盒,仍然可能检查很多无效像素。

进一步可以按行确定三角形覆盖的范围,只遍历每一行可能落在三角形内部的像素。相比直接扫描整个包围盒,这种方式可能更高效。

真实显示设备上的光栅化

真实屏幕中的像素

放大真实 LCD 屏幕后,可以看到不同的 RGB 子像素排列。像素并不一定是真正均匀发光的方块,将它简化为小方块只是为了便于讨论光栅化。

锯齿与走样

理想中的三角形边缘是连续的,但经过像素中心采样后只能得到离散结果。



斜边会出现明显的锯齿(Jaggies)。

这种现象属于走样(Aliasing):用有限的离散采样点表示连续图形时,可能丢失原来的细节信息。

如何减少走样,就是后面反走样要解决的问题。


5.4 作业 1

作业要求

作业 1 主要补两个矩阵:

  • get_model_matrix():让三角形绕 $Z$ 轴旋转。
  • get_projection_matrix():构造透视投影矩阵。

三角形三个顶点已经给好:

$$
(2,0,-2),\qquad(0,2,-2),\qquad(-2,0,-2)
$$

相机位置:

$$
(0,0,5)
$$

框架里的 View、Viewport、线框绘制也已经写好,所以这次重点就是把前面学过的 Model 和 Projection 真正用代码写出来。

准备环境

作业 0 已经配好了:

  • Visual Studio 2026
  • CMake
  • Eigen 3.4.0

作业 1 还需要安装 OpenCV。

OpenCV 是一个开源的计算机视觉和图像处理库,平时可以用来做图像读写、处理、显示,以及各种视觉算法。

作业中框架主要拿它来显示光栅器的结果和保存图片。

用到的 API 有:

cv::Mat
cv::imshow()
cv::waitKey()
cv::imwrite()
  • cv::Mat:OpenCV 用来表示图像的数据结构。
  • cv::imshow():把图像显示到窗口。
  • cv::waitKey():等待键盘输入,这里用来读取 A、D、Esc。
  • cv::imwrite():把当前画面保存成图片。

作业里的画面是由 Rasterizer 自己算出来的。

Rasterizer 里有一个 Frame Buffer,可以理解成当前这一帧所有像素的颜色数据。三角形经过变换、视口映射并画到屏幕后,结果都会写进这里。

main.cpp 再用:

cv::Mat image(
    700,
    700,
    CV_32FC3,
    r.frame_buffer().data()
);

把这块 Frame Buffer 当成一张 700 × 700 的 OpenCV 图像。

其中:

  • CV_32F:颜色分量使用 float。
  • C3:每个像素有 3 个颜色分量,也就是 RGB。
  • r.frame_buffer().data():Frame Buffer 中真正存放像素数据的内存地址。

接着:

cv::imshow("image", image);

就能看到程序运行时弹出的 image 窗口。

循环里还有:

key = cv::waitKey(10);

每帧等待一小段时间并读取按键,所以可以用 A、D 控制三角形旋转,按 Esc 退出。

如果走命令行保存模式:

cv::imwrite(filename, image);

则直接把这一帧保存成图片。

所以 OpenCV 在这里主要负责最后这一步:

Rasterizer 算出画面 → Frame Buffer 保存像素 → OpenCV 显示或保存结果。

下载 OpenCV

直接下载 Windows 的预编译版本,不需要自己编译 OpenCV 源码。

进入 OpenCV 官方下载页面或者 GitHub Releases,选择 Windows 版本的.exe。

下载到的是一个 Windows 可执行安装包,运行后实际上就是把 OpenCV 文件解压出来。

我放在:

D:\Software\OpenCV\opencv

解压后比较重要的路径是:

D:\Software\OpenCV\opencv\build

里面能看到:

OpenCVConfig.cmake

之后 CMake 就靠它找到 OpenCV。

修改 CMakeLists.txt

官方原始配置:

cmake_minimum_required(VERSION 3.10)                                      # 最低 CMake 版本

project(Rasterizer)                                                       # 项目名称

find_package(OpenCV REQUIRED)                                             # 查找 OpenCV

set(CMAKE_CXX_STANDARD 17)                                                # 使用 C++17

include_directories(/usr/local/include)                                   # Linux 常见头文件目录

add_executable(Rasterizer main.cpp rasterizer.hpp rasterizer.cpp
               Triangle.hpp Triangle.cpp)                                # 生成 Rasterizer

target_link_libraries(Rasterizer ${OpenCV_LIBRARIES})                     # 链接 OpenCV

这里的:

include_directories(/usr/local/include)

是 Linux 路径。

我本机的 Eigen 和 OpenCV 分别在:

D:\Software\EIGEN\eigen-3.4.0
D:\Software\OpenCV\opencv

所以改成:

cmake_minimum_required(VERSION 3.15)                              # 最低使用 CMake 3.15

project(Rasterizer LANGUAGES CXX)                                # 创建 C++ 项目 Rasterizer

set(CMAKE_CXX_STANDARD 17)                                       # 使用 C++17
set(CMAKE_CXX_STANDARD_REQUIRED ON)                              # 必须支持 C++17

set(OpenCV_DIR "D:/Software/OpenCV/opencv/build")                # OpenCVConfig.cmake 所在目录

find_package(OpenCV REQUIRED)                                    # 查找 OpenCV

include_directories("D:/Software/EIGEN/eigen-3.4.0")            # Eigen 头文件目录
include_directories(${OpenCV_INCLUDE_DIRS})                      # OpenCV 头文件目录

add_executable(
    Rasterizer
    main.cpp
    rasterizer.cpp
    Triangle.cpp
)                                                                 # 生成 Rasterizer.exe

target_link_libraries(
    Rasterizer
    PRIVATE
    ${OpenCV_LIBS}
)                                                                 # 链接 OpenCV

if(MSVC)
    target_compile_options(Rasterizer PRIVATE /utf-8)            # 源码使用 UTF-8
endif()

其中这句:

set(OpenCV_DIR "D:/Software/OpenCV/opencv/build")

指向的是 OpenCVConfig.cmake 所在目录。

Eigen 是 header-only,指定头文件目录就可以使用。

OpenCV 除了头文件之外还有 .lib、.dll,所以还需要:

target_link_libraries(
    Rasterizer
    PRIVATE
    ${OpenCV_LIBS}
)

修改保存后,Visual Studio 会重新执行 CMake。

看到:

Configuring done
Generating done

就说明 CMake 配置通过了。

修改 Eigen 引用

官方源码使用:

#include <eigen3/Eigen/Eigen>

这是 Linux 下常见的写法。

因为我的 CMake 已经把:

D:\Software\EIGEN\eigen-3.4.0

加入头文件目录,所以改成:

#include <Eigen/Eigen>

需要修改三个文件:

main.cpp
rasterizer.hpp
Triangle.hpp

改完以后重新 Build。

第一次编译

直接:

Ctrl + Shift + B

最后看到:

Linking CXX executable Rasterizer.exe

全部生成 已成功

说明这些都已经没问题:

CMake
Eigen
OpenCV
MSVC
Rasterizer.exe

这时候虽然已经生成成功,但第一次运行又碰到了一个问题。找不到 opencv_world4140d.dll。

OpenCV DLL 找不到

运行后提示:

找不到 opencv_world4140d.dll

这里的 d 是 Debug。

也就是说编译和链接其实已经成功,只是程序真正运行时,Windows 找不到 OpenCV 的 DLL。

我本机 OpenCV 实际的 DLL 在:

D:\Software\OpenCV\opencv\build\x64\vc16\bin

所以打开:

系统环境变量
→ 环境变量
→ Path
→ 新建

加入:

D:\Software\OpenCV\opencv\build\x64\vc16\bin

然后完全关闭 Visual Studio,再重新打开。

重新运行后 OpenCV 窗口就能正常弹出来了。

这里可以顺便区分一下:

编译时
Rasterizer.exe
    ↓
链接 OpenCV .lib

运行时
Rasterizer.exe
    ↓
加载 OpenCV .dll

所以会出现一种情况:

Build 成功
但是运行失败

就是因为运行时找不到 DLL。

先看一下框架做了什么

环境跑通以后,先看整个渲染流程。

main.cpp 每帧设置三个矩阵:

r.set_model(get_model_matrix(angle));
r.set_view(get_view_matrix(eye_pos));
r.set_projection(get_projection_matrix(45, 1, 0.1, 50));

在 rasterizer.cpp 中组合成:

Eigen::Matrix4f mvp = projection * view * model;

使用列向量,所以实际执行:

Model
 ↓
View
 ↓
Projection

三个顶点分别乘 MVP:

Eigen::Vector4f v[] = {
    mvp * to_vec4(buf[i[0]], 1.0f),
    mvp * to_vec4(buf[i[1]], 1.0f),
    mvp * to_vec4(buf[i[2]], 1.0f)
};

然后做透视除法:

for (auto& vec : v) {
    vec /= vec.w();
}

也就是:

\[
(x,y,z,w) \rightarrow \left(\frac{x}{w},\frac{y}{w},\frac{z}{w},1\right)
\]

这一步之后得到 NDC。

接着框架继续完成前面讲过的视口变换:

vert.x() = 0.5 * width * (vert.x() + 1.0);
vert.y() = 0.5 * height * (vert.y() + 1.0);

把:

$$
[-1,1]^2
$$

映射到:

$$
[0,width]\times[0,height]
$$

最后连接三个顶点,画出三角形线框。

整个流程就是:

模型坐标
  ↓ Model
世界坐标
  ↓ View
相机坐标
  ↓ Projection
Clip Space
  ↓ 透视除法 ÷ w
NDC
  ↓ Viewport
屏幕坐标

模型变换

先完成比较简单的 get_model_matrix()。

题目要求三角形绕 $Z$ 轴旋转。

绕 $Z$ 轴旋转矩阵:

\[
R_z(\alpha)=
\begin{pmatrix}
\cos\alpha&-\sin\alpha&0&0\\
\sin\alpha&\cos\alpha&0&0\\
0&0&1&0\\
0&0&0&1
\end{pmatrix}
\]

函数传进来的:

rotation_angle

单位是角度,但是:

std::sin()
std::cos()

使用弧度。

所以先转换:

float radian = rotation_angle / 180.0f * MY_PI;

完整实现:

Eigen::Matrix4f get_model_matrix(float rotation_angle)
{
    // rotation_angle 使用角度,例如 10、20、30。
    // sin / cos 使用弧度,所以先做 Degree -> Radian。
    float radian = rotation_angle / 180.0f * MY_PI;

    // 绕 Z 轴旋转。
    //
    // x、y 在 xy 平面内旋转;
    // z 保持不变。
    Eigen::Matrix4f model;

    model <<
        std::cos(radian), -std::sin(radian), 0.0f, 0.0f,
        std::sin(radian),  std::cos(radian), 0.0f, 0.0f,
        0.0f,              0.0f,             1.0f, 0.0f,
        0.0f,              0.0f,             0.0f, 1.0f;

    return model;
}

例如:

$$
\alpha=90^\circ
$$

那么:

\[
\cos90^\circ=0,\qquad \sin90^\circ=1
\]

点:

$$
(1,0,0)
$$

会变成:

$$
(0,1,0)
$$

也就是绕 $Z$ 轴逆时针旋转 $90^\circ$。

投影变换

然后完成:

get_projection_matrix()

程序调用:

get_projection_matrix(45, 1, 0.1, 50)

四个参数分别是:

eye_fov      = 45
aspect_ratio = 1
zNear        = 0.1
zFar         = 50

也就是:

  • 垂直视场角 $45^\circ$
  • 宽高比 $1:1$
  • 近平面距离 $0.1$
  • 远平面距离 $50$

Near 和 Far

这里有一个容易写错的地方。

传进来的:

zNear = 0.1
zFar  = 50

表示的是距离,所以都是正数。

但是相机朝 $-Z$ 方向看,因此在相机空间里的实际 $z$ 坐标是:

float n = -zNear;
float f = -zFar;

这次就是:

n = -0.1
f = -50

求 l、r、b、t

程序实际传入:

get_projection_matrix(45, 1, 0.1, 50);

也就是:

eye_fov      = 45°
aspect_ratio = 1
zNear        = 0.1
zFar         = 50

先把视场角从角度转成弧度:

float radian = eye_fov / 180.0f * MY_PI;

代入 \(45^\circ\):

\[
radian = 45^\circ\times\frac{\pi}{180^\circ} = \frac{\pi}{4} \approx0.785398
\]

zNear 和 zFar 表示的是距离,本身是正数。

相机朝 \(-Z\) 方向看,所以换成相机空间中的 \(z\) 坐标:

float n = -zNear;
float f = -zFar;

得到:

\[
n=-0.1
\]

\[
f=-50
\]

接下来求近平面的上下边界。

前面已经推过:

\[
t=|n|\tan\frac{fovY}{2}
\]

这里:

\[
\frac{fovY}{2} = 22.5^\circ
\]

所以:

\[
t = 0.1\tan22.5^\circ \approx0.0414214
\]

代码:

float t = std::abs(n) * std::tan(radian / 2.0f);

这里使用 std::abs(n),因为 \(n=-0.1\) 表示位置,而计算近平面高度时需要的是相机到近平面的距离 \(0.1\)。

上下对称:

\[
b=-t
\]

所以:

float b = -t;

得到:

\[
b\approx-0.0414214
\]

再求左右边界。

宽高比:

\[
aspect=\frac{r}{t}
\]

所以:

\[
r=aspect\cdot t
\]

代码:

float r = aspect_ratio * t;

这次:

\[
aspect=1
\]

因此:

\[
r = 1\times0.0414214 = 0.0414214
\]

左右对称:

float l = -r;

所以:

\[
l\approx-0.0414214
\]

最终这组参数:

eye_fov      = 45°
aspect_ratio = 1
zNear        = 0.1
zFar         = 50

        ↓

radian ≈ 0.785398

n = -0.1
f = -50

        ↓

l ≈ -0.0414214
r ≈  0.0414214

b ≈ -0.0414214
t ≈  0.0414214

因为这次:

aspect_ratio = 1

所以近平面的宽和高相同,得到的是一个正方形:

              t = 0.0414214
                    ↑
                    |
l = -0.0414214 ← 中心 → r = 0.0414214
                    |
                    ↓
              b = -0.0414214

对应代码:

// ------------------------------------------------------------
// 1. 根据 fovY / aspect 求视锥边界 l、r、b、t
// ------------------------------------------------------------

// 当前调用:
// get_projection_matrix(45, 1, 0.1, 50)
//
// eye_fov      = 45°
// aspect_ratio = 1
// zNear        = 0.1
// zFar         = 50

// tan() 使用弧度。
// 45° -> PI / 4 ≈ 0.785398
float radian = eye_fov / 180.0f * MY_PI;

// zNear / zFar 表示距离,本身是正数。
// 相机朝 -Z 方向看,所以转换成相机空间中的 z 坐标:
//
// n = -0.1
// f = -50
float n = -zNear;
float f = -zFar;

// 根据:
// t = |n| * tan(fovY / 2)
//
// 代入:
// t = 0.1 * tan(22.5°)
//   ≈ 0.0414214
float t = std::abs(n) * std::tan(radian / 2.0f);

// 上下对称:
// b ≈ -0.0414214
float b = -t;

// 根据:
// aspect = r / t
//
// 所以:
// r = aspect * t
//
// 当前 aspect = 1:
// r ≈ 0.0414214
float r = aspect_ratio * t;

// 左右对称:
// l ≈ -0.0414214
float l = -r;

到这里,描述这个透视视锥需要的六个边界值就齐了:

l ≈ -0.0414214    左
r ≈  0.0414214    右

b ≈ -0.0414214    下
t ≈  0.0414214    上

n = -0.1          近平面
f = -50           远平面

下一步就可以用这六个值构造:

\[
M_{persp\rightarrow ortho}
\]

把透视视锥压成长方体。

把视锥压成长方体

之前已经推过,透视投影不会直接把视锥一步变到 NDC,而是先做一次:

$$
M_{persp\rightarrow ortho}
$$

它的作用是把原来的 透视视锥挤压成长方体。

简单回顾一下:

原来的透视视锥

近处窄
远处宽

      ↓ M_persp→ortho

变成长方体

近处和远处的截面一样大

      ↓ M_ortho

映射到 [-1,1]^3

之所以先这么处理,是因为正交投影已经有一套现成的方法:先把长方体中心平移到原点,再缩放到标准立方体。

所以透视投影可以拆成:

\[
M_{persp} = M_{ortho}M_{persp\rightarrow ortho}
\]

其中:

  • $M_{persp\rightarrow ortho}$:负责把透视视锥挤压成长方体,产生近大远小的效果,同时保留 Near 和 Far 的深度边界。
  • $M_{ortho}$:把挤压后的长方体平移、缩放到 $[-1,1]^3$。

前面已经推得:

\[
M_{persp\rightarrow ortho} =
\begin{pmatrix}
n&0&0&0\\
0&n&0&0\\
0&0&n+f&-nf\\
0&0&1&0
\end{pmatrix}
\]

直接把这个矩阵写出来:

Eigen::Matrix4f perspectiveToOrthographic;

perspectiveToOrthographic <<
    n,    0.0f, 0.0f,  0.0f,
    0.0f, n,    0.0f,  0.0f,
    0.0f, 0.0f, n + f, -n * f,
    0.0f, 0.0f, 1.0f,  0.0f;

一个点:

$$
(x,y,z,1)^T
$$

乘完以后得到:

$$
(nx,\ ny,\ (n+f)z-nf,\ z)^T
$$

这里最关键的是最后一项:

$$
w^{\prime}=z
$$

此时结果还处于齐次坐标中,后面框架还会做一次透视除法:

vec /= vec.w();

也就是整个向量除以 $w^{\prime}=z$。

所以前两个坐标变成:

$$
x^{\prime}=\frac{nx}{z}
$$

$$
y^{\prime}=\frac{ny}{z}
$$

这时透视效果就出来了。

$|z|$ 越大,说明点离相机越远,除以 $z$ 后得到的 $x^{\prime}$、$y^{\prime}$ 就越小,因此物体在画面中看起来也越小。

也就是常说的:

近处大,远处小。

所以 $M_{persp\rightarrow ortho}$ 这一步可以简单理解为:

透视视锥
    ↓
按照深度压缩 x、y
    ↓
远处压得更多
    ↓
得到长方体

长方体得到以后,再继续做后面的正交投影,把它变换到标准的 NDC 空间 $[-1,1]^3$。

正交投影:平移

视锥已经压成长方体:

$$
[l,r]\times[b,t]\times[f,n]
$$

先把它的中心移动到原点。

中心是:

\[
\left(\frac{l+r}{2},\frac{b+t}{2},\frac{f+n}{2}\right)
\]

所以:

Eigen::Matrix4f orthographicTranslate;

orthographicTranslate <<
    1.0f, 0.0f, 0.0f, -(r + l) / 2.0f,
    0.0f, 1.0f, 0.0f, -(t + b) / 2.0f,
    0.0f, 0.0f, 1.0f, -(n + f) / 2.0f,
    0.0f, 0.0f, 0.0f, 1.0f;

因为当前视锥左右、上下对称:

l = -r
b = -t

所以 $x、y$ 中心本来就在 $0$。

主要需要移动的是 $z$。

正交投影:缩放

长方体三个方向的长度:

$$
r-l
$$

$$
t-b
$$

$$
n-f
$$

而标准立方体:

$$
[-1,1]^3
$$

每个方向长度都是 $2$。

所以缩放:

Eigen::Matrix4f orthographicScale;

orthographicScale <<
    2.0f / (r - l), 0.0f,             0.0f,             0.0f,
    0.0f,             2.0f / (t - b), 0.0f,             0.0f,
    0.0f,             0.0f,             2.0f / (n - f), 0.0f,
    0.0f,             0.0f,             0.0f,             1.0f;

使用列向量时矩阵从右往左执行。

想要:

先平移
再缩放

所以:

Eigen::Matrix4f orthographicProjection =
    orthographicScale * orthographicTranslate;

组合最终投影矩阵

整个过程:

透视视锥
   ↓
压成长方体
   ↓
正交投影
   ↓
[-1,1]^3

所以:

\[
M_{persp} = M_{ortho}M_{persp\rightarrow ortho}
\]

代码:

projection =
    orthographicProjection * perspectiveToOrthographic;

完整函数:

Eigen::Matrix4f get_projection_matrix(
    float eye_fov,
    float aspect_ratio,
    float zNear,
    float zFar)
{
    Eigen::Matrix4f projection = Eigen::Matrix4f::Identity();

    // fov 是角度,tan() 使用弧度。
    float radian = eye_fov / 180.0f * MY_PI;

    // 参数表示距离,是正数。
    // 相机朝 -Z 看,所以转换成相机空间 z 坐标后为负数。
    float n = -zNear;
    float f = -zFar;

    // 根据 fovY 求近平面的半高。
    float t = std::abs(n) * std::tan(radian / 2.0f);
    float b = -t;

    // 根据宽高比求近平面的半宽。
    float r = aspect_ratio * t;
    float l = -r;

    // 把透视视锥压成长方体。
    Eigen::Matrix4f perspectiveToOrthographic;

    perspectiveToOrthographic <<
        n,    0.0f, 0.0f,  0.0f,
        0.0f, n,    0.0f,  0.0f,
        0.0f, 0.0f, n + f, -n * f,
        0.0f, 0.0f, 1.0f,  0.0f;

    // 把长方体中心移动到原点。
    Eigen::Matrix4f orthographicTranslate;

    orthographicTranslate <<
        1.0f, 0.0f, 0.0f, -(r + l) / 2.0f,
        0.0f, 1.0f, 0.0f, -(t + b) / 2.0f,
        0.0f, 0.0f, 1.0f, -(n + f) / 2.0f,
        0.0f, 0.0f, 0.0f, 1.0f;

    // 缩放到标准立方体 [-1,1]^3。
    Eigen::Matrix4f orthographicScale;

    orthographicScale <<
        2.0f / (r - l), 0.0f,             0.0f,             0.0f,
        0.0f,             2.0f / (t - b), 0.0f,             0.0f,
        0.0f,             0.0f,             2.0f / (n - f), 0.0f,
        0.0f,             0.0f,             0.0f,             1.0f;

    // 正交投影:先平移,再缩放。
    Eigen::Matrix4f orthographicProjection =
        orthographicScale * orthographicTranslate;

    // 透视投影:先压缩视锥,再做正交投影。
    projection =
        orthographicProjection * perspectiveToOrthographic;

    return projection;
}

运行结果


完成两个函数以后重新:

Ctrl + Shift + B

生成成功后运行。

窗口里可以看到完整的白色线框三角形。

程序还提供了三个按键:

A    旋转角度 +10°
D    旋转角度 -10°
Esc  退出

比如按一次 A:

angle += 10;

下一帧重新调用:

get_model_matrix(angle)

三角形就会继续绕 $Z$ 轴旋转 $10^\circ$。

控制台里会一直刷:

frame count: ...

这个不是报错,只是每画一帧把帧数加一。

最后这一套流程已经完整跑通:

Model
模型变换:把模型从自己的局部坐标放到世界中

↓


View
视图变换:把整个场景变换到以相机为基准的坐标系

↓


Projection
投影变换:把相机看到的空间映射到裁剪空间

↓


Perspective Divide
透视除法:所有坐标除以 w,得到规范化后的坐标

↓


NDC
规范化设备坐标:可见区域被统一到 [-1,1] 的标准范围

↓


Viewport
视口变换:把 NDC 映射到实际窗口的宽和高

↓


Screen
屏幕坐标:得到最终可以用于光栅化的像素位置

也就是:

Model → View → Projection → Perspective Divide → NDC → Viewport → Screen

从三维模型的顶点开始,经过这一整套变换,最后才真正得到屏幕上的二维位置。

完整代码

CMakeLists.txt

cmake_minimum_required(VERSION 3.15)                              # 最低使用 CMake 3.15

project(Rasterizer LANGUAGES CXX)                                # 创建 C++ 项目 Rasterizer

set(CMAKE_CXX_STANDARD 17)                                       # 使用 C++17
set(CMAKE_CXX_STANDARD_REQUIRED ON)                              # 必须支持 C++17

set(OpenCV_DIR "D:/Software/OpenCV/opencv/build")                # OpenCVConfig.cmake 所在目录

find_package(OpenCV REQUIRED)                                    # 查找 OpenCV

include_directories("D:/Software/EIGEN/eigen-3.4.0")            # Eigen 头文件目录
include_directories(${OpenCV_INCLUDE_DIRS})                      # OpenCV 头文件目录

add_executable(
    Rasterizer
    main.cpp
    rasterizer.cpp
    Triangle.cpp
)                                                                 # 生成 Rasterizer.exe

target_link_libraries(
    Rasterizer
    PRIVATE
    ${OpenCV_LIBS}
)                                                                 # 链接 OpenCV

if(MSVC)                                                         # 使用 MSVC 时
    target_compile_options(Rasterizer PRIVATE /utf-8)            # 源码按 UTF-8 编译
endif()

main.cpp

#include "Triangle.hpp"
#include "rasterizer.hpp"
#include <Eigen/Eigen>
#include <iostream>
#include <opencv2/opencv.hpp>

// 圆周率,用于 Degree -> Radian。
// 例如 180° = PI,45° = PI / 4。
constexpr double MY_PI = 3.1415926;

// ============================================================
// View Matrix
// ============================================================

// 把相机移动到标准位置。
// 当前框架只处理相机位置,不处理任意相机朝向:
// 相机默认朝 -Z,看向场景。
Eigen::Matrix4f get_view_matrix(Eigen::Vector3f eye_pos)
{
    // 从单位矩阵开始。
    Eigen::Matrix4f view = Eigen::Matrix4f::Identity();

    // 相机位于 eye_pos。
    // 等价做法不是“真的移动相机”,而是把整个场景平移 -eye_pos。
    //
    // 例如 eye_pos = (0, 0, 5):
    // 场景整体平移 (0, 0, -5),
    // 从结果上看就像相机被移动到了原点。
    Eigen::Matrix4f translate;
    translate <<
        1, 0, 0, -eye_pos[0],
        0, 1, 0, -eye_pos[1],
        0, 0, 1, -eye_pos[2],
        0, 0, 0, 1;

    view = translate * view;

    return view;
}

// ============================================================
// Model Matrix
// ============================================================

// 根据 rotation_angle 构造绕 Z 轴旋转的模型矩阵。
Eigen::Matrix4f get_model_matrix(float rotation_angle)
{
    // rotation_angle 的单位是“度”。
    // std::sin / std::cos 使用“弧度”,所以先转换:
    //
    // radian = degree / 180 * PI
    //
    // 例如:
    // 90° -> PI / 2
    // 45° -> PI / 4
    float radian = rotation_angle / 180.0f * MY_PI;

    // 绕 Z 轴旋转时:
    //
    // x' = cos(a) * x - sin(a) * y
    // y' = sin(a) * x + cos(a) * y
    // z' = z
    //
    // 因此本质上就是在 xy 平面做二维旋转,
    // z 坐标保持不变。
    Eigen::Matrix4f model;

    model <<
        std::cos(radian), -std::sin(radian), 0.0f, 0.0f,
        std::sin(radian), std::cos(radian), 0.0f, 0.0f,
        0.0f, 0.0f, 1.0f, 0.0f,
        0.0f, 0.0f, 0.0f, 1.0f;

    // 例子:
    // rotation_angle = 90° 时:
    //
    // (1, 0, 0) -> (0, 1, 0)
    //
    // 也就是绕 Z 轴逆时针旋转 90°。
    return model;
}

// ============================================================
// Projection Matrix
// ============================================================

// 构造透视投影矩阵。
//
// 参数:
// eye_fov      垂直视场角,例如 45°
// aspect_ratio 画面宽高比 width / height
// zNear        相机到近平面的距离,例如 0.1
// zFar         相机到远平面的距离,例如 50
//
// 当前工程调用:
// get_projection_matrix(45, 1, 0.1, 50)
Eigen::Matrix4f get_projection_matrix(
    float eye_fov,
    float aspect_ratio,
    float zNear,
    float zFar)
{
    Eigen::Matrix4f projection = Eigen::Matrix4f::Identity();

    // ------------------------------------------------------------
    // 1. 根据 fovY / aspect 求视锥边界 l、r、b、t
    // ------------------------------------------------------------

    // eye_fov 是角度,tan() 使用弧度。
    float radian = eye_fov / 180.0f * MY_PI;

    // 函数传进来的 zNear / zFar 表示“距离”,所以是正数。
    //
    // 但是 GAMES101 的相机空间约定:
    // 相机在原点,朝 -Z 方向看。
    //
    // 因此近平面和远平面真正的 z 坐标分别是:
    // n = -zNear
    // f = -zFar
    //
    // 例如:
    // zNear = 0.1 -> n = -0.1
    // zFar  = 50  -> f = -50
    float n = -zNear;
    float f = -zFar;

    // 视锥上下对称。
    //
    // 从侧面看:
    //
    // tan(fovY / 2) = t / |n|
    //
    // 所以:
    // t = |n| * tan(fovY / 2)
    //
    // t 是近平面“半高”,b 是下边界。
    float t = std::abs(n) * std::tan(radian / 2.0f);
    float b = -t;

    // aspect = width / height。
    //
    // 对称视锥中:
    // aspect = r / t
    //
    // 所以:
    // r = aspect * t
    //
    // r 是近平面“半宽”,l 是左边界。
    float r = aspect_ratio * t;
    float l = -r;

    // ------------------------------------------------------------
    // 2. Perspective -> Orthographic
    // ------------------------------------------------------------

    // 先把透视视锥“挤压”成长方体。
    //
    // 矩阵:
    //
    // | n  0    0      0  |
    // | 0  n    0      0  |
    // | 0  0   n+f   -nf  |
    // | 0  0    1      0  |
    //
    // 一个点 (x, y, z, 1) 经过这个矩阵后:
    //
    // (nx,
    //  ny,
    //  (n+f)z - nf,
    //  z)
    //
    // 注意最后得到 w' = z。
    //
    // 后面 rasterizer.cpp 会执行:
    //     vec /= vec.w();
    //
    // 所以 x、y 最终会变成:
    //
    // x' = nx / z
    // y' = ny / z
    //
    // |z| 越大,投影后的 x、y 越小,
    // 这就是透视中的“近大远小”。
    Eigen::Matrix4f perspectiveToOrthographic;

    perspectiveToOrthographic <<
        n, 0.0f, 0.0f, 0.0f,
        0.0f, n, 0.0f, 0.0f,
        0.0f, 0.0f, n + f, -n * f,
        0.0f, 0.0f, 1.0f, 0.0f;

    // ------------------------------------------------------------
    // 3. Orthographic Projection:先平移
    // ------------------------------------------------------------

    // 视锥被挤压后,得到长方体:
    //
    // x ∈ [l, r]
    // y ∈ [b, t]
    // z ∈ [f, n]
    //
    // 它的中心是:
    //
    // ((l+r)/2, (b+t)/2, (f+n)/2)
    //
    // 所以平移其相反数,把长方体中心移动到原点。
    //
    // 当前视锥左右、上下完全对称:
    // l = -r
    // b = -t
    //
    // 因此 x、y 中心本来就是 0,
    // 这里主要需要处理 z 方向。
    Eigen::Matrix4f orthographicTranslate;

    orthographicTranslate <<
        1.0f, 0.0f, 0.0f, -(r + l) / 2.0f,
        0.0f, 1.0f, 0.0f, -(t + b) / 2.0f,
        0.0f, 0.0f, 1.0f, -(n + f) / 2.0f,
        0.0f, 0.0f, 0.0f, 1.0f;

    // ------------------------------------------------------------
    // 4. Orthographic Projection:再缩放
    // ------------------------------------------------------------

    // 平移之后,长方体三个方向的长度分别是:
    //
    // x:r - l
    // y:t - b
    // z:n - f
    //
    // 标准立方体 [-1,1]^3 每个方向长度都是 2,
    // 因此三个缩放比例分别为:
    //
    // 2 / (r - l)
    // 2 / (t - b)
    // 2 / (n - f)
    Eigen::Matrix4f orthographicScale;

    orthographicScale <<
        2.0f / (r - l), 0.0f, 0.0f, 0.0f,
        0.0f, 2.0f / (t - b), 0.0f, 0.0f,
        0.0f, 0.0f, 2.0f / (n - f), 0.0f,
        0.0f, 0.0f, 0.0f, 1.0f;

    // 使用列向量时矩阵从右往左作用。
    //
    // 想要:
    // 1. 先平移
    // 2. 再缩放
    //
    // 所以写成:
    // Scale * Translate
    Eigen::Matrix4f orthographicProjection =
        orthographicScale * orthographicTranslate;

    // ------------------------------------------------------------
    // 5. 完整透视投影
    // ------------------------------------------------------------

    // 整个过程:
    //
    // 原始视锥
    //   -> PerspectiveToOrthographic:压成长方体
    //   -> OrthographicProjection:映射到 [-1,1]^3
    //
    // 使用列向量,因此:
    //
    // M_persp = M_ortho * M_persp_to_ortho
    projection =
        orthographicProjection * perspectiveToOrthographic;

    return projection;
}

// ============================================================
// main
// ============================================================

int main(int argc, const char** argv)
{
    // 当前模型旋转角度。
    float angle = 0;

    // false:打开窗口实时显示;
    // true:命令行模式,渲染一帧后直接保存图片。
    bool command_line = false;

    // 命令行模式下默认保存成 output.png。
    std::string filename = "output.png";

    // 如果带命令行参数,则切换到保存图片模式。
    if (argc >= 3)
    {
        command_line = true;

        // argv[2] 保存旋转角度。
        angle = std::stof(argv[2]);

        if (argc == 4)
        {
            // 如果还传入了文件名,就使用指定文件名保存。
            filename = std::string(argv[3]);
        }
        else
        {
            return 0;
        }
    }

    // 创建 700 x 700 的光栅器。
    rst::rasterizer r(700, 700);

    // 相机位于 (0, 0, 5)。
    Eigen::Vector3f eye_pos = { 0, 0, 5 };

    // 三角形三个顶点。
    std::vector<Eigen::Vector3f> pos{
        { 2, 0, -2 },
        { 0, 2, -2 },
        { -2, 0, -2 }
    };

    // 只有一个三角形,使用第 0、1、2 个顶点。
    std::vector<Eigen::Vector3i> ind{ { 0, 1, 2 } };

    // 把顶点和索引交给 rasterizer 管理。
    auto pos_id = r.load_positions(pos);
    auto ind_id = r.load_indices(ind);

    int key = 0;
    int frame_count = 0;

    // ------------------------------------------------------------
    // 命令行模式:只画一帧,然后保存图片
    // ------------------------------------------------------------
    if (command_line)
    {
        // 每帧开始先清空颜色缓冲和深度缓冲。
        r.clear(rst::Buffers::Color | rst::Buffers::Depth);

        // 设置 MVP。
        r.set_model(get_model_matrix(angle));
        r.set_view(get_view_matrix(eye_pos));

        // fovY = 45°
        // aspect = 1
        // near = 0.1
        // far = 50
        r.set_projection(get_projection_matrix(45, 1, 0.1, 50));

        // 绘制一个三角形。
        r.draw(pos_id, ind_id, rst::Primitive::Triangle);

        // Frame Buffer 中每个像素是 Vector3f,
        // 用 OpenCV 包装成 700 x 700 的 RGB 图像。
        cv::Mat image(
            700,
            700,
            CV_32FC3,
            r.frame_buffer().data()
        );

        // 转成 8 位 RGB,便于保存。
        image.convertTo(image, CV_8UC3, 1.0f);

        cv::imwrite(filename, image);

        return 0;
    }

    // ------------------------------------------------------------
    // 交互模式:不断重绘,直到按 Esc
    // ------------------------------------------------------------

    // ASCII 27 对应 Esc。
    while (key != 27)
    {
        r.clear(rst::Buffers::Color | rst::Buffers::Depth);

        // 每一帧都根据当前 angle 重新计算模型矩阵。
        r.set_model(get_model_matrix(angle));
        r.set_view(get_view_matrix(eye_pos));
        r.set_projection(get_projection_matrix(45, 1, 0.1, 50));

        r.draw(pos_id, ind_id, rst::Primitive::Triangle);

        cv::Mat image(
            700,
            700,
            CV_32FC3,
            r.frame_buffer().data()
        );

        image.convertTo(image, CV_8UC3, 1.0f);

        // 显示当前 Frame Buffer。
        cv::imshow("image", image);

        // 等待 10ms,同时读取按键。
        key = cv::waitKey(10);

        // 只是观察程序正在持续刷新,不代表报错。
        std::cout << "frame count: " << frame_count++ << '\n';

        // A:逆时针多旋转 10°。
        if (key == 'a')
        {
            angle += 10;
        }
        // D:反方向旋转 10°。
        else if (key == 'd')
        {
            angle -= 10;
        }
    }

    return 0;
}

rasterizer.cpp

//
// Created by goksu on 4/6/19.
//

#include <algorithm>
#include "rasterizer.hpp"
#include <opencv2/opencv.hpp>
#include <math.h>
#include <stdexcept>

// ============================================================
// 缓冲数据
// ============================================================

// 保存一组顶点,返回一个 pos_buf_id。
// main.cpp 中的:
//   auto pos_id = r.load_positions(pos);
// 得到的就是这里分配的 ID。
rst::pos_buf_id rst::rasterizer::load_positions(
    const std::vector<Eigen::Vector3f>& positions)
{
    auto id = get_next_id();

    // emplace(key, value) 把顶点数组保存到 pos_buf。
    pos_buf.emplace(id, positions);

    return { id };
}

// 保存三角形索引。
// 例如 {0, 1, 2} 表示使用第 0、1、2 个顶点组成一个三角形。
rst::ind_buf_id rst::rasterizer::load_indices(
    const std::vector<Eigen::Vector3i>& indices)
{
    auto id = get_next_id();
    ind_buf.emplace(id, indices);

    return { id };
}

// ============================================================
// 画线
// ============================================================

// Bresenham 直线算法。
// 作用:在两个屏幕点之间选择一系列离散像素,让它们尽量接近理想直线。
// Assignment 1 最终就是用它画三角形的三条边。
void rst::rasterizer::draw_line(Eigen::Vector3f begin, Eigen::Vector3f end)
{
    // 这里只需要屏幕坐标 x、y。
    auto x1 = begin.x();
    auto y1 = begin.y();
    auto x2 = end.x();
    auto y2 = end.y();

    // 线框统一画成白色。
    Eigen::Vector3f line_color = { 255, 255, 255 };

    int x, y, dx, dy, dx1, dy1, px, py, xe, ye, i;

    // dx / dy:终点相对起点移动了多少。
    dx = x2 - x1;
    dy = y2 - y1;

    // 绝对值用于判断这条线更偏“横向”还是“纵向”。
    dx1 = fabs(dx);
    dy1 = fabs(dy);

    // Bresenham 的误差判断量。
    px = 2 * dy1 - dx1;
    py = 2 * dx1 - dy1;

    // |dx| >= |dy|:
    // 线更偏横向,每次主要让 x + 1,再根据误差决定 y 是否变化。
    if (dy1 <= dx1)
    {
        if (dx >= 0)
        {
            x = x1;
            y = y1;
            xe = x2;
        }
        else
        {
            x = x2;
            y = y2;
            xe = x1;
        }

        set_pixel(Eigen::Vector3f(x, y, 1.0f), line_color);

        for (i = 0; x < xe; i++)
        {
            x = x + 1;

            if (px < 0)
            {
                px = px + 2 * dy1;
            }
            else
            {
                // 根据线段方向决定 y 向上还是向下移动。
                if ((dx < 0 && dy < 0) || (dx > 0 && dy > 0))
                    y = y + 1;
                else
                    y = y - 1;

                px = px + 2 * (dy1 - dx1);
            }

            set_pixel(Eigen::Vector3f(x, y, 1.0f), line_color);
        }
    }
    // |dy| > |dx|:
    // 线更偏纵向,每次主要让 y + 1,再根据误差决定 x 是否变化。
    else
    {
        if (dy >= 0)
        {
            x = x1;
            y = y1;
            ye = y2;
        }
        else
        {
            x = x2;
            y = y2;
            ye = y1;
        }

        set_pixel(Eigen::Vector3f(x, y, 1.0f), line_color);

        for (i = 0; y < ye; i++)
        {
            y = y + 1;

            if (py <= 0)
            {
                py = py + 2 * dx1;
            }
            else
            {
                if ((dx < 0 && dy < 0) || (dx > 0 && dy > 0))
                    x = x + 1;
                else
                    x = x - 1;

                py = py + 2 * (dx1 - dy1);
            }

            set_pixel(Eigen::Vector3f(x, y, 1.0f), line_color);
        }
    }
}

// 三维点转四维齐次坐标。
// 对点来说默认 w = 1:
// (x, y, z) -> (x, y, z, 1)
// 这样就可以乘 4x4 的 MVP 矩阵。
auto to_vec4(const Eigen::Vector3f& v3, float w = 1.0f)
{
    return Vector4f(v3.x(), v3.y(), v3.z(), w);
}

// ============================================================
// MVP -> 透视除法 -> Viewport -> 线框绘制
// ============================================================

void rst::rasterizer::draw(
    rst::pos_buf_id pos_buffer,
    rst::ind_buf_id ind_buffer,
    rst::Primitive type)
{
    // 当前作业只实现三角形绘制。
    if (type != rst::Primitive::Triangle)
    {
        throw std::runtime_error(
            "Drawing primitives other than triangle is not implemented yet!");
    }

    // 根据 ID 找到真正的顶点缓冲和索引缓冲。
    auto& buf = pos_buf[pos_buffer.pos_id];
    auto& ind = ind_buf[ind_buffer.ind_id];

    // 将 NDC 的 z 做一次线性映射。
    // 这里是官方框架已有逻辑,Assignment 1 的重点不是这两个值。
    float f1 = (100 - 0.1) / 2.0;
    float f2 = (100 + 0.1) / 2.0;

    // 使用列向量时,矩阵从右向左作用:
    //
    // vertex
    //   -> Model
    //   -> View
    //   -> Projection
    //
    // 所以组合矩阵写成 P * V * M。
    Eigen::Matrix4f mvp = projection * view * model;

    // ind 中每一个 Vector3i 都描述一个三角形的三个顶点索引。
    for (auto& i : ind)
    {
        Triangle t;

        // 把三个顶点转为齐次坐标,再乘 MVP。
        Eigen::Vector4f v[] = {
            mvp * to_vec4(buf[i[0]], 1.0f),
            mvp * to_vec4(buf[i[1]], 1.0f),
            mvp * to_vec4(buf[i[2]], 1.0f)
        };

        // 透视除法(Perspective Divide)。
        //
        // Projection 之后得到的是齐次坐标 (x, y, z, w),
        // 需要除以 w 才得到 NDC:
        //
        // (x, y, z, w)
        //      ->
        // (x/w, y/w, z/w, 1)
        //
        // 在我们的透视矩阵中 w 与原来的 z 有关,
        // 所以这里会自然产生“近大远小”。
        for (auto& vec : v)
        {
            vec /= vec.w();
        }

        // Viewport Transformation。
        //
        // 透视除法后 x、y 位于 NDC 的 [-1,1]:
        //
        // x_screen = width  / 2 * (x_ndc + 1)
        // y_screen = height / 2 * (y_ndc + 1)
        //
        // 例如 width = 700:
        // x_ndc = -1 -> 0
        // x_ndc =  0 -> 350
        // x_ndc =  1 -> 700
        for (auto& vert : v)
        {
            vert.x() = 0.5 * width * (vert.x() + 1.0);
            vert.y() = 0.5 * height * (vert.y() + 1.0);
            vert.z() = vert.z() * f1 + f2;
        }

        // 把变换后的前三个分量重新写入 Triangle。
        //
        // 官方原始框架这里连续调用了三次相同的 setVertex,
        // 结果等价于调用一次。这里保留原代码结构,只做注释说明。
        for (int i = 0; i < 3; ++i)
        {
            t.setVertex(i, v[i].head<3>());
            t.setVertex(i, v[i].head<3>());
            t.setVertex(i, v[i].head<3>());
        }

        // 给三个顶点设置 RGB 颜色。
        // Assignment 1 最终只画白色线框,因此这些顶点颜色暂时不会体现在边线上。
        t.setColor(0, 255.0, 0.0, 0.0);
        t.setColor(1, 0.0, 255.0, 0.0);
        t.setColor(2, 0.0, 0.0, 255.0);

        // 连接三条边。
        rasterize_wireframe(t);
    }
}

// 三角形线框:分别连接 CA、CB、BA。
void rst::rasterizer::rasterize_wireframe(const Triangle& t)
{
    draw_line(t.c(), t.a());
    draw_line(t.c(), t.b());
    draw_line(t.b(), t.a());
}

// ============================================================
// MVP 矩阵设置
// ============================================================

void rst::rasterizer::set_model(const Eigen::Matrix4f& m)
{
    model = m;
}

void rst::rasterizer::set_view(const Eigen::Matrix4f& v)
{
    view = v;
}

void rst::rasterizer::set_projection(const Eigen::Matrix4f& p)
{
    projection = p;
}

// ============================================================
// Frame / Depth Buffer
// ============================================================

void rst::rasterizer::clear(rst::Buffers buff)
{
    // 清颜色缓冲:所有像素恢复成黑色。
    if ((buff & rst::Buffers::Color) == rst::Buffers::Color)
    {
        std::fill(
            frame_buf.begin(),
            frame_buf.end(),
            Eigen::Vector3f{ 0, 0, 0 }
        );
    }

    // 清深度缓冲:初始化为正无穷。
    // 后续做深度测试时,任何正常深度都会比 +∞ 更近。
    if ((buff & rst::Buffers::Depth) == rst::Buffers::Depth)
    {
        std::fill(
            depth_buf.begin(),
            depth_buf.end(),
            std::numeric_limits<float>::infinity()
        );
    }
}

// 创建光栅器时按照分辨率分配缓冲。
// 例如 700 x 700 会各自创建 490000 个元素。
rst::rasterizer::rasterizer(int w, int h)
    : width(w), height(h)
{
    frame_buf.resize(w * h);
    depth_buf.resize(w * h);
}

// 将二维 (x, y) 转换成 Frame Buffer 的一维下标。
// 框架的缓冲布局会对 y 做翻转。
int rst::rasterizer::get_index(int x, int y)
{
    return (height - y) * width + x;
}

// 写入一个屏幕像素。
void rst::rasterizer::set_pixel(
    const Eigen::Vector3f& point,
    const Eigen::Vector3f& color)
{
    // 超出屏幕范围的点直接忽略,避免数组越界。
    if (point.x() < 0 || point.x() >= width ||
        point.y() < 0 || point.y() >= height)
    {
        return;
    }

    // Frame Buffer 是一维数组:
    // 屏幕坐标需要换算成数组下标。
    auto ind = (height - point.y()) * width + point.x();

    frame_buf[ind] = color;
}

rasterizer.hpp

//
// Created by goksu on 4/6/19.
//
// rasterizer 保存整个简单光栅器需要的数据:
// - Model / View / Projection 三个矩阵
// - 顶点缓冲和索引缓冲
// - Frame Buffer 和 Depth Buffer
// - 屏幕宽高
//
// Assignment 1 目前只绘制三角形线框,还没有真正填充三角形内部。
//

#pragma once

#include "Triangle.hpp"
#include <algorithm>
#include <Eigen/Eigen>

using namespace Eigen;

namespace rst {

    // 指定需要清空哪一种缓冲。
    enum class Buffers
    {
        Color = 1,
        Depth = 2
    };

    // 让 Buffers 可以写成:
    // Buffers::Color | Buffers::Depth
    // 表示颜色缓冲和深度缓冲一起处理。
    inline Buffers operator|(Buffers a, Buffers b)
    {
        return Buffers((int)a | (int)b);
    }

    // 用于判断某个 Buffers 标志是否包含 Color / Depth。
    inline Buffers operator&(Buffers a, Buffers b)
    {
        return Buffers((int)a & (int)b);
    }

    // 当前框架定义的图元类型。
    enum class Primitive
    {
        Line,
        Triangle
    };

    // 顶点缓冲 ID。
    // 不直接返回 int,而是包装成不同的类型,防止把顶点缓冲 ID
    // 和索引缓冲 ID 传反。
    struct pos_buf_id
    {
        int pos_id = 0;
    };

    // 索引缓冲 ID。
    struct ind_buf_id
    {
        int ind_id = 0;
    };

    class rasterizer
    {
    public:
        // 创建 width x height 的光栅器,同时分配 Frame / Depth Buffer。
        rasterizer(int w, int h);

        // 把顶点数据、索引数据放入内部缓冲,并返回对应 ID。
        pos_buf_id load_positions(const std::vector<Eigen::Vector3f>& positions);
        ind_buf_id load_indices(const std::vector<Eigen::Vector3i>& indices);

        // 设置 MVP 三个矩阵。
        void set_model(const Eigen::Matrix4f& m);
        void set_view(const Eigen::Matrix4f& v);
        void set_projection(const Eigen::Matrix4f& p);

        // 给屏幕上的某个像素写颜色。
        void set_pixel(const Eigen::Vector3f& point, const Eigen::Vector3f& color);

        // 清空指定缓冲。
        void clear(Buffers buff);

        // 根据顶点缓冲、索引缓冲和图元类型进行绘制。
        void draw(pos_buf_id pos_buffer, ind_buf_id ind_buffer, Primitive type);

        // OpenCV 会直接使用 Frame Buffer 中的数据创建 cv::Mat。
        std::vector<Eigen::Vector3f>& frame_buffer() { return frame_buf; }

    private:
        // 使用 Bresenham 算法画一条线。
        void draw_line(Eigen::Vector3f begin, Eigen::Vector3f end);

        // Assignment 1 只画线框:
        // 将三角形的三条边分别画出来。
        void rasterize_wireframe(const Triangle& t);

    private:
        // Model / View / Projection 三个矩阵。
        // draw() 中会组合成:
        // mvp = projection * view * model
        Eigen::Matrix4f model;
        Eigen::Matrix4f view;
        Eigen::Matrix4f projection;

        // 顶点缓冲:
        // key 是 pos_buf_id,value 是一组三维顶点。
        std::map<int, std::vector<Eigen::Vector3f>> pos_buf;

        // 索引缓冲:
        // 每个 Vector3i 表示一个三角形使用哪三个顶点。
        std::map<int, std::vector<Eigen::Vector3i>> ind_buf;

        // 屏幕颜色缓冲。
        // 每个 Vector3f 保存一个像素的 RGB。
        std::vector<Eigen::Vector3f> frame_buf;

        // 深度缓冲。
        // Assignment 1 的线框绘制暂时没有真正使用深度测试,
        // 但框架已经提前准备好。
        std::vector<float> depth_buf;

        int get_index(int x, int y);

        // 输出画面的尺寸。
        int width, height;

        // 给每份缓冲分配唯一 ID。
        int next_id = 0;
        int get_next_id() { return next_id++; }
    };

} // namespace rst

Triangle.cpp

//
// Created by LEI XU on 4/11/19.
//

#include "Triangle.hpp"
#include <algorithm>
#include <array>
#include <stdexcept>

// 创建三角形时,先把顶点、颜色和纹理坐标初始化为 0。
// 后续真正绘制前,再通过 setVertex / setColor 等函数写入实际数据。
Triangle::Triangle()
{
    // 三个顶点初始化到原点。
    v[0] << 0, 0, 0;
    v[1] << 0, 0, 0;
    v[2] << 0, 0, 0;

    // 三个顶点颜色初始化为黑色。
    color[0] << 0.0, 0.0, 0.0;
    color[1] << 0.0, 0.0, 0.0;
    color[2] << 0.0, 0.0, 0.0;

    // 纹理坐标初始化为 (0, 0)。
    tex_coords[0] << 0.0, 0.0;
    tex_coords[1] << 0.0, 0.0;
    tex_coords[2] << 0.0, 0.0;
}

// 设置第 ind 个顶点的位置。
void Triangle::setVertex(int ind, Eigen::Vector3f ver)
{
    v[ind] = ver;
}

// 设置第 ind 个顶点的法线。
void Triangle::setNormal(int ind, Vector3f n)
{
    normal[ind] = n;
}

// 设置第 ind 个顶点的颜色。
void Triangle::setColor(int ind, float r, float g, float b)
{
    // 这里的输入使用常见的 0~255 RGB 范围。
    if ((r < 0.0) || (r > 255.) ||
        (g < 0.0) || (g > 255.) ||
        (b < 0.0) || (b > 255.)) {
        throw std::runtime_error("Invalid color values");
    }

    // 内部统一保存为 0~1。
    // 例如 (255, 0, 0) -> (1, 0, 0)。
    color[ind] = Vector3f(
        static_cast<float>(r) / 255.0f,
        static_cast<float>(g) / 255.0f,
        static_cast<float>(b) / 255.0f
    );
}

// 设置第 ind 个顶点的纹理坐标。
void Triangle::setTexCoord(int ind, float s, float t)
{
    tex_coords[ind] = Vector2f(s, t);
}

// 将三个三维顶点转换成四维齐次坐标。
// 对“点”来说 w = 1,这样后续才能使用 4x4 的 MVP 矩阵统一处理。
std::array<Vector4f, 3> Triangle::toVector4() const
{
    std::array<Vector4f, 3> res;

    std::transform(
        std::begin(v),
        std::end(v),
        res.begin(),
        [](auto& vec) {
            return Vector4f(vec.x(), vec.y(), vec.z(), 1.0f);
        }
    );

    return res;
}

Triangle.hpp

//
// Created by LEI XU on 4/11/19.
//
// Triangle 用来保存一个三角形的顶点以及每个顶点附带的属性。
// Assignment 1 目前主要使用顶点位置;颜色、纹理坐标、法线会在后续作业继续使用。
//

#ifndef RASTERIZER_TRIANGLE_H
#define RASTERIZER_TRIANGLE_H

#include <Eigen/Eigen>

using namespace Eigen;

class Triangle
{
public:
    // 三个顶点的原始三维坐标。
    // 框架约定顶点按照逆时针顺序保存:v0、v1、v2。
    Vector3f v[3];

    // 每个顶点附带的属性。
    Vector3f color[3];      // 顶点颜色 RGB
    Vector2f tex_coords[3]; // 顶点纹理坐标 (u, v)
    Vector3f normal[3];     // 顶点法线

    Triangle();

    // 直接取得三个顶点。
    // rasterize_wireframe() 会使用这些函数连接三条边。
    Eigen::Vector3f a() const { return v[0]; }
    Eigen::Vector3f b() const { return v[1]; }
    Eigen::Vector3f c() const { return v[2]; }

    // 修改指定下标的顶点属性。
    void setVertex(int ind, Vector3f ver);              // 设置第 ind 个顶点位置
    void setNormal(int ind, Vector3f n);                // 设置第 ind 个顶点法线
    void setColor(int ind, float r, float g, float b);  // 设置第 ind 个顶点颜色
    void setTexCoord(int ind, float s, float t);        // 设置第 ind 个顶点纹理坐标

    // 把三个 Vector3f 顶点转换成 Vector4f。
    // 点的齐次坐标 w 取 1,例如:
    // (2, 0, -2) -> (2, 0, -2, 1)
    std::array<Vector4f, 3> toVector4() const;
};

#endif // RASTERIZER_TRIANGLE_H

5.5 总结

  • 完成观测变换:在 MVP 之后,通过视口变换把规范立方体的 $x、y$ 映射到屏幕空间。
  • 光栅化:把连续几何图形转换成离散的像素表示,三角形是最基本的光栅化图元。
  • 采样:通过检查像素中心是否在三角形内部,决定像素是否被覆盖。
  • 叉积:判断点与三条有向边的位置关系,从而完成三角形内外测试。
  • 包围盒与增量遍历:减少无效的像素判断。
  • 走样:连续图形经过离散采样后产生的信息损失,锯齿是最常见的表现。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

赏

×

喜欢就点赞,疼爱就打赏