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
javascript,webgl
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. ;)