역자의 말.
최근 가까운 지인분께서 소천 하시고 나서 2주일 동안 블로그에 집중하기에 무리가 있었네요. 이제 일상으로 돌아와 조금씩 원래의 제 모습으로 복원?되고 있지않나 싶습니다. 종종 발표자료나 기술 자체에 대한 내용들을 포스팅 하거나 번역글로 올리곤 하는데 이번 글은 좀 더 재밌게 읽어 볼 맛이 나겠나 싶어 이렇게 공유 해 봅니다. 吕文伟군이 어떤 문제에 당면했을 때 자신과의 사투를 벌인 개발 일기와 같은 글입니다.
저자: 吕文伟
최근 나는 GPU Driven Rendering 관련 작업을 진행하고 있었다. 어느 날 동료가 자신이 구현한 Frustum Culling 기능에 문제가 생긴 것 같다고 말해왔다. 화면 속에서 Culling된 Geometry 인스턴스들이 계속 깜빡인다는 것이었다. 그런데 재미있는 현상이 하나 있었는데, 렌더링하는 오브젝트 수가 많아질수록 오히려 깜빡임이 덜 눈에 띈다는 것이었다. 나도 내 컴퓨터에서 이 문제를 재현해봤는데, 솔직히 꽤 당황스러웠다. Culling 코드 로직 자체는 굉장히 단순했고, 유일하게 의심스러운 건 InterlockedAdd라는 내장 함수 정도였는데, 그것도 딱히 꼬집을 만한 문제가 없었다. 어차피 이 원자 연산은 빼버릴 수도 없는 것이라, 없애면 알고리즘 자체가 성립하지 않는다.
사실 동료는 그 전에도 한 가지 문제를 제보한 적이 있었다. 첫 번째 버전을 구현했을 때 프레임률이 심각하게 떨어진다는 것이었는데, 꽤 의외의 결과였다. 관련 코드를 살펴보니 원인은 금방 찾을 수 있었다. 파이프라인에 익숙하지 않아서 초기화 단계를 빠뜨린 것이었다. Indirect Argument Buffer를 초기화하지 않은 상태에서 누산을 시작하면, 당연히 카운터 값이 실제 렌더링해야 할 수량을 훨씬 초과하게 된다. 원인을 파악한 후 동료는 일단 편하게 RHIClearUAVUint를 호출해서 Buffer를 초기화하는 방법을 택했다. 그런데 초기화를 해도 깜빡임 문제는 여전히 사라지지 않았다.
최근 나는 GPU Driven Rendering 관련 작업을 진행하고 있었다. 어느 날 동료가 자신이 구현한 Frustum Culling 기능에 문제가 생긴 것 같다고 말해왔다. 화면 속에서 Culling된 Geometry 인스턴스들이 계속 깜빡인다는 것이었다. 그런데 재미있는 현상이 하나 있었는데, 렌더링하는 오브젝트 수가 많아질수록 오히려 깜빡임이 덜 눈에 띈다는 것이었다. 나도 내 컴퓨터에서 이 문제를 재현해봤는데, 솔직히 꽤 당황스러웠다. Culling 코드 로직 자체는 굉장히 단순했고, 유일하게 의심스러운 건 InterlockedAdd라는 내장 함수 정도였는데, 그것도 딱히 꼬집을 만한 문제가 없었다. 어차피 이 원자 연산은 빼버릴 수도 없는 것이라, 없애면 알고리즘 자체가 성립하지 않는다.
사실 동료는 그 전에도 한 가지 문제를 제보한 적이 있었다. 첫 번째 버전을 구현했을 때 프레임률이 심각하게 떨어진다는 것이었는데, 꽤 의외의 결과였다. 관련 코드를 살펴보니 원인은 금방 찾을 수 있었다. 파이프라인에 익숙하지 않아서 초기화 단계를 빠뜨린 것이었다. Indirect Argument Buffer를 초기화하지 않은 상태에서 누산을 시작하면, 당연히 카운터 값이 실제 렌더링해야 할 수량을 훨씬 초과하게 된다. 원인을 파악한 후 동료는 일단 편하게 RHIClearUAVUint를 호출해서 Buffer를 초기화하는 방법을 택했다. 그런데 초기화를 해도 깜빡임 문제는 여전히 사라지지 않았다.
그래서 나는 RenderDoc으로 좀 더 깊이 분석해봤다. 캡처 데이터를 확인해보니, RHIClearUAVUint가 D3D의 특정 API(ClearUnorderedAccessViewUint)를 직접 호출하는 게 아니라, Compute Shader를 이용해서 Buffer 내용을 지우는 방식이었다. 관련 셰이더 코드(ClearReplacementShaders.usf)를 살펴봤는데, 놀랍게도 셰이더 안에서 사용하는 데이터 타입이 uint4였다. 반면 Indirect Argument Buffer의 데이터 포맷은 PF_R32_UINT로, 이 둘은 명백히 맞지 않는다. 당연히 메모리 범위 초과 접근이 발생할 수밖에 없다. D3D12UAV.cpp에서 관련 코드를 찾아보니 역시나 그랬다. 전달되는 Buffer가 1채널이든 2채널이든 4채널이든 간에 모두 4채널로 처리하고 있었다. 아래 그림에서 확인할 수 있다:


단서가 없으니 무작정 수정을 시도해볼 수밖에 없었다. 처음에는 InterlockedAdd가 문제라고 의심했다. 스레드 그룹 간 데이터 일관성이 보장되지 않아 누산이 잘못되는 게 아닐까 싶어서 스레드 그룹 수를 하나로 줄여봤다. 하지만 깜빡임은 사라지지 않았다. 이어서 RWBuffer 선언에 globallycoherent 키워드를 추가해봤다. 역시 소용없었다. 이제 정말 방법이 없다는 느낌이 들었고, 깊은 자기 회의에 빠져들었다. GPU Driven Rendering 기능을 이대로 포기해야 하나 싶어 억울하기도 했다. UE4에서도 비슷한 알고리즘을 구현한 적이 있었는데, 그땐 이런 문제가 없었다. 지금과 뭔가 다른 점이 있을 것이었다.
밤새 고민하다 보니 문득 한 가지 영감이 떠올랐다. RHI 내부 문제가 아닐까? 예전에 UE4에서는 주로 D3D11을 사용했고 D3D12는 거의 테스트해본 적이 없었다. 반면 현재 프로젝트는 Nanite 기능을 활성화해야 해서 기본 RHI가 D3D12였다. 알다시피 D3D12는 저수준 API라서, 원래 드라이버가 처리하던 많은 로직을 상위 레이어에서 직접 다뤄야 한다. 대표적인 예가 Resource Barrier인데, 이건 패스 간 데이터 동기화를 담당한다. 혹시 엔진이 일부 Barrier를 빠뜨린 건 아닐까? 그럴 가능성은 낮아 보였다. FRDGBuilder가 컨텍스트에 따라 패스 사이에 자동으로 Barrier를 삽입하고, 리소스 사용 상태를 추적하기 때문이다. 게다가 UE4 시절부터 존재하던 기능이 그렇게 허술하게 구현되어 있을 리 없다. Epic Games 엔지니어들이 그 정도 실수를 했을 리는 없다.
메모리 범위 초과가 깜빡임의 원인일 가능성을 배제하기 위해, 나는 동료에게 Buffer 초기화를 커스텀 Compute Shader로 교체해보도록 했다. 이 정도면 문제가 해결될 거라 기대했는데, 결과는 실망스러웠다. 메모리 범위 초과 위험을 없앴는데도 깜빡임은 여전히 따라다녔다. 생각보다 훨씬 질긴 문제였다.
일단 동료에게 D3D11로 바꿔서 테스트해보도록 했다. 테스트 결과, D3D11에서는 깜빡임이 확실히 줄었다고 했다. 주의 깊게 보지 않으면 Geometry 인스턴스가 가끔 사라졌다 나타나는 걸 알아채기 어려울 정도였다. 그리고 RenderDoc을 붙이면 깜빡임이 완전히 사라졌다. 마치 마법 같았다. 이 현상을 보고 나는 D3D11RHI 내부에 특수한 비표준 기능이 사용되고 있는데, RenderDoc 환경에서는 그 기능이 제대로 동작하지 않아서 깜빡임이 사라지는 게 아닐까 추측했다.
며칠간의 지친 디버깅 끝에 거의 손을 들 뻔했다. 합리적인 설명을 도저히 찾을 수 없었고, 완전한 막다른 골목에 몰린 기분이었다. 그래서 그냥 Nvidia 탓으로 돌리고, 드라이버 문제라고 결론 내릴까 생각했다. 간단한 데모를 만들어 버그를 재현한 뒤 그쪽 엔지니어에게 넘겨버리고 나는 편하게 결과만 받아볼 생각이었다. 다행히 그 어리석은 결정을 실행에 옮기지 않았다. 그랬다면 두고두고 웃음거리가 되었을 것이다. 덧붙이자면, D3D12RHI에서도 RenderDoc을 붙여본 적이 있었는데, 그때마다 Culling 관련 Dispatch Call을 찍어보면 Indirect Argument Buffer의 InstanceCount 파라미터가 가끔 0이 되는 걸 볼 수 있었다. 그걸 보고 혹시 RenderDoc 자체의 버그가 아닐까 의심하기도 했다.
또 한 번 밤을 꼬박 새워 생각하다가, Compute Shader와 관련된 어떤 특성이 떠올랐다. 하드웨어 벤더가 제공하는 확장 API를 통해 동작하는 기능이었다. 예전에 GPU Skin Cache 최적화 작업을 할 때 접한 적이 있었는데, 내부 구현을 자세히 들여다보지 않아서 정확히 어떻게 동작하는지는 몰랐다. 다만 Compute Shader의 병렬 처리 성능을 높여준다는 것만 알고 있었다. 그게 바로 제목에서 언급한 UAV Overlap이다. 이름 그대로, 여러 UAV 워크로드가 서로 간섭 없이 동시에 실행될 수 있도록 하는, 즉 시간 축 상에서 겹치게 만드는 기능이다. 관련 코드는 아래와 같다:

위 코드를 보면, FBaseGPUSkinCacheCS라는 Compute Shader가 BeginUAVOverlap과 EndUAVOverlap이라는 RHI API 사이에 감싸여 있다. 그리고 이 두 API는 FRHIUnorderedAccessView 타입 객체 배열을 전달받는다. 이런 구조 때문에 UAV Overlap 기능이 전달된 객체에만 적용되는 것처럼 오해하기 쉽다. 하지만 관련 코드를 직접 확인해보면 사실이 다르다는 걸 알 수 있다.

위 그림처럼, D3D11RHI에서 이 두 함수는 빈 구현체다. 아무런 로직도 없다. 계속 따라가다 보면 또 하나의 사실을 발견하게 된다. UAV Overlap은 기본적으로 자동으로 활성화된다. 예를 들어 아래 그림처럼 EnableUAVOverlap 함수를 호출하면 드라이버가 UAV Overlap 기능을 켜도록 만들 수 있는데, 주목할 점은 관련 API에 UAV 객체를 전달할 필요가 없다는 것이다. 즉, 현재 작동 중인 모든 UAV 객체에 일괄적으로 적용된다.



그렇다면 UAV Overlap은 어디서 꺼지는 걸까? 우선 확실한 건, EndUAVOverlap 함수가 UAV Overlap을 멈추지 않는다는 것이다. 오직 RHIEndTransitions를 통해서만 중단할 수 있다. 아래 그림에서 이 사실을 명확히 확인할 수 있다.

따라서 UAV Overlap 기능을 끄려면 반드시 FRHITransition을 거쳐야 하며, 이는 FRDGBuilder와 밀접하게 연관되어 있다. 물론 RHICmdList.Transition처럼 RHI API를 직접 호출해서 처리할 수도 있다. 내 가설을 빠르게 검증하기 위해, D3D11RHI에서 UAV 자동 플러시 기능을 강제로 비활성화해봤다. 즉, r.D3D11.AutoFlushUAV 콘솔 변수를 이용했다. 그러자 D3D11RHI에서 깜빡임이 사라졌다. 드디어 진짜 원인을 찾은 것 같았다. 생각을 더 검증하기 위해 D3D12RHI에서도 같은 방법을 시도해보려 했다. 그런데 D3D12RHI의 RHIDispatchComputeShader에는 UAV Overlap을 능동적으로 활성화하는 함수가 없었다. D3D12에는 이미 Resource Barrier가 있기 때문에, D3D11처럼 드라이버의 확장 API에 의존해서 UAV Overlap을 구현할 필요가 없다는 걸 그제야 깨달았다.
그렇다면 D3D12RHI는 UAV Overlap을 어떻게 처리할까? D3D11과 마찬가지로, 수동으로 Transition을 추가해야 한다. 최종적으로 FD3D12StateCache::FlushComputeShaderCache가 UAV Barrier를 추가한다. 아래 그림에서 알 수 있듯이, UAV의 Resource Barrier는 구체적인 리소스를 지정할 필요가 없다. 전역 동기화 포인트를 놓는 것과 동일하다. D3D12 관련 문서에서도 이 점을 명시하고 있다.


소스 코드를 꼼꼼히 읽다 보면 또 다른 단서들을 발견할 수 있다. 예를 들어, D3D12RHI에도 AutoFlushUAV 기능이 없는 게 아니라 그냥 깊이 숨어 있었다. 아래 그림처럼 RDG의 일부가 되어 있는 것이다.




UAV Barrier를 건너뛰고 싶다면, RDG UAV를 생성할 때(FRDGBuilder::CreateUAV) ERDGUnorderedAccessViewFlags::SkipBarrier 플래그를 추가하면 된다. 더 높은 병렬 처리 성능을 얻을 수 있다. 이제 다시 RDG 이야기로 돌아가보자. RDG가 패스에 바인딩된 모든 리소스의 상태 변화를 추적할 수 있냐는 질문에 대한 답은 '아니오'다. RDG와 관련된 리소스만 추적할 수 있다. 아래 그림을 보면 알 수 있다:

EnumerateTextureAccess와 EnumerateBufferAccess는 RDG 접두사가 붙은 리소스만 순회한다. 따라서 GRDGOverlapUAVs를 0으로 설정해도 내가 앞서 설명한 깜빡임 문제는 여전히 존재할 수 있다. 프로젝트에서 생성한 Indirect Argument Buffer가 RDG에서 온 게 아니기 때문이다. 한 가지 주목할 점은, 같은 UAV에 접근하는 두 패스 사이에 UAV Barrier가 자동으로 삽입되지는 않더라도, 해당 리소스가 다른 셰이더에서 SRV로 바인딩될 때는 RHI 내부에서도 리소스 사용 상태를 추적하기 때문에 적절한 Transition Barrier가 삽입된다. 아래 그림을 참조하자:

아래 그림을 보면, UAV 바인딩도 비슷한 기능을 가지고 있다. 하지만 전후 상태가 동일하기 때문에(D3D12_RESOURCE_STATE_UNORDERED_ACCESS) 어떤 Transition Barrier도 생성되지 않는다. 그리고 우리가 필요한 건 Transition Barrier가 아니라 UAV Barrier다. 이 둘은 기능이 다르다.

위의 분석을 통해 문제의 원인을 파악했으니, 수정 방법은 사실 매우 간단하다. Culling 패스 안에 아래 그림과 같은 코드 한 줄을 추가하여 Dispatch 전에 실행되도록 하면 된다. 단, 이후 패스들이 여전히 병렬로 실행될 수 있도록(즉, UAV Overlap이 유지되도록) 모든 패스에 UAV Barrier를 추가할 필요는 없다. 첫 번째 패스에만 추가하면 충분하다. 물론 Buffer를 FRDGBuilder로 할당(FRDGBuilder::CreateBuffer)한 뒤 FRDGBuilder::QueueBufferExtraction으로 추출해서 이후 패스에서 사용하도록 바꾸는 방법도 있다. 이쪽이 더 편하다. 매 프레임 할당하는 비용이 부담된다면, AllocatePooledBuffer 전역 함수를 사용해도 같은 효과를 얻을 수 있다.

글 서두에서 언급했던 이상한 현상, 즉 렌더링 부하가 줄어들수록 깜빡임이 더 두드러진다는 점도 이제 이해할 수 있다. 초기화 Compute Shader와 Culling Compute Shader가 동시에 실행되었기 때문이다. Dispatch Call에 순서가 있더라도, Culling 패스가 아직 끝나지 않은 시점에 GPU가 초기화 패스를 스케줄링하기 시작할 수 있다. 또는 캐시가 제때 플러시되지 않아서, 초기화 Compute Shader가 InterlockedAdd로 누산된 Buffer의 값을 도로 기본값으로 되돌려버리는 기회를 얻는 것이다. 이 상황이 매 프레임 일어나지도 않고 그 정도도 제각각이기 때문에, 결과적으로 깜빡임으로 나타나는 것이다.
마지막으로 오래된 이야기를 하나 더 언급하고 싶다. 몇 년 전, 나는 비슷한 문제로 머리를 쥐어짜던 적이 있었다. Distance Field Shadowing이 특정 상황에서 그림자가 깜빡이는 문제였는데, 당시 도저히 원인을 알 수 없어서 이 버그를 Nvidia에 넘긴 적이 있었다. 아마 드라이버 레벨에서 해결했을 것이다. 우리 프로그램에 일종의 '뒷문'을 열어주는 방식으로, 내부적으로 강제로 동기화 포인트를 삽입했을 것이다. 지금 다시 UE4 코드를 살펴보면(아래 그림 참조), 내가 앞서 언급한 문제가 실제로 존재함을 확인할 수 있다. Buffer가 RDG를 통해 생성된 게 아니기 때문에 엔진은 패스 안에서 능동적으로 Transition을 추가했다. 하지만 ClearUAVUint 내부에서도 Compute Shader가 실행되고, 이 Compute Shader는 기본적으로 UAV Barrier를 삽입하지 않는다. 그래서 이후의 FComputeCulledObjectStartOffsetCS가 TClearReplacementCS와 시간적으로 겹칠 수 있고, 깜빡임은 필연적인 결과가 된다. 초기 GPU들이 Compute 패스 병렬 처리를 지금만큼 잘 지원하지 못했기 때문에, 이런 문제가 당시엔 쉽게 눈에 띄지 않았을 것이다.


아래 그림처럼 UE5의 관련 구현과 비교해보면, 이런 문제가 더 이상 발생하지 않는다는 걸 알 수 있다. Buffer 생성을 GraphBuilder를 통해 제대로 처리하고 있기 때문이다. 내 주장을 더욱 확인하기 위해 RenderDoc 캡처 데이터를 확인해보니, 패스 사이에 이미 UAV Barrier가 삽입되어 있었다. 앞뒤 두 패스가 시간적으로 겹치지 않도록 보장하며, 리소스 경쟁 위험도 없다.

원문
https://zhuanlan.zhihu.com/p/1912902267457238754?share_code=XYhaTW4FSkSY&utm_psn=2020855595213829331
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| RenderDoc — 나만의 AI 프레임 분석 워크플로우 구축하기 (2) | 2026.03.12 |
|---|---|
| [FEAT. AI]아티스트는 왜 데이터 스팩 가이드가 존재해도 확인 없이 커밋을 하는가 (0) | 2026.03.09 |
| [번역] RenderDoc 활용법 (0) | 2026.03.06 |
| [번역] 어쌔신 크리드 미라지에서의 뉴럴 텍스처 압축 적용기 (0) | 2026.02.27 |
| [번역] GPU-Driven Rendering in Assassin's Creed Mirage (2) | 2026.02.26 |