C#으로 게임 루프 설계하기: 단계별 실시간 시뮬레이션 엔진 구축 | Sudhir Mangla


C# 실시간 시뮬레이션 루프 아키텍처

핵심 요약

C#에서 게임 루프나 실시간 시뮬레이션 엔진을 구축할 때는 단순한 무한 루프가 아닌 고정 타임스텝 아키텍처가 필수입니다. 실시간 시스템은 요청-응답 구조의 이벤트 기반 애플리케이션과 달리 지속적으로 상태를 진행시켜야 하며, 정확한 시간 제어와 결정론적 업데이트를 통해 안정성을 보장합니다. 시뮬레이션 시간을 실제 시간과 분리하는 누적기(accumulator) 패턴을 사용하여 변수 타임스텝으로 인한 불안정성을 해결하고, 멀티스레딩과 보간 기법으로 시각적 매끄러움을 제공하며, 메모리 효율성을 통해 수천 개의 엔티티를 초당 60회 업데이트할 수 있습니다.


상세 요약

1. 시뮬레이션과 반응형 시스템의 철학적 차이

전통적인 .NET 서비스(웹 API, 백그라운드 워커)는 반응형 환경에서 작동합니다. 외부 요청이 서비스를 깨우고, 각 실행은 고립되어 있으며, 시간은 이산적 요청으로 나뉨을 의미합니다. 반면 시뮬레이션 엔진은 지속적으로 실행되고 입력 여부와 관계없이 상태를 전진시켜야 하며, 엔진이 시간 자체를 소유합니다.

요청-응답 모델의 문제점: 표준 타이머(Task.Delay(), PeriodicTimer)는 OS 스케줄링과 내부 타이머 해상도에 의존합니다. Windows 기본 타이머 해상도는 15.6ms이므로, 16.666ms 캐던스를 안정적으로 달성할 수 없습니다. 누적된 시간 변동은 시뮬레이션에서 지터, 불안정성, 비결정성을 초래합니다.

시뮬레이션 루프의 핵심: 심박(heartbeat) 원리로 작동하며, 실제 시간을 읽고 경과 시간을 계산한 뒤, 시뮬레이션 시간을 작은 결정론적 증분으로 진행시키고, 매 사이클마다 드리프트를 정정합니다.

2. 실시간 시스템의 삼위일체

모든 실시간 시스템(게임, 로봇공학, 금융 엔진)은 세 가지 단계를 따릅니다.

입력 단계: 외부 신호(키보드 입력, 네트워크 데이터, 센서 패킷)가 임의의 시점에 도착하며, 시뮬레이션이 권위를 가지고 있으므로 입력은 상태를 직접 변경하지 않고 다음 업데이트에서 통합됩니다.

업데이트 단계: 고정 타임스텝(예: 1/60초)을 사용하여 세계 상태를 수학적으로 진행시킵니다. 차량 속도가 위치로 통합되고, 물리 력이 해결되며, 금융 상품이 이자를 축적하고, NPC가 의사결정 트리를 평가합니다. 일관성이 중요하며, 모든 업데이트 스텝은 동일한 시뮬레이션 시간 지속을 나타내야 합니다.

렌더 단계: 상태를 읽기만 하며 변경하지 않습니다. 그래픽 엔진은 144Hz 또는 240Hz에서 렌더링할 수 있지만, 서버 엔진은 20~30Hz로 스냅샷을 브로드캐스트하므로, 렌더링은 업데이트와 자주 분리됩니다.

3. 결정론성 및 부동점 정밀도

동일한 입력이 주어지면 다른 CPU 모델, 스레드 스케줄링, 명령 타이밍, 클록 드리프트에 관계없이 동일한 출력을 생성해야 합니다.

부동점 연산 문제: 부동점 산술은 결합법칙을 따르지 않습니다. (a + b) + c != a + (b + c)입니다. 두 코어가 다른 순서로 같은 표현을 계산하거나, GPU가 다른 반올림 결과를 생성할 수 있습니다. 물리 시뮬레이션에서 작은 반올림 차이는 객체가 한 기계에서는 더 일찍 충돌하여 그 지점 이후로 전체 세계 상태가 변합니다.

경쟁 조건: Parallel.ForEach(entities, e => e.Update())는 시뮬레이션이 업데이트 순서에 따라 달라지면 무작위성을 도입합니다. 금융 시뮬레이션에서 이자 축적을 비결정론적 순서로 처리하면 같은 시장을 복제하는 두 서버가 발산합니다.

4. 잘못된 루프의 분석

무한 루프 문제: while(true) { Update(); }는 CPU 코어를 즉시 포화시킵니다. OS가 스레드를 스로틀링하고, CPU 부스트 주파수가 변동하며, 다른 프로세스가 기아 상태에 빠지고, 전력 소비가 증가하며, 열 스로틀링이 발동합니다.

변수 타임스텝의 문제: 발사체가 초당 10 유닛으로 이동할 때, 타임스텝이 16ms에서 50ms로 급증하면, 발사체는 얇은 벽을 완전히 통과하여 충돌 감지를 건너뜁니다(터널링 효과). 금융 엔진에서 balance += balance * (rate * dt)로 복합 이자를 계산하면, dt가 변하면 빠른 기계는 더 자주 이자를 축적하고 다른 결과를 얻습니다.

죽음의 나선: 한 업데이트가 너무 오래 걸리면, 다음 틱의 델타 타임이 더 커지고, 더 큰 dt는 더 많은 시뮬레이션 작업을 의미하며, 틱 시간이 다시 증가합니다. 결국 엔진이 회복할 수 없고 프레임레이트가 붕괴합니다.

5. 고정 타임스텝 패턴

Glenn Fiedler가 공식화한 “Fix Your Timestep” 모델은 누적기 패턴을 도입하여 실제 시간과 시뮬레이션 시간을 분리합니다.

누적기 루프의 작동 원리:

  • 실제 경과 시간(실제 시간)을 측정합니다.
  • 버퍼에 누적합니다.
  • 고정 크기 청크(16.666ms)를 소비합니다.
  • 청크당 한 번 Update()를 실행합니다.
  • 남은 시간을 다음 루프로 이월합니다.
public void Run()
{
    var accumulator = TimeSpan.Zero;
    var previous = _stopwatch.Elapsed;
    
    while (true)
    {
        var now = _stopwatch.Elapsed;
        var frameTime = now - previous;
        previous = now;
        accumulator += frameTime;
        
        while (accumulator >= _fixedTimeStep)
        {
            Update(_fixedTimeStep);
            accumulator -= _fixedTimeStep;
        }
        
        var alpha = accumulator / _fixedTimeStep;
        Render(alpha);
    }
}

작동 원리: 느린 프레임은 여러 시뮬레이션 스텝을 발생시켜 시간을 일관성 있게 유지하고, 빠른 프레임은 0개 업데이트를 수행할 수 있지만 여전히 렌더링하며, 누적기는 뒤떨어지지도 앞서가지도 않도록 보장합니다.

6. Stopwatch vs DateTime.UtcNow

DateTime.UtcNow: Windows에서 약 0.5~1ms 해상도를 가지고, 시스템 시간 조정의 영향을 받으며, 시뮬레이션 타이밍에는 너무 거칠습니다.

Stopwatch: 마이크로초 이상의 고해상도를 가지고, 단조로우며(절대 역진하지 않음), Windows의 QueryPerformanceCounter에 기반합니다. 항상 Stopwatch를 사용해야 합니다.

Stopwatch.GetTimestamp(): 매우 높은 주파수 루프나 나노초 수준 수학의 경우, 원시 타임스탬프 값을 사용하여 서브 마이크로초 정밀도와 모든 지원 플랫폼에서 안정적 타이밍을 제공합니다.

7. 상태 보간과 렌더링 분리

시뮬레이션이 60Hz에서 업데이트되지만 GPU가 144Hz에서 실행되거나, 서버가 20Hz로 스냅샷을 브로드캐스트하고 클라이언트가 120Hz에서 렌더링하면 주파수가 일치하지 않습니다. 렌더러가 ‘현재’ 시뮬레이션 프레임만 사용하면 다음 업데이트까지 상태를 반복적으로 표시하다가 시뮬레이션이 진행되면 갑자기 다음 상태로 점프하여 지터로 나타납니다.

선형 보간(Lerp): alpha = accumulator / fixedTimeStep는 0과 1 사이의 값입니다. 렌더러가 다음 예상 시뮬레이션 스텝으로 얼마나 멀리 있는지를 나타냅니다.

State_render = State_previous * (1 - alpha) + State_current * alpha

이 공식은 위치, 회전, 가격 및 예측 가능하게 진행되는 대부분의 스칼라 또는 벡터 값에 적용됩니다.

이중 버퍼 패턴: 시뮬레이션이 틱을 완료하면, CurrentStatePreviousState로 복사하고, 업데이트된 세계 상태를 CurrentState에 작성합니다. 두 버퍼는 이 지점 이후 절대 변경되지 않으므로, 렌더링 측에서는 보간이 안전하고 일관성 있습니다.

8. 멀티스레드 루프 아키텍처

단일 스레드 루프에서는 시뮬레이션과 렌더링이 순차적으로 발생합니다. 시뮬레이션이 몇 밀리초 지연되면 렌더링도 지연됩니다. 렌더링 또는 브로드캐스트를 다른 스레드로 이동하면 시뮬레이션은 최대한 빠르고 일관성 있게 실행되도록 자유로워집니다.

생산자-소비자 아키텍처: System.Threading.Channels를 사용합니다.

var channel = Channel.CreateBounded<EntityState>(new BoundedChannelOptions(4)
{
    SingleWriter = true,
    SingleReader = true,
    FullMode = BoundedChannelFullMode.DropOldest
});

DropOldest는 채널이 이전 프레임을 버리므로 시뮬레이션이 렌더러 대기 상태에 빠지지 않습니다. 렌더러가 느리면 오래된 상태를 자동으로 버리고, 두 시스템 모두 서로를 차단하지 않으면서 최적으로 동작합니다.

백프레셔 전략:

  • DropOldest: 렌더링에 최적이며, 가장 최근 프레임을 보장합니다.
  • DropNewest: 데이터 스트림에서 이전 프레임이 중요할 때 사용합니다.
  • Wait: 시뮬레이션에는 절대 사용하면 안 되며, 대기가 캐던스를 깨집니다.

9. 데이터 구조 및 메모리 최적화

GC 일시 중지 회피: 누적 GC 일시 중지도 결정론성을 깹니다. 루프 내 모든 할당은 잠재적 레이턴시 스파이크입니다. LINQ 연산자, 람다 캡처, 박싱, 문자열 연결, 암시적 배열 크기 조정, 지연 실행 열거자는 숨겨진 할당을 생성합니다.

구조체 우선 설계: 구조체는 배열에 연속적으로 저장되어 캐시 효율성이 높습니다. 반면 클래스는 힙에 분산되어 캐시 미스를 초래합니다.

public readonly struct VehicleState
{
    public readonly Vector2 Position;
    public readonly Vector2 Velocity;
}

Entity Component System(ECS): 엔티티(단순 식별자), 컴포넌트(구조체 같은 데이터), 시스템(연속 배열 컴포넌트에서 작동)을 기반으로 데이터를 재구성합니다. C# ECS 라이브러리로는 ArchSvelto.ECS가 있습니다.

객체 풀링: 임시 객체를 할당하는 시스템의 경우, Microsoft.Extensions.ObjectPool을 사용하여 할당 비용을 분산시키고 GC 버짓을 방지합니다.

10. LogiSim 실전 구현

실시간 트래픽 엔진으로 수천 개의 차량을 시뮬레이션합니다. 정확히 60 UPS로 작동하고, 결정론적 진행을 유지하며, 부드러운 렌더링을 제공합니다.

VehicleState 구조체: 위치와 속도만 추적하며 Integrate() 메서드는 결정론적 업데이트를 용이하게 합니다.

SimulationLoop: 고정 스텝 누적기 설계를 따르며, 실제 업데이트 로직과 스냅샷 생산을 바인드합니다.

VehicleManager: 연속 배열을 사용하여 캐시 지역성을 최적화하고, 매 틱마다 상태 스냅샷을 복제합니다(렌더러가 불변 스냅샷 수신).

입력 처리: 콘솔 입력 핸들러는 별도 스레드에서 실행되어 시뮬레이션을 차단하지 않으며, volatile bool을 사용하여 Quit 신호를 전달합니다.

보간 기반 렌더러: 스냅샷을 읽고 이전과 현재 상태를 보간하여, 시뮬레이션이 이산적 업데이트를 생성해도 부드러운 모션을 생성합니다.

11. 성능 최적화 및 벤치마킹

BenchmarkDotNet을 사용하여 틱 성능을 측정합니다. 할당 패턴, 분기 오예측, 반복 비용을 즉시 표시합니다.

Span 및 SIMD 최적화: 배열 접근을 Span<T>로 대체하면 경계 확인을 줄일 수 있습니다. 대량 속도 연산에는 System.Numerics.Vector<T>를 활용합니다. 올바르게 구성하면 업데이트 시간을 20~40% 단축할 수 있습니다.

12. 결론 및 다음 단계

아키텍처가 조화롭게 작동하면 예측 가능하고 유지보수성 높으며 성능이 뛰어납니다.

결정론성 테스트: 커스텀 시간 소스를 주입하여 단위 테스트에서 시간을 수동으로 단계적으로 진행합니다. 기록-재생 기법을 사용하여 모든 입력 시퀀스 중 디버깅 복잡한 상호작용을 기록 및 재생할 수 있습니다.

분산 시뮬레이션: 단일 노드 결정론적 시뮬레이션을 마스터한 후, 다음 과제는 기계 간 시뮬레이션 분산입니다. 클록 동기화, 네트워크 지터, 교차 노드 조정이 필요하며, 락스텝 시뮬레이션, 상태 차분, 권위 위임 같은 기법이 필수입니다.

.NET에서 고성능 시뮬레이션은 완전히 실행 가능합니다. 구조체, 스팬, SIMD, 락프리 패턴, 새로운 런타임 개선으로 .NET은 많은 네이티브 엔진과 동등하거나 능가합니다. 시간을 아키텍처의 일부로 취급하고 매 계층을 결정론적이고 효율적으로 유지하는 것이 핵심입니다.


#c# #real-time-simulation #game-loop #performance #architecture

1개의 좋아요