Three.js는 웹에서 3D 그래픽을 구현하는 강력한 도구이지만, 처음 접하면 그 복잡성에 압도될 수 있습니다. React Three Fiber(R3F)는 이러한 복잡성을 React의 선언적 패러다임으로 우아하게 해결합니다. 로그인 페이지의 동적인 배경 효과를 구현하면서 R3F의 핵심 개념들을 차근차근 살펴보겠습니다.
Three.js에서는 Scene, Camera, Renderer 등을 명령형으로 설정해야 했습니다. 예를 들어:
// Three.js 방식 const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) const renderer = new THREE.WebGLRenderer()
반면 React Three Fiber에서는 이런 보일러플레이트 코드가 추상화되어 있어, React 개발자에게 더 친숙한 방식으로 작성할 수 있습니다:
// React Three Fiber 방식 function Scene() { return ( <Canvas camera={{ fov: 75, // 시야각 near: 0.1, // 최소 렌더링 거리 far: 1000, // 최대 렌더링 거리 position: [0, 0, 5] // 카메라 위치 (x, y, z) }} > {/* 3D 요소들이 들어갈 자리 */} </Canvas> ) }
프로젝트를 시작하기 전에 필요한 패키지들을 설치합니다:
npm install @react-three/fiber three @react-three/drei
여기서 @react-three/drei
는 자주 사용되는 Three.js 기능들을 React 컴포넌트로 제공하는 유틸리티 라이브러리입니다. 이는 마치 Material-UI가 React에서 일반적인 UI 컴포넌트를 제공하는 것과 유사합니다.
Canvas는 R3F의 핵심 컴포넌트로, Three.js의 렌더링 컨텍스트를 설정합니다. 다음은 최적화된 Canvas 설정의 예시입니다:
import { Canvas } from '@react-three/fiber' import { AdaptiveDpr, AdaptiveEvents, Preload } from '@react-three/drei' function App() { return ( <Canvas style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', background: '#2a0845' }} // 성능과 품질의 균형을 위한 설정 gl={{ antialias: true, // 계단 현상 방지 powerPreference: "high-performance" }} > {/* 성능 최적화 컴포넌트들 */} <AdaptiveDpr pixelated /> <AdaptiveEvents /> <Preload all /> {/* 기본 조명 설정 */} <ambientLight intensity={0.5} /> <pointLight position={[10, 10, 10]} /> </Canvas> ) }
이러한 설정에서 각 요소가 하는 역할을 이해하는 것이 중요합니다:
AdaptiveDpr
는 디바이스의 성능에 따라 해상도를 자동으로 조절합니다.AdaptiveEvents
는 이벤트 처리를 최적화합니다.Preload
는 텍스처와 모델을 미리 로드하여 사용자 경험을 개선합니다.이러한 기본 설정을 바탕으로, 로그인 페이지에 보이는 것과 같은 동적인 파티클 시스템을 구현하는 방법을 살펴보겠습니다.
파티클 시스템은 3D 그래픽에서 매우 중요한 요소입니다. 로그인 페이지에서 보이는 것처럼 부드럽게 움직이는 작은 사각형들은 사실 파티클 시스템을 활용한 것입니다. 이를 단계별로 구현해보면서 R3F의 핵심 개념들을 자연스럽게 익혀보겠습니다.
파티클 시스템을 만들기 전에, 먼저 단일 파티클을 생성하는 방법을 이해해야 합니다. R3F에서는 기하학적 도형을 만들 때 Three.js의 geometry와 material을 조합하여 사용합니다:
function Particle({ position }) { // useRef를 통해 mesh에 직접 접근할 수 있습니다 const meshRef = useRef() // 파티클의 움직임을 위한 애니메이션 로직 useFrame((state, delta) => { if (meshRef.current) { // 부드러운 회전 효과 추가 meshRef.current.rotation.x += delta * 0.1 meshRef.current.rotation.y += delta * 0.1 } }) return ( <mesh ref={meshRef} position={position}> <boxGeometry args={[0.1, 0.1, 0.1]} /> <meshStandardMaterial color="#8b5cf6" transparent opacity={0.8} /> </mesh> ) }
이제 여러 개의 파티클을 생성하고 관리하는 시스템을 구축해보겠습니다:
function ParticleSystem({ count = 50 }) { // 파티클들의 초기 위치를 생성합니다 const particles = useMemo(() => { const temp = [] for (let i = 0; i < count; i++) { const x = (Math.random() - 0.5) * 10 // -5에서 5 사이의 랜덤 값 const y = (Math.random() - 0.5) * 10 const z = (Math.random() - 0.5) * 10 temp.push({ position: [x, y, z], // 각 파티클의 움직임을 위한 속도값도 저장 velocity: { x: (Math.random() - 0.5) * 0.02, y: (Math.random() - 0.5) * 0.02, z: (Math.random() - 0.5) * 0.02 } }) } return temp }, [count]) return ( <group> {particles.map((particle, i) => ( <Particle key={i} position={particle.position} velocity={particle.velocity} /> ))} </group> ) }
파티클의 자연스러운 움직임을 위해 물리법칙을 적용해봅시다:
function Particle({ position, velocity }) { const meshRef = useRef() const velocityRef = useRef(velocity) useFrame((state, delta) => { if (!meshRef.current) return // 현재 위치 업데이트 meshRef.current.position.x += velocityRef.current.x meshRef.current.position.y += velocityRef.current.y meshRef.current.position.z += velocityRef.current.z // 화면 경계에 도달하면 반대 방향으로 이동 const bounds = 5 if (Math.abs(meshRef.current.position.x) > bounds) { velocityRef.current.x *= -1 } if (Math.abs(meshRef.current.position.y) > bounds) { velocityRef.current.y *= -1 } if (Math.abs(meshRef.current.position.z) > bounds) { velocityRef.current.z *= -1 } }) return ( <mesh ref={meshRef} position={position}> <boxGeometry args={[0.1, 0.1, 0.1]} /> <meshStandardMaterial color="#8b5cf6" transparent opacity={0.8} /> </mesh> ) }
이 코드에서 주목할 점은 useFrame
훅의 사용입니다. 이는 Three.js의 애니메이션 루프에 직접 접근할 수 있게 해주며, 매 프레임마다 파티클의 위치를 업데이트합니다. delta
값을 사용하여 프레임 레이트에 독립적인 부드러운 움직임을 구현할 수 있습니다.
이러한 기본 파티클 시스템을 더욱 발전시켜, 인터랙티브한 요소를 추가하고 성능을 최적화하는 방법을 알아보겠습니다.
파티클 시스템을 실제 프로덕션 환경에서 사용하기 위해서는 성능 최적화가 필수적입니다. 또한 사용자 상호작용을 추가하면 더욱 흥미로운 효과를 만들 수 있습니다. 이번 페이지에서는 이 두 가지 측면을 자세히 살펴보겠습니다.
개별 mesh를 여러 개 생성하는 대신, InstancedMesh를 사용하면 성능을 크게 향상시킬 수 있습니다. 이는 GPU에 단일 geometry를 한 번만 전송하고, 여러 인스턴스의 위치와 회전을 효율적으로 업데이트하는 방식입니다:
import { useMemo, useRef } from 'react' import { Matrix4, Object3D } from 'three' function OptimizedParticleSystem({ count = 1000 }) { const mesh = useRef() const dummy = useMemo(() => new Object3D(), []) const matrices = useMemo(() => new Float32Array(count * 16), [count]) useFrame((state) => { // 각 파티클의 변환 행렬을 업데이트합니다 for (let i = 0; i < count; i++) { const time = state.clock.elapsedTime // 파티클의 위치를 계산합니다 dummy.position.set( Math.sin(i + time) * 2, Math.cos(i + time) * 2, Math.sin(i * 0.5 + time) * 2 ) // 크기와 회전도 설정할 수 있습니다 dummy.scale.set(0.1, 0.1, 0.1) dummy.updateMatrix() // 행렬을 배열에 복사합니다 dummy.matrix.toArray(matrices, i * 16) } // instancedMesh의 매트릭스를 한 번에 업데이트합니다 mesh.current.instanceMatrix.needsUpdate = true }) return ( <instancedMesh ref={mesh} args={[null, null, count]}> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="#8b5cf6" /> </instancedMesh> ) }
파티클이 마우스 커서를 피하거나 따라가도록 만들어 보겠습니다:
function InteractiveParticleSystem() { const mouse = useRef({ x: 0, y: 0 }) // 마우스 위치를 정규화된 좌표로 변환합니다 const handleMouseMove = useCallback((event) => { mouse.current = { x: (event.clientX / window.innerWidth) * 2 - 1, y: -(event.clientY / window.innerHeight) * 2 + 1 } }, []) useEffect(() => { window.addEventListener('mousemove', handleMouseMove) return () => window.removeEventListener('mousemove', handleMouseMove) }, [handleMouseMove]) useFrame((state) => { // 마우스와 파티클 사이의 상호작용을 계산합니다 particles.current.forEach((particle, i) => { const mouseDistance = Math.hypot( particle.position.x - mouse.current.x * 5, particle.position.y - mouse.current.y * 5 ) // 마우스에서 멀어지는 힘을 계산합니다 if (mouseDistance < 1) { const angle = Math.atan2( particle.position.y - mouse.current.y * 5, particle.position.x - mouse.current.x * 5 ) particle.velocity.x += Math.cos(angle) * 0.02 particle.velocity.y += Math.sin(angle) * 0.02 } // 속도 감쇄를 적용합니다 particle.velocity.x *= 0.98 particle.velocity.y *= 0.98 }) }) // 이전 파티클 시스템 코드를 여기에 통합합니다 return ( // ...렌더링 코드 ) }
파티클 시스템의 시각적 품질을 높이기 위해 조명과 후처리 효과를 추가할 수 있습니다:
import { EffectComposer, Bloom } from '@react-three/postprocessing' function EnhancedScene() { return ( <Canvas> {/* 기존의 파티클 시스템 */} <InteractiveParticleSystem /> {/* 조명 설정 */} <ambientLight intensity={0.5} /> <pointLight position={[10, 10, 10]} intensity={1} /> {/* 후처리 효과 */} <EffectComposer> <Bloom intensity={1.5} luminanceThreshold={0.1} luminanceSmoothing={0.9} /> </EffectComposer> </Canvas> ) }
이러한 최적화와 효과들을 조합하면, 웹 브라우저에서도 수천 개의 파티클을 부드럽게 렌더링하면서 사용자와 상호작용하는 멋진 시각적 효과를 만들 수 있습니다. 이러한 파티클 시스템을 실제 UI 컴포넌트와 통합하는 방법을 알아보겠습니다.
실제 애플리케이션에서는 3D 효과가 UI를 보완하되 방해하지 않아야 합니다. 로그인 페이지를 예로 들어, 파티클 시스템과 UI를 어떻게 조화롭게 구성할 수 있는지 살펴보겠습니다.
먼저 Canvas와 UI 요소를 적절히 배치하는 방법을 이해해야 합니다. React Three Fiber의 Canvas는 일반적인 DOM 요소와 함께 사용될 수 있습니다:
function LoginPage() { return ( <div className="relative w-full h-screen"> {/* 3D 배경 레이어 */} <div className="absolute inset-0"> <Canvas> <ParticleBackground /> </Canvas> </div> {/* UI 레이어 */} <div className="relative z-10 flex items-center justify-center h-full"> <LoginForm /> </div> </div> ) } function LoginForm() { return ( <div className="bg-white/10 backdrop-blur-lg p-8 rounded-lg"> <h2 className="text-2xl mb-6 text-white">관리자 로그인</h2> {/* 폼 내용 */} </div> ) }
여기서 backdrop-blur-lg
클래스는 배경의 파티클 효과를 부드럽게 흐리게 만들어 텍스트의 가독성을 높여줍니다. 이는 시각적 계층을 만드는 효과적인 방법입니다.
파티클 시스템이 UI 요소의 존재를 인식하도록 만들 수 있습니다. 예를 들어, 로그인 폼 주변의 파티클이 다르게 행동하도록 할 수 있습니다:
function ParticleBackground() { const formBounds = useFormBounds() function useFormBounds() { const [bounds, setBounds] = useState({ x: 0, y: 0, width: 0, height: 0 }) useEffect(() => { const form = document.querySelector('.login-form') if (form) { const rect = form.getBoundingClientRect() setBounds({ x: (rect.left / window.innerWidth) * 2 - 1, y: -(rect.top / window.innerHeight) * 2 + 1, width: (rect.width / window.innerWidth) * 2, height: (rect.height / window.innerHeight) * 2 }) } }, []) return bounds } // 파티클의 동작을 수정하여 폼 주변에서 다르게 움직이도록 함 useFrame((state) => { particles.current.forEach(particle => { // 파티클이 폼 영역 안에 있는지 확인 if (isParticleNearForm(particle.position, formBounds)) { // 폼 주변에서는 더 천천히, 부드럽게 움직이도록 조정 particle.velocity.multiplyScalar(0.95) } }) }) return <OptimizedParticleSystem /> }
3D 효과는 멋지지만, 모든 사용자가 동일한 경험을 할 수는 없습니다. 성능과 접근성을 고려한 대체 방안을 준비해야 합니다:
function LoginPage() { const [is3DEnabled, setIs3DEnabled] = useState(true) const [isReducedMotion] = useReducedMotion() // 시스템 성능 확인 useEffect(() => { const checkPerformance = async () => { const gpu = await getGPUTier() setIs3DEnabled(gpu.tier >= 2 && !isReducedMotion) } checkPerformance() }, [isReducedMotion]) return ( <div className="relative w-full h-screen"> {is3DEnabled ? ( <div className="absolute inset-0"> <Canvas> <ParticleBackground /> </Canvas> </div> ) : ( <div className="absolute inset-0 bg-gradient-to-br from-purple-900 to-indigo-900" /> )} <div className="relative z-10"> <LoginForm /> </div> </div> ) }
이러한 방식으로 우리는 시각적으로 매력적인 3D 효과를 제공하면서도, 접근성과 성능을 고려한 대체 방안을 준비할 수 있습니다. 파티클 시스템에 애니메이션과 전환 효과를 추가하는 방법을 알아보겠습니다.
애니메이션은 단순한 시각적 효과 이상의 의미를 가집니다. 잘 설계된 애니메이션은 사용자의 주의를 안내하고, UI의 상태 변화를 자연스럽게 전달하며, 전반적인 사용자 경험을 향상시킵니다. 파티클 시스템에 애니메이션을 적용할 때는 물리 법칙을 고려하여 자연스러운 움직임을 만드는 것이 중요합니다.
현실 세계의 움직임은 대부분 스프링과 같은 탄성을 가집니다. React Three Fiber에서는 이러한 자연스러운 움직임을 구현하기 위해 스프링 물리학을 적용할 수 있습니다:
import { useSpring, a } from '@react-spring/three' function AnimatedParticle({ targetPosition }) { // 스프링 물리학을 사용한 부드러운 위치 전환 const { position } = useSpring({ position: targetPosition, config: { mass: 1, // 질량 tension: 280, // 탄성 friction: 60 // 마찰 } }) return ( <a.mesh position={position}> <boxGeometry args={[0.1, 0.1, 0.1]} /> <meshStandardMaterial color="#8b5cf6" /> </a.mesh> ) }
여기서 스프링의 매개변수들은 움직임의 특성을 결정합니다. 질량(mass)이 크면 움직임이 더 무겁게 느껴지고, 탄성(tension)이 높으면 더 빠르게 목표 위치에 도달하며, 마찰(friction)이 높으면 진동이 빨리 안정화됩니다.
자연계의 새 떼나 물고기 무리처럼, 파티클들도 집단적인 행동 패턴을 보일 수 있습니다. 이를 구현하기 위해 Boids 알고리즘의 기본 원칙을 적용해보겠습니다:
function calculateFlockBehavior(particle, particles) { // 1. 분리(Separation): 다른 파티클과 너무 가까워지는 것을 피함 function separate(particle, neighbors) { const avoidanceRadius = 0.5 let movement = new Vector3() neighbors.forEach(neighbor => { let distance = particle.position.distanceTo(neighbor.position) if (distance < avoidanceRadius) { let pushForce = particle.position.clone() .sub(neighbor.position) .normalize() .divideScalar(distance) movement.add(pushForce) } }) return movement } // 2. 정렬(Alignment): 이웃 파티클들의 평균 진행 방향을 따름 function align(particle, neighbors) { let averageVelocity = new Vector3() neighbors.forEach(neighbor => { averageVelocity.add(neighbor.velocity) }) return averageVelocity.divideScalar(neighbors.length) } // 3. 응집(Cohesion): 이웃 파티클들의 평균 위치를 향해 이동 function cohere(particle, neighbors) { let centerOfMass = new Vector3() neighbors.forEach(neighbor => { centerOfMass.add(neighbor.position) }) return centerOfMass.divideScalar(neighbors.length) .sub(particle.position) } // 세 가지 행동을 조합하여 최종 움직임 결정 const neighbors = findNeighbors(particle, particles) const separation = separate(particle, neighbors).multiplyScalar(1.5) const alignment = align(particle, neighbors).multiplyScalar(1.0) const cohesion = cohere(particle, neighbors).multiplyScalar(1.0) return separation.add(alignment).add(cohesion) }
이러한 군집 행동은 파티클들이 마치 살아있는 것처럼 보이게 만들며, 로그인 페이지에 역동적인 생명력을 불어넣습니다.
사용자 인터랙션에 반응하여 파티클 시스템의 전체적인 상태가 변화할 때도 부드러운 전환이 필요합니다:
function ParticleSystem({ mode }) { // 전체 시스템의 상태를 관리하는 스프링 애니메이션 const systemSpring = useSpring({ config: { duration: 2000 }, from: { spreadFactor: 1, energy: 0 }, to: async (next) => { if (mode === 'active') { await next({ spreadFactor: 1.5, energy: 1 }) } else { await next({ spreadFactor: 1, energy: 0 }) } } }) useFrame((state) => { // 스프링 값에 따라 파티클 시스템의 전체적인 특성 조정 const { spreadFactor, energy } = systemSpring particles.current.forEach(particle => { particle.velocity.multiplyScalar(energy.get()) particle.maxSpeed = energy.get() * 0.1 particle.spread = spreadFactor.get() }) }) }
이러한 애니메이션 시스템을 더욱 발전시켜, 사용자 입력과 상호작용하는 방법을 자세히 살펴보겠습니다.
사용자 입력에 대한 반응은 인터랙티브 시스템의 핵심입니다. 특히 3D 환경에서는 이러한 반응이 자연스럽고 직관적이어야 합니다. 오늘은 마우스와 터치 입력을 세련되게 처리하는 방법을 자세히 살펴보겠습니다.
마우스 움직임을 3D 공간으로 변환할 때는 여러 가지 고려사항이 있습니다. 2D 스크린 좌표를 3D 공간 좌표로 변환하는 과정을 상세히 살펴보겠습니다:
function useMousePosition() { const [mousePosition, setMousePosition] = useState({ x: 0, y: 0, z: 0 }) const camera = useThree((state) => state.camera) const size = useThree((state) => state.size) const updateMousePosition = useCallback((event) => { // 스크린 좌표를 정규화된 장치 좌표로 변환 const x = (event.clientX / size.width) * 2 - 1 const y = -(event.clientY / size.height) * 2 + 1 // 레이캐스터를 사용하여 3D 공간상의 평면과 교차점 찾기 const raycaster = new THREE.Raycaster() raycaster.setFromCamera({ x, y }, camera) // 가상의 평면을 만들어 교차점 계산 const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0) const intersection = new THREE.Vector3() raycaster.ray.intersectPlane(plane, intersection) setMousePosition(intersection) }, [camera, size]) useEffect(() => { window.addEventListener('mousemove', updateMousePosition) return () => window.removeEventListener('mousemove', updateMousePosition) }, [updateMousePosition]) return mousePosition }
이러한 복잡한 변환 과정이 필요한 이유는 2D 화면상의 마우스 위치를 3D 공간에서 의미 있는 위치로 매핑해야 하기 때문입니다. 이는 마치 실제 공간에서 레이저 포인터로 특정 지점을 가리키는 것과 유사합니다.
마우스 위치를 중심으로 파티클들에게 영향을 미치는 힘 필드를 구현해보겠습니다:
function ForceField({ position, strength = 1, radius = 2 }) { const forceField = useMemo(() => { return { apply: (particle) => { const distance = particle.position.distanceTo(position) if (distance < radius) { // 거리에 따른 힘의 감쇄를 계산 const force = 1 - (distance / radius) // 스무딩 함수를 적용하여 부드러운 전이 효과 생성 const smoothForce = force * force * (3 - 2 * force) // 힘의 방향 계산 const direction = particle.position.clone() .sub(position) .normalize() .multiplyScalar(smoothForce * strength) // 파티클의 속도에 힘을 적용 particle.velocity.add(direction) } } } }, [position, strength, radius]) return forceField }
이 힘 필드는 단순히 밀어내는 것이 아니라, 거리에 따라 부드럽게 감쇄되는 힘을 생성합니다. 이는 마치 자석이 철가루에 영향을 미치는 것과 비슷한 원리입니다. smoothForce 계산에 사용된 수식은 에르밋 보간법(Hermite interpolation)의 한 형태로, 부드러운 전이를 만드는 데 효과적입니다.
현대의 웹 애플리케이션은 다양한 입력 방식을 지원해야 합니다. 터치 입력에 대한 지원을 추가해보겠습니다:
function useMultiInputPosition() { const mousePosition = useMousePosition() const [touchPosition, setTouchPosition] = useState(null) const handleTouch = useCallback((event) => { // 멀티터치 처리 const touches = Array.from(event.touches) const positions = touches.map(touch => { // 각 터치 포인트를 3D 공간 좌표로 변환 // 이전의 마우스 변환 로직과 유사하게 처리 return convertTouchToSpace(touch) }) // 여러 터치 포인트의 평균 위치 계산 const averagePosition = positions.reduce((acc, pos) => acc.add(pos), new THREE.Vector3() ).divideScalar(positions.length) setTouchPosition(averagePosition) }, []) // 실제 사용할 위치는 터치나 마우스 중 활성화된 것 선택 const activePosition = touchPosition || mousePosition return activePosition }
이렇게 구현된 시스템은 데스크톱과 모바일 환경 모두에서 자연스럽게 작동하며, 사용자의 입력에 따라 파티클들이 유기적으로 반응하게 됩니다. 이러한 인터랙션을 시각적으로 더욱 풍부하게 만드는 셰이더 효과에 대해 알아보겠습니다.
앞서 배운 기본적인 셰이더를 바탕으로, 이제 더욱 세련된 시각 효과를 만들어보겠습니다. 실제 물리 현상을 모방하고 시각적 매력을 높이는 방법을 자세히 살펴보겠습니다.
파티클에 빛나는 듯한 효과를 주면 더욱 매력적인 시각 효과를 만들 수 있습니다. 이를 위해 프래그먼트 셰이더를 개선해보겠습니다:
uniform vec3 uColor; uniform float uIntensity; void main() { // 파티클의 중심으로부터의 거리 계산 vec2 center = gl_PointCoord - 0.5; float distance = length(center); // 기본 알파값 계산 float baseAlpha = 1.0 - smoothstep(0.45, 0.5, distance); // 글로우 효과를 위한 추가적인 발광 계산 float glow = exp(-distance * 4.0) * uIntensity; // 최종 색상 계산 vec3 finalColor = mix(uColor, vec3(1.0), glow); float finalAlpha = baseAlpha + glow * 0.5; gl_FragColor = vec4(finalColor, finalAlpha); }
이 셰이더가 하는 일을 자세히 살펴보면, 파티클의 중심으로부터 멀어질수록 밝기가 점점 감소하는 자연스러운 발광 효과를 만듭니다. 이는 실제 빛이 퍼져나가는 방식과 유사합니다. exp(-distance * 4.0)
는 지수 감쇄를 사용하여 자연스러운 빛의 감소를 표현합니다.
단일 색상 대신 화려한 그라데이션을 적용하여 더욱 풍부한 시각 효과를 만들어보겠습니다:
// 버텍스 셰이더에 추가되는 변수들 varying vec3 vPosition; varying float vDistance; void main() { // 기존 위치 계산 코드... // 프래그먼트 셰이더로 전달할 값들 설정 vPosition = position; vDistance = length(position); } // 프래그먼트 셰이더 varying vec3 vPosition; varying float vDistance; uniform float uTime; vec3 palette(float t) { // 색상 팔레트 정의 vec3 a = vec3(0.5, 0.5, 0.5); vec3 b = vec3(0.5, 0.5, 0.5); vec3 c = vec3(1.0, 1.0, 1.0); vec3 d = vec3(0.263, 0.416, 0.557); // 시간에 따라 변화하는 색상 계산 return a + b * cos(6.28318 * (c * t + d + uTime * 0.1)); }
이 코드는 삼각함수를 사용하여 시간에 따라 부드럽게 변화하는 색상을 만듭니다. 마치 오로라나 홀로그램과 같은 효과를 연출할 수 있습니다.
파티클의 속도나 가속도와 같은 물리적 특성을 시각적으로 표현하면 더욱 역동적인 효과를 만들 수 있습니다:
// 버텍스 셰이더에 추가 attribute vec3 velocity; varying float vSpeed; void main() { // 기존 코드... // 속도의 크기 계산 vSpeed = length(velocity); // 속도에 따른 파티클 크기 조절 gl_PointSize = size * (1.0 + vSpeed * 0.5); } // 프래그먼트 셰이더에서 활용 varying float vSpeed; void main() { // 기존 코드... // 속도에 따른 색상 변화 vec3 speedColor = mix( vec3(0.54, 0.36, 0.96), // 느린 속도의 색상 vec3(0.96, 0.36, 0.54), // 빠른 속도의 색상 smoothstep(0.0, 2.0, vSpeed) ); finalColor = mix(finalColor, speedColor, 0.5); }
이렇게 구현된 셰이더는 파티클의 움직임에 따라 동적으로 변화하는 시각적 피드백을 제공합니다. 파티클이 빠르게 움직일 때는 더 밝고 크게 보이며, 느리게 움직일 때는 더 차분한 모습을 보입니다.
이러한 셰이더 효과들을 실제 사용자 인터페이스와 조화롭게 통합하는 방법과 성능 최적화 기법에 대해 알아보겠습니다.
지금까지 우리는 아름다운 파티클 시스템을 만들었지만, 실제 프로덕션 환경에서 사용하기 위해서는 성능 최적화가 필수적입니다. 이번 페이지에서는 성능을 개선하면서도 시각적 품질을 유지하는 방법을 살펴보겠습니다.
GPU 인스턴싱은 동일한 지오메트리를 여러 번 그릴 때 CPU와 GPU 사이의 통신을 최소화하는 기술입니다. 파티클 시스템에서는 이 기술이 특히 유용합니다:
function OptimizedParticleSystem() { // 인스턴스 속성을 정의합니다 const instancedMesh = useRef() const particleCount = 5000 useEffect(() => { // 각 인스턴스의 변환 행렬을 저장할 배열을 만듭니다 const matrices = new Float32Array(particleCount * 16) const matrix = new Matrix4() // 각 파티클의 초기 상태를 설정합니다 for (let i = 0; i < particleCount; i++) { const x = (Math.random() - 0.5) * 10 const y = (Math.random() - 0.5) * 10 const z = (Math.random() - 0.5) * 10 matrix.setPosition(x, y, z) matrix.toArray(matrices, i * 16) } // 인스턴스 속성을 업데이트합니다 instancedMesh.current.instanceMatrix.set(matrices) instancedMesh.current.instanceMatrix.needsUpdate = true }, []) return ( <instancedMesh ref={instancedMesh} args={[null, null, particleCount]}> <boxGeometry args={[0.1, 0.1, 0.1]} /> <shaderMaterial vertexShader={vertexShader} fragmentShader={fragmentShader} uniforms={uniforms} /> </instancedMesh> ) }
이 방식은 기존의 방식보다 훨씬 효율적입니다. 각 파티클마다 새로운 메시를 만드는 대신, 하나의 지오메트리를 여러 번 재사용하기 때문입니다.
사용자의 시점에서 멀리 있는 파티클은 덜 상세하게 렌더링하여 성능을 개선할 수 있습니다:
function AdaptiveParticleSystem() { const camera = useThree((state) => state.camera) // 카메라와의 거리에 따라 파티클의 세부 수준을 조정합니다 const calculateDetail = useCallback((position) => { const distance = position.distanceTo(camera.position) const detail = { size: 1.0, complexity: 1.0 } // 거리에 따른 세부 수준 조정 if (distance > 10) { detail.size *= 0.8 detail.complexity *= 0.5 } if (distance > 20) { detail.size *= 0.6 detail.complexity *= 0.3 } return detail }, [camera]) // 셰이더에 세부 수준 정보를 전달합니다 const uniforms = useMemo(() => ({ uDetailLevel: { value: 1.0 } }), []) useFrame(() => { // 매 프레임마다 파티클의 세부 수준을 업데이트합니다 const systemCenter = new Vector3() const detail = calculateDetail(systemCenter) uniforms.uDetailLevel.value = detail.complexity }) return ( <OptimizedParticleSystem uniforms={uniforms} /> ) }
안정적인 성능을 위해 프레임 레이트를 모니터링하고 관리하는 것이 중요합니다:
function PerformanceMonitor() { const [fps, setFps] = useState(60) const frameCount = useRef(0) const lastTime = useRef(performance.now()) useFrame(() => { frameCount.current++ const currentTime = performance.now() // 1초마다 FPS를 계산합니다 if (currentTime - lastTime.current >= 1000) { setFps(frameCount.current) frameCount.current = 0 lastTime.current = currentTime // FPS가 낮다면 파티클 수를 줄입니다 if (fps < 30) { // 파티클 시스템에 최적화 신호를 보냅니다 // 예: 파티클 수 감소, 효과 단순화 등 } } }) return null }
이러한 최적화 기법들을 적용하면, 다양한 하드웨어 환경에서도 안정적으로 작동하는 파티클 시스템을 구현할 수 있습니다. 이 모든 것을 종합하여 완성된 시스템을 구축하는 방법을 살펴보겠습니다.
지금까지 우리는 파티클 시스템의 여러 측면을 살펴보았습니다. 이제 이 모든 요소를 하나로 통합하여, 실제 로그인 페이지에서 사용할 수 있는 완성된 시스템을 만들어보겠습니다. 이 과정에서 각 부분이 어떻게 조화롭게 작동하는지 이해하는 것이 중요합니다.
먼저 모든 기능을 통합한 완성된 코드를 살펴보겠습니다:
function LoginPageParticles() { // 시스템의 전반적인 상태를 관리합니다 const systemState = useRef({ active: true, particleCount: 5000, performanceMode: false }) // 성능 모니터링 로직을 구현합니다 useEffect(() => { const performanceObserver = new PerformanceObserver((list) => { const entries = list.getEntries() const fps = 1000 / entries[0].duration // 성능에 따라 시스템을 자동으로 조정합니다 if (fps < 30 && !systemState.current.performanceMode) { systemState.current.performanceMode = true systemState.current.particleCount = Math.floor( systemState.current.particleCount * 0.7 ) } }) performanceObserver.observe({ entryTypes: ['frame'] }) return () => performanceObserver.disconnect() }, []) return ( <Canvas camera={{ position: [0, 0, 5] }} onCreated={({ gl }) => { // WebGL 컨텍스트 최적화 gl.setPixelRatio(Math.min(window.devicePixelRatio, 2)) gl.physicallyCorrectLights = true }} > <AdaptiveParticleSystem count={systemState.current.particleCount} performanceMode={systemState.current.performanceMode} /> <EffectComposer> <Bloom intensity={1.5} luminanceThreshold={0.1} luminanceSmoothing={0.9} /> </EffectComposer> <MouseInteractionManager /> </Canvas> ) }
이 코드는 우리가 이전에 다룬 모든 개념을 통합합니다. 성능 모니터링, 적응형 렌더링, 후처리 효과, 그리고 사용자 상호작용까지 모두 포함되어 있습니다.
이제 이 파티클 시스템을 실제 로그인 페이지에 통합해보겠습니다:
function LoginPage() { const [isLoading, setIsLoading] = useState(true) const [isLoggedIn, setIsLoggedIn] = useState(false) // 로그인 상태에 따라 파티클 시스템의 행동을 변경합니다 const particleSystemState = useMemo(() => ({ idle: { energy: 0.5, spread: 1.0, color: '#8b5cf6' }, active: { energy: 1.0, spread: 1.5, color: '#6366f1' }, success: { energy: 2.0, spread: 2.0, color: '#10b981' } }), []) return ( <div className="relative min-h-screen"> {/* 파티클 시스템 배경 */} <div className="absolute inset-0"> <LoginPageParticles state={isLoggedIn ? 'success' : (isLoading ? 'idle' : 'active')} config={particleSystemState} /> </div> {/* 로그인 폼 */} <div className="relative z-10 flex items-center justify-center min-h-screen"> <div className="bg-white/10 backdrop-blur-lg p-8 rounded-lg"> <LoginForm onSubmit={async (credentials) => { // 로그인 처리 중에는 파티클이 더 활발하게 움직입니다 setIsLoading(true) try { await handleLogin(credentials) setIsLoggedIn(true) // 성공 시 파티클이 축하하는 듯한 모션을 보여줍니다 } catch (error) { // 실패 시 파티클이 흩어졌다가 다시 모이는 효과 } finally { setIsLoading(false) } }} /> </div> </div> </div> ) }
이렇게 구현된 시스템은 단순한 시각적 효과를 넘어 사용자 경험의 중요한 부분이 됩니다. 로그인 과정의 각 단계가 파티클의 움직임을 통해 자연스럽게 표현되며, 이는 사용자에게 시각적 피드백을 제공합니다.
React Three Fiber를 활용한 파티클 시스템 구현은 단순히 기술적인 도전을 넘어 사용자 경험을 풍부하게 만드는 창의적인 과정입니다. 성능 최적화, 시각적 품질, 그리고 사용자 상호작용을 모두 고려하면서, 우리는 웹 애플리케이션에 생동감을 불어넣을 수 있습니다.
앞으로도 WebGL과 3D 그래픽스 기술은 계속 발전할 것이며, 이는 웹 개발자들에게 더 많은 창의적 가능성을 제공할 것입니다. 이러한 기술을 마스터하는 것은 현대 웹 개발에서 점점 더 중요해지고 있습니다.