Building an Interactive Image Grid with Three.js


In this article, we’ll walk through coding this grid interaction, originally conceptualized by Amin Ankward, a creative designer, former colleague, and now friend (feel free to follow him on X 👀).

Before We Start

We’ll be using Three.js as our WebGL library, and that’s it—no GSAP today. Instead, I’d like to show you how to create animations without relying on any external animation libraries.

I’ve prepared a repository for you to clone if you want to follow along with this tutorial. It features basic Three.js scene initialization, a loader, and some utility functions. All the files we’ll be using are already created, so you can focus solely on the animations. You can access it here.

Here’s a quick file roundup if you’re following along with my starter:

  • MainThree.js – Initializes the Three.js scene, renderer, camera, and frame loop.
  • ExtendedObject3D – An implementation of Object3D that includes resize handling and an update function.
  • Grid.js – A class implementing ExtendedObject3D, where we’ll initialize and manage our cards.
  • Card.js – A class implementing ExtendedObject3D as well, representing the cards displayed on screen.
  • AssetsManager.js – The file where we’ll load, store, and retrieve our assets.

Grid Setup

Let’s start by setting up the grid, defining the number of rows and columns we want. In my example, I’ve decided to place a card every 100 pixels on the screen, but feel free to adjust it as needed.

If you’re following along with my starter, head to scripts/components/Grid.js and add the following lines:

// scripts/components/Grid.js

export class Grid extends ExtendedObject3D {
  static COLUMNS = Math.floor(window.innerWidth / 100) | 1;
  static ROWS = Math.floor(window.innerHeight / 100) | 1;
  // ...
  
  resize() {
	  Grid.COLUMNS = Math.floor(window.innerWidth / 100) | 1;
	  Grid.ROWS = Math.floor(window.innerHeight / 100) | 1;
  }
}

Alright, you may be wondering two things:

  1. Why use static? These variables need to be unique, and we will need them in other files later. Making them static allows access from anywhere in the project without requiring access to the Grid instance.
  2. What is this “| 1” and what does it do? It’s called a “Bitwise OR operator.” In our case, it will always turn our result into an odd number. Why would we want that? For aesthetic purposes, having an odd number of columns and rows allows one to be centered on the screen.

Now that we have our grid dimensions, let’s create our cards:

// scripts/components/Grid.js

import { Card } from './Card';

export class Grid extends ExtendedObject3D {
  // ...

  constructor() {
    super();

    this.#_createCards();
  }

  #_createCards() {
    for(let i = 0; i < Grid.COLUMNS; i++) {
      for(let j = 0; j < Grid.ROWS; j++) {
        const card = new Card(i, j);
        this.add(card);
      }
    }
  }
  
  // ...
}

Nothing fancy here; we’re simply looping through each column and row to place a card at every location. If you’re wondering what this.add() does, we inherit it from the Object3D class. It adds the card as a child of the grid so that it can be displayed on the screen (it’s normal if everything is still blank at the moment).

We also pass i and j as parameters to Card. We’ll use these indexes to calculate its position.

Card Initialization

Alright, our screen might look pretty blank right now. Let’s add these cards to it. Head to Card.js and insert the following code.

// scripts/components/Card.js
import {
  Mesh,
  MeshBasicMaterial,
  PlaneGeometry,
  Vector2,
} from "three";

// ...

export class Card extends ExtendedObject3D {
  static Geometry = new PlaneGeometry(1, 1);

  gridPosition = new Vector2();
  mesh;

  constructor(i, j) {
    super();

    this.gridPosition.set(i, j);
    this.#_createMesh();
  }

  #_createMesh() {
    const r = Math.ceil(Math.random() * 255);
    const g = Math.ceil(Math.random() * 255);
    const b = Math.ceil(Math.random() * 255);

    this.mesh = new Mesh(
      Card.Geometry,
      new MeshBasicMaterial({ color: new Color(`rgb(${r}, ${g}, ${b})`) })
    );
    
    this.add(this.mesh);
  }

	// ...
}

Performance tip: We’re creating the geometry as static because we’ll have many cards, all of the same size. Therefore, we only need to create one geometry instead of creating one for each card.

You should now see a randomly colored rectangle in the center of your screen, measuring exactly half the width and half the height of the screen.

Okay, that’s cool, but it’s not exactly what we want. Why is it rectangular when we’ve set both the width and height to 1 in our PlaneGeometry?

Read this also...  Claude Sonnet 3.5 — SitePoint

If you already know why it behaves this way, feel free to skip ahead. However, if you’re wondering, let’s quickly review before we continue. This will help you better understand the calculations we’ll perform later.

For those still with us, open the MainThree.js file. You should see that we’re using an OrthographicCamera.

// scripts/MainThree.js
this.#_Camera = new OrthographicCamera(
	-1, // left
	1, // right
	1, // top
	-1 // bottom
);

The parameters we’re setting here correspond to our screen coordinates. Let’s take a look at the diagram below:

The black rectangle represents your screen. Regardless of its dimensions, the coordinates remain the same: (-1, 1) marks the top-left corner, (1, -1) marks the bottom-right corner, and (0, 0) represents the center.

In simple terms, this means that both the width and height will always span a length of 2, even if their pixel dimensions differ significantly. That’s why our plane appears rectangular right now. To make it square-shaped, we need to scale it according to our screen’s aspect ratio.

// scripts/components/Card.js

import { Grid } from './Grid';
import { MainThree } from "../MainThree";

// ...

export class Card extends ExtendedObject3D {
  static #_DefaultScale = new Vector3();
  
  // ...

  #_createMesh() {
    // ...

    this.mesh.scale.copy(Card.#_DefaultScale);

    this.add(this.mesh);
  }

  static SetScale() {
    const aspect = window.innerWidth / window.innerHeight;
    const viewWidth = MainThree.Camera.right - MainThree.Camera.left;
    
    const columnWidth = viewWidth / Grid.COLUMNS;

    this.#_DefaultScale.x = columnWidth;
    this.#_DefaultScale.y = columnWidth * aspect;
  }
  
  resize(event) {
    this.mesh.scale.copy(Card.#_DefaultScale);
  }

  // ...
}

To determine the width of one column, we take the length of our camera view and divide it by the number of columns. Note that we are using a static method here to compute the size of a card only once, rather than for each card, as they will all share the same size.

To make this effective, we need to call it in both the Grid constructor and its resize function.

// scripts/components/Grid.js
export class Grid extends ExtendedObject3D {
  // ...

  constructor() {
    super();

    Card.SetScale();
    this.#_createCards();
  }

	// ...

  resize() {
    Grid.COLUMNS = Math.floor(window.innerWidth / 100) | 1;
    Grid.ROWS = Math.floor(window.innerHeight / 100) | 1;
    
    Card.SetScale();
  }

  // ...
}

You should have something like this right now: your plane, but squared. It might seem a bit lonely, though, so let’s add its friends to the grid.

Grid Positioning

You can try it on your own first if you want to challenge yourself a bit. If you’re not sure how to achieve it, here’s the logic:

As I mentioned before, our coordinates are normalized between -1 and 1. To place them correctly, you need to remap the indexes we provided in the parameters so they correspond to your grid. To clarify, if you have 16 columns, an index of 0 should return -1, while an index of 15 should return 1.

If you’re stuck, here’s the solution I came up with:

// scripts/components/Card.js
import { mapLinear } from "three/src/math/MathUtils.js";
// ...

export class Card extends ExtendedObject3D {
  // ...
  #_targetPosition = new Vector3()

  constructor(i, j) {
    super();

    this.gridPosition.set(i, j);

    this.#_createMesh();
    this.#_setTargetPosition();
  }

  // ...

  #_setTargetPosition() {
    let { x, y } = this.gridPosition;

    const cardWidth = Card.#_DefaultScale.x * 0.5;
    const cardHeight = Card.#_DefaultScale.y * 0.5;

    x = mapLinear(x, 0, Grid.COLUMNS, MainThree.Camera.left, MainThree.Camera.right) + cardWidth;
    y = mapLinear(y, 0, Grid.ROWS, MainThree.Camera.bottom, MainThree.Camera.top) + cardHeight;

    this.position.set(x,y, 0)
  }
}

Your screen should now look like this:

Let’s reduce their size slightly to create more space.

// scripts/components/Card.js
export class Card extends ExtendedObject3D {
  // ...

  #_defaultScale = new Vector3().setScalar(0.4)
  
  constructor() {
		this.gridPosition.set(i, j);

    this.#_createMesh();
    this.#_setTargetPosition();
    this.scale.copy(this.#_defaultScale);
  }

We’re starting to have something interesting!

Note that we’re not scaling our mesh this time; instead, we’re scaling the Object3D that contains it. This approach allows us to maintain the scale based on the aspect ratio we set earlier while also scaling it down.

Don’t add the following code; it’s just an HTML/CSS comparison to help you better understand the transformation we’ve made.

<section id="GRID">
	<div class="card">
		<div class="mesh">this is our plane</div>
	</div>
</section>
<style>
  .card {
  	  transform: scale(0.4);
  }

  .mesh {
	  width: 10px;
	  height: 10px;
	  transform: scaleY(OurAspectRatio);
  }
</style>

Hover Interaction

To achieve this, we first need to determine the distance between a card and our cursor. Let’s get the mouse position inside the Grid.

// scripts/components/Grid.js

export class Grid extends ExtendedObject3D {
  // ...

  static MousePosition = new Vector2();
  #_targetMousePosition = new Vector2();

  constructor() {
    super();

    Card.SetScale();
    this.#_createCards();
    this.#_setListeners();
  }

  #_setListeners() {
    window.addEventListener('mousemove', this.#_updateMousePos)
    window.addEventListener('touchmove', this.#_updateMousePos)
  }

  #_updateMousePos = (event) => {
    const isMobile = event.type === 'touchmove';
    
    const { clientX, clientY } = isMobile ? event.changedTouches[0] : event;

    const halfW = 0.5 * window.innerWidth;
    const halfH = 0.5 * window.innerHeight;

    // our mouse position, normalized on a [-1, 1] range.
    const x = (clientX - halfW) / window.innerWidth * 2
    const y = -(clientY - halfH) / window.innerHeight * 2

    this.#_targetMousePosition.set(x, y)
  }
  
  // ...

  update(dt) {
    this.#_lerpMousePosition(dt);
  }

  #_lerpMousePosition(dt) {
    Grid.MousePosition.lerp(
	    this.#_targetMousePosition, 
	    1 - Math.pow(0.0125, dt)
    );
  }
}

If you’re unfamiliar with lerps and curious about what’s happening in the update() method here, I highly recommend watching this video from Freya or this one from Simon, who explains it much better than I could. We’ll use them a lot from now on, so it’s important to understand how they work. In brief, they give us these smooth movements without relying on external animation libraries like GSAP.

Read this also...  Case Study: Motoyoshi Takamitsu | Codrops

We’re setting MousePosition as static here to easily retrieve it in our Cards without passing a reference to the Grid instance. The #_targetMousePosition is used only to interpolate our values and will not be needed elsewhere, so we can set it as private.

Okay, now that we have our cursor position, let’s compute the distance in the Cards file.

export class Card extends ExtendedObject3D {
  static #_DefaultScale = new Vector3();
  static #_MaxScale = new Vector3();
  
  // ...
  
  #_defaultScale = new Vector3().setScalar(0.2)
  #_targetScale = new Vector3()
  
  
  static SetScale() {
    // ...
		
	const isPortrait = window.innerWidth < window.innerHeight;
	const scaleFactor = isPortrait ? 8 : 20
		
    this.#_MaxScale
      .copy(this.#_DefaultScale)
      .multiplyScalar(scaleFactor)
  }
  
  update(dt) {
    this.#_updateScale(dt);
  }

  #_updateScale(dt) {
    const aspect = window.innerWidth / window.innerHeight;

    const distanceX = Grid.MousePosition.x - this.position.x;
    let distanceY = Grid.MousePosition.y - this.position.y;
    distanceY /= aspect;

    let distance = Math.pow(distanceX, 2) + Math.pow(distanceY, 2);
    distance *= 10;

    this.#_targetScale.lerpVectors(
      Card.#_DefaultScale,
      Card.#_MaxScale,
      Math.max(1 - distance, 0)
    );

    this.mesh.scale.lerp(this.#_targetScale, 1 - Math.pow(0.0002, dt));
  }
}

First, we add a #_MaxScale vector. Our card won’t be able to get larger than the value we set here. To do so, we can simply copy the default scale we set earlier and multiply it by a factor (feel free to adjust it as needed).

Next, we compute our distance in #_updateScale using the Pythagorean theorem, then use it as our interpolation factor.

If our distance is equal to 0, our card will be scaled to its maximum size. If the distance is equal to or greater than 1, it will be scaled to its minimum size.

You can adjust the radius by multiplying the distance by a factor. The higher the number, the smaller the radius.

You should have something that starts resembling our final result right now!

You might notice something off when the cards overlap. That’s because they all share the same z-position, so our renderer doesn’t know which one to render on top. Amin prepared a render that can help you better visualize what we want: the closer the card is to the mouse, the higher its z-position.

export class Card extends ExtendedObject3D {
	// ...
	
	#_updateScale(dt) {
    // ...
    this.position.z = -distance;
  }
}

Yep, that’s all!

Experimenting with Variables

Now, I suggest you play with some of the variables: radius, number of columns/rows, and the interpolants in our lerp’s pow(). You can achieve results with very different feels depending on what you set.

Card Shaders

Let’s get rid of these colors and implement our images by replacing the material with a custom one.

import { CardMaterial } from "../materials/CardMaterial";

export class Card extends ExtendedObject3D {
	// ...
	
	#_createMesh() {
		this.material = new CardMaterial()
    
    this.mesh = new Mesh(
      Card.Geometry,
      this.material
    );

    this.mesh.scale.copy(Card.#_DefaultScale);

    this.add(this.mesh);
  }
}

Your cards should all have turned red if everything went well. Before jumping straight into the shader, let’s describe what we want and what we need to achieve it.

  • We want an image We need textures
  • These images are black and white by default. We want them to gradually saturate based on our mouse position We need the distance

I’ve already loaded the textures for you, so you don’t have to worry about it. You’ll only need to retrieve them:

import { /* */ Uniform } from "three";

import { CardMaterial } from "../materials/CardMaterial";

export class Card extends ExtendedObject3D {
	// ...
	static #_Textures = [
      AssetsId.TEXTURE_1,
      AssetsId.TEXTURE_2,
      AssetsId.TEXTURE_3,
      AssetsId.TEXTURE_4,
      AssetsId.TEXTURE_5,
      AssetsId.TEXTURE_6,
      AssetsId.TEXTURE_7,
      AssetsId.TEXTURE_8,
      AssetsId.TEXTURE_9,
      AssetsId.TEXTURE_10,
    ];
	
	#_createMesh() {
    const randomIndex = Math.floor(Math.random() * Card.#_Textures.length);
    const textureId = Card.#_Textures[randomIndex];
    const texture = AssetsManager.GetAsset(textureId);

    this.material = new CardMaterial({
      uniforms: {
        uDistance: new Uniform(0),
        uTexture: new Uniform(texture),
      }
    });
  }
  
  #_updateScale(dt) {
    // ...
    this.material.uniforms.uDistance.value = distance;
  }
}

Okay, our uniforms are set up. Let’s get this shader now!

// scripts/materials/CardMaterial.js

import { ShaderMaterial } from "three";

export class CardMaterial extends ShaderMaterial {
  // ...

  #_rewriteVertexShader() {
    return /* glsl */`
      varying vec2 vUv;

      void main() {
        vUv = uv;

        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
      }
    `;
  }

  #_rewriteFragmentShader() {
    return /* glsl */`
      uniform sampler2D uTexture;
      uniform float uDistance;
      
      varying vec2 vUv;

      void main() {
        gl_FragColor = vec4(vec3(vUv, 1.), 1.);
      }
    `;
  }
}

Nothing fancy for the moment; we’re just setting it up so we can access our UVs in the fragment shader and retrieve our uniforms. Your screen should look like this right now:

Read this also...  How to Reduce Cost with Prompt Compression Techniques — SitePoint

Now let’s display our image and grayscale it.

// scripts/materials/CardMaterial.js

export class CardMaterial extends ShaderMaterial {
  // ...

  #_rewriteFragmentShader() {
    return /* glsl */ `
      uniform sampler2D uTexture;
      uniform float uDistance;
      
      varying vec2 vUv;

      vec3 getLuminance(vec3 color) {
        vec3 luminance = vec3(0.2126, 0.7152, 0.0722);
        return vec3(dot(luminance, color));
      }

      void main() {
        vec4 image = texture(uTexture, vUv);

        vec3 imageLum = getLuminance(image.xyz);
        vec3 color = imageLum;

        gl_FragColor = vec4(color, 1.);
      }
    `;
  }
}

We’re adding a getLuminance function that gives us a grayscaled version of our image. I first learned about luminance from this article written by Maxime Heckel. Basically, it represents how the human eye perceives brightness within colors.

Now, all we have to do is apply the effect based on our distance.

// scripts/materials/CardMaterial.js

export class CardMaterial extends ShaderMaterial {
  // ...

  #_rewriteFragmentShader() {
    return /* glsl */ `
      uniform sampler2D uTexture;
      uniform float uDistance;
      
      varying vec2 vUv;

      vec3 getLuminance(vec3 color) {
        vec3 luminance = vec3(0.2126, 0.7152, 0.0722);
        return vec3(dot(luminance, color));
      }

      void main() {
        vec4 image = texture(uTexture, vUv);
        float distanceFactor = min(max(uDistance, 0.), 1.);

        vec3 imageLum = getLuminance(image.xyz);
		vec3 color = mix(image.xyz, imageLum, distanceFactor);

        gl_FragColor = vec4(color, 1.);
      }
    `;
  }
}

And here we are!

Intro Animation

Let’s put these cards back in the center by commenting out the line where we set the position, and just store it in a new Vector3 for the moment.

// scripts/components/Card.js

export class Card extends ExtendedObject3D {
  // ...
  #_gridPosition = new Vector3();

  #_setTargetPosition() {
    // ...
		
	// Comment this ⬇️
	// this.position.set(x, y, 0);
		
    this.#_gridPosition.set(x, y, 0);
  }

The logic of the animation is pretty simple: we make our card move to its target position on the x-axis first. Then, when it’s close enough, we allow it to move to its y position too. This is the trick for achieving that wavy look.

// scripts/components/Card.js

export class Card extends ExtendedObject3D {
	// ...
	#_targetPosition = new Vector3();

	update(dt) {
    this.#_updateScale(dt);
    this.#_updatePosition(dt);
  }

	#_updatePosition(dt) {
    const distanceX = Math.abs(this.#_gridPosition.x - this.position.x);

    this.#_targetPosition.set(
      this.#_gridPosition.x,
      distanceX < 0.075 ? this.#_gridPosition.y : 0,
      this.position.z
    );

    this.position.lerp(
      this.#_targetPosition,
      1 - Math.pow(0.005 / Grid.COLUMNS, dt)
    );
  }
}

And we are done!

Even though this effect is already pretty cool, it can still be improved:

Going Further

  • Handling the resize – What I would do is probably create a bunch of cards (more than we need) during the initial setup and store them. That way, if you switch to a larger screen size, you’ll just need to display/rearrange them without creating new ones each time. Just make sure the inactive ones aren’t running their update functions every frame.
  • Adding border radius to cards – If you check the original post, you’ll see that my cards have rounded corners. Try implementing this on your own.
  • Allow cards to have images with an aspect ratio other than 1:1 in the shader – This wasn’t the focus of the article, so I didn’t handle cases where our images aren’t square. They would be stretched otherwise right now.
  • Completing the intro animation – In the original example, I’ve done an animation where the cards come from below. Try to reimplement this by yourself; there’s a small trick involved 👀

And that’s it! Thank you for reading all the way through; I hope you’ve enjoyed this tutorial and learned something along the way! 🙏



Source link

Leave a Comment

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

Shopping Cart
Scroll to Top

Discover more from You Grow Online

Subscribe now to keep reading and get access to the full archive.

Continue reading

Open chat
Hello 👋
Can we help you?