Skip to main content
민트라

모바일에서 60fps 유지하기 - 3D 랜딩페이지 최적화 삽질기

"데스크톱에서 확인했을 때는 완벽했는데요..." 클라이언트에게 이 말을 하고 싶지 않았습니다. 하지만 현실은 냉혹했죠. 제가 만든 3D 랜딩페이지가 클라이언트의 아이폰에서 슬라이드쇼처럼 버벅거렸습니다.

문제 발견 #

프로젝트 마감 이틀 전이었습니다. 최종 확인을 위해 제 폰으로 테스트했는데, 스크롤할 때마다 화면이 끊겼습니다. Chrome DevTools의 Performance 탭을 열어보니 프레임 타임이 100ms를 넘기고 있었습니다. 목표는 16ms(60fps)인데요.

측정 결과:
- 데스크톱 (M1 Mac): 60fps, 프레임 타임 ~8ms
- 아이폰 13: 8-12fps, 프레임 타임 80-120ms
- 갤럭시 S21: 15-20fps, 프레임 타임 50-65ms

원인 분석 #

1. 과도한 드로우 콜 #

renderer.info를 확인해보니 드로우 콜이 450개였습니다.

console.log(renderer.info.render.calls);  // 450

씬에 있는 객체가 50개도 안 되는데 드로우 콜이 450개? 알고 보니 텍스트 메시가 문제였습니다.

// 문제의 코드
const textGeometry = new TextGeometry('Hello World', {
  font: font,
  size: 1,
  height: 0.2
});
// 글자 하나당 별도의 지오메트리... 11글자 = 11 드로우 콜

해결책은 BufferGeometry를 머지하거나, 스프라이트 텍스트를 사용하는 것이었습니다.

// 해결: HTML 오버레이 사용
// 3D 텍스트 대신 CSS로 텍스트를 띄움
const textDiv = document.createElement('div');
textDiv.className = 'floating-text';
textDiv.textContent = 'Hello World';
document.body.appendChild(textDiv);

// CSS2DRenderer로 3D 공간에 배치
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';

2. 실시간 그림자 #

실시간 그림자가 성능을 잡아먹고 있었습니다.

// 문제: 모든 조명에서 그림자 계산
directionalLight.castShadow = true;
pointLight1.castShadow = true;
pointLight2.castShadow = true;

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

모바일에서는 그림자를 끄거나, 베이킹된 그림자 텍스처를 사용해야 했습니다.

// 해결: 모바일에서 그림자 비활성화
const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);

if (isMobile) {
  renderer.shadowMap.enabled = false;
  // 베이킹된 그림자 텍스처 사용
  const shadowTexture = textureLoader.load('/textures/baked-shadow.png');
  floor.material.map = shadowTexture;
}

3. 과도한 픽셀 비율 #

레티나 디스플레이를 고려해서 devicePixelRatio를 그대로 사용했는데, 이게 문제였습니다.

// 문제: 아이폰은 devicePixelRatio가 3
renderer.setPixelRatio(window.devicePixelRatio);
// 1920x1080 뷰포트 → 실제 렌더링: 5760x3240

픽셀이 9배나 많아지면 GPU가 힘들어합니다.

// 해결: 최대 2로 제한
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

4. 불필요한 업데이트 #

매 프레임마다 모든 것을 업데이트하고 있었습니다.

// 문제: 정적인 객체도 매 프레임 업데이트
function animate() {
  staticObjects.forEach(obj => {
    obj.updateMatrix();  // 불필요
  });
  renderer.render(scene, camera);
}

정적 객체는 matrixAutoUpdate를 끄고, 필요할 때만 업데이트해야 합니다.

// 해결
staticObjects.forEach(obj => {
  obj.matrixAutoUpdate = false;
  obj.updateMatrix();  // 초기화 시 한 번만
});

최적화 적용 후 #

개선 결과:
- 드로우 콜: 450 → 35
- 아이폰 13: 8fps → 55fps
- 갤럭시 S21: 15fps → 60fps

추가로 적용한 것들 #

LOD (Level of Detail) #

const lod = new THREE.LOD();

// 고품질 (가까울 때)
lod.addLevel(highDetailMesh, 0);
// 중간 품질 (중간 거리)
lod.addLevel(mediumDetailMesh, 10);
// 저품질 (멀 때)
lod.addLevel(lowDetailMesh, 20);

scene.add(lod);

프러스텀 컬링 확인 #

Three.js는 기본적으로 프러스텀 컬링을 하지만, 큰 객체가 잘못된 바운딩 박스를 가지면 문제가 됩니다.

// 바운딩 박스 수동 계산
mesh.geometry.computeBoundingBox();
mesh.geometry.computeBoundingSphere();

조건부 렌더링 #

화면에 보이지 않을 때는 렌더링을 멈춥니다.

let isVisible = true;

const observer = new IntersectionObserver((entries) => {
  isVisible = entries[0].isIntersecting;
});
observer.observe(canvas);

function animate() {
  if (isVisible) {
    renderer.render(scene, camera);
  }
  requestAnimationFrame(animate);
}

배운 점 #

1. 모바일 먼저 테스트

개발 초기부터 모바일에서 테스트했어야 합니다. 마감 직전에 발견하면 시간이 부족합니다.

2. 성능 예산 설정

프로젝트 시작 시 "드로우 콜 50개 이하, 삼각형 100만 개 이하" 같은 예산을 정하세요.

3. 점진적 향상

모바일 버전을 먼저 만들고, 데스크톱에서 효과를 추가하는 것이 효율적입니다.

4. 측정, 측정, 측정

"느린 것 같다"가 아니라 수치로 측정하세요. stats.js나 renderer.info를 활용하세요.

import Stats from 'three/addons/libs/stats.module.js';

const stats = new Stats();
document.body.appendChild(stats.dom);

function animate() {
  stats.begin();
  renderer.render(scene, camera);
  stats.end();
  requestAnimationFrame(animate);
}

결국 마감 하루를 남기고 60fps를 달성했습니다. 클라이언트에게 "모바일에서도 완벽합니다"라고 자신 있게 말할 수 있었죠. 이후로는 모든 3D 프로젝트에서 첫날부터 모바일 테스트를 합니다.