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
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) {}
}
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());
}
}
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);
}
}
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();
}
}
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,
};
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:
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;
}
}
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"];
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;
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.
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);
}
Now let's call it in the class constructor:
class Star {
constructor() {
this.pos = this.getPosition();
}
}
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)];
}
}
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;
}
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);
}
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();
}
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);
}
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);
}
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);
}
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;
}
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);
}
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/
Top comments (11)
I remember now why I subscribe to Dev.to years ago; to read such great and original piece of content.
👏
Hope you don't mind, but I liked this stuff so much I ended up adding some UI around it so it's a bit easier to play around with the variables 😝
jony.dev/traveling-through-space/
That is awesome! I updated the article with your simulation :)
No idea what the math is doing even though I have a math degree, but this is mesmerizing stuff. Thank you for sharing.
this is super cool !!! need more like this
this is the coolest stuff I've seen in a while!
Can you let us know how do you make the .gif animation to demonstrate the cos and sin relationship?
You will find more information here: 1ucasvb.tumblr.com/faq.
Wikipedia user profile: en.wikipedia.org/wiki/User:LucasVB....
Hey, that was a nice read, you got my follow, keep writing 😉
That's impressive! Thanks a lot!
You got my follow :) Marvelous!!