Animación de slideshow usando WebGL con librería curtainsJS

Animación realizada con WebGL para transición de imágenes usando librería curtainsjs. Imagen como displacement entre transición y modificaciones de vertex y fragment shaders
Animación de slideshow usando WebGL con librería curtainsJS

Ha sido mi primera toma de contacto con WebGL, algo que siempre quise mirarme ya que por ahí veía animaciones y transiciones bastante chulas. Aún sigo toqueteando, es un mundo. Mi idea era hacerlo sin ningún framework tipo ThreeJS o PixiJS, pero a la vez que veía que era muy trabajoso y requería de mucho esfuerzo, descubrí una librería muy simple y fácil de usar: https://www.curtainsjs.com/. Recomiendo ojearla, trastearla y empezar a usar los ejemplos que tienen creada en la Doc.

Si decides clonar mi repo, que tengo en el footer, te recomiendo que lo arranques con https://parceljs.org/

Recursos interesantes, al menos para un novato como yo en WebGL

Propiedades de clase

En el constructor de la clase WebglSlides definimos atributos necesarios

class WebglSlides {
    constructor(set) {
        this.canvas = set.canvas

        this.planeElement = set.planeElement
        this.multiTexturesPlane = null
        this.slidesState = {
            activeTextureIndex: 1,
            nextTextureIndex: null,
            maxTextures: set.planeElement.querySelectorAll("img").length - 1, // -1 to displacement
            navs: set.navs,

            isChanging: false,
            transitionTimer: 0,
        }
        this.params = {
            vertexShader: document.getElementById("vs")?.textContent || vertex,
            fragmentShader: document.getElementById("fs")?.textContent || fragment,
            uniforms: {
                transitionTimer: {
                    name: "uTransitionTimer",
                    type: "1f",
                    value: 0,
                },
            },
        }

        this.init()
    }

}

La instancia de la clase nos dara el contexto WebGL y añade el canvas a nuestro envolvente. Recibirá como parámetro un objeto con los elementos del DOM:

  1. <div class="canvas">: envolvente donde se creará el elemento canvas con curtainsJS
  2. <section class="slides multi-textures">: donde colocaremos las imágenes del slideshow y que serán usadas con curtainsJS para crear las texturas
  3. <button class="btn" data-goto="prev o next" type="button">: botones para controlar la paginación de los slides

Los tres primeros atributos son las referencias al canvas, planos y texturas.

Con this.slidesState = {} definimos la textura activa, la paginación, el estado de la transición y controlar los tiempos en la transformacion de los shaders.

Con this.params = {} definimos los shaders. Los ficheros que contienen los shaders son importados con ES6, también se pueden incluir como etiquetas script. Para poder acceder y realizar las modificaciones sobre los vertex lo haremos accediendo con this.params.uniforms.transitionTimer.name = uTransitionTimer

Finalmente llamamos al método init() para iniciar el slideshow. Este método hace la llamada a otros tres métodos.

Iniciar curtains

setupCurtains() {
    this.curtains = new Curtains({
        container: this.canvas,
        watchScroll: false,
        pixelRatio: Math.min(1.5, window.devicePixelRatio)
    })
    this.curtains.onError(() => this.error());
    this.curtains.onContextLost(() => this.restoreContext());
}

Instanciamos Curtains con tres parámetros

El primera parámetro, container será la referencia el elemento del DOM

El segundo parámetro, watchScroll lo fijamos a false ya que no necesitamos hacer escuchas de scroll para el slideshow

El tercero, pixelRatio optimiza la animación al dispositivo del usuario

Iniciar planos y texturas

initPlane() {
  this.multiTexturesPlane = new Plane(this.curtains, this.planeElement, this.params)

  this.multiTexturesPlane
    .onLoading(texture => {
      texture.setMinFilter(this.curtains.gl.LINEAR_MIPMAP_NEAREST)
    })
    .onReady(() => {
      const activeTexture = this.multiTexturesPlane.createTexture({
        sampler: "activeTexture",
        fromTexture: this.multiTexturesPlane.textures[this.slidesState.activeTextureIndex]
      })
      const nextTexture = this.multiTexturesPlane.createTexture({
        sampler: "nextTexture",
        fromTexture: this.multiTexturesPlane.textures[this.slidesState.nextTextureIndex]
      })

      this.initEvent(activeTexture, nextTexture)

    })
}

Se guarda la instancia del Plano en multiTexturesPlane. Al instanciarlo, le pasamos tres parámetros: instancia de curtains, las imágenes y los shaders

Usamos dos métodos de curtainjs para los Planos

En la carga le especificamos LINEAR_MIPMAP_NEAREST para que el "rellenado" de pixel sea más perfecta

Cuando ha cargado, creamos dos texturas, una será la textura actual y la otra será la siguiente. Al crear las dos texturas, en la key sampler apuntamos a sus nombres en los fragments y vertex shaders activeTexture y nextTexture

Actualizar texturas

update() {
    this.multiTexturesPlane.onRender(() => {
        if (this.slidesState.isChanging) {
            this.slidesState.transitionTimer += (90 - this.slidesState.transitionTimer) * 0.04;

            if (this.slidesState.transitionTimer >= 88.5 && this.slidesState.transitionTimer !== 90) {
                this.slidesState.transitionTimer = 90;
            }
        }

        this.multiTexturesPlane.uniforms.transitionTimer.value = this.slidesState.transitionTimer;
    });
}

Una vez que ya tenemos las texturas, llamamos al método onRender() para hacer modificaciones en función del tiempo sobre la textura activa

Registrar eventos click

initEvent(activeTexture, nextTexture) {
  this.slidesState.navs.forEach(nav => {
    nav.addEventListener('click', event => {

      if (!this.slidesState.isChanging) {
        this.curtains.enableDrawing()

        this.slidesState.isChanging = true;

        const to = event.target.getAttribute('data-goto');
        this.navigationDirection(to);

        nextTexture.setSource(this.multiTexturesPlane.images[this.slidesState.nextTextureIndex]);

        setTimeout(() => {

          this.curtains.disableDrawing();

          this.slidesState.isChanging = false;
          this.slidesState.activeTextureIndex = this.slidesState.nextTextureIndex;

          activeTexture.setSource(this.multiTexturesPlane.images[this.slidesState.activeTextureIndex]);

          this.slidesState.transitionTimer = 0;

        }, 1700);
      }
    })
  })
}

Se registra evento a los botones con atributos data-goto y se detecta el valor next o prev para decidir si será la textura prevía o siguiente. Esta lógica es realizada con el método navigationDirection(to)

Al animación se inicia si detectamos que actualmente no se está produciendo la animación if (!this.slidesState.isChanging). Cambiamos la textura siguiente mediente nextTexture.setSource(this.multiTexturesPlane.images[this.slidesState.nextTextureIndex]) y dentro de timeOut actualizamos la textura siguiente activeTexture.setSource(this.multiTexturesPlane.images[this.slidesState.activeTextureIndex]). Finalmente, reseteamos los tiempos this.slidesState.transitionTimer = 0 que fueron modificados en otro método

Detectar la textura a cargar

navigationDirection(to) {
  if (to == 'next') {
    if (this.slidesState.activeTextureIndex < this.slidesState.maxTextures) {
      this.slidesState.nextTextureIndex = this.slidesState.activeTextureIndex + 1
    } else {
      this.slidesState.nextTextureIndex = 1
    }
  } else {
    if (this.slidesState.activeTextureIndex <= 1) {
      this.slidesState.nextTextureIndex = this.slidesState.maxTextures
    } else {
      this.slidesState.nextTextureIndex = this.slidesState.activeTextureIndex - 1
    }
  }
}

Este método sólo es para detectar la Textura que se debe cargar, y actualizar los índices. Este método es llamda en dos ocasiones, para la actualización de textura o para cargar una alternativa de slider caso de que fallase en algún momento curtainsjs

Html del WebGL slideshow

<main class="wrapper">
  <div class="canvas"></div>
  <section class="slides multi-textures">
    <img src="./src/img/displacement4.jpg" data-sampler="displacement">
    <img src="./src/img/city/amsterdam.jpg">
    <img src="./src/img/city/bilbao.jpg">
    <img src="./src/img/city/golden-gate-bridge.jpg">
    <img src="./src/img/city/valencia.jpg">
    <img src="./src/img/city/water.jpg">
    <img src="./src/img/city/peine.jpg">
    <nav class="nav">
      <button class="btn" data-goto="prev" type="button">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H6M12 5l-7 7 7 7"/></svg>
      </button>
      <button class="btn" data-goto="next" type="button">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h13M12 5l7 7-7 7"/></svg>
      </button>
    </nav>
  </section>
</main>

La estructura del html si es importante. La envolvente wrapper es el contenedor de todo, incluso podremos usar más de un slideshow creando más instancias.

En <div class="canvas"> es donde la librería curtainsJS va a crear el elemento canvas

Dentro de <section class="slides multi-textures"> colocaremos todas las imágenes. En este caso, usamos una imagen displacement para las transiciones, la primera imagen debe contener el atributo data-sampler="displacement" para que curtains pueda interpretarlo

Código CSS del WebGL slideshow

Me ahorro la explicación, no tiene nada especial. Si se quiere ver, en el GitHub está todo. ;)