문제 상황

최근 한 프로젝트에서 3D 건물 모델링 시각화 작업을 진행하면서 심각한 성능 이슈에 직면했습니다.

수천 개의 정점(vertex)과 면(face)으로 구성된 복잡한 건물 모델을 렌더링할 때 프레임 저하 현상이 발생했고, 특히 여러 건물을 동시에 표시할 때는 브라우저가 버벅거리는 현상이 심각했습니다.

초기 구현에서는 THREE.Geometry를 사용했는데, Chrome 개발자 도구의 Performance 패널을 통해 분석해본 결과 다음과 같은 문제점들이 발견되었습니다

  • 초당 프레임이 평균 15FPS 미만으로 떨어짐
  • Garbage Collection이 빈번하게 발생
  • JavaScript 힙 메모리 사용량이 지속적으로 증가
  • GPU 처리 대기 시간이 길어짐

해결 방법: BufferGeometry 도입

이 문제를 해결하기 위해 THREE.BufferGeometry로의 전환을 결정했습니다.
BufferGeometry는 정점 데이터를 TypedArray 형태로 직접 GPU에 전달할 수 있어 메모리 효율성과 렌더링 성능이 훨씬 뛰어납니다.

1. 기존 코드 (THREE.Geometry 사용)

class BuildingVisualizer {
  constructor() {
    this.buildings = [];
    this.scene = new THREE.Scene();
  }

  createBuilding(buildingData) {
    const geometry = new THREE.Geometry();
    
    // 건물의 각 층 데이터를 순회하며 정점과 면 추가
    buildingData.floors.forEach((floor, floorIndex) => {
      const height = floorIndex * FLOOR_HEIGHT;
      
      floor.points.forEach(point => {
        geometry.vertices.push(
          new THREE.Vector3(point.x, height, point.y)
        );
      });

      // 면 추가
      for (let i = 0; i < floor.points.length - 2; i++) {
        geometry.faces.push(new THREE.Face3(
          i, i + 1, i + 2
        ));
      }
    });

    geometry.computeFaceNormals();
    geometry.computeVertexNormals();

    const material = new THREE.MeshPhongMaterial({
      color: 0x156289,
      emissive: 0x072534,
      side: THREE.DoubleSide
    });

    const mesh = new THREE.Mesh(geometry, material);
    this.buildings.push(mesh);
    this.scene.add(mesh);
  }
}

2. 개선된 코드 (BufferGeometry 사용)

class OptimizedBuildingVisualizer {
  constructor() {
    this.buildings = [];
    this.scene = new THREE.Scene();
    this.geometryCache = new Map(); // geometry 재사용을 위한 캐시
  }

  createBufferedBuilding(buildingData) {
    // 캐시된 geometry가 있는지 확인
    const cacheKey = this.generateGeometryCacheKey(buildingData);
    if (this.geometryCache.has(cacheKey)) {
      return this.createInstancedBuilding(this.geometryCache.get(cacheKey), buildingData);
    }

    const vertices = [];
    const normals = [];
    const indices = [];
    let vertexIndex = 0;

    // 건물의 각 층 데이터를 순회하며 버퍼 데이터 생성
    buildingData.floors.forEach((floor, floorIndex) => {
      const height = floorIndex * FLOOR_HEIGHT;
      
      // 각 층의 정점 데이터 생성
      floor.points.forEach(point => {
        vertices.push(point.x, height, point.y);
        normals.push(0, 1, 0); // 기본 법선 벡터
      });

      // 면 인덱스 생성
      for (let i = 0; i < floor.points.length - 2; i++) {
        indices.push(
          vertexIndex + i,
          vertexIndex + i + 1,
          vertexIndex + i + 2
        );
      }

      vertexIndex += floor.points.length;
    });

    const geometry = new THREE.BufferGeometry();
    
    // 버퍼 속성 설정
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
    geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
    geometry.setIndex(indices);
    
    // 법선 벡터 재계산
    geometry.computeVertexNormals();

    // geometry를 캐시에 저장
    this.geometryCache.set(cacheKey, geometry);

    return this.createInstancedBuilding(geometry, buildingData);
  }

  createInstancedBuilding(geometry, buildingData) {
    const material = new THREE.MeshPhongMaterial({
      color: buildingData.color || 0x156289,
      emissive: 0x072534,
      side: THREE.DoubleSide
    });

    const mesh = new THREE.Mesh(geometry, material);
    
    // 위치 및 회전 설정
    mesh.position.set(buildingData.position.x, 0, buildingData.position.z);
    mesh.rotation.y = buildingData.rotation || 0;

    this.buildings.push(mesh);
    this.scene.add(mesh);
    return mesh;
  }

  generateGeometryCacheKey(buildingData) {
    // 건물의 고유한 특성을 기반으로 캐시 키 생성
    return `${buildingData.type}_${buildingData.floors.length}_${buildingData.floors[0].points.length}`;
  }

  // 메모리 정리
  dispose() {
    this.buildings.forEach(building => {
      building.geometry.dispose();
      building.material.dispose();
    });
    this.geometryCache.clear();
  }
}

성능 개선 결과

최적화 작업 후 다음과 같은 성능 향상을 확인할 수 있었습니다

1. 프레임 레이트 개선

  • 최적화 전: 평균 15 FPS
  • 최적화 후: 평균 55 FPS
  • 약 267% 성능 향상

2. 메모리 사용량 감소

  • 최적화 전: 평균 450MB
  • 최적화 후: 평균 180MB
  • 약 60% 메모리 사용량 감소

GPU 처리 시간

  • 최적화 전: 프레임당 평균 45ms
  • 최적화 후: 프레임당 평균 12ms
  • 약 73% 처리 시간 감소

성능 개선 요인 분석

1. BufferGeometry 사용

  • TypedArray를 사용하여 메모리 효율성 향상
  • GPU 직접 전달로 인한 처리 속도 개선

2. Geometry 캐싱

  • 동일한 형태의 건물에 대한 geometry 재사용
  • 메모리 사용량 및 생성 시간 절약

3.최적화된 버퍼 데이터 구조

  • 불필요한 객체 생성 최소화
  • 효율적인 메모리 레이아웃

실제 적용 사례: 도시 계획 시각화 프로젝트

이 최적화 기법을 실제 도시 계획 시각화 프로젝트에 적용했을 때의 결과입니다.

// 실제 프로젝트 사용 예시
const cityVisualizer = new OptimizedBuildingVisualizer();

// 도시 데이터 로드
fetch('/api/city-data')
  .then(response => response.json())
  .then(cityData => {
    // 각 건물 데이터에 대해 시각화 수행
    cityData.buildings.forEach(buildingData => {
      cityVisualizer.createBufferedBuilding(buildingData);
    });
    
    // 성능 모니터링 시작
    const stats = new Stats();
    document.body.appendChild(stats.dom);
    
    // 애니메이션 루프
    function animate() {
      requestAnimationFrame(animate);
      stats.begin();
      renderer.render(scene, camera);
      stats.end();
    }
    animate();
  });

성능 모니터링 코드

class PerformanceMonitor {
  constructor() {
    this.frameCount = 0;
    this.totalFrameTime = 0;
    this.lastFrameTime = performance.now();
    this.memoryUsage = [];
  }

  measure() {
    const currentTime = performance.now();
    const frameTime = currentTime - this.lastFrameTime;
    this.lastFrameTime = currentTime;

    this.frameCount++;
    this.totalFrameTime += frameTime;

    if (window.performance && window.performance.memory) {
      this.memoryUsage.push(window.performance.memory.usedJSHeapSize / 1048576);
    }

    // 매 100프레임마다 통계 출력
    if (this.frameCount % 100 === 0) {
      this.printStats();
    }
  }

  printStats() {
    const avgFrameTime = this.totalFrameTime / this.frameCount;
    const fps = 1000 / avgFrameTime;
    const avgMemory = this.memoryUsage.reduce((a, b) => a + b, 0) / this.memoryUsage.length;

    console.log(`
      Performance Stats:
      Average FPS: ${fps.toFixed(2)}
      Average Frame Time: ${avgFrameTime.toFixed(2)}ms
      Average Memory Usage: ${avgMemory.toFixed(2)}MB
    `);
  }
}

결론 및 교훈

성능과 코드 품질의 균형

  • BufferGeometry는 코드가 더 복잡해질 수 있지만, 성능 향상이 필요한 경우 충분한 가치가 있습니다.
  • 적절한 추상화와 캐싱 전략으로 코드 복잡도를 관리할 수 있습니다.

메모리 관리의 중요성

  • 3D 애플리케이션에서 메모리 관리는 매우 중요합니다.
  • dispose() 메서드를 통한 명시적인 메모리 정리가 필요합니다.

성능 모니터링의 필요성

  • 지속적인 성능 모니터링을 통해 최적화의 효과를 측정하고 검증해야 합니다.
  • 실제 사용 환경에서의 테스트가 중요합니다.

이러한 최적화 작업을 통해 복잡한 3D 시각화 프로젝트에서도 안정적인 성능을 확보할 수 있었습니다.

특히 BufferGeometry의 도입은 대규모 3D 데이터를 다루는 웹 애플리케이션에서 필수적인 최적화 전략임을 확인할 수 있었습니다.

출처: Three.js Geometries Core

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글