본문 바로가기
AI에서 살아남기(to. Android Developer)/1인 프로젝트

[Virgin road] React Bits 의 Ballpit 코드를 수정해보자.

by 개발자 인민군 2026. 3. 9.

결혼 축하 편지를 남기는 플랫폼이다. 방 페이지에서 하객들이 작성한 편지마다 그들의 이름이 새겨진 공이 둥실둥실 떠다니는 모습을 보여주고 싶었다. 마치 축제의 풍선처럼, 행복한 순간들이 공중을 떠다니는 느낌을 표현하기 위해서죠.

이를 구현하기 위해 오픈소스로 공개된 Three.js 기반의 물리 시뮬레이션 Ballpit 컴포넌트(링크)를 활용했다. 하지만 그대로 적용하기엔 

여러가지 문제가 있었다.

 

1. 공에 편지 작성자 이름 추가

2. 이벤트 리스너 메모리 누수

3. W 클래스 (물리 엔진) - Vector3 객체 풀링

4. Z 클래스 (InstancedMesh) - NDC 연산 버퍼 사전할당

5. maxBoundary 중복 계산 제거

 

이 글에서는 이 문제들을 어떻게 해결했는지, 그리고 추가로 진행한 최적화 작업들을 정리하겠다.

 


1. 기능 추가 - 공에 편지 작성자 이름 추가

1-1. CSS2DRenderer 통합 (라벨 시스템)

왜 CSS2DRenderer인가?

Three.js로 텍스트를 렌더링하는 방법은 크게 세 가지이다.

  • TextGeometry — 3D 메시로 텍스트 생성. 폰트 로딩 필요, 무겁고 복잡함
  • Canvas Texture — Canvas에 텍스트 그린 후 Sprite에 붙임. 해상도 문제 있음
  • CSS2DRenderer — HTML DOM 요소를 3D 좌표에 오버레이. 가볍고 CSS 스타일 자유롭게 적용 가능하다

내 메인 화면의 공의 갯수는 30개 제한(많을수록 충돌 계산이 많아져 부화가 많이 생김, 모바일 전용 web이기 때문에 CPU가 높지 않음) 으로 두었기 때문에 CSS2DRenderer 가 가장 적합했다.

[canvas (WebGLRenderer)] ← Three.js 3D 렌더링
[div (CSS2DRenderer)] ← HTML 요소 오버레이

두 레이어가 겹쳐야 하기 때문에 position: absolute로 CSS2DRenderer의 div를 canvas 위에 정확히 올린다.

구현 흐름

1. X 클래스 생성자에서 CSS2DRenderer 초기화

this.labelRenderer = new CSS2DRenderer();
this.labelRenderer.setSize(this.canvas.clientWidth, this.canvas.clientHeight);
this.labelRenderer.domElement.style.position = 'absolute';
this.labelRenderer.domElement.style.top = '0';
this.labelRenderer.domElement.style.left = '0';
this.labelRenderer.domElement.style.pointerEvents = 'none'; // 마우스 이벤트 통과
if (this.canvas.parentNode) {
  this.canvas.parentNode.appendChild(this.labelRenderer.domElement);
}

pointerEvents: 'none'으로 해주자. 이유는 라벨 div가 마우스 이벤트를 가로채면 공 인터랙션이 안 되기 때문이다.

2. _render()에서 두 렌더러 동시 호출

private _render() {
  this.renderer.render(this.scene, this.camera);      // WebGL 렌더링
  this.labelRenderer.render(this.scene, this.camera); // CSS2D 렌더링
}

매 프레임 같은 camera 기준으로 두 렌더러가 동기화된다.

3. 리사이즈 시 CSS2DRenderer도 함께 크기 업데이트

private _updateRenderer() {
  this.renderer.setSize(this.size.width, this.size.height);
  this.labelRenderer.setSize(this.size.width, this.size.height); // 추가
  // ...
}

4. dispose 시 DOM 요소 직접 제거

private _setupLabels(labels: string[]) {
  const startIdx = this.config.followCursor ? 1 : 0;
  for (let i = startIdx; i < this.config.count; i++) {
    const labelText = labels[i - startIdx] ?? '';

    const div = document.createElement('div');
    div.textContent = labelText;
    div.style.cssText = `
      color: white;
      font-size: 12px;
      pointer-events: none;
      white-space: nowrap;
    `;

    const labelObject = new CSS2DObject(div); // div를 Three.js Object3D로 감쌈
    this.labelContainer.add(labelObject);
    this.labelObjects.push(labelObject);
  }
}

CSS2DRenderer는 DOM을 직접 다루기 때문에 renderer.dispose()만으로는 정리가 안 된다. 명시적으로 제거해야 메모리 누수가 없다.

5. Z 클래스에서 CSS2DObject 생성

private _setupLabels(labels: string[]) {
  const startIdx = this.config.followCursor ? 1 : 0;
  for (let i = startIdx; i < this.config.count; i++) {
    const labelText = labels[i - startIdx] ?? '';

    const div = document.createElement('div');
    div.textContent = labelText;
    div.style.cssText = `
      color: white;
      font-size: 12px;
      pointer-events: none;
      white-space: nowrap;
    `;

    const labelObject = new CSS2DObject(div); // div를 Three.js Object3D로 감쌈
    this.labelContainer.add(labelObject);
    this.labelObjects.push(labelObject);
  }
}
 

CSS2DObject는 HTML 요소를 Three.js의 Object3D처럼 씬에 추가할 수 있게 해주는 래퍼이다. CSS2DRenderer가 매 프레임 이 객체의 3D 위치를 화면 좌표로 변환해서 DOM 요소의 transform을 업데이트한다.

6. 매 프레임 라벨 위치 동기화

update(deltaInfo) {
  // ... 물리 업데이트 ...
  for (let idx = 0; idx < this.count; idx++) {
    // ... 공 위치 업데이트 ...
    
    // 라벨 위치를 공의 물리 위치와 동기화
    if (idx >= startIdx && this.labelObjects[idx - startIdx]) {
      this.labelObjects[idx - startIdx].position.fromArray(
        this.physics.positionData, 3 * idx
      );
    }
  }
}

 

2-1. 라벨 가림 판정 (Occlusion Culling)

위에서 개발했던 CSS2DRenderer는 자체 Occlusion Culling이 없기 때문에 3D 공간에서 공이 앞뒤로 겹칠 때, 앞의 공에 가려진 뒷 공의 라벨이 그대로 보이면 시각적으로 어색하게 보이는 이슈가 있다. 해서 

NDC(Normalized Device Coordinates) 좌표계에서 2D 원 겹침을 판정한다. 해서 모든 공에 NDC 좌표를 할당하고 누가 앞에 있는지 판별한다.

3D 월드 좌표 → NDC 좌표 (-1 ~ +1)

NDC에서는 카메라 기준 정규화된 2D 평면이므로 "앞의 공이 뒷 공의 라벨 위치를 덮는가"를 원의 포함 관계로 판정할 수 있다.

구현 흐름

1. 생성자에서 버퍼 사전 할당

this._projX = new Float32Array(config.count);   // NDC X 좌표
this._projY = new Float32Array(config.count);   // NDC Y 좌표
this._projZ = new Float32Array(config.count);   // NDC Z (깊이)
this._projR = new Float32Array(config.count);   // NDC 반경
this._projRSq = new Float32Array(config.count); // NDC 반경² (최적화)

매 프레임 배열을 새로 만들면 GC 부하가 생기기 때문에 미리 할당한다.

2. 1단계: 모든 공의 NDC 좌표 + 반경 계산

updateLabelDepth(camera: PerspectiveCamera) {
  const f = camera.projectionMatrix.elements[5]; // 1 / tan(fov/2)

  for (let i = 0; i < this.count; i++) {
    // 3D 좌표를 NDC로 변환
    this._projVec.fromArray(this.physics.positionData, 3 * i);
    this._projVec.project(camera); // 핵심: 월드 → NDC

    this._projX[i] = this._projVec.x;
    this._projY[i] = this._projVec.y;
    this._projZ[i] = this._projVec.z; // -1(가까움) ~ +1(멀음)

    // NDC 반경 계산 (원근 투영 공식)
    // r_ndc = r_world * f / distance_to_camera
    const camDist = Math.abs(worldZ - camera.position.z);
    this._projR[i] = camDist > 0.01
      ? (this.physics.sizeData[i] * f) / camDist
      : 0;
    this._projRSq[i] = this._projR[i] * this._projR[i]; // 제곱 사전 계산
  }

projectionMatrix.elements[5]가 1 / tan(fov/2)인 이유는 Three.js의 투영 행렬 구조 때문이다. 이 값으로 월드 반경을 NDC 반경으로 변환한다.

2. 2단계: 가림 판정

for (let idx = startIdx; idx < this.count; idx++) {
    let occluded = false;

    for (let jdx = startIdx; jdx < this.count; jdx++) {
      if (jdx === idx) continue;
      
      // NDC Z가 작을수록 카메라에 가까움
      // 내(idx)보다 멀리 있는 공(jdx)은 가릴 수 없으므로 스킵
      if (this._projZ[jdx] >= this._projZ[idx]) continue;

      // 2D 거리² 계산 (sqrt 생략 - 성능 최적화)
      const dx = this._projX[jdx] - this._projX[idx];
      const dy = this._projY[jdx] - this._projY[idx];
      const dist2DSq = dx * dx + dy * dy;

      // 가리는 공의 반경² 안에 내 라벨 중심이 들어오면 가려짐
      if (dist2DSq < this._projRSq[jdx]) {
        occluded = true;
        break; // 하나라도 가리면 더 검사할 필요 없음
      }
    }

    // DOM 변경은 실제로 바뀔 때만
    const newOpacity = occluded ? '0' : '1';
    if (label.element.style.opacity !== newOpacity) {
      label.element.style.opacity = newOpacity;
    }
  }
}

 

sqrt 대신 제곱끼리 비교하는 것이 좋다. 이유는 dist < r은 dist² < r²과 동치이므로 sqrt 연산을 생략해서 성능을 높인다.


2. 버그 수정 - bind()와 이벤트 리스너 메모리 누수

Three.js 렌더링 환경 전체를 관리하는 매니저 클래스인 class X 는 반복적인 보일러플레이트를 하나로 묶어서 추상화한 클래스이다.

먼저 이전 개발되어 있는 코드를 보자.

class X {
  // 바인딩된 함수를 저장하는 필드가 없음

  #initObservers() {
    // 등록할 때 bind() 호출 → 새 함수 A 생성
    window.addEventListener('resize', this.#onResize.bind(this));
    document.addEventListener('visibilitychange', this.#onVisibilityChange.bind(this));
  }

  #onResizeCleanup() {
    // 제거할 때 bind() 또 호출 → 새 함수 B 생성
    // A ≠ B 이므로 removeEventListener가 아무것도 못 지움
    window.removeEventListener('resize', this.#onResize.bind(this));
    document.removeEventListener('visibilitychange', this.#onVisibilityChange.bind(this));
  }
}

removeEventListener는 등록할 때 넘긴 함수와 완전히 동일한 참조여야 제거된다.

bind()는 호출할 때마다 새로운 함수 객체를 반환하기 때문에, 등록할 때의 함수와 제거할 때의 함수가 겉보기엔 같아 보여도 실제로는 다른 객체이다.

this.#onResize.bind(this) === this.#onResize.bind(this) // false

 

그래서 원본 코드에서는 removeEventListener를 호출해도 실제로는 아무것도 제거되지 않았고, 결과적으로 세 가지 문제가 발생했다.

리스너 누적

컴포넌트가 마운트/언마운트를 반복할수록 리스너가 쌓여서 resize 이벤트 발생 시 콜백이 여러 번 실행된다.

메모리 누수

리스너가 this를 참조하고 있기 때문에 컴포넌트가 언마운트되어도 Three.js의 renderer, scene, geometry 같은 무거운 객체들이 GC(가비지 컬렉션)되지 못하고 메모리에 계속 남는다.

React StrictMode 문제

개발 환경에서 StrictMode는 useEffect를 mount → unmount → mount 순으로 두 번 실행하는데, 리스너가 제거되지 않으면 중복 등록이 발생해서 렌더링 버그로 이어진다.

수정 후 코드는 다음과 같다.

class X {
  // 클래스 필드에 바인딩된 함수를 딱 한 번만 생성해서 저장
  private _boundOnResize = () => this._onResize();
  private _boundOnVisibilityChange = () => this._onVisibilityChange();

  private _initObservers() {
    // 저장된 참조(A)로 등록
    window.addEventListener('resize', this._boundOnResize);
    document.addEventListener('visibilitychange', this._boundOnVisibilityChange);
  }

  private _onResizeCleanup() {
    // 동일한 참조(A)로 제거 → 정확히 제거됨
    window.removeEventListener('resize', this._boundOnResize);
    document.removeEventListener('visibilitychange', this._boundOnVisibilityChange);
  }
}

3-1. 성능 개선 - Vector3 객체 풀링 (W 클래스)

문제: 매 프레임 객체 생성

원본 코드의 update() 루프를 보면:

// 원본 - 매 프레임, 공 개수만큼 반복
update(deltaInfo) {
  for (let idx = 0; idx < config.count; idx++) {
    const pos = new Vector3().fromArray(positionData, base); // 매번 생성
    const vel = new Vector3().fromArray(velocityData, base); // 매번 생성

    for (let jdx = idx + 1; jdx < config.count; jdx++) {
      const otherPos = new Vector3().fromArray(positionData, otherBase); // 매번 생성
      const otherVel = new Vector3().fromArray(velocityData, otherBase); // 매번 생성
      const diff = new Vector3().copy(otherPos).sub(pos);               // 매번 생성
      const correction = diff.normalize().multiplyScalar(0.5 * overlap); // 매번 생성
      const velCorrection = correction.clone().multiplyScalar(...);       // 매번 생성
    }
  }
}

공이 30개라면 충돌 검사 루프는 최대 `30 × 29 / 2 = 435`번 실행됩니다. 매 프레임(60fps 기준)마다:

435번 × 7개 Vector3 = 3,045개 객체 생성/프레임
3,045 × 60fps = 182,700개 객체/초 생성 후 즉시 버림

이렇게 생성된 객체들은 GC(가비지 컬렉터)가 주기적으로 청소해야 합니다. GC가 실행되는 순간 렌더링이 잠깐 멈추는 GC 스파이크(프레임 드랍) 가 발생합니다.

이를 해결하기 위해 클래스 필드에 미리 생성 후 재사용한다.

이렇게 하면 프로그램 전체 생명주기 동안 계속 재사용 한다.

// 개선 - 클래스 생성 시 딱 한 번만 생성
class W {
  private _pos = new Vector3();
  private _vel = new Vector3();
  private _otherPos = new Vector3();
  private _otherVel = new Vector3();
  private _diff = new Vector3();
  private _correction = new Vector3();
  private _velCorrection = new Vector3();

  update(deltaInfo) {
    // 구조분해로 짧은 이름으로 꺼내서 사용
    const { _pos: pos, _vel: vel, _otherPos: otherPos,
            _diff: diff, _correction: correction } = this;

    for (let idx = 0; idx < config.count; idx++) {
      // new 없이 기존 객체에 값만 덮어씀
      pos.fromArray(positionData, base);
      vel.fromArray(velocityData, base);

      for (let jdx = idx + 1; jdx < config.count; jdx++) {
        otherPos.fromArray(positionData, otherBase);
        diff.copy(otherPos).sub(pos); // 새 객체 생성 없이 재사용
        correction.copy(diff).normalize().multiplyScalar(0.5 * overlap);
      }
    }
  }
}

 

Vector3 재사용이 가능한 이유는 fromArray, copy 등은 내부 값(x, y, z)만 바꾸고 객체 자체는 그대로 유지되기 때문이다.

pos.fromArray([1, 2, 3]); // pos.x=1, pos.y=2, pos.z=3
pos.fromArray([4, 5, 6]); // pos.x=4, pos.y=5, pos.z=6 (같은 객체)

3-2. Float32Array 버퍼 사전 할당 (Z 클래스)

위에서 NDC 연산 을 하여 공이 어떤것이 가려진지 판별한 뒤 이름을 가리는 기능을 추가했었다.

그러나 이 기능을 성능 생각없이 개발하게 된다면 다음과 같다.

updateLabelDepth(camera) {
  // 매 프레임 새 배열 생성 - GC 대상
  const projX = new Array(this.count);
  const projY = new Array(this.count);
  const projZ = new Array(this.count);
  const projR = new Array(this.count);

  // 1단계: NDC 좌표 계산
  for (let i = 0; i < this.count; i++) {
    const vec = new Vector3(); // 또 매 프레임 생성
    vec.fromArray(this.physics.positionData, 3 * i);
    vec.project(camera);
    
    projX[i] = vec.x;
    projY[i] = vec.y;
    projZ[i] = vec.z;
    projR[i] = this.physics.sizeData[i] / camDist;
  }

  // 2단계: 가림 판정
  for (let idx = 0; idx < this.count; idx++) {
    for (let jdx = 0; jdx < this.count; jdx++) {
      const dx = projX[jdx] - projX[idx];
      const dy = projY[jdx] - projY[idx];
      const dist2DSq = dx * dx + dy * dy;
      
      // 매번 곱셈 발생
      if (dist2DSq < projR[jdx] * projR[jdx]) { // ← 매번 계산
        occluded = true;
      }
    }
  }
}
// 함수 끝나면 projX, projY, projZ, projR 전부 GC 대상

 

이렇게 개발한다면 다음과 같다.

60fps 기준 1초에
- 배열 4개 × 60 = 240개 생성 후 버림 → GC 부하
- Vector3 객체 30 × 60 = 1,800개 생성 후 버림 → GC 부하
- 반경 곱셈 435 × 60 = 26,100번 → 불필요한 연산

수정후 개선 코드는 다음과 같다.

1. 생성자에서 한 번만 할당

class Z extends InstancedMesh {
  // 타입 선언 (! = 나중에 반드시 초기화됨을 보장)
  private _projVec = new Vector3(); // Vector3도 재사용
  private _projX!: Float32Array;
  private _projY!: Float32Array;
  private _projZ!: Float32Array;
  private _projR!: Float32Array;
  private _projRSq!: Float32Array; // ← 제곱값 전용 배열 추가

  constructor(renderer, params) {
    super(...);
    // 공 개수만큼 고정 크기로 딱 한 번 할당
    this._projX   = new Float32Array(config.count);
    this._projY   = new Float32Array(config.count);
    this._projZ   = new Float32Array(config.count);
    this._projR   = new Float32Array(config.count);
    this._projRSq = new Float32Array(config.count);
    //              ↑ 연속 메모리 블록, GC 관리 안 받음
  }

2. 1단계: NDC 계산 + 제곱값 사전 저장

updateLabelDepth(camera: PerspectiveCamera) {
    // projectionMatrix.elements[5] = 1/tan(fov/2)
    // 원근 투영에서 월드 크기 → NDC 크기 변환 계수
    const f = camera.projectionMatrix.elements[5];

    for (let i = 0; i < this.count; i++) {
      const worldZ = this.physics.positionData[3 * i + 2];
      
      // 재사용 Vector3에 값만 덮어씀 (new 없음)
      this._projVec.fromArray(this.physics.positionData, 3 * i);
      this._projVec.project(camera); // 3D → NDC 변환
      
      // 기존 배열에 값만 덮어씀 (메모리 할당 없음)
      this._projX[i] = this._projVec.x;
      this._projY[i] = this._projVec.y;
      this._projZ[i] = this._projVec.z; // -1=가까움, +1=멀음

      // NDC 반경 계산: 월드 반경 * f / 카메라 거리
      const camDist = Math.abs(worldZ - camera.position.z);
      this._projR[i] = camDist > 0.01
        ? (this.physics.sizeData[i] * f) / camDist
        : 0;
      
      // 제곱값도 여기서 미리 계산해서 저장
      this._projRSq[i] = this._projR[i] * this._projR[i];
      //                  ↑ 30번만 실행 (비싼 곳이 아님)
    }

3. 2단계: 가림 판정 (여기서 _projRSq의 진가 발휘)

 for (let idx = startIdx; idx < this.count; idx++) {
      const label = this.labelObjects[idx - startIdx];
      let occluded = false;

      for (let jdx = startIdx; jdx < this.count; jdx++) {
        if (jdx === idx) continue;
        
        // NDC Z: 값이 작을수록 카메라에 가까움
        // 나(idx)보다 멀리 있는 공은 가릴 수 없으니 스킵
        if (this._projZ[jdx] >= this._projZ[idx]) continue;

        const dx = this._projX[jdx] - this._projX[idx];
        const dy = this._projY[jdx] - this._projY[idx];
        const dist2DSq = dx * dx + dy * dy;

        // 미리 계산된 제곱값 그냥 읽기만 함
        if (dist2DSq < this._projRSq[jdx]) {
          //             ↑ 435번 실행되는데 곱셈 없이 읽기만
          occluded = true;
          break; // 하나라도 가리면 즉시 탈출
        }
      }

      // 실제로 바뀔 때만 DOM 업데이트
      const newOpacity = occluded ? '0' : '1';
      if ((label.element as HTMLElement).style.opacity !== newOpacity) {
        (label.element as HTMLElement).style.opacity = newOpacity;
      }
    }
  }
 

이렇게 할시 효과는 다음과 같다.

  수정 전 수정 후
배열 생성/프레임 4개 생성 후 버림 0개 (재사용)
Vector3 생성/프레임 30개 생성 후 버림 0개 (재사용)
GC 대상 객체 매 프레임 34개 0개
곱셈 횟수/프레임 435번 30번 (1단계에서만)

3-2. maxBoundary 중복 계산 제거

maxBoundary는 물리 시뮬레이션에서 공이 Z축(깊이) 방향으로 벽에 부딪히는 경계값이다.

maxX: 좌우 경계
maxY: 상하 경계
maxZ: 앞뒤 경계 (Z축)

maxBoundary = Math.max(maxZ, maxSize)
↑ Z 경계와 공 최대 크기 중 더 큰 값

 

수정전은 다음과 같다. 이렇게 한다면 공 30개 × 60fps = 1,800번/초 씩 Math.max() 호출이 1,800번 실행 된다.

for (let idx = 0; idx < config.count; idx++) {
  // ... 위에서 X, Y 경계 처리 ...

  // 루프 안에서 매번 계산
  const maxBoundary = Math.max(config.maxZ, config.maxSize);
  //                  ↑ config.maxZ, config.maxSize는
  //                    루프 안에서 절대 안 바뀜
  //                    그런데 매번 계산하고 있음

  if (Math.abs(pos.z) + radius > maxBoundary) {
    pos.z = Math.sign(pos.z) * (config.maxZ - radius);
    vel.z = -vel.z * config.wallBounce;
  }
}

수정 후 는 다음과 같다. Math.max() 호출: 1번만 호출하여 이후 1,799번은 그냥 변수 읽기만 한다.

Java나 C++ 컴파일러는 이런 최적화를 자동으로 해준다. 하지만 JavaScript는 동적 언어라서 엔진이 항상 보장하지 못한다.

루프 안에서 config.maxZ가 바뀔 수도 있을 수 있다고 판단하기 때문에 자동 최적화를 해주지 못한다.

// 루프 밖에서 한 번만 계산
const maxBoundary = Math.max(config.maxZ, config.maxSize);
//                  ↑ 딱 1번만 실행

for (let idx = 0; idx < config.count; idx++) {
  // ... 위에서 X, Y 경계 처리 ...

  // 그냥 읽기만 함
  if (Math.abs(pos.z) + radius > maxBoundary) {
    pos.z = Math.sign(pos.z) * (config.maxZ - radius);
    vel.z = -vel.z * config.wallBounce;
  }
}

 


끝으로

React Bits 의 BallPit(링크) 라이브러리는 굉장히 잘 만든 라이브러리이다. (난 솔직히 만들라 해도 못만든다;;;)

이 라이브러리를 수정 개선하면서 나만의 서비스에 사용하여 더 재미있는 서비스를 만들어서 고마운 라이브러리이다.

 

서비스를 사용해보고 싶다면 이링크로 들어오시면 됩니다!

https://virginroad.store/

 

 

 


참고