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