图形渲染(1)光栅化

1,856 阅读11分钟

欢迎关注公众号: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实现过程

  1. 准备三角形点
  2. MVP变换(model、view/camera、projection)
  3. 将归一化坐标还原成真实场景的坐标,绘制三角形的三条边
  4. 监听按键,在循环逻辑里处理不同的按键消息

重点说明MVP变换,

model变换:好比你正在拍照,将一个物体放到场景中,摆好姿势,摆一个Pose就是model变换

view/camera变换:将相机和物体同时移动,使相机位于全局坐标系的(0,0,0)处,即原点。view变换是为了方便计算。

view/camera变换
view/camera变换

projection变换:物体最后成像是投影到相机的感光设备上,这个过程就是投影,同时为了方便计算,场景缩放成单位立方体,即 1x1x1。

透视投影
透视投影
投影成像
投影成像

注:投影分正交投影和透视投影,正交投影即物体平行的投射到屏幕上,透视投影模拟人眼的特点,按照近大远小来缩放

https://stackoverflow.com/questions/36573283/from-perspective-picture-to-orthographic-picture
https://stackoverflow.com/questions/36573283/from-perspective-picture-to-orthographic-picture
透视投影-近大远小
透视投影-近大远小

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(00) = cos(angle);
    model(01) = -sin(angle);
    model(10) = sin(angle);
    model(11) = 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实现了绘制的核心功能。以绘制一条线段为例说明:

  1. 给出线段的起点和终点:P0、P1
  2. 对P0、P1做插值,对插值的每一个像素点设置颜色,实际上代码里没有实现颜色差值,最后统一用白色即(255,255,255),opencv的颜色通道是ABGR,即最右边的通道是R通道
  3. 光栅化遍历是以左下角为原点,而图片数组缓冲是以左上角为原点。回忆下,opengl里对图片纹理采样时要上下翻转也是这个道理,即图片存储和渲染采样的y坐标相反,参考透视投影 解析手记文末说明[2]

代码较长,不逐一分析,可参考: https://github.com/kingiluob/Games101/blob/master/Assignment1/rasterizer.cpp

注意几点:

  1. Bresenham直线绘制算法
  2. 归一化坐标还原成屏幕坐标实现

代码中的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(700700);
    
    // 眼镜(or 相机)的位置
    Eigen::Vector3f eye_pos = {005};
    
    // 初始化三角形的三个点
    std::vector<Eigen::Vector3f> pos{{20-2}, {02-2}, {-20-2}};
    
    // 选取上面三个点,角标索引为0、1、2,和opengl里的操作一样基于角标选点
    std::vector<Eigen::Vector3i> ind{{012}};
  
    // 加载坐标点和,点的索引角标,和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(4510.150));
        
        // 绘制
        r.draw(pos_id, ind_id, rst::Primitive::Triangle);
        
        // CV_32FC3的理解见下文
        // 计算图片可以内存大一点,生成图片节省内存可以小一点
        cv::Mat image(700700, 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 /专注图像处理的移动开发老兵