我的github项目链接, 包含全部完整代码和三维图像数据.
介绍
我们使用的是来自 tinyrenderer 的github项目: 使用C++从零开始建立一个光栅化渲染器, 能够帮助我们入门OpenGL并理解其原理. 它所依赖库非常少, 包含他写的两个库文件, tgaimage.cpp
, tgaimage.h
和 .obj
3D模型文件读取库 model.cpp
, model.h
和 几何类型库 geometry.h
.
它可以将当前视角的图像输出为 .tga
格式的文件, 可以使用 PhotoShop
进行预览和修改, 我们使用的是 PhotoShop CS6
版本, g++
编译器版本为 8.1.0
.
中文教程参考: KrisYu 的github项目和 知乎 - 从零构建光栅器,tinyrenderer笔记 对应的github项目 MrZz233/tinyrenderer_notes
由于该简单渲染器并不支持鼠键交互功能, 所以我们打算使用该项目完成作业前两个部分, 即
- 网络顶点和边的非消隐外观显示;
- 网络顶点、边和多边形的动态隐藏元素去除;
其中第二部分我们打算使用Z-buffer算法完成.
后三个部分
- 鼠标, 键盘交互实现模型旋转, 视野放大等;
- 选择显示环境光, 漫反射(Lambert模型), 镜面反射(Phong模型)光照效应的面绘制效果;
- 选择显示Gouraud明暗处理和Phong明暗处理的面绘制效果.
*. 选择独特的网络模型进行可视化.
我们打算使用Unity3D完成.
基础命令
编译方法
假设我们写的程序为main.cpp, 并且和文件tgaimage.cpp
, tgaimage.h
, model.cpp
, model.h
, geometry.h
, 放在了同一个文件夹下.
编译命令(使用g++的链接编译)
编译并运行
源代码理解
由于我们是第一次接触OpenGL所以对其命令没有任何了解, 只能自己摸索查阅资料, 每部分我会根据结合源代码和别人所写的代码, 给出对命令的解释, 可能并不准确.
绘制点与线
基础命令
根据 tgaimage.h
查看源码, 发现它主要由两部分组成, 分别为 颜色设置(TGAColor) 和 画布设置(TGAImage).
点的绘制
绘制四个不同颜色的点
绘制交错的点
水平旋转后
线的绘制
根据两点 (x0,y0) 和 (x1,y1), 绘制出两点间的线段, 由插值多项式可知, 两点间直线可以表示为
y(x)=y0+x1−x0y1+y0(x−x0)
设 t=x1−x0x−x0, 于是 y=y0+(y1−y0)t, 由于像素点都是离散的, 我们考虑递增 x 坐标, 计算每一个x对应的y, 再绘制出来.
但是这样有一个问题, 如果两点间的斜率 k=∣∣∣∣∣x1−x0y1−y0∣∣∣∣∣>1, 由于 x 是离散递增的, 即变化量为 +1, 所以 y 的变化可能是阶跃的, 这样就导致线是间断的了(见下左图), 所以这种情况下, 我们需要对 y 坐标进行递增绘图, 为简化代码, 考虑直接交换 x,y 坐标即可.
对y进行递增后
绘制线框模型
我们使用了github - tinyrenderer
项目中所提供的免费模型.
我们知道 .obj
文件一种3D模型文件格式, 这里我们先将其打开, 并绘制出线框模型.
obj文件格式
这里打开第一个非洲人脸模型的数据(省略号省略过多的类似信息)
我们先研究 v
和 f
开头的信息.
-
v
后面的三个坐标分别为该顶点的 x,y,z
坐标, 其中 x,y,z∈[−1,1]∩R, 每一行就代表一个顶点的全部信息.
-
f 24/1/24 25/2/25 26/3/26
表示一个模型的三角面, 一个三角面由三个顶点构成. 一个面由三组信息构成, 每组信息的一个数据表示顶点的序号, 这个例子表示: 该面由序号 24,25,26 三个顶点组成这里的顶点顺序由 v
的读入顺序确定.
obj文件读取方式
我们在和主函数相同的目录创建名为 obj
的文件夹用于存放 .obj
文件, 并在和主函数相同的目录中新加入三个头文件 geometry.h
, model.h
, model.cpp
, 第一个用于存储几何数据, 如三维和二维中一个点的相关数据, 类名称分别为 Vec3
和 Vec2
, 其源代码如下, 我们加入了对其的解释
第二个和第三个用于读取文件, 我们从 Model
类来看
Model
类给出了一个模型所有的数据, vert是vertex的缩写, 即顶点; face是三角面.
-
私有变量的动态数组 verts_, faces_
分别存储该模型的顶点和面的数据, 其中 verts_
是一维动态数组, 而 faces_
是二维动态数组(因为其中要存储三个顶点的数据).
-
函数 nverts(), nfaces()
能够返回当前模型所拥有的的顶点数和面数, 对应于私有变量动态数组 verts_, faces_
的大小, 即返回 verts_.size(), faces_.size()
.
-
函数 vert(int i), face(int idx)
分别用于访问私有数组 verts_, faces_
中的元素, 即返回 verts_[i], faces_[idx]
.
下面代码给出了如何使用 Model.h
来读取文件和显示线框图.
该视角为沿z正轴方向投影图.
平面着色
我们已经会绘三角形面了, 接下来研究如何对三角形进行染色, 使我们的图片更加好看, 有立体感.
不难想到, 填充三角形内部可以绘制一条条横线段完成, 每一条横线左端点为三角形的左边的边界, 右端点为右边的边界, 我们先对y轴坐标排序, 然后对每个x计算左端点和右端点坐标即可.
假设三角形三个坐标为 (xi,xi)i=02 且 x0<x1<x2. 我们可以把当前扫到的 y 轴坐标视为一条扫描线, 从下至上扫过去, 每次对扫描线上三角形内部点进行填充.
-
扫描线的左端点计算比较容易, 根据 xl=x0+y2−y0y−y0(x2−x0) 即可得出.
-
右端点需要确定当前的扫描线有没有经过中间的顶点, 假设当前右端点在线段 (x1,y1),(xt,yt) 上, 则 xr=x1+yt−y1y−y1(xt−x1), 初始时 (xt,yt)=(x0,y0), 当扫描线 y⩾y1 时, (xt,yt)=(x1,y1).
我们接下来根据光照角度决定的光强, 对人脸绘制阴影, 以体现出其立体感, 这种方法称为Gouraud着色.
具体方法是, 先通过三角形的三个点, 计算出正面的法向量 n, 给定光照的方向 l, 利用内积即可计算出光照强度 n⋅l.
垂直光照
斜照射
其实该光照算法有明显的问题, 当法向量和入射光线夹角大于 2π 时, 即外积为负数, 我们是不会进行绘制阴影的, 这就导致有很多暗色地方没有三角形面填充.
而且, 模型的嘴部由于有内腔的存在, 所以导致内部渲染将外部渲染覆盖掉了, 下面我们将用Z-buffer算法对其进行改进.
Z-buffer 算法
这张图很好的揭示了, 如何处理内腔, 当视线上有两个面同时存在时, 我们只需要将视线最前方的图像显示出来即可.
质心坐标
首先我们需要引入质心坐标这个概念, 对于一个 △ABC, 设点 P 为其内点
则向量 AP 一定能表示为 AB,AC 的线性组合, 即
AP=uAB+vAC(u+v<1,u>0,v>0)(1)
我们做一点变形可得
OP=(1−u−v)OA+uOB+vOC=[1−u−vuv]⎣⎢⎢⎢⎡OAOBOC⎦⎥⎥⎥⎤
我们称 (1−u−v,u,v) 为点 P 对于 △ABC 的质心坐标
于是可以得出以下结论:
点 P 在 ABC 的内部, 当且仅当, P 对于 △ABC 的质心坐标的每一维分量均大于 0.
接下来考虑给出点 A,B,C,P 的坐标, 如何计算点 P 对于 △ABC 的质心坐标. 由 (1) 式可知
⇒ uAB+vAC+PA=0 ⇒⎩⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎧[uv1]⎣⎢⎢⎢⎡ABxACxPAx⎦⎥⎥⎥⎤=0 [uv1]⎣⎢⎢⎢⎡AByACyPAy⎦⎥⎥⎥⎤=0 k[uv1]=(ABx,ACx,PAx)×(ABy,ACy,PAy)=:(a,b,c)(2)
最后一个等号原因: 通过 (2) 式可以看出 [uv1] 正是右边两个向量的外积方向上. 所以
u=ca,v=cb
且当 ABC 三点共线时 c=0.
计算质心坐标的代码如下
所以又有一种填充三角形的方法, 先将三角形用外接矩形框住, 然后枚举矩形中的每一个点, 如果该点在三角形内部则进行绘制, 否则不绘制.
Z-buffer算法
思路非常简单, 将每个像素到光源的最短距离计算出来, 然后对于同一个像素位置, 取最短距离的点进行绘制即可.
我们利用质心坐标可以很容易计算出每个像素的距离, 公式如下
OPz=(1−u−v)OAz+uOBz+vOCz
我们先初始化一个和画布相同大小的数组 zbuffer
, 用于储存当前每个像素点的到光源终点的最大距离(也就是更原理光源起点了), 初始值为最小值, 绘制的时候判断是否距离更大, 然后用较大值进行覆盖即可.
垂直光照
斜光照