分享一个THREE.JS中无限滚动的技巧

分享一个THREE.JS中无限滚动的技巧

最近在学习three.js发现了一个无限滚动的效果感觉还挺好看的故此来分享一下

本篇代码使用了@react-three/fiber

1. 如何布局

首先我们准备好我们需要的图片资源,然后直接加载到页面中心

JavaScript 复制代码
function ImageTube() {

    const imageUrls = useMemo(() => [
        "/tube/im1.jpg",
        "/tube/im3.jpg",
        "/tube/im2.jpg",
        "/tube/im4.jpg",
        "/tube/im5.jpg",
        "/tube/im6.jpg",
        "/tube/im7.jpg",
        "/tube/im8.jpg",
        "/tube/im9.jpg",
    ], []);

    const textures = useTexture(imageUrls);


    return Array.from({length: imageUrls.length}).map((_, index) => {

        return (
            <mesh key={index}>
                <planeGeometry args={[1, 1]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })
}

PlaneGeometry 参数定义表

参数索引 参数名 类型 默认值 功能描述
args[0] width float 1.0 平面宽度:沿 X 轴的长度。
args[1] height float 1.0 平面高度:沿 Y 轴的长度。
args[2] widthSegments int 1 水平分段:沿宽度方向将平面切分成多少个面片。
args[3] heightSegments int 1 垂直分段:沿高度方向将平面切分成多少个面片。

1.1 上下分开排列布局!

目前我们已经把我们所有的图片信息都加载到画面中了,下一步我们让这些图片依次从上到下排列!

核心是利用meshposition属性来改变定位,举个例子🌰

JavaScript 复制代码
return Array.from({length: imageUrls.length}).map((_, index) => {

        const tileW = 0.8;
        const tileH = 1.0;

        // 定义对角线的边界
        const startX = -width / 2 + tileW / 2;     // 屏幕左侧
        const startY = height / 2 - tileH / 2;    // 屏幕上方
        const endX = width / 2 - tileW / 2;      // 屏幕右侧
        const endY = -height / 2 + tileH / 2;   // 屏幕下方

        const count = imageUrls.length;
        const t = index / (count - 1);

        const x = startX + (endX - startX) * t;
        const y = startY + (endY - startY) * t;
        const z = 0;


        return (
            <mesh key={index} position={[x,y,z]}>
                <planeGeometry args={[tileW, tileH]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })

可以很直观的看到我们已经完成了一个对角线排列的图片布局!

1.2 水平球形排列

经过上面的练习我们已经知道了可以通过position来排列,那么接下来我们把图片按照效果图那样排列一下!主要是用到了球坐标公式
{ x = radius × cos ⁡ ( θ ) z = radius × sin ⁡ ( θ ) y = height \begin{cases} x = \text{radius} \times \cos(\theta) \\ z = \text{radius} \times \sin(\theta) \\ y = \text{height} \end{cases} ⎩ ⎨ ⎧x=radius×cos(θ)z=radius×sin(θ)y=height

变量 名称 含义 作用
theta ( θ \theta θ) 弧度 (Radians) 物体在圆周上的角度位置。 决定物体在圆圈的"几点钟方向"。
radius ( r r r) 半径 物体距离圆心的距离。 决定圆阵的大小(圆柱体的粗细)。
x X 坐标 水平轴位置。 决定物体的左右分布。
z Z 坐标 深度轴位置。 决定物体的远近分布(产生 3D 深度感)。
JavaScript 复制代码
 return Array.from({length: imageUrls.length}).map((_, index) => {

        const count = imageUrls.length;
        // const y = (index - (count - 1) / 2) * ySpacing;

        const radius = 4;
        const theta = (index / count - 1) * Math.PI * 2;
        const x = Math.cos(theta) * radius;
        const z = Math.sin(theta) * radius;


        return (
            <mesh key={index} position={[x, 0, z]}>
                <planeGeometry args={[tileW, tileH]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })

1.3 引入Group上下排列

上面我们已经成功的实现了一个水平球形排列,接下来我们要把这九张图重复上下排列!这时候就有一个问题了,目前我们的xz的计算很简单就是代入公式直接求答案,如果要上下排列那么就不再是一个单纯的圆形而是一个圆柱体形的排列,复杂度上了一个档次所以我介绍一个新的组件Group!也就是组的概念,在这个组下的所有元素都受到这个Group的影响!也就是position属性也是先根据Group再计算自身的

JavaScript 复制代码
    const radius = 4;
    const tileW = 0.8;
    const tileH = 1.0;
    const ySpacing = 2.7;

    const rows = 5;
    const cols = 12;

    const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number;  }> = [];
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const y = (rowIndex - (rows - 1) / 2) * ySpacing;
            const baseRow = rowIndex % rows;
            out.push({rowIndex, y, baseRow});
        }
        return out;
    }, [rows, ySpacing]);


return (
        <group>
            {rowPositions.map(({rowIndex, y, baseRow}) => (
                <group key={rowIndex} position={[0, y, 0]}>

                    {Array.from({length: cols}).map((_, col) => {
                        const theta = (col / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
                    })}

                </group>
            ))}
        </group>
    )

成功完成五行十二列的布局!

2. 自滚动

目前的布局我们基本上实现了,接下来添加一个自旋转效果,说到自动的视图变化那肯定就要用到useFrame

useFrame 是 R3F 中最重要的 Hook,它允许你在每一帧(通常是每秒 60 次)执行代码。它是实现 动画物理模拟实时交互 的核心入口。

JavaScript 复制代码
    useFrame((_state, dt) => {
        ...
        ...
        👆每一帧自动运行
    });

举个例子!我们直接在useFrame中控制grouprotation.y

JavaScript 复制代码
 const rowGroupRefs = useRef<Array<Object3D | null>>([]);

 useFrame((_state, dt) => {
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            // const baseRow = rowIndex % rows;
            rowObj.rotation.y  += 0.0065;
        }
 })

{rowPositions.map(({rowIndex, y, baseRow}) => (
                <group
                    ...
                    ...
                    ref={(obj) => {
                        rowGroupRefs.current[rowIndex] = obj;
  }}>
...
...
...  

这样已经实现了一个基本的自滚动效果!

2.1 交错排列

现在的滚动效果可以发现每一行每一列都是对齐的有点过去生硬了,我们加一个偏移量

JavaScript 复制代码
 const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number; rowOffset: number }> = [];
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            ...
            ...
            + const rowOffset = baseRow % 2 === 0 ? 0 : 0.5;
            out.push({rowIndex, y, baseRow, rowOffset});
        }
        return out;
}, [rows, ySpacing]);


{Array.from({length: cols}).map((_, col) => {
                        const theta = ((col + rowOffset) / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
})}

2.2 和鼠标滚轮结合

现在基本的自转已经实现了,但是我们的目标是和鼠标滚轮结合还要有一个上下的滚动效果!

首先我们来实现一个上下滚动的效果,根据我们 1.3 学到的经验我们应该在这些外层再使用一个Group,并且控制这个Groupposition.y

JavaScript 复制代码
  const rowGroupRefs = useRef<Array<Object3D | null>>([]);
  + const groupRef = useRef<Object3D>(null);
  + const scrollCurrent = useRef(0);


    useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            // const baseRow = rowIndex % rows;
            rowObj.rotation.y += 0.0065;
        }


    })


  <group ref={groupRef}>
            {rowPositions.map(({rowIndex, y, baseRow,rowOffset}) => (
                <group>
                    ...
                    ...
                <group>    
   </group>

scrollTargetRef为外部组件传递进来

JavaScript 复制代码
function App() {

    const tubeScrollTarget = useRef(0);

    const onWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
        tubeScrollTarget.current += event.deltaY * 0.002;
    }, []);

    return (
        <div className="sceneRoot" onWheel={onWheel}>

            <Canvas
                camera={{position: [0, 0, 6.5], fov: 50}}
                onCreated={({camera}) => {
                    camera.lookAt(0, 0, 0);
                }}
            >

                {/*<OrbitControls enableDamping dampingFactor={0.05}/>*/}
                <ambientLight intensity={0.5}/>
                <directionalLight position={[5, 5, 5]} intensity={1}/>

                <Environment preset="studio" blur={10.5}/>
                <GridPlane targetCenterUv={targetCenterUv}/>
               
                <ImageTube2 scrollTargetRef={tubeScrollTarget}/>

            </Canvas>
        </div>
    )
}

okkk,现在和鼠标滚轮事件结合可以上下滚动我们的列表了,但是现在还有点呆板的是我们绕y轴旋转的列表还没有和滚轮事件结合,我们的目标是在鼠标滚动的时候我们的旋转列表应该进行一个加速滚动!

这个时候我们需要用到另一个公式

📈 动态旋转位移叠加公式

该公式负责在 useFrame 中更新全局旋转角度,结合了自动巡航与用户交互。

  1. 公式定义 Total_Angle_Increment = (Base_Auto_Speed + User_Input_Velocity) * Delta_Time

  2. 参数拆解

  • baseSpeed: 静态常量,控制背景自动旋转的快慢。
  • spinVelocityRef: 动态变量,承载用户交互产生的动能(惯性)。
  • scaledDt: 时间缩放因子,确保跨设备速度一致性。
  1. 运行逻辑
  2. 每一帧读取当前的"自转力"和"手动推力"。
  3. 将两者求和,得到当前的总瞬时速度
  4. 根据这一帧经过的时间计算出应该转过的弧度增量
  5. 累加到 angle.current,从而驱动所有 meshrotation.y

首先我们得速率spinVelocityRef依然要和鼠标滚轮事件绑定,同时区分一下鼠标滚动得方向tubeNaturalDir

JavaScript 复制代码
    const onWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
        tubeScrollTarget.current += event.deltaY * 0.002;
      + tubeSpinVelocity.current += event.deltaY * 0.004;

        if (event.deltaY < 0) tubeNaturalDir.current = -1;
        else if (event.deltaY > 0) tubeNaturalDir.current = 1;
    }, []);


 <ImageTube
    scrollTargetRef={tubeScrollTarget}
  + spinVelocityRef={tubeSpinVelocity}
    naturalDirRef={tubeNaturalDir}
    ...
    ... />

然后在我们得ImageTube中接收这个速率并且做一个递增处理

JavaScript 复制代码
 useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        ...
        ...

    })

接下来我们算一个旋转速度比例系数

JavaScript 复制代码
 useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

      + rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            0.12;
      // 根据方向不同产生不同方向得力
      + const scaledDt = dt * rotationSpeedScale.current;
        ...
        ...

    })

有了速率和速度比例系数之后我们就可以套进公式算出来我们应该滚动得距离了

JavaScript 复制代码
useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

3. 无限滚动!

拖拖拉拉才进入本章得主题,那就是实现一个无限滚动得效果,如果大家之前用css做过类似得效果得话其实是能了解到所谓得无限滚动都是障眼法,是通过瞬时改变值来实现得,也就是把滚动了一定距离得值直接改到起始位置!

在我们这个上下滚动得例子中很明显我们得滚动距离最大是rows * ySpacing / 2 ,同样由于我们支持上下自由滚动所以我们最起码需要

JavaScript 复制代码
const repeatCount = 3;
const totalRows = rows * repeatCount;

okok来实际看下代码,之前所有关于rows得地方都需要改变成totalRows

JavaScript 复制代码
function ImageTube({
                        baseSpeedRef,
                        tubeAngleRef,
                        naturalDirRef,
                        scrollTargetRef,
                        spinVelocityRef,
                        rotationSpeedScaleLerpRef,
                        rotationSpeedScaleTargetRef,
                    }: {
    baseSpeedRef: React.MutableRefObject<number>;
    tubeAngleRef: React.MutableRefObject<number>;
    naturalDirRef: React.MutableRefObject<number>;
    scrollTargetRef: React.MutableRefObject<number>;
    spinVelocityRef: React.MutableRefObject<number>;
    rotationSpeedScaleTargetRef: React.MutableRefObject<number>;
    rotationSpeedScaleLerpRef: React.MutableRefObject<number>;
}) {

    const groupRef = useRef<Object3D>(null);
    const rowGroupRefs = useRef<Array<Object3D | null>>([]);
    const scrollCurrent = useRef(0);
    const rotationSpeedScale = useRef(1);
    const angle = useRef(0);

    const imageUrls = useMemo(() => [
        "/tube/im1.jpg",
        "/tube/im3.jpg",
        "/tube/im2.jpg",
        "/tube/im4.jpg",
        "/tube/im5.jpg",
        "/tube/im6.jpg",
        "/tube/im7.jpg",
        "/tube/im8.jpg",
        "/tube/im9.jpg",
    ], []);


    const textures = useTexture(imageUrls);

    const radius = 4;
    const tileW = 0.8;
    const tileH = 1.0;
    const ySpacing = 2.7;

    const rows = 5;
    const cols = 12;

    const loopHeight = rows * ySpacing;
    const repeatCount = 3;
    const totalRows = rows * repeatCount;

    const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number; rowOffset: number }> = [];
        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const y = (rowIndex - (totalRows - 1) / 2) * ySpacing;
            const baseRow = rowIndex % rows;
            const rowOffset = baseRow % 2 === 0 ? 0 : 0.5;
            out.push({rowIndex, y, baseRow, rowOffset});
        }
        return out;
    }, [totalRows, ySpacing]);


    useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

    return (
        <group ref={groupRef}>
            {rowPositions.map(({rowIndex, y, baseRow, rowOffset}) => (
                <group
                    key={rowIndex}
                    position={[0, y, 0]}
                    ref={(obj) => {
                        rowGroupRefs.current[rowIndex] = obj;
                    }}>

                    {Array.from({length: cols}).map((_, col) => {
                        const theta = ((col + rowOffset) / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
                    })}

                </group>
            ))}
        </group>
    )

}

然后我们依然在useFrame中处理我们得瞬时逻辑!

JavaScript 复制代码
useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;
        // 当超过最大滚动距离得时候,我们瞬间改变滚动得高度
        if (scrollCurrent.current > loopHeight / 2) {
            scrollCurrent.current -= loopHeight;
            scrollTargetRef.current -= loopHeight;
        } else if (scrollCurrent.current < -loopHeight / 2) {
            scrollCurrent.current += loopHeight;
            scrollTargetRef.current += loopHeight;
        }

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

4. 结语

为什么总觉得成功只差一步!

参考资料

Reactive Depth: Building a Scroll-Driven 3D Image Tube with React Three Fiber

相关推荐
沙蒿同学8 分钟前
我把架构约定编译成了会变红的测试:Wails v2 + Go + Vue3 桌面脚手架实战
前端·后端·github
cpolar技术支持11 分钟前
本地 Playwright 测试报告怎么远程复盘?Trace Viewer 跑起来后,用 cpolar 分享失败现场
前端·自动化测试·测试工具·cpolar·playwright
wordbaby11 分钟前
企业级后台管理系统路由设计与最佳实践指南
前端
胡志辉的博客20 分钟前
【完全开源】IP 纯净度检测 可一键部署到自己的CF
前端·javascript·chrome·ip·chromium
Hilaku1 小时前
作为面试官,我最怕遇到什么样的候选人?
前端·javascript·程序员
TiDi1 小时前
Pinia优化重复请求
前端
web3d5201 小时前
01-用 Leafletjs 10 分钟搭一张水利一张图(Vue3 + Vite 实战)
前端·javascript
晚安日记wanna1 小时前
Vue2 的 defineProperty 差在哪四层追问筛掉九成候选人
前端·vue.js·面试
TiDi2 小时前
吸顶导航交互实现
前端
kisshyshy2 小时前
从Props透传到自定义Hook:系统梳理React跨层级通信与逻辑复用
前端·架构·代码规范