Comment traverser l’univers en Javascript

Comment traverser l’univers en Javascript

Y’a pas longtemps, je jouais avec ThreeJS et puis j’ai eu une idée de fifou. Ça devait être un POC, c’est devenu un side project qui a produit une expérience hypnotisante! Mais c’est quoi? Et surtout, comment je l’ai fait?



TLDR

J’ai créé une expérience 3D qui te fait -LITÉRALEMENT- traverser l’univers dans l’espace depuis le navigateur. C’est aussi spectaculaire que magnifique ! Ça n’utilise que des technologies web : HTML, CSS et Javascript. J’ai pris tellement de plaisir à faire ce side projet !

Avant d’aller plus loin dans la lecture de cet article, arrête tout, ouvre Chrome, mets-toi en plein écran, prends des pop-corn et fais l’expérience ACROSS THE UNIVERSE !

Comment traverser l’univers en Javascript


Ça c’est fait ? T’as kiffé ? Si ça t’intéresse de savoir, pourquoi et comment j’ai fait ça c’est ce que tu vas trouver dans la suite de cet article!



L’idée

J’ai commencé cette affaire la semaine dernière. Comme à mon habitude, je vaquais à mon surf nonchalant sur les internets mondiaux. Et je suis tombé sur cette vidéo d’un jeu vidéo connu.

Dans cette vidéo, on voit un trou de ver en plein écran. Je voulais justement écrire un article sur de la 3D en Javascript et je me suis dit BINGO! L’exemple de code de l’article va être la création d’un wormhole dans la navigateur. Let’s GO!





Garde en tête qu’à ce moment-là, je ne connais rien à ThreeJS ou de la gestion d’objets 3D en général. Et c’est ça qu’est bon ! Allez, je me sors les doigts, il est temps de bâtir un trou de ver.



Comprendre ThreeJS en 30 secondes

À la base, je devais écrire un format « Comprendre en 5 minutes » pour ThreeJS. Attention les yeux, je vais de te faire un topo en 30 secondes.

ThreeJS est une bibliothèque en Javascript, créée par Mr.doob, qui te permet de manipuler des objets 3D directement dans le navigateur. En fait, ce qu’il faut comprendre c’est que ThreeJS, via le Javascript, permet d’utiliser WebGL dans un canvas HTML5.

C’est WebGL qui permet le rendu 3D ! ThreeJS, via Javascript, te permet de piloter du WebGL, et donc de la 3D. Et le truc de fifou c’est qu’aucune installation et/ou plugin n’est nécessaire. C’est incroyable cette affaire non ?





Et pour que tu comprennes bien, très concrètement, il y a trois éléments de base qui permettent d’afficher de la 3D dans ThreeJS.

  • La scène : tu peux voir ça comme le monde 3D dans lequel tu vas bosser. Tu vas disposer des objets (mesh) dans la scène et les faire évoluer.
  • La caméra : C’est ce que l’utilisateur va voir de la scène que tu as créée.
  • Le rendu : le rendu prend une scène et la caméra en paramètre, et t’affiche le tout dans le canvas. Le rendu va produire jusqu’à 60 images par seconde dans une boucle infinie !

Un dessin pompé sur les internet va te permettre d’encore mieux comprendre.



Un hello World en ThreeJS ressemble à ça !

// instantiate scene, camera and rendererconst 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()


Si le sujet te passionne, et que tu veux savoir comment fonctionnent les mesh, les materials, les textures et tout le reste, j’en ferai un article. Aujourd’hui, on se concentre sur l’espace!



La galére

Maintenant que j’ai compris comment la base fonctionne, il est temps de m’attaquer au trou de ver.

Ma première idée d’implémentation était très simple, très intuitive. Faire un objet avec une forme de cylindre au milieu d’une scène et faire passer la caméra dedans. À ce moment-là, du point de vue de la caméra, je pensais que l’illusion serait parfaite. Simple, rapide, efficace.

OK alors, écrivons ça.

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()


Pas mal ! Il me restait plus qu’à mettre une texture d’une image de l’espace a l’intérieur et BOOM, le tour est joué. Enfin, c’est ce que je croyais.

En faisant les premiers tests avec de la texture et en faisant avancer la caméra à l’intérieur, je me suis vite rendu compte de plusieurs problèmes.

  • L’effet n’était vraiment pas terrible. Le fait que la caméra bouge dans le cylindre rendait très mal. C’était tout sauf l’illusion de tunnel que je voulais donner. L’effet WOW est primordiale pour ce projet. Sans l’illusion parfaite, ça sert à quedal.
  • Il aurait fallu gérer un très long tunnel. Et ça, ça complexifiait beaucoup de choses ! Faire croire à l’utilisateur qu’on traverse l’univers va demander beaucoup de distance. Des solutions de streaming existent, mais encore une fois, ça devenait complexe.

J’étais pas loin de laisser tomber et puis j’ai eu une idée. Le cerveau va essayer de donner du sens à tout ce qu’il voit. Et grâce à ça, y’a moyen de mentir au cerveau.



The cake is a lie

L’idée était simple. Laisser la caméra au même endroit, à l’entrée du cylindre, et faire bouger la texture à la place ! L’effet de mouvement de la texture serait perçu comme un mouvement de la caméra. Si le cerveau voit que les étoiles bougent, il va croire qu’il est en train lui-même d’avancer.

L’illusion devrait être particulièrement bonne de par la forme sphérique devant la face de l’utilisateur. Pour être sûr que ça fonctionne bien, une petite rotation du tout devrait ajouter à l’illusion.

Et à mon grand étonnement, techniquement, faire bouger la texture du cube est super simple. C’est encore plus facile que je pensais de mentir au cerveau.





Il suffirait donc d’ajouter une texture, de l’appliquer à notre mesh et de faire bouger cette texture a chaque frame dans la gameloop. Écrivons ça.

// 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()

Ça rend dégueulasse à cause de la compression GIF ici, mais l’illusion de mouvement est là ! Bien plus tard dans le projet je vais me rendre compte que cette façon de faire (bouger la texture) est utilisée partout, par plein de monde. Moi qui pensais avoir inventé quelque chose (lol), ça sera pour un autre jour !

En tout cas, je suis resté à fixer cet effet tunnel pendant longtemps comme un sociopathe. Et c’est à partir de ce moment-là que le plan de faire seulement un exemple pour un article s’arrête. J’ai mille idées qui fusent à la seconde.

On part sur un side project.



To infinity and beyond

Maintenant l’idée est de traverser un univers A, prendre un trou de ver avec plein d’effet pour atterrir dans un univers B. Oui, je suis déjà sur un multivers, j’ai pas le temps.

Je veux aussi un côté cinématique à tout ça, donc ça veut dire une mini histoire (texte) et de la musique ! Ça va être un spectacle cette affaire !

D’abord, il me faut de la couleur ! Des nébuleuses, du gaz, des supernova, de la vie ! Je me suis donc mis à la recherche d’une bonne texture de nébuleuse. Et j’ai trouvé.

Pour tester, j’ai créé un second cylindre et je l’ai mis, exactement, à la même position que le premier en me disant qu’il allait cacher le premier.

Mais autre chose s’est passé!





Les deux cylindres étant exactement au même endroit, les deux se sont affichés en superposition ! Alors non seulement c’est joli, mais en plus de ça donne de la profondeur à tout ça !

Les possibilités viennent une fois de plus de se multiplier devant mes yeux.





Y’avait plus qu’à être créatif !

Maintenant que la traversée du premier univers est pratiquement faite, il est temps de passer au saut en hyperespace !



Post processing

L’idée serait d’avoir un portail brillant à la Stargate au fond du tunnel. Puis d’accélérer brutalement la vitesse de mouvement de la texture. Puis de faire rapprocher le portail brillant lentement pour donner l’impression qu’on parcoure une vraie distance.

Lors de mes recherches pour cette partie, je suis tombé sur le concept de post processing. Le concept est simple, l’image est rendue normalement, mais avant d’être affiché, elle va par un ou plusieurs filtres et effets.

Ça va permettre des choses comme du grain de film, des glitchs, des effets de floraisons ou même des effets lumineux. Intéressant ! Ça veut dire qu’on peut faire une sphère avec un effet lumineux alors ?

Écrivons ça !

// 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()


Bon, ça commence vraiment à avoir de la gueule cette affaire. La technique de post-processing est vraiment en train de transcender ce voyage intersidéral.

En parcourant la doc de la libraire de postprocessing je me rends compte que des effets, y’en a vraiment beaucoup. Et là, je sais pas, je suis devenu fou. J’ai pété un plomb, comme un gros taré, sans réfléchir, j’ai commencé à tous les mettre en même temps.

Je l’ai voulais tous. TOUS. PLUUUSSSSSSS !



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
);


Alors, il s’avère que je vais vite revenir en arrière et ne choisir que deux effets pour le reste du projet. Déjà d’une, parce que tous en même temps c’est trop. Ça pique les yeux, en dirait un feu d’artifice fait par un schizophrène sous acide.

Mais surtout, dans un futur proche, je vais vite me rendre compte que tout ça a un prix niveau performance. Sur ma grosse machine, ça va. Mais quand j’ai commencé à tester sur mon portable, j’ai pleuré du sang.

À la fin du projet, je me suis retrouvé à tout couper pour optimiser la scène. Et même malgré toute l’optimisation du monde que j’ai pu faire sur la scène, j’ai toujours des exemples de personnes pour qui ça rame. Work in progress, il faut que je ship!

Mais bref, c’est pas très intéressant tout ça. Comment j’ai fait l’animation du saut en hyperespace ? Ça c’est intéressant. Et la réponse est simple : place a Tween.JS !



Horizon

La librairie Tween.JS fait une seule chose, mais elle le fait extrêmement bien. Elle prend une valeur dans un objet et le fait passer progressivement à une autre.

Tu vas me dire qu’on peut le faire facilement en vanilla Javascript et tu as raison. Mais Tween.JS a plusieurs choses en plus.

D’abord, les calculs faits pour faire la transition entre les valeurs, complexes ou non, sont extrêmement optimisés en interne.

Ensuite, Tween.JS vient avec plein de méthode très utile comme le « onUpdate » ou le « onComplete » qui va nous permettre de créer des événements à des moments clés de l’animation.

Enfin, Tween.JS vient avec un système de easing. Au lieu d’avoir une animation linéaire ennuyante et non réaliste, on a le droit à un paquet de nuances.

Quand j’ai ouvert la page pour savoir ce que je pouvais faire, j’ai lâché ma petite larme.





En prenant comme paramètres les valeurs d’opacité, de mouvement de textures et de position des cylindres couplés à l’animation via easing de Tween.JS : je fais ce que je veux. Je deviens littéralement un chef d’orchestre d’effet 3D en Javacript.

Faire un saut en hyperespace ? Easy. Écrivons ça.

/**
 * 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())
}


Et voilà ! L’univers est traversé, on a franchi l’horizon d’un trou de ver et on se balade désormais dans un univers parallèle. C’est beau !

Il y a plein de choses dont je ne parle pas dans cet article. Les diverses animations un peu partout. Le logo et l’ui/ux fait par mon ami Arnaud. Ou encore la musique ! L’incroyable musique de Melodysheep que j’ai contacté et qui m’a donné l’autorisation de les utiliser dans mon projet !

Comment j’ai synchronisé la musique avec les animations et plein d’autres questions seront répondues en regardant le code source du projet.

C’est un projet open source, tu veux participer ? Si tu vois un bug, un souci de performance ou une amélioration quelconque, envoie-moi une PR. J’ai l’approval facile.



Épilogue

Je ne pense pas que j’ai eu autant de fun sur un projet depuis longtemps. Quel plaisir ! S’il y a du monde qui passe sur le site, je ferai la suite. Si ya personne, je pense que je ferai une suite quand même. C’était trop jouissif pour que je m’arrête là !

Qui me parle ?

jesuisundev
Je suis un dev. En ce moment, je suis développeur backend senior / DevOps à Montréal pour un géant du jeux vidéo. Le dev est l'une de mes passions et j'écris comme je parle. Je continue à te parler quotidiennement sur mon Twitter. Tu peux m'insulter à cet e-mail ou le faire directement dans les commentaires juste en dessous. Y'a même une newsletter !

Pour me soutenir, la boutique officielle est disponible ! Sinon désactiver le bloqueur de pub et/ou utiliser les liens affiliés dans les articles, ça m'aide aussi.

11 commentaires sur “Comment traverser l’univers en Javascript”

  1. Bienvenue dans le monde du Front ! Un pays où on fait des trucs…juste parce que c’est beau ! B-)
    Je me suis bien marré en lisant cet article, merci !

  2. C’est magnifique !! Je verrais bien ça comme fond d’écran animé !
    En tout cas, pour un dev comme moi qui débute, je savais pas qu’il était possible de faire des choses aussi impressionnante en HTML + CSS + JS.
    C’est intéressant de suivre ton raisonnement tout au long du processus, de voir où tu vas trouver tes solutions face aux problème.
    Bravo et merci pour le partage !

  3. Super travail, super article, et j’ai hâte de voir la suite !
    Et puis pour les éléphants comme moi qui utilisent Firefox, ça fait plaisir parce que ça rend mieux sous Firefox que sous Chrome !

T'en penses quoi ?

Your email address will not be published. Required fields are marked *