使用 React Three Fiber 和 GSAP 实现 WebGL 轮播动画

  • 使用 React Three Fiber 和 GSAP 实现 WebGL 轮播动画已关闭评论
  • 294 次浏览
  • A+
所属分类:Web前端
摘要

参考:Building a WebGL Carousel with React Three Fiber and GSAP效果来源于由 Eum Ray 创建的网站 alcre.co.kr,具有迷人的视觉效果和交互性,具有可拖动或滚动的轮播,具有有趣的图像展示效果。

参考: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个 uniformsuResuImageRes。每当改变视口大小时,这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,存储缩放平面的值 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.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 并调整设置实现效果。效果几乎相同!

诀窍是将 materialthickness 属性与轮播滚动进度的速度联系起来,仅此而已。

// 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 启发的轮播一样。希望这篇文章对您自己的项目有所帮助和启发!