برچسب: ReactThreeFiber

  • How To Create Kinetic Image Animations with React-Three-Fiber

    How To Create Kinetic Image Animations with React-Three-Fiber



    For the past few months, I’ve been exploring different kinetic motion designs with text and images. The style looks very intriguing, so I decided to create some really cool organic animations using images and React Three Fiber.

    In this article, we’ll learn how to create the following animation using Canvas2D and React Three Fiber.

    Setting Up the View & Camera

    The camera’s field of view (FOV) plays a huge role in this project. Let’s keep it very low so it looks like an orthographic camera. You can experiment with different perspectives later. I prefer using a perspective camera over an orthographic one because we can always try different FOVs. For more detailed implementation check source code.

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

    Setting Up Our 3D Shapes

    First, let’s create and position 3D objects that will display our images. For this example, we need to make 2 components:

    Billboard.tsx – This is a cylinder that will show our stack of images

    '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 – This is another cylinder that will work like a moving banner

    '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;

    Once we have our components ready, we can use them on our page.

    Now let’s build the whole shape:

    1. Create a wrapper group – We’ll make a group that wraps all our components. This will help us rotate everything together later.

    page.jsx

    '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>
        );
    }

    2. Render Billboard and Banner components in the loop – Inside our group, we’ll create a loop to render our Billboards and Banners multiple times.

    page.jsx

    '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>
        );
    }

    3. Stack them up – We’ll use the index from our loop and the y position to stack our items on top of each other. Here’s how it looks so far:

    page.jsx

    '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>
        );
    }

    4. Add some rotation – Let’s rotate things a bit! First, I’ll hard-code the rotation of our banners to make them more curved and fit nicely with the Billboard component. We’ll also make the radius a bit bigger.

    page.jsx

    '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]} // <-- rotation of the billboard
                            />,
                            <Banner
                            key={`banner-${index}`}
                            radius={5}
                            rotation={[0, 0, 0.085]} // <-- rotation of the banner
                            position={[0, (index - (Math.ceil(COUNT / 2) - 1)) * GAP - GAP * 0.5, 0]}
                            />,
                        ])}
                    </group>
                </View>
            </div>
        );
    }

    5. Tilt the whole thing – Now let’s rotate our entire group to make it look like the Leaning Tower of Pisa.

    page.jsx

    '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]}> // <-- rotate the 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]}
                            />,
                            <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>
        );
    }

    6. Perfect! – Our 3D shapes are all set up. Now we can add our images to them.

    Creating a Texture from Our Images Using Canvas

    Here’s the cool part: we’ll put all our images onto a canvas, then use that canvas as a texture on our Billboard shape.

    To make this easier, I created some helper functions that simplify the whole process.

    getCanvasTexture.js

    import * as THREE from 'three';
    
    /**
    * Preloads an image and calculates its dimensions
    */
    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') {
            // Horizontal layout: scale to fit canvasHeight
            calculatedHeight = canvasHeight;
            calculatedWidth = canvasHeight * aspectRatio;
            } else {
            // Vertical layout: scale to fit 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');
    
        // Create canvas and context if not provided
        const canvasElement = canvas || document.createElement('canvas');
        const context = ctx || canvasElement.getContext('2d');
    
        if (!context) throw new Error('No context');
    
        // Preload all images in parallel
        const imageData = await Promise.all(
            images.map((image) => preloadImage(image.url, axis, canvasHeight, canvasWidth))
        );
    
        // Calculate total canvas dimensions
        const dimensions = calculateCanvasDimensions(imageData, axis, gap, canvasHeight, canvasWidth);
    
        // Setup canvas
        setupCanvas(canvasElement, context, dimensions);
    
        // Draw all images
        drawImages(context, imageData, axis, gap);
    
        // Create and return texture result
        return createTextureResult(canvasElement, dimensions)
    }

    Then we can also create a useCollageTexture hook that we can easily use in our components.

    useCollageTexture.jsx

    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,
        };
    }

    Adding the Canvas to Our Billboard

    Now let’s use our useCollageTexture hook on our page. We’ll create some simple loading logic. It takes a second to fetch all the images and put them onto the canvas. Then we’ll pass our texture and dimensions of canvas into the Billboard component.

    page.jsx

    '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); // <-- getting the texture and dimensions from the useCollageTexture hook
    
        if (isLoading) return <Loader />; // <-- showing the loader when the texture is loading
    
        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} // <--passing the texture to the billboard
                                dimensions={dimensions} // <--passing the dimensions to the 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>
        );
    }

    Inside the Billboard component, we need to properly map this texture to make sure everything fits correctly. The width of our canvas will match the circumference of the cylinder, and we’ll center the y position of the texture. This way, all the images keep their resolution and don’t get squished or stretched.

    Billboard.jsx

    '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) {
            // Canvas is wider than cylinder proportionally
            texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
            texture.repeat.y = 1;
            texture.offset.x = (1 - texture.repeat.x) / 2;
        } else {
            // Canvas is taller than cylinder proportionally
            texture.repeat.x = 1;
            texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
        }
    
        // Center the texture
        texture.offset.y = (1 - texture.repeat.y) / 2;
    }
    
    function Billboard({ texture, dimensions, radius = 5, ...props }) {
        const ref = useRef(null);
    
        setupCylinderTextureMapping(texture, dimensions, radius, 2);
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
                <meshBasicMaterial map={texture} side={THREE.DoubleSide} />
            </mesh>
        );
    }
    
    export default Billboard;

    Now let’s animate them using the useFrame hook. The trick to animating these images is to just move the X offset of the texture. This gives us the effect of a rotating mesh, when really we’re just moving the texture offset.

    Billboard.jsx

    'use client';
    
    import * as THREE from 'three';
    import { useRef } from 'react';
    import { useFrame } from '@react-three/fiber';  
    
    function setupCylinderTextureMapping(texture, dimensions, radius, height) {
        const cylinderCircumference = 2 * Math.PI * radius;
        const cylinderHeight = height;
        const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
    
        if (dimensions.aspectRatio > cylinderAspectRatio) {
            // Canvas is wider than cylinder proportionally
            texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
            texture.repeat.y = 1;
            texture.offset.x = (1 - texture.repeat.x) / 2;
        } else {
            // Canvas is taller than cylinder proportionally
            texture.repeat.x = 1;
            texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
        }
    
        // Center the texture
        texture.offset.y = (1 - texture.repeat.y) / 2;
    }
    
    function Billboard({ texture, dimensions, radius = 5, ...props }) {
        const ref = useRef(null);
    
        setupCylinderTextureMapping(texture, dimensions, radius, 2);
    
        useFrame((state, delta) => {
            if (texture) texture.offset.x += delta * 0.001;
        });
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
                <meshBasicMaterial map={texture} side={THREE.DoubleSide} />
            </mesh>
        );
    }
    
    export default Billboard;

    I think it would look even better if we made the back of the images a little darker. To do this, I created MeshImageMaterial – it’s just an extension of MeshBasicMaterial that makes our backface a bit darker.

    MeshImageMaterial.js

    import * as THREE from 'three';
    import { extend } from '@react-three/fiber';
    
    export class MeshImageMaterial extends THREE.MeshBasicMaterial {
        constructor(parameters = {}) {
            super(parameters);
            this.setValues(parameters);
        }
    
        onBeforeCompile = (shader) => {
            shader.fragmentShader = shader.fragmentShader.replace(
                '#include <color_fragment>',
                /* glsl */ `#include <color_fragment>
                if (!gl_FrontFacing) {
                vec3 blackCol = vec3(0.0);
                diffuseColor.rgb = mix(diffuseColor.rgb, blackCol, 0.7);
                }
                `
            );
        };
    }
    
    extend({ MeshImageMaterial });

    Billboard.jsx

    'use client';
    
    import * as THREE from 'three';
    import { useRef } from 'react';
    import { useFrame } from '@react-three/fiber';
    import '@/webgl/materials/MeshImageMaterial';
    
    function setupCylinderTextureMapping(texture, dimensions, radius, height) {
        const cylinderCircumference = 2 * Math.PI * radius;
        const cylinderHeight = height;
        const cylinderAspectRatio = cylinderCircumference / cylinderHeight;
    
        if (dimensions.aspectRatio > cylinderAspectRatio) {
            // Canvas is wider than cylinder proportionally
            texture.repeat.x = cylinderAspectRatio / dimensions.aspectRatio;
            texture.repeat.y = 1;
            texture.offset.x = (1 - texture.repeat.x) / 2;
        } else {
            // Canvas is taller than cylinder proportionally
            texture.repeat.x = 1;
            texture.repeat.y = dimensions.aspectRatio / cylinderAspectRatio;
        }
    
        // Center the texture
        texture.offset.y = (1 - texture.repeat.y) / 2;
    }
    
    function Billboard({ texture, dimensions, radius = 5, ...props }) {
        const ref = useRef(null);
    
        setupCylinderTextureMapping(texture, dimensions, radius, 2);
    
        useFrame((state, delta) => {
            if (texture) texture.offset.x += delta * 0.001;
        });
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry args={[radius, radius, 2, 100, 1, true]} />
                <meshImageMaterial map={texture} side={THREE.DoubleSide} toneMapped={false} />
            </mesh>
        );
    }
    
    export default Billboard;

    And now we have our images moving around cylinders. Next, we’ll focus on banners (or marquees, whatever you prefer).

    Adding Texture to the Banner

    The last thing we need to fix is our Banner component. I wrapped it with this texture. Feel free to take it and edit it however you want, but remember to keep the proper dimensions of the texture.

    We simply import our texture using the useTexture hook, map it onto our material, and animate the texture offset just like we did in our Billboard component.

    Billboard.jsx

    'use client';
    
    import * as THREE from 'three';
    import bannerTexture from '@/assets/images/banner.jpg';
    import { useTexture } from '@react-three/drei';
    import { useFrame } from '@react-three/fiber';
    import { useRef } from 'react';
    
    function Banner({ radius = 1.6, ...props }) {
        const ref = useRef(null);
    
        const texture = useTexture(bannerTexture.src);
        texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
    
        useFrame((state, delta) => {
            if (!ref.current) return;
            const material = ref.current.material;
            if (material.map) material.map.offset.x += delta / 30;
        });
    
        return (
            <mesh ref={ref} {...props}>
                <cylinderGeometry
                    args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
                />
                <meshBasicMaterial
                    map={texture}
                    map-anisotropy={16}
                    map-repeat={[15, 1]}
                    side={THREE.DoubleSide}
                    toneMapped={false}
                    backfaceRepeatX={3}
                />
            </mesh>
        );
    }
    
    export default Banner;

    Nice! Now we have something cool, but I think it would look even cooler if we replaced the backface with something different. Maybe a gradient? For this, I created another extension of MeshBasicMaterial called MeshBannerMaterial. As you probably guessed, we just put a gradient on the backface. That’s it! Let’s use it in our Banner component.

    We replace the MeshBasicMaterial with MeshBannerMaterial and now it looks like this!

    MeshBannerMaterial.js

    import * as THREE from 'three';
    import { extend } from '@react-three/fiber';
    
    export class MeshBannerMaterial extends THREE.MeshBasicMaterial {
        constructor(parameters = {}) {
            super(parameters);
            this.setValues(parameters);
    
            this.backfaceRepeatX = 1.0;
    
            if (parameters.backfaceRepeatX !== undefined)
    
            this.backfaceRepeatX = parameters.backfaceRepeatX;
        }
    
        onBeforeCompile = (shader) => {
            shader.uniforms.repeatX = { value: this.backfaceRepeatX * 0.1 };
            shader.fragmentShader = shader.fragmentShader
            .replace(
                '#include <common>',
                /* glsl */ `#include <common>
                uniform float repeatX;
    
                vec3 pal( in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d ) {
                    return a + b*cos( 6.28318*(c*t+d) );
                }
                `
            )
            .replace(
                '#include <color_fragment>',
                /* glsl */ `#include <color_fragment>
                if (!gl_FrontFacing) {
                diffuseColor.rgb = pal(vMapUv.x * repeatX, vec3(0.5,0.5,0.5),vec3(0.5,0.5,0.5),vec3(1.0,1.0,1.0),vec3(0.0,0.10,0.20) );
                }
                `
            );
        };
    }
    
    extend({ MeshBannerMaterial });

    Banner.jsx

    'use client';
    
    import * as THREE from 'three';
    import bannerTexture from '@/assets/images/banner.jpg';
    import { useTexture } from '@react-three/drei';
    import { useFrame } from '@react-three/fiber';
    import { useRef } from 'react';
    import '@/webgl/materials/MeshBannerMaterial';
    
    function Banner({ radius = 1.6, ...props }) {
    const ref = useRef(null);
    
    const texture = useTexture(bannerTexture.src);
    
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
    
    useFrame((state, delta) => {
        if (!ref.current) return;
    
        const material = ref.current.material;
    
        if (material.map) material.map.offset.x += delta / 30;
    });
    
    return (
        <mesh ref={ref} {...props}>
            <cylinderGeometry
                args={[radius, radius, radius * 0.07, radius * 80, radius * 10, true]}
            />
            <meshBannerMaterial
                map={texture}
                map-anisotropy={16}
                map-repeat={[15, 1]}
                side={THREE.DoubleSide}
                toneMapped={false}
                backfaceRepeatX={3}
            />
        </mesh>
    );
    }
    
    export default Banner;

    And now we have it ✨

    Check out the demo

    You can experiment with this method in lots of ways. For example, I created 2 more examples with shapes I made in Blender, and mapped canvas textures on them. You can check them out here:

    Final Words

    Check out the final versions of all demos:

    I hope you enjoyed this tutorial and learned something new!

    Feel free to check out the source code for more details!



    Source link