系列教程4: 光栅化和Z-Buffer
我们跳过教程3,因为教程3主要是教我们加载Blender导出的模型网格。我们暂时不需要去做这些工作。依然以cube为例足以。
到目前为止,我们的渲染函数可以进行简单的线框渲染。我们现在开始看看如何用光栅化算法来填充三角形。然后,我们将看到如何用Z-buffer来避免将模型背面的面绘制到前面。
光栅化
有太多不同类型的光栅化算法了。我们将在这个教程实现一个简单但是有效的光栅化算法。由于我们在CPU上运行我们的3D Soft Engine,我们必须对这部分有更多的关注。的确,它会消耗大量的CPU。而这重要的部分在今天都是由GPU来完成。
让我们开始一个练习。拿一张纸,开始画你能想到的所有三角形的类型。这个主意是为了找到一个通用的方法来绘制任意类型的三角形。
如果我们对三个顶点以y坐标的方式进行排序,即P1,P2,P3。我们最后只会有2种可能:
你会看到2种可能:P2在P1P3的右边 、 P2在P1P3的左边。在我们这个例子中,我们想要总是从左到右来绘制我们的线,从sx到ex。
此外,我们会通过移动P1.Y到P3.Y来绘制从左到右的线条。但是我们需要改变我们的逻辑当到达P2的时候,因为在这2种情况中,斜率都发生了改变。这就是为什么我们需要2步来完成绘制这个三角形。从上往下从P1.Y到P2.Y,然后从P2.Y到P3.Y,我们最终的目标。
如何完成我们的算法所需要理解的逻辑都在维基百科中: http://en.wikipedia.org/wiki/Slope
这真的只是一些基础的数学知识。
为了对case1和case2进行排序,你需要简单的计算一下斜率的倒数:
dP1P2 = P2.X - P1.X / P2.Y - P1.Y and dP1P3 = P3.X - P1.X / P3.Y - P1.Y
如果dP1P2 > dP1P3 则是第一个例子,即P2在右边。否则如果dP1P2 < dP1P3,则是第二个例子,即P2在左边。
现在我们有了算法的基本逻辑,我们需要知道如何计算出每个X在SX(StartX)和EX(End X)之间。因此我们首先需要计算出SX & EX。由于我们已知当前Y值以及P1P3和P1P2的斜率,我们能很容易的得到SX和EX。
让我们以第一个例子为例进行第一步。第一步是通过当前Y值计算出梯度(gradient)。它将告诉我们在第一步中正在P1.Y到P2.Y的哪个阶段。
Gradient = currentY - P1.Y / P2.Y - P1.Y
由于X和Y是线性相关的,我们可以通过梯度、P1.X、P3.X插值求出SX。 通过P1.X、P2.X插值求出EX。
如果你能理解这个插值的概念,你将能够理解剩下所有的教程包括处理光照和纹理。显然你必须花时间去阅读相关的代码。你还需要确定你能自己重新构建它而不是复制/粘贴代码。
现在我们已经描述了算法。我们开始编写代码。从删除Device类的DrawLine和DrawBLine函数开始,然后替换成如下代码:
// Project takes some 3D coordinates and transform them
// in 2D coordinates using the transformation matrixpublic Vector3 Project(Vector3 coord, Matrix transMat)
{
// transforming the coordinates
var point = Vector3.TransformCoordinate(coord, transMat);
// The transformed coordinates will be based on coordinate system
// starting on the center of the screen. But drawing on screen normally starts
// from top left. We then need to transform them again to have x:0, y:0 on top left.
var x = point.X * bmp.PixelWidth + bmp.PixelWidth / 2.0f;
var y = -point.Y * bmp.PixelHeight + bmp.PixelHeight / 2.0f;
return (new Vector3(x, y, point.Z));
}
// DrawPoint calls PutPixel but does the clipping operation beforepublic void DrawPoint(Vector2 point, Color4 color)
{
// Clipping what's visible on screen
if (point.X >= 0 && point.Y >= 0 && point.X < bmp.PixelWidth && point.Y < bmp.PixelHeight)
{
// Drawing a point
PutPixel((int)point.X, (int)point.Y, color);
}
}
我们已经准备好了第二个部分教程的材料。现在,是最重要的部分。基于之前的描述,这里是绘制三角形的逻辑。
// Clamping values to keep them between 0 and 1
float Clamp(float value, float min = 0, float max = 1)
{
return Math.Max(min, Math.Min(value, max));
}
// Interpolating the value between 2 vertices
// min is the starting point, max the ending point
// and gradient the % between the 2 points
float Interpolate(float min, float max, float gradient)
{
return min + (max - min) * Clamp(gradient);
}
// drawing line between 2 points from left to right
// papb -> pcpd
// pa, pb, pc, pd must then be sorted before
void ProcessScanLine(int y, Vector3 pa, Vector3 pb, Vector3 pc, Vector3 pd, Color4 color)
{
// Thanks to current Y, we can compute the gradient to compute others values like
// the starting X (sx) and ending X (ex) to draw between
// if pa.Y == pb.Y or pc.Y == pd.Y, gradient is forced to 1
var gradient1 = pa.Y != pb.Y ? (y - pa.Y) / (pb.Y - pa.Y) : 1;
var gradient2 = pc.Y != pd.Y ? (y - pc.Y) / (pd.Y - pc.Y) : 1;
int sx = (int)Interpolate(pa.X, pb.X, gradient1);
int ex = (int)Interpolate(pc.X, pd.X, gradient2);
// drawing a line from left (sx) to right (ex)
for (var x = sx; x < ex; x++)
{
DrawPoint(new Vector2(x, y), color);
}
}
public void DrawTriangle(Vector3 p1, Vector3 p2, Vector3 p3, Color4 color)
{
// Sorting the points in order to always have this order on screen p1, p2 & p3
// with p1 always up (thus having the Y the lowest possible to be near the top screen)
// then p2 between p1 & p3
if (p1.Y > p2.Y)
{
var temp = p2;
p2 = p1;
p1 = temp;
}
if (p2.Y > p3.Y)
{
var temp = p2;
p2 = p3;
p3 = temp;
}
if (p1.Y > p2.Y)
{
var temp = p2;
p2 = p1;
p1 = temp;
}
// inverse slopes
float dP1P2, dP1P3;
// http://en.wikipedia.org/wiki/Slope
// Computing inverse slopes
if (p2.Y - p1.Y > 0)
dP1P2 = (p2.X - p1.X) / (p2.Y - p1.Y);
else
dP1P2 = 0;
if (p3.Y - p1.Y > 0)
dP1P3 = (p3.X - p1.X) / (p3.Y - p1.Y);
else
dP1P3 = 0;
// First case where triangles are like that:
// P1
// -
// --
// - -
// - -
// - - P2
// - -
// - -
// -
// P3
if (dP1P2 > dP1P3)
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
if (y < p2.Y)
{
ProcessScanLine(y, p1, p3, p1, p2, color);
}
else
{
ProcessScanLine(y, p1, p3, p2, p3, color);
}
}
}
// First case where triangles are like that:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
if (y < p2.Y)
{
ProcessScanLine(y, p1, p2, p1, p3, color);
}
else
{
ProcessScanLine(y, p2, p3, p1, p3, color);
}
}
}
}
你可以看到在代码中我们如何来处理2种三角形。
最后,你需要更新渲染函数来调用DrawTriangle而不是调用3次drawLine / DrawBLine 。
var faceIndex = 0;foreach (var face in mesh.Faces)
{
var vertexA = mesh.Vertices[face.A];
var vertexB = mesh.Vertices[face.B];
var vertexC = mesh.Vertices[face.C];
var pixelA = Project(vertexA, transformMatrix);
var pixelB = Project(vertexB, transformMatrix);
var pixelC = Project(vertexC, transformMatrix);
var color = 0.25f + (faceIndex % mesh.Faces.Length) * 0.75f / mesh.Faces.Length;
DrawTriangle(pixelA, pixelB, pixelC, new Color4(color, color, color, 1));
faceIndex++;
}
此时,你可能会感觉到你能看穿Mesh。那是因为我们绘制三角形的时候还没有隐藏三角形的背面。
Z-Buffering或 如何利用depth-buffer
我们需要对当前像素在之前绘制时的Z值和当前的Z值进行比较和测试。如果当前像素的Z值小于之前的,我们覆盖并重新写入Z值。实际上这意味着我们正在绘制的面处于之前绘制面的前面。否则,如果当前像素位置的点的Z值大于之前的,我们会忽略它的绘制操作。
我们需要保存屏幕上每个像素点的Z值。因此,我们声明了一个float类型的数组,命名为depthBuffer。它的大小等于屏幕的像素数(width * height)。这个深度缓存在每个Clear()操作时必须初始化为一个非常大的默认z值。
在PutPixel函数中,我们需要测试当前像素点的Z值和depthBuffer中存放的Z值。此外,之前的逻辑是返回Vector2,。我们在此将改为Vector3来存放顶点的Z值,因为我们需要这个信息来保证绘制的正确性 。
最后,我们通过三角形的边插值得出X值,同样的,我们需要用同样的算法插值得出Z值。
private byte[] backBuffer;
private readonly float[] depthBuffer;
private WriteableBitmap bmp;
private readonly int renderWidth;
private readonly int renderHeight;
public Device(WriteableBitmap bmp)
{
this.bmp = bmp;
renderWidth = bmp.PixelWidth;
renderHeight = bmp.PixelHeight;
// the back buffer size is equal to the number of pixels to draw
// on screen (width*height) * 4 (R,G,B & Alpha values).
backBuffer = new byte[bmp.PixelWidth * bmp.PixelHeight * 4];
depthBuffer = new float[bmp.PixelWidth * bmp.PixelHeight];
}
// This method is called to clear the back buffer with a specific color
public void Clear(byte r, byte g, byte b, byte a) {
// Clearing Back Buffer
for (var index = 0; index < backBuffer.Length; index += 4)
{
// BGRA is used by Windows instead by RGBA in HTML5
backBuffer[index] = b;
backBuffer[index + 1] = g;
backBuffer[index + 2] = r;
backBuffer[index + 3] = a;
}
// Clearing Depth Buffer
for (var index = 0; index < depthBuffer.Length; index++)
{
depthBuffer[index] = float.MaxValue;
}
}
// Called to put a pixel on screen at a specific X,Y coordinates
public void PutPixel(int x, int y, float z, Color4 color)
{
// As we have a 1-D Array for our back buffer
// we need to know the equivalent cell in 1-D based
// on the 2D coordinates on screen
var index = (x + y * renderWidth);
var index4 = index * 4;
if (depthBuffer[index] < z)
{
return; // Discard
}
depthBuffer[index] = z;
backBuffer[index4] = (byte)(color.Blue * 255);
backBuffer[index4 + 1] = (byte)(color.Green * 255);
backBuffer[index4 + 2] = (byte)(color.Red * 255);
backBuffer[index4 + 3] = (byte)(color.Alpha * 255);
}
// Project takes some 3D coordinates and transform them
// in 2D coordinates using the transformation matrix
public Vector3 Project(Vector3 coord, Matrix transMat)
{
// transforming the coordinates
var point = Vector3.TransformCoordinate(coord, transMat);
// The transformed coordinates will be based on coordinate system
// starting on the center of the screen. But drawing on screen normally starts
// from top left. We then need to transform them again to have x:0, y:0 on top left.
var x = point.X * bmp.PixelWidth + bmp.PixelWidth / 2.0f;
var y = -point.Y * bmp.PixelHeight + bmp.PixelHeight / 2.0f;
return (new Vector3(x, y, point.Z));
}
// DrawPoint calls PutPixel but does the clipping operation beforepublic void DrawPoint(Vector3 point, Color4 color)
{
// Clipping what's visible on screen
if (point.X >= 0 && point.Y >= 0 && point.X < bmp.PixelWidth && point.Y < bmp.PixelHeight)
{
// Drawing a point
PutPixel((int)point.X, (int)point.Y, point.Z ,color);
}
}
// drawing line between 2 points from left to right
// papb -> pcpd
// pa, pb, pc, pd must then be sorted before
void ProcessScanLine(int y, Vector3 pa, Vector3 pb, Vector3 pc, Vector3 pd, Color4 color)
{
// Thanks to current Y, we can compute the gradient to compute others values like
// the starting X (sx) and ending X (ex) to draw between
// if pa.Y == pb.Y or pc.Y == pd.Y, gradient is forced to 1
var gradient1 = pa.Y != pb.Y ? (y - pa.Y) / (pb.Y - pa.Y) : 1;
var gradient2 = pc.Y != pd.Y ? (y - pc.Y) / (pd.Y - pc.Y) : 1;
int sx = (int)Interpolate(pa.X, pb.X, gradient1);
int ex = (int)Interpolate(pc.X, pd.X, gradient2);
// starting Z & ending Z
float z1 = Interpolate(pa.Z, pb.Z, gradient1);
float z2 = Interpolate(pc.Z, pd.Z, gradient2);
// drawing a line from left (sx) to right (ex)
for (var x = sx; x < ex; x++)
{
float gradient = (x - sx) / (float)(ex - sx);
var z = Interpolate(z1, z2, gradient);
DrawPoint(new Vector3(x, y, z), color);
}
}