How to Create a Physics-based 3D Cloth with Cannon.js and Three.js

Following my previous experiment where I’ve showed you how to build a 3D physics-based menu, let’s now take a look at how to turn an image into a cloth-like material that gets distorted by wind using Cannon.js and Three.js.

In this tutorial, we’ll assume that you’re comfortable with Three.js and understand the basic principles of the Cannon.js library. If you aren’t, take a look at my previous tutorial about Cannon and how to create a simple world using this 3D engine.

Before we begin, take a look at the demo that shows a concrete example of a slideshow that uses the cloth effect I’m going to explain. The slideshow in the demo is based on Jesper Landberg’s Infinite draggable WebGL slider.

Preparing the DOM, the scene and the figure

I’m going to start with an example from one of my previous tutorials. I’m using DOM elements to re-create the plane in my scene. All the styles and positions are set in CSS and re-created in the canvas with JavaScript. I just cleaned some stuff I don’t use anymore (like the data-attributes) but the logic is still the same:

// index.html
<section class="container">
    <article class="tile">
        <figure class="tile__figure">
            <img src="path/to/my/image.jpg" 
                class="tile__image" alt="My image" width="400" 
                height="300" />
        </figure>
    </article>
</section>

And here we go:

Creating the physics world and update existing stuff

We’ll update our Scene.js file to add the physics calculation and pass the physics World as an argument to the Figure object:

// Scene.js’s constructor
this.world = new C.World();
this.world.gravity.set(0, -1000, 0);

For this example, I’m using a large number for gravity because I’m working with big sized objects.

// Scene.js’s constructor
this.figure = new Figure(this.scene, this.world);

// Scene.js's update method
this.world.step(1 / 60);

// We’ll see this below!
this.figure.update()

Let’s do some sewing

In the last tutorial on Cannon, I talked about rigid bodies. As its name suggests, you give an entire object a shape that will never be distorted. In this example, I will not use rigid bodies but soft bodies. I’ll create a new body per vertex, give it a mass and connect them to recreate the full mesh. After that, like with the rigid bodies, I copy each Three vertices’ position with Cannon’s body position and voilà!

Let’s start by updating the subdivision segments of the mesh with a local variable “size”: 

const size = 8;

export default class Figure {
    constructor(scene, world) {
    this.world = world

//…

// Createmesh method
this.geometry = new THREE.PlaneBufferGeometry(1, 1, size, size);

Then, we add a new method in our Figure Class called “CreateStitches()” that we’ll call it just after the createMesh() method. The order is important because we’ll use each vertex coordinate to set the base position of our bodies.

Creating the soft body

Because I’m using a BufferGeometry rather than Geometry, I have to loop through the position attributes array based on the count value. It limits the number of iterations through the whole array and improves performances. Three.js provides methods that return the correct value based on the index.

createStitches() {
    // We don't want a sphere nor a cube for each point of our cloth. Cannon provides the Particle() object, a shape with ... no shape at all!
    const particleShape = new C.Particle();
    
    const { position } = this.geometry.attributes;
    const { x: width, y: height } = this.sizes;

    this.stitches = [];

    for (let i = 0; i < position.count; i++) {

      const pos = new C.Vec3(
        position.getX(i) * width,
        position.getY(i) * height,
        position.getZ(i)
      );

      const stitch = new C.Body({
          
        // We divide the mass of our body by the total number of points in our mesh. This way, an object with a lot of vertices doesn’t have a bigger mass. 
        mass: mass / position.count,
        
        // Just for a smooth rendering, you can drop this line but your cloth will move almost infinitely.
        linearDamping: 0.8,
        
        position: pos,
        shape: particleShape,

        // TEMP, we’ll delete later
        velocity: new C.Vec3(0, 0, -300)
      });

      this.stitches.push(stitch);
      this.world.addBody(stitch);
    }
}

Notice that we multiply by the size of our mesh. That’s because, in the beginning, we set the size of our plane to a size of 1. So each vertex has its coordinates normalized and we have to multiply them afterwards.

Updating the mesh

As we need to set our position in normalized coordinates, we have to divide by the width and height values and set it to the bufferAttribute.

// Figure.js
update() {
    const { position } = this.geometry.attributes;
    const { x: width, y: height } = this.sizes;

    for (let i = 0; i < position.count; i++) {
      position.setXYZ(
        i,
        this.stitches[i].position.x / width,
        this.stitches[i].position.y / height,
        this.stitches[i].position.z
      );
    }

    position.needsUpdate = true;
}

And voilà! Now you should have a falling bunch of unconnected points. Let’s change that by just setting the first row of our stitches to a mass of zero.

for (let i = 0; i < position.count; i++) {
      const row = Math.floor(i / (size + 1));

// ...

const stitch = new C.Body({
    mass: row === 0 ? 0 : mass / position.count,

// ...

I guess you noticed I increased the size plus one. Let’s take a look at the wireframe of our mesh:

As you can notice, when we set the number of segments with the ‘size’ variable, we have the correct number of subdivisions. But we are working on the mesh so we have one more row and column. By the way, if you inspect the count value we used above, we have 81 vertices (9*9), not 64 (8*8).

Connecting everything

Now, you should have a falling bunch of points falling down but not the first line! We have to create a DistanceConstraint from each point to their neighbour.

// createStitches()
for (let i = 0; i < position.count; i++) {
    const col = i % (size + 1);
    const row = Math.floor(i / (size + 1));

    if (col < size) this.connect(i, i + 1);
    if (row < size) this.connect(i, i + size + 1);
}

// New method in Figure.js
connect(i, j) {
    const c = new C.DistanceConstraint(this.stitches[i], this.stitches[j]);

    this.world.addConstraint(c);
}

And tadam! You now have a cloth floating within the void. Because of the velocity we set before, you can see the mesh moves but stops quickly. It’s the calm before the storm.

Let the wind blow

Now that we have a cloth, why not let a bit of wind blow? I’m going to create an array with the length of our mesh and fill it with a direction vector based on the position of my mouse multiplied by a force using simplex noise. Psst, if you have never heard of noise, I suggest reading this article.

We could imagine the noise looking like this image, except where we have angles in each cell, we’ll have a force between -1 and 1 in our case.

https://lramrz.com/2016/12/flow-field-in-p5js/

After that, we’ll add the forces of each cell on their respective body and the update function will do the rest.

Let’s dive into the code!

I’m going to create a new class called Wind in which I’m passing the figure as a parameter.

// First, I'm going to set 2 local constants
const baseForce = 2000;
const off = 0.05;

export default class Wind {
    constructor(figure) {
        const { count } = figure.geometry.attributes.position;
        this.figure = figure;
        
        // Like the mass, I don't want to have too much forces applied because of a large amount of vertices
        this.force = baseForce / count;

        // We'll use the clock to increase the wind movement
        this.clock = new Clock();

        // Just a base direction
        this.direction = new Vector3(0.5, 0, -1);
        
        // My array 
        this.flowfield = new Array(count);

        // Where all will happen!
        this.update()
    }
}
update() {
    const time = this.clock.getElapsedTime();

    const { position } = this.figure.geometry.attributes;
    const size = this.figure.geometry.parameters.widthSegments;

    for (let i = 0; i < position.count; i++) {
        const col = i % (size + 1);
        const row = Math.floor(i / (size + 1));

        const force = (noise.noise3D(row * off, col * off, time) * 0.5 + 0.5) * this.force;

        this.flowfield[i] = this.direction.clone().multiplyScalar(force);
    }
}

The only purpose of this object is to update the array values with noise in each frame so we need to amend Scene.js with a few new things.

// Scene.js 
this.wind = new Wind(this.figure.mesh);

// ...

update() {
// ...
    this.wind.update();
    this.figure.update();
// ...
}

And before continuing, I’ll add a new method in my update method after the figure.update():

this.figure.applyWind(this.wind);

Let’s write this new method in Figure.js:

// Figure.js constructor
// To help performance, I will avoid creating a new instance of vector each frame so I'm setting a single vector I'm going to reuse.
this.bufferV = new C.Vec3();

// New method
applyWind(wind) {
    const { position } = this.geometry.attributes;

    for (let i = 0; i < position.count; i++) {
        const stitch = this.stitches[i];

        const windNoise = wind.flowfield[i];
        const tempPosPhysic = this.bufferV.set(
            windNoise.x,
            windNoise.y,
            windNoise.z
        );

        stitch.applyForce(tempPosPhysic, C.Vec3.ZERO);
    }
}

Congratulation, you have created wind, Mother Nature would be proud! But the wind blows in the same direction. Let’s change that in Wind.js by updating our direction with the mouse position.

window.addEventListener("mousemove", this.onMouseMove.bind(this));

onMouseMove({ clientX: x, clientY: y }) {
    const { innerWidth: W, innerHeight: H } = window;

    gsap.to(this.direction, {
        duration: 0.8,
        x: x / W - 0.5,
        y: -(y / H) + 0.5
    });
}

Conclusion

I hope you enjoyed this tutorial and that it gave you some ideas on how to bring a new dimension to your interaction effects. Don’t forget to take a look at the demo, it’s a more concrete case of a slideshow where you can see this effect in action. 

Don’t hesitate to let me know if there’s anything not clear, feel free to contact me on Twitter @aqro.

Cheers!

How to Create a Physics-based 3D Cloth with Cannon.js and Three.js was written by Arno Di Nunzio and published on Codrops.

Building a Physics-based 3D Menu with Cannon.js and Three.js

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. 

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.js

import 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.js

export 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.js

import 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:

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.js
this.menu = new Menu(this.scene, this.world, this.camera);
// Menu.js
// A new constant for our global force on click
const 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.js

setup() {
    // 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:

(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 these
const 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 was written by Arno Di Nunzio and published on Codrops.

Making Gooey Image Hover Effects with Three.js

Flash’s grandson, WebGL has become more and more popular over the last few years with libraries like Three.js, PIXI.js or the recent OGL.js. Those are very useful for easily creating a blank board where the only boundaries are your imagination. We see more and more, often subtle integration of WebGL in an interface for hover, scroll or reveal effects. Examples are the gallery of articles on Hello Monday or the effects seen on cobosrl.co.

In this tutorial, we’ll use Three.js to create a special gooey texture that we’ll use to reveal another image when hovering one. Head over to the demo to see the effect in action. For the demo itself, I’ve created a more practical example that shows a vertical scrollable layout with images, where each one has a variation of the effect. You can click on an image and it will expand to a larger version while some other content shows up (just a mock-up). We’ll go over the most interesting parts of the effect, so that you get an understanding of how it works and how to create your own.

I’ll assume that you are comfortable with JavaScript and have some knowledge of Three.js and shader logic. If you’re not, have a look at the Three.js documentation or The Book of Shaders, Three.js Fundamentals or Discover Three.js.

Attention: This tutorial covers many parts; if you prefer, you can skip the HTML/CSS/JavaScript part and go directly go to the shaders section.

Now that we are clear, let’s do this!

Create the scene in the DOM

Before we start making some magic, we are first going to mark up the images in the HTML. It will be easier to handle resizing our scene after we’ve set up the initial position and dimension in HTML/CSS rather than positioning everything in JavaScript. Moreover, the styling part should be only made with CSS, not JavaScript. For example, if our image has a ratio of 16:9 on desktop but a 4:3 ratio on mobile, we just want to handle this using CSS. JavaScript will only get the new values and do its stuff.

// index.html

<section class="container">
	<article class="tile">
		<figure class="tile__figure">
			<img data-src="path/to/my/image.jpg" data-hover="path/to/my/hover-image.jpg" class="tile__image" alt="My image" width="400" height="300" />
		</figure>
	</article>
</section>

<canvas id="stage"></canvas>
// style.css

.container {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 100%;
	height: 100vh;
	z-index: 10;
}

.tile {
	width: 35vw;
	flex: 0 0 auto;
}

.tile__image {
	width: 100%;
	height: 100%;
	object-fit: cover;
	object-position: center;
}

canvas {
	position: fixed;
	left: 0;
	top: 0;
	width: 100%;
	height: 100vh;
	z-index: 9;
}

As you can see above, we have create a single image that is centered in the middle of our screen. Did you notice the data-src and data-hover attributes on the image? These will be our reference images and we’ll load both of these later in our script with lazy loading.

Don’t forget the canvas. We’ll stack it below our main section to draw the images in the exact same place as we have placed them before.

Create the scene in JavaScript

Let’s get started with the less-easy-but-ok part! First, we’ll create the scene, the lights, and the renderer.

// Scene.js

import * as THREE from 'three'

export default class Scene {
	constructor() {
		this.container = document.getElementById('stage')

		this.scene = new THREE.Scene()
		this.renderer = new THREE.WebGLRenderer({
			canvas: this.container,
			alpha: true,
	  })

		this.renderer.setSize(window.innerWidth, window.innerHeight)
		this.renderer.setPixelRatio(window.devicePixelRatio)

		this.initLights()
	}

	initLights() {
		const ambientlight = new THREE.AmbientLight(0xffffff, 2)
		this.scene.add(ambientlight)
	}
}

This is a very basic scene. But we need one more essential thing in our scene: the camera. We have a choice between two types of cameras: orthographic or perspective. If we keep our image flat, we can use the first one. But for our rotation effect, we want some perspective as we move the mouse around.

In Three.js (and other libraries for WebGL) with a perspective camera, 10 unit values on our screen are not 10px. So the trick here is to use some math to transform 1 unit to 1 pixel and change the perspective to increase or decrease the distortion effect.

// Scene.js

const perspective = 800

constructor() {
	// ...
	this.initCamera()
}

initCamera() {
	const fov = (180 * (2 * Math.atan(window.innerHeight / 2 / perspective))) / Math.PI

	this.camera = new THREE.PerspectiveCamera(fov, window.innerWidth / window.innerHeight, 1, 1000)
	this.camera.position.set(0, 0, perspective)
}

We’ll set the perspective to 800 to have a not-so-strong distortion as we rotate the plane. The more we increase the perspective, the less we’ll perceive the distortion, and vice versa.

The last thing we need to do is to render our scene in each frame.

// Scene.js

constructor() {
	// ...
	this.update()
}

update() {
	requestAnimationFrame(this.update.bind(this))
	
	this.renderer.render(this.scene, this.camera)
}

If your screen is not black, you are on the right way!

Build the plane with the correct sizes

As we mentioned above, we have to retrieve some additional information from the image in the DOM like its dimension and position on the page.

// Scene.js

import Figure from './Figure'

constructor() {
	// ...
	this.figure = new Figure(this.scene)
}
// Figure.js

export default class Figure {
	constructor(scene) {
		this.$image = document.querySelector('.tile__image')
		this.scene = scene

		this.loader = new THREE.TextureLoader()

		this.image = this.loader.load(this.$image.dataset.src)
		this.hoverImage = this.loader.load(this.$image.dataset.hover)
		this.sizes = new THREE.Vector2(0, 0)
		this.offset = new THREE.Vector2(0, 0)

		this.getSizes()

		this.createMesh()
	}
}

First, we create another class where we pass the scene as a property. We set two new vectors, dimension and offset, in which we’ll store the dimension and position of our DOM image.

Furthermore, we’ll use a TextureLoader to “load” our images and convert them into a texture. We need to do that as we want to use these pictures in our shaders.

We need to create a method in our class to handle the loading of our images and wait for a callback. We could achieve that with an async function but for this tutorial, let’s keep it simple. Just keep in mind that you’ll probably need to refactor this a bit for your own purposes.

// Figure.js

// ...
	getSizes() {
		const { width, height, top, left } = this.$image.getBoundingClientRect()

		this.sizes.set(width, height)
		this.offset.set(left - window.innerWidth / 2 + width / 2, -top + window.innerHeight / 2 - height / 2)
	}
// ...

We get our image information in the getBoundingClientRect object. After that, we’ll pass these to our two variables. The offset is here to calculate the distance between the center of the screen and the object on the page.

// Figure.js

// ...
	createMesh() {
		this.geometry = new THREE.PlaneBufferGeometry(1, 1, 1, 1)
		this.material = new THREE.MeshBasicMaterial({
			map: this.image
		})

		this.mesh = new THREE.Mesh(this.geometry, this.material)

		this.mesh.position.set(this.offset.x, this.offset.y, 0)
		this.mesh.scale.set(this.sizes.x, this.sizes.y, 1)

		this.scene.add(this.mesh)
	}
// ...

After that, we’ll set our values on the plane we’re building. As you can notice, we have created a plane of 1 on 1px with 1 row and 1 column. As we don’t want to distort the plane, we don’t need a lot of faces or vertices. So let’s keep it simple.

But why scale it while we can set the size directly? Glad you asked.

Because of the resizing part. If we want to change the size of our mesh afterwards, there is no other proper way than this one. While it’s easier to change the scale of the mesh, it’s not for the dimension.

For the moment, we set a MeshBasicMaterial, just to see if everything is fine.

Get mouse coordinates

Now that we have built our scene with our mesh, we want to get our mouse coordinates and, to keep things easy, we’ll normalize them. Why normalize? Because of the coordinate system in shaders.

coordinate-system

As you can see in the figure above, we have normalized the values for both of our shaders. So to keep things simple, we’ll prepare our mouse coordinate to match the vertex shader coordinate.

If you’re lost at this point, I recommend you to read the Book of Shaders and the respective part of Three.js Fundamentals. Both have good advice and a lot of examples to help understand what’s going on.

// Figure.js

// ...

this.mouse = new THREE.Vector2(0, 0)
window.addEventListener('mousemove', (ev) => { this.onMouseMove(ev) })

// ...

onMouseMove(event) {
	TweenMax.to(this.mouse, 0.5, {
		x: (event.clientX / window.innerWidth) * 2 - 1,
		y: -(event.clientY / window.innerHeight) * 2 + 1,
	})

	TweenMax.to(this.mesh.rotation, 0.5, {
		x: -this.mouse.y * 0.3,
		y: this.mouse.x * (Math.PI / 6)
	})
}

For the tween parts, I’m going to use TweenMax from GreenSock. This is the best library ever. EVER. And it’s perfect for our purpose. We don’t need to handle the transition between two states, TweenMax will do it for us. Each time we move our mouse, TweenMax will update the position and the rotation smoothly.

One last thing before we continue: we’ll update our material from MeshBasicMaterial to ShaderMaterial and pass some values (uniforms) and shaders.

// Figure.js

// ...

this.uniforms = {
	u_image: { type: 't', value: this.image },
	u_imagehover: { type: 't', value: this.hover },
	u_mouse: { value: this.mouse },
	u_time: { value: 0 },
	u_res: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
}

this.material = new THREE.ShaderMaterial({
	uniforms: this.uniforms,
	vertexShader: vertexShader,
	fragmentShader: fragmentShader
})

update() {
	this.uniforms.u_time.value += 0.01
}

We passed our two textures, the mouse position, the size of our screen and a variable called u_time which we will increment each frame.

But keep in mind that it’s not the best way to do that. For example, we only need to increment when we are hovering the figure, not every frame. I’m not going into details, but performance-wise, it’s better to just update our shader only when we need it.

The logic behind the trick & how to use noise

Still here? Nice! Time for some magic tricks.

I will not explain what noise is and where it comes from. If you’re interested, be sure to read this page from The Book of Shaders. It’s well explained.

Long story short, Noise is a function that gives us a value between -1 and 1 based on values we pass through. It will output a random pattern but more organic.

Thanks to noise, we can generate a lot of different shapes, like maps, random patterns, etc.

noise-example1

noise-example2

Let’s start with a 2D noise result. Just by passing the coordinate of our texture, we’ll have something like a cloud texture.

noise-result1

But there are several kinds of noise functions. Let’s use a 3D noise by giving one more parameter like … the time? The noise pattern will evolve and change over time. By changing the frequency and the amplitude, we can give some movement and increase the contrast.

It will be our first base.

noise-result2

Second, we’ll create a circle. It’s quite easy to build a simple shape like a circle in the fragment shader. We just take the function from The Book of Shaders: Shapes to create a blurred circle, increase the contrast and voilà!

noise-result3

Last, we add these two together, play with some variables, cut a “slice” of this and tadaaa:

noise-result4

We finally mix our textures together based on this result and here we are, easy peasy lemon squeezy!

Let’s dive into the code.

Shaders

We won’t really need the vertex shader here so this is our code:

 // vertexShader.glsl
varying vec2 v_uv;

void main() {
	v_uv = uv;

	gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

ShaderMaterial from Three.js provides some useful default variables when you’re a beginner:

  • position (vec3): the coordinates of each vertex of our mesh
  • uv (vec2): the coordinates of our texture
  • normals (vec3): normal of each vertex our mesh have.

Here we’re just passing the UV coordinates from the vertex shader to fragment shader.

Create the circle

Let’s use the function from The Book of Shaders to build our circle and add a variable to handle the blurriness of our edges.

Moreover, we’ll add the mouse position to the origin of our circle. This way, the circle will be moving as long as we move our mouse over our image.

// fragmentShader.glsl
uniform vec2 u_mouse;
uniform vec2 u_res;

float circle(in vec2 _st, in float _radius, in float blurriness){
	vec2 dist = _st;
	return 1.-smoothstep(_radius-(_radius*blurriness), _radius+(_radius*blurriness), dot(dist,dist)*4.0);
}

void main() {
	vec2 st = gl_FragCoord.xy / u_res.xy - vec2(1.);
	// tip: use the following formula to keep the good ratio of your coordinates
	st.y *= u_res.y / u_res.x;

	vec2 mouse = u_mouse;
	// tip2: do the same for your mouse
	mouse.y *= u_res.y / u_res.x;
	mouse *= -1.;
	
	vec2 circlePos = st + mouse;
	float c = circle(circlePos, .03, 2.);

	gl_FragColor = vec4(vec3(c), 1.);
}

Make some noooooise

As we saw above, the noise function has several parameters and gives us a smooth cloudy pattern. How could we have that? Glad you asked.

For this part, I’m using glslify and glsl-noise, and two npm packages to include other functions. It keeps our shader a little bit more readable and avoids having a lot of displayed functions that we will not use after all.

// fragmentShader.glsl
#pragma glslify: snoise2 = require('glsl-noise/simplex/2d')

//...

varying vec2 v_uv;

uniform float u_time;

void main() {
	// ...

	float n = snoise2(vec2(v_uv.x, v_uv.y));

	gl_FragColor = vec4(vec3(n), 1.);
}

noise-result5

By changing the amplitude and the frequency of our noise (exactly like the sin/cos functions), we can change the render.

// fragmentShader.glsl

float offx = v_uv.x + sin(v_uv.y + u_time * .1);
float offy = v_uv.y - u_time * 0.1 - cos(u_time * .001) * .01;

float n = snoise2(vec2(offx, offy) * 5.) * 1.;

noise-result6

But it isn’t evolving through time! It is distorted but that’s it. We want more. So we will use noise3d instead and pass a 3rd parameter: the time.

float n = snoise3(vec3(offx, offy, u_time * .1) * 4.) * .5;

As you can see, I changed the amplitude and the frequency to have the render I desire.

Alright, let’s add them together!

Merging both textures

By just adding these together, we’ll already see an interesting shape changing through time.

noise-result7

To explain what’s happening, let’s imagine our noise is like a sea floating between -1 and 1. But our screen can’t display negative color or pixels more than 1 (pure white) so we are just seeing the values between 0 and 1.

explanation-noise1

And our circle is like a flan.

explanation-noise2

By adding these two shapes together it will give this very approximative result:

explanation-noise3

Our very white pixels are only pixels outside the visible spectrum.

If we scale down our noise and subtract a small number, it will be completely moving down your waves until it disappears above the surface of the ocean of visible colors.

noise-result8

float n = snoise(vec3(offx, offy, u_time * .1) * 4.) - 1.;

Our circle is still there but not enough visible to be displayed. If we multiply its value, it will be more contrasted.

float c = circle(circlePos, 0.3, 0.3) * 2.5;

noise-result9

We are almost there! But as you can see, there are still some details missing. And our edges aren’t sharp at all.

To avoid that, we’ll use the built-in smoothstep function.

float finalMask = smoothstep(0.4, 0.5, n + c);

gl_FragColor = vec4(vec3(finalMask), 1.);

Thanks to this function, we’ll cut a slice of our pattern between 0.4 et 0.5, for example. The shorter the space is between these values, the sharper the edges are.

Finally, we can mix our two textures to use them as a mask.

uniform sampler2D u_image;
uniform sampler2D u_imagehover;

// ...

vec4 image = texture2D(u_image, uv);
vec4 hover = texture2D(u_imagehover, uv);

vec4 finalImage = mix(image, hover, finalMask);

gl_FragColor = finalImage;

We can change a few variables to have a more gooey effect:

// ...

float c = circle(circlePos, 0.3, 2.) * 2.5;

float n = snoise3(vec3(offx, offy, u_time * .1) * 8.) - 1.;

float finalMask = smoothstep(0.4, 0.5, n + pow(c, 2.));

// ...

And voilà!

Check out the full source here or take a look at the live demo.

Mic drop

Congratulations to those who came this far. I haven’t planned to explain this much. This isn’t perfect and I might have missed some details but I hope you’ve enjoyed this tutorial anyway. Don’t hesitate to play with variables, try other noise functions and try to implement other effects using the mouse direction or play with the scroll!

If you have any questions, let me know in the comments section! I also encourage you to download the demo, it’s a little bit more complex and shows the effects in action with hover and click effects ¯\_(?)_/¯

References and Credits

Making Gooey Image Hover Effects with Three.js was written by Arno Di Nunzio and published on Codrops.