欢迎关注公众号:sumsmile /专注图像处理的移动开发老兵
这是我的图形学笔记。案例来自闫令琪-现代计算机图形学[1]课程的8道作业。作业地址:http://games-cn.org/forums/topic/allhw/
第一篇实现绘制一个三角形,麻雀虽小五脏俱全,不使用任何图形API,模拟opengl的原理完成简单图形的光栅化,整个实现不少细节需要反复推敲。
目录:
1.实现效果
除了显示三角形,还能响应按键左右(A/D)旋转, Esc退出。
2.准备工作
极简开发环境:
- 开发IDE:visual studio code,也可以用windows平台的vs 或者Mac平台的xcode
- 依赖库:Eigen(线性代数运算)、opencv(绘制、图像处理)
- 开发语言:C++
- Cmake编译工具
3.核心概念
3.1什么是光栅化
raster(光栅)在德语里是screen(屏幕)的意思,rasterize(光栅化)即把图形画在屏幕上。
光栅化有很多形式,不一定是指显示在屏幕上。
3.1实现过程
- 准备三角形点
- MVP变换(model、view/camera、projection)
- 将归一化坐标还原成真实场景的坐标,绘制三角形的三条边
- 监听按键,在循环逻辑里处理不同的按键消息
重点说明MVP变换,
model变换:好比你正在拍照,将一个物体放到场景中,摆好姿势,摆一个Pose就是model变换
view/camera变换:将相机和物体同时移动,使相机位于全局坐标系的(0,0,0)处,即原点。view变换是为了方便计算。
projection变换:物体最后成像是投影到相机的感光设备上,这个过程就是投影,同时为了方便计算,场景缩放成单位立方体,即 1x1x1。
注:投影分正交投影和透视投影,正交投影即物体平行的投射到屏幕上,透视投影模拟人眼的特点,按照近大远小来缩放
4.核心代码说明
4.1实现MVP
model变换
model变换主要涉及旋转
// model变换,这里只实现围绕z轴旋转
Eigen::Matrix4f get_model_matrix(float rotation_angle)
{
Eigen::Matrix4f model = Eigen::Matrix4f::Identity();
// TODO: Implement this function
// Create the model matrix for rotating the triangle around the Z axis.
// Then return it.
float angle = rotation_angle * MY_PI / 180;
model(0, 0) = cos(angle);
model(0, 1) = -sin(angle);
model(1, 0) = sin(angle);
model(1, 1) = cos(angle);
return model;
}
view变换
view变换涉及平移与旋转,旋转还有点复杂,需要求矩阵的逆。本例较简单,仅需处理平移
Eigen::Matrix4f get_view_matrix(Eigen::Vector3f eye_pos)
{
Eigen::Matrix4f view = Eigen::Matrix4f::Identity();
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;
}
projection变换,这里实现透视投影
稍微复杂一点,分几步:
- 将透视投影的视锥挤压成正交投影的立方体
- 视窗移动到原点,缩放成 1 * 1 * 1
注:视窗可以理解为显示窗口,即你要选取整个场景中的哪一部分显示到屏幕上
代码实现可以分3次逐步计算,也可以把三次矩阵乘法合并成一个矩阵:
第一步:透视-->正交
第二、三步:正交投影变换
矩阵合并:
Eigen::Matrix4f get_projection_matrix(float eye_fov, float aspect_ratio,
float zNear, float zFar)
{
// 生成单位矩阵
Eigen::Matrix4f projection = Eigen::Matrix4f::Identity();
// 视角转成弧度角
float eye_angle = eye_fov * MY_PI / 180;
float t,b,l,r;
// 计算 top right left bottom坐标
t = zNear * tan(eye_angle/2);
r = t * aspect_ratio;
l = -r;
b = -t;
Eigen::Matrix4f m1;
Eigen::Matrix4f m2;
Eigen::Matrix4f m3;
// 1. 透视投影压缩成正交投影
m1 << zNear,0,0,0, 0,zNear,0,0,0, 0,zNear + zFar, -zNear*zFar, 0,0,1,0;
// 2. 中心移到原点,方便计算而已。投影相当于观察窗口,窗口中心移动到原点是整体移动,不会改变model 和 view的相对位置。
m2 <<1,0,0,0, 0,1,0,0, 0,0,1,-(zNear + zFar)/2, 0,0,0,1;
// 3. 缩放成单位立方体,也是方便计算
// 注意,缩放操作不是线性的,不能还原,z坐标已经丢失了,不过依然可以用来表示深度
m3 << 2/(r-l),0,0,0, 0,2/(t-b),0,0, 0,0,2/(zNear-zFar),0, 0,0,0,1;
projection = m3 * m2 * m1 * projection;
return projection;
}
4.2光栅化器实现
rasterizer.cpp实现了绘制的核心功能。以绘制一条线段为例说明:
- 给出线段的起点和终点:P0、P1
- 对P0、P1做插值,对插值的每一个像素点设置颜色,实际上代码里没有实现颜色差值,最后统一用白色即(255,255,255),opencv的颜色通道是ABGR,即最右边的通道是R通道
- 光栅化遍历是以左下角为原点,而图片数组缓冲是以左上角为原点。回忆下,opengl里对图片纹理采样时要上下翻转也是这个道理,即图片存储和渲染采样的y坐标相反,参考透视投影 解析手记文末说明[2]
代码较长,不逐一分析,可参考: https://github.com/kingiluob/Games101/blob/master/Assignment1/rasterizer.cpp
注意几点:
- Bresenham直线绘制算法
- 归一化坐标还原成屏幕坐标实现
代码中的Breshnham直线插值算法实现的不太优雅,参考的是stack overflow的一个实现: https://stackoverflow.com/a/16405254
参考下面博客的Breshnham代码,实现的更好些: https://www.cnblogs.com/1Kasshole/p/14038274.html。Bresenham是谁[3]
4.3组织主程序
nt main(int argc, const char** argv)
{
float angle = 0;
// 控制是否命令行调用,并输出图片,这里我简化了代码删除了相关逻辑
bool command_line = false;
std::string filename = "output.png";
// 设置光栅化大小,宽 * 高
rst::rasterizer r(700, 700);
// 眼镜(or 相机)的位置
Eigen::Vector3f eye_pos = {0, 0, 5};
// 初始化三角形的三个点
std::vector<Eigen::Vector3f> pos{{2, 0, -2}, {0, 2, -2}, {-2, 0, -2}};
// 选取上面三个点,角标索引为0、1、2,和opengl里的操作一样基于角标选点
std::vector<Eigen::Vector3i> ind{{0, 1, 2}};
// 加载坐标点和,点的索引角标,和opengl操作一样,同一个点可能多次使用
auto pos_id = r.load_positions(pos);
auto ind_id = r.load_indices(ind);
// 键盘记录
int key = 0;
int frame_count = 0;
while (key != 27) {
// 清楚缓冲区:颜色缓冲和深度缓冲
r.clear(rst::Buffers::Color | rst::Buffers::Depth);
// 设置mvp
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_32FC3的理解见下文
// 计算图片可以内存大一点,生成图片节省内存可以小一点
cv::Mat image(700, 700, CV_32FC3, r.frame_buffer().data());
// 1.0f 是alpha通道,即完全不透明
image.convertTo(image, CV_8UC3, 1.0f);
cv::imshow("image", image);
// 等待键盘输入 10ms
key = cv::waitKey(10);
std::cout << "frame count: " << frame_count++ << '\n';
if (key == 'a') {
angle += 10;
}
else if (key == 'd') {
angle -= 10;
}
}
return 0;
}
CV_8UC1,CV_32FC3等参数的含义
bit_depth
比特数---代表8bite,16bites,32bites,64bites---举个例子吧--比如说,
如果你现在创建了一个存储--灰度图片的Mat对象,这个图像的大小为宽100,高100,那么,现在这张灰度图片中有10000个像素点,它每一个像素点在内存空间所占的空间大小是8bite,8位--所以它对 应的就是CV_8;
S|U|F
S--代表---signed int---有符号整形
U--代表--unsigned int--无符号整形
F--代表--float---------单精度浮点型
C<number_of_channels>
代表---一张图片的通道数,比如:
1--灰度图片--grayImg---是--单通道图像
2--RGB彩色图像---------是--3通道图像
3--带Alph通道的RGB图像--是--4通道图像
4.4编译&运行
工程基于cmake组织代码,不了解cmake可以简单的查阅cmake的基本操作和CMakeList.txt编写。
最基本的cmake命令:
进入build文件夹下,生成cmake相关文件
cmake ..
编译,j4表示用4线程
make -j4
运行
./Rasterizer
CMakeList.txt说明一点:
find_package(OpenCV REQUIRED)命令,是去Cmake的根目录下运行findOpencv,这里opencv一般能找到,但是其他的库就不一定行,要看安装的方式。find失败,可以直接include,如
include_directories(/usr/local/Cellar/eigen/3.3.9/include)
结束语
按照文中说明,你应该能很容易实现效果,跑起来demo,文末给出了我参考的代码。感谢闫令琪老师带来的图形学课程,收获良多。
闫令琪老师是个90后,当年拿了最佳博士论文,现在是加州大学圣芭芭拉分校的博士生导师,助理教授,看面相十分青涩,讲起课来非常专业,钢琴十级,吃鸡游戏也是好手。
"Lingqi Yan, first of his name, the unrejected, author of seven papers, breaker of the record, and the chicken eater." -- Born to be Legendary, by Lifan Wu, UCSD
有些人生而不凡!
其他参考
GAMES101 实现—1[4]
GAMES101 实现—2[5]
GAMES101 实验笔记[6]
参考资料
[1]GAMES101:现代计算机图形学入门: http://games-cn.org/intro-graphics/
[2]透视投影 解析手记: https://www.cnblogs.com/1Kasshole/p/13993749.html
[3]Bresenham介绍: en.wikipedia.org/wiki/Jack_Elton_Bresenham
[4]github实现—1: https://github.com/kingiluob/Games101/blob/master/Assignment1/rasterizer.cpp
[5]games101实现—2: https://github.com/Quanwei1992/GAMES101/blob/master/01/rasterizer.cpp
[6]GAMES101 实验笔记: https://zhuanlan.zhihu.com/p/355170943
欢迎关注公众号:sumsmile /专注图像处理的移动开发老兵