This post was originally posted on my personal blog.
TLDR
I created a 3D experience that makes you -LITERALLY- travel through the universe in space from the browser. It’s as spectacular as it is beautiful! It only uses web technologies: HTML, CSS and Javascript. I had so much fun doing this side project!
Before going further in reading this article, stop everything, open Chrome, go full screen, have some popcorn and experience ACROSS THE UNIVERSE !
Done? Did you like it? If you’re interested to know why and how I did it, that’s what you’ll find in the rest of this article!
The idea
I started this whole thing last week. As usual, I was hanging out in the world wide web. And I stumbled upon this video from a famous video game.
In this video, you can see a wormhole in full screen. I wanted to write an article about 3D in Javascript and I thought BINGO! The sample code for the article will be the creation of a wormhole in the browser.
Keep in mind that at that time, I don’t know anything about ThreeJS or 3D object management in general. And that’s what’s good! Come on, it’s time to build a wormhole.
Understand ThreeJS in 30 seconds
Basically, I wanted to write a “Understand in 5 minutes” format for ThreeJS. Watch out, I’m going to give you a 30-second briefing instead.
ThreeJS is a Javascript library, created by Mr.doob, that allows you to manipulate 3D objects directly in the browser. In fact, what you have to understand is that ThreeJS, via Javascript, allows you to use WebGL in an HTML5 canvas.
It’s WebGL that allows 3D rendering! ThreeJS, via Javascript, allows you to drive WebGL, and thus 3D. And the crazy thing about it is that no installation and/or plugin are needed.
And for you to understand very concretely, there are three basic elements that allow you to display 3D in ThreeJS.
- The scene: you can see it as the 3D world where you’re going to work. You’re going to place objects (mesh) in the scene and make them evolve.
- The camera: This is what the user will see of the scene you’ve created.
- The rendering: the rendering takes a scene and the camera in parameter, and display frames in the canvas. The rendering will produce up to 60 frames per second in an infinite loop!
Let’s look at a drawing from the internet to understand even better.
A hello world in ThreeJS looks like this!
// instantiate scene, camera and renderer
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer()
// build a red cube mesh with default box geometry and basic material
const geometry = new THREE.BoxGeometry()
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const cube = new THREE.Mesh(geometry, material)
// add the mesh in the scene
scene.add(cube)
// set the camera in front of the cube
camera.position.z = 5
// set the size of the renderer in fullscreen
renderer.setSize(window.innerWidth, window.innerHeight)
// put the renderer in the HTML page (canvas)
document.body.appendChild(renderer.domElement)
// game loop rendering each frame
function animate() {
requestAnimationFrame(animate)
// rotating the cube at each frame
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
// render a frame from the pov of the camera
renderer.render(scene, camera)
}
animate()
If you’re passionate about the subject, and want to know how meshes, materials, textures and all the rest work, I’ll make an article about it. Today we’re focusing on space!
The first wall
Now that we understand how the base works, it’s time to tackle the wormhole.
My first implementation idea was very simple, very intuitive. Make an object with the shape of a cylinder in the middle of a scene. Then pass the camera through it. From the camera’s point of view, I thought the illusion would be perfect. Simple, fast, effective.
Okay then, let’s write that down.
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
const geometry = new THREE.CylinderGeometry(5, 5, 20, 32)
const material = new THREE.MeshBasicMaterial({ wireframe: true })
const cylinder = new THREE.Mesh(geometry, material)
const light = new THREE.PointLight(0xFFFF00)
light.position.set(0, 0, 0)
scene.add(light)
scene.add(cylinder)
camera.position.z = 0
camera.position.x = 0
camera.position.y = 15
camera.lookAt(0, 0, 0)
cylinder.flipSided = true
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
function animate() {
requestAnimationFrame(animate)
cylinder.rotation.y += 0.01;
controls.update();
renderer.render(scene, camera)
}
animate()
Not bad! All I had to do was to put a texture of space inside and BOOM, and that was it. At least, that’s what I thought.
While doing the first tests with the texture and moving the camera inside, I quickly realized several problems.
- The effect was really not great. The fact that the camera was moving inside the cylinder made it look very bad. It was anything but the tunnel illusion I wanted. The WOW effect is essential for this project. Without the perfect illusion, it’s useless.
- A very long tunnel would have had to be managed. And that made a lot of things more complex! Making the user believe that we are crossing the universe will require a lot of distance. Streaming solutions exist, but once again, it became complex.
I was close to giving up and then I had an idea. The brain will try to make sense of everything it sees. And thanks to that, there’s a way to lie to the brain.
The cake is a lie
The idea was simple. Leave the camera in the same place, at the entrance of the cylinder, and move the texture instead! The effect of moving the texture would be perceived as a movement of the camera. If the brain sees that the stars are moving, it will believe that it is moving itself.
The illusion should be particularly good because of the spherical shape in front of the user’s face. To make sure it works well, a small rotation of the whole should add to the illusion.
And to my amazement, technically, moving the texture of the cube is super simple. It’s even easier than I thought to lie to the brain.
So we just need to add a texture, apply it to our mesh and make it move for each frame in the gameloop. Let’s write that down.
// dark space full of stars
const darkCylinderTexture = new THREE.TextureLoader().load('/images/dark_space_texture.jpg')
// repeat the texture in different ways to make sure the effect is infinite
darkCylinderTexture.wrapS = THREE.RepeatWrapping
darkCylinderTexture.wrapT = THREE.MirroredRepeatWrapping
darkCylinderTexture.repeat.set(1, 1)
// building the material with the texture
// we only need the inside of the cylinder to be textured
const darkCylinderMaterial = new THREE.MeshLambertMaterial({
side: THREE.BackSide,
map: darkCylinderTexture
})
// building and adding mesh to the scene
const darkCylinder = new THREE.Mesh(
new THREE.CylinderBufferGeometry(1, 1, 20, 12, 0, true),
darkCylinderMaterial
)
scene.add(darkCylinder)
function animate() {
requestAnimationFrame(animate)
// move forward the texture
darkCylinderTexture.offset.y -= 0.0010;
// rotation of the texture
darkCylinderTexture.offset.x -= 0.0005;
renderer.render(scene, camera)
}
animate()
It looks disgusting because of the GIF compression here, but the illusion of motion is real on the webpage! Much later in the project I’m going to realize that this way of doing things (moving the texture) is used everywhere, by lots of people. I thought I had invented something (lol), but that will be for another day!
So, I stayed staring at this tunnel effect for a long time like a sociopath. And that’s when the plan to make just one example for one article stops. I’ve got a thousand ideas flowing by the second.
We’re going on a side project.
To infinity and beyond
Now the idea is to cross an A universe, take a wormhole with a lot of effects, and then land in a B universe. Yes, I’m already on a multiverse project.
I also want a cinematic side to all this, so that means a mini story (text) and music! It’s going to be a show!
First of all, I need color! Nebulas, gas, supernova, life! So I started looking for a good nebula texture. And I found it.
To test, I created a second cylinder and put it exactly in the same position as the first one, telling myself that it would hide the first one.
But something else happened!
The two cylinders, being in exactly at the same place, were superimposed! So not only is it pretty, but it gives depth to the whole thing!
The possibilities have once again multiplied before my eyes.
It was just a matter of being creative now !
Now that the crossing of the first universe is almost done, it’s time to jump into hyperspace!
Post processing
The idea would be to have a shiny portal at the end of the tunnel. Then, brutally accelerate the speed of movement of the texture. Make the shiny portal come closer slowly, to give the impression that we are travelling a real distance.
During my research for this part, I came across the concept of post processing. The concept is simple, the image is rendered normally, but before being displayed, it goes through one or more filters and effects.
This will allow things like film grain, glitch, blooming effects or even light effects. Interesting! That means i can make a sphere with a light effect then?
Let’s write that down!
// building the basic white material for the horizon
const horizonMaterial = new THREE.MeshBasicMaterial({color: 0xffffff})
// building the sphere geometry for the horizon
const horizonGeometry = new THREE.SphereBufferGeometry(0.25, 32, 32)
// baking the mesh with material and geometry
const horizon = new THREE.Mesh(sunGeometry, sunMaterial)
//applying the postprocessing god rays effect to the horizon
const godRaysEffect = new POSTPROCESSING.GodRaysEffect(camera, horizon , {
height: 480,
kernelSize: POSTPROCESSING.KernelSize.SMALL,
density: 1.2,
decay: 0.92,
weight: 1,
exposure: 5,
samples: 60,
clampMax: 1.0
})
// postprocessing effect pass instance
const effectPass = new POSTPROCESSING.EffectPass(
camera,
godRaysEffect
)
// enable effect pass
effectPass.renderToScreen = true
// we make the effect composer with the renderer itself !
const composer = new POSTPROCESSING.EffectComposer(renderer)
// postprocessing mandatory first render pass
composer.addPass(new POSTPROCESSING.RenderPass(scene, camera))
// postprocessing effect render pass
composer.addPass(effectPass);
// game loop
function animate() {
requestAnimationFrame(animate)
// rendering via the composer !
composer.render()
}
animate()
Well, this is really starting to look good. The post-processing technique is really transcending this interstellar journey.
As I browse through the post-processing documentation, I realize that there are a lot of effects. And, I don’t know, I went crazy. I started to put them all at the same time.
I wanted them all. ALL OF THEM! MORE!
const godRaysEffect = new POSTPROCESSING.GodRaysEffect(camera, horizon, {
height: 480,
kernelSize: POSTPROCESSING.KernelSize.SMALL,
density: 1.2,
decay: 0.92,
weight: 1,
exposure: 5,
samples: 60,
clampMax: 1.0
});
const vignetteEffect = new POSTPROCESSING.VignetteEffect({
darkness: 0.5
})
const depthEffect = new POSTPROCESSING.RealisticBokehEffect({
blendFunction: POSTPROCESSING.BlendFunction.ADD,
focus: 2,
maxBlur: 5
})
const bloomEffect = new POSTPROCESSING.BloomEffect({
blendFunction: POSTPROCESSING.BlendFunction.ADD,
kernelSize: POSTPROCESSING.KernelSize.SMALL
});
// postprocessing effect pass instance
const effectPass = new POSTPROCESSING.EffectPass(
camera,
bloomEffect,
vignetteEffect,
depthEffect,
godRaysEffect
);
So, it turns out that I’m going to quickly go back and choose only two effects for the rest of the project. First because, all at once is too much. And second because it looks like a firework made by a schizophrenic on acid.
But above all, in the near future, I will soon realize that all this has a huge price in terms of performance. On my big machine, it's fine. But when I started testing on my laptop, I cried blood.
At the end of the project, I found myself cutting everything to optimize the scene. And even with all the optimization I’ve been able to do on the stage, I still have examples of people with performance issues. Work in progress, I have to ship!
Anyway, last stop : how did I do the hyperspace jump animation? That’s interesting. And the answer is simple: Tween.JS!
Horizon
The Tween.JS library does one thing, but it does it extremely well. It takes a value in an object and gradually moves it to another one.
You’re going to tell me that you can do it easily in vanilla Javascript and you’re right. But Tween.JS comes with many more things.
First of all, the calculations made to make the transition between values, complex or not, are extremely optimized internally.
Then, Tween.JS comes with a lot of very useful methods like the “onUpdate” or the “onComplete” which will allow us to create events at key moments of the animation.
Finally, Tween.JS comes with an easing system. Instead of having a boring and unrealistic linear animation, we get a lot of nuances.
And when I opened the page to see what I could do, It was Christmas before the date.
Taking as parameters the values of opacity, texture movement and position of the cylinders coupled with the animation via Tween.JS easing: I can do anything. I literally became a 3D effect orchestra conductor in Javascript.
Making a jump into hyperspace? Easy. Let’s write that.
/**
* Entrypoint of the horizon event
* Will be trigger by the click on the horizon
*
* @param {Object} event event of the click
*/
function prepareLaunchHorizonEvent(event) {
event.preventDefault()
document.getElementById('callToAction').remove()
somniumAudio.fade(1, 0, 1500)
oceansAudio.volume(0)
oceansAudio.play()
oceansAudio.fade(0, 1, 5000)
const timeToLaunch = 12500
const easingHideAndSpeed = TWEEN.Easing.Quintic.In
const easingRotation = TWEEN.Easing.Quintic.Out
const slowingTextureRotationDark = new TWEEN.Tween(darkTextureRotation)
.to({ value: 0.0001 }, timeToLaunch)
.easing(easingRotation)
const slowingTextureRotationColorFull = new TWEEN.Tween(colorFullTextureRotation)
.to({ value: 0.0001 }, timeToLaunch)
.easing(easingRotation)
const slowingGlobalRotation = new TWEEN.Tween(globalRotation)
.to({ value: 0 }, timeToLaunch)
.easing(easingRotation)
const reduceBloomEffect = new TWEEN.Tween(bloomEffect.blendMode.opacity)
.to({ value: 1 }, timeToLaunch)
.easing(TWEEN.Easing.Elastic.Out)
const reduceDark = new TWEEN.Tween(darkCylinderMaterial)
.to({ opacity: 0.1 }, timeToLaunch)
.easing(easingHideAndSpeed)
const hideColorFull = new TWEEN.Tween(colorFullCylinderMaterial)
.to({ opacity: 0 }, timeToLaunch)
.easing(easingHideAndSpeed)
const slowingSpeedDark = new TWEEN.Tween(darkMoveForward)
.to({ value: 0.0001 }, timeToLaunch)
.easing(easingHideAndSpeed)
const slowingSpeedColorFull = new TWEEN.Tween(colorFullMoveForward)
.to({ value: 0.0001 }, timeToLaunch)
.easing(easingHideAndSpeed)
// leaving normal space
reduceBloomEffect.start()
reduceDark.start()
hideColorFull.start().onComplete(() => scene.remove(colorFullCylinder))
// slowing general rotation
slowingTextureRotationDark.start()
slowingTextureRotationColorFull.start()
slowingGlobalRotation.start()
// slowing general speed
slowingSpeedDark.start()
slowingSpeedColorFull.start().onComplete(() => launchHorizonEvent())
}
/**
* Horizon event
* Water + Dark cylinder
*/
function launchHorizonEvent() {
darkTextureRotation.value = 0.0040
const showDark = new TWEEN.Tween(darkCylinderMaterial)
.to({ opacity: 1 }, 500)
.easing(TWEEN.Easing.Circular.Out)
const showWater = new TWEEN.Tween(waterCylinderMaterial)
.to({ opacity: 0.3 }, 500)
.easing(TWEEN.Easing.Circular.Out)
const speedUpDark = new TWEEN.Tween(darkMoveForward)
.to({ value: 0.0086 }, 2000)
.easing(TWEEN.Easing.Elastic.Out)
const speedUpWater = new TWEEN.Tween(waterMoveForward)
.to({ value: 0.0156 }, 2000)
.easing(TWEEN.Easing.Elastic.Out)
const horizonExposure = new TWEEN.Tween(effectPass.effects[0].godRaysMaterial.uniforms.exposure)
.to({ value: 45 }, 35000)
.easing(TWEEN.Easing.Circular.In)
// huge speed at launch
speedUpDark.start()
speedUpWater.start()
// show hyperspace
scene.add(waterCylinder)
showWater.start()
showDark.start().onComplete(() => secondPhaseHorizonEvent())
// launch long exposure from horizon
// because of the huge timeout this will be trigger after all the horizon phase event
horizonExposure.start().onComplete(() => enterParallelUniverse())
}
There you go! The universe is crossed, we also crossed the horizon of a wormhole and we are now exploring a parallel universe. It is beautiful!
There are many things I don’t talk about in this article. The various animations everywhere. The logo and the ui/ux made by my friend Arnaud. Or the music! The incredible music of Melodysheep that I contacted and who gave me the authorization to use them in my project!
How I synchronized the music with the animations and many other questions will be answered by looking at the project source code.
It’s an open source project, do you want to participate? If you see a bug, a performance issue or any improvement, send me a PR. I have easy approval.
Across The Universe
A three minute web experience across the universe.
https://www.across-universe.com/
License
Attribution-NonCommercial-ShareAlike 3.0 Unported (CC BY-NC-SA 3.0)
See : https://creativecommons.org/licenses/by-nc-sa/3.0/
Install
npm install
Launch
DEV
npm run-script start-dev
PROD
npm start
Epilogue
I don’t think I’ve had this much fun on a side project in a long time. If there’s a lot of people passing by on the website, I’ll do chapter 2. If there’s nobody, I think I’ll do a chapter 2 anyway. It was too much fun for me to stop here!
Top comments (19)
Smiling! But I would say there a places where the waiting is a little too long. Bravo though!
True ! I needed to sync with the music. Part 2 will be more dynamic i think.
Better control the music than the music control you. Cracks out audacity 😅. Honestly top marks for this. Wish I had such a grasp on webgl
cool!
Really good. Congratulations!!!
This is amazing. I need to try webgl again...
A fitting wonderful song for a wonderful project!
Awesome !!! Love it
Utterly impressive!
Awesome
no software to install?
nop ! just you and your browser !
i love this but honestly am not getting any result in my browser
Hey! Could you give me information about your browser, system etc etc ?
So i could track down the problem and fix it ?
Thanks a bunch!
first i used spider monkey then V8