如何写一个软渲染(2)-Primitive

如何写一个软渲染(2)-Primitive

提要

图形学中Primitive通常指点、线、三角面。在光栅化的流水线里,所有的要呈现在屏幕上的东西都是由这三种Primitive组成。

点绘制在上一篇已经实现了,今天就说线和三角面的Rasterization。


Bresenham画线算法

假设我们需要由(x0, y0)这一点,绘画一直线至右下角的另一点(x1, y1),x,y分别代表其水平及垂直坐标,并且x1 - x0 > y1 - y0。在此我们使用电脑系统常用的坐标系,即x坐标值沿x轴向右增长,y坐标值沿y轴向下增长。

因此x及y之值分别向右及向下增加,而两点之水平距离为x1 - x0 且垂直距离为y1-y0。由此得之,该线的斜率必定介乎于1至0之间。而此算法之目的,就是找出在 x0 与x1之间,第x行相对应的第y列,从而得出一像素点,使得该像素点的位置最接近原本的线。

对于由(x0, y0)及(x1, y1)两点所组成之直线,公式如下:



因此,对于每一点的x,其y的值是



因为x及y皆为整数,但并非每一点x所对应的y皆为整数,故此没有必要去计算每一点x所对应之y值。反之由于此线之斜率介乎于1至0之间,故此我们只需要找出当x到达那一个数值时,会使y上升1,若x尚未到此值,则y不变。至于如何找出相关的x值,则需依靠斜率。斜率之计算方法为

。由于此值不变,故可于运算前预先计算,减少运算次数。

算法的具体实现如下

void Rasterizer::DrawOneLine(Line2d *line, Color c)
{
	int x1 = line->start.x;
	int y1 = line->start.y;
	int x2 = line->end.x;
	int y2 = line->end.y;

	int x, y, rem = 0;

	//line is a pixel
	if (x1 == x2 && y1 == y2) 
	{
		DrawPixel(x1, y1, c);
	}
	//vertical line
	else if (x1 == x2) 
	{
		int inc = (y1 <= y2) ? 1 : -1;
		for (y = y1; y != y2; y += inc) DrawPixel(x1, y, c);
		DrawPixel(x2, y2, c);
	}
	//horizontal line
	else if (y1 == y2) 
	{
		int inc = (x1 <= x2) ? 1 : -1;
		for (x = x1; x != x2; x += inc) DrawPixel(x, y1, c);
		DrawPixel(x2, y2, c);
	}
	else {
		int dx = (x1 < x2) ? x2 - x1 : x1 - x2;
		int dy = (y1 < y2) ? y2 - y1 : y1 - y2;

		// slope < 1
		if (dx >= dy) 
		{
			if (x2 < x1) x = x1, y = y1, x1 = x2, y1 = y2, x2 = x, y2 = y;
			for (x = x1, y = y1; x <= x2; x++) 
			{
				DrawPixel(x, y, c);
				rem += dy;
				if (rem >= dx) 
				{
					rem -= dx;
					y += (y2 >= y1) ? 1 : -1;
					//DrawPixel(x, y, c);
				}
			}
			DrawPixel(x2, y2, c);
		}
		// slope > 1
		else {
			if (y2 < y1) x = x1, y = y1, x1 = x2, y1 = y2, x2 = x, y2 = y;
			for (x = x1, y = y1; y <= y2; y++) 
			{
				DrawPixel(x, y, c);
				rem += dx;
				if (rem >= dy) 
				{
					rem -= dy;
					x += (x2 >= x1) ? 1 : -1;
					//DrawPixel(x, y, c);
				}
			}
			DrawPixel(x2, y2, c);
		}
	}
}

测试代码

//Test draw line
for (int i = 0; i < 100; i++)
{
    line->start.x = widowWidth * Random::Value();
    line->start.y = windowHeight * Random::Value();

    line->end.x = widowWidth * Random::Value();
    line->end.y = windowHeight * Random::Value();

    rasterizer.DrawOneLine(line, Color::RandomColor());
}


结果



Cohen–Sutherland线段裁剪算法

在绘制线段的时候,我们没法保证线段一定在窗口内部,这时候就需要对线段进行裁剪。裁剪的算法就是Cohen–Sutherland 算法。

原理简单描述:

首先对线段的两个端点按所在的区域进行分区编码,根据编码(bitmask)可以迅速地判明全部在窗口内的线段和全部在某边界外侧的线段。只有不属于这两种情况的线段,才需要求出线段与窗口边界的交点,求出交点后,舍去窗外部分。

  对剩余部分,把它作为新的线段看待,又从头开始考虑。两遍循环之后,就能确定该线段是部分截留下来,还是全部舍弃。


算法实现

void Rasterizer::CohenSutherlandLineClip(Line2d *line, Vector2 min, Vector2 max)
{
	int x0 = line->start.x;
	int x1 = line->end.x;

	int y0 = line->start.y;
	int y1 = line->end.y;

	int outcode0 = EnCode(line->start, min, max);
	int outcode1 = EnCode(line->end, min, max);
	bool accept = false;

	while (true) {
		// Bitwise OR is 0. Trivially accept and get out of loop. start and end all in center.
		if (!(outcode0 | outcode1))
		{
			accept = true;
			break;
		}
		else if (outcode0 & outcode1) { // Bitwise AND is not 0. Trivially reject and get out of loop
			break;
		}
		else {
			// failed both tests, so calculate the line segment to clip
			// from an outside point to an intersection with clip edge
			double x, y;

			// At least one endpoint is outside the clip rectangle; pick it.
			int outcodeOut = outcode0 ? outcode0 : outcode1;

			// Now find the intersection point;
			// use formulas y = y0 + slope * (x - x0), x = x0 + (1 / slope) * (y - y0)
			if (outcodeOut & LINE_TOP) {           // point is above the clip rectangle
				x = x0 + (x1 - x0) * (max.y - y0) / (y1 - y0);
				y = max.y;
			}
			else if (outcodeOut & LINE_BOTTOM) { // point is below the clip rectangle
				x = x0 + (x1 - x0) * (min.y - y0) / (y1 - y0);
				y = min.y;
			}
			else if (outcodeOut & LINE_RIGHT) {  // point is to the right of clip rectangle
				y = y0 + (y1 - y0) * (max.x - x0) / (x1 - x0);
				x = max.x;
			}
			else if (outcodeOut & LINE_LEFT) {   // point is to the left of clip rectangle
				y = y0 + (y1 - y0) * (min.x - x0) / (x1 - x0);
				x = min.x;
			}

			// Now we move outside point to intersection point to clip
			// and get ready for next pass.
			if (outcodeOut == outcode0) {
				x0 = x;
				y0 = y;
				outcode0 = EnCode(Vector2(x0, y0), min, max);
			}
			else {
				x1 = x;
				y1 = y;
				outcode1 = EnCode(Vector2(x1, y1), min, max);
			}
		}
	}
	if (accept) {
		line->start.x = x0;
		line->start.y = y0;
		line->end.x = x1;
		line->end.y = y1;
	}
}


int Rasterizer::EnCode(Vector2& pos, Vector2& min, Vector2& max)
{
	int code;
	code = LINE_INSIDE;          // initialised as being inside of clip window

	if (pos.x < min.x)           // to the left of clip window
		code |= LINE_LEFT;
	else if (pos.x > max.x)      // to the right of clip window
		code |= LINE_RIGHT;
	if (pos.y < min.y)           // below the clip window
		code |= LINE_BOTTOM;
	else if (pos.y > max.y)      // above the clip window
		code |= LINE_TOP;
	return code;
}

测试代码


//Test draw lines with clip
for (int i = 0; i < 100; i++)
{
    line->start.x =  widowWidth * Random::Range(-2.0f, 2.0f);
    line->start.y = windowHeight * Random::Range(-2.0f, 2.0f);

    line->end.x = widowWidth * Random::Range(-2.0f, 2.0f);
    line->end.y = windowHeight * Random::Range(-2.0f, 2.0f);

    rasterizer.DrawOneLine(line, Color::RandomColor());
}


结果



基于Scanline 算法的三角形填充

三角形的栅格化方法由很多种,首先从最old-school扫描线算法说起。




按照扫描线算法,扫描钱从上至下依次从左到右进行绘制,到达C点的时候,右边的斜率发生了改变,这时候不得不按照新的斜率再往下扫。

我们的目的是编写一个三角形填充算法,能够适用所有三角形

对于任意的一个三角形,都可以分为两类 - P2 在 P1 和 P3 的右侧 或 P2在 P1 和 P3 的左侧。




而这两类三角形通过在p2点水平方向的分割,可以分为让scanline算法更好处理的两种情况 flat bottom 三角形Step1和flat top 三角形 Step2.这两种三角形左边和右边的deltaY是一样的,这样只需要循环deltaY步然后把需要填充的地方填充好像素就可以了。

对于flat bottom三角形的填充,伪代码如下

fillBottomFlatTriangle(Vertice v1, Vertice v2, Vertice v3)
{
  float invslope1 = (v2.x - v1.x) / (v2.y - v1.y);
  float invslope2 = (v3.x - v1.x) / (v3.y - v1.y);

  float curx1 = v1.x;
  float curx2 = v1.x;

  for (int scanlineY = v1.y; scanlineY <= v2.y; scanlineY++)
  {
    drawLine((int)curx1, scanlineY, (int)curx2, scanlineY);
    curx1 += invslope1;
    curx2 += invslope2;
  }
}






对于flattop的三角形,就从下往上就可以了。

所以绘制一个三角形一共分三步 – 拆成两个三角形 -> 绘制上面的三角形->绘制下面的三角形。

在对三角形进行拆分的时候,需要求出第四个顶点的坐标值,如下图所示




实际上需要求的就是x4,应为y4和y2是一样的。根据相似三角形可以推断出



那么绘制一个三角形的伪代码如下

DrawTriangle()
{
  /* at first sort the three vertices by y-coordinate ascending so v1 is the topmost vertice */
  sortVerticesAscendingByY();

  /* here we know that v1.y <= v2.y <= v3.y */
  /* check for trivial case of bottom-flat triangle */
  if (v2.y == v3.y)
  {
    fillBottomFlatTriangle(v1, v2, v3);
  }
  /* check for trivial case of top-flat triangle */
  else if (vt1.y == vt2.y)
  {
    fillTopFlatTriangle(g, vt1, vt2, vt3);
  } 
  else
  {
    /* general case - split the triangle in a topflat and bottom-flat one */
    Vertice v4 = new Vertice( 
      (int)(vt1.x + ((float)(vt2.y - vt1.y) / (float)(vt3.y - vt1.y)) * (vt3.x - vt1.x)), vt2.y);
    fillBottomFlatTriangle(g, vt1, vt2, v4);
    fillTopFlatTriangle(g, vt2, v4, vt3);
  }
}



测试代码:

for (int i = 0; i < 10; i++)
{
	Vector2 v0(widowWidth * Random::Value(), windowHeight * Random::Value());
	Vector2 v1(widowWidth * Random::Value(), windowHeight * Random::Value());
	Vector2 v2(widowWidth * Random::Value(), windowHeight * Random::Value());
	Color col0 = Color::RandomColor();
	Color col1 = Color::RandomColor();
	Color col2 = Color::RandomColor();
	rasterizer.DrawTriangle2D(Vertex2D(v0, col0), Vertex2D(v1, col1), Vertex2D(v2, col2));
}


结果




基于重心坐标插值的三角形栅格化

现代的GPU基本都采用的这种方法,计算量虽然稍微大一些,但是好处是方便并行化。

具体的可以参考文章 Rasterization on Larrabee。


首先我们来了解一下重心坐标是啥,直接看三年前总结的一个关于插值的东西

重心座标插值(Barycentric Interpolation)blog.csdn.net图标

给一下重心计算的函数

Vector3 BarycentricFast(Vector4&  a, Vector4&  b, Vector4&  c, Vector4&  p, bool& isInLine)
{
    Vector4 v0 = b - a, v1 = c - a, v2 = p - a;

	float d00 = v0.x * v0.x + v0.y * v0.y;
    float d01 = v0.x * v1.x + v0.y * v1.y;
    float d11 = v1.x * v1.x + v1.y * v1.y;
    float d20 = v2.x * v0.x + v2.y * v0.y;
    float d21 = v2.x * v1.x + v2.y * v1.y;

    float denom = d00 * d11 - d01 * d01;
	//三角形变成了一条线
	if(Mathf::Abs(denom) <0.000001)
	{
		isInLine = true;
		return -1 * Vector3::one;
	}else
	{
		isInLine = false;
	}


    float v = (d11 * d20 - d01 * d21) / denom;
    float w = (d00 * d21 - d01 * d20) / denom;
    float u = 1.0f - v - w;

    return Vector3(u,v,w);
}


有了重心坐标之后就可以帮助我们办两件事1)判断某个点是否在三角形内 2)插值求该点的颜色。

看下光栅化的过程。

第一步要做的就是求出要绘制三角形的aabb

Vector2 bboxmin( Mathf::Infinity,  Mathf::Infinity);
Vector2 bboxmax(Mathf::NegativeInfinity, Mathf::NegativeInfinity);
Vector2 clamp(mRenderContext->width-1, mRenderContext->height-1);

bboxmin.x = Mathf::Max(0.f, Mathf::Min(bboxmin.x , pVSOutput0->position.x));
bboxmin.y = Mathf::Max(0.f, Mathf::Min(bboxmin.y , pVSOutput0->position.y));

bboxmax.x = Mathf::Min(clamp.x, Mathf::Max(bboxmax.x, pVSOutput0->position.x));
bboxmax.y = Mathf::Min(clamp.y, Mathf::Max(bboxmax.y, pVSOutput0->position.y));

bboxmin.x = Mathf::Max(0.f, Mathf::Min(bboxmin.x , pVSOutput1->position.x));
bboxmin.y = Mathf::Max(0.f, Mathf::Min(bboxmin.y , pVSOutput1->position.y));

bboxmax.x = Mathf::Min(clamp.x, Mathf::Max(bboxmax.x, pVSOutput1->position.x));
bboxmax.y = Mathf::Min(clamp.y, Mathf::Max(bboxmax.y, pVSOutput1->position.y));

bboxmin.x = Mathf::Max(0.f, Mathf::Min(bboxmin.x , pVSOutput2->position.x));
bboxmin.y = Mathf::Max(0.f, Mathf::Min(bboxmin.y , pVSOutput2->position.y));

bboxmax.x = Mathf::Min(clamp.x, Mathf::Max(bboxmax.x, pVSOutput2->position.x));
bboxmax.y = Mathf::Min(clamp.y, Mathf::Max(bboxmax.y, pVSOutput2->position.y));

然后就是遍历aabb中的像素,求重心坐标

bool isInline = false;
for (P.x=bboxmin.x; P.x<=bboxmax.x; P.x++) {
	for (P.y=bboxmin.y; P.y<=bboxmax.y; P.y++) 
	{
		Vector4 currentPoint(P.x, P.y, 0, 0);

		Vector3 barycentricCoord = BarycentricFast2(pVSOutput0->position,pVSOutput1->position, pVSOutput2->position, currentPoint, isInline);
		float fInvW =1.0f / (barycentricCoord.x * pVSOutput0->position.w+ barycentricCoord.y*pVSOutput1->position.w +barycentricCoord.z * pVSOutput2->position.w);
		Color col = barycentricCoord.x * pVSOutput0->color + barycentricCoord.y*pVSOutput1->color + barycentricCoord.z * pVSOutput2color;
		
		if(isInline)
		 	continue;
		
		float threshold = -0.000001;
		if (barycentricCoord.x<threshold || barycentricCoord.y<threshold || barycentricCoord.z<threshold ) continue;
		DrawPixel(P.x,  mRenderContext->height - P.y - 1, col);
	}
}




后面的光栅化三角形的算法就用这个。

画家算法

在绘制这些三角形的时候,有用到一个算法,称为画家算法。

“画家算法”表示头脑简单的画家首先绘制距离较远的场景,然后用绘制距离较近的场景覆盖较远的部分。画家算法首先将场景中的多边形根据深度进行排序,然后按照顺序进行描绘。这种方法通常会将不可见的部分覆盖,这样就可以解决可见性问题。

我们就默认最先绘制的在最里面,但是这种处理方式在遇到下面这种情况就无解了:



还有就是绘制了没有必要的像素,后面会实现深度测试来搞定处理这个问题。


总结

今天主要学习了点,线,三角形的绘制,难点一是工程的setup,这里推荐大家用自己最熟悉的方式来搭建自己的框架,可以是DX,可以sdl或者Qt,这也是一个很好的学习过程。第二个点是三角形绘制的算法。

有了点,线,三角形的绘制之后,后面我们就可以绘制更多有趣的东西了,这些将在后面的内容中继续深入学习。


参考

http://www.drdobbs.com/parallel/rasterization-on-larrabee/217200602www.drdobbs.com图标Rasterization on Larrabeewww.drdobbs.com图标Painter's algorithmen.wikipedia.org图标Midpoint circle algorithmen.wikipedia.org图标http://www.sunshine2k.de/coding/java/TriangleRasterization/TriangleRasterization.htmlwww.sunshine2k.de
Cohen-Sutherland algorithmen.wikipedia.org
Bresenham's line algorithmen.wikipedia.org图标

编辑于 2018-09-02

文章被以下专栏收录