Bộ chuyển đổi hình ảnh

Starter pack

Trong bài học này, chúng tôi sẽ học cách tải và sử dụng hình ảnh texture trong shader của chúng tôi để tạo bộ chuyển đổi hình ảnh responsive này:

Và đây là kết quả cuối cùng trên thiết bị di động:

Dự án này được lấy cảm hứng từ Codepen của Sikriti Dakua.

Hy vọng bạn sẽ có động lực để học cách tạo hiệu ứng này, hãy bắt đầu nào!

Dự án khởi đầu

Dự án khởi đầu của chúng tôi chứa một phần toàn màn hình bao gồm một logo, một nút menu và một thành phần <Canvas> với một khối lập phương màu trắng ở giữa cảnh.

Chúng tôi sẽ sử dụng Framer Motion để tạo hoạt hình cho các phần tử HTML nhưng bạn có thể sử dụng bất kỳ thư viện nào khác hoặc thậm chí CSS thuần để tạo hoạt hình cho chúng. Chúng tôi chỉ sử dụng phiên bản mặc định của Framer Motion, không cần cài đặt gói 3D.

Đối với giao diện người dùng, tôi đã chọn Tailwind CSS, nhưng bạn có thể tự do sử dụng giải pháp mà bạn cảm thấy thoải mái nhất.

Thư mục public/textures/optimized chứa các hình ảnh mà chúng tôi sẽ sử dụng trong bộ chuyển đổi. Tôi đã tạo chúng bằng AI với Leonardo.Ai và tối ưu hóa chúng bằng Squoosh. Tôi đã chọn tỷ lệ 3:4 để có định dạng dọc nhìn đẹp trên thiết bị di động.

AI Generated Image

Một trong những hình ảnh mà chúng tôi sẽ sử dụng đã được tối ưu hóa với Squoosh từ 3.9mb xuống còn 311kb.

Thành phần trình chiếu hình ảnh

Hãy bắt đầu bằng cách thay thế khối lập phương trắng bằng một hình phẳng sẽ được sử dụng để hiển thị các hình ảnh. Chúng ta tạo một thành phần mới tên là ImageSlider:

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

Điều chỉnh chiều rộng và chiều cao theo tỷ lệ khung hình của các hình ảnh bạn sẽ sử dụng.

Thuộc tính fillPercent sẽ được dùng để điều chỉnh kích thước hình phẳng sao cho chỉ chiếm một phần trăm chiều cao/rộng của màn hình.

Trong App.jsx, chúng ta nhập thành phần ImageSlider và thay thế khối lập phương trắng bằng nó:

import { ImageSlider } from "./ImageSlider";

// ...

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

// ...

Và đây là kết quả:

Image Slider Plane

Hình phẳng đang chiếm quá nhiều không gian

Chúng ta muốn hình phẳng của mình linh hoạt và chỉ chiếm 75% (fillPercent) chiều cao màn hình. Chúng ta có thể đạt được điều này bằng cách sử dụng hook useThree để lấy kích thước viewport và tạo ra một hệ số tỉ lệ để điều chỉnh kích thước hình phẳng:

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

Để tính toán hệ số tỉ lệ của chúng ta, chúng ta chia viewport.height cho height của hình phẳng chia cho fillPercent. Điều này sẽ cho chúng ta một tỉ lệ mà chúng ta có thể sử dụng để tăng kích thước hình phẳng.

Để hiểu toán học đằng sau điều này, chúng ta có thể coi viewport.height là chiều cao tối đa của hình phẳng. Nếu chiều cao viewport của chúng ta là 3 và chiều cao hình phẳng của chúng ta là 4, chúng ta cần tỉ lệ hình phẳng bằng 3 / 4 để làm cho nó vừa với màn hình. Nhưng vì chúng ta muốn chỉ chiếm 75% chiều cao màn hình, chúng ta chia chiều cao hình phẳng bởi fillPercent để có chiều cao tham chiếu mới. Điều này cho 4 / 0.75 = 5.3333.

Sau đó, chúng ta nhân widthheight với ratio để có các kích thước mới.

Nó hoạt động tốt khi chúng ta thay đổi kích thước theo chiều dọc nhưng không phải theo chiều ngang. Chúng ta cần điều chỉnh chiều rộng của hình phẳng để chỉ chiếm 75% chiều rộng màn hình khi chiều cao viewport lớn hơn chiều rộng.

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

Đừng quên thay đổi ratio từ const thành let để có thể gán lại cho nó. (Hoặc sử dụng toán tử ternary thay thế)

Bây giờ hình phẳng là có khả năng linh hoạt và chỉ chiếm 75% chiều cao hoặc chiều rộng màn hình tùy thuộc vào kích thước màn hình.

Chúng tôi đã sẵn sàng để hiển thị hình ảnh trên hình phẳng.

Kết cấu hình ảnh shader tùy chỉnh

Đầu tiên, hãy tải một trong những hình ảnh và hiển thị nó trên <meshBasicMaterial> hiện tại sử dụng hook useTexture từ 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

Hình ảnh được hiển thị đẹp mắt trên mặt phẳng.

Bây giờ, vì chúng ta muốn thêm các hiệu ứng sáng tạo trong quá trình chuyển đổi giữa các hình ảnh và khi di chuột, chúng ta sẽ tạo một shader material tùy chỉnh để có thể hiển thị đồng thời hai hình ảnh và thực hiện các hiệu ứng chuyển động.

ImageSliderMaterial

Hãy tạo shader material tùy chỉnh của chúng ta có tên ImageSliderMaterial. Tôi quyết định giữ nó trong cùng một tệp với component ImageSlider vì chúng có liên quan chặt chẽ. Nhưng bạn có thể tạo một tệp riêng nếu muốn.

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

// ...

Chúng ta lưu trữ kết cấu của mình trong một uniform có tên uTexture và truyền nó cho shader đoạn để hiển thị.

Kiểu của uTexture uniform là sampler2D được sử dụng để lưu trữ các kết cấu 2D.

Để trích xuất màu của kết cấu tại một vị trí cụ thể, chúng ta sử dụng hàm texture2D và truyền cho nó uTexture và các tọa độ vUv.

Hãy thay thế meshBasicMaterial của chúng ta bằng ImageSliderMaterial mới:

// ...

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

Hình ảnh được hiển thị bằng shader material tùy chỉnh của chúng ta.

Chấm màu

Tôi biết bạn bắt đầu có đôi mắt sắc bén 🦅 và bạn đã nhận thấy rằng chấm màu của hình ảnh trông khác biệt!

Điều này là do <meshBasicMaterial/> thực hiện một số xử lý bổ sung bên trong fragment shader để điều chỉnh màu sắc dựa trên tone mappingmàu không gian được chọn trên renderer.

Trong khi đây là điều mà chúng ta có thể tái tạo thủ công trong custom shader, đây không phải là mục tiêu của bài học này và là một chủ đề nâng cao.

Thay vào đó, chúng ta có thể sử dụng các đoạn mã có sẵn để kích hoạt cùng hiệu ứng như các materials chuẩn của Three.js. Nếu bạn xem mã nguồn của meshBasicMaterial, bạn sẽ thấy đó là sự pha trộn giữa các câu lệnh #include và mã tùy chỉnh.

meshBasicMaterial source code

Để làm cho mã dễ dàng tái sử dụng và bảo trì, Three.js sử dụng một bộ tiền xử lý để bao gồm mã từ các file khác. May mắn thay, chúng ta cũng có thể sử dụng các shader chunks đó trong custom shader material của mình!

Hãy thêm hai dòng này vào cuối fragment shader của chúng ta:

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

Để hiểu rõ hơn cách mà các shader chunks hoạt động, công cụ này cho phép bạn nhấp vào các câu lệnh include để thấy mã được bao gồm: ycw.github.io/three-shaderlib-skim

Image Slider Material with Shader Chunks

Chấm màu giờ đây giống như meshBasicMaterial. 🎨

Trước khi đi sâu hơn vào shader, hãy chuẩn bị giao diện người dùng của chúng ta.

Quản Lý Trạng Thái với Zustand

Zustand là một thư viện quản lý trạng thái nhỏ, nhanh và có khả năng mở rộng, cho phép chúng ta tạo một kho lưu trữ toàn cục để quản lý trạng thái ứng dụng của chúng ta.

Đây là một giải pháp thay thế cho Redux hoặc một giải pháp context tùy chỉnh để chia sẻ trạng thái giữa các thành phần và quản lý logic trạng thái phức tạp. (Thậm chí nếu không phải trong dự án của chúng ta. Logic của chúng ta đơn giản.)

Hãy thêm Zustand vào dự án của chúng ta:

yarn add zustand

Và tạo một file mới tên là useSlider.js trong thư mục hooks:

import { create } from "zustand";

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

Hàm create nhận một hàm làm tham số, hàm này sẽ nhận một hàm set để cập nhật và kết hợp trạng thái cho chúng ta. Chúng ta có thể đặt trạng thái và các phương thức của mình bên trong đối tượng trả về.

Đầu tiên là dữ liệu chúng ta cần:

// ...

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 sẽ lưu trữ chỉ số của slide hiện tại.
  • direction sẽ lưu trữ hướng của chuyển tiếp.
  • items sẽ lưu trữ dữ liệu của các slide. (đường dẫn tới hình ảnh, tên ngắn, tiêu đề, màu nền và mô tả)

Bây giờ chúng ta có thể tạo các phương thức để đi đến slide trước và tiếp theo:

// ...

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

Hàm set sẽ kết hợp trạng thái mới với trạng thái trước đó. Chúng ta sử dụng toán tử modulo để quay lại slide đầu tiên khi đạt tới slide cuối cùng và ngược lại.

Trạng thái của chúng ta đã sẵn sàng, hãy chuẩn bị giao diện người dùng của chúng ta.

Giao diện Trình chiếu

Chúng ta sẽ tạo một component mới tên là Slider để hiển thị chi tiết văn bản của slide và các nút điều hướng. Trong 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>
  );
};

Chúng tôi sẽ không đi sâu vào chi tiết của CSS đã sử dụng nhưng hãy để tôi giải thích cho bạn những điểm chính:

  • Container giữa là một div tái tạo các kích thước và tỷ lệ khung hình của mặt phẳng 3D của chúng ta. Bằng cách đó chúng ta có thể một vị trí văn bản và các nút tương ứng với mặt phẳng.
  • Chúng ta sử dụng aspect-square để giữ tỷ lệ khung hình của container.
  • Các nút mũi tên đến từ Heroicons.
  • Tiêu đề và tên ngắn có kích thước cố định và ẩn tràn để tạo hiệu ứng văn bản thú vị sau này.
  • Các lớp md: được sử dụng để điều chỉnh bố cục trên các màn hình lớn hơn.

Hãy thêm component Slider của chúng ta bên cạnh Canvas trong 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;

Trình chiếu được hiển thị trước canvas.

Chúng ta cần thay đổi phong cách của Canvas để được hiển thị dưới dạng nền và chiếm toàn bộ chiều rộng và chiều cao màn hình:

{/* ... */}
<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",
  }}
>
{/* ... */}

Hãy thêm phông chữ và phong cách tùy chỉnh vào index.css của chúng ta:

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

Các lớp text-outline được sử dụng để tạo viền xung quanh văn bản.

Để thêm các phông chữ tùy chỉnh, chúng ta cần cập nhật tailwind.config.js của mình:

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

Bây giờ chúng ta có một giao diện đẹp:

Hiệu ứng chữ

Để làm cho các chuyển đổi trở nên thú vị hơn, chúng ta sẽ thêm một số hiệu ứng chữ vào tiêu đề, tên ngắn và mô tả.

Đầu tiên, chúng ta cần xác định hướng để biết liệu chúng ta đang chuyển sang slide kế tiếp hay trước đó. Chúng ta có thể lấy nó từ hook useSlider:

// ...

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

Để có thể thực hiện hoạt ảnh cho đoạn văn bản đã hiển thị trước đó ra ngoài và đoạn văn bản mới xuất hiện, chúng ta cần chỉ số của slide trước đó. Chúng ta có thể dễ dàng tính toán nó:

// ...

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

Giờ chúng ta có thể thêm các hiệu ứng chữ với sự trợ giúp của Framer Motion. Hãy bắt đầu với tiêu đề ở góc dưới bên phải:

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

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

Chúng ta sử dụng thuộc tính animate để chuyển đổi giữa các trạng thái khác nhau và định nghĩa các thuộc tính khác nhau cho mỗi trạng thái trong thuộc tính variants.

Để hoạt ảnh từng ký tự, chúng ta chia tiêu đề thành một mảng ký tự và sử dụng thuộc tính staggerChildren để trì hoãn hoạt ảnh của từng ký tự.

Thuộc tính from được sử dụng để xác định vị trí bắt đầu của hoạt ảnh.

Hãy gỡ thuộc tính overflow-hidden khỏi tiêu đề để xem hiệu ứng:

Chữ ở tiêu đề được hoạt ảnh xuất hiện và biến mất.

Hãy thêm hiệu ứng tương tự cho tên ngắn gọn:

// ...

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

Và một hiệu ứng mờ dần đơn giản cho mô tả:

// ...

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

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

Giao diện người dùng của chúng ta hiện đã được hoạt ảnh và sẵn sàng để sử dụng.

Chúng ta sẵn sàng để bước vào phần thú vị nhất của bài học này: hiệu ứng chuyển tiếp shader! 🎉

Hiệu ứng chuyển tiếp hình ảnh

Giống như khi chúng ta làm hiệu ứng cho văn bản, để thực hiện chuyển tiếp giữa các hình ảnh, chúng ta sẽ cần các texture của hình ảnh hiện tại và trước đó.

Hãy lưu trữ đường dẫn hình ảnh trước đó trong thành phần ImageSlider của chúng ta:

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

  // ...
};

Với hook useEffect, chúng ta lưu trữ đường dẫn hình ảnh hiện tại vào trạng thái lastImage và khi hình ảnh thay đổi, chúng ta cập nhật trạng thái lastImage với đường dẫn hình ảnh mới.

Trước khi sử dụng prevTexture trong shader của chúng ta, và trước khi quên, hãy preload tất cả các hình ảnh để tránh hiện tượng nhấp nháy khi chúng ta thay đổi slide:

// ...

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

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

Bằng cách làm như vậy, chúng ta đang preload tất cả các hình ảnh, chúng ta có thể an toàn thêm một màn hình tải ở đầu trang web của chúng ta để tránh bất kỳ nhấp nháy nào.

Bây giờ, hãy thêm hai uniforms vào ImageSliderMaterial để lưu trữ texture trước đó và tiến trình của chuyển tiếp:

// ...

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

Chúng ta sử dụng hàm mix để nội suy giữa texture trước đó và texture hiện tại dựa trên uniform uProgression.

Chúng ta có thể thấy sự pha trộn giữa hình ảnh trước đó và hình ảnh hiện tại.

Hiệu ứng mờ dần

Hãy tạo animation cho uniform uProgression để tạo ra chuyển đổi mượt mà giữa các hình ảnh.

Đầu tiên, chúng ta cần một tham chiếu đến material của mình để có thể cập nhật 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>
  );
};

Chúng ta có thể loại bỏ prop uProgression vì sẽ cập nhật nó thủ công.

Bây giờ trong useEffect khi hình ảnh thay đổi, ta có thể thiết lập uProgression về 0 và animate nó đến 1 trong vòng lặp 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
    );
  });
  // ...
};

Chúng ta bây giờ đã có một chuyển đổi mượt mà giữa các hình ảnh.

Hãy xây dựng thêm dựa trên điều này để tạo ra một hiệu ứng thú vị hơn.

Vị trí biến dạng

Để làm cho quá trình chuyển tiếp thú vị hơn, chúng ta sẽ đẩy các hình ảnh theo hướng của sự chuyển tiếp.

Chúng ta sẽ sử dụng tọa độ vUv để làm biến dạng vị trí của các hình ảnh. Hãy thêm một uDistortion uniform vào ImageSliderMaterial và sử dụng nó để làm biến dạng tọa độ 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.