Image slider

Starter pack

Dalam pelajaran ini kita akan belajar cara memuat dan menggunakan gambar tekstur dalam shader kita untuk membuat slider gambar responsif ini:

Dan berikut adalah hasil akhirnya di perangkat mobile:

Proyek ini terinspirasi oleh Codepen oleh Sikriti Dakua.

Saya harap Anda termotivasi untuk belajar cara membuat efek ini, mari kita mulai!

Proyek awal

Proyek awal kami berisi bagian layar penuh yang terdiri dari logo, tombol menu dan komponen <Canvas> dengan kubus putih di tengah adegan.

Kita akan menggunakan Framer Motion untuk menganimasikan elemen HTML tetapi Anda bisa menggunakan pustaka lain atau bahkan CSS biasa untuk menganimasikannya. Kita hanya akan menggunakan versi default dari Framer Motion, tidak perlu menginstal paket 3D.

Untuk UI saya memilih Tailwind CSS tetapi silakan gunakan solusi yang paling Anda nyaman.

Folder public/textures/optimized berisi gambar yang akan kita gunakan dalam slider. Saya menghasilkan gambar-gambar ini menggunakan AI dengan Leonardo.Ai dan mengoptimasinya dengan Squoosh. Saya memilih rasio 3:4 untuk memiliki orientasi potret yang akan terlihat baik di perangkat mobile.

AI Generated Image

Salah satu gambar yang akan kita gunakan dioptimalkan dengan Squoosh dari 3.9mb menjadi 311kb.

Komponen Image slider

Mari kita mulai dengan mengganti kubus putih dengan bidang yang akan digunakan untuk menampilkan gambar. Kita buat sebuah komponen baru bernama ImageSlider:

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

Sesuaikan lebar dan tinggi dengan rasio aspek dari gambar yang akan Anda gunakan.

Properti fillPercent akan digunakan untuk menyesuaikan ukuran bidang agar hanya mengambil persentase dari tinggi/lebar layar.

Di App.jsx kita impor komponen ImageSlider dan mengganti kubus putih dengan itu:

import { ImageSlider } from "./ImageSlider";

// ...

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

// ...

Dan inilah hasilnya:

Image Slider Plane

Bidang mengambil terlalu banyak ruang

Kami ingin bidang kami bisa responsif dan hanya mengambil 75%(fillPercent) dari tinggi layar. Kami bisa mencapainya dengan menggunakan hook useThree untuk mendapatkan dimensi viewport dan membuat faktor skala untuk menyesuaikan ukuran bidang:

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

Untuk menghitung faktor skala kita, kita membagi viewport.height dengan height bidang yang dibagi dengan fillPercent. Ini akan memberi kita rasio yang bisa kita gunakan untuk menskalakan bidang.

Untuk memahami matematika di balik ini, kita bisa memikirkan viewport.height sebagai ketinggian maksimum bidang. Jika ketinggian viewport kita adalah 3 dan ketinggian bidang kita adalah 4, kita perlu menskalakan bidang dengan 3 / 4 agar sesuai dengan layar. Namun karena kita hanya ingin mengambil 75% dari ketinggian layar, kita membagi ketinggian bidang dengan fillPercent untuk mendapatkan referensi ketinggian baru. Yang menghasilkan 4 / 0.75 = 5.3333.

Kemudian kita kalikan width dan height dengan ratio untuk mendapatkan dimensi baru.

Ini bekerja baik saat kita mengubah ukuran secara vertikal tetapi tidak secara horizontal. Kita perlu menyesuaikan lebar bidang untuk hanya mengambil 75% dari lebar layar ketika tinggi viewport lebih besar dari lebar.

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

Jangan lupa untuk mengganti ratio dari const menjadi let agar bisa diganti. (Atau gunakan operator ternary sebagai gantinya)

Sekarang bidang tersebut responsif dan hanya mengambil 75% dari tinggi atau lebar layar tergantung pada dimensi layar.

Kita siap menampilkan gambar di bidang tersebut.

Tekstur gambar shader kustom

Pertama, mari kita muat salah satu gambar dan tampilkan pada <meshBasicMaterial> saat ini menggunakan useTexture hook dari 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

Gambar terlihat dengan baik pada plane.

Sekarang, karena kita ingin menambahkan efek kreatif selama transisi antara gambar dan saat hover, kita akan membuat custom shader material untuk dapat menampilkan dua gambar sekaligus dan menganimasikannya.

ImageSliderMaterial

Mari kita buat custom shader material yang dinamai ImageSliderMaterial. Saya memilih untuk menyimpannya dalam file yang sama dengan komponen ImageSlider karena sangat terkait dengannya. Namun, Anda dapat membuat file terpisah jika diinginkan.

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

// ...

Kita menyimpan tekstur kita dalam uniform bernama uTexture dan kita berikan ke fragment shader untuk menampilkannya.

Tipe dari uTexture uniform adalah sampler2D yang digunakan untuk menyimpan tekstur 2D.

Untuk mengekstrak warna tekstur di posisi tertentu, kita menggunakan fungsi texture2D dan memberikannya uTexture dan koordinat vUv.

Mari kita ganti meshBasicMaterial kita dengan ImageSliderMaterial yang baru:

// ...

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

Gambar ditampilkan menggunakan shader material kustom kita.

Koreksi Warna

Saya tahu Anda mulai memiliki mata yang tajam 🦅 dan Anda memperhatikan bahwa koreksi warna gambar terlihat berbeda!

Ini karena <meshBasicMaterial/> melakukan beberapa pemrosesan tambahan di dalam fragment shader untuk menyesuaikan warna berdasarkan pilihan tone mapping dan color space pada renderer.

Meskipun ini adalah sesuatu yang bisa kita tiru secara manual dalam custom shader kita, ini bukanlah tujuan dari pelajaran ini dan termasuk topik lanjutan.

Sebagai gantinya, kita dapat menggunakan fragmen siap pakai untuk mengaktifkan efek yang sama seperti material standar Three.js. Jika Anda melihat pada kode sumber meshBasicMaterial, Anda akan melihat bahwa itu adalah campuran dari #include statements dan kode custom.

kode sumber meshBasicMaterial

Untuk membuat kode yang mudah digunakan kembali dan dikelola, Three.js menggunakan preprocessing untuk memasukkan kode dari file lain. Untungnya, kita bisa menggunakan potongan shader tersebut dalam custom shader material kita juga!

Mari tambahkan dua baris ini di akhir fragment shader kita:

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

Untuk memahami lebih baik bagaimana potongan shader bekerja, alat ini memungkinkan Anda untuk mengklik pada include statement untuk melihat kode yang disertakan: ycw.github.io/three-shaderlib-skim

Material Image Slider dengan Shader Chunks

Koreksi warna sekarang sama seperti meshBasicMaterial. 🎨

Sebelum melanjutkan dengan shader, mari kita siapkan UI kita.

Manajemen Keadaan dengan Zustand

Zustand adalah sebuah pustaka manajemen keadaan yang kecil, cepat, dan skalabel yang memungkinkan kita untuk membuat sebuah store global untuk mengelola keadaan aplikasi kita.

Ini adalah alternatif untuk Redux atau solusi konteks kustom untuk berbagi keadaan antara komponen dan mengelola logika keadaan yang kompleks. (Meskipun bukan demikian dalam proyek kita. Logika kita sederhana.)

Mari tambahkan Zustand ke dalam proyek kita:

yarn add zustand

Dan buat file baru bernama useSlider.js di dalam folder hooks:

import { create } from "zustand";

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

Fungsi create mengambil sebuah fungsi sebagai argumen yang akan menerima fungsi set untuk memperbarui dan menggabungkan keadaan untuk kita. Kita dapat menempatkan keadaan dan metode kita di dalam objek yang dikembalikan.

Pertama, data yang kita butuhkan:

// ...

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 akan menyimpan indeks slide saat ini.
  • direction akan menyimpan arah transisi.
  • items akan menyimpan data dari slides. (path ke gambar, nama singkat, judul, warna latar belakang, dan deskripsi)

Sekarang kita dapat membuat metode untuk ke slide sebelumnya dan berikutnya:

// ...

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

Fungsi set akan menggabungkan keadaan baru dengan keadaan sebelumnya. Kami menggunakan operator modulo untuk kembali ke slide pertama ketika kita mencapai yang terakhir dan sebaliknya.

Keadaan kita sudah siap, mari kita siapkan UI kita.

Antarmuka Slider

Kita akan membuat komponen baru bernama Slider untuk menampilkan detail teks slide dan tombol navigasi. Dalam 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>
  );
};

Kita tidak akan membahas detail CSS yang digunakan, tetapi biarkan saya jelaskan poin-poin utamanya:

  • Kontainer tengah adalah div yang mereproduksi dimensi dan rasio aspek pesawat 3D kita. Dengan cara ini, kita dapat memposisikan teks dan tombol relatif terhadap pesawat.
  • Kita menggunakan aspect-square untuk menjaga rasio aspek kontainer.
  • Tombol panah berasal dari Heroicons.
  • Judul dan nama pendek memiliki dimensi tetap dan overflow tersembunyi untuk membuat efek teks menarik nantinya.
  • Kelas md: digunakan untuk menyesuaikan tata letak pada layar yang lebih besar.

Mari tambahkan komponen Slider kita di samping Canvas dalam 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;

Slider ditampilkan sebelum kanvas.

Kita perlu mengubah gaya Canvas agar ditampilkan sebagai latar belakang dan mengambil lebar dan tinggi layar penuh:

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

Mari tambahkan font dan gaya kustom ke index.css kita:

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

Kelas text-outline digunakan untuk membuat garis luar pada teks.

Untuk menambahkan font kustom, kita perlu memperbarui tailwind.config.js kita:

/** @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: [],
};

Sekarang kita memiliki antarmuka yang terlihat bagus:

Efek Teks

Untuk membuat transisi lebih menarik, kita akan menambahkan beberapa efek teks pada judul, nama pendek, dan deskripsi.

Pertama, kita perlu mendapatkan arah untuk mengetahui apakah kita akan ke slide berikutnya atau sebelumnya. Kita bisa mendapatkannya dari hook useSlider:

// ...

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

Agar bisa menganimasikan teks yang ditampilkan sebelumnya keluar dan teks baru masuk, kita memerlukan indeks dari slide sebelumnya. Kita bisa menghitungnya dengan mudah:

// ...

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

Sekarang kita bisa menambahkan efek teks dengan bantuan Framer Motion. Mari mulai dengan judul di kanan bawah:

// ...
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">
      {/* KONTENER TENGAH */}
      <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]">
        {/* ... */}
        {/* KANAN BAWAH */}
        <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" // untuk membuat transform bekerja (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>
  );
};

Kita menggunakan animate prop untuk beralih antara berbagai keadaan dan kita mendefinisikan berbagai properti untuk setiap keadaan di variants prop.

Untuk menganimasikan setiap karakter, kita membagi judul menjadi array karakter dan menggunakan staggerChildren prop untuk menunda animasi setiap karakter.

Prop from digunakan untuk mendefinisikan posisi awal animasi.

Mari hapus overflow-hidden dari judul untuk melihat efeknya:

Teks judul dianimasikan masuk dan keluar.

Mari tambahkan efek yang sama pada nama pendek:

// ...

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

Dan efek fade in dan out sederhana untuk deskripsi:

// ...

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

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

Antarmuka kita sekarang sudah dianimasikan dan siap digunakan.

Kita siap menuju bagian paling menarik dari pelajaran ini: efek transisi shader! 🎉

Efek transisi gambar

Seperti yang kita lakukan untuk melakukan animasi pada teks, untuk melakukan transisi antara gambar-gambar tersebut, kita memerlukan tekstur gambar saat ini dan sebelumnya.

Mari kita simpan jalur gambar sebelumnya di komponen ImageSlider kita:

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

  // ...
};

Dengan useEffect hook, kita menyimpan jalur gambar saat ini di dalam lastImage state dan ketika gambar berubah, kita memperbarui lastImage state dengan jalur gambar yang baru.

Sebelum menggunakan prevTexture dalam shader kita, dan sebelum lupa, mari kita muat terlebih dahulu semua gambar agar terhindar dari tampilan yang berkedip saat kita mengubah slide:

// ...

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

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

Dengan melakukan ini, kita memuat semua gambar terlebih dahulu, kita bisa dengan aman menambahkan layar pemuatan di awal situs web kita untuk menghindari tampilan yang berkedip.

Sekarang, mari tambahkan dua uniforms ke ImageSliderMaterial kita untuk menyimpan tekstur sebelumnya dan progres transisi:

// ...

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

Kita menggunakan fungsi mix untuk menginterpolasi antara tekstur sebelumnya dan yang saat ini berdasarkan uniform uProgression.

Kita bisa melihat percampuran antara gambar sebelumnya dan saat ini.

Efek Memudar Masuk dan Keluar

Mari kita animasikan uProgression uniform untuk menciptakan transisi yang mulus antara gambar-gambar tersebut.

Pertama, kita perlu referensi ke material kita agar bisa memperbarui uProgression uniform:

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

Kita bisa menghapus prop uProgression karena kita akan memperbaruinya secara manual.

Sekarang di dalam useEffect ketika gambar berubah, kita bisa mengatur uProgression ke 0 dan menganimasikannya ke 1 dalam 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
    );
  });
  // ...
};

Transisi antara gambar-gambar sekarang menjadi lebih mulus.

Mari kita bangun lebih lanjut dari ini untuk menciptakan efek yang lebih menarik.

Posisi Terdistorsi

Untuk membuat transisi lebih menarik, kita akan mendorong gambar ke arah transisi.

Kita akan menggunakan koordinat vUv untuk mendistorsi posisi gambar. Mari tambahkan uniform uDistortion ke ImageSliderMaterial dan menggunakannya untuk mendistorsi koordinat 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.