Everest.agency WebGL Infinite Scroller deconstruction

Daniel Velasquez
Mar 23 · 9 min read

I always thought one had to be a crazy experienced developer to make a website like the ones on Awwwards. So, I never even tried to do make one.

But recently I read taotajima.jp WebGL deconstruction and it inspired me to do my own deconstructions and share it with others.

After a few weeks of deconstructing websites, I figured out how to do a lot of awesome effects and grew a lot as a developer.

The website

I’ll be deconstructing the Infinite Scroller found on Everest.agency’s home page. It was released in January 2019 and developed by Aristide Benoist( @AriBenoist) in collaboration with @builtbyeverest, winning multiple awards in Awwwards and FWA for its beautiful design and code.

If you haven't seen the site, I recommend you do it before reading this post. My deconstruction of the Infinite Scroller doesn’t do justice to the website.

With that out of the way, let's see what the Infinite Scroll looks like:

Home Page’s Scroller

Everest.agency’s home page

I’m going to explain only the magic behind the effect. So If you want to see how to do the complete project check this Sandbox, or this Github repo.

Effects breakdown

  • Z displacement: The images seem to get closer on click
  • Preserved aspect ratio on the images

Infinite Scroll

Does it keep creating new planes in advance and deleting the ones out-of-view?. Does it keep reusing the out-of-view planes? What kind of black magic is this?

In reality, it is both easier and more clever than black magic:

When you scroll past a threshold, the scroll moves back to a position that looks exactly the same.

Also, the images shift to counteract moving the scroll back. More on this in a little bit

Since everything keeps moving backward, we never reach the end and so it seems infinite.

Here is how the effect looks from far away:

Note: It seems like it’s constantly adding and removing planes, but it isn’t. Those are the same planes being pushed back over and over again

Now, for the effect to be invisible the threshold of scroll and distance to move back the scroll has to be planeHeight + planeMarginY. Moving it back by this value makes the result be visually the same if you look at it from a close distance.

// On scroll
const spaceY = height + marginY;
if (Math.abs(scroll) > spaceY) {
// Whenever the scroll whoes over the threshold. Move it back on the opposite direction.
scroll = scroll - spaceY * scrollDirection;
}

Short and simple, isn’t it?

With enough planes to cover a little bit over the screen, the effect is unnoticeable.

But what about the images? If you are always moving the planes backward, then we should see the same images over and over again.

Good deduction! That’s exactly what happens:

To fix this issue, we’ll change all the images in the direction of the scroll at the same moment when the scroll jumps back. Making the jump unperceivable again.

const spaceY = height + marginY;

if (Math.abs(scroll) > spaceY) {
// Whenever the scroll whoes over the threshold. Move it back on the opposite direction.
scroll = scroll - spaceY * scrollDirection;
planes = planes.map(plane => {
return Object.assign({}, plane, {imgNo: plane.imgNo + scrollDirection })
})
}

And that’s how the infinite Scroll works under the hood!

By itself the Infinite scroll it’s awesome, but what makes an experience shine is the details. So let’s see a couple of them!

Z Displacement

If you are curious about learning what “shader” even means. I recommend you fiddle around with The Book Of shaders. It’s exciting, fun and you learn a lot of the basics in there.

First, we are going to calculate the distance from the center.

And initialize zChange which will hold the actual displacement.

void main() {
// Distance of vertex from center
float distance = length(position.xy);
float zChange = 0.;
//
vec3 pos = position.xyz;
pos.z += zChange;
gl_Position= projectionMatrix * modelViewMatrix * vec4( pos,1.0);
}

Now, we’ll add u_maxDistance uniform to limit how far the vertex can be until the effect doesn't apply. And we'll use the size of our camera's view, so if the vertex is outside of view, it doesn't get displaced.

Then, we are going to normalize distance by dividing it by u_maxDistance. And set zChange to the normalizedDistance

uniform float u_maxDistance;
void main() {
// Distance of vertex from center
float distance = length(position.xy);
float zChange = 0.;

if(distance<u_maxDistance){
float normalizedDistance = distance / u_maxDistance;
zChange = normalizedDistance;
}


vec3 pos = position.xyz;
pos.z += zChange;
gl_Position= projectionMatrix * modelViewMatrix * vec4( pos,1.0);
}

Our planes are closer to the screen the farther away they are from the center. We want the opposite so we’ll invert normalizedDistance.

We want to control how close we want the planes to move forward. So we’ll add u_magnitude as a new uniform and multiply it withzChange.

And finally, add u_progress as an uniform which will be a number from 1 to 0 to control the progress of the effect.

uniform float u_maxDistance;
uniform float u_progress;
uniform float u_magnitude;

void main() {
// Distance of vertex from center
float distance = length(position.xy);
float zChange = 0.;

if(distance<u_maxDistance){
float normalizedDistance = distance / u_maxDistance;
zChange = normalizedDistance;
zChange = normalizedDistance * u_magnitude * u_progress;
}


vec3 pos = position.xyz;
pos.z += zChange;
gl_Position= projectionMatrix * modelViewMatrix * vec4( pos,1.0);
}

To top it off, add onMouseDown event listener that updates u_progress uniform from 0 to 1 in 500ms.

And there you have it, an amazing effect to complement your infinite Scroller!

Preserved aspect ratio

Imagine my confusion when this issue came up:

When sampling a texture in a fragment shader, we need to use some sort of texture coordinates.

And my obvious guess was to use the plane’s UVs.

Let’s see what that looks like:

precision mediump float;
uniform sampler2D u_texture;
varying vec2 vUv;
void main(){
vec2 texCoords = vec2(vUv.x, 1.-vUv.y);
vec3 color = texture2D(u_texture, vUv ).xyz;
gl_FragColor = vec4(color,1.);
}

Some images look compressed and others stretched!

Why is that happening?

The plane’s UVs range from 0 to 1, and the texture coordinates also range from 0 to 1.

By using the plane’s UVs as texture coordinates we are trying to fit the whole image inside the plane. Instead, we want to fit the plane inside the image:

[image showing what multiplying with the factor the UV’s does. Show 2 columns, one with the factor and one without the factor, and show the change in numbers by comparation]

Using Javascript, we need to find a factor that converts the UVs into the correct texture coordinates.

For that, we’ll need to find the plane’s ratio and the screen’s ratio. And depending on which ratio is bigger, we’ll calculate the UV factor.

const planeRatio = plane.width / plane.height;
const imageRatio = image.width / image.height;
if(planeRatio > imageRatio){

} else {

}

For the image to not be stretched or compressed, we need to match one side and calculate the other side with the planeRatio.

Depending on which ratio is bigger, matching one side or another will have a different outcome.

Let's see what are the possible outcomes of ratios:

If the image’s ratio is bigger than the plane’s ratio,

  • Given the same width, the image will bleed outside of the plane. It will cover the whole plain.
  • Given the same height, the image will leave a gap inside of the plane. Result: It won’t cover the whole plane.

But if the image’s ratio is smaller than the plane’s ratio,

  • Given the same width, the image will leave a gap inside of the plane. Result: It won’t cover the whole plane.
  • Given the same height, the image will bleed outside of the plane. It will cover the whole plain.

Note: If the ratios are the same, the image will cover exactly the whole plane. So we don’t account for this.

This is how each outcome looks like:

In our case, we always want the image to cover the whole plane instead of leaving a gap inside the plane. If needed, the image should always bleed.

So,

If the image’s ratio is bigger. The texture coordinates will have the same width as the image and the height gets calculated with the plane’s aspect ratio.

If the image’s ratio is smaller. The texture coordinates will have the same height as the image and the width gets calculated with the plane’s aspect ratio.

const planeRatio = plane.width / plane.height;
const imageRatio = image.width / image.height;
if(planeRatio > imageRatio){
// Given the same width
factor.width = 1;
// Calculate the texture coordinates height with the plane's ratio.
factor.height = (1 / rectRatio) * imageRatio;
} else {
// Calculate the texture coordinates width with the plane's ratio.
factor.width = (1 * rectRatio) / imageRatio;
// Given the same height
factor.height = 1;
}

Pass the result as vector2 uniform u_textureFactor to the fragment shader. Multiply it with the UVs.

If you want it on the center, subtract half of the factor- u_textureFactor / 2. and add half of the image 0.5.

precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_textureFactor;
varying vec2 vUv;
void main(){
vec2 texCoords = vec2(vUv.x, 1.-vUv.y) * u_textureFactor - u_textureFactor / 2. + 0.5;
// Center Image
// 0.5 = Push the plane right by half of the image size.
// Our plane is right after the center of the image.
// u_textureFactor / 2. = Move the plane back by half of its width.
texCoords += -u_textureFactor/ 2. + 0.5;
vec3 color = texture2D(u_texture, texCoords ).xyz;
gl_FragColor = vec4(color,1.);
}

Look at the difference, it looks so much better now!

That’s it, done with the important effects of the Infinite Scroller!

Although these effects are the most complex part of the code. To build the complete Infinite Scroller you are still missing a few simple things:

  • Creating the WebGL scene and adding the planes.
  • Adding black and white effect
  • Re-rendering whenever it needs to

But don’t get intimidated if you don’t understand how to do those things. If you understood any of the effects explained in the post, everything else should be a piece of cake.

You can find a live version built with ThreeJS in Codesanbox, and GitHub.

Conclusion

This is my first post ever, so any kind of feedback is welcome. And I’ll continue making more posts like this one, so get ready!

If you want to connect, actually learned something new, or want to give me some feedback. Feel free to share your thoughts with me on Twitter @Anemolito.

Thanks for reading! If you have any questions, let me know in the comments.

Add “dolphins tend to have ____” with your own ending for extra points in my books.

P.S. I’m open for WebGL, React or any web projects. Focused onFreelance, but also full-time if your project is exciting and meaningful!

Edit 1: On the original publish I totally messed up and didn’t credit @ariBenoist who the site. It was a mistake on my part, as we even talked on private. Apologies to Aristide and to everyone else.

Some awesome link