React Three Fiber 프레임 드랍 잡기
Rabbit Hole은 이것저것 붙일수록 끊기기 시작했어요.
스스로 다시 그려지는 터널, 떠다니는 장식, 토끼를 쫓는 카메라.
어느 순간 프레임이 부드럽지 않더라고요.
끊김은 전부 같은 세 가지 습관에서 나왔고, 셋 다 스크린샷엔 안 잡힙니다.
플레이하면서 느낌으로만 와요.
뭐가 원인이었고 각각을 어떻게 고쳤는지 적어봅니다.

주어진 예산
60fps면 한 프레임에 대략 16밀리초가 주어져요.
그 안에 게임 로직이랑 애니메이션이랑 렌더를 다 끝내야 합니다.
useFrame은 매 프레임 그 창 안에서 콜백을 돌리고요.
그래서 진짜 중요한 규칙은 "빠른 코드를 짜라"가 아니라 "가비지 컬렉터를 깨우지 마라"예요.
몇 밀리초짜리 GC 멈춤이 안 좋은 순간에 걸리면 그게 그대로 드랍된 프레임이고, 화면에선 뚝 끊기는 걸로 보입니다.
이렇게 보면 봐야 할 곳이 바뀌어요.
R3F 끊김의 대부분은 느린 수학이 아니라, 매 프레임 할당하는 코드가 만드는 메모리 압력입니다.
1. useFrame 안에서 할당하지 않기
이게 제일 커요.
벡터 연산을 이렇게 쓰는 건 아주 자연스럽죠.
useFrame(() => {
const offset = new THREE.Vector3(x, y, 0).applyQuaternion(frame.quat).add(frame.pos);
mesh.position.copy(offset);
});
여기 new THREE.Vector3()랑 new THREE.Quaternion()은 초당 예순 번, 배치하는 오브젝트 수만큼 매번 새로 할당돼요.
데모에선 멀쩡하지만 실제 씬은 쌓이는 가비지에 서서히 파묻힙니다.
해결은 재사용 객체예요.
한 번만 만들어서 ref에 넣어두고 매 프레임 다시 씁니다.
터널에선 핫패스의 모든 배치가 재사용할 임시 객체 몇 개를 거쳐 가요.
const _v = useRef(new THREE.Vector3());
const _q = useRef(new THREE.Quaternion());
useFrame(() => {
_v.current.set(x, y, 0).applyQuaternion(f.quat).add(f.pos);
mesh.position.copy(_v.current);
mesh.quaternion.copy(f.quat).multiply(_q.current.setFromAxisAngle(Z_AXIS, angle));
});
같은 수학, 새로 만드는 건 없음. .set()은 새 객체를 만드는 대신 이미 있는 걸 덮어써요.
이게 습관이 되면 프레임 루프 안의 new가 잘못돼 보이기 시작합니다.
2. mount/unmount 대신 재활용
두 번째로 아픈 습관은 "세상이 지나간다"를 시야에 들어올 때 메시를 추가하고 나갈 때 제거하는 식으로 표현하는 거예요.
R3F에서 mount/unmount는 React 재조정에 Three.js 객체 생성·해제까지 얹히니, 계속 반복하기엔 너무 무겁습니다.
터널은 그렇게 안 해요.
고정된 벽 세그먼트 여섯 개를 그리고, 플레이어가 내려가면 각 세그먼트를 버리는 대신 새 구간을 가리키게 합니다.
세그먼트는 자기가 맡은 조각이 바뀔 때만, 그것도 값싼 정수 비교 뒤에서 하나만 지오메트리를 다시 만들어요.
if (worldIndex.current !== wi) {
worldIndex.current = wi;
wall.current.geometry.dispose();
wall.current.geometry = new THREE.TubeGeometry(/* ... */);
}
당근이든 장애물이든 파티클이든 "스폰"되는 건 다 같은 방식이에요.
풀을 두고, 숨겼다 다시 배치하고, 아이템마다 mount하지 않기.
그리고 지오메트리를 갈아끼울 땐 이전 걸 dispose 하세요.
안 그러면 세션 내내 GPU 메모리가 샙니다.
3. 프레임 루프에서 React 렌더 걷어내기
미묘한 놈은 useFrame에서 setState를 부르는 거예요.
무해해 보이지만 상태 업데이트 하나하나가 React 리렌더를 예약하고, 그걸 매 프레임 하면 재조정기 전체가 16밀리초 안으로 딸려 들어옵니다.
제가 지키는 규칙은 깔끔한 분리예요.
매 프레임 움직이는 건 ref로 Three.js 객체를 직접 바꿉니다.
position, rotation, scale.
React 상태는 안 건드려요.
React 상태는 사람 속도로 바뀌는 것, 그러니까 점수나 일시정지, 지금 테마 같은 데만 씁니다.
그래서 매 프레임 떠다니는 반딧불은 ref를 바꾸는 거고요.
mesh.position.y += Math.sin(t * 1.2 + phase) * 0.002; // ref 변경, 리렌더 없음
점수는 실제로 바뀔 때만 스토어를 통해 갱신됩니다.
Zustand 같은 스토어에서 값을 읽을 때도 같은 원칙이에요.
필요한 가장 좁은 조각만 구독해서, 무관한 변경이 컴포넌트를 리렌더하지 않게 하세요.
const theme = useGameStore((s) => s.activeTheme); // 테마가 바뀔 때만 리렌더
스토어 전체를 구독하면 코인 하나 주울 때마다 테마만 신경 쓰는 컴포넌트까지 다시 돌아갑니다.
정리하면
세 가지는 접근하는 각도만 다를 뿐 결국 같은 이야기예요.
매 프레임 도는 코드는 이미 만들어 둔 객체만 손대고, 그 안에서 뭔가를 새로 만들지는 않게 하는 것.
벡터는 재사용하고, 메시는 풀에서 꺼내 다시 쓰고, 값은 ref로 바꿉니다.
이렇게 핫패스가 가비지 컬렉터와 React 재조정기에게 일을 떠넘기지 않으면, 프레임을 일정하게 유지하는 일이 한결 수월해져요.