Image slider

Starter pack

In questa lezione impareremo a caricare e utilizzare immagini texture nei nostri shader per creare questo slider di immagini reattivo:

Ecco il risultato finale su mobile:

Il progetto è ispirato da questo Codepen di Sikriti Dakua.

Spero che tu sia motivato ad imparare come creare questo effetto, iniziamo!

Progetto iniziale

Il nostro progetto iniziale contiene una sezione a schermo intero con un logo, un pulsante menu e un componente <Canvas> con un cubo bianco al centro della scena.

Useremo Framer Motion per animare gli elementi HTML, ma puoi utilizzare qualsiasi altra libreria o anche solo CSS puro per animarli. Utilizzeremo solo la versione di default di Framer Motion, non è necessario installare il pacchetto 3D.

Per l'UI ho scelto Tailwind CSS, ma sentiti libero di utilizzare la soluzione che preferisci.

La cartella public/textures/optimized contiene le immagini che utilizzeremo nello slider. Le ho generate utilizzando l'IA con Leonardo.Ai e ottimizzate con Squoosh. Ho scelto un rapporto di 3:4 per avere un'orientazione verticale che sarà visivamente gradevole su mobile.

Immagine Generata con AI

Una delle immagini che utilizzeremo, ottimizzata con Squoosh da 3.9mb a 311kb.

Componente del cursore per immagini

Iniziamo sostituendo il cubo bianco con un piano che verrà utilizzato per visualizzare le immagini. Creiamo un nuovo componente chiamato ImageSlider:

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

Adjusta la larghezza e l'altezza al rapporto d'aspetto delle immagini che utilizzerai.

La prop fillPercent verrà utilizzata per regolare la dimensione del piano in modo che occupi solo una percentuale dell'altezza/larghezza dello schermo.

In App.jsx importiamo il componente ImageSlider e sostituiamo il cubo bianco con esso:

import { ImageSlider } from "./ImageSlider";

// ...

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

// ...

Ed ecco il risultato:

Image Slider Plane

Il piano occupa troppo spazio

Vogliamo che il nostro piano sia reattivo e occupi solo il 75% (fillPercent) dell'altezza dello schermo. Possiamo ottenere questo risultato utilizzando l'hook useThree per ottenere le dimensioni del viewport e creando un fattore di scala per regolare la dimensione del piano:

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

Per calcolare il nostro fattore di scala dividiamo la viewport.height per l'altezza del piano diviso per fillPercent. Questo ci darà un rapporto che possiamo utilizzare per scalare il piano.

Per capire la matematica dietro a questo, possiamo pensare alla viewport.height come all'altezza massima del piano. Se l'altezza del nostro viewport è 3 e l'altezza del nostro piano è 4, dobbiamo scalare il piano di 3 / 4 per farlo adattare allo schermo. Ma poiché vogliamo occupare solo il 75% dell'altezza dello schermo, dividiamo l'altezza del piano per fillPercent per ottenere la nuova altezza di riferimento. Che ci dà 4 / 0.75 = 5.3333.

Poi moltiplichiamo la width e la height per il ratio per ottenere le nuove dimensioni.

Funziona bene quando ridimensioniamo verticalmente ma non orizzontalmente. Dobbiamo regolare la larghezza del piano affinché occupi solo il 75% della larghezza dello schermo quando l'altezza del viewport è maggiore della larghezza.

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

Non dimenticare di cambiare il ratio da const a let per poterlo riassegnare. (Oppure usa un operatore ternario)

Ora il piano è reattivo e occupa solo il 75% dell'altezza o della larghezza dello schermo a seconda delle dimensioni dello schermo.

Siamo pronti per visualizzare le immagini sul piano.

Texture dell'immagine con shader personalizzato

Per prima cosa, carichiamo una delle immagini e visualizziamola sul <meshBasicMaterial> corrente utilizzando il hook useTexture di 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'immagine è visualizzata con cura sul piano.

Ora, poiché vogliamo aggiungere effetti creativi durante la transizione tra le immagini e al passaggio del mouse, creeremo un materiale shader personalizzato per poter visualizzare due immagini contemporaneamente e animarle.

ImageSliderMaterial

Creiamo il nostro materiale shader personalizzato chiamato ImageSliderMaterial. Ho scelto di mantenerlo nello stesso file del componente ImageSlider poiché è strettamente correlato ad esso, ma puoi crearlo in un file separato se preferisci.

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

// ...

Conserviamo la nostra texture in un uniform chiamato uTexture e la passiamo allo shader di frammentazione per visualizzarla.

Il tipo di uniform uTexture è sampler2D, utilizzato per memorizzare texture 2D.

Per estrarre il colore della texture in una posizione specifica, utilizziamo la funzione texture2D e le passiamo il uTexture e le coordinate vUv.

Sostituiamo il nostro meshBasicMaterial con il nostro nuovo 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'immagine è visualizzata utilizzando il nostro materiale shader personalizzato.

Correzione del colore

So che inizi ad avere un occhio acuto 🦅 e hai notato che la correzione del colore dell'immagine appare diversa!

Questo accade perché il <meshBasicMaterial/> esegue un'elaborazione extra all'interno dello shader di frammento per regolare il colore in base al tone mapping e allo color space scelti sul renderer.

Anche se potremmo replicare manualmente questa cosa nel nostro shader personalizzato, non è l'obiettivo di questa lezione ed è un argomento avanzato.

Invece, possiamo utilizzare frammenti pronti all'uso per abilitare gli stessi effetti dei materiali standard di Three.js. Se guardi al codice sorgente di meshBasicMaterial, vedrai che è un mix di dichiarazioni #include e codice personalizzato.

Codice sorgente meshBasicMaterial

Per rendere il codice facilmente riutilizzabile e manutenibile, Three.js utilizza un preprocessore per includere codice da altri file. Fortunatamente, possiamo utilizzare quegli shader chunks anche nel nostro shader material personalizzato!

Aggiungiamo queste due righe alla fine del nostro shader di frammento:

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

Per capire meglio come funzionano gli shader chunks, questo strumento ti permette di cliccare sulle dichiarazioni include per vedere il codice che è incluso: ycw.github.io/three-shaderlib-skim

Materiale Image Slider con Shader Chunks

La correzione del colore è ora la stessa del meshBasicMaterial. 🎨

Prima di andare oltre sullo shader, prepariamo la nostra interfaccia utente.

Gestione dello Stato con Zustand

Zustand è una piccola libreria di gestione dello stato, veloce e scalabile, che ci permette di creare uno store globale per gestire lo stato della nostra applicazione.

È un'alternativa a Redux o a una soluzione custom di context per condividere lo stato tra i componenti e gestire logiche di stato complesse. (Anche se non è il caso del nostro progetto, dove la logica è semplice.)

Aggiungiamo Zustand al nostro progetto:

yarn add zustand

E creiamo un nuovo file chiamato useSlider.js in una cartella hooks:

import { create } from "zustand";

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

La funzione create prende una funzione come argomento che riceverà una funzione set per aggiornare e unire lo stato per noi. Possiamo inserire il nostro stato e i metodi all'interno dell'oggetto restituito.

Prima i dati di cui abbiamo bisogno:

// ...

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 memorizzerà l'indice della slide corrente.
  • direction memorizzerà la direzione della transizione.
  • items memorizzerà i dati delle slide. (percorso dell'immagine, nome breve, titolo, colore di sfondo e descrizione)

Ora possiamo creare i metodi per andare alla slide precedente e successiva:

// ...

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 funzione set unirà il nuovo stato con quello precedente. Usiamo l'operatore modulo per tornare alla prima slide quando raggiungiamo l'ultima e viceversa.

Il nostro stato è pronto, prepariamo la nostra UI.

Slider UI

Creeremo un nuovo componente denominato Slider per visualizzare i dettagli testuali delle diapositive e i pulsanti di navigazione. In 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">
      {/* CONTENITORE CENTRALE */}
      <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]">
        {/* IN ALTO A SINISTRA */}
        <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>
        {/* FRECCE AL CENTRO */}
        <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>

        {/* IN BASSO A DESTRA */}
        <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>
  );
};

Non passeremo attraverso i dettagli del CSS utilizzato, ma lasciatemi spiegare i punti principali:

  • Il contenitore centrale è un div che riproduce le dimensioni e il rapporto d'aspetto del nostro piano 3D. In questo modo possiamo posizionare il testo e i pulsanti in relazione al piano.
  • Usiamo aspect-square per mantenere il rapporto d'aspetto del contenitore.
  • I pulsanti a freccia provengono da Heroicons.
  • Il titolo e il nome breve hanno dimensioni fisse e overflow hidden per creare successivamente effetti di testo interessanti.
  • Le classi md: sono usate per adattare il layout su schermi più grandi.

Aggiungiamo il nostro componente Slider accanto al Canvas in 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;

Lo slider è visualizzato prima del canvas.

Dobbiamo cambiare lo stile del Canvas affinché sia visualizzato come sfondo e prenda la larghezza e l'altezza dell'intero schermo:

{/* ... */}
<Canvas
  camera={{ position: [0, 0, 5], fov: 30 }}
  className="top-0 left-0"
  style={{
    // Sovrascrivere lo stile di default applicato da R3F
    width: "100%",
    height: "100%",
    position: "absolute",
  }}
>
{/* ... */}

Aggiungiamo font e stili personalizzati al nostro 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;
  }
}

Le classi text-outline sono usate per creare un contorno attorno al testo.

Per aggiungere i font personalizzati, dobbiamo aggiornare il nostro 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: [],
};

Ora abbiamo un'interfaccia utente dall'aspetto gradevole:

Effetti di testo

Per rendere le transizioni più interessanti, aggiungeremo alcuni effetti di testo al titolo, nome breve e descrizione.

Per prima cosa, dobbiamo ottenere la direzione per sapere se stiamo andando alla diapositiva successiva o precedente. Possiamo ottenerla dal hook useSlider:

// ...

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

Per poter animare il testo precedentemente visualizzato in uscita e il nuovo testo in ingresso, abbiamo bisogno dell'indice della diapositiva precedente. Possiamo calcolarlo facilmente:

// ...

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

Ora possiamo aggiungere gli effetti di testo con l'aiuto di Framer Motion. Iniziamo con il titolo in basso a destra:

// ...
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">
      {/* CONTENITORE CENTRALE */}
      <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]">
        {/* ... */}
        {/* IN BASSO A DESTRA */}
        <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" // per far funzionare il transform (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>
  );
};

Usiamo la prop animate per passare tra i diversi stati e definiamo le diverse proprietà per ciascuno stato nella prop variants.

Per animare ogni carattere, dividiamo il titolo in un array di caratteri e usiamo la prop staggerChildren per ritardare l'animazione di ciascun carattere.

La prop from è usata per definire la posizione iniziale dell'animazione.

Rimuoviamo l'overflow-hidden dal titolo per vedere l'effetto:

Il testo del titolo è animato in entrata e in uscita.

Aggiungiamo lo stesso effetto al nome breve:

// ...

export const Slider = () => {
  // ...
  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* CONTENITORE CENTRALE */}
      <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]">
        {/* IN ALTO A SINISTRA */}
        <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>
  );
};

E un semplice effetto di fade in e out per la descrizione:

// ...

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

  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* CONTENITORE CENTRALE */}
      <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]">
        {/* ... */}
        {/* IN BASSO A DESTRA */}
        {/* ... */}
        <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>
  );
};

La nostra UI è ora animata e pronta per essere utilizzata.

Siamo pronti per passare alla parte più interessante di questa lezione: l'effetto di transizione shader! 🎉

Effetto di transizione delle immagini

Come abbiamo fatto per l'animazione del testo, per effettuare la transizione tra le immagini avremo bisogno delle texture dell'immagine corrente e di quella precedente.

Salviamo il percorso dell'immagine precedente nel nostro 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 l'hook useEffect, memorizziamo il percorso dell'immagine corrente nello stato lastImage e quando l'immagine cambia, aggiorniamo lo stato lastImage con il nuovo percorso dell'immagine.

Prima di utilizzare il prevTexture nel nostro shader, e prima che ce ne dimentichiamo, precarichiamo tutte le immagini per evitare sfarfallii quando cambiamo diapositiva:

// ...

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

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

Facendo questo, precarichiamo tutte le immagini, e possiamo tranquillamente aggiungere una schermata di caricamento all'inizio del nostro sito web per evitare qualsiasi sfarfallio.

Ora, aggiungiamo due uniformi al nostro ImageSliderMaterial per memorizzare la texture precedente e il progresso della transizione:

// ...

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

Utilizziamo la funzione mix per interpolare tra la texture precedente e quella corrente basandoci sull'uniforme uProgression.

Possiamo vedere una mescolanza tra l'immagine precedente e quella corrente.

Effetto di dissolvenza

Animiamo l'uniforme uProgression per creare una transizione fluida tra le immagini.

Per prima cosa, abbiamo bisogno di un riferimento al nostro material per poter aggiornare 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>
  );
};

Possiamo eliminare la prop uProgression dato che la aggiorneremo manualmente.

Ora nel useEffect, quando l'immagine cambia, possiamo impostare uProgression a 0 e animarla verso 1 in un loop 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
    );
  });
  // ...
};

Ora abbiamo una transizione fluida tra le immagini.

Costruiamo su questo per creare un effetto più interessante.

Posizione distorta

Per rendere la transizione più interessante, spingeremo le immagini nella direzione della transizione.

Utilizzeremo le coordinate vUv per distorcere la posizione delle immagini. Aggiungiamo un uniform uDistortion al nostro ImageSliderMaterial e usiamolo per distorcere le coordinate vUv:

Three.js logoReact logo

React Three Fiber: The Ultimate Guide to 3D Web Development

✨ You have reached the end of the preview ✨

Go to the next level with Three.js and React Three Fiber!

Get full access to this lesson and the complete course when you enroll:

  • 🔓 Full lesson videos with no limits
  • 💻 Access to the final source code
  • 🎓 Course progress tracking & completion
  • 💬 Invite to our private Discord community
Unlock the Full Course – Just $85

One-time payment. Lifetime updates included.