参考:Building a WebGL Carousel with React Three Fiber and GSAP

  • 在线 demo
  • github 源码

成果来源于由 Eum Ray 创立的网站 alcre.co.kr,具备迷人的视觉效果和交互性,具备可拖动或滚动的轮播,具备乏味的图像展现成果。

本文将应用 WebGL、React Three Fiber 和 GSAP 实现相似的成果。通过本文,能够理解如何应用 WebGL、React Three Fiber 和 GSAP 创立交互式 3D 轮播。

筹备

首先,用 createreact app 创立我的项目

npx create-react-app webgl-carsouelcd webgl-carsouelnpm start

而后装置相干依赖

npm i @react-three/fiber @react-three/drei gsap leva react-use -S
  • @react-three/fiber: 用 react 实现的简化 three.js 写法的一个十分闻名的库
  • @react-three/drei:@react-three/fiber 生态中的一个十分有用的库,是对 @react-three/fiber 的加强
  • gsap: 一个十分闻名的动画库
  • leva: @react-three/fiber 生态中用以在几秒钟内创立GUI控件的库
  • react-use: 一个风行的 react hooks 库

1. 生成具备纹理的 3D 立体

首先,创立一个任意大小的立体,搁置于原点(0, 0, 0)并面向相机。而后,应用 shaderMaterial 材质将所需图像插入到材质中,批改 UV 地位,让图像填充整个几何体外表。

为了实现这一点,须要应用一个 glsl 函数,函数将立体和图像的比例作为转换参数:

/* --------------------------------Background Cover UV--------------------------------u = basic UVs = plane sizei = image size*/vec2 CoverUV(vec2 u, vec2 s, vec2 i) {  float rs = s.x / s.y; // aspect plane size  float ri = i.x / i.y; // aspect image size  vec2 st = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x); // new st  vec2 o = (rs < ri ? vec2((st.x - s.x) / 2.0, 0.0) : vec2(0.0, (st.y - s.y) / 2.0)) / st; // offset  return u * s / st + o;}

接下来,将定义2个 uniformsuResuImageRes。每当扭转视口大小时,这2个变量将会随之扭转。应用 uRes 以像素为单位存储全面的大小,应用 uImageRes 存储图像纹理的大小。

上面是创立立体和设置着色器材质的代码:

// Plane.jsimport { useEffect, useMemo, useRef } from "react"import { useThree } from "@react-three/fiber"import { useTexture } from "@react-three/drei"import { useControls } from 'leva'const Plane = () => {  const $mesh = useRef()  const { viewport } = useThree()  const tex = useTexture(    'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/1.jpg'  )  const { width, height } = useControls({    width: {      value: 2,      min: 0.5,      max: viewport.width,    },    height: {      value: 3,      min: 0.5,      max: viewport.height,    }  })  useEffect(() => {    if ($mesh.current.material) {      $mesh.current.material.uniforms.uRes.value.x = width      $mesh.current.material.uniforms.uRes.value.y = height    }  }, [viewport, width, height])  const shaderArgs = useMemo(() => ({    uniforms: {      uTex: { value: tex },      uRes: { value: { x: 1, y: 1 } },      uImageRes: {        value: { x: tex.source.data.width, y: tex.source.data.height }      }    },    vertexShader: /* glsl */ `      varying vec2 vUv;      void main() {        vUv = uv;        vec3 pos = position;        gl_Position = projectionMatrix * modelViewMatrix * vec4( pos, 1.0 );      }    `,    fragmentShader: /* glsl */ `      uniform sampler2D uTex;      uniform vec2 uRes;      uniform vec2 uImageRes;      /*      -------------------------------------      background Cover UV      -------------------------------------      u = basic UV      s = screen size      i = image size      */      vec2 CoverUV(vec2 u, vec2 s, vec2 i) {        float rs = s.x / s.y; // aspect screen size        float ri = i.x / i.y; // aspect image size        vec2 st = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x); // new st        vec2 o = (rs < ri ? vec2((st.x - s.x) / 2.0, 0.0) : vec2(0.0, (st.y - s.y) / 2.0)) / st; // offset        return u * s / st + o;      }      varying vec2 vUv;      void main() {        vec2 uv = CoverUV(vUv, uRes, uImageRes);        vec3 tex = texture2D(uTex, uv).rgb;        gl_FragColor = vec4(tex, 1.0);      }    `  }), [tex])  return (    <mesh ref={$mesh}>      <planeGeometry args={[width, height, 30, 30]} />      <shaderMaterial args={[shaderArgs]} />    </mesh>  )}export default Plane

2、向立体增加缩放成果

首先, 设置一个新组件来包裹 <Plane />,用以治理缩放成果的激活和停用。

应用着色器材质 shaderMaterial 调整 mesh 大小可放弃几何空间的尺寸。因而,激活缩放成果后,必须显示一个新的通明立体,其尺寸与视口相当,不便点击整个图像复原到初始状态。

此外,还须要在立体的着色器中实现波浪成果。

因而,在 uniforms 中增加一个新字段 uZoomScale,存储缩放立体的值 xy,从而失去顶点着色器的地位。缩放值通过在立体尺寸和视口尺寸比例来计算:

$mesh.current.material.uniforms.uZoomScale.value.x = viewport.width / width$mesh.current.material.uniforms.uZoomScale.value.y = viewport.height / height

接下来,在 uniforms 中增加一个新字段 uProgress,来管制波浪成果的数量。通过应用 GSAP 批改 uProgress,动画实现平滑的缓动成果。

创立波形成果,能够在顶点着色器中应用 sin 函数,函数在立体的 x 和 y 地位上增加波状静止。

// CarouselItem.jsimport { useEffect, useRef, useState } from "react"import { useThree } from "@react-three/fiber"import gsap from "gsap"import Plane from './Plane'const CarouselItem = () => {  const $root = useRef()  const [hover, setHover] = useState(false)  const [isActive, setIsActive] = useState(false)  const { viewport } = useThree()  useEffect(() => {    gsap.killTweensOf($root.current.position)    gsap.to($root.current.position, {      z: isActive ? 0 : -0.01,      duration: 0.2,      ease: "power3.out",      delay: isActive ? 0 : 2    })  }, [isActive])  // hover effect  useEffect(() => {    const hoverScale = hover && !isActive ? 1.1 : 1    gsap.to($root.current.scale, {      x: hoverScale,      y: hoverScale,      duration: 0.5,      ease: "power3.out"    })  }, [hover, isActive])  const handleClose = (e) => {    e.stopPropagation()    if (!isActive) return    setIsActive(false)  }  const textureUrl = 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/1.jpg'  return (    <group      ref={$root}      onClick={() => setIsActive(true)}      onPointerEnter={() => setHover(true)}      onPointerLeave={() => setHover(false)}    >      <Plane        width={1}        height={2.5}        texture={textureUrl}        active={isActive}      />      {isActive ? (        <mesh position={[0, 0, 0]} onClick={handleClose}>          <planeGeometry args={[viewport.width, viewport.height]} />          <meshBasicMaterial transparent={true} opacity={0} color="red" />        </mesh>      ) : null}    </group>  )}export default CarouselItem

<Plane /> 组件也要进行更改,反对参数及参数变更解决,更改后:

// Plane.jsimport { useEffect, useMemo, useRef } from "react"import { useThree } from "@react-three/fiber"import { useTexture } from "@react-three/drei"import gsap from "gsap"const Plane = ({ texture, width, height, active, ...props}) => {  const $mesh = useRef()  const { viewport } = useThree()  const tex = useTexture(texture)  useEffect(() => {    if ($mesh.current.material) {      // setting      $mesh.current.material.uniforms.uZoomScale.value.x = viewport.width / width      $mesh.current.material.uniforms.uZoomScale.value.y = viewport.height / height      gsap.to($mesh.current.material.uniforms.uProgress, {        value: active ? 1 : 0,        duration: 2.5,        ease: 'power3.out'      })      gsap.to($mesh.current.material.uniforms.uRes.value, {        x: active ? viewport.width : width,        y: active? viewport.height : height,        duration: 2.5,        ease: 'power3.out'      })    }  }, [viewport, active]);  const shaderArgs = useMemo(() => ({    uniforms: {      uProgress: { value: 0 },      uZoomScale: { value: { x: 1, y: 1 } },      uTex: { value: tex },      uRes: { value: { x: 1, y: 1 } },      uImageRes: {        value: { x: tex.source.data.width, y: tex.source.data.height }      }    },    vertexShader: /* glsl */ `      varying vec2 vUv;      uniform float uProgress;      uniform vec2 uZoomScale;      void main() {        vUv = uv;        vec3 pos = position;        float angle = uProgress * 3.14159265 / 2.;        float wave = cos(angle);        float c = sin(length(uv - .5) * 15. + uProgress * 12.) * .5 + .5;        pos.x *= mix(1., uZoomScale.x + wave * c, uProgress);        pos.y *= mix(1., uZoomScale.y + wave * c, uProgress);        gl_Position = projectionMatrix * modelViewMatrix * vec4( pos, 1.0 );      }    `,    fragmentShader: /* glsl */ `      uniform sampler2D uTex;      uniform vec2 uRes;      // uniform vec2 uZoomScale;      uniform vec2 uImageRes;      /*      -------------------------------------      background Cover UV      -------------------------------------      u = basic UV      s = screen size      i = image size      */      vec2 CoverUV(vec2 u, vec2 s, vec2 i) {        float rs = s.x / s.y; // aspect screen size        float ri = i.x / i.y; // aspect image size        vec2 st = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x); // new st        vec2 o = (rs < ri ? vec2((st.x - s.x) / 2.0, 0.0) : vec2(0.0, (st.y - s.y) / 2.0)) / st; // offset        return u * s / st + o;      }      varying vec2 vUv;      void main() {        vec2 uv = CoverUV(vUv, uRes, uImageRes);        vec3 tex = texture2D(uTex, uv).rgb;        gl_FragColor = vec4(tex, 1.0);      }    `  }), [tex])  return (    <mesh ref={$mesh} {...props}>      <planeGeometry args={[width, height, 30, 30]} />      <shaderMaterial args={[shaderArgs]} />    </mesh>  )}export default Plane

3、实现能够用鼠标滚动或拖动挪动的图像轮播

这部分是最乏味的,但也是最简单的,因为必须思考很多事件。

首先,须要应用 renderSlider 创立一个组用以蕴含所有图像,图像用 <CarouselItem /> 渲染。
而后,须要应用 renderPlaneEvent() 创立一个全面用以治理事件。

轮播最重要的局部在 useFrame() 中,须要计算滑块进度,应用 displayItems() 函数设置所有item 地位。

另一个须要思考的重要方面是 <CarouselItem />z 地位,当它变为活动状态时,须要使其 z 地位更凑近相机,以便缩放成果不会与其余 meshs 抵触。这也是为什么当退出缩放时,须要 mesh 足够小以将 z 轴地位复原为 0 (详见 <CarouselItem />)。也是为什么禁用其余 meshs 的点击,直到缩放成果被停用。

// data/images.jsconst images = [  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/1.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/2.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/3.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/4.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/5.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/6.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/7.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/8.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/9.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/10.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/11.jpg' },  { image: 'https://raw.githubusercontent.com/supahfunk/webgl-carousel/main/public/img/12.jpg' }]export default images
// Carousel.jsimport { useEffect, useRef, useState, useMemo } from "react";import { useFrame, useThree } from "@react-three/fiber";import { usePrevious } from 'react-use'import gsap from 'gsap'import CarouselItem from './CarouselItem'import images from '../data/images'// Plane settingsconst planeSettings = {  width: 1,  height: 2.5,  gap: 0.1}// gsap defaultsgsap.defaults({  duration: 2.5,  ease: 'power3.out'})const Carousel = () => {  const [$root, setRoot] = useState();  const [activePlane, setActivePlane] = useState(null);  const prevActivePlane = usePrevious(activePlane)  const { viewport } = useThree()  // vars  const progress = useRef(0)  const startX = useRef(0)  const isDown = useRef(false)  const speedWheel = 0.02  const speedDrag = -0.3  const $items = useMemo(() => {    if ($root) return $root.children  }, [$root])  const displayItems = (item, index, active) => {    gsap.to(item.position, {      x: (index - active) * (planeSettings.width + planeSettings.gap),      y: 0    })  }  useFrame(() => {    progress.current = Math.max(0, Math.min(progress.current, 100))    const active = Math.floor((progress.current / 100) * ($items.length - 1))    $items.forEach((item, index) => displayItems(item, index, active))  })  const handleWheel = (e) => {    if (activePlane !== null) return    const isVerticalScroll = Math.abs(e.deltaY) > Math.abs(e.deltaX)    const wheelProgress = isVerticalScroll ? e.deltaY : e.deltaX    progress.current = progress.current + wheelProgress * speedWheel  }  const handleDown = (e) => {    if (activePlane !== null) return    isDown.current = true    startX.current = e.clientX || (e.touches && e.touches[0].clientX) || 0  }  const handleUp = () => {    isDown.current = false  }  const handleMove = (e) => {    if (activePlane !== null || !isDown.current) return    const x = e.clientX || (e.touches && e.touches[0].clientX) || 0    const mouseProgress = (x - startX.current) * speedDrag    progress.current = progress.current + mouseProgress    startX.current = x  }  // click  useEffect(() => {    if (!$items) return    if (activePlane !== null && prevActivePlane === null) {      progress.current = (activePlane / ($items.length - 1)) * 100    }  }, [activePlane, $items]);  const renderPlaneEvents = () => {    return (      <mesh        position={[0, 0, -0.01]}        onWheel={handleWheel}        onPointerDown={handleDown}        onPointerUp={handleUp}        onPointerMove={handleMove}        onPointerLeave={handleUp}        onPointerCancel={handleUp}      >        <planeGeometry args={[viewport.width, viewport.height]} />        <meshBasicMaterial transparent={true} opacity={0} />      </mesh>    )  }  const renderSlider = () => {    return (      <group ref={setRoot}>        {images.map((item, i) => (          <CarouselItem            width={planeSettings.width}            height={planeSettings.height}            setActivePlane={setActivePlane}            activePlane={activePlane}            key={item.image}            item={item}            index={i}          />        ))}      </group>    )  }  return (    <group>      {renderPlaneEvents()}      {renderSlider()}    </group>  )}export default Carousel

<CarouselItem> 须要更改,以便依据参数显示不同的图像,及其他细节解决,更改后如下:

// CarouselItem.jsimport { useEffect, useRef, useState } from "react"import { useThree } from "@react-three/fiber"import gsap from "gsap"import Plane from './Plane'const CarouselItem = ({  index,  width,  height,  setActivePlane,  activePlane,  item}) => {  const $root = useRef()  const [hover, setHover] = useState(false)  const [isActive, setIsActive] = useState(false)  const [isCloseActive, setIsCloseActive] = useState(false);  const { viewport } = useThree()  const timeoutID = useRef()  useEffect(() => {    if (activePlane === index) {      setIsActive(activePlane === index)      setIsCloseActive(true)    } else {      setIsActive(null)    }  }, [activePlane]);    useEffect(() => {    gsap.killTweensOf($root.current.position)    gsap.to($root.current.position, {      z: isActive ? 0 : -0.01,      duration: 0.2,      ease: "power3.out",      delay: isActive ? 0 : 2    })  }, [isActive])  // hover effect  useEffect(() => {    const hoverScale = hover && !isActive ? 1.1 : 1    gsap.to($root.current.scale, {      x: hoverScale,      y: hoverScale,      duration: 0.5,      ease: "power3.out"    })  }, [hover, isActive])  const handleClose = (e) => {    e.stopPropagation()    if (!isActive) return    setActivePlane(null)    setHover(false)    clearTimeout(timeoutID.current)    timeoutID.current = setTimeout(() => {      setIsCloseActive(false)    }, 1500);    // 这个计时器的持续时间取决于 plane 敞开动画的工夫  }  return (    <group      ref={$root}      onClick={() => setActivePlane(index)}      onPointerEnter={() => setHover(true)}      onPointerLeave={() => setHover(false)}    >      <Plane        width={width}        height={height}        texture={item.image}        active={isActive}      />      {isCloseActive ? (        <mesh position={[0, 0, 0.01]} onClick={handleClose}>          <planeGeometry args={[viewport.width, viewport.height]} />          <meshBasicMaterial transparent={true} opacity={0} color="red" />        </mesh>      ) : null}    </group>  )}export default CarouselItem

4、实现前期解决成果,加强轮播体验

真正吸引我眼球并激发我复制次轮播的是视口边缘拉伸像素的成果。

过来,我通过 @react-three/postprocessing 来自定义着色器屡次实现此成果。然而,最近我始终在应用 MeshTransmissionMaterial,因而有了一个想法,尝试用这种资料笼罩 mesh 并调整设置实现成果。成果简直雷同!

窍门是将 materialthickness 属性与轮播滚动进度的速度分割起来,仅此而已。

// PostProcessing.jsimport { forwardRef } from "react";import { useThree } from "@react-three/fiber";import { MeshTransmissionMaterial } from "@react-three/drei";import { Color } from "three";import { useControls } from 'leva'const PostProcessing = forwardRef((_, ref) => {  const { viewport } = useThree()  const { active, ior } = useControls({    active: { value: true },    ior: {      value: 0.9,      min: 0.8,      max: 1.2    }  })  return active ? (    <mesh position={[0, 0, 1]}>      <planeGeometry args={[viewport.width, viewport.height]} />      <MeshTransmissionMaterial        ref={ref}        background={new Color('white')}        transmission={0.7}        roughness={0}        thickness={0}        chromaticAberration={0.06}        anisotropy={0}        ior={ior}      />    </mesh>  ) : null})export default PostProcessing

因为后处理作用于 <Carousel /> 组件,所以须要进行相应的更改,更改后如下:

// Carousel.jsimport { useEffect, useRef, useState, useMemo } from "react";import { useFrame, useThree } from "@react-three/fiber";import { usePrevious } from 'react-use'import gsap from 'gsap'import PostProcessing from "./PostProcessing";import CarouselItem from './CarouselItem'import { lerp, getPiramidalIndex } from "../utils";import images from '../data/images'// Plane settingsconst planeSettings = {  width: 1,  height: 2.5,  gap: 0.1}// gsap defaultsgsap.defaults({  duration: 2.5,  ease: 'power3.out'})const Carousel = () => {  const [$root, setRoot] = useState();  const $post = useRef()  const [activePlane, setActivePlane] = useState(null);  const prevActivePlane = usePrevious(activePlane)  const { viewport } = useThree()  // vars  const progress = useRef(0)  const startX = useRef(0)  const isDown = useRef(false)  const speedWheel = 0.02  const speedDrag = -0.3  const oldProgress = useRef(0)  const speed = useRef(0)  const $items = useMemo(() => {    if ($root) return $root.children  }, [$root])  const displayItems = (item, index, active) => {    const piramidalIndex = getPiramidalIndex($items, active)[index]    gsap.to(item.position, {      x: (index - active) * (planeSettings.width + planeSettings.gap),      y: $items.length * -0.1 + piramidalIndex * 0.1    })  }  useFrame(() => {    progress.current = Math.max(0, Math.min(progress.current, 100))    const active = Math.floor((progress.current / 100) * ($items.length - 1))    $items.forEach((item, index) => displayItems(item, index, active))    speed.current = lerp(speed.current, Math.abs(oldProgress.current - progress.current), 0.1)    oldProgress.current = lerp(oldProgress.current, progress.current, 0.1)    if ($post.current) {      $post.current.thickness = speed.current    }  })  const handleWheel = (e) => {    if (activePlane !== null) return    const isVerticalScroll = Math.abs(e.deltaY) > Math.abs(e.deltaX)    const wheelProgress = isVerticalScroll ? e.deltaY : e.deltaX    progress.current = progress.current + wheelProgress * speedWheel  }  const handleDown = (e) => {    if (activePlane !== null) return    isDown.current = true    startX.current = e.clientX || (e.touches && e.touches[0].clientX) || 0  }  const handleUp = () => {    isDown.current = false  }  const handleMove = (e) => {    if (activePlane !== null || !isDown.current) return    const x = e.clientX || (e.touches && e.touches[0].clientX) || 0    const mouseProgress = (x - startX.current) * speedDrag    progress.current = progress.current + mouseProgress    startX.current = x  }  // click  useEffect(() => {    if (!$items) return    if (activePlane !== null && prevActivePlane === null) {      progress.current = (activePlane / ($items.length - 1)) * 100    }  }, [activePlane, $items]);  const renderPlaneEvents = () => {    return (      <mesh        position={[0, 0, -0.01]}        onWheel={handleWheel}        onPointerDown={handleDown}        onPointerUp={handleUp}        onPointerMove={handleMove}        onPointerLeave={handleUp}        onPointerCancel={handleUp}      >        <planeGeometry args={[viewport.width, viewport.height]} />        <meshBasicMaterial transparent={true} opacity={0} />      </mesh>    )  }  const renderSlider = () => {    return (      <group ref={setRoot}>        {images.map((item, i) => (          <CarouselItem            width={planeSettings.width}            height={planeSettings.height}            setActivePlane={setActivePlane}            activePlane={activePlane}            key={item.image}            item={item}            index={i}          />        ))}      </group>    )  }  return (    <group>      {renderPlaneEvents()}      {renderSlider()}      <PostProcessing ref={$post} />    </group>  )}export default Carousel
// utils/index.js/** * 返回 v0, v1 之间的一个值,能够依据 t 进行计算 * 示例: * lerp(5, 10, 0) // 5 * lerp(5, 10, 1) // 10 * lerp(5, 10, 0.2) // 6 */export const lerp = (v0, v1, t) => v0 * (1 - t) + v1 * t/** * 以金字塔形态返回索引值递加的数组,从具备最大值的指定索引开始。这些索引通常用于在元素之间创立重叠成果 * 示例:array = [0, 1, 2, 3, 4, 5] * getPiramidalIndex(array, 0) // [ 6, 5, 4, 3, 2, 1 ] * getPiramidalIndex(array, 1) // [ 5, 6, 5, 4, 3, 2 ] * getPiramidalIndex(array, 2) // [ 4, 5, 6, 5, 4, 3 ] * getPiramidalIndex(array, 3) // [ 3, 4, 5, 6, 5, 4 ] * getPiramidalIndex(array, 4) // [ 2, 3, 4, 5, 6, 5 ] * getPiramidalIndex(array, 5) // [ 1, 2, 3, 4, 5, 6 ] */export const getPiramidalIndex = (array, index) => {  return array.map((_, i) => index === i ? array.length : array.length - Math.abs(index - i))}

总之,通过应用 React Three Fiber 、GSAP 和一些创造力,能够在 WebGL 中创立令人惊叹的视觉效果和交互式组件,就像受 alcre.co.kr 启发的轮播一样。心愿这篇文章对您本人的我的项目有所帮忙和启发!