软光栅从零开始——绘制线框、背面剔除、zbuffer
在此篇中,我们将学习如何绘制一个三角形并对其进行着色,如何判断屏幕中同一个像素位置顶点的前后顺序
绘制三角形和平面着色
绘制图形,我们需要画线也就需要学习画线算法,但图形种类多种多样,为什么我们选择学习三角形呢?
因为三角形是最基本的多边形,其拥有许多特性:
- 三角形可以分解其他多边形,也就是我们可以不断分解其他多边形,最终形成有多个三角形组成的多边形
- 三角形保证是平面的
- 三角形可以用叉乘判断内外
- 三角形内部可以定义插值
在讲明为什么我们选择三角形作为最基本的图形后,我们现在来学习如何绘制一个三角形
按照之前的画线算法,我们可以很轻松的画出三角形来,如下图。但问题是我们平时在游戏中看到的丰富多彩的画面,很明显三角形内部是被填充了的,那么请想想我们如何对三角形进行填充呢?
实际上一个填充了的图形也是由许多许多线组成的,也就是我们可以在三角形内部从下往上画许多许多的水平线将其填充。具体实现上我们会将三角形分成上下两部分,从下向上填充
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
// 将三角形三个顶点y这一维度按从小到大的顺序排列
if (t0.y>t1.y) std::swap(t0, t1);
if (t0.y>t2.y) std::swap(t0, t2);
if (t1.y>t2.y) std::swap(t1, t2);
int total_height = t2.y-t0.y; //最大高度。三角形从最y维度上最大的点到最低的点的差
//填充三角形下半部分
for (int y=t0.y; y<=t1.y; y++) {
int segment_height = t1.y-t0.y+1; //y维度上,t1到t2的高度差
float alpha = (float)(y-t0.y) / total_height;
float beta = (float)(y-t0.y) / segment_height;
Vec2i A = t0 + (t2-t0) * alpha;
Vec2i B = t0 + (t1-t0) * beta;
if (A.x>B.x) std::swap(A, B);
for (int j=A.x; j<=B.x; j++) {
image.set(j, y, color); // 填充
}
}
//填充三角形上半部分,方法同上
for (int y=t1.y; y<=t2.y; y++) {
int segment_height = t2.y - t1.y + 1;
float alpha = (float)(y-t0.y) / total_height;
float beta = (float)(y-t1.y) / segment_height;
Vec2i A = t0 + (t2-t0) * alpha;
Vec2i B = t1 + (t2-t1) * beta;
if (A.x>B.x) std::swap(A, B);
for (int j=A.x; j<=B.x; j++) {
image.set(j, y, color);
}
}
}
虽然以上方法离结果已经很近了,但想想这样绘制出的模型,终究是用单调的颜色填充出的,这跟我们平时看的模型相差甚远,如下,这明显着色不够充分,少了明暗,没有体积感
来回想一下,在图形学中我们学到了着色,那什么是着色呢?引入明暗和不同颜色的过程叫做着色,颜色有了我们还差光照,接下来我们来实现光照
在生活中,我们可以观察到当光线与平面垂直时,光照强度是最强的,也就是说光线与平面的夹角越大,光照越强,夹角越小,光照越弱。因此,我们用光线的负方向 * 法线方向 * cosθ来表示光照强度。对强度为负的情况,相当于夹角在[180,360],这种所求的平面在相机方向是看不到的,我们会忽略它,这也就是背面剔除
那么一个三角形面的法向量如何求呢?我们可以使用叉乘,将三角形两条边上的向量进行叉乘,会得到一个垂直两向量的向量。在下图也就是$$\vec{AC} × \vec{AB}$$。这时存在一个问题,若调换C和B,也就是$$\vec{AB} × \vec{AC}$$此时法向量与之前方向截然相反,对于这种情况我们应该如何处理呢?在模型的制作过程中规定好顶点的顺序,保证每个平面的法向量朝向模型外面
注:如何判断两个相同向量互换位置后叉乘的正负?对于左手坐标系来说,伸出左手,右手坐标系则伸出右手,除开大拇指,我们并拢其余四指,指向前一个向量的方向,然后四指向后一个向量的位置弯曲,,此时大拇指的朝向若是向上则是正,向下为负
Vec3f light_dir(0,0,-1); // 光源相对于物体的位置,光照的负方向
for (int i=0; i<model->nfaces(); i++) {
std::vector<int> face = model->face(i);
Vec2i screen_coords[3];
Vec3f world_coords[3];
for (int j=0; j<3; j++) {
Vec3f v = model->vert(face[j]);
screen_coords[j] = Vec2i((v.x+1.)*width/2., (v.y+1.)*height/2.);
world_coords[j] = v;
}
Vec3f n = (world_coords[2]-world_coords[0])^(world_coords[1]-world_coords[0]);
n.normalize();
float intensity = n*light_dir;
if (intensity>0) {
triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255));
}
}
结果如上图,是不是感觉有些违和感?没错,这个眼睛和嘴巴有些奇怪,原因是因为这两个地方都有一层内腔,我们渲染的时候也将内腔给渲染出来了,覆盖了本来显示的眼睛和嘴巴
zBuffer
之所以会出现内腔覆盖眼睛和嘴巴,是因为我们渲染的时候并未确立这两个顶点的前后顺序,从而导致的覆盖,因此我们需要想个办法来区分前后顺序
想想在我们生活中,我们画画如下图,我们会先画后面的山,再画前面的草地和树,那我们是不是可以将这种方法应用于此呢?答案是不行的,因为计算机无法分辨这些物体的前后关系,emm它只会按照顶点顺序来绘制
转换思路,既然每个按照上面的方法无法确定顺序,那我们是否可以确立屏幕范围内上每个像素位置上的点的深度值,根据这个深度值来确定我们面前能够看到的,也就是说,这样既能确立应该将哪个顶点绘制出来,又可以不关心绘制顺序
那么这个深度值如何求得呢?我们只知道顶点的z坐标,不知道三角形内部的啊
这个时候就需要用到重心坐标求插值的方法了
那么什么是重心坐标(我们只考虑三角形)呢?如下图,给定一个三角形ABC,该平面内任意一点都可以用三个顶点的线性组合进行表示:$$(x,y) = \alpha A + \beta B + \gamma C$$,其中$$α+β+γ = 1$$,我们将三个系数αβγ看作点A,B,C的权重,此时我们称这个点(α,β,γ)就是三角形的重心,这种坐标系又称重心坐标系。值得注意的是,当三个系数都为非负数时,这个点在三角形内部
利用面积来计算确定系数
利用坐标计算确定系数
在坐标系角度来求解。我们将重心坐标系中a点看作原点,$$\vec{ab}和\vec{ac}$$看作基向量,任意点p可以表示为$$p = a + \beta (b-a) + \gamma (c-a)$$,移项后可得$$p = (1-\beta - \gamma)a + \beta b + \gamma c$$,定义$$\alpha = 1 - \beta - \gamma$$,最终依旧可以得出$$ p = \alpha a + \beta b + \gamma c$$
看完这些,那么为什么重心坐标是三角形顶点属性的平滑过渡呢?因为这个三系数时线性变化,那么它们每经过一个位置都是均匀变化的
注意!重心坐标插值,投影前后重心坐标可能会发生变化,所以需要在对应的阶段计算重心坐标
那我们我们为什么需要插值呢?由于很多操作都是基于顶点完成,我们希望在三角形内完成平滑的过渡,插值的内容很多,如纹理坐标,颜色,法向量等等;用处很大,不仅在软件光栅化,而且在如雷贯耳的光线追踪中同样会使用
补充一点,重心坐标可以求一个点是否在三角形内部,但我们可以用另一种方法来快速判定。我们事先需要知道一个三角形三个点的顶点坐标,以及要求的点的坐标,分别计算$$\vec{P0P1} × \vec{P0Q},\vec{P1P2} × \vec{P1Q}, \vec{P2P0} × \vec{P2Q}$$,若三个值同为正或同为负,则在三角形内部,否则在外部。其原理也就是重心坐标
那么接下来的实现就十分简单了。
//判断点是否在三角形内
bool isInsideTriangle( Vec3f* v, int x, int y )
{
Vec2f side1 = { v[1].x - v[0].x, v[1].y - v[0].y };
Vec2f side2 = { v[2].x - v[1].x, v[2].y - v[1].y };
Vec2f side3 = { v[0].x - v[2].x, v[0].y - v[2].y };
Vec2f v1 = { x - v[0].x, y - v[0].y };
Vec2f v2 = { x - v[1].x, y - v[1].y };
Vec2f v3 = { x - v[2].x, y - v[2].y };
float z1 = side1.x * v1.y - v1.x * side1.y;
float z2 = side2.x * v2.y - v2.x * side2.y;
float z3 = side3.x * v3.y - v3.x * side3.y;
if( ( z1 > 0 && z2 > 0 && z3 > 0) || ( z1 < 0 && z2 < 0 && z3 < 0 ) ) return true;
return false;
}
//计算重心坐标
Vec3f computeBarycentric2D(float x, float y, Vec3f* v)
{
float c1 = (x * (v[1].y - v[2].y) + (v[2].x - v[1].x) * y + v[1].x * v[2].y - v[2].x*v[1].y) / (v[0].x * (v[1].y - v[2].y) + (v[2].x - v[1].x) * v[0].y + v[1].x * v[2].y - v[2].x * v[1].y );
float c2 = (x * (v[2].y - v[0].y) + (v[0].x - v[2].x) * y + v[2].x * v[0].y - v[0].x*v[2].y) / (v[1].x * (v[2].y - v[0].y) + (v[0].x - v[2].x) * v[1].y + v[2].x * v[0].y - v[0].x * v[2].y );
float c3 = (x * (v[0].y - v[1].y) + (v[1].x - v[0].x) * y + v[0].x * v[1].y - v[1].x*v[0].y) / (v[2].x * (v[0].y - v[1].y) + (v[1].x - v[0].x) * v[2].y + v[0].x * v[1].y - v[1].x * v[0].y );
return Vec3f(c1, c2, c3);
}
//绘制三角形
void drawTriangle( Vec3f* v, TGAImage& image, TGAColor color )
{
float minX = std::min( v[0].x, std::min( v[1].x, v[2].x ) );
float maxX = std::max( v[0].x, std::max( v[1].x, v[2].x ) );
float minY = std::min( v[0].y, std::min( v[1].y, v[2].y ) );
float maxY = std::max( v[0].y, std::max( v[1].y, v[2].y ) );
for( int i = minX; i <= maxX; ++i )
{
for( int j = minY; j <= maxY; ++j )
{
if( isInsideTriangle( v, i, j ) == true )
{
//重心坐标插值求z值
auto bc_screen = computeBarycentric2D( i, j, v );
float tempZ = v[0].z * bc_screen.x / 1.0f + v[1].z * bc_screen.y / 1.0f + v[2].z * bc_screen.z / 1.0f;
//更新z值
if( tempZ > zBuffer[j][i] )
{
zBuffer[j][i] = tempZ;
image.set( i, j, color );
}
}
}
}
}
//
int main(int argc, char** argv)
{
//...
for( int i = 0; i < model->nfaces(); ++i )
{
//取顶点索引值
std::vector<int> face = model->face(i);
Vec3f screen_coords[3]; //屏幕坐标
Vec3f world_coords[3]; //世界坐标
for( int j = 0; j < 3; ++j )
{
//取当前索引值对应的顶点信息
//这个顶点信息就是这个当前这个顶点在世界空间下的坐标
world_coords[j] = model->vert( face[j] );
//将取到的顶点变换到屏幕坐标
//obj文件中的顶点每个维度上取值范围都在[-1,1]间,我们需要将其变换至屏幕坐标[0,0]到[width,height]这个范围内
screen_coords[j] = { ( world_coords[j].x + 1 ) * width / 2 , ( world_coords[j].y + 1 ) * height / 2 , world_coords[j].z };
}
Vec3f n = (world_coords[2] - world_coords[0]) ^ ( world_coords[1] - world_coords[0]);
n.normalize();
float intensity = n * light_dir;
if( intensity > 0 ) drawTriangle( screen_coords, image, TGAColor( intensity * 255, intensity * 255, intensity * 255, 255 ) );
}
//...
}