图像滑块

Starter pack

在本课程中,我们将学习如何在 shader 中加载和使用纹理图像来创建这个响应式图像滑块

这是在移动设备上的最终效果:

该项目灵感来自 Sikriti Dakua 的这个 Codepen

希望你有学习这个效果的动力,开始吧!

初始项目

我们的初始项目包含一个全屏部分,其中有一个 logo,一个菜单按钮和一个 <Canvas> 组件,中间有一个白色立方体。

我们将使用 Framer Motion 来为 HTML 元素 添加动画,但你也可以使用任何其他库,甚至是普通的 CSS 来为它们添加动画。我们只会使用 Framer Motion 的默认版本,不需安装 3D package

对于 UI 我选择了 Tailwind CSS,但请随意使用你最习惯的解决方案。

public/textures/optimized 文件夹中包含了我们将在滑块中使用的图片。这些图片是我使用 Leonardo.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>
  );
};

调整宽度和高度以符合你将使用的图片的长宽比。

fillPercent 属性将用于调整平面的尺寸以占用屏幕高度/宽度的百分比。

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

然后我们将 widthheight 乘以 ratio 以获得新尺寸。

它在我们垂直调整大小时工作得很好,但在横向调整时效果不佳。我们需要调整平面的宽度,以便在视口高度大于宽度时只占屏幕宽度的 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>
  );
};

不要忘记将 ratioconst 更改为 let 以便重新赋值。(或者使用三元运算符代替)

现在平面是响应式的,根据屏幕尺寸,它只占屏幕高度或宽度的 75%。

我们准备在平面上显示图像。

自定义 shader 图像纹理

首先,我们使用 Drei 中的 useTexture 钩子加载一张图像并将其显示在当前的 <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>
  );
};

图像滑块平面与纹理

图像优雅地显示在平面上。

现在,因为我们希望在图像之间切换和悬停时添加创意效果,我们将创建一个自定义 shader 材质,以便能够同时显示两张图像并对其进行动画处理。

ImageSliderMaterial

让我们创建一个名为 ImageSliderMaterial 的自定义 shader 材质。我选择将它保存在与 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,
});

// ...

我们将纹理存储在一个名为 uTextureuniform 中,并将其传递给片段 shader 以显示它。

uTextureuniform 类型是 sampler2D,用于存储 2D 纹理。

为了提取特定位置的纹理颜色,我们使用 texture2D 函数并传入 uTexturevUv 坐标。

让我们用新的 ImageSliderMaterial 替换 meshBasicMaterial

// ...

export const ImageSlider = ({ width = 3, height = 4, fillPercent = 0.75 }) => {
  // ...
  return (
    <mesh>
      <planeGeometry args={[width * ratio, height * ratio]} />
      <imageSliderMaterial uTexture={texture} />
    </mesh>
  );
};

使用 ImageSliderMaterial 的图像滑块平面

使用自定义 shader 材质显示图像。

色彩分级

我知道你开始拥有敏锐的眼睛 🦅 并且注意到图像的色彩分级看起来不同了!

这是因为 <meshBasicMaterial/> 在片段着色器内进行了一些额外的处理,以根据渲染器上选择的 tone mappingcolor space 来调整颜色。

虽然这可以在我们的自定义着色器中手动复制,但这不是本节课程的目标,这是一个高级话题。

相反,我们可以使用现成的片段来实现与标准 Three.js materials 相同的效果。如果查看 meshBasicMaterial 的源代码,你会看到它是 #include 语句和自定义代码的混合。

meshBasicMaterial 源代码

为了使代码易于重用和维护,Three.js 使用预处理器来从其它文件中包含代码。幸运的是,我们也可以在自定义着色材质中使用这些着色器块!

让我们在片段着色器的末尾添加这两行:

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

为了更好地理解着色器块如何工作,该工具允许您点击 include 语句来查看所包含的代码:ycw.github.io/three-shaderlib-skim

使用着色器块的图像滑块材质

现今的色彩分级与 meshBasicMaterial 相同。 🎨

在进一步研究着色器之前,让我们准备好我们的 UI。

Zustand 状态管理

Zustand 是一个小型、快速且可扩展的状态管理库,它允许我们创建一个全局存储来管理应用程序状态。

相比 Redux 或自定义上下文解决方案,Zustand 是一种在组件之间共享状态并管理复杂状态逻辑的替代方案。(即使在我们的项目中并不如此,我们的逻辑很简单。)

让我们将 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: "享受内心的宁静。",
      color: "#201d24",
    },
    {
      image:
        "textures/optimized/Default_balinese_futuristic_villa_with_garden_outside_jungle_0.jpg",
      short: "TK",
      title: "Breath",
      color: "#263a27",
      description: "感受自然的环绕。",
    },
    {
      image:
        "textures/optimized/Default_desert_arabic_futuristic_villa_with_garden_oasis_outsi_0.jpg",
      short: "OZ",
      title: "Travel",
      color: "#8b6d40",
      description: "勇敢面对未知。",
    },
    {
      image:
        "textures/optimized/Default_scandinavian_ice_futuristic_villa_with_garden_outside_0.jpg",
      short: "SK",
      title: "Calm",
      color: "#72a3ca",
      description: "释放你的思想。",
    },
    {
      image:
        "textures/optimized/Default_traditional_japanese_futuristic_villa_with_garden_outs_0.jpg",
      short: "AU",
      title: "Feel",
      color: "#c67e90",
      description: "情感与体验。",
    },
  ],
}));
  • 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 函数会将新状态与之前的状态合并。我们使用模运算符来在到达最后一张幻灯片时回到第一张,反之亦然。

我们的状态已准备好,接下来让我们准备用户界面。

滑块界面

我们将创建一个名为 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">
      {/* 中间容器 */}
      <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[curSlide].short}
          </h1>
        </div>
        {/* 中间箭头 */}
        <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>

        {/* 右下角 */}
        <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,但让我向你解释一下主要要点:

  • 中间容器是一个 div,用于再现我们3D平面的尺寸和长宽比。这样我们可以相对于平面定位文本和按钮。
  • 我们使用 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 的样式,使其显示为背景并占据全屏宽度和高度:

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

文本效果

为了使过渡更有趣,我们将为标题、缩写和描述添加一些文本效果。

首先,我们需要获取方向,以了解是前往下一个还是上一个幻灯片。我们可以从 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">
      {/* 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现在已动画化并准备好使用。

我们准备好进入本课中最有趣的部分:着色器过渡效果! 🎉

图像过渡效果

就像我们为文本动画所做的那样,要在图像之间进行过渡,我们需要当前和先前的图像纹理。

让我们在 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 hook,我们将当前图像路径存储在 lastImage 状态中,当图像更改时,我们用新图像路径更新 lastImage 状态。

在使用我们的着色器中的 prevTexture 之前,以及在我们忘记之前,让我们预加载所有图像,以避免在更改幻灯片时闪烁:

// ...

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

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

通过这样做,我们预加载了所有图像,我们可以在网站开始时安全地添加加载屏幕以避免任何闪烁。

现在,让我们在 ImageSliderMaterial 中添加两个 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 uniform 在之前和当前纹理之间进行插值。

我们可以看到先前和当前图像之间的混合效果。

淡入淡出效果

让我们通过动画化 uProgression uniform 来创建图像间的平滑过渡。

首先,我们需要一个对 material 的引用,以便更新 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>
  );
};

我们可以去掉 uProgression 属性,因为我们会手动更新。

现在,在图像变化的 useEffect 中,我们可以将 uProgression 设置为 0,并在 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 坐标来扭曲图像的位置。让我们在 ImageSliderMaterial 中添加一个 uDistortion uniform,并用它来扭曲 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.