Image slider

Starter pack

Nesta lição, aprenderemos como carregar e usar imagens de textura em nossos shaders para criar este slider de imagem responsivo:

E aqui está o resultado final no celular:

O projeto é inspirado neste Codepen por Sikriti Dakua.

Espero que você esteja motivado a aprender como criar este efeito, vamos começar!

Projeto inicial

Nosso projeto inicial contém uma seção em tela cheia com um logo, um botão de menu e um componente <Canvas> com um cubo branco no meio da cena.

Usaremos o Framer Motion para animar os elementos HTML, mas você pode usar qualquer outra biblioteca ou até mesmo CSS puro para animá-los. Usaremos apenas a versão padrão do Framer Motion, não é necessário instalar o pacote 3D.

Para a UI escolhi Tailwind CSS, mas sinta-se à vontade para usar a solução com a qual você se sinta mais confortável.

A pasta public/textures/optimized contém as imagens que usaremos no slider. Eu as gerei usando IA com Leonardo.Ai e otimizei com Squoosh. Escolhi uma proporção de 3:4 para ter uma orientação de retrato que ficará boa no celular.

AI Generated Image

Uma das imagens que usaremos, otimizada com Squoosh de 3.9mb para 311kb.

Componente de slider de imagem

Vamos começar substituindo o cubo branco por um plano que será utilizado para exibir as imagens. Criamos um novo componente chamado ImageSlider:

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

Ajuste a largura e a altura de acordo com a proporção das imagens que você usará.

A prop fillPercent será usada para ajustar o tamanho do plano para ocupar apenas uma porcentagem da altura/largura da tela.

No App.jsx importamos o componente ImageSlider e substituímos o cubo branco por ele:

import { ImageSlider } from "./ImageSlider";

// ...

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

// ...

E aqui está o resultado:

Image Slider Plane

O plano está ocupando muito espaço

Queremos que nosso plano seja responsivo e ocupe apenas 75% (fillPercent) da altura da tela. Podemos conseguir isso usando o hook useThree para obter as dimensões do viewport e criar um fator de escala para ajustar o tamanho do 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 nosso fator de escala, dividimos a viewport.height pela height do plano dividida por fillPercent. Isso nos dará um ratio que podemos usar para escalar o plano.

Para entender a matemática por trás disso, podemos pensar na viewport.height como a altura máxima do plano. Se nossa altura da viewport for 3 e a altura do plano for 4, precisamos escalar o plano por 3 / 4 para que ele se encaixe na tela. Porém, como queremos ocupar apenas 75% da altura da tela, dividimos a altura do plano por fillPercent para obter a nova altura de referência. O que dá 4 / 0.75 = 5.3333.

Então multiplicamos a width e a height pelo ratio para obter as novas dimensões.

Funciona bem quando redimensionamos verticalmente, mas não horizontalmente. Precisamos ajustar a largura do plano para ocupar apenas 75% da largura da tela quando a altura da viewport for maior que a largura.

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ão se esqueça de mudar o ratio de const para let para poder reassinalá-lo. (Ou use um operador ternário em vez disso)

Agora o plano é responsivo e ocupa apenas 75% da altura ou largura da tela, dependendo das dimensões da tela.

Estamos prontos para exibir as imagens no plano.

Custom shader image texture

Primeiro, vamos carregar uma das imagens e exibi-la no <meshBasicMaterial> atual usando o hook useTexture da 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

A imagem é exibida de forma agradável no plano.

Agora, como queremos adicionar efeitos criativos durante a transição entre imagens e no hover, vamos criar um material de shader personalizado para poder exibir duas imagens ao mesmo tempo e animá-las.

ImageSliderMaterial

Vamos criar nosso material de shader personalizado chamado ImageSliderMaterial. Eu escolhi mantê-lo no mesmo arquivo do componente ImageSlider pois está intimamente relacionado a ele. Mas você pode criar um arquivo separado, se preferir.

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

// ...

Armazenamos nossa textura em um uniform chamado uTexture e o passamos para o fragment shader para exibi-lo.

O tipo do uniform uTexture é sampler2D que é utilizado para armazenar texturas 2D.

Para extrair a cor da textura em uma posição específica, usamos a função texture2D e passamos uTexture e as coordenadas vUv.

Vamos substituir nosso meshBasicMaterial pelo nosso novo 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

A imagem é exibida usando nosso material de shader personalizado.

Color grading

Eu sei que você começou a ter olhos de águia 🦅 e notou que a gradação de cores da imagem está diferente!

Isso acontece porque o <meshBasicMaterial/> faz um processamento extra dentro do fragment shader para ajustar a cor com base no tone mapping e color space escolhidos no renderizador.

Embora isso seja algo que poderíamos replicar manualmente em nosso shader personalizado, esse não é o objetivo desta lição e é um tópico avançado.

Em vez disso, podemos usar fragmentos prontos para habilitar os mesmos efeitos que os materiais padrão do Three.js. Se você olhar o código-fonte do meshBasicMaterial, verá que é uma mistura de declarações #include e código personalizado.

meshBasicMaterial source code

Para tornar o código facilmente reutilizável e sustentável, o Three.js usa um pré-processador para incluir código de outros arquivos. Felizmente, podemos usar esses chunks de shader no nosso material de shader personalizado também!

Vamos adicionar essas duas linhas no final do nosso fragment shader:

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

Para entender melhor como os chunks de shader funcionam, esta ferramenta permite que você clique nas declarações de inclusões para ver o código que é incluído: ycw.github.io/three-shaderlib-skim

Image Slider Material with Shader Chunks

A gradação de cores agora é a mesma que a do meshBasicMaterial. 🎨

Antes de prosseguir com o shader, vamos preparar nossa UI.

Zustand State Management

Zustand é uma biblioteca de gerenciamento de estado pequena, rápida e escalável que nos permite criar uma loja global para gerenciar o estado da nossa aplicação.

É uma alternativa ao Redux ou a uma solução de contexto customizada para compartilhar estado entre componentes e gerenciar lógica de estado complexa. (Mesmo se não for o caso no nosso projeto. Nossa lógica é simples.)

Vamos adicionar Zustand ao nosso projeto:

yarn add zustand

E criar um novo arquivo chamado useSlider.js em uma pasta hooks:

import { create } from "zustand";

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

A função create recebe uma função como argumento que receberá uma função set para atualizar e mesclar o estado para nós. Podemos colocar nosso estado e métodos dentro do objeto retornado.

Primeiro, os dados que precisamos:

// ...

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 vai armazenar o índice do slide atual.
  • direction vai armazenar a direção da transição.
  • items vai armazenar os dados dos slides. (caminho para a imagem, nome curto, título, cor de fundo e descrição)

Agora podemos criar os métodos para ir para o slide anterior e próximo slide:

// ...

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

A função set vai mesclar o novo estado com o anterior. Usamos o operador módulo para voltar ao primeiro slide quando atingimos o último e vice-versa.

Nosso estado está pronto, vamos preparar nossa UI.

Slider UI

Vamos criar um novo componente chamado Slider para exibir os detalhes do texto do slide e os botões de navegação. Em 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>
  );
};

Não entraremos nos detalhes do CSS usado, mas deixe-me explicar os pontos principais:

  • O contêiner do meio é um div que reproduz as dimensões e a proporção do nosso plano 3D. Dessa forma, podemos posicionar o texto e os botões em relação ao plano.
  • Usamos aspect-square para manter a proporção do contêiner.
  • Os botões de seta vêm de Heroicons.
  • O título e o nome curto têm dimensões fixas e overflow hidden para criar efeitos de texto interessantes posteriormente.
  • Classes md: são usadas para ajustar o layout em telas maiores.

Vamos adicionar nosso componente Slider ao lado do Canvas em 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;

O slider é exibido antes do canvas.

Precisamos alterar o estilo do Canvas para ser exibido como um fundo e ocupar toda a largura e altura da tela:

{/* ... */}
<Canvas
  camera={{ position: [0, 0, 5], fov: 30 }}
  className="top-0 left-0"
  style={{
    // Sobrescrevendo o estilo padrão aplicado pelo R3F
    width: "100%",
    height: "100%",
    position: "absolute",
  }}
>
{/* ... */}

Vamos adicionar fontes e estilos personalizados ao nosso 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;
  }
}

As classes text-outline são usadas para criar um contorno ao redor do texto.

Para adicionar as fontes personalizadas, precisamos atualizar nosso 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: [],
};

Agora temos uma UI de aparência agradável:

Efeitos de texto

Para tornar as transições mais interessantes, adicionaremos alguns efeitos de texto ao título, nome curto e descrição.

Primeiro, precisamos obter a direção para saber se estamos indo para o próximo ou o slide anterior. Podemos obtê-la a partir do hook useSlider:

// ...

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

Para animar o texto exibido anteriormente para fora e o novo texto para dentro, precisamos do índice do slide anterior. Podemos calculá-lo 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;
  }
  // ...
};

Agora podemos adicionar os efeitos de texto com a ajuda do Framer Motion. Vamos começar com o título no canto inferior direito:

// ...
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">
      {/* CONTAINER DO MEIO */}
      <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]">
        {/* ... */}
        {/* INFERIOR DIREITO */}
        <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" // para fazer o transform funcionar (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 a prop animate para alternar entre os diferentes estados e definimos as diferentes propriedades para cada estado na prop variants.

Para animar cada caractere, dividimos o título em um array de caracteres e usamos a prop staggerChildren para atrasar a animação de cada caractere.

A prop from é usada para definir a posição inicial da animação.

Vamos remover o overflow-hidden do título para ver o efeito:

O texto do título é animado para entrar e sair.

Vamos adicionar o mesmo efeito ao nome curto:

// ...

export const Slider = () => {
  // ...
  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* CONTAINER DO MEIO */}
      <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]">
        {/* SUPERIOR ESQUERDO */}
        <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 um efeito simples de fade in e out para a descrição:

// ...

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

  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* CONTAINER DO MEIO */}
      <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]">
        {/* ... */}
        {/* INFERIOR DIREITO */}
        {/* ... */}
        <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>
  );
};

Nossa interface de usuário agora está animada e pronta para ser usada.

Estamos prontos para pular para a parte mais interessante desta lição: o efeito de transição com shader! 🎉

Efeito de transição de imagem

Como fizemos para animar o texto, para fazer a transição entre as imagens, precisaremos das texturas da imagem atual e da anterior.

Vamos armazenar o caminho da imagem anterior em nosso 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]);

  // ...
};

Com o hook useEffect, armazenamos o caminho da imagem atual no estado lastImage e, quando a imagem muda, atualizamos o estado lastImage com o novo caminho da imagem.

Antes de usar a prevTexture em nosso shader, e antes que esqueçamos, vamos pré-carregar todas as imagens para evitar piscadas ao mudar o slide:

// ...

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

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

Fazendo isso, estamos pré-carregando todas as imagens, e podemos adicionar com segurança uma tela de carregamento no início de nosso site para evitar qualquer piscada.

Agora, vamos adicionar dois uniforms ao nosso ImageSliderMaterial para armazenar a textura anterior e o progresso da transição:

// ...

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 a função mix para interpolar entre a textura anterior e a atual com base no uniforme uProgression.

Podemos ver uma mistura entre a imagem anterior e a atual.

Efeito de Fade in e out

Vamos animar a uniform uProgression para criar uma transição suave entre as imagens.

Primeiro, precisamos de uma referência ao nosso material para poder atualizar o uniform 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>
  );
};

Podemos nos livrar da prop uProgression já que vamos atualizá-la manualmente.

Agora no useEffect quando a imagem mudar, podemos definir uProgression para 0 e animá-lo para 1 em um 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
    );
  });
  // ...
};

Agora temos uma transição suave entre as imagens.

Vamos construir em cima disso para criar um efeito mais interessante.

Posição distorcida

Para tornar a transição mais interessante, vamos empurrar as imagens na direção da transição.

Utilizaremos as coordenadas vUv para distorcer a posição das imagens. Vamos adicionar um uniform uDistortion ao nosso ImageSliderMaterial e usá-lo para distorcer as coordenadas vUv:

End of lesson preview

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