Create an Abstract Image Slideshow with OGL, GLSL, and GSAP

Learn how to create a WebGL-powered image slideshow animated using a single value.

In today’s tutorial, we’ll dig (very) deep into the WebGL and GLSL worlds to create a really cool image carousel whose entire animation will depend on a single value; every calculation will be made inside the shader.

How this tutorial is structured

I’ve organized the tutorial in multiple steps, some of them really short and others “less-short”, all because I want to guide you through the entire process and explain you the reasoning behind them.

At the beginning of each step you can find a link with the code diff on the GitHub repository, so that you can see exactly what has changed and where, just in case you end up with different results.

Let’s get started!

Step 01 – Initial setup

View code diff

First, let’s create the files we need using OGL’s triangle screen shader example as the base.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Triangles' grid carousel [OGL+GSAP]</title>
  <link rel="stylesheet" href="./app.scss">
</head>
<body>
  <div id="canvas-wrapper" data-canvas-wrapper></div>

  <script src="./app.js"></script>
</body>
</html>

app.scss

html,
body {
  margin: 0;
  overflow: hidden;
}

body {
  background-color: #112233;
}

#canvas-wrapper {
  height: 100vh;
  position: relative;
  width: 100%;

  canvas {
    height: 100%;
    position: absolute;
    width: 100%;
  }
}

app.js

import { Renderer, Program, Mesh, Triangle } from 'ogl'
import { gsap } from 'gsap'

class WebGLCarousel {
  constructor() {
    this.wrapper = document.querySelector('[data-canvas-wrapper]')
  }

  init() {
    this._createRenderer()
    this._createScene()
    this._addListeners()
    this._onResize()

    gsap.ticker.add(() => {
      this.program.uniforms.uTime.value += 0.01
      this.renderer.render({ scene: this.mesh })
    })
  }

  _createRenderer() {
    this.renderer = new Renderer()
    this.gl = this.renderer.gl

    this.wrapper.appendChild(this.gl.canvas)

    this.gl.clearColor(1, 1, 1, 1)
  }

  _createScene() {
    this.geometry = new Triangle(this.gl)

    this.program = new Program(this.gl, {
      vertex: require('./shaders/effect.vertex.glsl'),
      fragment: require('./shaders/effect.fragment.glsl'),
      uniforms: {
        uTime: { value: 0 },
        uProgress: { value: 0 }
      }
    })

    this.mesh = new Mesh(this.gl, {
      geometry: this.geometry,
      program: this.program
    })
  }

  _addListeners() {
    window.addEventListener('resize', this._onResize.bind(this), { passive: true })
  }

  _onResize() {
    this.renderer.setSize(this.wrapper.clientWidth, this.wrapper.clientHeight)
  }
}

const app = new WebGLCarousel()
app.init()

effect.fragment.glsl

precision highp float;

uniform float uTime;

varying vec2 vUv;

void main() {
  float sTime = sin(uTime);
  float cTime = cos(uTime);

  vec3 color = vec3(vUv.x*0.8 + sTime*0.2, vUv.y, 0.5 + cTime*0.5);

  gl_FragColor = vec4(color, 1.0);
}

effect.vertex.glsl

attribute vec2 uv;
attribute vec2 position;

varying vec2 vUv;

void main() {
  gl_Position = vec4(position, 0.0, 1.0);

  vUv = uv;
}

The app.js file doesn’t do that much at the moment; it creates a basic scene with a mesh that covers the entire screen and renders a color gradient.

You might have noticed that I used gsap.ticker.add(() => { ... }) instead of the classic requestAnimationFrame(() => { ... }). This is because there are screens and devices that have a really high refresh rate; this would make our application run faster (in terms of speed of animation) than what we want.

GSAP’s ticker makes sure that our application won’t exceed 60fps even on screens that run at, for instance, 144fps.

Now let’s install the packages we need by running the following commands from the terminal:

$ yarn add gsap ogl
$ yarn add -D parcel-bundler

On the first run, Parcel will install a bunch of other development dependencies like sass and glslify because it will see that we’re using SCSS and GLSL code. The good thing about Parcel is that everything will simply work without any additional configuration.

Open the package.json file and add the following commands:

{
 ...

 "scripts": {
    "dev": "rm -rf dist && parcel src/index.html",
    "build": "rm -rf dist && parcel build src/index.html --no-source-maps"
  },

 ...
}

Now, go to the terminal and run

$ yarn dev

then open a new browser tab and navigate to http://localhost:1234; if you see a fullscreen color gradient that changes over time like the image below, then you’re good to proceed!

Step 02 – Add debug panel

View code diff

Now let’s add a debug panel using Tweakpane. This will come in handy many times during development because it allows updating values in real-time without going back and forth from the code editor.

Install it from the terminal

yarn add tweakpane

and then add it to the app.js file

...
import { Pane } from 'tweakpane'
...

...
const pane = new Pane()

pane.addInput(this.program.uniforms.uProgress, 'value', { label: 'uProgress', min: 0, max: 1, step: 0.01 })
...

We just added a configuration panel for the uProgress uniform but the effect is not visible on the application, so let’s edit the effect.fragment.glsl file in order to do so.

...
uniform float uProgress;
...

void main() {
  vec3 color = vec3(vUv, uProgress);
  
  gl_FragColor = vec4(color, 1.0);
}

What we just did here is simple: the color variable’s red and green channels depends on the vUv coordinates, and the blue channel is determined by the value of the uProgress uniform.

Now, if you try to edit the uniform from the panel you can see that the colors of the gradient change.

Step 03 – Draw a triangle

View code diff

In order to draw shapes, we must deal with the conversion of cartesian coordinates to polar coordinate, arctangents, and other “not that easy” stuff. All that stuff is outside the scope of this tutorial so I’ll just paste here the code that I took from The Book of Shaders.

Shape.js

#define PI 3.14159265359
#define TWO_PI 6.28318530718

float Shape(in vec2 st, in vec2 p, in float size, in float sides, in float blur) {
  vec2 pos = vec2(p) - st;
  float a = atan(pos.x, pos.y) + PI;
  float r = TWO_PI / sides;
  float d = cos(floor(.5 + a/r)*r - a) * length(pos);

  float color = smoothstep(size + blur, size - blur, d);

  return color;
}

#pragma glslify: export(Shape)

Please notice the last line that says #pragma glslify: export(Shape). That is the glslify syntax that will allow us to “import” this file into other GLSL files. glslify is basically the “NodeJS of shaders”.

Let’s import this new file into the effect.fragment.glsl file

#pragma glslify: Shape = require(./modules/Shape)

From now on, we can use the Shape function; let’s start by adding a single triangle at the center of the screen.

// vUv go from 0 to 1 on both axes, we have to
// remap them so that they go from -1 to +1.
// This is a common practice to easily align
// things at the center of the screen.
vec2 uv = vUv*2.0 - 1.0;

...

// Generate a triangle ...
vec2 pos = vec2(0.0);
float size = 0.1;
float sides = 3.0;
float blur = 0.001;
float triangle = Shape(uv, pos, size, sides, blur);

// ... and add it to the color variable
color += triangle;

...

Now, if you’ve done everything correctly, you can see a black screen with a white triangle at its center.

Step 04 – make everything resolution-independent

View code diff

Ok, everything we’ve done so far works ALMOST like a charm. If you resize the browser window you see that the triangle gets squashed.

This can be avoided with just one line of code in the fragment shader, but first, we must provide it the size of the canvas.

First, in the app.js file, import the OGL’s Vec2 object and add a new uResolution uniform to the shader declaration

// Add Vec2 to he modules imported from OGL
import { Renderer, Program, Mesh, Triangle, Vec2 } from 'ogl'

...

// Add the new uResolution uniform
this.program = new Program(this.gl, {
  uniforms: {
    ...
    uResolution: {
      value: new Vec2(
        this.gl.canvas.clientWidth,
        this.gl.canvas.clientHeight
      )
    }
  }
})

...
// Update the uResolution uniform on Resize
_onResize() {

  ...

  this.program.uniforms.uResolution.value = new Vec2(
    this.gl.canvas.clientWidth,
    this.gl.canvas.clientHeight
  )
}

Now let’s work on the fragment shader

// Tell the shader to expect a new uResolution uniform of type vec2
uniform vec2 uResolution;

// Right after declaring the uv variable, update it this way
uv.x *= uResolution.x / uResolution.y;

Go back to the browser and try resizing the browser window. If you’ve done everything right, the triangle shouldn’t deform anymore.

Step 05 – Generate a grid

View code diff

Yeah, a single triangle is cool, but we need to generate lots of them.

The first thing that might come to mind is a couple of for loops that iterate through both the X and Y axes and draw a triangle at a certain set of coordinates. While this is correct, there’s a more efficient way to do that (your GPU will thank you).

Add this line to the fragment shader

vec2 gv = fract(uv*5.0);

This calculation takes the uv variable subdivides it by the value you define and makes it repeat on both axes.

When adopting this technique, it’s common practice to name the new set of UVs gv, which stands for grid uv.

Now, if you replace uv with gv in the triangle formula …

float triangle = Shape(gv, pos, size, sides, blur);

you see that a lot has changed

But hey, what’s going on?

The reason is really simple; the origin of the gv coordinates is at the bottom-left corner. So, we simply have to update the pos variable in order to place the triangle at the center again.

vec2 pos = vec2(0.5);

And here’s our grid of triangles, all without any additional work for the GPU.

Then why not add some axes to use as reference and help us align things?
Add these lines to the fragment shader:

// Get the size of a single pixel
float pixel = 1.0 / uResolution.x;

// Draw two red lines on both axes by using
// the values of uv.x and uv.y
color.r += 1.0 - step(pixel*4.0, abs(uv.x));
color.r += 1.0 - step(pixel*4.0, abs(uv.y));

Easy peasy!

Step 06 – Refactor

View code diff

Before things start to get complicated, why not refactor what we have now in order to better organize our code?

Edit the fragment shader this way

precision highp float;

uniform float uProgress;
uniform vec2 uResolution;

varying vec2 vUv;

#pragma glslify: Shape = require(./modules/Shape)

// We moved the triangle creation to a separate function
// because we will use it multiple times later
float Triangle(vec2 uv, vec2 position) {
  float size = 0.2;
  float sides = 3.0;
  float blur = 0.001;

  return Shape(uv, position, size, sides, blur);
}

// The creation of the grid will need its own function,
// because it will contain a lot of code later.
float Tiles(vec2 uv) {
  float result = 0.0;
  vec2 gv = fract(uv*5.0);

  result += Triangle(gv, vec2(0.5));

  return result;
}

void main() {
  vec2 uv = vUv*2.0 - 1.0;
  uv.x *= uResolution.x / uResolution.y;

  vec3 color = vec3(0.0);

  // Add the output of the Tiles function to
  // the output color.
  color += Tiles(uv);

  // Debug axes
  float pixel = 1.0 / uResolution.x;
  color.r += 1.0 - step(pixel*4.0, abs(uv.x));
  color.r += 1.0 - step(pixel*4.0, abs(uv.y));

  gl_FragColor = vec4(color, 1.0);
}

Now if you check the browser nothing should have changed, and that’s good news.

Step 07 – Avoid triangles being “cut”

View code diff

Everything we’ve done so far works like a charm, but what happens if you increase the size of the triangles or move them to another position? Simple, they will be drawn outside the boundaries and be “cut”.

There’s a simple trick to avoid this kind of situation, and it consists in drawing on the neighbor tiles as well.

Add this code to the Tiles function:

for (float y = -1.0; y <= 1.0; y++) {
  for (float x = -1.0; x <= 1.0; x++) {
    // Get the coordinates of the neighbor tile
    vec2 tileOffset = vec2(x, y);

    // Draw the triangle
    result += Triangle(gv - tileOffset, vec2(0.5));
  }
}

The code above loops through all the tiles that surround our current tile (including the tile we’re on) and draws a triangle on each one of them. This technique does not actually draw on those tiles, but simply at the same coordinates.

This gives us some flexibility with positioning the triangles allowing us to give the illusion that the triangles are positioned outside their boundaries.

Be aware that since we’re looping through a 3×3 grid, what we’re drawing must not exceed that space, otherwise it will be cut. To avoid this we can increase the size of the grid (5×5 or even more), but this would increase the work for the GPU.

Just like the previous step, if you check the browser, nothing should have changed (at least visually), but we’re actually drawing 9 triangles now.

Step 08 – Shift triangles on even rows

View code diff

Now, let’s shift our triangles a bit to make things a bit more interesting. The technique is pretty easy.

Add this to the Tiles function…

// This is a common practice to get a unique identifier of each tile.
// With this, each tile's ID will be something like
// vec2(1.0, 2.0), vec2(0.0, 4.0), and so on.
// Those values are basically the indices of the current row and column.
vec2 id = floor(uv*5.0);

… and this to the nested for loops

// On even rows, we must shift the triangles by
// half of their width, so we use the mod function to
// determine if the current row is even or odd.
// If the current row is even, the mod function returns 1.0,
// so we must divide it by half.
vec2 tileShift = vec2(mod(id.y, 2.0)*0.5, 0.0);

// Subtract the value of tileShift from the coordinates of the triangle
result += Triangle(gv - tileOffset - tileShift, vec2(0.5));

Now if you go back to the browser you should see that some of the triangles are shifted now, and thanks to the technique we used in the previous step they “overflow” their boundaries.

Step 09 – Add triangles pointing up

View code diff

Now it’s time to add another set of triangles to the mix, but this time they will be pointing in the opposite direction.

So, the first thing we have to do is creating a new module that exports a Rotate function:

Rotate.glsl

mat2 Rotate(float angle) {
  float s = sin(angle);
  float c = cos(angle);

  return mat2(c, -s, s, c);
}

#pragma glslify: export(Rotate)

Then, import it into the fragment shader just like we did with the Shape function:

#pragma glslify: Rotate = require(./modules/Rotate)

Add then this code inside the nested for loops

// Create a new set of UVs named `st` and rotate them around their center
// Notice that PI is not a variable available in GLSL out of the box,
// but it's defined inside the Shape module. 
vec2 st = (gv - 0.5)*Rotate(PI) + 0.5;

// Offset the new UVs by half of the width plus
// an arbitrary value for the Y axis.
st -= vec2(0.5, 0.37);

// Add the triangle
result += Triangle(st - tileOffset + tileShift, vec2(0.5));

Also, for future reference, we will need to be able to identify each triangle, exactly like we did with the tiles using the floor function; let’s do it:

// Get a unique identifier of each triangle ...
vec2 triangleID = id + tileOffset;

// ... and use its y component instead of the y component
// of the tile ID. The result is the same, but we'll
// play with it later.
vec2 tileShift = vec2(mod(triangleID.y, 2.0)*0.5, 0.0);

If you go back to the browser, you should see that now there is a new set of triangles that point up.

Step 10 – Manage the size of the grid

View code diff

All good, but a grid that covers the entire screen is not what we want, so let’s determine its size.

In app.js, add a uGridSize uniform

this.program = new Program(this.gl, {
  ...

  uniforms: {
    ...

    uGridSize: {
      value: new Vec2(5, 3)
    }
  }
})

and make it configurable via the debug panel

pane.addInput(this.program.uniforms.uGridSize.value, 'x', { label: 'Grid size X', min: 0, max: 20, step: 1 })

pane.addInput(this.program.uniforms.uGridSize.value, 'y', { label: 'Grid size Y', min: 0, max: 20, step: 1 })

Then add the uGridSize uniform to the fragment shader

uniform vec2 uGridSize;

Inside the nested for loops, determine whether the current triangle must be drawn or not

// If the value of triangleID.x goes past uGridSize.x, then
// isVisible is set to 0.0, which means that the triangle won't
// be displayed on the X axis.
float isVisible = step(abs(triangleID.x), uGridSize.x);

// We do the same calculation for the Y axis.
// Multiplying the previous value by this new calculation
// results in a "rectangular" mask.
isVisible *= step(abs(triangleID.y), uGridSize.y);

It’s now time to draw only the triangles that we want to be visible. To make everything more readable we should refactor a bit:

// 'd' and 'u' are the two "layers" of triangles,
// respectively pointing down and up.
// Each layer is multiplied by the value of the 'isVisible' variable,
// making all the exceeding triangles invisible.

float d = Triangle(gv - tileOffset - tileShift, vec2(0.5));
d *= isVisible;

float u = Triangle(st, vec2(0.5, 0.4));
u *= isVisible;

// Add the sum of the 'd' and 'u' layers to the
// 'result' output variable
result += d+u;

We’re now able to determine the size of our grid of triangles.

Step 11 – Align the grid to the center

View code diff

Technically, our grid is aligned to the center of the screen, but not visually. Try setting both the grid values to zero.; something is obviously not good.

To fix this, we have to manually offset the UVs.

// Move the triangles ttoward the bottom-left corner
// by adding some value. 0.25 is enough in this case.
vec2 gv = fract(uv*5.0 + 0.25);
vec2 id = floor(uv*5.0 + 0.25);

Now, the grid is perfectly aligned to the center of the screen.

Step 12 – Add textures

View code diff

Our job with the grid is done (for now); now it’s time to work with textures.

To be able to work with static files we have to install the parcel-plugin-static-files-copy plugin from the terminal

yarn add -D parcel-plugin-static-files-copy

This way, we are able to work with static files by simply putting them inside a /static directory.

After that, let’s add the code to load and manage the textures to the app.js file.

// Import the Texture module from OGL
import { Renderer, Program, Mesh, Triangle, Vec2, Texture } from 'ogl'
// Define the URLs of the textures inside the constructor() method
constructor() {
  this.texturesURLs = [
    '/images/image-01.jpg',
    '/images/image-02.jpg',
    '/images/image-03.jpg'
  ]
}
// Add these two methods to the class
//
// _loadTexure() loads a single texture and adds
// it to this.textures[], returning a Promise object
//
// _loadTextures() loads all the textures in
// this.texturesURLs[] and returns a Promise object
//

_loadTexture(url, index) {
  return new Promise(resolve => {
    const img = new Image()
    img.src = url

    img.onload = () => {
      this.textures[index] = new Texture(this.gl, {
        image: img
      })

      resolve()
    }
  })
}

_loadTextures() {
  return new Promise(resolve => {
    this.textures = []

    const promises = this.texturesURLs.map((url, index) => this._loadTexture(url, index))

    Promise
      .all(promises)
      .then(() => resolve())
  })
}
// Refactor the init() method so that the class waits for
// the textures to load before doing anything.
init() {
  this._createRenderer()

  this._loadTextures()
    .then(() => {
      this._createScene()
      this._createDebugPanel()
      this._addListeners()
      this._onResize()

      gsap.ticker.add(() => {
        this.renderer.render({ scene: this.mesh })
      })
    })
}
// Add a uTexture0 uniform to the program
this.program = new Program(this.gl, {
  ...

  uniforms: {
    ...

    uTexture0: {
      value: this.textures[0]
    }
  }
})

Now, let’s add the texture to the fragment shader.

// Tell the shader to expect a uTexture0 uniform.
// In GLSL, textures are a sampler2D data type.
uniform sampler2D uTexture0;

// Inside the main() function, define a tex0 variable,
// and use that for the final output.
// The UVs are re-mapped to go from 0.0 to 1.0.
vec4 tex0 = texture2D(uTexture0, uv*0.5 + 0.5);

// For this, we could've also write 'gl_FragColor = tex0'
// since tex0 is a variable of type vec4.
gl_FragColor = vec4(tex0.rgb, 1.0);

Et voilà, our texture is there!

Step 13 – Add ‘cover’ effect to textures

View code diff

Depending on the texture, you might see weird things on the edges, like this

That is the last pixel of the texture that gets repeated infinitely, causing bad visual issues.
What we can do to avoid this is emulating CSS’s cover feature, so that the image will cover the entire screen no matter the size of the screen.

Luckily, someone else already wrote the code to do it. Let’s create a new Cover module with that code.

Cover.glsl

vec2 Cover(vec2 uv, vec2 screenSize, vec2 imageSize) {
  vec2 s = screenSize;
  vec2 i = imageSize;

  float rs = s.x / s.y;
  float ri = i.x / i.y;

  vec2 new = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x);
  vec2 offset = (rs < ri ? vec2((new.x - s.x) / 2.0, 0.0) : vec2(0.0, (new.y - s.y) / 2.0)) / new;
  vec2 st = uv * s / new + offset;

  return st;
}

#pragma glslify: export(Cover)

Import that module inside the fragment shader

#pragma glslify: Cover = require(./modules/Cover)

You may have noticed that the Cover function requires additional data that we currently don’t have: the size of the image.

Edit the app.js file by adding a new uniform with the size of the image to provide to the fragment shader.

// 1. Add a 'uTexture0Size' uniform to the program
this.program = new Program(this.gl, {
  ...

  uniforms: {
    ...

    uTexture0Size: {
      value: new Vec2()
    }
  }
})

...

// 2. Set the value of 'uTextureSize' in the render loop
gsap.ticker.add(() => {
  this.program.uniforms.uTexture0Size.value = new Vec2(
    this.textures[0].width,
    this.textures[0].height
  )
})

Add the uTexture0Size uniform to the fragment shader.

uniform vec2 uTexture0Size;

Now we have all the data we need to use the Cover function.

main() {
  ...
  
  // Use this new set of UVs to create the texture.
  // 
  // Notice that we're using the original vUv variable here.
  // That's because the 'Cover' function expect a set of UVs that
  // go from 0 to 1.
  vec2 coverUV = Cover(vUv, uResolution, uTexture0Size);
  vec4 tex0 = texture2D(uTexture0, coverUV);

  ...
}

Now check the browser and try resizing the screen. The image covers perfectly the area, just like with CSS.

Step 14 – Mask the texture

View code diff

Now it’s time to “merge” the texture and the grid of triangles; the process is extremely easy.

Our grid is a black layer with an N amount of white triangles; what happens if we multiply the texture’s colors by the value of the grid? Where the mask is black, the color of the texture would be hidden, and when the color is white the texture is fully visible.

Edit the fragment shader this way:

// Create the masks with the triangles
float mask0 = Tiles(uv);

...

// This is where the magic happens. We hide all the
// areas of the texture where the mask is black.
vec3 layer0 = tex0.rgb*mask0;

color = layer0;

gl_FragColor = vec4(color, 1.0);

And here it is. An image made of triangles whose number is configurable.

Step 15 – Fade out the image

View code diff

It’s time to work on the transition effect, so let’s start with fading out the image using the uProgress uniform.

To do so, we’re going to add a new progress parameter to the Tiles function; this parameter will be used to manage the whole animation.

Start by updating the fragment shader

...

float Tiles(vec2 uv, float progress) {
  ...

  // 'alpha' is the opacity of the triangle.
  // It goes from 1 to 0 depending on the value of
  // the 'progress' parameter.
  float alpha = smoothstep(1., 0., progress);

  // The value of both 'd' and 'u' is multiplied by
  // the value of 'alpha', making everything fade out
  // while the value of 'progress' increases.
  float d = Triangle(gv - tileOffset - tileShift, vec2(0.5));
  d *= isVisible;
  d *= alpha;

  float u = Triangle(st, vec2(0.5, 0.4));
  u *= isVisible;
  u *= alpha;

  ...
}

..

void main() {
  ...

  float progress0 = smoothstep(0.1, 0.9, uProgress);

  float mask0 = Tiles(uv, progress0);

   ...
}

It works, but it could be way more interesting if we randomize the speed of each triangle’s animation. We could do this by generating a random number using the randomID variable and using it as the first parameter of the smoothstep function.

We don’t have a function to generate random values yet, so let’s add it.

Create a Random.glsl file

float Random(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p+45.32);
  return fract(p.x * p.y);
}

#pragma glslify: export(Random)

Import it into the fragment shader

#pragma glslify: Random = require(./modules/Random)

Then use it to generate a random value for the smoothstep function.

// The 'clamp' function limits the minimum and maximum values
// respectively to 0.1 and 0.9
float fadeStart = clamp(Random(triangleID), 0.1, 0.9);

float alpha = smoothstep(fadeStart, 0., progress);

Much, much better; don’t you think?

Step 16 – Add the 2nd texture

View code diff

Adding the 2nd texture is mostly a matter of duplicating what we already did for the 1st one.

Edit the fragment shader with the code to display the 2nd texture

// Declare the uniforms
uniform sampler2D uTexture1;
uniform vec2 uTexture1Size;

...

void main() {
  ...
  
  // The progress for the 2nd texture runs backwards,
  // so the 1st parameter is higher than the 2nd.
  float progress1 = smoothstep(1.0, 0.2, uProgress);

  ...

  // Create the mask for the 2nd texture, just like
  // what we did for the 1st one.
  float mask1 = Tiles(uv, progress1);

  ... 

  // After creating the 1st texture, create the 2nd one.
  // Don't forget to re-declare the 'coverUV' variable.
  coverUV = Cover(vUv, uResolution, uTexture1Size);
  vec4 tex1 = texture2D(uTexture1, coverUV);

  ...

  // Apply the mask to the texture
  vec3 layer1 = tex1.rgb*mask1;

  // This is the key part.
  // Based on the value of 'uProgress', show one layer or the other.
  // If 'uProgress' is 0, 'layer0' is completely visible,
  // Otherwise, if 'uProgress' is 1, layer1 is visible.
  // Every value in-between will make the layers overlap.
  color = mix(layer0, layer1, uProgress);
}

Now add the proper code to the app.js file

...

// Add the uniforms to the program
this.program = new Program(this.gl, {
  ...

  uniforms: {
    ...

    uTexture1: {
      value: this.textures[1]
    },
    uTexture1Size: {
      value: new Vec2()
    }
  }
})

...

// Update the value of 'uTexture1Size' in the render loop
gsap.ticker.add(() => {
  ...

  this.program.uniforms.uTexture1Size.value = new Vec2(
    this.textures[1].width,
    this.textures[1].height
  )

  ...
})

And that’s it! Now the two images swap depending on the value of the uProgress uniform.

Step 17 – Animate triangles’ scale

View code diff

Our basic effect is in place, now it’s time to make things more interesting. Let’s start by scaling the triangles during the animation.

At the moment, the size of the triangles is hardcoded, so let’s edit the Triangle function to make it expect a new size parameter:

float Triangle(vec2 uv, vec2 position, float size) {
  float sides = 3.0;
  float blur = 0.001;

  return Shape(uv, position, size, sides, blur);
}

Then, define a size variable inside the nested for loops like this:

// Goes from 0.26 to 0.9 based on the value of 'progress'
float size = mix(0.26, 0.9, progress);

Finally, add the size variable to both the Triangle functions:

float d = Triangle(gv - tileOffset - tileShift, vec2(0.5), size);

...

float u = Triangle(st, vec2(0.5, 0.4), size);

It works, but all the triangles have all the same size, all the time; adding some randomness here too would make things a bit better:

// Define a 'sizeFactor' variable generated from the ID of
// the triangle, whose minimum value is 0.3.
float sizeFactor = max(Random(triangleID), 0.3);

// Multiply 'progress' by the value of 'sizeFactor'.
// This is enough for adding randomness to the
// triangles' scale.
float size = mix(0.26, 0.9, progress*sizeFactor);

Good! Now the triangles scale randomly during the animation. But there’s one more detail that we need to work on.

Now, when the new image starts fading in, it seems like the triangles “bounce back” to their original position; the cause is the progress1 variable:

// This goes from 1 to 0, meaning that the whole animation
// will run backwards
float progress1 = smoothstep(1.0, 0.2, uProgress);

Fixing it is really simple; we must edit the formula to make it go from -1 to 0. Edit that line like this:

// 'smoothstep' always goes from 0 to 1, but if we
// subtract 1 at the end, the result goes from -1 to 0
//
// Please note that we swapped the 1st and 2nd parameter
// top make the animation go forwards.
float progress1 = smoothstep(0.2, 1.0, uProgress) - 1.0;

And here we have it!

Step 18 – Offset each triangle

View code diff

Another thing we can do here is adding a very subtle offset to each triangle.

I’m sure you’ve already guessed how we’re going to do that: yes, we’re will use the triangleID variable to generate a random offset for each axis and add it to the triangle’s coordinate.

Add this inside the nested for loops:

// The 'Random' function generate a number that goes from 0 to 1,
// but for the offset values we must remap them so that they
// go from -1 to 1
float randomOffsetX = Random(triangleID.y);
randomOffsetX = (randomOffsetX - 0.5) * 2.0;

float randomOffsetY = Random(triangleID.x);
randomOffsetY = (randomOffsetY - 0.5) * 2.0;

vec2 randomOffset = vec2(randomOffsetX, randomOffsetY)*0.4;

Then, subtract randomOffset from the triangles’ coordinates:

float d = Triangle(gv - tileOffset - tileShift - randomOffset, vec2(0.5), size);

...

vec2 st = (gv - tileOffset - tileShift - randomOffset)*Rotate(PI) + 0.5;

Beautiful, uh?

Step 19 – Show full image

View code diff

Now, why don’t we push things even further?

First, let’s scale the triangles based on their distance from the center of the screen:

// Gets a normalized value (from 0 to 1)
float dist = distance(vec2(0.0), triangleID) / max(uGridSize.x, uGridSize.y);
dist *= 0.15;

// Add 'dist' to the size formula
float size = mix(0.02, 0.8, progress*sizeFactor)+dist;

Second, add some random rotation to each triangle:

// In order to rotate the coordinates we must multiply them by
// a mat2 variable, so let's calculate it first.
// This goes from -PI and +PI
mat2 triangleRandomRotation = Rotate(PI*(Random(triangleID) - 0.5)*2.0);

Then rotate the triangles:

// Rotate the 'gv' variable around their center.
// 'vec4(0.0, 0.4)' is there only for styling purposes; otherwise
// the triangles tend to go downwards.
vec2 rotatedGV = (gv - vec2(0.0, 0.4) - tileOffset - tileShift - randomOffset - 0.5)*triangleRandomRotation + 0.5;

// Replace 'gv' with 'rotatedGV'
float d = Triangle(rotatedGV, vec2(0.5), size);

...

// Apply the rotation to 'st' as well.
vec2 st = (gv - tileOffset - tileShift - randomOffset)*triangleRandomRotation + 0.5;

Lastly, we are going to show the full image along with all these triangles:

...

// Create a mask of the size of the grid that is used to display the full image
// The logic is the same used to determine whether to show a triangle or not.
float fullImageMask = step(abs(id.x) + 0.5, uGridSize.x);
fullImageMask *= step(abs(id.y), uGridSize.y);

// "Mask" it with the value of the triangles' grid.
// This basically creates holes in the mask.
// This step is needed because we will add this mask with the triangles, otherwise
// The final result would have areas much more luminous than the normal.
fullImageMask *= 1.0 - result;

// Set the alpha value of this mask using the `progress` parameter.
fullImageMask *= smoothstep(0.45, 0.1, abs(progress));

// Add the value of 'fullImageMask' to 'result', which already
// contains tll the triangles.
result += fullImageMask;

...

And here’s the final result!

Step 20 – Scale the textures and rotate the masks

View code diff

Another nice touch would be to scale the images a bit during the animation. Let’s do it:

...

// To scale a set of coordinates that go from 0 to 1 around
// their center, we must:
// 1. Subtract 0.5 from them
// 2. Multiply them by the value we want (values bigger than 1 will make everything smaller)
// 3. Add 0.5 back
// 
// Same goes for rotations.

// Scale the UVs of the 1st texture from 1.0 to 1.05
// This makes the texture slightly smaller during the animation.
coverUV = (coverUV - 0.5)*mix(1.0, 1.05, smoothstep(0.1, 0.5, uProgress)) + 0.5;

...

// Scale the UVs of the 1st texture from 0.95 to 1.0
// This makes the texture slightly bigger, making it the right size at the end of the transition.
coverUV = (coverUV - 0.5)*mix(0.95, 1.0, smoothstep(0.6, 1.0, uProgress)) + 0.5;

...

And yes, it works!

Finally, let’s rotate the whole mask a bit. Add this at the beginning of the Tiles function:

// Create a new set of UVs to apply a rotation matrix to, and
// use them to create the grid.
//
// This adds a subtle rotation of 3/100 PI + another rotation of
// 4/100 PI depending on the value of 'progress', which goes from -1 to 1
//
// This way, the texture that fades out rotates from the 'idle' angle to
// the 'out' angle, while the texture that fades in rotates from the 'in' angle
// to the 'idle' angle.
mat2 rotateUV = Rotate(PI*0.03 + PI*mix(0.0, 0.04, progress));
vec2 uv2 = uv*rotateUV;

// Replace uv with 'uv2' on both 'gv' and 'id'
vec2 gv = fract(uv2*5.0 + 0.25);
vec2 id = floor(uv2*5.0 + 0.25);

If you’ve done everything properly, you should see it working like the video below|

Step 21 – Add the background(s)

View code diff

It’s not over yet! Now we have to add the colored background beneath the slides, whose colors are picked directly from the images and determined by a noise texture. It’s long, but not as complicated as one might think.

Let’s start by adding the noise texture to the program; I’ve chosen the Noise_18 texture from this collection from 1MAFX.

...

this.program = new Program(this.gl, {
  ...

  uniforms: {
    ...

    uNoiseTexture: {
      value: this.noiseTexture
    }
  }
})

...

Then refactor the _loadTexture() method …

// We replaced the 'index' parameter with a 'params' parameter,
// which is an additional set of configuration for the Texture.
// This is needed when loading the noise texture.
_loadTexture(url, params = {}) {
  return new Promise(resolve => {
    const img = new Image()
    img.src = url

    img.onload = () => {
      const texture = new Texture(this.gl, {
        ...params,
        image: img
      })

      resolve(texture)
    }
  })
}

… and the _loadTextures() method:

// First, we load all the textures and assign the
// response to the 'this.textures' property.
// Then, load the noise texture and assign it to 'this.noiseTexture'.
_loadTextures() {
  return new Promise(resolve => {
    const textures = this.texturesURLs.map(url => this._loadTexture(url))
    const colorThief = new ColorThief()

    Promise
      // Load the images for the carousel
      .all(textures)
      .then(res => this.textures = res)

      // Load the noise texture
      .then(() => {
        // Since the noise will move over time, we must set
        // both the 'wrapS' and 'wrapT' property to 'this.gl.REPEAT',
        // making it repeat seamlessly.
        return this._loadTexture('/images/Noise_18.jpg', {
          wrapS: this.gl.REPEAT,
          wrapT: this.gl.REPEAT
        })
      })
      .then(res => {
        this.noiseTexture = res
        resolve()
      })
  })
}

Add then the noise texture to the fragment shader:

uniform sampler2D uNoiseTexture;

...

void main() {
  ...

  vec2 bgNoiseUV = uv;
  vec3 bgNoise = texture2D(uNoiseTexture, bgNoiseUV).rgb;

  ...

  gl_FragColor = vec4(bgNoise, 1.0);
}

A bit too much, uh? Let’s tweak it a bit by making it bigger and adding some rotation to the coordinates:

vec2 bgNoiseUV = uv;
bgNoiseUV *= 0.25; // Make it bigger
bgNoiseUV *= Rotate(PI*0.25); // Rotate by 1/4 PI
vec3 bgNoise = texture2D(uNoiseTexture, bgNoiseUV).rgb;

Much, much better! Now it’s time to animate it. First, let’s add a new uTime uniform to the Program which we’ll use to move the coordinates of the noise texture

this.program = new Program(this.gl, {
  ...
  uniforms: {
    ...

    uTime: { value: 0 }
  }
  ...
})

and update its value in the render loop

gsap.ticker.add(() => {
  ...

  this.program.uniforms.uTime.value += 0.1

  ...
})

Then, define it in the fragment shader and add it to the UV coordinates of the noise texture to move it over time

uniform float uTime;

...

void main() {
  ...

  bgNoiseUV += vec2(uTime*0.01, -uTime*0.03);

  ...
}

Cool! Now our noise is moving!

Let’s proceed by adding some color. The colors will come directly from the images, and for this, we’re going to use the color-thief library.

First, add it to the project from the terminal:

$ yarn add colorthief

And import it into app.js and create a new instance of it:

import ColorThief from 'colorthief'

...

const colorThief = new ColorThief()

Then, right after downloading the textures, loop through them and run ColorThief to get the dominant and secondary colors:

...

// Creates a 'this.color' array which contains a series
// of objects with this structure:
// {
//   primary: [255, 120, 200],
//   secondary: [90, 110, 210]
// }
//
// With 'getPalette()' we chose the color with index 3,
// but you can pick the color you want.
this.colors = res.map(e => {
  return ({
    primary: colorThief.getColor(e.image),
    secondary: colorThief.getPalette(e.image)[3]
  })
})

...

Add two new uBackground0 and uBackground1 uniforms to the Program, which contain both the primary and secondary colors of the textures.

this.program = new Program(this.gl, {
  ...

  uniforms: {
    ...

    uBackground0: {
      value: [
        this.colors[0].primary,
        this.colors[0].secondary
      ]
    },
    uBackground1: {
      value: [
        this.colors[1].primary,
        this.colors[1].secondary
      ]
    }
  }
})

Add them to the fragment shader

// This syntax tells the shader that those two uniforms
// are arrays of lenght 2 and contain vec3 variables.
uniform vec3 uBackground0[2];
uniform vec3 uBackground1[2];

Let’s now create both the backgrounds inside the main() function:

// 1. The colors go from 0 to 255, but since we need them go have a value
// between 0 and 1 we must divide them by 255
//
// 2. Setting the 'noise texture's value as the 3rd parameter,
// we can easily interpolate between the two colors.
vec3 background0 = mix(uBackground0[0] / 255., uBackground0[1] / 255., bgNoise);
vec3 background1 = mix(uBackground1[0] / 255., uBackground1[1] / 255., bgNoise);

Let’s test this:

gl_FragColor = vec4(background0, 1.0);

And it works!

The last thing we have to do is “merging” the slides with their respective backgrounds, and we’re going to use the glsl-blend package to do it. glsl-blend is a module that contains a set of blending modes for GLSL, the same blending modes that Photoshop and other graphics tools use.

Let’s start by installing it from the terminal:

$ yarn add glsl-blend

Since we simply have to put the slides on top of their backgrounds, we will need the blendNormal module:

#pragma glslify: blendNormal = require(glsl-blend/normal)

Then define two new variables that will contain the blended layers:

// Refactor the 'layer0' and 'layer1' variables to be
// of type vec4. We will need their alpha channel shortly.
vec4 layer0 = tex0*mask0;
vec4 layer1 = tex1*mask1;

...

// For the alpha value (3rd parameter) we use the alpha channel
// of the layers; otherwise the 'layer' lavels would overlap
// entirely the backgrounds
vec3 slide0 = blendNormal(background0, layer0.rgb, layer0.a);
vec3 slide1 = blendNormal(background1, layer1.rgb, layer1.a);

...

// Replace 'layer0' and 'layer1' with 'slide0' and 'slide1'
color = mix(slide0, slide1, smoothstep(0.5, 0.85, uProgress));

And here’s the final result!

Step 22 – Add carousel behavior

View code diff

Let’s start by adding the basic markup and some rough styling:

index.html

...

<nav id="controls">
  <button data-carousel-control data-dir="-1">Prev</button>
  <button data-carousel-control data-dir="1">Next</button>
</nav>

...

app.scss

...

#controls {
  align-items: center;
  bottom: 40px;
  display: flex;
  justify-content: center;
  left: 0;
  position: fixed;
  width: 100%;

  button {
    margin: 0 20px;
  }
}

...

There are a few steps to take into consideration when it comes to WebGL carousels:

  1. Determine what will be the next texture (uTexture1, in our case)
  2. Go from the first texture to the second (animate uProgress)
  3. When the animation finishes, set the animation progress back to the initial value (uProgress = 0)
  4. Set the value of the initial texture to be the same of the second (uTexture0 = uTexture1)

After this, both uTexture0 and uTexture1 will be the same, but we actually don’t care because only uTexture0 is visible.

The better manage all this stuff we should keep all this information inside a state object

constructor() {
  ...

  this.state = {
    isAnimating: false,
    currentTextureIndex: 0,
    texture0: null,
    texture1: null
  }

  ...
}

Define the above object’s texture0 and texture1 properties in the _loadTextures() method after downloading them

this.state.texture0 = res[0]
this.state.texture1 = res[1]

And then replace all the references to this.textures[0] and this.textures[1] with the textures inside the this.state object. I won’t list all of them, just refer to the code diff.

Create now a _onButtonClick() method with the following code.

_onButtonClick(e) {
  // Do nothing if an animation is already running
  if (this.state.isAnimating) return

  // Get the direction of the clicked button (defaults to 1)
  const direction = Number(e.currentTarget.dataset.dir ?? 1)

  // Define the index of the texture that will be set as texture1
  let nextTextureIndex = this.state.currentTextureIndex + direction

  if (nextTextureIndex < 0)
    nextTextureIndex = this.textures.length - 1

  if (nextTextureIndex >= this.textures.length)
    nextTextureIndex = 0


  const tl = new gsap.timeline({
    onStart: () => {
      // Prevent any other animation from starting
      this.state.isAnimating = true

      // Set the next texture to display
      this.state.texture1 = this.textures[nextTextureIndex]
      this.program.uniforms.uTexture1.value = this.state.texture1

      // Set the background colors of the next slide
      this.program.uniforms.uBackground1.value = [
        this.colors[nextTextureIndex].primary,
        this.colors[nextTextureIndex].secondary
      ]
    },
    onComplete: () => {
      // Re-enable animations
      this.state.isAnimating = false

      // Reset the `uProgress` uniform ...
      this.program.uniforms.uProgress.value = 0

      // ... and set what was only the next texture as current texture
      this.state.texture0 = this.textures[nextTextureIndex]
      this.program.uniforms.uTexture0.value = this.state.texture0

      // Same thing with the background colors
      this.program.uniforms.uBackground0.value = [
        this.colors[nextTextureIndex].primary,
        this.colors[nextTextureIndex].secondary
      ]

      // End of the animation. Set the new texture's index as the current one.
      this.state.currentTextureIndex = nextTextureIndex
    }
  })

  tl
    .to(this.program.uniforms.uProgress, {
      value: 1,
      duration: 1.3
    })
  }

Add a new this.ui object to the class in the contructor(). It will contain more stuff later.

this.ui = {
  buttons: document.querySelectorAll('[data-carousel-control]')
}

Finally, register the event listeners:

_addListeners() {
  ...

  for (const button of this.ui.buttons) {
    button.addEventListener('click', this._onButtonClick.bind(this), { passive: true })
  }

  ...
}

Aaaaaaaand it works!

But let’s tweak it by making it rotate in one direction or the other depending on what slide we want to show.

Add a new uAnimationDirection in the program, as usual

this.program = new Program(this.gl, {
  ...

  uniforms: {
    uAnimationDirection: { value: 1 }
  }
})

And update its value from the _onButtonClick() method

// This value can be -1 OR +1
this.program.uniforms.uAnimationDirection.value = direction

Lastly, update the fragment shader:

// Declare it
uniform float uAnimationDirection;

...

// Here, multiply 'progress' by the value of 'uAnimationDirection'.
// This way it rotates CCW when clicking on the 'prev' button.
mat2 rotateUV = Rotate(PI*0.03 + PI*mix(0.0, 0.04, progress*uAnimationDirection));

...

Pretty easy! It now rotates in both directions.

Step 23 – Add slides’ markup

View code diff

The very last thing that’s left is adding all the slides’ markup to the page, so here’s the code that you can copypaste!

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Triangles' grid carousel [OGL+GSAP]</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Fjalla+One&family=Noto+Sans&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="./app.scss">
</head>
<body>
  <div id="canvas-wrapper" data-canvas-wrapper></div>

  <div id="slides">
    <div class="slide" data-slide>
      <span class="slide__title | font-display" data-slide-title style="--progress: 0">We are homo ludens</span>
      <span class="slide__copy | font-copy" data-slide-copy>We are the ones who play</span>
    </div>

    <div class="slide" data-slide>
      <span class="slide__title | font-display" data-slide-title style="--progress: -110">It's dangerous</span>
      <span class="slide__copy | font-copy" data-slide-copy style="opacity: 0">to go alone, take this!</span>
    </div>

    <div class="slide" data-slide>
      <span class="slide__title | font-display" data-slide-title style="--progress: -110">Snake? Snake?</span>
      <span class="slide__copy | font-copy" data-slide-copy style="opacity: 0">SNAAAAAAAAAAAKE!</span>
    </div>
  </div>

  <nav id="controls">
    <button class="font-display" data-carousel-control data-dir="-1">Prev</button>
    <button class="font-display" data-carousel-control data-dir="1">Next</button>
  </nav>

  <script src="./app.js"></script>
</body>
</html>

app.scss

@import "functions";
@import "mixins";

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  overflow: hidden;
}

body {
  background-color: #112233;
}

.font-display {
  font-family: 'Fjalla One', sans-serif;
}

.font-copy {
  font-family: 'Noto Sans', sans-serif;
}

#canvas-wrapper {
  height: 100vh;
  position: relative;
  width: 100%;

  canvas {
    height: 100%;
    position: absolute;
    width: 100%;
  }
}

#controls {
  align-items: center;
  display: flex;
  justify-content: space-between;
  left: 0;
  padding: 0 30px;
  pointer-events: none;
  position: fixed;
  top: 50%;
  transform: translateY(-50%);
  touch-action: none;
  width: 100%;
  z-index: 5;

  button {
    background-color: transparent;
    border: none;
    color: white;
    cursor: pointer;
    font-size: 21px;
    margin: 0 20px;
    padding: 0;
    pointer-events: auto;
    position: relative;
    text-transform: uppercase;
    touch-action: auto;

    &::before,
    &::after {
      background-color: currentColor;
      content: "";
      display: block;
      height: 2px;
      opacity: 0;
      position: absolute;
      transition: 0.25s transform, 0.25s opacity;
      width: 100%;
    }

    &::before {
      top: -5px;
      transform: translateY(-5px);
    }

    &::after {
      bottom: -5px;
      transform: translateY(5px);
    }

    &:hover {

      &::before,
      &::after {
        opacity: 1;
        transform: translateY(0);
      }
    }
  }
}

#slides {
  display: grid;
  grid-template-areas:
    "."
    "slide"
    ".";
  grid-template-columns: 1fr;
  grid-template-rows: 3fr 1fr 1fr;
  height: 100%;
  left: 0;
  pointer-events: none;
  position: fixed;
  top: 0;
  touch-action: none;
  width: 100%;
  z-index: 4;
}

.slide {
  align-items: center;
  align-self: center;
  color: white;
  display: flex;
  flex-direction: column;
  grid-area: slide;
  pointer-events: auto;
  text-align: center;
  touch-action: auto;

  &__title {
    @include fluid-type(320px, 1400px, 40px, 100px);

    clip-path: polygon(
      calc(var(--progress) * 1%) 0%,
      calc(var(--progress) * 1% + 110%) 0%,
      calc(var(--progress) * 1% + 100%) 100%,
      calc(var(--progress) * 1% - 10%) 100%);
    text-shadow: 0.01em 0.04em 0 rgba(black, 0.7);
  }

  &__copy {
    @include fluid-type(320px, 1400px, 16px, 21px);
    text-shadow: 0.1em 0.1em rgba(black, 0.5);
  }
}

_functions.scss

@function strip-unit($value) {
  @return $value / ($value * 0 + 1);
}

_mixins.scss

@mixin fluid-type($min-vw, $max-vw, $min-font-size, $max-font-size) {
  $u1: unit($min-vw);
  $u2: unit($max-vw);
  $u3: unit($min-font-size);
  $u4: unit($max-font-size);

  @if $u1 == $u2 and $u1 == $u3 and $u1 == $u4 {
    & {
      font-size: $min-font-size;
      @media screen and (min-width: $min-vw) {
        font-size: calc(#{$min-font-size} + #{strip-unit($max-font-size - $min-font-size)} * ((100vw - #{$min-vw}) / #{strip-unit($max-vw - $min-vw)}));
      }
      @media screen and (min-width: $max-vw) {
        font-size: $max-font-size;
      }
    }
  }
}

Nothing too complicated here. The slides’ container is a grid element and each slide is positioned on the same cell.

Please notice that the .slide__title elements have a clip-path applied that masks them, whose position is determined by the value of a local --progress custom property. Instead of animating the whole mask, we will simply animate that single custom property using GSAP.

Now, add the slides to the this.ui object:

this.ui = {
  buttons: document.querySelectorAll('[data-carousel-control]'),
  slides: document.querySelectorAll('[data-slide]')
}

Then, in the _onButtonClick() method, get a reference of the current and next texture’s elements to animate

const currentSlide = this.ui.slides[this.state.currentTextureIndex]
const currentSlideTitle = currentSlide.querySelector('[data-slide-title]')
const currentSlideCopy = currentSlide.querySelector('[data-slide-copy]')

const nextSlide = this.ui.slides[nextTextureIndex]
const nextSlideTitle = nextSlide.querySelector('[data-slide-title]')
const nextSlideCopy = nextSlide.querySelector('[data-slide-copy]')

Finally, update the timeline. This moves the current mask from the idle position to the right, and che next texture’s mask from left to the idle position.

tl
  .add('start')
  .to(currentSlideTitle, {
    '--progress': 110,
    duration: 0.5
  })
  .fromTo(nextSlideTitle, { '--progress': -110 }, {
    '--progress': 0,
    duration: 1
  }, '<0.1')

  .to(currentSlideCopy, { opacity: 0, duration: 0.35 }, 'start+=0.1')
  .to(nextSlideCopy, { opacity: 1, duration: 0.5 }, '>')

  .to(this.program.uniforms.uProgress, {
    value: 1,
    duration: 1.5
  }, 'start')

And that’s it!

Step 24 – Final tweaks

View code diff

We’re almost done, but there are still a few things left to tweak.

Let’s start by adding a slight rotation to the triangles. Edit the triangleRandomRotation variable in the fragment shader:

// Usual technique to rotate something around its center.
// The original value goes from -PI to +PI, then is
// multiplied by 0.1 (you can tweak this value)
// and multiplied again by the value of the 'progress',
// making it rotate during the animation.
float triangleAnimationRotation = PI*((Random(triangleID.yx) - 0.5) * 2.0)*0.1*progress*uAnimationDirection;

mat2 triangleRandomRotation = Rotate(triangleAnimationRotation+PI*(Random(triangleID) - 0.5)*2.0);

Also, I don’t like how the image overlaps the triangles in-between the animation, it looks really “flat”. So let’s make the triangles overlap the image. The formula is the following:

// This interpolates the value of 'result' from 1 to 0.5 based on
// the 'progress' parameter, making the mask half-transparent and
// giving the look that the triangles are overlapping the image.
fullImageMask *= 1.0 - (result * mix(1.0, 0.5, abs(progress*2.0)));

Conclusion

You did it! You finally reached the end! I know it’s been long but for this I wanted to proceed with small steps explaining the reasoning between every single step, something that I think is more useful than the result itself.

Francesco Michelini

I'm a freelance creative developer who has recently fallen in love with WebGL, GLSL and 3D modeling. I always focus on improving my development and design skills. I'm also a gamer since I was 5 y.o.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!