본문 바로가기

React/React

[1편] 3D 웹게임 렌더링 최적화: 33FPS -> 61FPS 성능 개선 사례

들어가며

기본 게임 기능 개발을 완료하고, Chrome DevTools Performance 탭으로 측정해 본 결과는 암담했습니다. JavaScript 실행 시간이 20.9초로 매우 높았고, 큰 번들 사이즈로 인한 초기 로딩 지연이 있었습니다. JavaScript scripting이 전체 실행 시간의 59.6%를 차지했고, 커버리지 분석 결과 상당수의 미사용 코드가 발견되었습니다.

이러한 성능 이슈는 게임플레이에 직접적인 영향을 미쳤습니다. 목표했던 60fps의 부드러운 애니메이션이 30fps 이하로 떨어지는 프레임 드랍 현상이 발생했고, 유저의 게임 경험을 크게 저해했습니다. React Three Fiber, Rapier 물리엔진이 결합된 복잡한 3D 환경에서 이러한 성능 문제를 해결하기 위해 다양한 최적화 방법을 시도했습니다. 이 글에서는 성능 측정부터 최적화 구현, 그리고 그 과정에서 겪은 시행착오와 인사이트를 공유하고자 합니다.

 

성능 저하의 원인 파악

애니메이션 프레임이 25 FPS까지 떨어지는 현상

 

React Three Fiber에서 제공하는 `<Stats />` 컴포넌트를 사용해 실시간 애니메이션 fps를 확인해 본 결과, 33fps에서 적게는 20fps까지 프레임 드랍 현상이 발생하는 것을 확인했습니다. 본인 캐릭터에 비해 다른 캐릭터의 움직임이 다소 부자연스럽게 느껴졌고, 이는 사용자 경험을 저해시키는 요인이 되었습니다. 이 현상이 발생되는 원인을 파악하기 위해, Chrome Performance 측정을 시작했습니다.

 

Chrome Performance 분석

하단의 원형 차트에서 전체 실행 시간(38754ms) 중, 노란색 부분이 Scripting 시간을 나타내며 가장 큰 비중을 차지함을 알 수 있습니다. JavaScript 실행이 너무 많은 시간을 차지하다보니 프레임 드랍이 발생하게 됨을 파악했습니다. 그런데 왜 JavaScript 실행이 오래 걸리면 프레임 드랍이 발생하는걸까요? 주된 이유는 브라우저의 렌더링 파이프라인과 관련이 있습니다.

 

브라우저는 한 프레임을 처리할 때 다음과 같은 순서로 작업을 수행합니다:

  1. JavaScript 실행
  2. Style 계산
  3. Layout 계산
  4. Paint
  5. Composite

60fps를 유지하려면 이 모든 과정이 16.67ms(1000ms/60) 안에 완료되어야 합니다. 하지만 JavaScript 실행이 너무 오래 걸리면 메인 스레드가 블로킹되고, 다음 프레임의 시작을 지연시키게 됩니다. 자연스레 뒤따르는 모든 렌더링 단계도 지연되게 되고, 결과적으로 한 프레임이 16.67ms를 넘기게 되어 프레임 드롭 발생하게 됩니다.

 

상단의 프레임 타임라인을 보면 이를 확인할 수 있습니다. 파란색 수직 막대들이 프레임 실행을 나타내는데, 특히 뒷부분에서 막대 간격이 불규칙하고 높이가 일정하지 않은 것으로 보아 프레임 처리가 불안정함을 알 수 있습니다. 이렇게 일부 프레임에서 긴 실행 시간이 발생하여 프레임 드롭 현상이 있음을 확인할 수 있었습니다.

 

자, 바로 과도한 js scripting 시간을 줄여야함을 확인했습니다. 그런데 이제 이 스크립팅 시간을 어떻게 줄일 수 있을까요?

 

3가지의 병목 가능성

과도한 scripting 시간의 원인이 되는 곳을 확인하기 위해 우선적으로 두 가지를 확인했습니다.

  • CPU 프로파일로 bottom-up 패널에서 가장 많은 시간을 소비한 함수가 무엇인지
  • 게임을 하며 <Stats />로 프레임 드롭이 발생하는 시점 확인

 

이렇게 확인해본 결과, 매 프레임마다 발생하는 물리 연산이 부하를 크게 일으키고 있는 것을 확인했습니다. 특히, 캐릭터가 충돌하고 선물을 뺏는 등의 상호 작용이 발생할 경우, 과도한 scripting으로 인해 더 큰 프레임 드랍이 발생하고 있음을 알 수 있었습니다. 

<Physics>
    <Map scale={0.1} position={[0, 0, 0]} model={`/maps/map.glb`} />
    <ItemBoxes items={gameItems} colors={colors} />
    <Players players={players} localPlayerId={id} mouseSpeed={mouseSpeed} />
</Physics>

 

그래서 첫 번째로는 기본적인 물리연산을 담당하는 Physics 태그에 어떤 attributes가 있는지 확인을 하고 최적화 할 수 있는 부분이 있는지 알아보았습니다. Physics에는 물리연산주기의 timeStep, 최대 연산 단계의 maxSteps, 기본 충돌체를 설정할 수 있는 colliders, 시각적 부드러움을 가능하게 해주는 보간 설정의 interpolate 등이 있는 것을 확인했습니다.

<Physics timeStep={1 / 30} colliders={false} maxSteps={3}>
	// ...
</Physics>

 

이 중 기존 게임성에 영향을 주지 않는 부분을 확인하여 물리 연산의 기본 값이었던 1/60을 반으로 1/30으로 줄이고 보간을 적용했습니다. 그리고 과도한 연산의 또 하나의 원인이 되는 기본 충돌 설정을 false로 변경해주었습니다. 기본 충돌 설정을 false로 바꿔준 대신, 필요한 오브젝트에만(캐릭터, 맵 트리, 집 등) 직접 충돌체를 설정했습니다. 마지막으로 극단적인 상황에서의 성능 저하 방지를 위해 한 프레임당 최대 물리 연산 단계를 3으로 제한했습니다. 이렇게 매 프레임마다 발생하는 기본적인 물리 연산의 부하를 줄였습니다.

 

그리고 기존에는 3개의 분리된 컴포넌트에서 물리 연산이 계속 되고 있었는데요. 두 번째 최적화 작업으로 이를 useCharacterControl이라는 커스텀 훅으로 일원화시켜주었습니다. 하나의 훅에서 모든 물리 연산을 처리하여 중복되는 연산들을 제거해주었습니다. 또한 매 프레임마다 환경 변수를 읽어오는 대신, 전역 상수로 선언하여 참조 비용 절감시켜 성능에 도움이 되도록 했습니다.

const ROTATION_SPEED = degToRad(import.meta.env.VITE_INGAME_ROTATION_SPEED);
const JUMP_FORCE = import.meta.env.VITE_INGAME_JUMP_FORCE;
const MAX_HEIGHT = 30;
const GRAVITY_FORCE =
  import.meta.env.VITE_INGAME_GRAVITY *
  0.016 *
  import.meta.env.VITE_INGAME_EXTRA_GRAVITY;
const DISTANCE_THRESHOLD_SQ = Math.pow(
  import.meta.env.VITE_DISTANCE_THRESHOLD,
  2,
);

const useCharacterControl = ({
  charType,
  rotationTarget,
  mouseControlRef,
  characterRotationTarget,
  isPunching,
  punchAnimationTimer,
  setAnimation,
  giftCnt,
  stolenMotion,
  stealMotion,
  isCurrentlyStolen,
  stolenAnimationTimer,
  position,
  character,
  container,
  eventBlock,
  isSkillActive,
  speed,
}: CharacterControlConfig) => {
  const { updateAnimation, playJumpAnimation, playPunchAnimation } =
    useCharacterAnimation({
      charType,
      stolenMotion,
      isCurrentlyStolen,
      stolenAnimationTimer,
      isPunching,
      punchAnimationTimer,
      stealMotion,
      giftCnt,
      setAnimation,
    });

  const setPlayers = useSetAtom(playersAtom);
  const { id } = useAtomValue(playerInfoAtom);
  const setPlayerRotation = useSetAtom(playerRotationAtom);
  const getControls = useKeyControl();
  const controls = getControls();

  const updateMovement = (rb: RapierRigidBody) => {
    const vel = rb.linvel();
    const pos = rb.translation();
    const isOnGround = Math.abs(vel.y) < 0.1;

    if (isSkillActive) {
      switch (charType) {
        case 1:
          rb.setTranslation(position, true);
          return;
        case 2:
          break;
        case 3:
          break;
        default:
          break;
      }
    }

    // 빼앗기는 상태 처리
    if (stolenMotion && !isCurrentlyStolen.current) {
      updateAnimation(vel);
      return;
    }

    // 스틸 액션 처리
    if (controls.catch && !isPunching.current) {
      playPunchAnimation();
    }

    if (eventBlock !== 0) {
      const zeroVel = { x: 0, y: vel.y, z: 0 };
      rb.setLinvel(zeroVel, true);
      return;
    }

    // 서버 위치 보정
    const distanceSquared =
      Math.pow(position.x - pos.x, 2) + Math.pow(position.z - pos.z, 2);

    if (distanceSquared > DISTANCE_THRESHOLD_SQ) {
      rb.setTranslation(position, true);

      const angle = Math.atan2(position.x - pos.x, position.z - pos.z);
      const speed = Math.sqrt(vel.x * vel.x + vel.z * vel.z);
      vel.x = Math.sin(angle) * speed;
      vel.z = Math.cos(angle) * speed;
      rb.setLinvel(vel, true);

      setPlayers((prev) =>
        prev.map((player) =>
          player.id === id
            ? {
                ...player,
                position: { ...position },
                velocity: { ...vel },
              }
            : player,
        ),
      );
      return;
    }

    const movement = { x: 0, y: 0, z: 0 };
    // 기본 이동 방향 설정
    if (controls.forward) movement.z = 1;
    if (controls.backward) movement.z = -1;
    if (controls.left) movement.x = 1;
    if (controls.right) movement.x = -1;

    // 회전 처리
    if (movement.x !== 0 && !mouseControlRef.current?.isLocked) {
      rotationTarget.current += ROTATION_SPEED * movement.x;
    }

    // 점프 처리
    if (controls.jump) {
      if (pos.y >= MAX_HEIGHT) {
        vel.y += GRAVITY_FORCE;
      } else {
        vel.y = JUMP_FORCE;
      }
      playJumpAnimation();
    } else if (!isOnGround) {
      vel.y += GRAVITY_FORCE;
    }

    // 이동 처리
    const isMoving = movement.x !== 0 || movement.z !== 0;
    if (isMoving) {
      // 캐릭터 회전
      characterRotationTarget.current = Math.atan2(movement.x, movement.z);
      const totalRotation =
        rotationTarget.current + characterRotationTarget.current;
      vel.x = Math.sin(totalRotation) * speed;
      vel.z = Math.cos(totalRotation) * speed;
    }

    updateAnimation(vel);

    if (character.current) {
      character.current.rotation.y = lerpAngle(
        character.current.rotation.y,
        characterRotationTarget.current,
        0.1,
      );
      setPlayerRotation(rotationTarget.current + character.current.rotation.y);
    }

    if (container.current) {
      container.current.rotation.y = MathUtils.lerp(
        container.current.rotation.y,
        rotationTarget.current,
        0.1,
      );
    }
    rb.setLinvel(vel, true);
	
    // 필요한 경우에만 상태 업데이트
    if (pos.x !== position.x || pos.y !== position.y || pos.z !== position.z) {
      setPlayers((prev) =>
        prev.map((player) =>
          player.id === id
            ? {
                ...player,
                position: { x: pos.x, y: pos.y, z: pos.z },
                velocity: { ...vel },
              }
            : player,
        ),
      );
    }
  };
  return { updateMovement };
};

export default useCharacterControl;

 

위의 여러 컴포넌트에 분산된 물리 연산을 하나로 통합시키고, 불필요한 상태 업데이트를 방지하기 위해 매 프레임마다 상태 업데이트를 하던 부분을 위치 변경이 있을 때만 상태를 업데이트하도록 처리해주었습니다.

 

최적화 결과

이렇게 물리 연산 최적화와 커스텀 훅 리팩토링을 통해 매 프레임마다 발생하는 연산량을 크게 줄일 수 있었습니다.

 

- JavaScript 실행 시간 비율: 59.6% → 37.5% (22.1%p 감소)
- 애니메이션 프레임: 33fps → 61fps (84.8% 향상)

특히 유저들이 가장 불편해했던 캐릭터 간 상호작용 시의 프레임 드랍 현상이 크게 개선되어, 더 나은 게임 경험을 제공할 수 있게 되었습니다.

전체 실행 시간에서 37.5%만 차지하는 scripting 비율과 69fps까지 올라가는 개선된 애니메이션 프레임

 

 

그럼에도 불구하고 LCP는 6초 정도로, 초기 페이지가 로드 되는 시간은 줄여지지 않았는데요. 이를 해결하기 위해 라우팅과 동적 import, 트리 쉐이팅, vite 번들링 최적화를 통한 코드 스플리팅을 적용했습니다. 이 최적화 과정은 다음 편에서 이어서 설명하겠습니다!

 

다음 글에서 다룰 초기 로딩 성능 개선을 위한 다음과 같은 최적화 작업들✨

- React Router를 활용한 코드 스플리팅

- Dynamic Import를 통한 지연 로딩

- Tree Shaking을 통한 번들 크기 최적화

- Vite 설정을 통한 효율적인 청크 분할

 
반응형