2023. 8. 31. 03:13ㆍ언리얼이 리얼보다 쉬웠어요
언리얼의 오브젝트 관리
언리얼 엔진의 경우 C++ 기반이지만 GC가 존재하며 UObject의 형태로 생성된 객체들의 생명주기를 관리한다. 더 나아가 말하자면 언리얼은 스스로 UObject의 메모리를 할당하고 해제하는 것을 권장하지 않는다. 언리얼에서 손수 래핑한 함수를 거쳐 객체를 생성하고 소멸... 시키는 것처럼 보이게 하면서 GC에게 넘기도록 유도한다.
액터의 경우 그 액터를 소유한 레벨을 치우지 않는 이상 Destroy 함수를 통해 명시적 소멸 예약을 걸어두지 않는 한 살아 있다. 액터가 아닌 UObject는 까딱하면 GC가 데려가버린다. 이를 막기 위해 UPROPERTY() 매크로를 이용하여 '지금 참조중이다'라는 걸 일러두고 시작하는 일이 잦지만, UPROPERTY()는 하드 레퍼런스를 걸어두는 것이기 때문에 순환 참조의 위험이 도사린다. 내내 활성 상태로 유지할 예정이 없는 객체는 위크 포인터 등의 활용도 적극적으로 고려해볼 필요가 있다.
언리얼의 GC
언리얼의 GC는 대중적인 Mark-and-Sweep 형태를 띠고 있다. 루트셋부터 쭉 타고타고 가면서 나오는 오브젝트들을 대상으로 접근 가능한지 여부를 마킹(Mark)한 후에 접근 불가능한 객체들을 해제(Sweep)하는 형식의 추적 GC다. 아무데서도 참조하는 흔적이 없어 더이상 쓰이지 않는 것으로 판단되는 객체가 그때 GC에 쓸려나가 메모리에서 내려가는 것이다. 그렇다면 이런 GC 프로세스는 어떠한 타이밍에 실행될까? 언리얼에서 GC는 크게 이럴 때 트리거된다.
1. 퍼시스턴트 레벨 전환
- UEngine::Browse(FWorldContext& WorldContext, FURL URL, FString& Error) 참조
2. 수동 GC(강제 GC)
- UEngine::ForceGarbageCollection(bool bForcePurge) 호출
- bForcePurge가 true면 bFullPurgeTriggered가 체크되고, false면 GC 시간이 초기화되어 다음 틱의 GC 판단에 반영
3. 자동 GC(Timing GC)
- 지정된 GC 실행 주기에 따라 GC 자동 실행
- gc.LowMemory.MemoryThresholdMB를 사용할 경우 메모리가 부족해지면 더 잦은 빈도로 GC실행
언리얼의 경우 엔진에서 매 틱마다 GC 실행 여부를 체크한다. 미리 GC 실행 트리거를 걸어두지 않았다면 기본적으로 지정된 GC 실행 주기마다 GC가 실행된다. 강제 GC(Full Purge 체크)가 아닌 이상 기본적으로 GC 실행으로 인한 히치 발생을 최대한 줄이기 위해 Incremental Purge의 형태로 실행된다. 만약 수동으로 GC 트리거를 걸어두었다면 다음 틱에서 주기를 무시하고 GC가 실행된다. UE5에서도 GC 트리거 타이밍에 큰 흐름의 변화는 없다.
간략하게 축약하면 이런 느낌이다. 여기서 Incremental Purge란 말 그대로 조금씩 나눠서 버린다는 흐름이다. Unreachable 오브젝트들을 한 틱 안에서 몽땅 처리하는 게 아니라 지정된 시간 제한을 정해두고 다 못버리면 다음 틱, 다 못버리면 다음 틱, 이런식으로 나눠서 처리함으로써 부하를 줄인다. 물론 이래도 GC는 무겁다.
그렇다면 트리거된 후 실제 작업 수행은 어떤 과정을 통해서 진행될까? GC 로직으로 진입하는 함수들은 몇 가지 있지만 결국 UE::GC::CollectGarbageInternal, 그리고 내부 구현 로직 함수인 CollectGarbageImpl로 통하게 되므로 이쪽을 타고 내려가보겠다.
여기서 클러스터는 언리얼이 GC 최적화를 위해 사용한 기법 중 하나로, 관련된 객체들의 클러스터링을 통해 Reachability 분석 단계에서 중복된 분석을 방지하는 기술이다. 이렇게 봤을 때 Full Purge의 경우 한번에 전부 치워버리지만, Incremental의 경우 오브젝트의 수집까지만 마친 후 실제 파괴는 다음으로 미루는 것을 볼 수 있다. 미리 수집된 Unreachable 오브젝트들은 다음 틱, 또 다음 틱에서 야금야금 치워지게 된다.
뱀발
이건 그냥 예전에 UE4 당시 있었던 이슈인데, 기억에 남아서 겸사겸사 적어놓는다. 심리스 오픈월드 프로젝트에서 수 초 단위로 GC가 호출된 적이 있었다. 확인해보니까 스트리밍 레벨이 언로드될 때마다 ForceGarbageCollection이 호출되는 것이었다. 캐릭터가 빠른 속도로 이동할 때마다 바뀌는 레벨 스트리밍 상태에 맞춰 GC도 바쁘게 호출되고 있었다. UE4에서 발생한 이슈였고, 그때 해결은 GLevelStreamingForceVerifyLevelsGotRemovedByGC의 기본값이 1로 되어 있어서 Scalability를 통해 s.ForceGCAfterLevelStreamedOut = 0으로 설정해줘서 강제 호출을 막았었다. 간만에 GC쪽 로직을 보면서 그때가 생각나서 확인해보니 UE5에도 변경점은 없는 것으로 보인다. UWorld::UpdateLevelStreaming() 참조.
'언리얼이 리얼보다 쉬웠어요' 카테고리의 다른 글
[UE5] 언리얼 인사이트로 안드로이드 프로파일링하기 (0) | 2023.08.21 |
---|---|
[UE5] Octahedral Impostors (0) | 2023.08.18 |
[UE4/UE5] Replication Graph - GridSpatialization2D Node (0) | 2023.08.08 |
[UE4/UE5] Replication Graph (0) | 2023.07.22 |