본문 바로가기
Three.js/Class techniques

[threejs-journey 2-4] Particles

by SL123 2025. 8. 12.

개요

이번 포스트에서는 Three.js를 사용하여 웹에서 동적인 파티클 효과(별, 눈, 연기 등)를 구현하는 방법을 알아보겠습니다.

 

파티클은 수많은 작은 이미지를 사용해 화려한 시각 효과를 만들어내지만, 그만큼 성능 최적화가 매우 중요합니다. 이 글에서는 파티클의 기본 개념과 애니메이션을 다뤄볼 것이고, 추후에 셰이더에서 파티클을 더 자세하게 다뤄보겠습니다.

 

 

Points, PointsMaterial

 

가장 먼저, 파티클이 무엇인지 이해해야 합니다. Three.js에서 파티클은 수많은 작은 Plane(평면)으로 이루어져 있으며, 이 Plane들은 항상 카메라를 바라보도록 설정되어 있습니다.(이것을 '빌보드'라고 합니다.)

 

가장 간단하게 파티클을 만드는 방법은 일반적인 GeometryPointsMaterial을 사용하는 것입니다. 예를 들어, 구(Sphere) 행텨의 파티클을 만들어 보겠습니다.

// 마치 눈 같은 파티클을 만들 때 SphereGeometry를 사용한다.
const particleGeometry = new THREE.SphereGeometry(1, 32, 32);

// 해당 파티클을 만들기 위해 material을 만들어주자
const particleMaterial = new THREE.PointsMaterial({
    size: 0.02,
    sizeAttenuation: true // 원근감 적용 여부
});

// Mesh를 만들듯이 Points를 만든다.
const particles = new THREE.Points(particleGeometry, particleMaterial);
scene.add(particles);

 

여기서 중요한 속성은 sizeAttenuation입니다.

  • true(기본값): 카메라와의 거리에 따라 파티클 크기가 변합니다. 가까우면 커보이고, 멀면 작아 보입니다. (원근감 O)
  • false: 모든 파티클이 카메라와의 거리에 상관없이 동일한 크기로 보입니다.(원근감 X)

적용 전
적용 후

 

이 방법은 간단하지만, SphereGeometry의 모든 정점(vertex)에 파티클이 하나씩 생기므로 원하는 만큼 자유롭게 배치하기는 어렵습니다.

 

 

 

BufferGeometry를 이용한 파티클 생성

수천, 수만 개의 파티클을 자유자재로 다루려면 BufferGeometry를 사용하는 것이 필수적입니다. BufferGeometry는 정점의 위치, 색상 등의 데이터를 직접 Float32Array와 같은 타입 배열(Typed Array)로 관리합니다. 이 1차원 배열은 GPU가 매우 빠르게 처리할 수 있어 성능 최적화에 결정적입니다.

 

5000개의 파티클을 랜덤한 위치에 생성해보겠습니다.

// Geometry 생성
const particleGeometry = new THREE.BufferGeometry();
const count = 5000;

// 각 파티클은 x, y, z 세 개의 좌표값을 가지므로 count * 3 크기의 배열을 생성
const positions = new Float32Array(count * 3);

for(let i = 0; i < count * 3; i++) {
    // -5 ~ +5 사이의 랜덤한 위치에 파티클 배치
    positions[i] = (Math.random() - 0.5) * 10;
}

// position이라는 이름으로 attribute를 생성하고, 3개씩(x,y,z) 끊어 하나의 정점으로 인식하도록 설정
particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

이제 SphereGeometry 대신 이 particleGeometry를 사용하면, 우리가 직접 정의한 5000개의 위치에 파티클이 생성됩니다. 이것이 파티클 시스템의 표준적인 구현 방식입니다.

 

 

 

텍스처, 투명, 색상 조정

https://www.kenney.nl/assets/particle-pack

 

Particle Pack · Kenney

Download this package (80 assets) for free, CC0 licensed!

www.kenney.nl

코드 이전에 텍스처가 없는 분들은 위에서 가져와주세요.

 

 

단순한 사각형 파티클로는 테스트하기 어려운 부분들이 존재합니다. 이제는 파티클에 텍스처를 입혀 모양을 바꾸고, 색상을 입혀주겠습니다. 주로 원형이나 별 모양의 텍스처를 많이 사용합니다.

// 텍스처 로더 생성
const textureLoader = new THREE.TextureLoader();
const particleTexture = textureLoader.load('/textures/particles/star.png'); // 예시 경로

const particleMaterial = new THREE.PointsMaterial({
    size: 0.1,
    sizeAttenuation: true,
    color: new THREE.Color('#ff88cc'),
    map: particleTexture, // 텍스처 적용
});

 

 

렌더링 문제 해결

 

텍스처를 적용하면 바로 문제가 발생합니다. 파티클 텍스처의 투명해야 할 검은색 배경 부분이 뒤에 있는 다른 파티클을 가리는 현상입니다. WebGLRenderer는 기본적으로 각 픽셀의 값의 깊이(z-값)를 테스트하여 어떤 것을 앞에 그릴지 결정하기 때문입니다.

 

해당 문제를 해결하는 몇 가지 방법이 있습니다.

 

 

 

transparentalphaMap

 

가장 먼저 시도할 방법은 재질(Material)을 투명하게 만드는 것입니다.

const particleMaterial = new THREE.PointsMaterial({
    size: 0.1,
    sizeAttenuation: true,
    color: new THREE.Color('#ff88cc'),
    transparent: true, // 투명도 활성화
    alphaMap: particleTexture, // 텍스처의 명암을 알파(투명도) 정보로 사용
});

 

해당 방법은 대부분의 경우 잘 작동하지만, 여러 파티클이 겹칠 때 어떤 것을 먼저 그려야 할 지 GPU가 헷갈리면서 일부 파티클이 제대로 렌더링 되지 않는 문제가 여전히 발생할 수 있습니다.

 

 

 

alphaTest

 

alphaTest는 특정 알파값 이하의 픽셀을 아예 렌더링하지 않도록 하는 속성입니다. 이를 이용해 "거의 투명한" 픽셀들을 버려서 렌더링 오류를 줄일 수 있습니다.

particleMaterial.alphaTest = 0.001; // 알파값이 0.001보다 큰 픽셀만 렌더링

 

 

 

 

depthWirte

 

가장 효과적인 방법 중 하나는 depthWirtefalse로 설정하는 것입니다.

particleMaterial.depthWrite = false;

 

해당 옵션은 파티클을 렌더링 할 때 깊이 버퍼(depth buffer)에 정보를 기록하지 않도록 합니다. GPU는 여전히 다른 오브젝트와 깊이를 비교하지만(depthTest), 파티클끼리 깊이 정보를 새로 쓰지 않기 때문에 파티클 간의 겹침 순서 문제를 상당 부분 해결할 수 있습니다.

1, 2, 3 방법 모두 잘 동작한다.

 

 

주의점


2, 3번 방법을 사용하면 파티클 뒤에 있는 다른 일반 Mesh 오브젝트는 정상적으로 가려지지만, 파티클이 다른 불투명 오브젝트를 뚫고 앞에 그려지는 문제가 발생할 수 있습니다.

 

특히 2번 방법은 Test를 하지 못하게 막기 때문에 모든 불투명을 뚫고 다닙니다.따라서 상황에 맞게 alphaTestdepthWrite를 중요합니다.

 

 

 

 

 

블렌딩

blending 속성을 변경하면 파티클이 겹쳤을 때 색상이 더해지는 멋진 효과를 만들 수 있습니다. 어두운 배경에서 빛나는 파티클을 표현할 때 유용합니다.

particleMaterial.blending = THREE.AdditiveBlending; // 픽셀 색상을 더함

이 방법은 고품질의 효과를 만들지만, 여러 색상이 혼합되면서 연산량이 늘어나므로 성능에 영향을 줄 수 있습니다. 

 

 

 

 

개별 색상 적용

모든 파티클이 같은 색이면 단조로울 수 있습니다. position과 마찬가지로 color정보를 BufferAttribute로 추가하면 각 파티클에 다른 색상을 부여할 수 있습니다.

// ... 기존 코드 ...
const colors = new Float32Array(count * 3); // 컬러 Attribute 추가

for(let i = 0; i < count * 3; i++) {
    positions[i] = (Math.random() - 0.5) * 10;
    colors[i] = Math.random(); // r, g, b 값을 랜덤하게 설정
}

particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
particleGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); // 컬러 Attribute 설정

// Material 수정
const particleMaterial = new THREE.PointsMaterial({
    // ... 기존 속성 ...
    vertexColors: true // 버텍스 컬러 사용 활성화
});

기존 색깔(파티클 이미지 변경)
변경된 색깔

 

vertexColors: true를 설정하면 PointsMaterial에 지정된 colorsetAttribute로 지정된 각 정점의 color가 혼합되어 형형색색의 파티클이 만들어집니다.

 

흥미로운 점은기존 material에 color와 ranom으로 지정된 색상이 혼합이 된다는 것입니다.  해당 머터리얼을 끄면 색깔이 변하게 됩니다. 

 

 

 

 

파티클 애니메이션

파티클 전체를 회전시키거나 이동시키는 것은 particles.rotation.y += elapsedTime처럼 간단합니다. 하지만 각 파티클이 독립적으로, 마치 살아있는 것처럼 움직이게 하려면 어떻게 해야할까요?

 

바로 BufferGeometry의 position 속성을 직접 조작하는 것입니다.

 

 

 

배열의 사용

particleGeometry.attributes.position.array는 모든 파티클의 x, y, z 좌표가 [x1, y1, z1, x2, y2, z2, ...] 형태로 쭉 나열된 1차원 배열(Float32Array)입니다.

 

이 배열에 직접 접근하여 각 파티클의 좌표를 매 프레임마다 조금씩 변경함으로써 애니메이션을 만들 수 있습니다.

 

 

 

펄럭이는 양탄자 효과

애니메이션 루프(tick 함수) 안에서 for문을 통해 각 파티클에 접근하여 y값을 sin 함수로 계속 바꿔주는 코드입니다.

// 애니메이션 루프 (tick 함수) 내에서
const elapsedTime = clock.getElapsedTime();

// 'count'번, 즉 파티클의 개수만큼만 반복합니다.
for (let i = 0; i < count; i++) {
    // i번째 파티클의 x 좌표가 시작되는 배열 인덱스
    const i3 = i * 3;

    // 계산에 사용할 현재 파티클의 x 좌표 값
    const x = particleGeometry.attributes.position.array[i3];

    // 현재 파티클의 y 좌표를 sin 함수를 이용해 업데이트합니다.
    // elapsedTime과 x값을 더해 각 파티클이 다른 타이밍에 움직이도록 합니다.
    particleGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime + x);
}

// 💡 매우 중요!
// 자바스크립트에서 Geometry의 데이터를 변경했음을 Three.js에 알려줘야
// GPU로 데이터가 다시 전송되어 화면에 반영됩니다.
particleGeometry.attributes.position.needsUpdate = true;

 

needsUpdate = true 속성은 Three.js가 성능을 위해 Geometry 데이터를 GPU에 캐싱해두기 때문에 반드시 필요합니다. 이 플래그를 통해 "CPU에서 데이터가 바뀌었으니 GPU도 업데이트해!"라고 알려주는 것입니다.

 

 

 

 

명확한 한계와 해결책

문제는 프레임 드랍입니다. 이 방식은 모든 계산은 CPU에서 처리합니다. 파티클이 100~1000개 정도라면 괜찮지만, 수만 개로 늘어나면 매 프레임마다 실행되는 for문이 CPU에 엄청난 부하를 주어 애니메이션이 뚝뚝 끊기게 됩니다.

 

이러한 부분은 셰이더(Shader)를 통해 GPU로 처리하는 것이 가장 이상적입니다. 수많은 정점의 위치를 동시에 병렬로 계산하는 것은 그래픽 카드가 가장 잘하는 일이기 때문입니다. 애니메이션 로직을 Vertex Shader로 옮기면, CPU는 거의 관여하지 않으므로 수십만개의 파티클도 부드럽게 움직이게 할 수 있습니다.

 

 

 

결론

Three.js 파티클을 다루는 핵심적인 내용들을 모두 살펴보았습니다.

  • BufferGeometry를 이용해 대량의 파티클을 다뤘습니다.
  • 텍스처의 투명도 문제는 transparent, alphaTest, depthWrite 속성을 통해 해결할 수 있으며, 상황에 맞는 선택이 중요합니다.
  • blendingvertexColors는 파티클 효과를 한층 더 풍부하고 아름답게 만들어줍니다.
  • 간단한 애니메이션은 CPU로 구현할 수 있지만, 진정한 고성능을 원한다면 최종적으로는 GPU(셰이더)를 활용해야 합니다.

이러한 기법들을 잘 이해하고 있다면 추후에 다룰 셰이더에서의 파티클을 원리를 잘 이해하고 사용할 수 있게 됩니다. 이해가 안된다면 다음에 보고, "엘리스의 토끼굴"에 빠지지 않도록 합시다.