Unity FixedUpdate 물리 기반 캐릭터 연산 병목 해소: 프레임 안정화 실전 가이드

Unity FixedUpdate 물리 기반 캐릭터 연산 병목 해소: 프레임 안정화 실전 가이드

Unity FixedUpdate에서 물리 기반 캐릭터가 늘어날 때 발생하는 CPU 병목의 원인을 진단하고 Rigidbody 처리 분리·NonAlloc 쿼리·갱신 주기 제어로 물리 프레임을 안정화하는 방법을 정리합니다.

물리 기반 캐릭터의 FixedUpdate 병목은 대개 ‘캐릭터 수 × 물리 쿼리 수 × 고정 시간당 실행 횟수’가 동시에 증가하면서 발생한다. 먼저 Unity Profiler로 Physics.Simulate, Physics.Raycast, Character.FixedUpdate의 비용을 분리해 확인하고 매 틱이 필요 없는 판단은 주기적으로 낮추며 할당 없는 물리 쿼리로 바꾸는 것이 가장 효과적이다.

FixedUpdate 물리 프레임 병목은 왜 발생하는가?

Unity의 FixedUpdate는 렌더 프레임과 별개로 Time.fixedDeltaTime 간격마다 실행된다. 기본값인 0.02초에서는 초당 최대 50회 실행된다. 렌더링이 잠깐 밀리면 Unity는 누적된 물리 시간을 따라잡기 위해 한 프레임에 FixedUpdate를 여러 번 호출할 수 있다.

물리 기반 캐릭터가 매 물리 틱마다 지면 확인, 장애물 감지, 경사 계산, 주변 대상 탐색을 수행하면 호출량은 빠르게 커진다.

QueriesPerSecond=CharacterCount×QueriesPerTick×1fixedDeltaTime\text{QueriesPerSecond} = \text{CharacterCount} \times \text{QueriesPerTick} \times \frac{1}{\text{fixedDeltaTime}}

예를 들어 NPC 100명이 틱마다 Raycast 3회와 OverlapSphere 1회를 실행하면 기본 설정에서 초당 최대 20,000회의 물리 쿼리가 발생한다. 여기에는 실제 Rigidbody 시뮬레이션과 충돌 해결 비용이 포함되지 않는다.

병목 원인흔한 구현결과
과도한 물리 쿼리모든 캐릭터가 매 틱 Raycast 수행Physics CPU 시간 증가
GC 할당Physics.OverlapSphere 배열 반환값 사용GC 스파이크와 프레임 끊김
중복된 상태 판정이동·애니메이션·AI가 각각 지면 검사같은 결과를 여러 번 계산
추격 물리 스텝렌더 프레임 저하 후 FixedUpdate 연속 실행체감상 더 큰 프레임 드롭

Unity Profiler에서 Physics.Simulate와 캐릭터 FixedUpdate 비용을 비교하는 예시

Unity Profiler로 FixedUpdate 병목을 어떻게 찾을까?

최적화 전에 비용의 위치를 확인해야 한다. ‘물리를 사용한다’는 사실만으로 Physics 엔진이 병목이라고 판단하면 실제로는 C# 코드나 GC 문제를 놓치기 쉽다.

1. CPU Usage에서 물리 스텝을 분리해 본다

Unity Profiler의 CPU Usage > Timeline에서 다음 항목을 확인한다.

  1. FixedUpdate.PhysicsFixedUpdate 또는 Physics.Simulate가 긴지 확인한다.
  2. 개별 Character.FixedUpdate가 긴지 확인한다.
  3. GC.Alloc가 물리 프레임마다 반복되는지 확인한다.

Physics.Simulate가 길다면 Collider 수, Rigidbody 수, 충돌 쌍, 물리 쿼리 양을 우선 줄인다. 반대로 캐릭터 스크립트가 길다면 중복 계산과 매 틱 상태 갱신을 먼저 정리한다.

2. Deep Profile은 짧은 구간에서만 사용한다

Deep Profile은 호출 정보를 자세히 보여 주지만 프로파일링 자체의 비용이 크다. 실제 성능 측정은 일반 Profiler로 하고 특정 캐릭터 메서드의 내부 호출을 찾을 때만 짧게 켠다.

3. ProfilerRecorder로 물리 예산을 수치화한다

개발 빌드에서 물리 비용 회귀를 감시하려면 ProfilerRecorder를 사용할 수 있다. 다만 샘플 이름은 Unity 버전과 플랫폼에 따라 달라질 수 있으므로 Profiler에서 실제 이름을 먼저 확인해야 한다.

using Unity.Profiling;
using UnityEngine;

public sealed class PhysicsBudgetMonitor : MonoBehaviour
{
    private ProfilerRecorder _physicsRecorder;

    private void OnEnable()
    {
        _physicsRecorder = ProfilerRecorder.StartNew(
            ProfilerCategory.Physics,
            "Physics.Simulate");
    }

    private void OnDisable() => _physicsRecorder.Dispose();

    private void Update()
    {
        if (_physicsRecorder.Valid)
            Debug.Log($"Physics.Simulate: {_physicsRecorder.LastValue / 1_000_000f:F2} ms");
    }
}

물리 기반 캐릭터 FixedUpdate를 최적화하는 3단계

1. 모든 계산을 FixedUpdate에서 제거하지 말고 책임을 분리한다

Rigidbody에 힘을 가하거나 속도를 변경하는 코드는 물리 스텝에 둔다. 반면 입력 수집, 카메라 회전, UI 갱신, 먼 거리 NPC의 의사결정처럼 매 물리 틱이 필요 없는 작업은 Update 또는 별도 주기로 옮긴다.

작업권장 실행 위치이유
Rigidbody.AddForce, Rigidbody.MovePositionFixedUpdate물리 시뮬레이션과 동기화
플레이어 입력 읽기Update렌더 프레임마다 최신 입력 확보
카메라 추적LateUpdate캐릭터 이동 이후 처리
원거리 NPC 탐색타이머 또는 분산 갱신매 틱 정확도가 불필요
지면 접촉 결과 사용캐시된 물리 결과중복 Raycast 방지

입력은 Update에서 저장하고 FixedUpdate에서 소비하면 된다.

using UnityEngine;

public sealed class RigidbodyMover : MonoBehaviour
{
    [SerializeField] private Rigidbody body;
    [SerializeField] private float speed = 5f;
    private Vector2 _moveInput;

    private void Update()
    {
        _moveInput = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
    }

    private void FixedUpdate()
    {
        Vector3 velocity = new(_moveInput.x * speed, body.linearVelocity.y, _moveInput.y * speed);
        body.linearVelocity = velocity;
    }
}

Rigidbody.linearVelocity는 Unity 6 계열 API 예시다. 이전 Unity 버전에서는 같은 목적에 Rigidbody.velocity를 사용한다.

2. Raycast와 Overlap 계열 쿼리를 캐시하고 NonAlloc API로 교체한다

같은 틱 안에서 이동, 애니메이션, AI가 각각 지면을 검사하는 구조는 비용이 누적된다. 캐릭터 단위의 GroundProbe처럼 한 곳에서 접지 결과를 계산하고 나머지는 결과만 읽도록 만든다.

배열을 새로 만드는 Physics.OverlapSphere 대신 Physics.OverlapSphereNonAlloc을 사용하면 반복 호출에서 GC 할당을 피할 수 있다.

using UnityEngine;

public sealed class GroundProbe : MonoBehaviour
{
    [SerializeField] private LayerMask groundMask;
    [SerializeField] private float probeDistance = 0.3f;

    public bool IsGrounded { get; private set; }
    public RaycastHit GroundHit { get; private set; }

    private void FixedUpdate()
    {
        IsGrounded = Physics.Raycast(
            transform.position + Vector3.up * 0.1f,
            Vector3.down,
            out RaycastHit hit,
            probeDistance,
            groundMask,
            QueryTriggerInteraction.Ignore);

        if (IsGrounded)
            GroundHit = hit;
    }
}
private readonly Collider[] _nearbyTargets = new Collider[16];

private int FindNearbyTargets(Vector3 position, float radius, LayerMask targetMask)
{
    return Physics.OverlapSphereNonAlloc(
        position,
        radius,
        _nearbyTargets,
        targetMask,
        QueryTriggerInteraction.Ignore);
}

NonAlloc API는 버퍼가 가득 찬 경우 결과가 잘릴 수 있다. 반환된 개수가 버퍼 길이와 같다면 실제 게임 데이터 기준으로 버퍼 크기를 검토해야 한다. 무작정 큰 배열을 캐릭터마다 만들기보다 탐색 빈도와 최대 탐지 수를 먼저 낮추는 편이 효과적이다.

3. 중요도가 낮은 캐릭터는 물리 판단 주기를 분산한다

NPC 모두가 50Hz로 주변 탐색과 경사 판정을 할 필요는 없다. 플레이어와 가까운 캐릭터만 매 틱 처리하고 원거리 NPC는 5~10Hz로 낮춘다. 여러 NPC가 같은 틱에 몰리지 않도록 인스턴스별 오프셋을 둔다.

using UnityEngine;

public sealed class ThrottledSense : MonoBehaviour
{
    [SerializeField] private float interval = 0.2f;
    private float _nextCheckTime;

    private void Awake()
    {
        _nextCheckTime = Time.time + Random.value * interval;
    }

    private void FixedUpdate()
    {
        if (Time.time < _nextCheckTime)
            return;

        _nextCheckTime += interval;
        ScanNearbyTargets();
    }

    private void ScanNearbyTargets()
    {
        // Physics.OverlapSphereNonAlloc 등의 실제 탐색을 이곳에 둔다.
    }
}
flowchart TD
    A[캐릭터 물리 처리] --> B{플레이어와 가까운가?}
    B -- 예 --> C[매 FixedUpdate 처리]
    B -- 아니오 --> D{이번 분산 갱신 차례인가?}
    D -- 예 --> E[물리 쿼리와 AI 판단]
    D -- 아니오 --> F[캐시된 결과 사용]

가까운 캐릭터와 원거리 NPC에 서로 다른 물리 갱신 주기를 적용하는 개념도

Fixed Timestep을 높이면 물리 병목이 해결될까?

Time.fixedDeltaTime을 0.02초에서 0.0333초로 높이면 초당 물리 스텝 수가 약 50회에서 30회로 줄어든다. 따라서 전체 물리 비용은 내려갈 수 있다. 하지만 이것은 계산량을 줄이는 대신 물리 해상도를 낮추는 선택이다.

PhysicsStepsPerSecond=1fixedDeltaTime\text{PhysicsStepsPerSecond} = \frac{1}{\text{fixedDeltaTime}}
fixedDeltaTime초당 스텝 수적합한 상황주의점
0.0250일반적인 액션 게임캐릭터가 많으면 비용 증가
0.0333약 30느린 이동, 다수 NPC충돌과 이동 반응이 거칠어질 수 있음
0.0167약 60빠른 반응이 필요한 게임CPU 물리 비용 증가

먼저 캐릭터별 쿼리 수와 중복 갱신을 줄인 뒤 게임의 이동 속도와 충돌 요구사항을 기준으로 fixedDeltaTime을 조정한다. 빠른 투사체나 고속 Rigidbody에는 Continuous Collision Detection 같은 별도 대응이 필요할 수 있다.

또한 Time.maximumDeltaTime은 큰 렌더 프레임 지연 뒤 처리할 최대 물리 시간을 제한한다. 값을 과도하게 낮추면 물리 시간이 실제 시간보다 뒤처질 수 있으므로 프레임 드롭을 숨기는 수단이 아니라 최악 상황의 안전장치로 다뤄야 한다.

Rigidbody와 Collider 설정에서 함께 점검할 항목

스크립트 최적화만으로 부족할 때는 물리 월드 자체를 줄인다.

  • 움직이지 않는 환경 오브젝트에는 Rigidbody를 붙이지 않는다.
  • 이동하지 않는 Rigidbody는 Is Kinematic보다 Rigidbody 제거가 가능한지 먼저 검토한다.
  • 충돌할 필요가 없는 레이어 조합은 Project Settings > Physics의 Layer Collision Matrix에서 끈다.
  • 메시가 복잡한 동적 Rigidbody에 MeshCollider를 사용하지 않는다. 가능한 경우 BoxCollider, CapsuleCollider, SphereCollider 같은 단순 Collider를 사용한다.
  • 화면 밖 또는 비활성 전투 구역의 AI는 Rigidbody 시뮬레이션과 센서 갱신을 함께 비활성화한다.

자주 묻는 질문 (FAQ)

FixedUpdate 안에서 Raycast를 쓰면 항상 느린가?

아니다. 플레이어 1명처럼 필요한 수가 적으면 정상적인 선택이다. 문제는 많은 객체가 여러 번 호출하거나 같은 결과를 중복해서 계산할 때 발생한다.

Physics.OverlapSphereNonAlloc만 쓰면 GC 문제가 모두 해결되는가?

아니다. NonAlloc 쿼리는 결과 배열 할당만 줄인다. LINQ, 문자열 보간 로그, 매 틱 생성하는 컬렉션과 객체도 별도로 확인해야 한다.

모든 NPC의 FixedUpdate 주기를 낮춰도 되는가?

안 된다. 플레이어 근처에서 충돌하거나 정밀한 반응이 필요한 NPC는 매 물리 스텝 처리가 필요할 수 있다. 거리와 게임플레이 중요도를 기준으로 대상을 나눠야 한다.

정리

FixedUpdate 병목의 핵심은 물리 프레임 자체보다 그 안에서 반복되는 캐릭터별 작업량이다. Profiler로 Physics.Simulate, 캐릭터 스크립트, GC 할당을 구분한 뒤 물리 책임 분리, 쿼리 결과 캐싱, NonAlloc API, NPC 갱신 주기 분산 순서로 적용하면 안정적인 개선을 얻을 수 있다.

마지막으로 대표 전투 장면에서 캐릭터 수를 늘려 측정하고 평균 프레임 시간뿐 아니라 최악 프레임의 Physics.Simulate 시간과 GC Alloc도 함께 기록하자. 최적화의 완료 기준은 코드 변경이 아니라 목표 기기에서 물리 프레임 예산을 지키는 것이다.

#Unity 최적화#FixedUpdate#물리 기반 캐릭터#Rigidbody#C# 성능 최적화#Physics.NonAlloc

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs