Wawa Sensei logo

Deslizador de imágenes

Starter pack

En esta lección aprenderemos cómo cargar y utilizar imágenes de textura en nuestros shaders para crear este deslizador de imágenes responsivo:

Y aquí está el resultado final en móvil:

El proyecto está inspirado en este Codepen por Sikriti Dakua.

Espero que estés motivado para aprender cómo crear este efecto, ¡comencemos!

Proyecto inicial

Nuestro proyecto inicial contiene una sección de pantalla completa que incluye un logo, un botón de menú y un componente <Canvas> con un cubo blanco en el centro de la escena.

Utilizaremos Framer Motion para animar los elementos HTML pero puedes usar cualquier otra biblioteca o incluso CSS puro para animarlos. Solo usaremos la versión predeterminada de Framer Motion, no es necesario instalar el paquete 3D.

Para la UI elegí Tailwind CSS pero siéntete libre de usar la solución con la que te sientas más cómodo.

La carpeta public/textures/optimized contiene las imágenes que utilizaremos en el deslizador. Las generé utilizando IA con Leonardo.Ai y las optimicé con Squoosh. Elegí una proporción de 3:4 para tener una orientación de retrato que se verá bien en móvil.

Imagen Generada por IA

Una de las imágenes que usaremos, optimizada con Squoosh de 3.9mb a 311kb.

Componente de carrusel de imágenes

Vamos a empezar reemplazando el cubo blanco con un plano que será usado para mostrar las imágenes. Creamos un nuevo componente llamado ImageSlider:

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  return (
    <mesh>
      <planeGeometry args={[width, height]} />
      <meshBasicMaterial color="white" />
    </mesh>
  );
};

Ajusta el ancho y la altura a la relación de aspecto de las imágenes que usarás.

La prop fillPercent se usará para ajustar el tamaño del plano de modo que ocupe solo un porcentaje de la altura/anchura de la pantalla.

En App.jsx importamos el componente ImageSlider y reemplazamos el cubo blanco con él:

import { ImageSlider } from "./ImageSlider";

// ...

function App() {
  return (
    <>
      {/* ... */}
      <Canvas camera={{ position: [0, 0, 5], fov: 30 }}>
        <color attach="background" args={["#201d24"]} />
        <ImageSlider />
      </Canvas>
      {/* ... */}
    </>
  );
}

// ...

Y aquí está el resultado:

Image Slider Plane

El plano está ocupando demasiado espacio

Queremos que nuestro plano sea responsive y ocupe solo 75%(fillPercent) de la altura de la pantalla. Podemos lograr esto usando el hook useThree para obtener las dimensiones del viewport y crear un factor de escala para ajustar el tamaño del plano:

import { useThree } from "@react-three/fiber";

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  const viewport = useThree((state) => state.viewport);
  const ratio = viewport.height / (height / fillPercent);

  return (
    <mesh>
      <planeGeometry args={[width * ratio, height * ratio]} />
      <meshBasicMaterial color="white" />
    </mesh>
  );
};

Para calcular nuestro factor de escala, dividimos el viewport.height por la height del plano dividida por fillPercent. Esto nos dará una proporción que podemos usar para escalar el plano.

Para entender las matemáticas detrás de esto, podemos pensar en el viewport.height como la altura máxima del plano. Si la altura de nuestro viewport es 3 y la altura de nuestro plano es 4, necesitamos escalar el plano por 3 / 4 para que se ajuste a la pantalla. Pero como queremos ocupar solo el 75% de la altura de la pantalla, dividimos la altura del plano por fillPercent para obtener la nueva altura de referencia. Lo que nos da 4 / 0.75 = 5.3333.

Luego multiplicamos el width y height por el ratio para obtener las nuevas dimensiones.

Funciona bien cuando redimensionamos verticalmente pero no horizontalmente. Necesitamos ajustar el ancho del plano para que ocupe solo el 75% del ancho de la pantalla cuando la altura del viewport es mayor que el ancho.

import { useThree } from "@react-three/fiber";

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  const viewport = useThree((state) => state.viewport);
  let ratio = viewport.height / (height / fillPercent);
  if (viewport.width < viewport.height) {
    ratio = viewport.width / (width / fillPercent);
  }

  return (
    <mesh>
      <planeGeometry args={[width * ratio, height * ratio]} />
      <meshBasicMaterial color="white" />
    </mesh>
  );
};

No olvides cambiar el ratio de const a let para poder reasignarlo. (O usa un operador ternario en su lugar)

Ahora el plano es responsive y ocupa solo el 75% de la altura o anchura de la pantalla dependiendo de las dimensiones de la pantalla.

Estamos listos para mostrar las imágenes en el plano.

Textura de imagen con shader personalizado

Primero, carguemos una de las imágenes y mostrémosla en el <meshBasicMaterial> actual usando el hook useTexture de Drei:

import { useTexture } from "@react-three/drei";
// ...

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  const image =
    "textures/optimized/Default_authentic_futuristic_cottage_with_garden_outside_0.jpg";
  const texture = useTexture(image);
  // ...
  return (
    <mesh>
      <planeGeometry args={[width * ratio, height * ratio]} />
      <meshBasicMaterial color="white" map={texture} />
    </mesh>
  );
};

Image Slider Plane with Texture

La imagen se muestra de manera agradable en el plano.

Ahora, porque queremos agregar efectos creativos durante la transición entre imágenes y al pasar el cursor, vamos a crear un material shader personalizado para poder mostrar dos imágenes al mismo tiempo y animarlas.

ImageSliderMaterial

Vamos a crear nuestro material shader personalizado llamado ImageSliderMaterial. He decidido mantenerlo en el mismo archivo que el componente ImageSlider ya que está estrechamente relacionado con él. Pero puedes crear un archivo separado si lo prefieres.

// ...
import { shaderMaterial } from "@react-three/drei";
import { extend } from "@react-three/fiber";

const ImageSliderMaterial = shaderMaterial(
  {
    uTexture: undefined,
  },
  /*glsl*/ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  /*glsl*/ ` 
  varying vec2 vUv;
  uniform sampler2D uTexture;

  void main() {
    vec2 uv = vUv;
    vec4 curTexture = texture2D(uTexture, vUv);
          
    gl_FragColor = curTexture;
  }`
);

extend({
  ImageSliderMaterial,
});

// ...

Almacenamos nuestra textura en un uniform llamado uTexture y lo pasamos al fragment shader para mostrarla.

El tipo del uniform uTexture es sampler2D, que se utiliza para almacenar texturas 2D.

Para extraer el color de la textura en una posición específica, usamos la función texture2D y le pasamos el uTexture y las coordenadas vUv.

Reemplacemos nuestro meshBasicMaterial con nuestro nuevo ImageSliderMaterial:

// ...

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  // ...
  return (
    <mesh>
      <planeGeometry args={[width * ratio, height * ratio]} />
      <imageSliderMaterial uTexture={texture} />
    </mesh>
  );
};

Image Slider Plane with ImageSliderMaterial

La imagen se muestra usando nuestro material shader personalizado.

Color grading

¡Sé que estás desarrollando una vista aguda 🦅 y has notado que la gradación del color de la imagen se ve diferente!

Esto se debe a que <meshBasicMaterial/> realiza un procesamiento adicional dentro del fragment shader para ajustar el color basado en el tone mapping y el color space elegidos en el renderer.

Aunque esto es algo que podríamos replicar manualmente en nuestro custom shader, no es el objetivo de esta lección y es un tema avanzado.

En su lugar, podemos usar fragments listos para habilitar los mismos efectos que los materiales estándar de Three.js. Si examinas el código fuente de meshBasicMaterial, verás que es una mezcla de declaraciones #include y código personalizado.

meshBasicMaterial source code

Para hacer el código fácilmente reutilizable y mantenible, Three.js usa un preprocesador para incluir código de otros archivos. Por suerte, ¡podemos usar esos shader chunks en nuestro custom shader material también!

Vamos a añadir esas dos líneas al final de nuestro fragment shader:

  void main() {
    // ...
    #include <tonemapping_fragment>
    #include <encodings_fragment>
  }

Para entender mejor cómo funcionan los shader chunks, esta herramienta te permite hacer clic en las declaraciones de include para ver el código que se incluye: ycw.github.io/three-shaderlib-skim

Image Slider Material with Shader Chunks

La gradación de color ahora es la misma que la de meshBasicMaterial. 🎨

Antes de profundizar en el shader, vamos a preparar nuestra UI.

Zustand State Management

Zustand es una biblioteca de gestión de estado pequeña, rápida y escalable que nos permite crear una tienda global para gestionar el estado de nuestra aplicación.

Es una alternativa a Redux o una solución de contexto personalizada para compartir estado entre componentes y gestionar lógica de estado compleja. (Aunque no sea el caso en nuestro proyecto. Nuestra lógica es sencilla.)

Vamos a agregar Zustand a nuestro proyecto:

yarn add zustand

Y crear un nuevo archivo llamado useSlider.js en una carpeta hooks:

import { create } from "zustand";

export const useSlider = create((set) => ({}));

La función create toma una función como argumento que recibirá una función set para actualizar y fusionar el estado por nosotros. Podemos poner nuestro estado y métodos dentro del objeto devuelto.

Primero los datos que necesitamos:

// ...

export const useSlider = create((set) => ({
  curSlide: 0,
  direction: "start",
  items: [
    {
      image:
        "textures/optimized/Default_authentic_futuristic_cottage_with_garden_outside_0.jpg",
      short: "PH",
      title: "Relax",
      description: "Enjoy your peace of mind.",
      color: "#201d24",
    },
    {
      image:
        "textures/optimized/Default_balinese_futuristic_villa_with_garden_outside_jungle_0.jpg",
      short: "TK",
      title: "Breath",
      color: "#263a27",
      description: "Feel the nature surrounding you.",
    },
    {
      image:
        "textures/optimized/Default_desert_arabic_futuristic_villa_with_garden_oasis_outsi_0.jpg",
      short: "OZ",
      title: "Travel",
      color: "#8b6d40",
      description: "Brave the unknown.",
    },
    {
      image:
        "textures/optimized/Default_scandinavian_ice_futuristic_villa_with_garden_outside_0.jpg",
      short: "SK",
      title: "Calm",
      color: "#72a3ca",
      description: "Free your mind.",
    },
    {
      image:
        "textures/optimized/Default_traditional_japanese_futuristic_villa_with_garden_outs_0.jpg",
      short: "AU",
      title: "Feel",
      color: "#c67e90",
      description: "Emotions and experiences.",
    },
  ],
}));
  • curSlide almacenará el índice de la diapositiva actual.
  • direction almacenará la dirección de la transición.
  • items almacenará los datos de las diapositivas. (ruta a la imagen, nombre corto, título, color de fondo y descripción)

Ahora podemos crear los métodos para ir a la diapositiva anterior y siguiente:

// ...

export const useSlider = create((set) => ({
  // ...
  nextSlide: () =>
    set((state) => ({
      curSlide: (state.curSlide + 1) % state.items.length,
      direction: "next",
    })),
  prevSlide: () =>
    set((state) => ({
      curSlide: (state.curSlide - 1 + state.items.length) % state.items.length,
      direction: "prev",
    })),
}));

La función set fusionará el nuevo estado con el anterior. Usamos el operador de módulo para volver a la primera diapositiva cuando llegamos a la última y viceversa.

Nuestro estado está listo, vamos a preparar nuestra UI.

Slider UI

Crearemos un nuevo componente llamado Slider para mostrar los detalles del texto de la diapositiva y los botones de navegación. En Slider.jsx:

import { useSlider } from "./hooks/useSlider";

export const Slider = () => {
  const { curSlide, items, nextSlide, prevSlide } = useSlider();
  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* MIDDLE CONTAINER */}
      <div className="h-auto w-screen aspect-square max-h-[75vh] md:w-auto md:h-[75vh] md:aspect-[3/4] relative max-w-[100vw]">
        {/* TOP LEFT */}
        <div className="w-48 md:w-72 left-4 md:left-0 md:-translate-x-1/2 absolute -top-8 ">
          <h1
            className="relative antialiased overflow-hidden font-display 
          text-[5rem] h-[4rem]  leading-[4rem]
          md:text-[11rem] md:h-[7rem]  md:leading-[7rem] font-bold text-white block"
          >
            {items[curSlide].short}
          </h1>
        </div>
        {/* MIDDLE ARROWS */}
        <button
          className="absolute left-4 md:-left-14 top-1/2 -translate-y-1/2 pointer-events-auto"
          onClick={prevSlide}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
            strokeWidth={1.5}
            stroke="currentColor"
            className="w-8 h-8 stroke-white hover:opacity-50 transition-opacity duration-300 ease-in-out"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
            />
          </svg>
        </button>
        <button
          className="absolute right-4 md:-right-14 top-1/2 -translate-y-1/2 pointer-events-auto"
          onClick={nextSlide}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
            strokeWidth={1.5}
            stroke="currentColor"
            className="w-8 h-8 stroke-white hover:opacity-50 transition-opacity duration-300 ease-in-out"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
            />
          </svg>
        </button>

        {/* BOTTOM RIGHT */}
        <div className="absolute right-4 md:right-auto md:left-full md:-ml-20 bottom-8">
          <h2
            className="antialiased font-display font-bold 
            text-transparent text-outline-0.5 
            block overflow-hidden relative w-[50vw]
            text-5xl h-16
            md:text-8xl md:h-28"
          >
            {items[curSlide].title}
          </h2>
        </div>
        <div className="absolute right-4 md:right-auto md:left-full md:top-full md:-mt-10 bottom-8 md:bottom-auto">
          <p className="text-white w-64 text-sm font-thin italic ml-4 relative">
            {items[curSlide].description}
          </p>
        </div>
      </div>
    </div>
  );
};

No profundizaremos en los detalles del CSS utilizado, pero permíteme explicarte los puntos principales:

  • El contenedor del medio es un div que reproduce las dimensiones y la relación de aspecto de nuestro plano 3D. De esa manera podemos posicionar el texto y los botones en relación al plano.
  • Usamos aspect-square para mantener la relación de aspecto del contenedor.
  • Los botones de flecha provienen de Heroicons.
  • El título y el nombre corto tienen dimensiones fijas y desbordamiento oculto para crear efectos de texto interesantes más adelante.
  • Las clases md: se usan para ajustar el diseño en pantallas más grandes.

Agreguemos nuestro componente Slider junto al Canvas en App.jsx:

// ...
import { Slider } from "./Slider";

function App() {
  return (
    <>
      <main className="bg-black">
        <section className="w-full h-screen relative">
          {/* ... */}
          <Slider />
          <Canvas camera={{ position: [0, 0, 5], fov: 30 }}>
            <color attach="background" args={["#201d24"]} />
            <ImageSlider />
          </Canvas>
        </section>
        {/* ... */}
      </main>
    </>
  );
}

export default App;

El slider se muestra antes que el canvas.

Necesitamos cambiar el estilo del Canvas para que se muestre como un fondo y ocupe el ancho y alto completo de la pantalla:

{/* ... */}
<Canvas
  camera={{ position: [0, 0, 5], fov: 30 }}
  className="top-0 left-0"
  style={{
    // Overriding the default style applied by R3F
    width: "100%",
    height: "100%",
    position: "absolute",
  }}
>
{/* ... */}

Agreguemos fuentes y estilos personalizados a nuestro index.css:

@import url("https://fonts.googleapis.com/css2?family=Red+Rose:wght@700&display=swap&family=Poppins:ital,wght@1,100&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  .text-outline-px {
    -webkit-text-stroke: 1px white;
  }
  .text-outline-0\.5 {
    -webkit-text-stroke: 2px white;
  }
  .text-outline-1 {
    -webkit-text-stroke: 4px white;
  }
}

Las clases text-outline se utilizan para crear un contorno alrededor del texto.

Para agregar las fuentes personalizadas, necesitamos actualizar nuestro tailwind.config.js:

/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
    fontFamily: {
      sans: ["Poppins", "sans-serif"],
      display: ["Red Rose", "sans-serif"],
    },
  },
  plugins: [],
};

Ahora tenemos una interfaz de usuario con buen aspecto:

Efectos de texto

Para hacer las transiciones más interesantes, añadiremos algunos efectos de texto al título, nombre corto y descripción.

Primero, necesitamos obtener la dirección para saber si vamos a la siguiente o a la diapositiva previa. Podemos obtenerla del hook useSlider:

// ...

export const Slider = () => {
  const { curSlide, items, nextSlide, prevSlide, direction } = useSlider();
  // ...
};

Para poder animar el texto que se mostró anteriormente y el nuevo texto, necesitamos el índice de la diapositiva anterior. Podemos calcularlo fácilmente:

// ...

export const Slider = () => {
  // ...
  let prevIdx = direction === "next" ? curSlide - 1 : curSlide + 1;
  if (prevIdx === items.length) {
    prevIdx = 0;
  } else if (prevIdx === -1) {
    prevIdx = items.length - 1;
  }
  // ...
};

Ahora podemos añadir los efectos de texto con la ayuda de Framer Motion. Empecemos con el título en la esquina inferior derecha:

// ...
import { motion } from "framer-motion";
const TEXT_TRANSITION_HEIGHT = 150;

export const Slider = () => {
  // ...
  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* MIDDLE CONTAINER */}
      <div className="h-auto w-screen aspect-square max-h-[75vh] md:w-auto md:h-[75vh] md:aspect-[3/4] relative max-w-[100vw]">
        {/* ... */}
        {/* BOTTOM RIGHT */}
        <div className="absolute right-4 md:right-auto md:left-full md:-ml-20 bottom-8">
          <h2
            className="antialiased font-display font-bold 
                  text-transparent text-outline-0.5 
                  block overflow-hidden relative w-[50vw]
                  text-5xl h-16
                  md:text-8xl md:h-28"
          >
            {items.map((item, idx) => (
              <motion.div
                key={idx}
                className="absolute top-0 left-0 w-full text-right md:text-left"
                animate={
                  idx === curSlide
                    ? "current"
                    : idx === prevIdx
                    ? "prev"
                    : "next"
                }
                variants={{
                  current: {
                    transition: {
                      delay: 0.4,
                      staggerChildren: 0.06,
                    },
                  },
                }}
              >
                {item.title.split("").map((char, idx) => (
                  <motion.span
                    key={idx}
                    className="inline-block" // to make the transform work (translateY)
                    variants={{
                      current: {
                        translateY: 0,
                        transition: {
                          duration: 0.8,
                          from:
                            direction === "prev"
                              ? -TEXT_TRANSITION_HEIGHT
                              : TEXT_TRANSITION_HEIGHT,
                          type: "spring",
                          bounce: 0.2,
                        },
                      },
                      prev: {
                        translateY:
                          direction === "prev"
                            ? TEXT_TRANSITION_HEIGHT
                            : -TEXT_TRANSITION_HEIGHT,
                        transition: {
                          duration: 0.8,
                          from:
                            direction === "start" ? -TEXT_TRANSITION_HEIGHT : 0,
                        },
                      },
                      next: {
                        translateY: TEXT_TRANSITION_HEIGHT,
                        transition: {
                          from: TEXT_TRANSITION_HEIGHT,
                        },
                      },
                    }}
                  >
                    {char}
                  </motion.span>
                ))}
              </motion.div>
            ))}
          </h2>
        </div>
        {/* ... */}
      </div>
    </div>
  );
};

Usamos el prop animate para cambiar entre los diferentes estados y definimos las diferentes propiedades para cada estado en el prop variants.

Para animar cada carácter, dividimos el título en un array de caracteres y usamos el prop staggerChildren para retrasar la animación de cada carácter.

El prop from se usa para definir la posición inicial de la animación.

Vamos a eliminar el overflow-hidden del título para ver el efecto:

El texto del título se anima entrando y saliendo.

Vamos a añadir el mismo efecto al nombre corto:

// ...

export const Slider = () => {
  // ...
  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* MIDDLE CONTAINER */}
      <div className="h-auto w-screen aspect-square max-h-[75vh] md:w-auto md:h-[75vh] md:aspect-[3/4] relative max-w-[100vw]">
        {/* TOP LEFT */}
        <div className="w-48 md:w-72 left-4 md:left-0 md:-translate-x-1/2 absolute -top-8 ">
          <h1
            className="relative antialiased overflow-hidden font-display 
                    text-[5rem] h-[4rem]  leading-[4rem]
                    md:text-[11rem] md:h-[7rem]  md:leading-[7rem] font-bold text-white block"
          >
            {items.map((_item, idx) => (
              <motion.span
                key={idx}
                className="absolute top-0 left-0 md:text-center w-full"
                animate={
                  idx === curSlide
                    ? "current"
                    : idx === prevIdx
                    ? "prev"
                    : "next"
                }
                variants={{
                  current: {
                    translateY: 0,
                    transition: {
                      duration: 0.8,
                      from:
                        direction === "prev"
                          ? -TEXT_TRANSITION_HEIGHT
                          : TEXT_TRANSITION_HEIGHT,
                      type: "spring",
                      bounce: 0.2,
                      delay: 0.4,
                    },
                  },
                  prev: {
                    translateY:
                      direction === "prev"
                        ? TEXT_TRANSITION_HEIGHT
                        : -TEXT_TRANSITION_HEIGHT,
                    transition: {
                      type: "spring",
                      bounce: 0.2,
                      delay: 0.2,
                      from: direction === "start" ? -TEXT_TRANSITION_HEIGHT : 0,
                    },
                  },
                  next: {
                    translateY: TEXT_TRANSITION_HEIGHT,
                    transition: {
                      from: TEXT_TRANSITION_HEIGHT,
                    },
                  },
                }}
              >
                {items[idx].short}
              </motion.span>
            ))}
          </h1>
        </div>
        {/* ... */}
      </div>
    </div>
  );
};

Y un simple efecto de desvanecimiento para la descripción:

// ...

export const Slider = () => {
  // ...

  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* MIDDLE CONTAINER */}
      <div className="h-auto w-screen aspect-square max-h-[75vh] md:w-auto md:h-[75vh] md:aspect-[3/4] relative max-w-[100vw]">
        {/* ... */}
        {/* BOTTOM RIGHT */}
        {/* ... */}
        <div className="absolute right-4 md:right-auto md:left-full md:top-full md:-mt-10 bottom-8 md:bottom-auto">
          <p className="text-white w-64 text-sm font-thin italic ml-4 relative">
            {items.map((item, idx) => (
              <motion.span
                key={idx}
                className="absolute top-0 left-0 w-full text-right md:text-left"
                animate={
                  idx === curSlide
                    ? "current"
                    : idx === prevIdx
                    ? "prev"
                    : "next"
                }
                initial={{
                  opacity: 0,
                }}
                variants={{
                  current: {
                    opacity: 1,
                    transition: {
                      duration: 1.2,
                      delay: 0.6,
                      from: 0,
                    },
                  },
                }}
              >
                {item.description}
              </motion.span>
            ))}
          </p>
        </div>
      </div>
    </div>
  );
};

Nuestra interfaz de usuario ahora está animada y lista para ser usada.

Estamos listos para saltar a la parte más interesante de esta lección: el efecto de transición con shader! 🎉

Efecto de transición de imágenes

Como hicimos para animar el texto, para hacer la transición entre las imágenes, necesitaremos las texturas de la imagen actual y la anterior.

Vamos a guardar la ruta de la imagen anterior en nuestro componente ImageSlider:

// ...
import { useSlider } from "./hooks/useSlider";
import { useEffect, useState } from "react";

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  const { items, curSlide } = useSlider();
  const image = items[curSlide].image;
  const texture = useTexture(image);
  const [lastImage, setLastImage] = useState(image);
  const prevTexture = useTexture(lastImage);

  useEffect(() => {
    const newImage = image;

    return () => {
      setLastImage(newImage);
    };
  }, [image]);

  // ...
};

Con el hook useEffect, guardamos la ruta de la imagen actual en el estado lastImage y cuando la imagen cambia, actualizamos el estado lastImage con la nueva ruta de la imagen.

Antes de usar la prevTexture en nuestro shader, y antes de que se nos olvide, vamos a precargar todas las imágenes para evitar parpadeos cuando cambiamos la diapositiva:

// ...

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  // ...
};

useSlider.getState().items.forEach((item) => {
  useTexture.preload(item.image);
});

Haciendo esto, estamos precargando todas las imágenes, podríamos agregar de manera segura una pantalla de carga al principio de nuestro sitio web para evitar cualquier parpadeo.

Ahora, vamos a agregar dos uniforms a nuestro ImageSliderMaterial para guardar la textura anterior y el progreso de la transición:

// ...

const ImageSliderMaterial = shaderMaterial(
  {
    uProgression: 1.0,
    uTexture: undefined,
    uPrevTexture: undefined,
  },
  /*glsl*/ `
  // ...
  `,
  /*glsl*/ `
  varying vec2 vUv;
    uniform sampler2D uTexture;
    uniform sampler2D uPrevTexture;
    uniform float uProgression;
  
    void main() {
      vec2 uv = vUv;
      vec4 curTexture = texture2D(uTexture, vUv);
      vec4 prevTexture = texture2D(uPrevTexture, vUv);
      
      vec4 finalTexture = mix(prevTexture, curTexture, uProgression);
      gl_FragColor = finalTexture;
      #include <tonemapping_fragment>
      #include <encodings_fragment>
    }`
);
// ...

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  // ...
  return (
    <mesh>
      <planeGeometry args={[width * ratio, height * ratio]} />
      <imageSliderMaterial
        uTexture={texture}
        uPrevTexture={prevTexture}
        uProgression={0.5}
      />
    </mesh>
  );
};

Usamos la función mix para interpolar entre la textura anterior y la actual basado en el uniform uProgression.

Podemos ver una mezcla entre la imagen anterior y la actual.

Efecto de desvanecimiento

Vamos a animar el uProgression uniforme para crear una transición suave entre las imágenes.

Primero, necesitamos una referencia a nuestro material para poder actualizar el uProgression uniforme:

// ...
import { useRef } from "react";

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  // ...
  const material = useRef();
  // ...
  return (
    <mesh>
      <planeGeometry args={[width * ratio, height * ratio]} />
      <imageSliderMaterial
        ref={material}
        uTexture={texture}
        uPrevTexture={prevTexture}
      />
    </mesh>
  );
};

Podemos prescindir de la prop uProgression ya que la actualizaremos manualmente.

Ahora en el useEffect cuando la imagen cambie, podemos establecer el uProgression a 0 y animarlo a 1 en un bucle useFrame:

// ...
import { useFrame } from "@react-three/fiber";
import { MathUtils } from "three/src/math/MathUtils.js";

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  // ...
  useEffect(() => {
    const newImage = image;
    material.current.uProgression = 0;

    return () => {
      setLastImage(newImage);
    };
  }, [image]);

  useFrame(() => {
    material.current.uProgression = MathUtils.lerp(
      material.current.uProgression,
      1,
      0.05
    );
  });
  // ...
};

Ahora tenemos una transición suave entre las imágenes.

Vamos a construir sobre esto para crear un efecto más interesante.

Posición distorsionada

Para hacer la transición más interesante, moveremos las imágenes en la dirección de la transición.

Usaremos las coordenadas vUv para distorsionar la posición de las imágenes. Agreguemos un uDistortion uniform a nuestro ImageSliderMaterial y usémoslo para distorsionar las coordenadas vUv:

End of lesson preview

To get access to the entire lesson, you need to purchase the course.