April 19, 2019

3D Starfield - An Introduction To Perspective Projection

How It Works

A Starfield is an excellent introduction to programming 3D graphics. It deals with the most basic unit of 3D graphics, the vertex. Every star is simply a vertex. If you were to draw a line connecting 3 stars, you would then have a polygon, or face, the building block of 3D models. But how to make the stars move in 3D? We use a technique called perspective projection. If you google perspective projection, there is no shortage of poor explainations involving visualizing a pinhole camera. While it is accurate, it is not very helpful for visualizing 3D.


Perspective projection is the act of flattening a 3D scene onto a flat surface. In this case, we are taking a 3D scene of stars and flattening them onto the computer screen. How it works is best visualized by looking out a window at a building. Grab an erasable marker and draw dots on the window where you see the corners of the building while keeping your head very still. You end up with a series of dots that outline the shape of the building. Then connect the appropriate dots with lines to create the image of the building. You have just used perspective to project a 3D image onto a flat 2D surface. This is exactly how it works inside the computer. The 3D scene is represented mathematically inside the computer, and perspective equations are used to determine where to draw the dots on the screen.

The perspective projection equation is surprisingly simple. The equation gives the 2D projected coordinates x' and y' given a 3D coordinate of a vertex, (x, y, z), and a focal length, f. You can picture the focal length as the distance from your eyeball to the window in our earlier analogy. Having a shorter focal length will give you a wider field of view.



The Code

We begin by getting the canvas context and defining some of the parameters that we will be using in our starfield. Most of these are self-explainatory except xMax, yMax, and depth which define the size of the 3D box that the stars move about in. xMax and yMax are the width and height of the box, respectively. Depth is how far away from the screen the box goes, which we measure against the z axis. I highly recommend experimenting with these values to get a feel for how they affect the animation.





// Get canvas context
c = document.getElementById('canvas');
ctx = c.getContext('2d');

// Starfield parameters
height = canvas.height;
width = canvas.width;
depth = 140;
xMax = width * 25;
yMax = height * 25;
nStars = 200;
speed = 1.2;
focalLength = 2;


Next we need a function to generate our star objects. We want the star to be able to start a random location within the 3D box, update its position by moving toward the screen, and project its 3D coordinates onto 2D screen space.

function newStar() {
  var star = {x: (Math.random() * xMax) - xMax/2, // X and Y are adjusted so origin is in center instead
              y: (Math.random() * yMax) - yMax/2, // of being in the upper right-hand corner
              z: Math.random() * 100 + 40, // Makes sure new stars start a decent distance away from camera
              screenX: 0,
              screenY: 0,
              update: function() {
                  this.z -= speed; 
                  this.screenX = this.x / this.z * focalLength + width/2; // These 2 lines project the 3D coordinates onto 2D screen space
                  this.screenY = this.y / this.z * focalLength + height/2;
                }
              };
  return star;
}


First lets look at how the x and y coordinates are initialized. Math.random() * xMax generates a random number from 0 to xMax. Then we use - xMax/2 to allow the number to go negative. The reason for this is that we are conceptually shifting the center of our coordinate system from the top-left corner to the center of the screen. For some reason, the standard in computer graphics is to describe the screen using x and y coordinates in an upside-down coordinate system whose origin is in the very top-left of the screen. Unfortunately, our projection equations assume that the center of the coordinate system is our center of vision. Well our eyes aren't focused on the upper-left corner of the screen. We focus on the center. Thus, we need our coordinate system to be centered on the screen.

Next we have initialize z using Math.random() * 100 + 40. This allows the star to appear at a depth from 40 to 140. This ensures that a star doesn't appear too close to the screen which would ruin the illusion.

screenX and screenY store the screen-space coordinates. That is, the 2D coordinates resulting from the perspective projection.

The update() function is called every frame. It first moves the star by speed units. Then the perspective projection is performed on the 3D coordinates. Finally, the resulting projection, which is now in 2D screen-space, is shifted back into the screen's native coordinate system using +width/2 and +height/2


Now its time to initialize the stars. We create an array of stars and populate it. Notice that even though the star generates its own random z coordinate, we start off by assigning our own. Remember that the random z that the star generates is at a minimum of 40 units from the screen. If we relied on this, all of the stars would be far away when the animation started instead of being evenly dispersed across the screen. So we generate our own initial z without the minimum depth boundary.

// Init the stars
stars = [];

for (var i = 0; i < nStars; i++) {
  stars.push(newStar());
  stars[i].z = Math.random() * depth; // Randomize the initial Z so they properly fill the screen
}


Next we make a function to draw an individual star as a white rectangle using its screen-space coordinates. When the star gets close enough to the screen, we make the rectangle bigger (crappy effect but true to the original Windows screensaver).

function drawStar(star) {
  var x = star.screenX;
  var y = star.screenY;

  if (star.z < 35) // If star is close
    var size = 4; // Big star
  else
    var size = 2; // Little star

  ctx.fillStyle = "white";
  ctx.fillRect(x, y, size, size);
}


Finally, we make the general drawing function. It starts by painting the screen black, then looping through each star calling their individual update and drawing functions. After updating and drawing, it checks to see if the star has moved too close to the screen. If so, it resets it. The last step is to use setInterval() to tell the browser to call the draw() function 15 times a second.

function draw() {
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, width, height); // Clear the screen
  
  // Update and draw the stars
  for (var i = 0; i < nStars; i++) {
    stars[i].update();
    drawStar(stars[i]);

    if (stars[i].z <= 1) // Too close. Reset star.
      stars[i] = newStar();
  }
}

window.onload = function() {
    setInterval(draw, 1000/15 ); // 15 fps. Hope you have a good video card...
}