Three.js와 React로 구현하는 인터랙티브 3D 마법책 애니메이션

웹 개발에서 3D 그래픽과 애니메이션을 활용하면 사용자 경험을 크게 향상시킬 수 있습니다. 이번 포스팅에서는 Three.js와 React를 결합하여 인터랙티브한 3D 마법책 애니메이션을 만드는 방법을 알아보겠습니다. 단순히 코드를 설명하는 것을 넘어, 웹 개발자로서 배울 수 있는 주요 개념과 기술적 포인트를 중점적으로 다루겠습니다.

프로젝트 개요

![마법책 애니메이션 이미지(예시)]

이 프로젝트는 다음과 같은 특징을 가진 3D 마법책을 구현합니다:

  • 클릭/터치로 책이 열리고 닫히는 애니메이션
  • 책이 열리면 주변에 마법 파티클이 생성
  • 페이지 넘기기 기능
  • 반응형 디자인(모바일 지원)

주요 기술 스택

  • React: UI 컴포넌트 관리
  • Three.js: 3D 그래픽 구현
  • Tailwind CSS: 스타일링
  • Babel: JSX 변환

프로젝트 구조와 핵심 코드 분석

1. React 컴포넌트 설정과 상태 관리

const EnhancedMagicBook = () => {
    const containerRef = useRef(null);
    const [isLoading, setIsLoading] = useState(true);
    const [bookState, setBookState] = useState('closed');
    const [currentPage, setCurrentPage] = useState(0);
    const pageCount = 5; // 컴포넌트 스코프에서 정의
    
    useEffect(() => {
        // Three.js 초기화 및 애니메이션 코드
        // ...
    }, []);
    
    return (
        <div className="relative w-full h-screen">
            <div ref={containerRef} className="absolute inset-0"></div>
            {isLoading && (
                <div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
                    <p className="text-white text-2xl">Loading...</p>
                </div>
            )}
            {bookState !== 'closed' && (
                <div className="absolute bottom-10 left-1/2 transform -translate-x-1/2 flex space-x-4">
                    <button
                        onClick={() => turnPage('prev')}
                        className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
                        disabled={currentPage === 0}
                        aria-label="Go to previous page"
                    >
                        Previous Page
                    </button>
                    <button
                        onClick={() => turnPage('next')}
                        className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
                        disabled={currentPage === pageCount - 1}
                        aria-label="Go to next page"
                    >
                        Next Page
                    </button>
                </div>
            )}
        </div>
    );
};

개발자 팁:

  • useRef를 사용해 Three.js 렌더링을 위한 DOM 요소를 참조합니다. React와 Three.js의 전형적인 통합 방식입니다.
  • 책의 상태(closed, opening, open, closing, turning)를 명확히 구분하여 애니메이션 로직을 쉽게 관리합니다.
  • 조건부 렌더링(bookState !== 'closed')을 활용해 상황에 맞는 UI를 제공합니다.
  • 접근성을 위해 aria-label 속성을 추가한 것에 주목하세요.

2. Three.js 초기화 및 장면 설정

// 기본 THREE.js 설정
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 7);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
containerRef.current.appendChild(renderer.domElement);

// 조명 설정
const ambientLight = new THREE.AmbientLight(0x333366, 0.5);
scene.add(ambientLight);
const centerLight = new THREE.PointLight(0xb38bff, 1, 20);
centerLight.position.set(0, 0, 5);
centerLight.castShadow = true;
centerLight.shadow.mapSize.width = 1024;
centerLight.shadow.mapSize.height = 1024;
scene.add(centerLight);

개발자 팁:

  • { antialias: true }로 렌더러를 초기화하여 가장자리를 부드럽게 처리합니다.
  • setPixelRatio(window.devicePixelRatio)는 고해상도 디스플레이에서 선명한 출력을 보장합니다.
  • 여러 조명을 조합하여 깊이감 있는 장면을 연출합니다. 특히 컬러 조명을 활용하여 마법 책 분위기를 조성합니다.
  • 그림자 매핑을 활성화하여 현실감 있는 렌더링을 구현합니다.

3. 캔버스를 활용한 동적 텍스처 생성

// 책 표지 텍스처 만들기
const createCoverTexture = () => {
    const canvas = document.createElement('canvas');
    canvas.width = 1024;
    canvas.height = 1024;
    const ctx = canvas.getContext('2d');
    
    // 표지 배경 그라데이션
    const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
    gradient.addColorStop(0, '#3a0ca3');
    gradient.addColorStop(1, '#4361ee');
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    
    // 가장자리 테두리
    ctx.strokeStyle = '#7209b7';
    ctx.lineWidth = 30;
    ctx.strokeRect(40, 40, canvas.width - 80, canvas.height - 80);
    
    // 중앙 원형 장식과 별 문양
    // ...
    
    return new THREE.CanvasTexture(canvas);
};

개발자 팁:

  • HTML Canvas API와 Three.js를 결합하여 동적으로 텍스처를 생성하는 강력한 패턴입니다.
  • 이 접근 방식을 사용하면 외부 이미지 파일 없이도 복잡한 텍스처를 프로그래밍 방식으로 생성할 수 있습니다.
  • 그라데이션, 텍스트, 도형을 조합하여 정교한 디자인을 만들 수 있습니다.
  • 텍스트 줄바꿈 함수(wrapText)는 Canvas에서 텍스트 작업 시 매우 유용합니다.

4. 파티클 시스템 구현

// 파티클 시스템
const particleCount = 200;
const particlesGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3);
const scales = new Float32Array(particleCount);
const velocities = new Float32Array(particleCount * 3);
const colorOptions = [
    new THREE.Color(0xff53d0),
    new THREE.Color(0x4cc9f0),
    new THREE.Color(0xffbe0b),
    new THREE.Color(0x4361ee)
];

// 파티클 속성 초기화
for (let i = 0; i < particleCount; i++) {
    // 위치, 속도, 색상, 크기 설정
    // ...
}

// 버퍼 속성 설정
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
particlesGeometry.setAttribute('scale', new THREE.BufferAttribute(scales, 1));

// 파티클 애니메이션
if (particles.visible) {
    const positions = particles.geometry.attributes.position.array;
    for (let i = 0; i < particleCount; i++) {
        positions[i * 3] += velocities[i * 3] * delta;
        positions[i * 3 + 1] += velocities[i * 3 + 1] * delta;
        positions[i * 3 + 2] += velocities[i * 3 + 2] * delta;
        
        // 파티클이 너무 멀어지면 다시 생성
        const distance = Math.sqrt(
            positions[i * 3] ** 2 +
            positions[i * 3 + 1] ** 2 +
            positions[i * 3 + 2] ** 2
        );
        if (distance > 10) {
            // 새 위치로 재설정
            // ...
        }
    }
    particles.geometry.attributes.position.needsUpdate = true;
}

개발자 팁:

  • BufferGeometryBufferAttribute를 사용하여 효율적인 파티클 시스템을 구현합니다.
  • velocities 배열을 별도로 관리하여 각 파티클의 움직임을 제어합니다.
  • needsUpdate = true로 설정하여 변경된 위치 데이터를 GPU에 업데이트합니다.
  • 파티클이 일정 범위를 벗어나면 재생성하는 로직으로 시각적 연속성을 유지합니다.

5. 애니메이션 및 트랜지션 구현

// 애니메이션 상태
let animationTime = 0;
const openDuration = 3.0;
const closeDuration = 2.0;
const pageTurnDuration = 1.0;

// 애니메이션 루프
function animate() {
    const delta = clock.getDelta();
    animationTime += delta;

    // 책 애니메이션
    if (bookState === 'opening') {
        const progress = Math.min(animationTime / openDuration, 1.0);
        const easedProgress = 1 - Math.pow(1 - progress, 3); // cubic ease out
        book.position.y = easedProgress * 0.5;
        book.rotation.x = easedProgress * -0.2;
        frontCover.rotation.y = easedProgress * Math.PI * 0.5;
        // ...
        if (progress >= 1.0) {
            setBookState('open');
        }
    } else if (bookState === 'turning') {
        const progress = Math.min(animationTime / pageTurnDuration, 1.0);
        const easedProgress = 1 - Math.pow(1 - progress, 3);
        pages.forEach((page, index) => {
            if (index <= currentPage) {
                page.rotation.y = easedProgress * Math.PI;
            } else {
                page.rotation.y = 0;
            }
        });
        // ...
    }
    
    // 렌더링 & 다음 프레임 요청
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
}

개발자 팁:

  • requestAnimationFrame 루프를 사용하여 부드러운 애니메이션을 구현합니다.
  • clock.getDelta()로 프레임 간 시간을 측정하여 애니메이션 속도를 일정하게 유지합니다.
  • 진행 상태(progress)를 0~1 사이로 정규화하여 애니메이션을 제어합니다.
  • 이징 함수(1 - Math.pow(1 - progress, 3))를 사용하여 부드럽고 자연스러운 움직임을 만듭니다.
  • 상태 기반 애니메이션 접근법은 복잡한 시각 효과를 관리하는 데 매우 효과적입니다.

6. 커스텀 애니메이션 함수 구현 (GSAP 대체)

// GSAP 비슷한 애니메이션 함수
function gsapLike(object, targetValues, duration, delay = 0) {
    const startValues = {};
    const changeValues = {};
    const startTime = Date.now() + delay * 1000;
    
    for (const key in targetValues) {
        startValues[key] = object[key];
        changeValues[key] = targetValues[key] - startValues[key];
    }
    
    function animate() {
        const now = Date.now();
        const elapsedTime = (now - startTime) / 1000;
        
        if (elapsedTime < 0) {
            requestAnimationFrame(animate);
            return;
        }
        
        if (elapsedTime < duration) {
            const progress = 1 - Math.pow(1 - elapsedTime / duration, 3);
            
            for (const key in targetValues) {
                object[key] = startValues[key] + changeValues[key] * progress;
            }
            
            requestAnimationFrame(animate);
        } else {
            for (const key in targetValues) {
                object[key] = targetValues[key];
            }
        }
    }
    
    animate();
}

개발자 팁:

  • GSAP(GreenSock Animation Platform)와 유사한 기능을 자체 구현한 좋은 예입니다.
  • 외부 라이브러리 없이도 선언적 애니메이션 API를 만들 수 있습니다.
  • 이징 함수를 통해 자연스러운 움직임을 구현합니다.
  • 지연(delay) 파라미터를 통해 시차 애니메이션을 쉽게 만들 수 있습니다.
  • 객체의 여러 속성을 동시에 애니메이션화할 수 있습니다.

7. 터치 이벤트 처리 및 모바일 최적화

// 이벤트 리스너
const handleClick = () => {
    if (bookState === 'closed') {
        openBook();
    } else if (bookState === 'open') {
        closeBook();
    }
};
window.addEventListener('click', handleClick);

// 터치 이벤트 추가 (패시브 모드 비활성화)
const handleTouchStart = (event) => {
    event.preventDefault();
    if (bookState === 'closed') {
        openBook();
    } else if (bookState === 'open') {
        closeBook();
    }
};
window.addEventListener('touchstart', handleTouchStart, { passive: false });

// 정리
return () => {
    window.removeEventListener('resize', onWindowResize);
    window.removeEventListener('click', handleClick);
    window.removeEventListener('touchstart', handleTouchStart);
    if (containerRef.current && renderer.domElement) {
        containerRef.current.removeChild(renderer.domElement);
    }
};

개발자 팁:

  • { passive: false } 옵션은 모바일 브라우저에서 매우 중요합니다. 이를 통해 기본 터치 동작(예: 스크롤)을 방지하고 커스텀 동작을 구현할 수 있습니다.
  • event.preventDefault()를 사용하여 기본 브라우저 동작을 방지합니다.
  • useEffect 클린업 함수에서 모든 이벤트 리스너를 제거하여 메모리 누수를 방지합니다.
  • null 체크(containerRef.current && renderer.domElement)로 컴포넌트 언마운트 시 발생할 수 있는 오류를 방지합니다.

8. 창 크기 변경 처리와 반응형 디자인

// 창 크기 변경 처리
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', onWindowResize);

개발자 팁:

  • camera.aspect 조정과 updateProjectionMatrix() 호출은 Three.js에서 반응형 디자인을 구현하는 핵심 단계입니다.
  • 창 크기가 변경될 때마다 렌더러 크기를 조정하여 항상 전체 화면을 채울 수 있습니다.
  • React의 컴포넌트 라이프사이클과 Three.js의 이벤트를 적절히 연결하는 패턴을 보여줍니다.

핵심 개발 포인트 및 배울 점

  1. React와 Three.js 통합:
    • React의 선언적 UI 패러다임과 Three.js의 명령형 그래픽 API를 효과적으로 결합하는 패턴을 배울 수 있습니다.
    • useRefuseEffect를 활용하여 DOM 조작과 3D 렌더링을 연결하는 방법을 보여줍니다.
  2. 동적 텍스처 생성:
    • Canvas API를 활용하여 프로그래밍 방식으로 텍스처를 생성하는 강력한 기법을 익힐 수 있습니다.
    • 이미지 파일 없이도 복잡한 그래픽을 생성할 수 있어 로딩 시간과 리소스 사용을 최적화할 수 있습니다.
  3. 상태 기반 애니메이션:
    • 애니메이션을 여러 상태로 나누어 관리하는 패턴은 복잡한 인터랙션을 구현할 때 매우 유용합니다.
    • 진행률(progress)과 이징 함수를 사용하여 자연스러운 움직임을 만드는 방법을 배울 수 있습니다.
  4. 성능 최적화:
    • BufferGeometryBufferAttribute를 사용한 효율적인 파티클 시스템 구현.
    • 파티클 수 제한 및 최적화된 렌더링 기법을 통한 성능 관리.
  5. 접근성과 사용자 경험:
    • 모바일 터치 이벤트 처리와 접근성 속성(aria-label) 추가.
    • 로딩 상태 표시 및 조건부 UI 렌더링을 통한 사용자 경험 개선.
  6. 클린 코드 관행:
    • 컴포넌트 언마운트 시 메모리 누수를 방지하는 철저한 클린업.
    • null 체크를 통한 안전한 DOM 조작.
    • 명확한 함수 이름과 구조화된 코드 조직.

결론

이 프로젝트는 현대 웹 개발의 여러 핵심 개념을 종합적으로 보여줍니다. React의 컴포넌트 기반 아키텍처와 Three.js의 강력한 3D 렌더링 기능을 결합하여 인상적인 사용자 경험을 만들 수 있음을 보여줍니다. 특히 Canvas API를 활용한 동적 텍스처 생성, 커스텀 애니메이션 시스템, 파티클 효과는 웹 개발자가 숙달하면 다양한 프로젝트에 응용할 수 있는 가치 있는 기술입니다.

이 코드를 기반으로 더 다양한 책 디자인, 페이지 전환 효과, 인터랙션을 추가해보세요. Three.js의 다양한 머티리얼, 셰이더, 물리 효과를 실험해보며 더 놀라운 시각적 경험을 만들어보는 것도 좋은 학습 방향이 될 것입니다.


이 포스팅이 여러분의 웹 개발 여정에 도움이 되길 바랍니다. 질문이나 피드백이 있으시면 댓글로 남겨주세요!

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다