본문 바로가기
Web/JS

three.js로 3D 책 구현하기

by 가나닩 2024. 12. 20.

※ Next.js 14.2.18 버전의 App router와 three.js 0.171.0 버전 사용

도서와 관련한 간단한 홈페이지를 구현하게되면 책을 3D로 둘러볼수 있는 기능을 넣어보고 싶었다.

 

자바스크립트에서는 간편하게 3D 개체를 구현해볼 수 있는 three.js라는 라이브러리가 있다. 이를 활용하면 카메라, 빛, 3D객체들을 매우 편리하게 구현할 수 있다.

 

구현한 전체 코드는 아래와 같다.

더보기
"use client";

// import 생략

const RotatingBook: React.FC<{ rotationY: number; cover: string }> = ({ rotationY, cover }) => {
    const bookRef = useRef<THREE.Mesh>(null);

    const rotateAngle = [0, Math.PI * 0.5, Math.PI * 1, Math.PI * 0.2, Math.PI * 0.8];

    useFrame(() => {
        if (bookRef.current) {
            const currentY = bookRef.current.rotation.y;
            const targetY = rotateAngle[rotationY];

            if (Math.abs(currentY - targetY) > 0.001) {
                bookRef.current.rotation.y = THREE.MathUtils.lerp(currentY, targetY, 0.1);
            } else {
                bookRef.current.rotation.y = targetY;
            }
        }
    });

	// 책 이미지를 불러오는 코드 (생략)
    // coverImage, sideImage, backImage에 이미지 url이 저장됨

	// 불러온 이미지를 기반으로하여 3D 객체의 사이즈를 정함, useImageSize라는 커스텀훅 사용
    const [coverW, coverH] = useImageSize(coverImage);
    const [sideW] = useImageSize(sideImage);
    const bookSizeRatio = 3.3;
    let convertH = 4.5;
    let convertD = 0.3;

    if (coverW !== null && coverH !== null && sideW !== null) {
        convertH = coverH * (bookSizeRatio / coverW);
        convertD = sideW * (bookSizeRatio / coverW);
    }
	
    //
    const [loadedTextures, setLoadedTextures] = useState<{
        front: THREE.Texture;
        back: THREE.Texture;
        left: THREE.Texture;
        white: THREE.Texture;
    } | null>(null);

	// textures에 앞, 뒤, 옆, 비어있는면의 텍스쳐를 저장, 데이터가 없을경우도 대비함
    // useMemo를 통해 이미지 요청이 반복되지 않도록함
    const textures = useMemo(() => {
        const loader = new THREE.TextureLoader();

        const loadTexture = (url: string, fallback: string): Promise<THREE.Texture> => {
            return new Promise((resolve) => {
                loader.load(
                    url,
                    (texture) => resolve(texture),
                    undefined,
                    () => resolve(loader.load(fallback))
                );
            });
        };

        const loadAllTextures = async () => {
            const [front, back, left, white] = await Promise.all([
                loadTexture(coverImage, backEmptyImage.src),
                loadTexture(backImage, backEmptyImage.src),
                loadTexture(sideImage, sideEmptyImage.src),
                loader.loadAsync(bookPageTextureImage.src),
            ]);
            return { front, back, left, white };
        };

        const texturesPromise = loadAllTextures();

        return texturesPromise;
    }, [coverImage, backImage, sideImage]);

    useEffect(() => {
        textures.then((loadedTextures) => {
            setLoadedTextures(loadedTextures);
        });
    });
    
   	// loadedTextures가 null인 경우를 대비 (typescript) 
    if (!loadedTextures) {
        return null;
    }

	// mesh로 3D 객체를 생성
    return (
        <>
            <mesh ref={bookRef} position={[0, 0, 0]} castShadow>
                <boxGeometry args={[convertD, convertH, bookSizeRatio]} />
                <meshBasicMaterial attach="material-0" map={loadedTextures.front} />
                <meshBasicMaterial attach="material-1" map={loadedTextures.back} />
                <meshBasicMaterial attach="material-2" map={loadedTextures.white} />
                <meshBasicMaterial attach="material-3" map={loadedTextures.white} />
                <meshBasicMaterial attach="material-4" map={loadedTextures.left} />
                <meshBasicMaterial attach="material-5" map={loadedTextures.white} />
            </mesh>
        </>
    );
};

// 그림자를 생성하기 위한 면을 만듦
const Plane = () => (
    <mesh
        receiveShadow
        rotation={[0, 1.6, 0]}
        position={[-2, 0, 0]}
    >
        <planeGeometry args={[10, 10]} />
        <shadowMaterial opacity={0.2} />
    </mesh>
);

const BookImage: React.FC<{ cover: string }> = ({ cover }) => {
    const [rotationY, setRotationY] = useState(3);

    const handleRotate = () => {
        // rotationY 상태값을 변경해서 책의 각도를 바꾸는 코드 생략
    };
    return (
        <>
            <div className={styles.wrap}>
                <Canvas camera={{ position: [24, 0, 0], fov: 13 }} shadows>
                    <ambientLight intensity={0.5} />
                    <spotLight
                        position={[20, 3, 3]}
                        angle={0.2}
                        castShadow
                        shadow-mapSize-width={512}
                        shadow-mapSize-height={512}
                        shadow-radius={50}
                    />
                    <RotatingBook rotationY={rotationY} cover={cover} />
                    <Plane />
                </Canvas>
                // 각종 버튼들
            </div>
        </>
    );
};

export default BookImage;

최종적으로는 아래와 같은 결과물을 만들었다.

 

 

 

1. 책 이미지 크기에 따라 3D개체의 크기 지정하기

모든 책은 사이즈가 다르다. 책의 크기나 두께가 다른 모든 책을 최대한 자연스럽게 표현하려면 이미지 크기에 맞춰 객체크기가 변하도록 해야한다.

 

useImageSize라는 커스텀 훅을 만들어서 이미지크기를 활용했다.

import { useEffect, useState } from "react";

const useImageSize = (url: string): [number | null, number | null] => {
    const [size, setSize] = useState<[number | null, number | null]>([null, null]);

    useEffect(() => {
        if (!url) return;

        const img = new Image();
        img.src = url;
        const handleLoad = () => setSize([img.naturalWidth, img.naturalHeight]);
        const handleError = () => setSize([null, null]);

        img.onload = handleLoad;
        img.onerror = handleError;

        return () => {
            img.onload = null;
            img.onerror = null;
        };
    }, [url]);

    return size;
};

export default useImageSize;

 

 

1-1. new Image() 와 useImageSize

자바스크립트에서 new Image()를 활용하면 DOM에 이미지를 추가하지않고 이미지 객체를 로드하여 이미지의 사이즈나 로드상태를 확인할 수 있다. useImageSize로 url정보만 넘겨주면 naturalWidth와 naturalHeight를 활용해 이미지 가로세로 크기를 알아내고 반환해준다.

 

1-2. 비율에 맞춰 3D 개체 생성하기

이미지 크기를 알아낸뒤에는 그 비율에 맞춰 3D개체를 생성해야한다. 책 사이즈 그대로 객체를 생성하면 홈페이지내 원하는 공간에 적당한 크기로 3D객체를 넣을 수 없기 때문이다.

예시에서는 width 크기를 고정값으로 두고 나머지 사이즈를 비율에 맞춰 조정했다.

 

width값을 bookSizeRatio라는 변수로 관리하고 이 변수에 비율을 맞춰 나머지 사이즈를 조정하게 되면 해당 값을 수정하는것만으로도 객체의 사이즈를 자유자재로 변경시킬 수 있다는 장점이 있다.

    const [coverW, coverH] = useImageSize(coverImage);
    const [sideW] = useImageSize(sideImage); // 옆면사이즈는 width값만 필요함 (책두께)
    const bookSizeRatio = 3.3; // 3D 객체의 고정 width값
    let convertH = 4.5; // 3D 객체의 height 기본값
    let convertD = 0.3; // 3D 객체의 depth 기본값

    // 비율에 맞게 convertH와 convertD의 값을 조정
    if (coverW !== null && coverH !== null && sideW !== null) {
        convertH = coverH * (bookSizeRatio / coverW);
        convertD = sideW * (bookSizeRatio / coverW);
    }
    
    //...
    
    return (
        <>
            <mesh ref={bookRef} position={[0, 0, 0]} castShadow>
                <boxGeometry args={[convertD, convertH, bookSizeRatio]} />
                <meshBasicMaterial attach="material-0" map={loadedTextures.front} />
                <meshBasicMaterial attach="material-1" map={loadedTextures.back} />
                <meshBasicMaterial attach="material-2" map={loadedTextures.white} />
                <meshBasicMaterial attach="material-3" map={loadedTextures.white} />
                <meshBasicMaterial attach="material-4" map={loadedTextures.left} />
                <meshBasicMaterial attach="material-5" map={loadedTextures.white} />
            </mesh>
        </>
    );

 

책의 크기를 알아낸뒤 이를 그대로 사용하면 너무 크거나 너무 작은 3D 객체가 생성될 수 있다. 이를 방지하기 위해 width값은 고정값으로 두고 height와 depth를 비율에 맞게 수정하여 3D객체의 사이즈로 사용했다.

이미지 크기를 구하는 로직이 제대로 동작하지 않았을때를 대비해 기본값도 모두 적용해주었다.

 

 

 

2. 로드한 이미지 3D 객체에 집어넣기

3D객체를 생성한뒤 이미지를 붙여넣어 주어야한다.

 

단순히 이미지를 집어넣기만 하는건 아주 간단하게 할 수 있다.

const frontImage = new THREE.TextureLoader().load(`이미지 URL`);

return (
        <>
            <mesh ref={bookRef} position={[0, 0, 0]} castShadow>
                <boxGeometry args={[convertD, convertH, bookSizeRatio]} />
                <meshBasicMaterial attach="material-0" map={frontImage} />
                ...다른 면은 생략
            </mesh>
        </>
    );

 

three.js에서 제공하는 textureLoader로 이미지 url을 집어넣으면 간단하게 이미지를 집어넣을 수 있다.

 

만약 수많은 책의 이미지 정보를 외부 API 등으로 사용할경우 제대로 불러올수 없는 경우가 발생할 수 있다.

이때를 대비하여 이미지가 제대로 로드되지 않을경우 대체이미지를 사용할 수 있도록 해주어야한다.

const [loadedTextures, setLoadedTextures] = useState<{
        front: THREE.Texture;
        back: THREE.Texture;
        left: THREE.Texture;
        white: THREE.Texture;
    } | null>(null);

const textures = useMemo(() => {
    const loader = new THREE.TextureLoader();

    const loadTexture = (url: string, fallback: string): Promise<THREE.Texture> => {
        return new Promise((resolve) => {
            loader.load(
                url,
                (texture) => resolve(texture),
                undefined,
                () => resolve(loader.load(fallback))
            );
        });
    };

    const loadAllTextures = async () => {
        const [front, back, left, white] = await Promise.all([
            loadTexture(coverImage, backEmptyImage.src), // 앞면
            loadTexture(backImage, backEmptyImage.src), // 뒷면
            loadTexture(sideImage, sideEmptyImage.src), // 좌측
            loader.loadAsync(bookPageTextureImage.src), // 흰색 텍스처 (기본)
        ]);
        return { front, back, left, white };
    };

    const texturesPromise = loadAllTextures();

    return texturesPromise;
}, [coverImage, backImage, sideImage]);

useEffect(() => {
    textures.then((loadedTextures) => {
        setLoadedTextures(loadedTextures);
    });
});

 

이미지를 사용할때는 loadedTextures.front의 형식으로 사용하면 된다. loadedTextures 객체는 이미지의 url정보를 통해 생성된 텍스쳐 정보를 담고있다. 

해당 객체의 각 값들은 url을 통해 이미지를 불러오고 이미지가 없을경우 fallback에 해당하는 대체이미지를 사용하도록 되어있다.

 

 

 

3. 왜곡을 줄이기 위한 카메라 위치 및 fov값 설정

fov값은 쉽게 말해 시야각인데 이 값에 따라 객체가 왜곡되어 보일 수 있다.

좌측부터 fov값 65, 13, 5

 

값이 낮을수록 멀리서 카메라를 확대하여 객체를 바라본다고 생각하면 된다. 당연히 카메라의 시야각을 조정하는 것이므로 카메라의 위치나 객체의 크기를 조정해주어야한다.

return (
        <>
            <div className={styles.wrap}>
                <Canvas camera={{ position: [24, 0, 0], fov: 5 }} shadows>
                    <ambientLight intensity={0.5} />
                    <spotLight
                        position={[20, 3, 3]}
                        angle={0.2}
                        castShadow
                        shadow-mapSize-width={512}
                        shadow-mapSize-height={512}
                        shadow-radius={50}
                    />
                    <RotatingBook rotationY={rotationY} cover={cover} />
                    <Plane />
                </Canvas>
                ... 기타 내용 생략
            </div>
        </>
    );

 

three.js는 canvas로 구현되며 camera값의 position과 fov값을 통해 위치와 시야각을 조정해줄 수 있다.

필요에 따라 책의 사이즈를 조절할 필요도 있다. 이 글의 예시에서는 위에서 설명한 bookSizeRatio 값을 변경하여 크기를 조정할 수 있다.

 

 

 

4. 버튼을 통해 state를 변경하여 객체의 각도 조정하기

여기서는 React를 사용해본 사람이면 익숙한 useState를 활용해 책의 각도를 0~4의 값으로 변경시키도록 한다.

 

three.js의 3D객체는 라디안을 기준으로 회전한다. 즉 파이값(3.14....) 만큼 회전 시키면 180도를 회전하게 된다.

파이값에 0을 곱하면 제자리, 1을 곱하면 180도, 0.5를 곱하면 90도 등등으로 원하는 각도를 회전시킬 수 있다.

const rotateAngle = [0, Math.PI * 0.5, Math.PI * 1, Math.PI * 0.2, Math.PI * 0.8, Math.PI * 1.3];

useFrame(() => {
    if (bookRef.current) {
        const currentY = bookRef.current.rotation.y;
        const targetY = rotateAngle[rotationY];

        if (Math.abs(currentY - targetY) > 0.001) {
            bookRef.current.rotation.y = THREE.MathUtils.lerp(currentY, targetY, 0.1);
        } else {
            bookRef.current.rotation.y = targetY;
        }
    }
});

 

useFrame을 사용해서 객체를 움직일 수 있다. 여기서 rotationY는 버튼을 통해 변경시키는 0~4의 값을 가지는 state이다.

 

bookRef를 가지고 있는 3D 객체를 rotation을 통해 회전시킬 수 있다.

여기서 조건문을 사용한 이유는 아래와 같다.

  1. 부동소수점 연산 오차 : lerp를 사용하면 두 값을 점점 좁혀 가깝게 하지만 값이나 오차에 따라 정확히 목표값에 도달하지 않을 수 있다.
  2. 불필요한 연산 방지 : 위의 오차 문제도 포함하여 목표 값에 도달했는데도 연산이 계속되는 경우를 방지한다. 현재값과 목표값을 비교하여 충분히 가까워지면 (차이가 0.001 이하가 되면) 연산을 중단하고 고정된 값으로 보정한다.

※ lerp 함수의 세번째값은 보간계수이다. 예를들어 0.1일 경우 기존값에서 목표값에 해당하는 사이값을 10% 이동한다. 이후 에도 이를 반복한다. 값이 커질수록 빠르게 목표값에 가까워지므로 속도와도 비슷하게 볼 수 있다.

 

 

 

5. 그림자 만들기

three.js에서의 그림자는 포토샵이나 css의 그림자 옵션처럼 간단하게 구현할 수 없다. 3D객체의 그림자를 만들 빛을 설정해 주어야하고 그 빛을 받아 그림자를 만들어낼 면 또한 만들어줘야한다.

<Canvas camera={{ position: [24, 0, 0], fov: 13 }} shadows>
    <ambientLight intensity={0.5} />
    <spotLight
        position={[20, 3, 3]}
        angle={0.2}
        castShadow
        shadow-mapSize-width={512}
        shadow-mapSize-height={512}
        shadow-radius={50}
    />
    <RotatingBook rotationY={rotationY} cover={cover} />
    <Plane />
</Canvas>

 

먼저 그림자를 만들 빛을 생성한다. ambientLight는 전체적으로 비춰주는 조명이고 해당 예시에서는 spotLight를 통해 그림자를 만들었다. 내용을 설명하면 아래와 같다.

  • position : 광원의 위치이다. camera의 위치를 설정할때와 같은 개념으로 사용하면 된다.
  • angle : 광원의 넓이이다. 얼마나 퍼지는 빛인가를 설정하는것인데 직선으로 뻗는 빛을 만들경우 (값이 낮을경우) 그림자가 선명하게 만들어지고 뿌옇게 넓은 빛을 만들경우 (값이 높을경우) 그림자가 흐릿하게 만들어진다.
  • shadow-mapSize : 그림자의 해상도
  • shadow-radius : 그림자를 부드럽게 만들어준다.
const Plane = () => (
    <mesh
        receiveShadow
        rotation={[0, 1.6, 0]}
        position={[-2, 0, 0]}
    >
        <planeGeometry args={[10, 10]} />
        <shadowMaterial opacity={0.2} />
    </mesh>
);

 

그림자를 만들어내는 평면이다. 3D 객체를 만들때와 마찬가지로 mesh를 사용하여 면을 만들어준다. 내용을 설명하면 아래와 같다.

  • receiveShadow : 그림자를 받을지를 설정한다.
  • rotation : 면의 회전
  • position : 면의 위치
  • planeGeometry : 평면의 기하학적 크기를 정의한다. args에 들어간 값은 각각 width와 height이다.
  • shadowMaterial : 평면의 재질을 그림자 표현을 위한 특수재질로 설정한다. opacity는 그림자의 투명도이다.

 

 

 

6. 결론

실제 책의 크기와 두께를 고려한 3D 책 애니메이션을 만들어보고 싶다는 생각에서 시작하여 원하는 형태에 어느정도 가까운 결과물을 만들어 낼 수 있었다. 이를 잘 활용하면 책 크기 비교에도 사용할 수 있을것이다.

CSS만을 이용해 3D 애니메이션을 구현하는건 난이도도 높고 css 내용이 매우 복잡해질 수 있다. 원하는 형태의 3D객체를 쉽게 만들어보고 카메라의 위치, 시야각, 물체의 크기, 위치 등을 바로바로 조정하며 확인할 수 있는것이 three.js의 장점인것 같다.