Rendering 3d From Scratch Chapter 7 - The Depth Buffer

I’ve put together everything we’ve built so far in a demo below. It’s very simple. It renders a few rectangular prisms, and you can move the camera around.

3d scene, bruh
camera x
camera y
camera z
camera size

You might’ve noticed there’s a glaring issue. If you missed it, just rotate the camera around 360 degrees. This isn’t 3d at all! It’s just a bunch of polygons drawn in a seemingly random order! It’s trippy, but not what we’re going for.

So what is actually happening? To diagnose this, let’s think back about how an eye or camera works. Light comes into the eye after bouncing off of objects. But if some other object obscures a ray of light, that ray never reaches your eye, and therefore your eye never perceives it. This probably sounds silly, but this is why when you put some object behind another object, you no longer see it. In the example above, objects are simply rendering in whatever order they are executed. There’s nothing checking whether some “light ray” is obscured by an object in front of it.

Luckily, there’s a common solution to this problem known as a depth buffer. A depth buffer lives alongside your screen, and for every pixel you write to the screen, you also record the distance from your camera to the pixel in your depth buffer. Then, if you find yourself writing two pixels to the same spot on the screen, you simply compare the depths of the two pixels and throw out the one that’s further away.

We can accomplish all this in our code by modifying a few key places. First, we can update Pixel to store the distance from the camera:

function Pixel(x, y, distanceFromCamera) {
    this.x = x;
    this.y = y;
    this.distanceFromCamera = distanceFromCamera;
}

Now let’s make a few mods to our ScreenBuffer:

Add a depth buffer to the screen. Depths can be initialized to some max value:

var VERY_FAR_AWAY = 1000000.0;
this.depthBuffer = [];
for (var y = 0; y < this.height; y++) {
  var depthRow = [];
  for (var x = 0; x < this.width; x++) {
    depthRow.push(VERY_FAR_AWAY);
  }
  this.depthBuffer.push(depthRow);
}

When setting a pixel, check its depth against the current depth and only write the pixel if it’s closer:

ScreenBuffer.prototype.setPixel = function(pixel, rgb) {
  var idx = (Math.round(pixel.y) * this.width + Math.round(pixel.x)) * 4;
  if (this.depthBuffer[pixel.y][pixel.x] > pixel.distanceFromCamera) {
    this.depthBuffer[pixel.y][pixel.x] = pixel.distanceFromCamera;
    this.pixels[idx + 0] = rgb.r;
    this.pixels[idx + 1] = rgb.g;
    this.pixels[idx + 2] = rgb.b;
    this.pixels[idx + 3] = 255;
  }
};

When we clear the screen, also clear the depth buffer:

ScreenBuffer.prototype.clear = function() {
  this.ctx.clearRect(0, 0, this.width, this.height);
 
  this.buffer = this.ctx.getImageData(0, 0, this.width, this.height);
  this.pixels = this.buffer.data;
 
  for (var y = 0; y < this.height; y++) {
    for (var x = 0; x < this.width; x++) {
      this.depthBuffer[y][x] = 1000000.0;
    }
  }
};

Now we need to go back and actually set our depth when creating pixels. This requires a few steps. The first is to calculate the distance from our face vertices and the camera in our “draw” function. I’ll copy the whole draw method here and highlight the important sections with comments:

Renderer.prototype.draw = function(mesh, camera, rgb) {
  for (var i = 0; i < mesh.faces.length; i++) {
    var projectedFace = projectOnto(
      camera.position,
      mesh.faces[i],
      camera.plane
    );
    var minY = 100000;
    var maxY = -100000;
    var pixels = [];
    for (var j = 0; j < projectedFace.vertices.length; j++) {
      var v = projectedFace.vertices[j];
      // Calculate distance from camera
      var distFromCam = mesh.faces[i].vertices[j].distance(camera.position);
      var fromCorner = v.subtract(camera.viewCorners[0]);
      var x = fromCorner.dotProduct(camera.camRight) / camera.horizontalMag;
      var y = fromCorner.dotProduct(camera.camUp) / camera.verticalMag;
 
      var pixelX = Math.round(this.screenBuffer.width * x);
      var pixelY = Math.round(this.screenBuffer.height * y);
 
      if (pixelY < minY) {
        minY = pixelY;
      }
      if (pixelY > maxY) {
        maxY = pixelY;
      }
 
      // Initialize pixels with camera distances
      pixels.push(new Pixel(pixelX, pixelY, distFromCam));
    }
    this._fillScanlines(minY, maxY, pixels, rgb);
  }
};

Then, in our scanline filling algorithm, as we’re interpolating coordinates across faces, we also have to interpolate the depth. I’ll copy those methods as well, but there’s only a few necessary updates to call out:

Renderer.prototype._getXIntersections = function(y, faceVertices) {
  var xIntersections = [];
 
  var prev = faceVertices[faceVertices.length - 1];
  for (var i = 0; i < faceVertices.length; i++) {
    var faceVertex = faceVertices[i];
    if (faceVertex.y == y) {
      xIntersections.push(faceVertex);
    } else if (
      (prev.y < y && y < faceVertex.y) ||
      (faceVertex.y < y && y < prev.y)
    ) {
      var yDir = faceVertex.y - prev.y;
      var xDir = faceVertex.x - prev.x;
      // Calculate difference in distance
      var distDir = faceVertex.distanceFromCamera - prev.distanceFromCamera;
 
      var t = (y - prev.y) / yDir;
      var xIntersection = Math.round(prev.x + xDir * t);
      // Determine depth of pixel
      var depth = prev.distanceFromCamera + distDir * t;
      xIntersections.push(new Pixel(xIntersection, 0, depth));
    }
    prev = faceVertex;
  }
 
  xIntersections.sort(function(xi1, xi2) {
    return xi1.x - xi2.x;
  });
 
  return xIntersections;
};
 
Renderer.prototype._fillScanlines = function(minY, maxY, faceVertices, rgb) {
  for (
    var y = Math.max(0, minY);
    y <= Math.min(this.screenBuffer.height - 1, maxY);
    y++
  ) {
    var xIntersections = this._getXIntersections(y, faceVertices);
 
    for (var i = 1; i < xIntersections.length; i += 2) {
      var x1 = xIntersections[i - 1];
      var x2 = xIntersections[i];
 
      var xDiff = x2.x - x1.x;
      var distDiff = x2.distanceFromCamera - x1.distanceFromCamera;
 
      for (
        var x = Math.max(0, x1.x);
        x <= Math.min(x2.x, this.screenBuffer.width - 1);
        x++
      ) {
        var t = (x - x1.x) / xDiff;
        var dist = x1.distanceFromCamera + distDiff * t;
        this.screenBuffer.setPixel(new Pixel(x, y, dist), rgb);
      }
    }
  }
};

Once those changes have been slotted into place, our trippy faces should start behaving themselves. Feel free to try it yourself if you’re following along, but otherwise, we’ll put it all together in the next post.

We’ve now successfully drawn a 3d scene with simple primitives! This is a pretty huge step, but there’s a great deal more to learn. In the next chapter, I’ll finish up this toy example with a few niceties, and discuss potential next topics to take on.

Jon Bedard3d drawing