使用React-Three-Fiber创建动态图像动画

本文详细介绍了如何使用React-Three-Fiber和Three.js创建动态图像动画,包括3D几何体设置、纹理映射、Canvas纹理生成和动画实现等技术内容。

如何使用React-Three-Fiber创建动态图像动画

通过React Three Fiber驱动的旋转纹理、3D几何体和流畅运动,让静态视觉效果焕发生机。

设置视图和相机

相机的视场角(FOV)在这个项目中起着重要作用。让我们保持非常低的FOV,使其看起来像正交相机。您稍后可以尝试不同的视角。我更喜欢使用透视相机而不是正交相机,因为我们可以尝试不同的FOV。有关更详细的实现,请查看源代码。

1
<PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />

设置3D形状

首先,让我们创建并定位将显示图像的3D对象。对于这个示例,我们需要制作2个组件:

Billboard.tsx - 这是一个将显示图像堆栈的圆柱体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
'use client';

import { useRef } from 'react';
import * as THREE from 'three';

function Billboard({ radius = 5, ...props }) {
    const ref = useRef(null);

    return (
        <mesh ref={ref} {...props}>
            <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
            <meshBasicMaterial color="red" side={THREE.DoubleSide} />
        </mesh>
    );
}

Banner.tsx - 这是另一个像移动横幅一样工作的圆柱体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use client';

import * as THREE from 'three';
import { useRef } from 'react';

function Banner({ radius = 1.6, ...props }) {
    const ref = useRef(null);

    return (
        <mesh ref={ref} {...props}>
            <cylinderGeometry
            args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
            />
            <meshBasicMaterial
            color="blue"
            side={THREE.DoubleSide}
            />
        </mesh>
    );
}

export default Banner;

准备好组件后,我们可以在页面上使用它们。

现在让我们构建整个形状:

  1. 创建包装组 - 我们将创建一个包装所有组件的组。这将帮助我们稍后一起旋转所有内容。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
'use client';

import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';

export default function Home() {
    return (
        <div className={styles.page}>
            <View className={styles.view} orbit={false}>
            <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} /> 
                <group>

                </group>
            </View>
        </div>
    );
}
  1. 在循环中渲染Billboard和Banner组件 - 在我们的组内,我们将创建一个循环来多次渲染Billboards和Banners。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
'use client';

import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';

export default function Home() {
    return (
        <div className={styles.page}>
            <View className={styles.view} orbit={false}>
            <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                <group>
                    {Array.from({ length: COUNT }).map((_, index) => [
                        <Billboard
                        key={`billboard-${index}`}
                        radius={5}
                        />,
                        <Banner
                        key={`banner-${index}`}
                        radius={5}
                        />,
                    ])}
                </group>
            </View>
        </div>
    );
}
  1. 将它们堆叠起来 - 我们将使用循环中的索引和y位置将项目堆叠在一起。到目前为止的样子:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
'use client';

import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';

const COUNT = 10;
const GAP = 3.2;

export default function Home() {
    return (
        <div className={styles.page}>
            <View className={styles.view} orbit={false}>
            <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                <group>
                    {Array.from({ length: COUNT }).map((_, index) => [
                        <Billboard
                        key={`billboard-${index}`}
                        radius={5}
                        position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                        />,
                        <Banner
                        key={`banner-${index}`}
                        radius={5}
                        position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
                        />,
                    ])}
                </group>
            </View>
        </div>
    );
}
  1. 添加一些旋转 - 让我们稍微旋转一下!首先,我将硬编码横幅的旋转,使它们更加弯曲并很好地适应Billboard组件。我们还将使半径更大一些。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
'use client';

import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';

const COUNT = 10;
const GAP = 3.2;

export default function Home() {
    return (
        <div className={styles.page}>
            <View className={styles.view} orbit={false}>
            <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                <group>
                    {Array.from({ length: COUNT }).map((_, index) => [
                        <Billboard
                        key={`billboard-${index}`}
                        radius={5}
                        position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                        rotation={[0, index * Math.PI * 0.5, 0]} // <-- billboard的旋转
                        />,
                        <Banner
                        key={`banner-${index}`}
                        radius={5}
                        rotation={[0, 0, 0.085]} // <-- banner的旋转
                        position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
                        />,
                    ])}
                </group>
            </View>
        </div>
    );
}
  1. 倾斜整个东西 - 现在让我们旋转整个组,使其看起来像比萨斜塔。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
'use client';

import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';

const COUNT = 10;
const GAP = 3.2;

export default function Home() {
    return (
        <div className={styles.page}>
            <View className={styles.view} orbit={false}>
            <PerspectiveCamera makeDefault fov={7} position={[0, 0, 70]} near={0.01} far={100000} />
                <group rotation={[-0.15, 0, -0.2]}> // <-- 旋转组
                    {Array.from({ length: COUNT }).map((_, index) => [
                        <Billboard
                        key={`billboard-${index}`}
                        radius={5}
                        position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                        rotation={[0, index * Math.PI * 0.5, 0]}
                        />,
                        <Banner
                        key={`banner-${index}`}
                        radius={5}
                        rotation={[0, 0, 0.085]}
                        position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
                        />,
                    ])}
                </group>
            </View>
        </div>
    );
}
  1. 完美! - 我们的3D形状都已设置好。现在我们可以将图像添加到它们上面。

使用Canvas从图像创建纹理

这是很酷的部分:我们将所有图像放到画布上,然后将该画布用作Billboard形状上的纹理。

为了使这更容易,我创建了一些简化整个过程的辅助函数。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import * as THREE from 'three';

/**
* 预加载图像并计算其尺寸
*/
async function preloadImage(imageUrl, axis, canvasHeight, canvasWidth) {
    const img = new Image();

    img.crossOrigin = 'anonymous';

    await new Promise((resolve, reject) => {
        img.onload = () => resolve();
        img.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
        img.src = imageUrl;
    });

    const aspectRatio = img.naturalWidth / img.naturalHeight;

    let calculatedWidth;
    let calculatedHeight;

    if (axis === 'x') {
        // 水平布局:缩放以适应canvasHeight
        calculatedHeight = canvasHeight;
        calculatedWidth = canvasHeight * aspectRatio;
        } else {
        // 垂直布局:缩放以适应canvasWidth
        calculatedWidth = canvasWidth;
        calculatedHeight = canvasWidth / aspectRatio;
    }

    return { img, width: calculatedWidth, height: calculatedHeight };
}

function calculateCanvasDimensions(imageData, axis, gap, canvasHeight, canvasWidth) {
    if (axis === 'x') {
        const totalWidth = imageData.reduce(
        (sum, data, index) => sum + data.width + (index > 0 ? gap : 0), 0);

        return { totalWidth, totalHeight: canvasHeight };
    } else {
        const totalHeight = imageData.reduce(
        (sum, data, index) => sum + data.height + (index > 0 ? gap : 0), 0);

        return { totalWidth: canvasWidth, totalHeight };
    }
}

function setupCanvas(canvasElement, context, dimensions) {
    const { totalWidth, totalHeight } = dimensions;
    const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);

    canvasElement.width = totalWidth * devicePixelRatio;
    canvasElement.height = totalHeight * devicePixelRatio;

    if (devicePixelRatio !== 1) context.scale(devicePixelRatio, devicePixelRatio);

    context.fillStyle = '#ffffff';
    context.fillRect(0, 0, totalWidth, totalHeight);
}

function drawImages(context, imageData, axis, gap) {
    let currentX = 0;
    let currentY = 0;

    context.save();

    for (const data of imageData) {
        context.drawImage(data.img, currentX, currentY, data.width, data.height);

        if (axis === 'x') currentX += data.width + gap;
        else currentY += data.height + gap;
    }

    context.restore();
}

function createTextureResult(canvasElement, dimensions) {
    const texture = new THREE.CanvasTexture(canvasElement);
    texture.needsUpdate = true;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.ClampToEdgeWrapping;
    texture.generateMipmaps = false;
    texture.minFilter = THREE.LinearFilter;
    texture.magFilter = THREE.LinearFilter;

    return {
        texture,
        dimensions: {
            width: dimensions.totalWidth,
            height: dimensions.totalHeight,
            aspectRatio: dimensions.totalWidth / dimensions.totalHeight,
        },
    };
}

export async function getCanvasTexture({
    images,
    gap = 10,
    canvasHeight = 512,
    canvasWidth = 512,
    canvas,
    ctx,
    axis = 'x',
}) {
    if (!images.length) throw new Error('No images');

    // 如果未提供,则创建画布和上下文
    const canvasElement = canvas || document.createElement('canvas');
    const context = ctx || canvasElement.getContext('2d');

    if (!context) throw new Error('No context');

    // 并行预加载所有图像
    const imageData = await Promise.all(
        images.map((image) => preloadImage(image.url, axis, canvasHeight, canvasWidth))
    );

    // 计算总画布尺寸
    const dimensions = calculateCanvasDimensions(imageData, axis, gap, canvasHeight, canvasWidth);

    // 设置画布
    setupCanvas(canvasElement, context, dimensions);

    // 绘制所有图像
    drawImages(context, imageData, axis, gap);

    // 创建并返回纹理结果
    return createTextureResult(canvasElement, dimensions)
}

然后我们还可以创建一个useCollageTexture钩子,我们可以轻松地在组件中使用它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { useState, useEffect, useCallback } from 'react';
import { getCanvasTexture } from '@/webgl/helpers/getCanvasTexture';

export function useCollageTexture(images, options = {}) {
const [textureResults, setTextureResults] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

const { gap = 0, canvasHeight = 512, canvasWidth = 512, axis = 'x' } = options;

const createTexture = useCallback(async () => {
    try {
        setIsLoading(true);
        setError(null);

        const result = await getCanvasTexture({
            images,
            gap,
            canvasHeight,
            canvasWidth,
            axis,
        });

        setTextureResults(result);

    } catch (err) {
        setError(err instanceof Error ? err : new Error('Failed to create texture'));
    } finally {
        setIsLoading(false);
    }
}, [images, gap, canvasHeight, canvasWidth, axis]);

    useEffect(() => {
        if (images.length > 0) createTexture();
    }, [images.length, createTexture]);

    return {
        texture: textureResults?.texture || null,
        dimensions: textureResults?.dimensions || null,
        isLoading,
        error,
    };
}

将Canvas添加到我们的Billboard

现在让我们在页面上使用useCollageTexture钩子。我们将创建一些简单的加载逻辑。获取所有图像并将它们放到画布上需要一秒钟。然后我们将纹理和画布尺寸传递给Billboard组件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
'use client';

import styles from './page.module.scss';
import Billboard from '@/components/webgl/Billboard/Billboard';
import Banner from '@/components/webgl/Banner/Banner';
import Loader from '@/components/ui/modules/Loader/Loader';
import images from '@/data/images';
import { View } from '@/webgl/View';
import { PerspectiveCamera } from '@react-three/drei';
import { useCollageTexture } from '@/hooks/useCollageTexture';

const COUNT = 10;
const GAP = 3.2;

export default function Home() {
    const { texture, dimensions, isLoading } = useCollageTexture(images); // <-- 从useCollageTexture钩子获取纹理和尺寸

    if (isLoading) return <Loader />; // <-- 当纹理加载时显示加载器

    return (
        <div className={styles.page}>
            <View className={styles.view} orbit={false}>
                <PerspectiveCamera makeDefault fov={7} position={[0, 0, 100]} near={0.01} far={100000} />
                <group rotation={[-0.15, 0, -0.2]}>
                    {Array.from({ length: COUNT }).map((_, index) => [
                        <Billboard
                            key={`billboard-${index}`}
                            radius={5}
                            rotation={[0, index * Math.PI * 0.5, 0]}
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP, 0]}
                            texture={texture} // <-- 将纹理传递给billboard
                            dimensions={dimensions} // <-- 将尺寸传递给billboard
                        />,
                        <Banner
                            key={`banner-${index}`}
                            radius={5.035}
                            rotation={[0, 0, 0.085]}
                            position={[
                                0,
                                (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5,
                                0,
                            ]}
                        />,
                    ])}
                </group>
            </View>
        </div>
    );
}

在Billboard组件内部,我们需要正确映射此纹理以确保一切正确拟合。我们画布的宽度将与圆柱体的周长匹配,并且我们将纹理的y位置居中。这样,所有图像都保持其分辨率,不会被挤压或拉伸。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
'use client';

import * as THREE from 'three';
import { useRef } from 'react';  

function setupCylinderTextureMapping(texture, dimensions, radius, height) {
    const cylinderCircumference = 2 * Math.PI * radius;
    const cylinderHeight = height;
    const cylinderAspectRatio = cylinderCircumference / cylinderHeight;

    if (dimensions.aspectRatio > cylinderAspectRatio) {
        // 画布比圆柱体按比例更宽
        texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
        texture.repeat.y = 1;
        texture.offset.x = (1 - texture
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计