Creating the effect of traveling through space

Eduard Iudinkov - Jan 29 '23 - - Dev Community

Hello everyone! Today we're going to create the effect of traveling through space using javascript and canvas. Let's get started!

Theory

This effect is based on the simplest way of obtaining a perspective projection of a point from three-dimensional space onto a plane. For our case, we need to divide the value of the x and y coordinates of a three-dimensional point by their distance from the origin:

P'X = Px / Pz
P'Y = Py / Pz

The projection of a point from three-dimensional space onto a plane

Environment setup

Let's define the Star class that will store the states of the star and have three main methods: updating the state of the star, drawing the star on the screen, and getting its position in 3D space:

class Star {
  constructor() {}

  getPosition() {}

  update() {}

  draw(ctx) {}
}
Enter fullscreen mode Exit fullscreen mode

Next, we need a class that will be used to create and manage the instances of the Star class. Let's call it Space and create an array of Star objects in its constructor, each one representing a star:

class Space {
  constructor() {
    this.stars = new Array(STARS).fill(null).map(() => new Star());
  }
}
Enter fullscreen mode Exit fullscreen mode

It will also have three methods: update, draw, and run. The run method will iterate through the star instances by first calling the update method, and then drawing them with the draw method:

class Space {
  constructor() {
    this.stars = new Array(STARS).fill(null).map(() => new Star());
  }

  update() {
    this.stars.forEach((star) => star.update());
  }

  draw(ctx) {
    this.stars.forEach((star) => star.draw(ctx));
  }

  run(ctx) {
    this.update();
    this.draw(ctx);
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, we should define a new class called Canvas that will create the canvas element and call the run method of the Space class:

class Canvas {
  constructor(id) {
    this.canvas = document.createElement("canvas");

    this.canvas.id = id;
    this.canvas.width = window.innerWidth;
    this.canvas.height = window.innerHeight;

    document.body.appendChild(this.canvas);
    this.ctx = this.canvas.getContext("2d");
  }


  draw() {
    const space = new Space();
    const draw = () => {
      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
      space.run(this.ctx);
      requestAnimationFrame(draw);
    };
    draw();
  }
}
Enter fullscreen mode Exit fullscreen mode

Thus, the preparatory part of the project has been completed and we can begin to implement its main functionality.

Main functionality

The first step we need to take is to define a uniform function that generates random numbers in a given range of numbers. To do this, we will create a random object and implement the function in it using the Math.random() method:

const random = {
  uniform: (min, max) => Math.random() * (max - min) + min,
};
Enter fullscreen mode Exit fullscreen mode

Once we need a class to implement the space vectors Vec, since javascript does not support working with vectors. What is a vector? A vector is a mathematical object that describes directions in space. Vectors are built from the numbers that form their components. In the picture below you can see a 2D vector with two components:

2D vector

Vector operations

Consider two vectors. The following basic operations are defined for these vectors:

Addition: V + W = (Vx + Wx, Vy + Wy)

Subtraction: V - W = (Vx - Wx, Vy - Wy)

Division: V / W = (Vx / Wx, Vy / Wy)

Scaling: aV = (aVx, aVy)

Multiplication: V * W = (Vx * Wx, Vy * Wy)

Based on this information, we will implement the main methods of working with vectors that we will need in future:

class Vec {
  constructor(...components) {
    this.components = components;
  }

  add(vec) {
    this.components = this.components.map((c, i) => c + vec.components[i]);
    return this;
  }

  sub(vec) {
    this.components = this.components.map((c, i) => c - vec.components[i]);
    return this;
  }

  div(vec) {
    this.components = this.components.map((c, i) => c / vec.components[i]);
    return this;
  }

  scale(scalar) {
    this.components = this.components.map((c) => c * scalar);
    return this;
  }

  multiply(vec) {
    this.components = this.components.map((c, i) => c * vec.components[i]);
    return this;
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementation

First, let's define the center of the screen as a two-dimensional vector and make a set of several colors for our stars:

const CENTER = new Vec(window.innerWidth / 2, window.innerHeight / 2);
const COLORS = ["#FF7900", "#F94E5D", "#CA4B8C"];
Enter fullscreen mode Exit fullscreen mode

and also introduce the constant Z, which will be used to indicate the distance along the z axis from which stars will start moving:

const Z = 35;
Enter fullscreen mode Exit fullscreen mode

Next, we will assign the position of each star in three-dimensional space to the attributes. We will do this by implementing the getPosition method of our Star class. This method uses a unit circle with a random radius to generate coordinates using sin and cos. These functions are mathematically related to unit circles; therefore they can be used to represent points in three-dimensional space.

Circle cos sin

Thus we get the following code:

getPosition() {
  const angle = random.uniform(0, 2 * Math.PI);
  const radius = random.uniform(0, window.innerHeight);

  const x = Math.cos(angle) * radius;
  const y = Math.sin(angle) * radius;

  return new Vec(x, y, Z);
}
Enter fullscreen mode Exit fullscreen mode

Now let's call it in the class constructor:

class Star {
  constructor() {
    this.pos = this.getPosition();
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, in the constructor we set the speed of the star, its color and position on the screen in terms of a two-dimensional vector and its size:

class Star {
  constructor() {
    this.size = 10;
    this.pos = this.getPosition();
    this.screenPos = new Vec(0, 0);
    this.vel = random.uniform(0.05, 0.25);
    this.color = COLORS[Math.floor(Math.random() * COLORS.length)];
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, we will move the star along the Z axis at a set speed and when it reaches its minimum value, we will call a getPosition method to randomly set its new position:

update() {
  this.pos.components[2] -= this.vel;
  this.pos = this.pos.components[2] < 1 ? this.getPosition() : this.pos;
}
Enter fullscreen mode Exit fullscreen mode

The coordinates of a star on the screen can be calculated by dividing the X and Y coordinates by the value of the Z component, taking the center of the screen into account:

update() {
  this.pos.components[2] -= this.vel;
  this.pos = this.pos.components[2] < 1 ? this.getPosition() : this.pos;
  this.screenPos = new Vec(this.pos.components[0], this.pos.components[1])
      .div(new Vec(this.pos.components[2], this.pos.components[2]))
      .add(CENTER);
}
Enter fullscreen mode Exit fullscreen mode

Next, we will display the star on the screen by using the draw method. To do this, we use rect method:

draw(ctx) {
  ctx.fillStyle = this.color;

  ctx.beginPath();
  ctx.rect(this.screenPos.components[0], this.screenPos.components[1], this.size, this.size);
  ctx.closePath();
  ctx.fill();
}
Enter fullscreen mode Exit fullscreen mode

Let's see how the stars move in real time. As you can see, the stars move as expected, but their size does not change:

To solve this problem, we divide the value of the Z constant by the current value of the star along the axis Z. The result is as follows:

If you look closely, you'll see that the stars that are farther away are drawn on top of the nearby stars. To solve this problem, we will use the so-called Z Buffer and sort the stars by distance until they are drawn. Let's do this sorting in the run method of the Space class:

run(ctx) {
  this.update();
  this.stars.sort((a, b) => b.pos.components[2] - a.pos.components[2]);
  this.draw(ctx);
  }
Enter fullscreen mode Exit fullscreen mode

In addition, we will introduce a scale factor in the getPosition method of the Star class to scale our visualization by increasing the random radius to create larger stars:

getPosition(scale = 35) {
  const angle = random.uniform(0, 2 * Math.PI);
  const radius =
      random.uniform(window.innerHeight / scale, window.innerHeight) * scale;

  const x = Math.cos(angle) * radius;
  const y = Math.sin(angle) * radius;

  return new Vec(x, y, Z);
}
Enter fullscreen mode Exit fullscreen mode

and also slightly change the function for the value of the projection of the star to a more suitable one:

update() {
  this.pos.components[2] -= this.vel;
  this.pos = this.pos.components[2] < 1 ? this.getPosition() : this.pos;
  this.screenPos = new Vec(this.pos.components[0], this.pos.components[1])
      .div(new Vec(this.pos.components[2], this.pos.components[2]))
      .add(CENTER);

  this.size = (Z - this.pos.components[2]) / (this.pos.components[2] * 0.2);
}
Enter fullscreen mode Exit fullscreen mode

As a result, we get a complete space picture:

In addition we can rotate the XY plane by a small angle. To do this, we calculate the new values of x and y using sin and cos:

rotateXY(angle) {
  const x = this.components[0] * Math.cos(angle) - this.components[1] * Math.sin(angle);
  const y = this.components[0] * Math.sin(angle) + this.components[1] * Math.cos(angle);
  this.components[0] = x;
  this.components[1] = y;
}
Enter fullscreen mode Exit fullscreen mode

and call this method in the update method of the Star class:

update() {
  this.pos.components[2] -= this.vel;
  this.pos = this.pos.components[2] < 1 ? this.getPosition() : this.pos;
  this.screenPos = new Vec(this.pos.components[0], this.pos.components[1])
      .div(new Vec(this.pos.components[2], this.pos.components[2]))
      .add(CENTER);

  this.size = (Z - this.pos.components[2]) / (this.pos.components[2] * 0.2);
  this.pos.rotateXY(0.003);
}
Enter fullscreen mode Exit fullscreen mode

As a result, we get the following picture:

Moreover, if we slightly change the initial parameters and calculate the random radius differently, we can get the effect of traveling through a tunnel:

Conclusion

We created a visualization of movement through space and learned how to do this kind of visualization.

Additional resources

Jony Hayama has created UI for the simulation, so if you want to play with variables more conveniently check this link out - https://jony.dev/traveling-through-space/

. .