Write a 3D Soft Engine from Scratch: Part 4

Share this article

In the previous tutorial, Part 3, 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.

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 “hiding” 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

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, I have an extra bonus tutorial for you on 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. Look out for that tomorrow.

Originally published: https://blogs.msdn.com/b/davrous/archive/2013/06/21/tutorial-part-4-learning-how-to-write-a-3d-software-engine-in-c-ts-or-js-rasterization-amp-z-buffering.aspx. Reprinted here with permission of the author.

Frequently Asked Questions about Writing a 3D Software Engine from Scratch

What are the basic concepts I need to understand before starting to write a 3D software engine from scratch?

Before you start writing a 3D software engine from scratch, you need to understand some fundamental concepts. These include understanding the basics of 3D graphics, such as vertices, polygons, and textures. You also need to understand how to manipulate these elements in 3D space, which involves knowledge of mathematics, particularly geometry and linear algebra. Additionally, you should be familiar with a programming language such as C++ or Java, as well as with the principles of object-oriented programming.

How does rasterization work in 3D rendering?

Rasterization is a process used in 3D rendering to convert geometric data into a raster image, which is a grid of pixels. The process involves determining which pixels in the image correspond to a particular geometric object. This is done by transforming the object’s vertices into screen space, then filling in the pixels that lie within the object’s boundaries. The color of each pixel is determined by applying a shader, which takes into account factors such as the object’s material properties and the lighting conditions.

What are the steps to write my own software rasterizer?

Writing your own software rasterizer involves several steps. First, you need to set up a frame buffer, which is an array of pixels that will hold the final image. Next, you need to implement a function to draw individual pixels to the frame buffer. Then, you need to implement functions to draw geometric primitives such as lines and triangles. Finally, you need to implement the rasterization process itself, which involves transforming 3D objects into screen space, determining which pixels they cover, and coloring those pixels appropriately.

How can I create a 3D game engine using Scratch?

Scratch is a visual programming language that is primarily designed for creating 2D animations and games. However, it is possible to create a simple 3D game engine in Scratch by using a technique called raycasting. This involves casting rays from the camera into the 3D scene and determining what they hit. By drawing vertical lines on the screen with heights proportional to the distances of the hit objects, you can create a 3D effect.

What are some resources for learning more about 3D graphics programming?

There are many resources available for learning about 3D graphics programming. These include online tutorials, video courses, and textbooks. Some recommended resources include the book “Real-Time Rendering” by Tomas Akenine-Möller, Eric Haines, and Naty Hoffman; the website Scratchapixel, which offers detailed lessons on various topics in 3D graphics; and the YouTube channel TheBennyBox, which has a series of videos on creating a 3D game engine from scratch.

How does triangle rasterization work in 3D graphics?

Triangle rasterization is a process used in 3D graphics to determine which pixels in a raster image correspond to a particular triangle. This involves transforming the triangle’s vertices into screen space, then iterating over the pixels in the bounding box of the transformed triangle. For each pixel, a test is performed to determine whether it lies within the triangle. If it does, the pixel is colored according to the triangle’s properties.

What are the challenges in writing a 3D software engine from scratch?

Writing a 3D software engine from scratch can be a challenging task. It requires a deep understanding of 3D graphics concepts, as well as strong programming skills. Some of the challenges include handling complex geometric calculations, implementing efficient algorithms for rendering, and dealing with issues related to performance and memory management. Additionally, creating realistic graphics requires knowledge of advanced topics such as shading, lighting, and texture mapping.

What is the role of a shader in a 3D software engine?

A shader is a program that is used in a 3D software engine to determine the color of pixels in the final rendered image. Shaders take into account various factors such as the properties of the objects being rendered, the lighting conditions, and the viewpoint of the camera. They can be used to create a wide range of effects, from simple flat shading to complex effects like reflections, shadows, and transparency.

How can I optimize the performance of my 3D software engine?

There are several strategies you can use to optimize the performance of your 3D software engine. These include using efficient algorithms for rendering, minimizing the amount of geometric data that needs to be processed, and optimizing the use of memory. Additionally, you can use techniques such as culling and level of detail to reduce the complexity of the scene being rendered.

What are some common mistakes to avoid when writing a 3D software engine from scratch?

Some common mistakes to avoid when writing a 3D software engine from scratch include neglecting to plan your architecture in advance, trying to implement too many features at once, and ignoring performance considerations. It’s also important to thoroughly test your engine and fix any bugs that arise. Finally, don’t forget to document your code and keep it organized, as this will make it easier to maintain and improve your engine in the future.

David RoussetDavid Rousset
View Author

David Rousset is a Senior Program Manager at Microsoft, in charge of driving adoption of HTML5 standards. He has been a speaker at several famous web conferences such as Paris Web, CodeMotion, ReasonsTo or jQuery UK. He’s the co-author of the WebGL Babylon.js open-source engine. Read his blog on MSDN or follow him on Twitter.

HTML5 Dev Center
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week