Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
September 13, 2021 12:31 pm GMT

I built the entire universe in JavaScript

Its not a clickbait title. I really did it. And its not just a web experience. No. Its a real game, freely explorable, in 3D, right in your browser ! Dont you believe me ?

TLDR

Ho, i know your time is precious. Heres the condensed version.

I built a free 3D browser game, freely explorable, across infinite universes procedurally generated in JavaScript. The goal? Go from universe to universe and discover the origin of everything.

Its a four chapter story with an epic reveal at the end.

Before going further in reading this article, stop everything, put yourself in full screen, take some popcorn and play ACROSS THE MULTIVERSE !

https://across-multiverse.com/

No time to play?

You prefer to enjoy it on a PC and not on a mobile phone ?

Or do you just want to see more before playing it?

I got you.

I made a launch trailer on YouTube! Its only 3 minutes long. It shows a lot of things.

But beware, its extremely spectacular!

I put a lot of my time, my soul and my skills in the creation of this free game for the internet.

If you have five seconds to share it, that would be wonderful.

Meanwhile, its time to talk about the flashing elephant in the middle of the room.

How the fuck i did that?

Talk is cheap. Show me the code

I know that many of you prefer to dive right into the code. Even before reading my beautiful explanations. And I totally understand this.

For the most impatient, here is the full source code of the game.

GitHub logo jesuisundev / acrossthemultiverse

An in-browser, freely explorable, 3D game across infinite universes procedurally generated. Go from universe to universe and discover the origin of everything. A four chapter story with an epic revelation at the end.

Across The Multiverse

Across The Multiverse

js-standard-style

An in-browser, freely explorable, 3D game across infinite universes procedurally generatedGo from universe to universe and discover the origin of everythingA four chapter story with an epic revelation at the end.

https://across-multiverse.com/

A blog post explaining how everything works is available here !

License

Source Code

Under MIT license.

Music

All musics are owned by artists (see list below) and are used here underAttribution-NonCommercial-ShareAlike 3.0 Unported (CC BY-NC-SA 3.0) license

You can't use the musics for commercial use and/or without mentioning the following artists

  • Transcendent - Joel Nielsen
  • I Walk With Ghosts - Scott Buckley
  • Discovery - Scott Buckley
  • Celestial - Scott Buckley
  • Omega - Scott Buckley
  • Into the Night - Melodysheep

Footage

Under Attribution 4.0 International (CC BY 4.0)

You can use footage of the game for any use but you must mention this project with the following link : https://across-multiverse.com/

Install

It is of course open source under MIT license (for the code).

I still recommend following the story of the projects gradual creation via this article. It will give more context. And most importantly make a lot more sense.

How to build the universe?

Before I even start, you should know that I use the Three.js JavaScript library. This library allows you to use the WebGL api via JavaScript to do 3D rendering in the browser.

It It is possible that you dont understand the sentence before and/or that you dont know Three.js.
Fortunately I thought of everyone.

I made an article dedicated to Three.js and 3D rendering in JavaScript.

This article will allow you to immediately understand the basis of the project in only 5 minutes.

Anyway, back to the point.

How to build the universe?

Clearly the problem is too big.

I couldnt tackle this problem head on. And thats not how you do it when youre a developer. There is only one reflex to have when faced with a huge and complex problem.

Reducing complexity

Building the universe? Too complicated. I dont even know where to start. Okay, then lets narrow it down.

Building the Milky Way? Still too complicated, lets reduce it.

Building our solar system? No. Its too complicated. Lets reduce it.

Building an empty space filled with stars?

Ha! That sounds doable! A simple starfield in the darkness of space.

Thinking about it a little bit, I think this problem is really simple. That means Ive reduced the complexity enough.

Its time to get started.

How to build a simple starfield?

From then on, I always used a reference image. A photo or a real representation of what I wanted to recreate. An image to look at to get as close as possible to a realistic rendering.

For the starfield, I had decided to take a picture from the Hubble satellite.

I figure I just need to display random white dots in a black space.

It is very easy.

Lets start by creating an empty, black space and put a camera in it.

const scene = new THREE.Scene()const renderer = new THREE.WebGLRenderer({ powerPreference: "high-performance", antialias: false, stencil: false, depth: false })renderer.setSize(window.innerWidth, window.innerHeight)renderer.setClearColor(0xffffff, 0)renderer.domElement.id = 'starfield'document.body.appendChild(renderer.domElement)const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)camera.position.set(0, 10, 0)camera.lookAt(0, 0, 0)function animate(time) {    renderer.render(scene, camera)    requestAnimationFrame(animate)}animate()

OKAY. Great stuff. All this to display a black screen?!

Yes. Lets be patient. We have to start somewhere.

Now, harder.

Lets display white dots randomly in this black space. Can you do it? I didnt know how. So I look at to the documentation.

And i fount it !

There is a class dedicated to this : Points.

Perfect, lets follow the doc and write this down.

function getStarsGeometry() {    const geometry = new THREE.BufferGeometry()    geometry.setAttribute('position', new THREE.Float32BufferAttribute(getStarsRandomVertices(), 3))    return geometry}function getStarsRandomVertices(verticesNumber = 10000) {    const vertices = []    for (let i = 0; i < verticesNumber; i++) {        const x = 2000 * Math.random() - 1000;        const y = 2000 * Math.random() - 1000;        const z = 2000 * Math.random() - 1000;        vertices.push(x, y, z);    }    return vertices}function getStarsMaterial() {    const starSprite = new THREE.TextureLoader().load('../images/star.png');    const starMaterial = new THREE.PointsMaterial({ size: 5, sizeAttenuation: true, map: starSprite, alphaTest: 0.5, transparent: true });    return starMaterial}function getStars() {    const stars = new THREE.Points(getStarsGeometry(), getStarsMaterial())    return stars}scene.add(getStars())

In this piece of code, what will really do the magic is the getStarsRandomVertices function.

Our starfield (here represented by new THREE.Points) needs two things.

1 : The coordinates of each point to be displayed
2 : The material of each of the points. That is to say what we will display (to simplify) for each of the points.

The coordinates are managed by getStarsRandomVertices.

Our camera is placed at coordinates 0,0,0. We want stars all around us. So our points should be placed between the coordinates -1000 and 1000. All around us.

To do this, we will do a simple calculation.

2000 * Math.random() 1000

This very simple piece of math gives us a random value (math.random is not really random but lets admit it) between -1000 and 1000. We put this calculation in each axis (x, y, z) and thats it!

The material is managed by getStarsMaterial

Not much to say here. Well just use an image of a white circle as a texture and apply it. For the moment we dont need much.

We put the two together in the getStars function and we have solved our problem.

Well, for the moment it just gives an image with static white dots in 2D.

Its kind of lame. We can do better.

Its time to iterate on this result.

Improvement via iteration

Lets just start by bringing things to life.

The idea right now is to just move the camera in relation to the movement of the mouse. When I did that, I just wanted to make sure I was aware that I was doing 3D now.

Lets write this down.

document.body.addEventListener('pointermove', onPointerMove);function onPointerMove(event) {    if (event.isPrimary === false) return    mouseX = event.clientX - windowHalfX    mouseY = event.clientY - windowHalfY}function animate(time) {    renderer.render(scene, camera)    camera.position.x += (mouseX - camera.position.x) * 0.05    camera.position.y += (-mouseY - camera.position.y) * 0.05    requestAnimationFrame(animate)}

OK its cool, it moves, its 3D, we are happy.

Now lets make it even more interactive. What Id like to do is walk around in there. Freely. Like in an FPS game, with the mouse and the keyboard.

At that point, I had two choices:

  • Either I rewrote a whole FPS navigation system myself.
  • Either I took a FPS control class already made by Three.js.

I obviously chose to use the code already done.

It is tested and used by many people. I advise you to do the same thing when this situation happens to you. Unless you are in a learning process, there is no point in reinventing the wheel.

However, the features offered by the module were not enough for me.

I wanted more.

I wanted a velocity system. Give the players that feeling of acceleration and deceleration. So I had to use the existing module and extend those possibilities in my own class.

OK, lets write this down.

import { PointerLockControls } from './PointerLockControls.js'import * as THREE from 'three'export default class Controls {  constructor (camera, parameters) {    this.parameters = parameters    this.camera = camera    this.pointerLockControls = new PointerLockControls(this.camera, document.body)    this.velocity = new THREE.Vector3()    this.direction = new THREE.Vector3()    this.moveForward = false    this.moveBackward = false    this.moveLeft = false    this.moveRight = false  }  onKeyDown (event) {    if (this.pointerLockControls.isLocked) {      switch (event.code) {        case 'ArrowUp':        case 'KeyW':        case 'KeyZ':          this.moveForward = true          break        case 'ArrowLeft':        case 'KeyA':        case 'KeyQ':          this.moveLeft = true          break        case 'ArrowDown':        case 'KeyS':          this.moveBackward = true          break        case 'ArrowRight':        case 'KeyD':          this.moveRight = true          break      }    }  }  onKeyUp (event) {    if (this.pointerLockControls.isLocked) {      switch (event.code) {        case 'ArrowUp':        case 'KeyW':        case 'KeyZ':          this.moveForward = false          break        case 'ArrowLeft':        case 'KeyA':        case 'KeyQ':          this.moveLeft = false          break        case 'ArrowDown':        case 'KeyS':          this.moveBackward = false          break        case 'ArrowRight':        case 'KeyD':          this.moveRight = false          break      }    }  }  handleMovements (timePerf, prevTimePerf) {    const delta = timePerf - prevTimePerf    this.direction.z = Number(this.moveForward) - Number(this.moveBackward)    this.direction.x = Number(this.moveRight) - Number(this.moveLeft)    if (this.moveForward || this.moveBackward) {      this.velocity.z -= this.direction.z * this.parameters.controls.velocity * delta    }    if (this.moveLeft || this.moveRight) {      this.velocity.x -= this.direction.x * this.parameters.controls.velocity * delta    }    this.pointerLockControls.moveRight(-this.velocity.x * delta)    this.pointerLockControls.moveForward(-this.velocity.z * delta)  }}

And just like that, we built a starfield that can be explored like in a FPS.

Here you go, Ill put a codesandbox, you can play live in it.

You just have to click in the stars to switch to FPS mode.

Not bad, right?

Its not bad.

But its time to get down to business.

How to simulate infinity?

So far we have just placed dots around the player. But all he has to do is move a little bit and he sees the trickery. Its a bit lame again.

So how do we get this scaled?

How do you do it in a way that makes sense?

And above all, how do you do it without blowing up the memory and/or without going below 60FPS.

Now the real project begins.

The grid

** At that point, I stopped touching my keyboard.**

To code a solution for this kind of problem, you cant feel your way to it. No. You have to solve the problem conceptually -on paper- before doing anything.

Otherwise you are wasting your time.

And I hate wasting my time.

Its time to make drawings.

One of the first ideas that came to mind was the concept of a grid to represent space.

Concretely, the space would be an infinite grid. The player would go from square to square to see what it contains. And in each square you put what you want.

Stars, nebulae, galaxies, suns, black holes, whatever you want!

To better understand what Im talking about, Ill draw you a little picture.

Solve the problem

Lets start by representing our existing code. Right now, we have our player in the original 0,0,0 square, surrounded by stars. If he moves away a little he is in complete darkness.

Conceptually, it looks like this.

  • The little dude represents our player.
  • The blue stars represent the points already randomly placed around him.
  • The numbers in red are the coordinates of each square in space.

So far, nothing complex.

And thats the goal! I want to keep it simple at all costs. Its complex to keep it simple. Lets try to keep it simple in the algorithm for updating the grid.

We need two main functions.

The initialization function.

This function will create the material in the original square and in all the squares around the player.

The advantage of the initialization phase is that you can afford expensive actions. As long as the player is not in a gameplay phase, you are quite free.

Conceptually, it looks like this.

  • The green stars represent the points dynamically created by the initialization function

The update function.

This function will update the grid only when the player crosses the border between two squares.

With this function we want two things:

  • Delete the content of the squares which are too far from the player
  • Create the content of the squares where the player is most likely to go
    Conceptually, it would look like this.

  • The blue stars represent the points already placed

  • The green stars represent the points we create dynamically

  • The red stars represent the points that are deleted

And just like that, we managed the infinity simulation.

The player can go anywhere he wants. In the direction and the way he wants. He will not see the trickery. Wherever he goes, there will be wonderful things to look at.

I like this solution because it has several advantages.

  • It is relatively efficient

The fact that the contents of the squares are created on the fly and, above all, that they are deleted at the same time, relieves the memory a lot. In addition, only the minimum number of squares necessary is created each time.

  • We dont have to manage the players direction

No matter which direction the player is going, the algorithm will be the same. Indeed, we dont need to know what are the squares in front of the player. We just want to know which squares around the player are empty ! So he can go in the direction he wants, our algorithm will do exactly the same thing.

  • Its easy to manage

No need for a data structure straight from hell like a graph or a tree like an octree. No, no. Fuck that, leave me alone. One array, two hashmaps and thats enough. No headaches. Keep it simple.

Well, lets write this down.

Coding the solution

We are going to create this famous class which will manage the grid. For the sake of length and simplification, I dont explain everything. And above all, I dont show everything.

You have the full source code if you want to see everything.

Were just looking at the important parts today.

import MultiverseFactory from '../procedural/MultiverseFactory'export default class Grid {  constructor (camera, parameters, scene, library) {    this.camera = camera    this.parameters = parameters    this.scene = scene    this.library = library    this.activeClusters = new Map()    this.queueClusters = new Map()    this.multiverseFactory = new MultiverseFactory(this.scene, this.library, this.parameters)  }  getCurrentClusterPosition () {    const currentCameraPosition = this.getCurrentCameraPosition()    const xCoordinate = Math.trunc(currentCameraPosition.x / this.parameters.grid.clusterSize)    const yCoordinate = Math.trunc(currentCameraPosition.y / this.parameters.grid.clusterSize)    const zCoordinate = Math.trunc(currentCameraPosition.z / this.parameters.grid.clusterSize)    const currentClusterPosition = `${xCoordinate},${yCoordinate},${zCoordinate}`    return currentClusterPosition  }  getCurrentCameraPosition () {    this.camera.updateMatrixWorld()    return this.camera.position  }  getClustersStatus (currentCluster) {    const clustersNeighbour = this.getNeighbourClusters(currentCluster)    const clustersToPopulate = this._getEmptyClustersToPopulate(clustersNeighbour)    const clustersToDispose = this._getPopulatedClustersToDispose(clustersNeighbour, currentCluster)    return {      clustersNeighbour,      clustersToPopulate,      clustersToDispose    }  }  getNeighbourClusters (currentCluster) {    const neighbourClusters = [currentCluster]    const currentClusterArray = currentCluster.split(',')    const x = currentClusterArray[0]    const y = currentClusterArray[1]    const z = currentClusterArray[2]    // forward    neighbourClusters.push(`${x},${y},${Number(z) - 1}`)    // backward    neighbourClusters.push(`${x},${y},${Number(z) + 1}`)    // right    neighbourClusters.push(`${Number(x) + 1},${y},${z}`)    // left    neighbourClusters.push(`${Number(x) - 1},${y},${z}`)    // forward right    neighbourClusters.push(`${Number(x) + 1},${y},${Number(z) - 1}`)    // forward left    neighbourClusters.push(`${Number(x) - 1},${y},${Number(z) - 1}`)    // backward right    neighbourClusters.push(`${Number(x) + 1},${y},${Number(z) + 1}`)    // backward left    neighbourClusters.push(`${Number(x) - 1},${y},${Number(z) + 1}`)    return neighbourClusters  }  disposeClusters (clustersToDispose) {    for (const clusterToDispose of clustersToDispose) {      let matter = this.activeClusters.get(clusterToDispose)      matter.dispose()      matter = null      this.activeClusters.delete(clusterToDispose)    }  }  addMattersToClustersQueue (matters, type = 'starfield', subtype = null) {    for (const clusterToPopulate of Object.keys(matters)) {      this.queueClusters.set(clusterToPopulate, {        type: type,        subtype: subtype,        data: matters[clusterToPopulate]      })    }  }  populateNewUniverse () {    const clusterStatus = this.getClustersStatus('0,0,0')    this.buildMatters(clusterStatus.clustersToPopulate)  }  renderMatters (position, cluster) {    const matter = this.multiverseFactory.createMatter(cluster.type)    matter.generate(cluster.data, position, cluster.subtype)    matter.show()    this.queueClusters.delete(position)    this.activeClusters.set(position, matter)  }  _getEmptyClustersToPopulate (neighbourClusters) {    const emptyClustersToPopulate = []    for (const neighbourCluster of neighbourClusters) {      if (!this.activeClusters.has(neighbourCluster)) {        emptyClustersToPopulate.push(neighbourCluster)      }    }    return emptyClustersToPopulate  }  _getPopulatedClustersToDispose (neighbourClusters, currentCluster) {    const populatedClustersToDispose = []    for (const activeClusterKey of this.activeClusters.keys()) {      if (currentCluster !== activeClusterKey && !neighbourClusters.includes(activeClusterKey)) {        populatedClustersToDispose.push(activeClusterKey)      }    }    return populatedClustersToDispose  }}

And it works!

The contents of the boxes are added on the fly as the player approaches. The illusion is almost perfect. I say almost because unfortunately we have a big problem.

I know it doesnt show much in the video.

The performances when updating the grid are disastrous.

It freezes the image, its just disgusting and unplayable as is.

It is therefore time to diagnose and optimize.

Diagnose & Optimize

When a performance problem occurs in an application, the first reflex is to diagnose before doing anything.

Diagnose

In the case of a web application like ours, we will do this with the chrome dev tools. F12, tab Performance then CTRL+E to record what happens. Then we use the application normally before stopping the recording and analyzing the results.

By doing this, I quickly understood what was going on.

We have big FPS drops because we try to do too many things at the same time.

We do too many things for JavaScript. JavaScript being single-threaded, it is not forgiving. Too much is required, in too little time, for a single thread.

Remember the simple calculation I told you about at the beginning?

2000 * Math.random() 1000

We do this 300,000 times for each stars. In one frame.

Multiply by 3 for each axis (x, y x) of the coordinates.

Again multiplied by 3 for the three new squares that are created each time the player moves from square to square.

And right now, were just doing simple math for starfield. When well create nebulae or galaxies later, the math will be much more intensive.

Its expensive. Very expensive. So expensive that we exceed the limit of 16ms allowed per frame for a fluid image. We go up to 33ms. It blocks the event loop, it freezes the image and it becomes unplayable.

If we leave it like that, our player will also leave the game in 33ms.

Optimize

To avoid this, I have two solutions.

  • First, we will free ourselves from the single thread limit of JavaScript.

We will do it using the Web Workers of the browser. Im not going to do a lecture on this, its very well known, and the MDN page is extremely well done to understand them.

Concretely, we will send to Web Workers all the heavy calculations of the game.

These calculations will then be done in the background, by the browser. The goal is not to disturb our main thread. It must be in charge of only one thing: displaying things in a fluid way to the players.

Once the heavy calculations are done, the Web Workers return the results in events. Our main thread just has to display them !

// in worker.jsself.onmessage = messageEvent => {  const heavyMath = _getHeavyMath()  self.postMessage(heavyMath)}function _getHeavyMath () {  const positions = []  const colors = []  // heavy math to process positions and colors of particles  return {    positions: new Float32Array(positions),    colors: new Float32Array(colors)  }}// in main.jsworker.onmessage = messageEvent => this.showData(messageEvent.data)

And just like that, we divide the load by ten!

But thats not enough. In order to have excellent performances, we will relieve the event loop a little more.

  • Secondly, we are going to spread out the display phases of the squares in time.

As it is, the heavy calculations are well done in the web workers. But it is very possible that the display of the three squares are requested at exactly the same time. We want to avoid this to have a perfectly smooth image.

To do this, we will use a little trick.

We will allow only one display of star fields at the same time via a boolean. Then we will spread out in time the display phases of each square via a setTimeout. This means that the display of each square will not be instantaneous. It will be spaced out by 50ms. One by one.

50ms is huge in terms of relief for the event loop.

And it is imperceptible to the player.

Exactly what we need.

isRenderingClusterInProgress = trueconst clusterTorender = grid.queueClusters.keys().next().valuesetTimeout(() => {  grid.renderMatters(clusterTorender,   grid.queueClusters.get(clusterTorender))  isRenderingClusterInProgress = false}, parameters.global.clusterRenderTimeOut)

Et voil !

Infinite starfields in your browser.

Isnt life beautiful?

And the rest?

If you have played the game and/or seen the trailer, you can see that 90% of the content is missing in this article.

Nebulae, suns, black holes, red giants, fucking wormholes between universes, four different universes and the incredible finale!

Yes, it is. But this project is huge. Too huge to be written about in one article.

A lot of articles (at least four) on the subject is coming. Well take a look at each of the topics to talk about them.

  • How to build Nebulae in JavaScript ?
  • How to build Red Giant in JavaScript ?
  • How to build Black Hole in JavaScript ?
  • How to build Worm Hole in Javascript ?

So stay tuned folks !

Im just getting started.

Epilogue

It was the biggest personal project Ive ever done. It was incredible to do. There were ups, downs, distress and wonder. Ill let you enjoy the game. Im not sure Ill be doing a project this big again anytime soon.

Please share it with others. It would mean a lot to me.

Im going to get some sleep now.


Original Link: https://dev.to/jesuisundev/i-built-the-entire-universe-in-javascript-4i10

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To