Image slider

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 Generated Image

Squooshで3.9mbから311kbに最適化された使用する画像の一つ。

Image slider component

まず、白いキューブを画像を表示するためのplaneに置き換えます。ImageSliderという新しいコンポーネントを作成します:

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

使用する画像のアスペクト比に合わせてwidthとheightを調整します。

fillPercentプロップは、planeのサイズを画面の高さ/幅の一部だけに調整するために使用されます。

App.jsxImageSliderコンポーネントをインポートし、白いキューブを置き換えます:

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

planeが大きすぎる

planeをレスポンシブにし、画面の高さの75%fillPercent)だけを占めるようにします。これを実現するために、useThreeフックを使ってviewportの寸法を取得し、planeのサイズを調整するためのスケールファクターを作成します:

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をplaneのheightfillPercentで割った値で割ります。これにより、planeをスケールするための比率が得られます。

この数学を理解するために、viewport.heightをplaneの最大高さと考えることができます。もしviewportの高さが3でplaneの高さが4の場合、3 / 4でplaneをスケールして画面に収める必要があります。しかし、画面の高さの75%だけを占めたい場合、planeの高さをfillPercentで割って新しい参照高さを得ます。つまり、4 / 0.75 = 5.3333です。

その後、widthheightに比率を掛けて新しい寸法を得ます。

縦にリサイズするときはうまく動作しますが、横にはうまく動作しません。viewportの高さが幅より大きい場合、planeの幅を画面の幅の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>
  );
};

ratioを再代入できるようにするためにconstからletに変更することを忘れないでください。(または三項演算子を使用することもできます)

これで、planeはレスポンシブになり、画面の高さや幅に応じて75%だけを占めるようになります。

これで、planeに画像を表示する準備が整いました。

カスタムシェーダー画像テクスチャ

まず、DreiuseTexture フックを使用して画像を読み込み、現在の <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を作成し、2つの画像を同時に表示してアニメーションするようにします。

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を使用して表示されています。

Color grading

目ざとい方はお気づきかと思いますが🦅、画像のカラーグレーディングが異なっています!

これは、<meshBasicMaterial/>がレンダラーで選択されたtone mappingおよびcolor spaceに基づいて色を調整するために、フラグメントシェーダ内で追加の処理を行うためです。

このレッスンの目標ではありませんし、高度なトピックですが、カスタムシェーダでこれを手動で再現することは可能です。

代わりに、標準のThree.js materialsと同じ効果を有効にするための、使いやすいフラグメントを利用できる準備ができています。meshBasicMaterialのソースコードを見れば、それが#include文とカスタムコードのミックスであることがわかります。

meshBasicMaterial source code

コードを簡単に再利用可能かつ保守可能にするために、Three.jsはプリプロセッサを使用して他のファイルからコードをインクルードします。幸いなことに、これらのシェーダーチャンクをカスタムシェーダーマテリアルでも使用できます!

フラグメントシェーダの最後にこれら2行を追加しましょう:

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

シェーダーチャンクの働きをよりよく理解するために、このツールを使用すると、インクルード文をクリックして含まれるコードを見ることができます:ycw.github.io/three-shaderlib-skim

Image Slider Material with Shader Chunks

カラーグレーディングがmeshBasicMaterialと同じになりました。🎨

シェーダについてこれ以上進む前に、UIの準備をしましょう。

Zustand State Management

Zustand は、私たちのアプリケーションの状態を管理するためのグローバルストアを作成できる、小さくて高速かつスケーラブルな状態管理ライブラリです。

これは、Reduxやカスタムコンテキストソリューションの代替として、コンポーネント間で状態を共有し、複雑な状態ロジック(プロジェクトではシンプルなロジックしか使用していませんが)を管理するための方法です。

プロジェクトにZustandを追加しましょう:

yarn add zustand

そして、hooksフォルダーにuseSlider.jsという新しいファイルを作成します:

import { create } from "zustand";

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

create関数は、引数として関数を受け取り、この関数に対してset関数を渡します。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の準備をしましょう。

Slider 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:クラスは、より大きな画面でレイアウトを調整するために使用されます。

SliderコンポーネントをApp.jsxCanvasの隣に追加しましょう:

// ...
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フックから取得できます:

// ...

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">
      {/* 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>
  );
};

animateプロップを使って異なる状態間を切り替え、それぞれの状態のプロパティをvariantsプロップで定義します。

各文字をアニメーションさせるために、タイトルを文字の配列に分割し、staggerChildrenプロップを使用して各文字のアニメーションを遅延させます。

fromプロップを使用してアニメーションの開始位置を定義します。

タイトルのoverflow-hiddenを削除して効果を確認しましょう:

タイトルテキストがアニメーションで表示され、非表示になります。

同じ効果を短い名前にも追加しましょう:

// ...

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

そして、説明にはシンプルなフェードイン・フェードアウト効果を追加します:

// ...

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

UIがアニメーション化され、使用準備ができました。

さて、このレッスンの最も興味深い部分であるシェーダートランジション効果に進む準備が整いました! 🎉

Image transition effect

テキストのアニメーションと同様に、画像間のトランジションを行うためには、現在の画像テクスチャと前の画像テクスチャが必要です。

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に2つのuniformを追加しましょう:

// ...

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ユニフォームに基づいて前のテクスチャと現在のテクスチャを補間します。

前の画像と現在の画像の間のミックス効果が見られます。

フェードイン・フェードアウト効果

uProgression uniform をアニメートして、画像間のスムーズな遷移を作成しましょう。

まず、uProgression uniform を更新できるように 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座標を歪ませましょう。

End of lesson preview

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