Skip to main content
민트라

10만 개 파티클 렌더링 - 성능과의 싸움

빅데이터 시각화 프로젝트를 맡았습니다. 요구사항: "10만 개의 데이터 포인트를 3D 공간에 표시하고, 실시간으로 필터링/업데이트 가능해야 함." 처음에는 "그냥 파티클 10만 개 만들면 되지 뭐"라고 생각했습니다. 순진했습니다.

첫 번째 시도: 순진한 접근 #

// 절대 이렇게 하지 마세요
for (let i = 0; i < 100000; i++) {
  const geometry = new THREE.SphereGeometry(0.1);
  const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(
    Math.random() * 100,
    Math.random() * 100,
    Math.random() * 100
  );
  scene.add(mesh);
}

결과: 브라우저 크래시. 10만 개의 Mesh는 10만 번의 draw call을 의미합니다.

두 번째 시도: Points 사용 #

const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(100000 * 3);

for (let i = 0; i < 100000; i++) {
  positions[i * 3] = Math.random() * 100;
  positions[i * 3 + 1] = Math.random() * 100;
  positions[i * 3 + 2] = Math.random() * 100;
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

const material = new THREE.PointsMaterial({
  size: 0.5,
  color: 0xff0000
});

const points = new THREE.Points(geometry, material);
scene.add(points);

결과: 렌더링은 됐지만 문제가 있었습니다.

세 번째 시도: 커스텀 Attributes #

파티클마다 다른 속성을 주기 위해 커스텀 attribute를 추가했습니다.

const positions = new Float32Array(100000 * 3);
const colors = new Float32Array(100000 * 3);
const sizes = new Float32Array(100000);

for (let i = 0; i < 100000; i++) {
  // 위치
  positions[i * 3] = Math.random() * 100;
  positions[i * 3 + 1] = Math.random() * 100;
  positions[i * 3 + 2] = Math.random() * 100;

  // 색상 (데이터 값에 따라)
  const value = data[i].value;
  colors[i * 3] = value;     // R
  colors[i * 3 + 1] = 0;     // G
  colors[i * 3 + 2] = 1 - value; // B

  // 크기
  sizes[i] = data[i].importance * 2;
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));

PointsMaterial은 커스텀 size attribute를 지원하지 않아서 ShaderMaterial을 사용했습니다.

const material = new THREE.ShaderMaterial({
  uniforms: {
    uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) }
  },
  vertexShader: `
    attribute float size;
    attribute vec3 color;
    varying vec3 vColor;

    uniform float uPixelRatio;

    void main() {
      vColor = color;
      vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
      gl_PointSize = size * uPixelRatio * (300.0 / -mvPosition.z);
      gl_Position = projectionMatrix * mvPosition;
    }
  `,
  fragmentShader: `
    varying vec3 vColor;

    void main() {
      // 원형 파티클
      float dist = distance(gl_PointCoord, vec2(0.5));
      if (dist > 0.5) discard;

      gl_FragColor = vec4(vColor, 1.0);
    }
  `,
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending
});

네 번째 문제: 업데이트 성능 #

데이터가 실시간으로 변하면 파티클도 업데이트해야 합니다.

// 매 프레임 업데이트 (느림!)
function updateParticles() {
  const positions = geometry.attributes.position.array;

  for (let i = 0; i < 100000; i++) {
    positions[i * 3] = newData[i].x;
    positions[i * 3 + 1] = newData[i].y;
    positions[i * 3 + 2] = newData[i].z;
  }

  geometry.attributes.position.needsUpdate = true;
}

10만 개 항목을 매 프레임 업데이트하니 CPU가 병목이었습니다.

해결: 부분 업데이트 #

변경된 파티클만 업데이트하도록 수정했습니다.

function updateParticle(index, newPosition) {
  const positions = geometry.attributes.position.array;
  positions[index * 3] = newPosition.x;
  positions[index * 3 + 1] = newPosition.y;
  positions[index * 3 + 2] = newPosition.z;

  // 다음 렌더 전에 한 번만 업데이트
  needsUpdate = true;
}

function animate() {
  if (needsUpdate) {
    geometry.attributes.position.needsUpdate = true;
    needsUpdate = false;
  }
  renderer.render(scene, camera);
}

해결: Web Worker #

대량 계산은 Web Worker로 분리했습니다.

// main.js
const worker = new Worker('particle-worker.js');

worker.postMessage({ type: 'calculate', data: rawData });

worker.onmessage = (e) => {
  const { positions, colors } = e.data;
  geometry.attributes.position.array.set(positions);
  geometry.attributes.color.array.set(colors);
  geometry.attributes.position.needsUpdate = true;
  geometry.attributes.color.needsUpdate = true;
};

// particle-worker.js
self.onmessage = (e) => {
  const { data } = e.data;
  const positions = new Float32Array(data.length * 3);
  const colors = new Float32Array(data.length * 3);

  for (let i = 0; i < data.length; i++) {
    // 복잡한 계산...
    positions[i * 3] = calculateX(data[i]);
    // ...
  }

  self.postMessage({ positions, colors }, [positions.buffer, colors.buffer]);
};

다섯 번째 문제: 필터링 #

사용자가 특정 조건의 파티클만 보고 싶어합니다.

해결: GPU 기반 필터링 #

필터링을 셰이더에서 처리했습니다.

// 필터 조건 전달
material.uniforms.uFilterMin = { value: 0.3 };
material.uniforms.uFilterMax = { value: 0.7 };
// Vertex Shader
attribute float value;
uniform float uFilterMin;
uniform float uFilterMax;

void main() {
  // 필터 범위 밖이면 화면 밖으로
  if (value < uFilterMin || value > uFilterMax) {
    gl_Position = vec4(9999.0, 9999.0, 9999.0, 1.0);
    gl_PointSize = 0.0;
    return;
  }

  // 정상 렌더링
  // ...
}

CPU에서 배열을 필터링하는 것보다 훨씬 빠릅니다.

최종 성능 #

최적화 전:
- 초기 로딩: 12초
- FPS: 5-10
- 필터링: 2초 지연

최적화 후:
- 초기 로딩: 1.5초
- FPS: 55-60
- 필터링: 즉각 반응

핵심 교훈 #

1. Draw call을 줄여라

10만 개의 Mesh 대신 1개의 Points. 이것만으로 성능이 100배 이상 개선됩니다.

2. GPU를 활용해라

복잡한 계산은 셰이더에서. CPU-GPU 데이터 전송을 최소화하세요.

3. 업데이트를 최소화해라

전체 업데이트 대신 부분 업데이트. needsUpdate는 필요할 때만.

4. Worker를 활용해라

무거운 계산은 메인 스레드에서 분리. 렌더링 루프를 막지 마세요.

5. 시각적 타협

10만 개 모두 보일 필요 없습니다. LOD, 클러스터링 등을 고려하세요.

// 거리에 따른 LOD
const distance = camera.position.distanceTo(particleCenter);
if (distance > 100) {
  // 먼 거리: 1/10만 표시
  geometry.setDrawRange(0, 10000);
} else {
  // 가까운 거리: 전체 표시
  geometry.setDrawRange(0, 100000);
}

빅데이터 시각화는 단순히 "많이 그리기"가 아닙니다. 10만 개를 60fps로 그리려면 완전히 다른 접근이 필요합니다.