Building a Physics-based 3D Menu with Cannon.js and Three.js | Codrops (2024)

DemoCode

Yeah, shaders are good but have you ever heard of physics?

Nowadays, modern browsers are able to run an entire game in 2D or 3D. It means we can push the boundaries of modern web experiences to a more engaging level. The recent portfolio of Bruno Simon, in which you can play a toy car, is the perfect example of that new kind of playful experience. He used Cannon.js and Three.js but there are other physics libraries like Ammo.js or Oimo.js for 3D rendering, or Matter.js for 2D.

After months of hard but fun work, I'm glad to finally show you my new portfolio 🚗https://t.co/rVPv9oVMud

Made with #threeJS and #canonJS pic.twitter.com/zrq8rpILq1

— Bruno Simon (@bruno_simon) October 24, 2019

In this tutorial, we’ll see how to use Cannon.js as a physics engine and render it with Three.js in a list of elements within the DOM. I’ll assume you are comfortable with Three.js and know how to set up a complete scene.

Prepare the DOM

This part is optional but I like to manage my JS with HTML or CSS. We just need the list of elements in our nav:

<nav class="mainNav | visually-hidden"> <ul> <li><a href="#">Watermelon</a></li> <li><a href="#">Banana</a></li> <li><a href="#">Strawberry</a></li> </ul></nav><canvas id="stage"></canvas>

Prepare the scene

Let’s have a look at the important bits. In my Class, I call a method “setup” to init all my components. The other method we need to check is “setCamera” in which I use an Orthographic Camera with a distance of 15. The distance is important because all of our variables we’ll use further are based on this scale. You don’t want to work with too big numbers in order to keep it simple.

// Scene.jsimport Menu from "./Menu";// ...export default class Scene { // ... setup() { // Set Three components this.scene = new THREE.Scene() this.scene.fog = new THREE.Fog(0x202533, -1, 100) this.clock = new THREE.Clock() // Set options of our scene this.setCamera() this.setLights() this.setRender() this.addObjects() this.renderer.setAnimationLoop(() => { this.draw() }) } setCamera() { const aspect = window.innerWidth / window.innerHeight const distance = 15 this.camera = new THREE.OrthographicCamera(-distance * aspect, distance * aspect, distance, -distance, -1, 100) this.camera.position.set(-10, 10, 10) this.camera.lookAt(new THREE.Vector3()) } draw() { this.renderer.render(this.scene, this.camera) } addObjects() { this.menu = new Menu(this.scene) } // ...}

Create the visible menu

Basically, we will parse all our elements in our menu, create a group in which we will initiate a new mesh for each letter at the origin position. As we’ll see later, we’ll manage the position and rotation of our mesh based on its rigid body.

If you don’t know how creating text in Three.js works, I encourage you to read the documentation. Moreover, if you want to use a custom font, you should check out facetype.js.

In my case, I’m loading a Typeface JSON file.

// Menu.jsexport default class Menu { constructor(scene) { // DOM elements this.$navItems = document.querySelectorAll(".mainNav a"); // Three components this.scene = scene; this.loader = new THREE.FontLoader(); // Constants this.words = []; this.loader.load(fontURL, f => { this.setup(f); }); } setup(f) { // These options give us a more candy-ish render on the font const fontOption = { font: f, size: 3, height: 0.4, curveSegments: 24, bevelEnabled: true, bevelThickness: 0.9, bevelSize: 0.3, bevelOffset: 0, bevelSegments: 10 }; // For each element in the menu... Array.from(this.$navItems) .reverse() .forEach(($item, i) => { // ... get the text ... const { innerText } = $item; const words = new THREE.Group(); // ... and parse each letter to generate a mesh Array.from(innerText).forEach((letter, j) => { const material = new THREE.MeshPhongMaterial({ color: 0x97df5e }); const geometry = new THREE.TextBufferGeometry(letter, fontOption); const mesh = new THREE.Mesh(geometry, material); words.add(mesh); }); this.words.push(words); this.scene.add(words); }); }}

Building a physical world

Cannon.js uses the loop of render of Three.js to calculate the forces that rigid bodies sustain between each frame. We decide to set a global force you probably already know: gravity.

// Scene.jsimport C from 'cannon'// 
setup() { // Init Physics world this.world = new C.World() this.world.gravity.set(0, -50, 0) // 
 }// 
 addObjects() { // We now need to pass the world of physic as an argument this.menu = new Menu(this.scene, this.world);}draw() { // Create our method to update the physic this.updatePhysics(); this.renderer.render(this.scene, this.camera);}updatePhysics() { // We need this to synchronize three meshes and Cannon.js rigid bodies this.menu.update() // As simple as that! this.world.step(1 / 60);}// 


As you see, we set the gravity of -50 on the Y-axis. It means that all our bodies will undergo a force of -50 each frame to the infinite until they encounter another body or the floor. Notice that if we change the scale of our elements or the distance number of our camera, we need to also adjust the gravity number.

Rigid bodies

Rigid bodies are simpler invisible shapes used to represent our meshes in the physical world. Usually, their meshes are way more elementary than our rendered mesh because the fewer vertices we have to calculate, the faster it is.

Note that “soft bodies” also exist. It represents all the bodies that undergo a distortion of their mesh because of other forces (like other objects pushing them or simply gravity affecting them).

For our purpose, we will create a simple box for each letter of their size, and place them in the correct position.

There are a lot of things to update in Menu.js so let’s look at every part.

First, we need two more constants:

// Menu.js// It will calculate the Y offset between each element.const margin = 6;// And this constant is to keep the same total mass on each word. We don't want a small word to be lighter than the others. const totalMass = 1;

The totalMass will involve the friction on the ground and the force we’ll apply later. At this moment, “1” is enough.

// 
export default class Menu { constructor(scene, world) { // 
 this.world = world this.offset = this.$navItems.length * margin * 0.5; } setup(f) { // 
 Array.from(this.$navItems).reverse().forEach(($item, i) => { // 
 words.letterOff = 0; Array.from(innerText).forEach((letter, j) => { const material = new THREE.MeshPhongMaterial({ color: 0x97df5e }); const geometry = new THREE.TextBufferGeometry(letter, fontOption); geometry.computeBoundingBox(); geometry.computeBoundingSphere(); const mesh = new THREE.Mesh(geometry, material); // Get size of our entire mesh mesh.size = mesh.geometry.boundingBox.getSize(new THREE.Vector3()); // We'll use this accumulator to get the offset of each letter. Notice that this is not perfect because each character of each font has specific kerning. words.letterOff += mesh.size.x; // Create the shape of our letter // Note that we need to scale down our geometry because of Box's Cannon.js class setup const box = new C.Box(new C.Vec3().copy(mesh.size).scale(0.5)); // Attach the body directly to the mesh mesh.body = new C.Body({ // We divide the totalmass by the length of the string to have a common weight for each words. mass: totalMass / innerText.length, position: new C.Vec3(words.letterOff, this.getOffsetY(i), 0) }); // Add the shape to the body and offset it to match the center of our mesh const { center } = mesh.geometry.boundingSphere; mesh.body.addShape(box, new C.Vec3(center.x, center.y, center.z)); // Add the body to our world this.world.addBody(mesh.body); words.add(mesh); }); // Recenter each body based on the whole string. words.children.forEach(letter => { letter.body.position.x -= letter.size.x + words.letterOff * 0.5; }); // Same as before this.words.push(words); this.scene.add(words); }) } // Function that return the exact offset to center our menu in the scene getOffsetY(i) { return (this.$navItems.length - i - 1) * margin - this.offset; } // ...}

You should have your menu centered in your scene, falling to the infinite and beyond. Let’s create the ground of each element of our menu in our words loop:

// 
words.ground = new C.Body({ mass: 0, shape: new C.Box(new C.Vec3(50, 0.1, 50)), position: new C.Vec3(0, i * margin - this.offset, 0)});this.world.addBody(words.ground);// 
 

A shape called “Plane” exists in Cannon. It represents a mathematical plane, facing up the Z-axis and usually used as ground. Unfortunately, it doesn’t work with superposed grounds. Using a box is probably the easiest way to make the ground in this case.

Interaction with the physical world

We have an entire world of physics beneath our fingers but how to interact with it?

We calculate the mouse position and on each click, cast a ray (raycaster) towards our camera. It will return the objects the ray is passing through with more information, like the contact point but also the face and its normal.

Normals are perpendicular vectors of each vertex and faces of a mesh:

Building a Physics-based 3D Menu with Cannon.js and Three.js | Codrops (2)

We will get the clicked face, get the normal and reverse and multiply by a constant we have defined. Finally, we’ll apply this vector to our clicked body to give an impulse.

To make it easier to understand and read, we will pass a 3rd argument to our menu, the camera.

// Scene.jsthis.menu = new Menu(this.scene, this.world, this.camera);
// Menu.js// A new constant for our global force on clickconst force = 25;constructor(scene, world, camera) { this.camera = camera; this.mouse = new THREE.Vector2(); this.raycaster = new THREE.Raycaster(); // Bind events document.addEventListener("click", () => { this.onClick(); }); window.addEventListener("mousemove", e => { this.onMouseMove(e); });}onMouseMove(event) { // We set the normalized coordinate of the mouse this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1; this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;}onClick() { // update the picking ray with the camera and mouse position this.raycaster.setFromCamera(this.mouse, this.camera); // calculate objects intersecting the picking ray // It will return an array with intersecting objects const intersects = this.raycaster.intersectObjects( this.scene.children, true ); if (intersects.length > 0) { const obj = intersects[0]; const { object, face } = obj; if (!object.isMesh) return; const impulse = new THREE.Vector3() .copy(face.normal) .negate() .multiplyScalar(force); this.words.forEach((word, i) => { word.children.forEach(letter => { const { body } = letter; if (letter !== object) return; // We apply the vector 'impulse' on the base of our body body.applyLocalImpulse(impulse, new C.Vec3()); }); }); }}

Constraints and connections

As you can see at the moment, you can punch each letter like the superman or superwoman you are. But even if this is already looking cool, we can still do better by connecting every letter between them. In Cannon, it’s called constraints. This is probably the most satisfying thing with using physics.

// Menu.jssetup() { // At the end of this method this.setConstraints()}setConstraints() { this.words.forEach(word => { for (let i = 0; i < word.children.length; i++) { // We get the current letter and the next letter (if it's not the penultimate) const letter = word.children[i]; const nextLetter = i === word.children.length - 1 ? null : word.children[i + 1]; if (!nextLetter) continue; // I choosed ConeTwistConstraint because it's more rigid that other constraints and it goes well for my purpose const c = new C.ConeTwistConstraint(letter.body, nextLetter.body, { pivotA: new C.Vec3(letter.size.x, 0, 0), pivotB: new C.Vec3(0, 0, 0) }); // Optionnal but it gives us a more realistic render in my opinion c.collideConnected = true; this.world.addConstraint(c); } });}

To correctly explain how these pivots work, check out the following figure:

Building a Physics-based 3D Menu with Cannon.js and Three.js | Codrops (3)

(letter.mesh.size, 0, 0) is the origin of the next letter.

Remove the sandpaper on the floor

As you have probably noticed, it seems like our ground is made of sandpaper. That’s something we can change. In Cannon, there are materials just like in Three. Except that these materials are physic-based. Basically, in a material, you can set the friction and the restitution of a material. Are our letters made of rock, or rubber? Or are they maybe slippy?

Moreover, we can define the contact material. It means that if I want my letters to be slippy between each other but bouncy with the ground, I could do that. In our case, we want a letter to slip when we punch it.

// In the beginning of my setup method I declare theseconst groundMat = new C.Material();const letterMat = new C.Material();const contactMaterial = new C.ContactMaterial(groundMat, letterMat, { friction: 0.01});this.world.addContactMaterial(contactMaterial);

Then we set the materials to their respective bodies:

// ...words.ground = new C.Body({ mass: 0, shape: new C.Box(new C.Vec3(50, 0.1, 50)), position: new C.Vec3(0, i * margin - this.offset, 0), material: groundMat});// ...mesh.body = new C.Body({ mass: totalMass / innerText.length, position: new C.Vec3(words.letterOff, this.getOffsetY(i), 0), material: letterMat});// ...

Tada! You can push it like the Rocky you are.

Final words

I hope you have enjoyed this tutorial! I have the feeling that we’ve reached the point where we can push interfaces to behave more realistically and be more playful and enjoyable. Today we’ve explored a physics-powered menu that reacts to forces using Cannon.js and Three.js. We can also think of other use cases, like images that behave like cloth and get distorted by a click or similar.

Cannon.js is very powerful. I encourage you to check out all the examples, share, comment and give some love and don’t forget to check out all the demos!

Building a Physics-based 3D Menu with Cannon.js and Three.js | Codrops (2024)
Top Articles
Latest Posts
Recommended Articles
Article information

Author: Mrs. Angelic Larkin

Last Updated:

Views: 6236

Rating: 4.7 / 5 (47 voted)

Reviews: 94% of readers found this page helpful

Author information

Name: Mrs. Angelic Larkin

Birthday: 1992-06-28

Address: Apt. 413 8275 Mueller Overpass, South Magnolia, IA 99527-6023

Phone: +6824704719725

Job: District Real-Estate Facilitator

Hobby: Letterboxing, Vacation, Poi, Homebrewing, Mountain biking, Slacklining, Cabaret

Introduction: My name is Mrs. Angelic Larkin, I am a cute, charming, funny, determined, inexpensive, joyous, cheerful person who loves writing and wants to share my knowledge and understanding with you.