Introduction

I love doing 3d stuff. Especially if I can do it without using any sort of libraries or WebGL. The math behind it is very simple.

One of the most pleasing effects to the eye is Parallax scrolling. Basically, Parallax scrolling is the effect, where things in the foreground move slower than the ones in the background, creating a 3d like effect. It's been around since ages, but it didn't change very much since then.

The implementation is usually the same, you have multiple layers, each of them having something drawn at it, and you move the furthest layer slower than the closest one. This works well, until you want to add stuff inbetween layers. You can just add another layers, but then you would need to calibrate how fast each moves again, which can be frustrating after some time.

In this tutorial I want to cover how you can create a star field with flexible parallax scrolling by imitating 3d.

Creating the star field

Initialization

First we initialize the canvas and the functions we will be using.

HTML:

  <canvas id="canvas"></canvas>

CSS:

  canvas {
  position: absolute;
  top: 0;
  left: 0;
}

JavaScript:

  var c = document.getElementById("canvas");
c.width = innerWidth;     // This 2 line sets the canvas so it takes up
c.height = innerHeight;   // the full page
var ctx = c.getContext("2d");

function loop() {
  update();
  render();
  requestAnimationFrame(loop);
}

// Here I use the so-called update-method design pattern
// More about this pattern can be found here: http://gameprogrammingpatterns.com/update-method.html
function update() {
  // Updater code
}

function render() {
  // Renderer code
}

loop();

Smoothening the animation

I love 2d canvas, but I must admit, it has it's limitations. It's not terribly fast. Thus small animations like this too can run at a low framerate on some machines (like phones). If we don't sort this out, we get the same effect which happened to the original Sonic Game.

Fortunately, there's a really easy solution to this: Instead of working with per-frame speeds, we need to use per-second ones, and scale them down based on how many milliseconds has elapsed between the 2 drawing frames:

  var lastFrame = Date.now(); // This will store the last frame's timestamp

// ...

function loop() {
  // In an ideal world this value would be 0.0166666666.... second, because
  // 1 / 60 = 0.0166666666
  update((Date.now() - lastFrame) / 1000); 
  render();
  requestAnimationFrame(loop);
}

function update(time) {
  // ...
}

Then when we need to update something's position later, we will multiply it's speed/second with the time variable.

Generating the stars

Our star will be a basic JavaScript object (just for simplicity). It will have 3 properties:

  • x: The x position of the star
  • y: The y position of the star
  • depth: The layer the star is in (can be any number, both integer and float)

So, let's create an array of randomly placed stars:

  var stars = [];
var starCount = 50;  // The amount of stars on the screen
var starSize = 10;   // The default size of the star
var maxDepth = 50;   // The maximum depth of a star
var minDepth = 15;   // The minimum depth of a star

for (var i = 0; i < starCount; i++) {
  stars.push({
    x: (Math.random() - 0.5) * c.width * 2,
    y: (Math.random() - 0.5) * c.height * 2,
    depth: Math.random() * (maxDepth - minDepth) + minDepth
  });
}


Rendering the stars

We will be using a simple ctx.fillRectangle(...) to draw the stars, because 1.) It's faster and 2.) It simply looks cool.

Now the hard part. How do we convert a position with a depth to 2 dimension? We could use projection matrices, write a function to multiply vectors and matrices together...

...or we can just divide with depth / maxDepth. Because that apparently works (and also what projection matrices are based on).

  function render() {
  ctx.clearRect(0, 0, c.width, c.height);
  ctx.fillStyle = "white";
  for (var i = 0; i < starCount; i++) {
    var star = stars[i];
    var proj = star.depth / maxDepth;
    var pos = {
      x: star.x / proj,
      y: star.y / proj
    }
    var size = starSize / proj; // This'll be the size of the projected star
    ctx.fillRect(pos.x - size / 2, pos.y - size / 2, size, size);
  }
}

If you take the current code and start it, you'll see that it works now, but it's not really an ... animation.

Animating

Because we're already imitating 3d, we should create the animation by imitating the use of a camera. We only need to store the current position of the camera, then when we render the scene, we simply subtract it's position from the star's position:

  var camera = {
  x: 0,
  y: 0
}

// I use a mouse-control, because it's the easiest one to implement, and looks
// great.

var mouse = {
  x: 0,
  y: 0
};
c.addEventListener("mousemove", function (e) {
  mouse.x = e.clientX - c.width / 2,
  mouse.y = e.clientY - c.height / 2
});

// ...

function update(time) {
  camera.x += mouse.x * time;
  camera.y += mouse.y * time;
}

function render() {
  ctx.clearRect(0, 0, c.width, c.height);
  ctx.fillStyle = "white";
  for (var i = 0; i < stars.length; i++) {
    var star = stars[i];
    var proj = 1 / star.depth * maxDepth;
    var pos = {
      x: (star.x - camera.x) / proj,
      y: (star.y - camera.y) / proj
    };

    var size = starSize / proj;
    ctx.fillRect(pos.x - size / 2, pos.y - size / 2, size, size);
  }
}

And now if you try running it, it'll move to a direction depending on where your mouse pointer is at.

Wrapping

We have 1 problem though. Our star field is not infinite, and if you go in a direction, you quickly reach the end of it. To sort this out, we could either generate new stars when we need to have new ones, or make the current stars wrap around. We're going to implement the last one, because it's easier, and this tutorial is already very long.

To make the stars wrap around, we need to check when their projected position is off-screen, and if it is, we need to place it on the other side to make it visible. We also need to make it so they don't instantly get teleported, instead they need to go completely off the screen before.

After some fiddling, this is what I came up

  var pos = {
  x: ((star.x - camera.x) / proj + size / 2) % (c.width + size) - size / 2,
  y: ((star.y - camera.y) / proj + size / 2) % (c.height + size) - size / 2
};

if (pos.x < -size / 2)
  pos.x += c.width + size;
if (pos.y < -size / 2)
  pos.y += c.height + size;

Yeah, it's kind of long. The last 2 if statements are necessary, because in most of the languages, the modulo operator returns a negative value for negative values, so -10 % 20 = -10. So, when the position becomes negative, we just put it at the other end of the canvas.

Final words

There you are, a good looking star field demo. The method of creating 3d by dividing with depth/z is very useful. I use it in countless of my pens. If you have time, check some of them out.


1,065 1 34