2. 자연스러운 우주 느낌의 배경 파티클을 웹에서 만들어 보자.
2025년 8월 15일
배경 파티클 시스템 구현
Todo
- 자연스럽게 흐르는 움직임
- 시간에 따라 변화하는 패턴
- 사용자가 등장하기 전 무한한 공간을 연출
랜덤값으로 하면?
처음에는 Math.random()으로 각 파티클의 위치를 업데이트했습니다. 결과:
- 파티클이 각자 무작위로 움직여 어수선함
- 패턴이 없어 유기적 흐름이 부족
- 프레임마다 급격히 변해 좀 많이 부자연스러움
랜덤의 한계:
// 랜덤 사용 시 문제점 positions[i3 + 0] += (Math.random() - 0.5) * 2; // 너무 급격한 변화 positions[i3 + 1] += (Math.random() - 0.5) * 2; // 패턴 없음 positions[i3 + 2] += (Math.random() - 0.5) * 2; // 부자연스러움
노이즈(Noise)의 선택
Perlin Noise(Simplex Noise)를 선택한 이유:
- 연속성: 인접한 입력값이 비슷한 출력을 만들어 부드러운 변화
- 자연스러움: 유기적 흐름과 소용돌이 패턴
- 시간 변화: 4D 노이즈로 시간 축 추가 가능
- 성능: 경량 라이브러리로 실시간 처리 가능
Three.js 파티클 시스템 구조
1. BufferGeometry: 대량 파티클 처리
20,000개 파티클을 효율적으로 처리하기 위해 BufferGeometry를 사용했습니다.
this.particles = new THREE.BufferGeometry(); this.positions = new Float32Array(this.particleCount * 3);
왜 BufferGeometry인가?
- TypedArray로 메모리 효율적
- GPU에 직접 전달 가능
- 일반 Geometry보다 빠름
메모리 구조:
- 각 파티클은 (x, y, z) 3개 값
- 20,000개 × 3 = 60,000개 Float32 값
- 약 240KB (Float32 = 4 bytes)
2. 초기 위치 설정
파티클을 3D 공간에 균등하게 분산:
for (let i = 0; i < this.particleCount; i++) { const i3 = i * 3; this.positions[i3 + 0] = (Math.random() - 0.5) * 600; // X: -300 ~ 300 this.positions[i3 + 1] = (Math.random() - 0.5) * 600; // Y: -300 ~ 300 this.positions[i3 + 2] = (Math.random() - 0.5) * 600; // Z: -300 ~ 300 }
공간 크기 결정:
- 카메라 초기 위치: Z = 50
- 시야각: 75도
- 600 단위 공간으로 카메라 주변을 채움
3. Three.js에 데이터 연결
this.particles.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
BufferAttribute: TypedArray를 Three.js 형식으로 변환3: 각 정점당 3개 값 (x, y, z)
3D 공간 설정
카메라 설정
this.camera = new THREE.PerspectiveCamera( 75, // 시야각 (FOV) window.innerWidth / window.innerHeight, // 종횡비 0.1, // near plane 1000 // far plane ); this.camera.position.z = 50;
공간 범위:
- Near: 0.1
- Far: 1000
- 파티클 범위: -300 ~ 300 (카메라 주변)
씬(Scene) 설정
this.scene = new THREE.Scene();
- 별도 조명 없이 파티클 자체 발광 사용
텍스처 적용
이미지 텍스처 로드
this.textureLoader = new THREE.TextureLoader(); this.particleTexture = this.textureLoader.load('./asset.png');
텍스처 사용 이유: 파티클이 카메라 가까이 왔을 때, 단순히 점이면 몰입도가 떨어진다고 생각해습니다. 파티클 이미지를 자체 제작하여, 이미지로 사용했습니다.
PointsMaterial 설정
this.particleMaterial = new THREE.PointsMaterial({ size: 2, // 파티클 크기 map: this.particleTexture, // 텍스처 이미지 transparent: true, // 투명도 사용 alphaTest: 0.1, // 알파 테스트 (투명 부분 제거) blending: THREE.AdditiveBlending, // 가산 블렌딩 });
AdditiveBlending:
- 겹치는 파티클이 밝게 합성
- 별처럼 보이는 효과
- 우주 느낌 강화
노이즈 구현: 4D 노이즈로 시간 변화
SimplexNoise 초기화
this.noise = new SimplexNoise(); this.clock = new THREE.Clock();
SimplexNoise: Perlin Noise 개선 버전THREE.Clock: 경과 시간 추적
4D 노이즈 샘플링
각 축에 대해 서로 다른 위치에서 노이즈를 샘플링:
const noiseScale = 0.005; // 공간적 스케일 (Frequency) const timeScale = 0.1; // 시간적 변화 속도 const forceStrength = 0.8; // 힘의 세기 const noiseX = this.noise.noise4D(x * noiseScale, y * noiseScale, z * noiseScale, time * timeScale) * forceStrength; const noiseY = this.noise.noise4D(y * noiseScale, z * noiseScale, x * noiseScale, time * timeScale) * forceStrength; const noiseZ = this.noise.noise4D(z * noiseScale, x * noiseScale, y * noiseScale, time * timeScale) * forceStrength; positions[i3 + 0] += noiseX; positions[i3 + 1] += noiseY; positions[i3 + 2] += noiseZ;
왜 각 축마다 다른 입력을 사용하는가?
- X축: (x, y, z, time)
- Y축: (y, z, x, time) - 순환 이동
- Z축: (z, x, y, time) - 순환 이동
noise에 들어가는 x, y, z값을 이전 좌표에 근거하는 동시에 한칸씩 땡겨주면 직선운동이 아닌 소용돌이 같은 자연스러운 움직임이 생길거라 예상했고, 의도한 대로 잘 움직였습니다.
노이즈 파라미터 튜닝
공간적 스케일 (noiseScale = 0.005):
- 작을수록 큰 패턴 (부드러움)
- 클수록 작은 패턴 (세밀함)
- 실행시켜보고 적당한 값 사용
시간적 스케일 (timeScale = 0.1):
- 변화 속도 조절
- 0.1은 천천히 변화
- 실행시켜보고 적당한 값 사용
힘의 세기 (forceStrength = 0.8):
- 파티클 이동 거리
- 너무 크면 빠르게 사라짐
- 너무 작으면 움직임이 미미함
- 실행시켜보고 적당한 값 사용
애니메이션 루프
update() 메서드
update() { const time = this.clock.getElapsedTime(); const positions = this.particleSystem.geometry.attributes.position.array; // 각 파티클 위치 업데이트 for (let i = 0; i < this.particleCount; i++) { // ... 노이즈 계산 및 위치 업데이트 } // Three.js에 변경사항 알림 this.particleSystem.geometry.attributes.position.needsUpdate = true; }
핵심 포인트:
getElapsedTime(): 시작부터 경과 시간- 직접 배열 수정:
positions배열을 직접 변경 needsUpdate = true: Three.js에 변경 알림 (필수)
메인 애니메이션 루프
animate() { requestAnimationFrame(() => this.animate()); this.backgroundParticles.update(); this.renderer.render(this.scene, this.camera); }
requestAnimationFrame: 브라우저 최적화된 프레임 주기- 보통 60fps (약 16.67ms/프레임)
문제 해결 과정
문제 1: 파티클이 움직이지 않음
원인: needsUpdate 플래그 누락
// 이전 코드 positions[i3 + 0] += noiseX; // needsUpdate 없음! // 수정 코드 positions[i3 + 0] += noiseX; this.particleSystem.geometry.attributes.position.needsUpdate = true;
해결: 매 프레임마다 needsUpdate = true 설정
문제 2: 파티클이 너무 빠르게 사라짐
원인: forceStrength가 너무 큼
// 초기 시도 const forceStrength = 2.0; // 너무 큼 // 조정 후 const forceStrength = 0.8; // 적절함
해결: 파라미터를 반복 조정
문제 3: 노이즈 패턴이 너무 작음
원인: noiseScale이 너무 큼
// 초기 시도 const noiseScale = 0.1; // 패턴이 너무 세밀함 // 조정 후 const noiseScale = 0.005; // 적절한 크기의 패턴
해결: 시각적 피드백으로 스케일 조정
문제 4: 성능 저하
원인: 아마도 불필요한 연산
최적화:
- 노이즈 계산은 필수이므로 유지
- 파티클 개수 조정 가능 (20,000 → 15,000)
- 불필요한 렌더링 제거
최종 구현 결과
성공적으로 구현된 기능:
- 20,000개 파티클 실시간 렌더링
- 4D 노이즈 기반 자연스러운 움직임
- 텍스처 적용으로 시각적 품질 향상
- 60fps 유지
성능:
- 초기 로딩: 약 1초
- 프레임레이트: 60fps 유지
- 메모리 사용: 약 240KB
이를 통해, 자연스럽게 파티클이 이동하는 우주 느낌의 배경 파티클 시스템을 구현할 수 있었습니다.