이미지 슬라이더

Starter pack

이 강의에서는 텍스처 이미지를 로드하고 셰이더에서 사용하는 방법을 배워, 이 반응형 이미지 슬라이더를 만들어 보겠습니다:

여기 모바일 버전 최종 결과입니다:

이 프로젝트는 Sikriti Dakua님의 Codepen에서 영감을 받았습니다.

이 효과를 만드는 방법을 배우고 싶다는 동기 부여가 되었길 바랍니다. 시작해 봅시다!

스타터 프로젝트

스타터 프로젝트는 로고, 메뉴 버튼, 그리고 장면 중앙에 하얀 큐브가 있는 <Canvas> 컴포넌트를 포함한 전체 화면 섹션이 있습니다.

우리는 HTML 요소를 애니메이션하기 위해 Framer Motion를 사용할 것입니다. 다른 라이브러리나 일반 CSS를 사용하여 애니메이션할 수도 있습니다. Framer Motion의 기본 버전만 사용할 것이며, 3D 패키지를 설치할 필요는 없습니다.

UI를 위해 Tailwind CSS를 선택했지만, 편한 솔루션을 사용하셔도 됩니다.

public/textures/optimized 폴더에는 슬라이더에서 사용할 이미지들이 있습니다. 이 이미지는 Leonardo.Ai로 AI 생성 후 Squoosh를 사용하여 최적화했습니다. 3:4 비율을 선택하여 모바일에서 보기 좋게 세로 방향으로 만들었습니다.

AI 생성 이미지

Squoosh로 3.9MB에서 311KB로 최적화된 이미지 중 하나입니다.

이미지 슬라이더 컴포넌트

흰색 큐브를 이미지 표시용 평면으로 교체해보겠습니다. ImageSlider라는 새 컴포넌트를 생성합니다:

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

사용할 이미지의 종횡비에 맞게 width와 height를 조정하세요.

fillPercent prop는 화면 높이/너비의 일정 비율만큼만 평면 크기를 조정하는 데 사용됩니다.

App.jsx에서 ImageSlider 컴포넌트를 임포트하여 흰색 큐브를 그것으로 대체합니다:

import { ImageSlider } from "./ImageSlider";

// ...

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

// ...

결과는 다음과 같습니다:

Image Slider Plane

평면이 너무 많은 공간을 차지하고 있습니다

평면이 반응형이 되어 화면 높이의 75%(fillPercent)만 차지하도록 만들고 싶습니다. 이를 위해 useThree 훅을 사용하여 viewport의 크기를 가져오고, 평면 크기를 조정할 스케일 팩터를 만듭니다:

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

스케일 팩터를 계산하기 위해 viewport.height를 평면의 height 나누기 fillPercent으로 나눕니다. 이렇게 하면 평면을 스케일링할 수 있는 비율을 얻을 수 있습니다.

이 수학적 계산을 이해하기 위해, viewport.height를 평면의 최대 높이로 생각할 수 있습니다. 뷰포트 높이가 3이고 평면 높이가 4라면, 평면이 화면에 맞도록 3 / 4로 스케일링해야 합니다. 그러나 화면 높이의 75%만 차지하고 싶기 때문에 평면 높이를 fillPercent로 나눠 새로운 기준 높이를 얻습니다. 즉, 4 / 0.75 = 5.3333가 됩니다.

그런 다음 widthheightratio를 곱하여 새로운 크기를 얻습니다.

수직으로 크기를 조절할 때는 잘 작동하지만 수평으로는 그렇지 않습니다. 화면 높이가 너비보다 큰 경우 평면 너비를 화면 너비의 75%만 차지하도록 조정해야 합니다.

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

const에서 let으로 ratio를 변경하여 재할당할 수 있게 설정하는 것을 잊지 마십시오. (또는 삼항 연산자를 사용하는 방법도 있습니다)

이제 평면이 반응형이 되어 화면 크기에 따라 화면 높이 또는 너비의 75%만 차지합니다.

이제 평면에 이미지를 표시할 준비가 되었습니다.

커스텀 셰이더 이미지 텍스처

먼저, DreiuseTexture hook을 사용하여 이미지 중 하나를 로드하고 현재 <meshBasicMaterial>에 표시해 보겠습니다:

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

이미지가 평면에 잘 표시됩니다.

이제 이미지 간 전환과 호버 시 창의적인 효과를 추가하고자 하므로, 두 개의 이미지를 동시에 표시하고 애니메이션화할 수 있도록 커스텀 셰이더 material을 만듭니다.

ImageSliderMaterial

ImageSliderMaterial이라는 커스텀 셰이더 material을 만들어 봅시다. 저는 이것을 ImageSlider 컴포넌트와 밀접하게 관련이 있으므로 같은 파일에 유지하기로 결정했습니다. 하지만 선호에 따라 별도의 파일로 만들 수도 있습니다.

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

// ...

우리의 텍스처를 uTexture라는 이름의 uniform에 저장하고, 이를 프래그먼트 셰이더에 전달하여 표시합니다.

uTexture uniform의 타입은 sampler2D로, 2D 텍스처를 저장하는 데 사용됩니다.

특정 위치에서 텍스처의 색상을 추출하기 위해 texture2D 함수를 사용하고 uTexturevUv 좌표를 전달합니다.

meshBasicMaterial을 새 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

이미지가 우리의 커스텀 셰이더 material을 사용하여 표시됩니다.

색상 그레이딩

눈이 매서워지기 시작한 것을 알고 있어요 🦅 이미지 색상 그레이딩이 다르게 보이는 것을 눈치챘군요!

이유는 <meshBasicMaterial/>이 fragment shader 내부에서 선택된 tone mappingcolor space에 기반하여 색상을 조정하는 추가적인 처리를 수행하기 때문입니다.

이것은 우리가 커스텀 shader에서 수동으로 복제할 수 있는 것이지만, 이 레슨의 목표는 아니며 고급 주제입니다.

대신, 우리는 표준 Three.js material과 동일한 효과를 활성화하기 위해 사용할 수 있는 미리 준비된 fragments를 사용할 수 있습니다. meshBasicMaterial의 소스 코드를 보면 #include 문과 커스텀 코드의 혼합임을 알 수 있습니다.

meshBasicMaterial 소스 코드

코드를 쉽게 재사용하고 유지 보수할 수 있도록 Three.js는 다른 파일에서 코드를 포함하도록 전처리기를 사용합니다. 다행스럽게도 이러한 shader chunk를 우리의 커스텀 shader material에서도 사용할 수 있습니다!

두 줄을 fragment shader의 끝에 추가해 봅시다:

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

shader chunks가 어떻게 작동하는지 더 잘 이해하기 위해 이 도구를 사용하여 include 문을 클릭하여 포함된 코드를 볼 수 있습니다: ycw.github.io/three-shaderlib-skim

Shader Chunks가 포함된 이미지 슬라이더 Material

meshBasicMaterial과 색상 그레이딩이 동일해졌습니다. 🎨

shader에 대해 더 나아가기 전에, UI를 준비해봅시다.

Zustand 상태 관리

Zustand는 애플리케이션 상태를 관리하기 위한 글로벌 스토어를 생성할 수 있게 해주는 작고 빠르며 확장 가능한 상태 관리 라이브러리입니다.

Redux나 컴포넌트 간 상태를 공유하고 복잡한 상태 로직을 관리하기 위한 사용자 정의 컨텍스트 솔루션의 대안으로 사용할 수 있습니다. (우리 프로젝트에서는 로직이 간단하더라도 말이죠.)

프로젝트에 Zustand를 추가해봅시다:

yarn add zustand

그리고 hooks 폴더에 useSlider.js라는 새 파일을 만듭니다:

import { create } from "zustand";

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

create 함수는 함수를 인수로 받아 set 함수를 통해 상태를 업데이트하고 병합합니다. 우리는 반환된 객체 안에 우리 상태와 메소드를 넣을 수 있습니다.

먼저 필요한 데이터를 정의해보겠습니다:

// ...

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는 현재 슬라이드 인덱스를 저장합니다.
  • direction은 전환 방향을 저장합니다.
  • items는 슬라이드의 데이터를 저장합니다. (이미지 경로, 쇼트네임, 제목, 배경색, 설명)

이제 이전 및 다음 슬라이드로 이동하는 메서드를 만들어 봅시다:

// ...

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

set 함수는 새로운 상태를 이전 상태와 병합합니다. 모듈로 연산자를 사용하여 마지막 슬라이드에 도달했을 때 첫 번째 슬라이드로 돌아가도록 하거나 그 반대로 처리합니다.

이제 우리의 상태가 준비되었으니, UI를 준비해 보겠습니다.

슬라이더 UI

우리는 슬라이드 텍스트 세부 정보와 내비게이션 버튼을 표시하기 위해 Slider라는 새로운 컴포넌트를 만들 것입니다. 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>
  );
};

사용된 CSS의 세부 사항에 대해 설명하지는 않겠지만 주요 요점을 설명하겠습니다:

  • 중간 컨테이너는 3D 평면의 크기와 종횡비를 재현하는 div입니다. 이를 통해 텍스트와 버튼을 평면에 상대적으로 배치할 수 있습니다.
  • aspect-square를 사용하여 컨테이너의 종횡비를 유지합니다.
  • 화살표 버튼은 Heroicons에서 가져왔습니다.
  • 제목과 단축 이름은 고정된 크기를 가지고 있으며, 나중에 흥미로운 텍스트 효과를 생성하기 위해 오버플로우가 숨겨져 있습니다.
  • md: 클래스는 더 큰 화면에서 레이아웃을 조정하는 데 사용됩니다.

App.jsx에서 Slider 컴포넌트를 Canvas 옆에 추가해 봅시다:

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

슬라이더가 캔버스 앞에 표시됩니다.

Canvas의 스타일을 배경으로 설정하고 전체 화면 너비와 높이를 차지하도록 변경해야 합니다:

{/* ... */}
<Canvas
  camera={{ position: [0, 0, 5], fov: 30 }}
  className="top-0 left-0"
  style={{
    // R3F에 의해 적용된 기본 스타일 재정의
    width: "100%",
    height: "100%",
    position: "absolute",
  }}
>
{/* ... */}

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

text-outline 클래스는 텍스트 주위에 윤곽선을 생성하는 데 사용됩니다.

사용자 정의 폰트를 추가하려면 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: [],
};

이제 예쁜 UI를 갖게 되었습니다:

텍스트 효과

전환을 더 흥미롭게 만들기 위해 제목, 짧은 이름 및 설명에 텍스트 효과를 추가할 것입니다.

먼저, 우리가 다음 슬라이드 또는 이전 슬라이드로 이동하는지 여부를 알기 위해 direction을 얻어야 합니다. useSlider hook에서 이를 얻을 수 있습니다:

// ...

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

이전 슬라이드에서 표시된 텍스트를 애니메이션 하여 나오게 하고 새 텍스트가 들어오도록 하려면, 이전 슬라이드의 인덱스가 필요합니다. 이는 쉽게 계산할 수 있습니다:

// ...

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

이제 Framer Motion의 도움을 받아 텍스트 효과를 추가할 수 있습니다. 먼저 오른쪽 하단에 있는 제목부터 시작합니다:

// ...
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">
      {/* 가운데 컨테이너 */}
      <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]">
        {/* ... */}
        {/* 오른쪽 아래 */}
        <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" // transform을 작동하게 하기 위해 (translateY)
                    variants={{
                      current: {
                        translateY: 0,
                        transition: {
                          duration: 0.8,
                          from:
                            direction === "prev"
                              ? -TEXT_TRANSITION_HEIGHT
                              : TEXT_TRANSITION_HEIGHT,
                          type: "spring",
                          bounce: 0.2,
                        },
                      },
                      prev: {
                        translateY:
                          direction === "prev"
                            ? TEXT_TRANSITION_HEIGHT
                            : -TEXT_TRANSITION_HEIGHT,
                        transition: {
                          duration: 0.8,
                          from:
                            direction === "start" ? -TEXT_TRANSITION_HEIGHT : 0,
                        },
                      },
                      next: {
                        translateY: TEXT_TRANSITION_HEIGHT,
                        transition: {
                          from: TEXT_TRANSITION_HEIGHT,
                        },
                      },
                    }}
                  >
                    {char}
                  </motion.span>
                ))}
              </motion.div>
            ))}
          </h2>
        </div>
        {/* ... */}
      </div>
    </div>
  );
};

animate prop을 사용하여 서로 다른 상태 간에 전환하고, variants prop으로 각 상태의 속성을 정의했습니다.

각 문자를 애니메이션 하려면 제목을 문자 배열로 분할하고 staggerChildren prop을 사용하여 각 문자 애니메이션의 지연을 줄 수 있습니다.

from prop은 애니메이션의 시작 위치를 정의하는 데 사용됩니다.

제목에서 overflow-hidden을 제거하여 효과를 확인하세요:

제목 텍스트가 들어오고 나가는 애니메이션이 실행됩니다.

같은 효과를 짧은 이름에 추가해보겠습니다:

// ...

export const Slider = () => {
  // ...
  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* 가운데 컨테이너 */}
      <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]">
        {/* 왼쪽 위 */}
        <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>
  );
};

그리고 설명에 간단한 페이드 인 및 아웃 효과를 추가합니다:

// ...

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

  return (
    <div className="grid place-content-center h-full select-none overflow-hidden pointer-events-none relative z-10">
      {/* 가운데 컨테이너 */}
      <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]">
        {/* ... */}
        {/* 오른쪽 아래 */}
        {/* ... */}
        <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>
  );
};

우리의 UI가 이제 애니메이션으로 준비되었습니다.

이제 이 강의의 가장 흥미로운 부분인 셰이더 전환 효과로 넘어갈 준비가 되었습니다! 🎉

이미지 전환 효과

텍스트를 애니메이션화할 때와 마찬가지로, 이미지 간의 전환을 수행하려면 현재 및 이전 이미지 텍스처가 필요합니다.

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

  // ...
};

useEffect 훅을 사용하여, 현재 이미지 경로를 lastImage 상태에 저장하고 이미지가 변경되면, 새로운 이미지 경로로 lastImage 상태를 업데이트합니다.

prevTexture를 셰이더에서 사용하기 전에, 슬라이드를 변경할 때 깜빡임을 방지하기 위해 모든 이미지를 미리 로드해봅시다:

// ...

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

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

이렇게 함으로써, 모든 이미지를 미리 로드하게 되며, 웹사이트 초기 로딩 화면에 안전하게 로딩 화면을 추가하여 깜빡임을 방지할 수 있습니다.

이제 ImageSliderMaterial에 이전 텍스처와 전환의 진행을 저장할 두 개의 uniforms를 추가해봅시다:

// ...

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

mix 함수를 사용하여 이전 텍스처와 현재 텍스처 간의 보간을 uProgression uniform에 기반하여 수행합니다.

이전 이미지와 현재 이미지 간의 혼합을 확인할 수 있습니다.

페이드 인 및 아웃 효과

uProgression 유니폼을 애니메이션하여 이미지 간의 부드러운 전환을 만들어 봅시다.

먼저, uProgression 유니폼을 업데이트할 수 있도록 material에 대한 참조를 가져와야 합니다:

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

우리가 수동으로 업데이트할 것이기 때문에 uProgression prop을 제거할 수 있습니다.

이제 이미지가 변경될 때 useEffect 내에서 uProgression0으로 설정하고 useFrame 루프에서 1로 애니메이션할 수 있습니다:

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

이제 이미지 간의 부드러운 전환이 가능합니다.

이를 기반으로 더욱 흥미로운 효과를 만들어 보겠습니다.

왜곡된 위치

전환을 더욱 흥미롭게 만들기 위해 이미지를 전환의 방향으로 밀어낼 것입니다.

vUv 좌표를 사용하여 이미지의 위치를 왜곡할 것입니다. ImageSliderMaterialuDistortion 유니폼을 추가하고 이를 사용하여 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.