Wawa Sensei logo

Slider d'images

Starter pack

Dans cette leçon, nous allons apprendre à charger et utiliser des textures d'images dans nos shaders pour créer ce slider d'images réactif :

Et voici le résultat final sur mobile :

Le projet est inspiré de ce Codepen par Sikriti Dakua.

J'espère que vous êtes motivés pour apprendre comment créer cet effet, commençons !

Projet de départ

Notre projet de départ contient une section en plein écran avec un logo, un bouton de menu et un composant <Canvas> avec un cube blanc au milieu de la scène.

Nous utiliserons Framer Motion pour animer les éléments HTML mais vous pouvez utiliser n'importe quelle autre bibliothèque ou même du CSS pur pour les animer. Nous n'utiliserons que la version par défaut de Framer Motion, inutile d'installer le package 3D.

Pour l'UI, j'ai choisi Tailwind CSS mais n'hésitez pas à utiliser la solution avec laquelle vous êtes le plus à l'aise.

Le dossier public/textures/optimized contient les images que nous utiliserons dans le slider. Je les ai générées en utilisant l'IA avec Leonardo.Ai et les ai optimisées avec Squoosh. J'ai choisi un ratio de 3:4 pour avoir une orientation portrait qui rendra bien sur mobile.

AI Generated Image

Une des images que nous utiliserons, optimisée avec Squoosh de 3,9mb à 311kb.

Composant de diaporama d'images

Commençons par remplacer le cube blanc par un plan qui sera utilisé pour afficher les images. Nous créons un nouveau composant nommé ImageSlider :

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

Ajustez la largeur et la hauteur au ratio d'aspect des images que vous utiliserez.

La prop fillPercent sera utilisée pour ajuster la taille du plan afin qu'il ne prenne qu'un pourcentage de la hauteur/largeur de l'écran.

Dans App.jsx, nous importons le composant ImageSlider et remplaçons le cube blanc par celui-ci :

import { ImageSlider } from "./ImageSlider";

// ...

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

// ...

Et voici le résultat :

Image Slider Plane

Le plan prend trop de place

Nous voulons que notre plan soit réactif et ne prenne que 75% (fillPercent) de la hauteur de l'écran. Nous pouvons y parvenir en utilisant le hook useThree pour obtenir les dimensions du viewport et créer un facteur d'échelle pour ajuster la taille du plan :

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>
  );
};

Pour calculer notre facteur d'échelle, nous divisons la viewport.height par la height du plan divisée par fillPercent. Cela nous donnera un ratio que nous pouvons utiliser pour mettre à l'échelle le plan.

Pour comprendre les mathématiques derrière cela, nous pouvons penser à la viewport.height comme la hauteur maximale du plan. Si la hauteur de notre viewport est de 3 et la hauteur de notre plan est de 4, nous devons mettre à l'échelle le plan par 3 / 4 pour le faire tenir dans l'écran. Mais comme nous voulons prendre seulement 75% de la hauteur de l'écran, nous divisons la hauteur du plan par fillPercent pour obtenir la nouvelle hauteur de référence. Ce qui donne 4 / 0.75 = 5.3333.

Ensuite, nous multiplions la width et la height par le ratio pour obtenir les nouvelles dimensions.

Cela fonctionne bien lorsque nous redimensionnons verticalement mais pas horizontalement. Nous devons ajuster la largeur du plan pour ne prendre que 75% de la largeur de l'écran lorsque la hauteur du viewport est supérieure à la largeur.

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>
  );
};

N'oubliez pas de changer le ratio de const à let pour pouvoir le réassigner. (Ou utilisez un opérateur ternaire à la place)

Maintenant, le plan est réactif et ne prend que 75% de la hauteur ou de la largeur de l'écran en fonction des dimensions de l'écran.

Nous sommes prêts à afficher les images sur le plan.

Texture d'image de shader personnalisé

Tout d'abord, chargeons l'une des images et affichons-la sur le <meshBasicMaterial> actuel en utilisant le 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

L'image est bien affichée sur le plan.

Maintenant, parce que nous voulons ajouter des effets créatifs lors de la transition entre les images et au survol, nous allons créer un material de shader personnalisé pour pouvoir afficher deux images en même temps et les animer.

ImageSliderMaterial

Créons notre material de shader personnalisé nommé ImageSliderMaterial. J'ai choisi de le garder dans le même fichier que le composant ImageSlider car il y est étroitement lié. Mais vous pouvez créer un fichier séparé si vous préférez.

// ...
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,
});

// ...

Nous stockons notre texture dans un uniform nommé uTexture et nous le transmettons au fragment shader pour l'afficher.

Le type de l'uniform uTexture est sampler2D, lequel est utilisé pour stocker les textures 2D.

Pour extraire la couleur de la texture à une position spécifique, nous utilisons la fonction texture2D et lui passons le uTexture et les coordonnées vUv.

Remplaçons notre meshBasicMaterial par notre nouveau 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

L'image est affichée en utilisant notre material de shader personnalisé.

Color grading

Je sais que tu commences à avoir une vision aiguisée 🦅 et tu as remarqué que le color grading de l'image semble différent !

C'est parce que le <meshBasicMaterial/> effectue un traitement supplémentaire dans le fragment shader pour ajuster la couleur en fonction du tone mapping et du color space choisis sur le renderer.

Bien que cela soit quelque chose que nous pourrions reproduire manuellement dans notre shader personnalisé, ce n'est pas l'objectif de cette leçon et c'est un sujet avancé.

À la place, nous pouvons utiliser des fragments prêts à l'emploi pour activer les mêmes effets que les matériaux standard de Three.js. Si tu regardes le code source du meshBasicMaterial, tu verras que c'est un mélange d'instructions #include et de code personnalisé.

meshBasicMaterial source code

Pour rendre le code facilement réutilisable et maintenable, Three.js utilise un préprocesseur pour inclure du code provenant d'autres fichiers. Heureusement, nous pouvons aussi utiliser ces shader chunks dans notre shader material personnalisé !

Ajoutons ces deux lignes à la fin de notre fragment shader :

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

Pour mieux comprendre comment fonctionnent les shader chunks, cet outil te permet de cliquer sur les instructions d'inclusion pour voir le code inclus : ycw.github.io/three-shaderlib-skim

Image Slider Material with Shader Chunks

Le color grading est maintenant le même que celui du meshBasicMaterial. 🎨

Avant d'aller plus loin sur le shader, préparons notre UI.

Gestion de l'État avec Zustand

Zustand est une bibliothèque de gestion d'état petite, rapide et évolutive qui nous permet de créer un store global pour gérer l'état de notre application.

C'est une alternative à Redux ou à une solution de context personnalisée pour partager l'état entre les composants et gérer une logique d'état complexe. (Même si ce n'est pas le cas dans notre projet. Notre logique est simple.)

Ajoutons Zustand à notre projet :

yarn add zustand

Et créons un nouveau fichier nommé useSlider.js dans un dossier hooks :

import { create } from "zustand";

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

La fonction create prend une fonction comme argument qui recevra une fonction set pour mettre à jour et fusionner l'état pour nous. Nous pouvons mettre notre état et nos méthodes à l'intérieur de l'objet retourné.

D'abord, les données dont nous avons besoin :

// ...

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 stockera l'index de la diapositive actuelle.
  • direction stockera la direction de la transition.
  • items stockera les données des diapositives. (chemin de l'image, nom court, titre, couleur de fond et description)

Nous pouvons maintenant créer les méthodes pour aller à la diapositive précédente et à la suivante :

// ...

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 fonction set fusionnera le nouvel état avec le précédent. Nous utilisons l'opérateur modulo pour revenir à la première diapositive lorsque nous atteignons la dernière et vice versa.

Notre état est prêt, préparons notre UI.

Interface Slider

Nous allons créer un nouveau composant nommé Slider pour afficher les détails du texte du slide et les boutons de navigation. Dans 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">
      {/* MID 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>
        {/* MID 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>
  );
};

Nous n'allons pas entrer dans les détails du CSS utilisé, mais laissez-moi vous expliquer les points principaux :

  • Le conteneur central est un div reproduisant les dimensions et le ratio d'aspect de notre plan 3D. De cette manière, nous pouvons positionner le texte et les boutons par rapport au plan.
  • Nous utilisons aspect-square pour conserver le ratio d'aspect du conteneur.
  • Les boutons en forme de flèche viennent de Heroicons.
  • Le titre et le nom court ont des dimensions fixes et un débordement caché pour créer des effets de texte intéressants plus tard.
  • Les classes md: sont utilisées pour ajuster la mise en page sur les écrans plus grands.

Ajoutons notre composant Slider à côté du Canvas dans 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;

Le slider est affiché avant le canvas.

Nous devons changer le style du Canvas pour qu'il soit affiché comme un fond et qu'il prenne toute la largeur et la hauteur de l'écran :

{/* ... */}
<Canvas
  camera={{ position: [0, 0, 5], fov: 30 }}
  className="top-0 left-0"
  style={{
    // Remplacement du style par défaut appliqué par R3F
    width: "100%",
    height: "100%",
    position: "absolute",
  }}
>
{/* ... */}

Ajoutons des polices et styles personnalisés à notre 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;
  }
}

Les classes text-outline sont utilisées pour créer un contour autour du texte.

Pour ajouter les polices personnalisées, nous devons mettre à jour notre 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: [],
};

Nous avons maintenant une interface utilisateur élégante :

Effets de texte

Pour rendre les transitions plus intéressantes, nous ajouterons des effets de texte au titre, au nom court et à la description.

Tout d'abord, nous devons obtenir la direction pour savoir si nous allons à la diapositive suivante ou précédente. Nous pouvons l'obtenir à partir du hook useSlider :

// ...

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

Pour pouvoir animer le texte affiché précédemment et le nouveau texte, nous avons besoin de l'index de la diapositive précédente. Nous pouvons le calculer facilement :

// ...

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;
  }
  // ...
};

Maintenant, nous pouvons ajouter les effets de texte avec l'aide de Framer Motion. Commençons par le titre en bas à droite :

// ...
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>
  );
};

Nous utilisons la prop animate pour passer entre les différents états et nous définissons les différentes propriétés pour chaque état dans la prop variants.

Pour animer chaque caractère, nous divisons le titre en un tableau de caractères et nous utilisons la prop staggerChildren pour retarder l'animation de chaque caractère.

La prop from est utilisée pour définir la position de départ de l'animation.

Retirons le overflow-hidden du titre pour voir l'effet :

Le texte du titre est animé en entrée et sortie.

Ajoutons le même effet au nom court :

// ...

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>
  );
};

Et un effet simple de fondu pour la description :

// ...

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>
  );
};

Notre interface utilisateur est maintenant animée et prête à être utilisée.

Nous sommes prêts à passer à la partie la plus intéressante de cette leçon : l'effet de transition de shader ! 🎉

Effet de transition d'image

Comme nous l'avons fait pour l'animation du texte, pour effectuer la transition entre les images, nous aurons besoin des textures de l'image actuelle et de l'image précédente.

Stockons le chemin de l'image précédente dans notre composant 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]);

  // ...
};

Avec le hook useEffect, nous stockons le chemin de l'image actuelle dans l'état lastImage et lorsque l'image change, nous mettons à jour l'état lastImage avec le nouveau chemin de l'image.

Avant d'utiliser le prevTexture dans notre shader, et avant d'oublier, préchargeons toutes les images pour éviter le scintillement lorsque nous changeons de diapositive :

// ...

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

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

En faisant cela, nous préchargeons toutes les images, nous pourrions ajouter en toute sécurité un écran de chargement au début de notre site Web pour éviter tout scintillement.

Ajoutons maintenant deux uniformes à notre ImageSliderMaterial pour stocker la texture précédente et la progression de la transition :

// ...

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>
  );
};

Nous utilisons la fonction mix pour interpoler entre la texture précédente et la texture actuelle en fonction de l'uniforme uProgression.

On peut voir un mélange entre l'image précédente et l'image actuelle.

Effet de fondu entrant et sortant

Animons l'uniforme uProgression pour créer une transition fluide entre les images.

Tout d'abord, nous avons besoin d'une référence à notre material pour pouvoir mettre à jour l'uniforme uProgression :

// ...
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>
  );
};

Nous pouvons nous débarrasser de la prop uProgression car nous la mettrons à jour manuellement.

Maintenant, dans le useEffect lorsque l'image change, nous pouvons définir uProgression à 0 et l'animer jusqu'à 1 dans une boucle 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
    );
  });
  // ...
};

Nous avons maintenant une transition fluide entre les images.

Construisons à partir de cela pour créer un effet plus intéressant.

Position déformée

Pour rendre la transition plus intéressante, nous allons pousser les images dans la direction de la transition.

Nous utiliserons les coordonnées vUv pour déformer la position des images. Ajoutons un uniforme uDistortion à notre ImageSliderMaterial et utilisons-le pour déformer les coordonnées vUv :

End of lesson preview

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