參考:[Building a WebGL Carousel with React Three Fiber and GSAP](https://tympanus.net/codrops/2023/04/27/building-a-webgl-carousel-with-react-three-fibe ...
參考:Building a WebGL Carousel with React Three Fiber and GSAP
效果來源於由 Eum Ray 創建的網站 alcre.co.kr,具有迷人的視覺效果和交互性,具有可拖動或滾動的輪播,具有有趣的圖像展示效果。
本文將使用 WebGL、React Three Fiber 和 GSAP 實現類似的效果。通過本文,可以瞭解如何使用 WebGL、React Three Fiber 和 GSAP 創建互動式 3D 輪播。
準備
首先,用 createreact app
創建項目
npx create-react-app webgl-carsouel
cd webgl-carsouel
npm 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 UV
s = plane size
i = 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個 uniforms
:uRes
和 uImageRes
。每當改變視口大小時,這2個變數將會隨之改變。使用 uRes
以像素為單位存儲片面的大小,使用 uImageRes
存儲圖像紋理的大小。
下麵是創建平面和設置著色器材質的代碼:
// Plane.js
import { 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
,存儲縮放平面的值 x
、y
,從而得到頂點著色器的位置。縮放值通過在平面尺寸和視口尺寸比例來計算:
$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.js
import { 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.js
import { 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.js
const 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.js
import { 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 settings
const planeSettings = {
width: 1,
height: 2.5,
gap: 0.1
}
// gsap defaults
gsap.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.js
import { 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
並調整設置實現效果。效果幾乎相同!
訣竅是將 material
的 thickness
屬性與輪播滾動進度的速度聯繫起來,僅此而已。
// PostProcessing.js
import { 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.js
import { 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 settings
const planeSettings = {
width: 1,
height: 2.5,
gap: 0.1
}
// gsap defaults
gsap.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 啟發的輪播一樣。希望這篇文章對您自己的項目有所幫助和啟發!