안녕하세요, 저희는 언리얼 엔진의 PSO 프리캐싱 시스템을 작업한 엔지니어, 겐조 테르 엘스트(Kenzo ter Elst), 다니엘레 베토렐(Daniele Vettorel), 앨런 벤텀(Allan Bentham), 미네아 발타(Mihnea Balta)입니다.
최근 에픽 커뮤니티에서는 셰이더 스터터링과 이것이 게임 개발 프로젝트에 미치는 영향에 대해 많은 논의가 이루어지고 있습니다.
오늘은 이러한 현상이 발생하는 이유를 알아보고, PSO 프리캐싱이 이 문제를 해결하는 방식을 설명하고, 셰이더 스터터링을 최소화하는 데 도움이 되는 몇 가지 개발 모범 사례를 살펴보겠습니다. 또한 PSO 프리캐싱 시스템의 향후 계획에 대해서도 알려드리고자 합니다.
더 자세히 알고 싶으시다면 한국 시간으로 2월 7일 금요일 오전 4시에 Twitch와 YouTube에서 진행되는 인사이드 언리얼 라이브스트림을 놓치지 마세요.
셰이더 컴파일 스터터링은 렌더링 엔진이 신규 셰이더를 드로잉에 사용하기 직전에 신규 셰이더를 컴파일해야 한다는 걸 발견하여 드라이버의 컴파일 완료를 기다리는 동안 모든 게 중지되면서 발생합니다.어떻게 이런 일이 발생하는지 그 원인을 이해하려면 우선 셰이더가 GPU에서 실행되는 코드로 변환되는 방식을 자세히 살펴봐야 합니다.
셰이더는 GPU에서 실행되는 프로그램으로 트랜스포메이션, 디포메이션, 섀도잉, 라이팅, 포스트 프로세싱 등 3D 이미지 렌더링과 관련된 다양한 단계를 수행할 수 있습니다. 셰이더는 일반적으로 HLSL과 같은 고급 언어로 작성되며, GPU가 실행할 수 있는 머신 코드로 컴파일되어야 합니다. 이 과정은 CPU의 경우와 유사합니다. C++와 같은 고급 언어로 작성된 코드가 컴파일러를 거쳐 x64, ARM 등 특정 아키텍처를 위한 인스트럭션(명령어)이 되는 흐름입니다.
하지만 중요한 차이점이 있다면, PC, Mac, Android 같은 각 플랫폼은 일반적으로 한두 개의 CPU 인스트럭션 세트를 타깃으로 하지만 GPU의 경우에는 그 종류가 다양하며 각각의 인스트럭션 세트도 매우 다르다는 것입니다. AMD와 Intel용으로 컴파일된 실행파일의 경우, 두 공급업체가 동일한 인스트럭션 세트를 사용하고 강력한 하위 호환성을 보장하기 때문에 10년 전에 x64 PC용으로 컴파일된 실행 파일을 최근에 생산되는 칩에서도 실행할 수 있습니다. 반면 AMD용으로 컴파일된 GPU 바이너리는 NVIDIA에서 작동하지 않고 그 반대의 경우도 마찬가지며, 같은 공급업체라 하더라도 하드웨어 세대가 다르다면 인스트럭션 세트가 바뀔 수 있습니다.
따라서 CPU 프로그램은 실행 가능한 머신 코드로 직접 컴파일 및 배포하는 게 가능하지만, GPU 프로그램에는 다른 접근 방식을 사용해야 합니다. 고급 셰이더 코드는 중간 표현 또는 바이트코드로 컴파일되는데, 이 중간 단계는 3D API에 의해 정의된 추상 인스트럭션 세트를 사용합니다(Direct3D 11에는 DXBC, Direct3D 12에는 DXIL, Vulkan에는 SPIR-V 등).
게임은 모든 GPU 아키텍처마다 셰이더 라이브러리를 하나씩 두는 게 아니라 이러한 바이트코드 바이너리만을 포함하여 배포되며, 결과적으로 단일 셰이더 라이브러리만을 갖을 수 있습니다. 런타임에서 드라이버는 바이트코드를 머신에 설치된 GPU의 실행 코드로 변환합니다. 이 접근법은 때때로 CPU 프로그램에도 사용되는데, 예를 들어 Java 소스 코드를 바이트코드로 컴파일하여 CPU에 관계없이 Java 환경이 있는 모든 플랫폼에서 동일한 바이너리를 실행할 수 있도록 하는 것입니다.
이 시스템을 도입할 당시에는 게임이 비교적 단순하고 셰이더가 적었으며 바이트코드에서 실행 가능한 코드로 변환하는 것이 간단했기 때문에, 런타임에서 이 작업을 수행하는 데 드는 비용은 고려하지 않아도 될 수준이었습니다. 하지만 GPU 성능이 더욱 강력해지면서 셰이더 코드가 점점 더 많아졌고 드라이버도 더 효율적인 머신 코드를 생성하기 위해 정교한 변환을 수행하기 시작하면서 런타임 컴파일 비용은 문제가 되었습니다. 그렇게 Direct3D 11에서는 한계점에 도달했기 때문에, Direct3D 12 및 Vulkan과 같은 최신 API에서는 파이프라인 스테이트 오브젝트(PSO, Pipeline State Object)라는 개념을 도입하여 이 문제를 해결하기 시작한 것입니다.
오브젝트 렌더링 시에는 여러 셰이더(예: 버텍스 셰이더와 픽셀 셰이더가 함께 작동하는 경우)와 컬링 모드, 블렌드 모드, 뎁스 및 스텐실 비교 모드 등 GPU에 대한 다양한 설정이 필요합니다. 이러한 항목들이 모여 GPU 파이프라인의 구성 또는 스테이트를 정의하게 됩니다.
Direct3D 11과 OpenGL 같은 이전의 그래픽 API는 스테이트의 일부를 임의의 시간에 개별적으로 변경할 수 있어서, 드라이버는 게임의 드로 요청이 있을 때만 완전한 구성을 파악할 수 있습니다. 일부 설정은 실행 가능한 셰이더 코드에 영향을 미치므로, 어떤 경우에는 드로 명령이 수행될 때에 드라이버가 셰이더 컴파일을 시작할 수 있기도 합니다. 이 경우, 단일 드로 명령을 처리하는 데 수십 밀리초 이상이 소요되어 셰이더가 처음 사용될 때 프레임이 매우 길어지게 됩니다. 대부분의 게이머들이 이를 히칭(버벅임) 또는 스터터링(끊김 현상)이라고 부르고 있습니다.
최신 API에서는 개발자가 하나의 드로 요청에 사용할 모든 셰이더와 세팅을 파이프라인 스테이트 오브젝트(PSO)에 담아, 이를 하나의 단위로 활용합니다. 무엇보다 PSO는 언제든지 생성할 수 있으므로, 이론상으로 엔진은 필요한 모든 것을 충분히 일찍(예를 들면, 로딩하는 동안에도) 생성하여 렌더링하기 전에 컴파일을 완료할 수 있습니다.
언리얼 엔진은 아티스트가 시각적으로 풍부하고 매력적인 월드를 제작하는 데 사용할 수 있는 강력한 머티리얼 제작 시스템을 갖추고 있으며, 대부분의 게임에는 수천 개의 머티리얼이 존재합니다. 각 머티리얼은 다양한 셰이더를 생성할 수 있는데, 예를 들어 스태틱 메시와 스킨드 메시, 스플라인 메시 등에 대응하여 머티리얼 하나가 여러 다른 버텍스 셰이더를 생성해야 합니다. 동일한 버텍스 셰이더가 여러 픽셀 셰이더와 함께 사용될 수도 있는데, 역시 다양한 세트만큼 파이프라인 설정이 배로 늘어납니다. 따라서 이와 같은 모든 경우의 수를 고려하면 서로 다른 수백만 개의 PSO를 미리 컴파일해야 하는데, 시간과 메모리를 고려하면 당연히 실현 불가능한 일입니다. 레벨 하나를 로드하는 데도 몇 시간이 걸릴 수 있습니다.
이와 같은 가능한 PSO 중에 극히 일부의 서브셋이 런타임에 사용되지만, 머티리얼을 따로 떼어놓고 보면 해당 서브셋이 무엇인지 알 수 없습니다. 서브셋은 게임 세션에 따라 변경될 수도 있는데, 비디오 설정을 수정하면 특정 렌더링 기능이 전환되면서 엔진이 서로 다른 셰이더 또는 파이프라인 스테이트를 사용하게 됩니다. 초기 Direct3D 12 엔진 구현은 플레이테스트, 자동화된 레벨 플라이스루 및 기타 검색 방법에 의존하여 실제로 발생하는 PSO를 기록했습니다. 이 데이터는 최종 게임에 포함되어 시작 또는 레벨 로드 시 알려진 PSO를 생성하는 데 사용되었습니다. 언리얼 엔진에서는 이를 '번들화된 PSO 캐시”라고 부르며 UE 5.2까지는 가장 권장되는 모범 사례였습니다.
번들화된 캐시만으로 충분한 게임들도 있지만, 많은 제한이 있습니다. 번들화된 캐시를 수집하는 데는 많은 리소스가 사용될 뿐 아니라 콘텐츠 변경 시 최신 상태를 유지해야 합니다. 예를 들어 플레이어의 행동에 따라 오브젝트 머티리얼이 바뀌는 것처럼 매우 동적인 월드를 갖춘 게임에서는 기록 과정에서 모든 PSO를 탐색하지 못할 수도 있습니다.
또한 맵이 다수 존재하거나 플레이어가 여러 스킨 중에 하나를 선택할 수 있어 세션 마다 베리에이션이 많은 경우 캐시 크기가 필요한 수준보다 훨씬 커질 수 있습니다. 대표적으로 포트나이트는 이와 같은 제한으로 인해 번들화된 캐시가 적합하지 않다는 것을 보여주는 좋은 사례입니다. 게다가 포트나이트의 경우에는 사용자가 제작한 콘텐츠가 포함되므로 포트나이트 섬마다 다른 PSO 캐시를 사용해야 하며, 이러한 캐시를 수집하는 책임은 콘텐츠 크리에이터에게 있습니다.
크고 다양해진 게임 월드와 사용자 제작 콘텐츠를 지원하기 위해 언리얼 엔진 5.2에서는 로딩 시간에 잠재적인 PSO를 결정하는 기술인 PSO 프리캐싱이 도입되었습니다. 오브젝트가 실행될 때 시스템은 해당 머티리얼을 검사하고 메시의 정보(예: 스태틱 또는 애니메이션되는)와 글로벌 설정(예: 비디오 퀄리티 세팅) 등을 사용하여 오브젝트를 렌더링하는 데 사용할 수 있는 PSO의 서브셋을 계산합니다.
이 서브셋은 여전히 최종적으로 사용될 것보다 크겠지만 전체 PSO 캐시의 크기에 비하면 훨씬 작기 때문에 로딩 시에 컴파일할 수 있게 됩니다. 예를 들어 포트나이트 배틀로얄 매치에서는 약 3만 개의 PSO를 컴파일하고 그중 약 1만 개만 사용하지만, 전체 조합이 수백만 개에 달하는 것을 고려하면 극히 일부에 불과합니다.
맵 실행 중에 생성된 오브젝트는 실행 화면이 표시되는 동안 PSO를 미리 캐시합니다. 게임플레이 동안 스트리밍 또는 생성되는 머티리얼은 렌더링되기 전에 PSO가 준비될 때까지 대기하거나, 이미 컴파일된 기본 머티리얼을 사용할 수도 있습니다. 대부분의 경우 스트리밍이 단 몇 프레임만 딜레이되기 때문에 눈에 띄지 않습니다. 이 시스템은 머티리얼의 PSO 컴파일 스터터링을 최소화하며, 사용자가 제작한 콘텐츠와도 원활하게 작동합니다.
이미 표시된 메시의 머티리얼을 변경하는 것은 더 어려운 경우인데, 이는 새 PSO가 컴파일되는 동안 이를 숨기거나 기본 머티리얼을 사용하여 렌더링할 수 없기 때문입니다. 따라서 저희는 게임 코드와 블루프린트가 시스템에 미리 힌트를 제공하여 추가 PSO도 사전에 캐시할 수 있도록 지원하는 API를 개발하고 있습니다. 또한 새 머티리얼이 컴파일되는 동안 이전 머티리얼을 계속 렌더링할 수 있도록 엔진을 변경할 예정입니다.
언리얼 엔진에는 머티리얼과 관련이 없는 별도의 셰이더 클래스가 있습니다. 이러한 클래스를 글로벌 셰이더라고 합니다. 모션 블러, 업스케일링, 노이즈 제거 등 다양한 알고리즘과 이펙트를 구현하기 위해 렌더러가 사용하는 프로그램입니다. 프리캐싱 메커니즘은 글로벌 컴퓨트 셰이더에는 적용되었지만, 언리얼 엔진 5.5 현재 글로벌 그래픽 셰이더는 처리하지 않고 있습니다.이러한 유형의 PSO는 처음 사용될 때 드물게 한차례 히칭을 일으킬 수 있습니다. 프리캐싱이 적용되는 범위에 있어 이러한 남은 격차를 해소하기 위한 작업이 진행 중입니다.
번들화된 캐시는 프리캐싱과 함께 사용할 수 있으며, 특정 게임에서는 도움이 됩니다. 일부 범용 머티리얼을 번들화된 캐시에 포함시키면 게임플레이 동안이 아니라 시작 시 컴파일할 수 있기 때문입니다. 또한 탐색 과정에서 글로벌 그래픽 셰이더를 검색하고 기록하기 때문에 글로벌 그래픽 셰이더에도 도움이 될 수 있습니다.
드라이버는 컴파일된 PSO를 디스크에 저장하므로, 이후 게임 세션에서 다시 검색될 경우 그 즉시 로드할 수 있습니다. 이러한 드라이버 캐시는 사용하는 엔진과 PSO 컴파일 전략에 관계없이 게임에 이점을 제공합니다. PSO 프리캐싱을 사용하는 언리얼 엔진 타이틀은 두 번째 실행 시 로딩 화면이 눈에 띄게 짧아집니다. 포트나이트는 드라이버 캐시가 비어 있는 경우 배틀로얄 매치를 로드하는 데 약 20~30초가 더 걸립니다. 새 드라이버를 설치하면 캐시가 지워지므로, 드라이버 업데이트 후 게임을 처음 실행할 때 로딩 화면이 길어지는 것은 정상입니다.
언리얼은 드라이버 캐시의 이점을 활용하기 위해, 로드하는 동안 PSO를 생성하고 컴파일이 완료되면 즉시 메모리에서 제거합니다. 이것이 바로, 이 기술을 프리캐싱이라고 부르는 이유입니다. 이후 렌더링에 PSO가 필요한 경우 엔진에서 컴파일을 요청하면 드라이버가 캐시에서 반환만 하면 됩니다. 프리캐싱 시스템이 이를 캐시에 저장해 두었기 때문입니다. PSO가 드로잉에 사용되는 경우에는, 이를 사용하는 프리미티브가 씬에서 모두 제거될 때까지 로드된 상태를 유지하므로, 매 프레임마다 드라이버에 계속 요청하지 않아도 됩니다.
PSO를 미리 캐시한 후 버리면 사용되지 않은 PSO가 메모리에 남아 있지 않다는 장점이 있습니다. 다만 필요할 때 드라이버 캐시에서 PSO를 바로 가져오는 데 시간이 걸릴 수 있으며, 컴파일하는 것보다 훨씬 빠르기는 하지만 머티리얼을 처음 렌더링할 때 미세한 스터터링 현상이 발생할 수 있다는 단점이 있습니다.
한 가지 간단한 해결책은 미리 캐시된 PSO를 버리지 않고 유지하는 것이지만, 이 경우 메모리 사용량이 1GB 이상 증가할 수 있으므로 RAM이 충분한 컴퓨터에서만 수행해야 합니다. 현재 저희는 메모리 사용량을 최소화하고 미리 캐시된 PSO를 유지할 수 있는 시점을 자동으로 결정하는 솔루션을 개발하고 있습니다.
실행 가능한 PSO 코드에 영향을 미치는 것은 일부 스테이트로 한정됩니다. 즉, 셰이더는 같지만 파이프라인 설정이 다른 두 개의 PSO를 생성하면 첫 번째 셰이더만 비용이 높은 컴파일 과정을 거치게 되고 두 번째 셰이더는 캐시에서 즉시 반환될 수 있습니다.
안타깝게도 코드 생성에 중요한 스테이트 세트는 GPU마다 다르고, 드라이버 버전의 영향을 받을 수 있습니다. 언리얼 엔진은 프리캐싱 과정에서 일부 순열을 건너뛸 수 있는 몇 가지 실용적인 지식을 활용하고 있습니다. 드라이버 캐시 덕분에 중복 요청이 줄어들긴 했지만, 엔진은 여전히 이를 생성하기 위한 작업을 수행해야 합니다. 이 같은 작업은 결국 누적되기 때문에 가지치기 과정(pruning process)은 메모리 사용량뿐만 아니라 로드 시간을 줄이는 데도 유용합니다.
모바일 플랫폼은 동일한 온디바이스(On-Device) 셰이더 컴파일 모델을 사용하므로 언리얼 엔진의 프리캐싱 시스템은 모바일 플랫폼에서도 매우 효과적입니다. 일반적으로 모바일 렌더러는 데스크톱보다 적은 수의 셰이더를 사용하지만, 모바일 CPU가 더 느리기 때문에 PSO 컴파일이 훨씬 오래 걸립니다. 따라서, 이를 위해 해당 프로세스를 몇 가지 조정해야 했습니다.
거의 사용되지 않는 일부 순열을 건너뛰면 프리캐싱 세트가 더 이상 보수적이지 않게 되므로 일반적이지 않은 스테이트가 렌더링되는 경우 히칭이 발생할 수 있습니다. 또한 맵을 실행하는 동안 로딩 화면이 너무 오랫동안 표시되는 것을 방지하기 위해 프리캐싱 타임아웃을 설정했습니다. 즉, 아직 완료되지 않은 컴파일 작업이 남아 있는 동안에도 게임은 시작될 수 있다는 뜻입니다. 이때 진행 중인 PSO 중 하나가 당장 필요한 경우 스터터링이 발생할 수 있습니다. 이러한 히칭 현상을 최소화하기 위해 우선순위 부스트 시스템을 사용하여 PSO가 필요한 경우 작업을 대기열의 앞쪽으로 이동시킵니다.
콘솔은 단일 타깃 GPU를 사용하므로 이러한 문제를 해결할 필요가 없습니다. 각 셰이더는 실행 가능한 코드로 바로 컴파일되어 게임에 포함되어 배포됩니다. 동일한 버텍스 셰이더와 여러 픽셀 셰이더를 함께 사용하거나 다양한 파이프라인 스테이트로 인해 조합의 수가 크게 늘어나는 것은 걱정하지 않아도 되는데, 이러한 요소는 재컴파일을 유발하지 않기 때문입니다. 셰이더와 스테이트는 런타임에서 큰 비용 없이 PSO에 조합될 수 있으므로 이러한 플랫폼에서는 PSO 히칭이 발생하지 않습니다
'Direct3D 11에는 이러한 문제가 없었다'는 오해가 있어, 이전 컴파일 모델이나 심지어 이전 그래픽 API로 돌아가자는 요청도 가끔 접수됩니다. 앞서 설명한 바와 같이 당시에도 히칭은 발생했으며, API 설계 방식상 엔진에서 이를 방지할 수 있는 방법은 없었습니다. 그저 당시의 게임에는 셰이더가 더 단순하고 적었으며, 레이 트레이싱과 같은 일부 기능은 존재하지도 않았기 때문에 오류 발생 빈도가 적거나 시간이 짧았을 뿐입니다.
드라이버 또한 스터터링을 최소화하고자 많은 노력을 기울였지만 완전히 방지할 수는 없었습니다. Direct3D 12는 이 문제가 더 악화되기 전에 PSO를 도입하여 해결하려고 했으나, 기존 머티리얼 시스템을 개조하기가 어렵기도 했고, 게임의 복잡성이 커지면서 드러난 API의 단점 때문에 엔진이 이를 효과적으로 사용하는데에 시간이 걸렸습니다.
특히 언리얼 엔진은 사용 사례가 많고 다양한 기존 콘텐츠와 워크플로를 갖춘 범용 엔진이기 때문에 이 문제를 해결하기가 특히 어려웠습니다. 그렇지만 마침내 실행 가능한 솔루션을 확보하는 단계에 이르렀고, Vulkan 익스텐션 그래픽 파이프라인 라이브러리와 같은 기존 API 단점을 해결하는 훌륭한 시도들도 있습니다.
프리캐싱 시스템은 언리얼 엔진 5.2에 실험단계로 도입된 이후 많은 발전을 거듭해 왔으며 대부분의 셰이더 컴파일 스터터링을 방지하고 있습니다. 하지만 아직 일부 적용 범위에 있어 격차가 존재하며 또 다른 제한 사항들이 있기에, 이를 개선하기 위한 노력이 계속되고 있습니다. 또한 하드웨어 및 소프트웨어 공급업체와 협력하여 게임에서 이 시스템이 사용되는 실제 방식에 맞게 드라이버 및 그래픽 API를 조정하고 있습니다.
궁극적인 목표는 프리캐싱을 자동으로 그리고 최적으로 처리하여 게임 개발자가 별다른 조치를 하지 않아도 히칭이 발생하지 않도록 하는 것입니다. 이러한 시스템이 완성되기 전까지 원활한 게임플레이를 보장하기 위해 라이선스 사용자 분들이 적용할 만한 리스트가 있습니다.
이 주제에 대해 더 자세히 알고 싶으시다면 한국 시간으로 2월 7일 금요일 오전 4시에 Twitch와 YouTube에서 진행되는 인사이드 언리얼 스트리밍에 참가하세요.
언리얼 엔진 설치 방법
런처를 다운로드합니다
언리얼 에디터를 설치하고 실행하려면 먼저 에픽게임즈 런처를 다운로드하여 설치해야 합니다.
언리얼 엔진을 설치합니다
로그인을 마치면 언리얼 엔진 탭을 찾고 설치 버튼을 클릭하여 최신 버전을 다운로드합니다.