Tutorial part 4: learning how to write a 3D software engine in C#, TS or JS – Rasterization & Z-Buffering

In the previous tutorial, learning how to write a 3D soft engine in C#, TS or JS – loading meshes exported from Blender, we’ve loaded a JSON file where our meshes were serialized from Blender. Up to now, our render function was drawing the meshes with only a simple wireframe rendering. We’re now going to see how to fill the triangles using a rasterization algorithm. Then, we’ll see how to handle a Z-Buffer to avoid having faces living in the back being drawn on top on front faces.

This tutorial is part of the following series:

1 – Writing the core logic for camera, mesh & device object
2 – Drawing lines and triangles to obtain a wireframe rendering
3 – Loading meshes exported from Blender in a JSON format
4 – Filling the triangle with rasterization and using a Z-Buffer (this article)
4b – Bonus: using tips & parallelism to boost the performance
5 – Handling light with Flat Shading & Gouraud Shading
6 – Applying textures, back-face culling and WebGL

By following this tutorial, you will be able to have such rendering:

Rasterization

There’s a lot of different types of rasterization algorithms. I even know someone in my team who has made his own patented rasterization algorithm for a well known GPU maker. It’s also thanks to him that I now know what Boustrophedon is and it has really changed my life since then. :-)

To be more serious, we’re going to implement in this tutorial a simple but efficient rasterization algorithm. As we’re running on CPU with our 3D software engine, we must pay a lot of attention to this part. Indeed, it will to cost us a lot of CPU. Today, of course, this heavy part is done directly by GPUs.

Let’s start by an exercise. Take a piece of paper and start drawing all the types of triangles you could think of. The idea is to find a generic way to draw any type of triangles.

If we’re sorting the three vertices of each triangle on the Y coordinates in order to always have P1 followed by P2 followed by P3, we will finally only have 2 possible cases:

image

You then see that we have 2 cases: P2 is on the right of P1P3 or P2 is on the left of P1P3. In our case, as we want to always draw our lines from left to right from sx to ex, we will have a first conditional IF to handle these 2 cases.

Moreover, we’re going to draw from left to right by moving down from P1.Y to P3.Y following the red line drawn on the left case of the figure. But we will need to change our logic reaching P2.Y as the slope will change in both cases. That’s why, we’ve got 2 steps in the scan line process. Moving down from P1.Y to P2.Y and then from P2.Y to P3.Y, our final destination.

All the logic needed to understand how to build our algorithm is described on Wikipedia: https://en.wikipedia.org/wiki/Slope . This is really some basic math.

To be able to sort the cases between case 1 and case 2, you simply need to compute the inverse slopes in this way:

dP1P2 = P2.X - P1.X / P2.Y - P1.Y and dP1P3 = P3.X - P1.X / P3.Y - P1.Y

If dP1P2 > dP1P3 then we are in the first case with P2 on the right, otherwise if dP1P2 > dP1P2, we are in the second case with P2 on the left.

Now that we have the basic logic of our algorithm, we need to know how to compute X on each line between SX (Start X) and EX (End X) on my figure. So we need to compute SX & EX first. As we know the Y value and the slope P1P3 & P1P2, we can easily find SX & EX we’re interested in.

Let’s take the step 1 of the case 1 as an example. First step is to compute our gradient with the current Y value in our loop. It will tell us at which stage we are in the scan line processing between P1.Y and P2.Y in Step 1.

gradient = currentY - P1.Y / P2.Y - P1.Y

As X and Y are linearly linked, we can interpolate SX based on this gradient using P1.X and P3.X & interpolate EX using P1.X and P2.X.

If you manage to understand this concept of interpolation, you will be able to understand all the remaining tutorials to handle light & texture. You then definitely need to spend time on reading the associated code. You need also to be sure you’d be able to rebuild it from scratch yourself without copy/pasting the code below.

If it’s still not clear enough, here are other interesting articles to read addressing also rasterization:

- 3D Software Rendering Engine - Part I
- Triangle Rasterization
- Software Rasterization Algorithms for filling triangles

Now that we have our algorithm described. Let’s now work on the code. Start by removing the drawLine and drawBline from the device class. Then, replace your existing functions/methods by those one:

 // 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 before
public 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);
    }
}

 

 // Project takes some 3D coordinates and transform them
// in 2D coordinates using the transformation matrix
public project(coord: BABYLON.Vector3, transMat: BABYLON.Matrix): BABYLON.Vector3 {
    // transforming the coordinates
    var point = BABYLON.Vector3.TransformCoordinates(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 * this.workingWidth + this.workingWidth / 2.0;
    var y = -point.y * this.workingHeight + this.workingHeight / 2.0;
    return (new BABYLON.Vector3(x, y, point.z));
}

// drawPoint calls putPixel but does the clipping operation before
public drawPoint(point: BABYLON.Vector2, color: BABYLON.Color4): void {
    // Clipping what's visible on screen
    if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth && point.y < this.workingHeight) {
        // Drawing a yellow point
        this.putPixel(point.x, point.y, color);
    }
}

 

 // Project takes some 3D coordinates and transform them
// in 2D coordinates using the transformation matrix
Device.prototype.project = function (coord, transMat) {
    var point = BABYLON.Vector3.TransformCoordinates(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 * this.workingWidth + this.workingWidth / 2.0 >> 0;
    var y = -point.y * this.workingHeight + this.workingHeight / 2.0 >> 0;
    return (new BABYLON.Vector3(x, y, point.z));
};

// drawPoint calls putPixel but does the clipping operation before
Device.prototype.drawPoint = function (point, color) {
    // Clipping what's visible on screen
    if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth
                                        && point.y < this.workingHeight) {
        // Drawing a yellow point
        this.putPixel(point.x, point.y, color);
    }
};

 

We’re just preparing some stuff for the second part of this tutorial. Now, here is the most important part. Here is the logic that going to draw the triangles based on the previous explanations.

 // 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;

    // https://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);
            }
        }
    }
}

 

 // Clamping values to keep them between 0 and 1
public clamp(value: number, min: number = 0, max: number = 1): number {
    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
public interpolate(min: number, max: number, gradient: number) {
    return min + (max - min) * this.clamp(gradient);
}

// drawing line between 2 points from left to right
// papb -> pcpd
// pa, pb, pc, pd must then be sorted before
public processScanLine(y: number, pa: BABYLON.Vector3, pb: BABYLON.Vector3, 
                       pc: BABYLON.Vector3, pd: BABYLON.Vector3, color: BABYLON.Color4): void {
    // 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;

    var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
    var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;

    // drawing a line from left (sx) to right (ex) 
    for (var x = sx; x < ex; x++) {
        this.drawPoint(new BABYLON.Vector2(x, y), color);
    }
}

public drawTriangle(p1: BABYLON.Vector3, p2: BABYLON.Vector3, 
                    p3: BABYLON.Vector3, color: BABYLON.Color4): void {
    // 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
    var dP1P2: number; var dP1P3: number;

    // https://en.wikipedia.org/wiki/Slope
    // Computing 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 = p1.y >> 0; y <= p3.y >> 0; y++)
        {
            if (y < p2.y) {
                this.processScanLine(y, p1, p3, p1, p2, color);
            }
            else {
                this.processScanLine(y, p1, p3, p2, p3, color);
            }
        }
    }
    // First case where triangles are like that:
    //       P1
    //        -
    //       -- 
    //      - -
    //     -  -
    // P2 -   - 
    //     -  -
    //      - -
    //        -
    //       P3
    else {
        for (var y = p1.y >> 0; y <= p3.y >> 0; y++)
        {
            if (y < p2.y) {
                this.processScanLine(y, p1, p2, p1, p3, color);
            }
            else {
                this.processScanLine(y, p2, p3, p1, p3, color);
            }
        }
    }
}

 

 // Clamping values to keep them between 0 and 1
Device.prototype.clamp = function (value, min, max) {
    if (typeof min === "undefined") { min = 0; }
    if (typeof max === "undefined") { 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
Device.prototype.interpolate = function (min, max, gradient) {
    return min + (max - min) * this.clamp(gradient);
};

// drawing line between 2 points from left to right
// papb -> pcpd
// pa, pb, pc, pd must then be sorted before
Device.prototype.processScanLine = function (y, pa, pb, pc, pd, 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;

    var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
    var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;

    // drawing a line from left (sx) to right (ex) 
    for(var x = sx; x < ex; x++) {
        this.drawPoint(new BABYLON.Vector2(x, y), color);
    }
};

Device.prototype.drawTriangle = function (p1, p2, p3, 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
    var dP1P2; var dP1P3;

    // https://en.wikipedia.org/wiki/Slope
    // Computing 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 = p1.y >> 0; y <= p3.y >> 0; y++) {
            if(y < p2.y) {
                this.processScanLine(y, p1, p3, p1, p2, color);
            } else {
                this.processScanLine(y, p1, p3, p2, p3, color);
            }
        }
    }
    // First case where triangles are like that:
    //       P1
    //        -
    //       -- 
    //      - -
    //     -  -
    // P2 -   - 
    //     -  -
    //      - -
    //        -
    //       P3
    else {
        for(var y = p1.y >> 0; y <= p3.y >> 0; y++) {
            if(y < p2.y) {
                this.processScanLine(y, p1, p2, p1, p3, color);
            } else {
                this.processScanLine(y, p2, p3, p1, p3, color);
            }
        }
    }
};

 

You see in the code how we’re handling the 2 types of triangles to fill as well as the 2 steps in the scan line process.

Finally, you need to update the render function to call drawTriangle instead of the 3 calls to drawLine/drawBline. We’re also using a level of grey to draw each triangle. Otherwise, if we draw every of them with the same color, we wouldn’t be able to really see what’s going on. We’ll see in the next tutorial how to handle a light in a proper way.

 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++;
}

 

 for (var indexFaces = 0; indexFaces < cMesh.Faces.length; indexFaces++) {
    var currentFace = cMesh.Faces[indexFaces];
    var vertexA = cMesh.Vertices[currentFace.A];
    var vertexB = cMesh.Vertices[currentFace.B];
    var vertexC = cMesh.Vertices[currentFace.C];

    var pixelA = this.project(vertexA, transformMatrix);
    var pixelB = this.project(vertexB, transformMatrix);
    var pixelC = this.project(vertexC, transformMatrix);

    var color: number = 0.25 + ((indexFaces % cMesh.Faces.length) / cMesh.Faces.length) * 0.75;
    this.drawTriangle(pixelA, pixelB, pixelC, new BABYLON.Color4(color, color, color, 1));
}

 

 for (var indexFaces = 0; indexFaces < cMesh.Faces.length; indexFaces++) {
    var currentFace = cMesh.Faces[indexFaces];
    var vertexA = cMesh.Vertices[currentFace.A];
    var vertexB = cMesh.Vertices[currentFace.B];
    var vertexC = cMesh.Vertices[currentFace.C];

    var pixelA = this.project(vertexA, transformMatrix);
    var pixelB = this.project(vertexB, transformMatrix);
    var pixelC = this.project(vertexC, transformMatrix);

    var color = 0.25 + ((indexFaces % cMesh.Faces.length) / cMesh.Faces.length) * 0.75;
    this.drawTriangle(pixelA, pixelB, pixelC, new BABYLON.Color4(color, color, color, 1));
}

 

And you should have this first result:

What’s going wrong there? You’ve probably got the feeling that you can watch through the mesh. This is because we’re drawing all triangles without “hidding” the triangles living in the back.

Z-Buffering or how to use a depth Buffer

We then need to test the Z value of the current pixel and compare it to a buffer before drawing it. If the Z of the current pixel to draw is lower than the previous pixel that was drawn here, we can override it. Indeed, this would mean that the current face we’re drawing is in front of a previously drawn face. However, if the Z of the current pixel to draw is greater than the previous pixel drawn here, we can discard the draw operation.

We then need to keep an history of these Z indexes per pixel on screen. To do that, declare a new array of float, named it depthBuffer. Its size will be equal to the number of pixels on screen (width * height). This depth buffer must be initialized during each clear() operation with a very high default Z value.

In the putPixel function/method, we just need to test the Z index of the pixel against the one that was stored in the depth buffer. Moreover, part of our previous logic was returning Vector2 to logically draw on screen. We’re going to change it to Vector3 to push the Z values of the vertices as we now need this information to be able to draw faces correctly.

Finally, in the same way we were interpolating X value between each side of the triangles, we need to interpolate also Z values using the very same algorithm for each pixel.

In conclusion, here is the code you need to update in your Device object:

 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 before
public 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);
    }
}

 

 // the back buffer size is equal to the number of pixels to draw
// on screen (width*height) * 4 (R,G,B & Alpha values). 
private backbuffer: ImageData;
private workingCanvas: HTMLCanvasElement;
private workingContext: CanvasRenderingContext2D;
private workingWidth: number;
private workingHeight: number;
// equals to backbuffer.data
private backbufferdata;
private depthbuffer: number[];

constructor(canvas: HTMLCanvasElement) {
    this.workingCanvas = canvas;
    this.workingWidth = canvas.width;
    this.workingHeight = canvas.height;
    this.workingContext = this.workingCanvas.getContext("2d");
    this.depthbuffer = new Array(this.workingWidth * this.workingHeight);
}

// This function is called to clear the back buffer with a specific color
public clear(): void {
    // Clearing with black color by default
    this.workingContext.clearRect(0, 0, this.workingWidth, this.workingHeight);
    // once cleared with black pixels, we're getting back the associated image data to 
    // clear out back buffer
    this.backbuffer = this.workingContext.getImageData(0, 0, this.workingWidth, this.workingHeight);

    // Clearing depth buffer
    for (var i = 0; i < this.depthbuffer.length; i++) {
        // Max possible value 
        this.depthbuffer[i] = 10000000;
    }
}

// Called to put a pixel on screen at a specific X,Y coordinates
public putPixel(x: number, y: number, z: number, color: BABYLON.Color4): void {
    this.backbufferdata = this.backbuffer.data;
    // As we have a 1-D Array for our back buffer
    // we need to know the equivalent cell index in 1-D based
    // on the 2D coordinates of the screen
    var index: number = ((x >> 0) + (y >> 0) * this.workingWidth);
    var index4: number = index * 4;

    if (this.depthbuffer[index] < z) {
        return; // Discard
    }

    this.depthbuffer[index] = z;

    // RGBA color space is used by the HTML5 canvas 
    this.backbufferdata[index4] = color.r * 255;
    this.backbufferdata[index4 + 1] = color.g * 255;
    this.backbufferdata[index4 + 2] = color.b * 255;
    this.backbufferdata[index4 + 3] = color.a * 255;
}

// Project takes some 3D coordinates and transform them
// in 2D coordinates using the transformation matrix
public project(coord: BABYLON.Vector3, transMat: BABYLON.Matrix): BABYLON.Vector3 {
    // transforming the coordinates
    var point = BABYLON.Vector3.TransformCoordinates(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 * this.workingWidth + this.workingWidth / 2.0;
    var y = -point.y * this.workingHeight + this.workingHeight / 2.0;
    return (new BABYLON.Vector3(x, y, point.z));
}

// drawPoint calls putPixel but does the clipping operation before
public drawPoint(point: BABYLON.Vector3, color: BABYLON.Color4): void {
    // Clipping what's visible on screen
    if (point.x >= 0 && point.y >= 0 && point.x < this.workingWidth && point.y < this.workingHeight) {
        // Drawing a yellow point
        this.putPixel(point.x, 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
public processScanLine(y: number, pa: BABYLON.Vector3, pb: BABYLON.Vector3, pc: BABYLON.Vector3, pd: BABYLON.Vector3, color: BABYLON.Color4): void {
    // 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;

    var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
    var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;

    // starting Z & ending Z
    var z1: number = this.interpolate(pa.z, pb.z, gradient1);
    var z2: number = this.interpolate(pc.z, pd.z, gradient2);

    // drawing a line from left (sx) to right (ex) 
    for (var x = sx; x < ex; x++) {
        var gradient: number = (x - sx) / (ex - sx); // normalisation pour dessiner de gauche à droite

        var z = this.interpolate(z1, z2, gradient);

        this.drawPoint(new BABYLON.Vector3(x, y, z), color);
    }
}

 

 function Device(canvas) {
    this.workingCanvas = canvas;
    this.workingWidth = canvas.width;
    this.workingHeight = canvas.height;
    this.workingContext = this.workingCanvas.getContext("2d");
    this.depthbuffer = new Array(this.workingWidth * this.workingHeight);
}

// This function is called to clear the back buffer with a specific color
Device.prototype.clear = function () {
    // Clearing with black color by default
    this.workingContext.clearRect(0, 0, this.workingWidth, this.workingHeight);
    // once cleared with black pixels, we're getting back the associated image data to 
    // clear out back buffer
    this.backbuffer = this.workingContext.getImageData(0, 0, this.workingWidth, this.workingHeight);

    // Clearing depth buffer
    for (var i = 0; i < this.depthbuffer.length; i++) {
        // Max possible value 
        this.depthbuffer[i] = 10000000;
    }
};

// Called to put a pixel on screen at a specific X,Y coordinates
Device.prototype.putPixel = function (x, y, z, color) {
    this.backbufferdata = this.backbuffer.data;
    // As we have a 1-D Array for our back buffer
    // we need to know the equivalent cell index in 1-D based
    // on the 2D coordinates of the screen
    var index = ((x >> 0) + (y >> 0) * this.workingWidth);
    var index4 = index * 4;

    if(this.depthbuffer[index] < z) {
        return; // Discard
    }

    this.depthbuffer[index] = z;

    // RGBA color space is used by the HTML5 canvas 
    this.backbufferdata[index4] = color.r * 255;
    this.backbufferdata[index4 + 1] = color.g * 255;
    this.backbufferdata[index4 + 2] = color.b * 255;
    this.backbufferdata[index4 + 3] = color.a * 255;
};

// Project takes some 3D coordinates and transform them
// in 2D coordinates using the transformation matrix
Device.prototype.project = function (coord, transMat) {
    // transforming the coordinates
    var point = BABYLON.Vector3.TransformCoordinates(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 * this.workingWidth + this.workingWidth / 2.0;
    var y = -point.y * this.workingHeight + this.workingHeight / 2.0;
    return (new BABYLON.Vector3(x, y, point.z));
};

// drawPoint calls putPixel but does the clipping operation before
Device.prototype.drawPoint = function (point, color) {
    // Clipping what's visible on screen
    if(point.x >= 0 && point.y >= 0 && point.x < this.workingWidth && point.y < this.workingHeight) {
        // Drawing a point
        this.putPixel(point.x, 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
Device.prototype.processScanLine = function (y, pa, pb, pc, pd, 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;

    var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
    var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;

    // starting Z & ending Z
    var z1 = this.interpolate(pa.z, pb.z, gradient1);
    var z2 = this.interpolate(pc.z, pd.z, gradient2);

    // drawing a line from left (sx) to right (ex) 
    for(var x = sx; x < ex; x++) {
        var gradient = (x - sx) / (ex - sx);
        var z = this.interpolate(z1, z2, gradient);
        this.drawPoint(new BABYLON.Vector3(x, y, z), color);
    }
};

 

Using this new code, you should obtain the same kind of rendering as the iframe embedded at the very top of this article.

As usual, you can download the solutions containing the source code:

- C# : SoftEngineCSharpPart4.zip

- TypeScript : SoftEngineTSPart4.zip

- JavaScript : SoftEngineJSPart4.zip or simply right-click –> view source on the first embedded iframe

Next time, in the fifth tutorial, we’ll see how to simulate lighting thanks to the Gouraud Shading and we will obtain this kind of rendering:

image

But before that, you should have a look to the bonus tutorial: learning how to write a 3D software engine in C#, TS or JS – Optimizing & Parallelism explaining how to boost the current algorithm thanks to Parallel.For in C# and why we can’t have the same optimization in JavaScript.

Follow the author @davrous