(개발 관련 검색했을 때 한국어로 된 글 90퍼센트는 일기장이라는 어느 영상을 보고 뜨끔하여 앞으로 일기장이 아닌 정보 전달을 위한 글을 써보고자 한다. 근데 사실 일기장이 맞긴하다. 애초에 노트라고 이름도 적어놨고... 딱히 광고를 붙이거나 해서 부수입을 얻고자 하는 것도 아니니)
서문
게임 제작시 최적화를 위해 어떻게 리소스를 제작해야 하며, 어떤 식으로 렌더링을 해야하는 것일까? 어떤 기술은 왜 무겁고 어떤 기술은 무엇을 했길래 어떤 측면에서 가벼워 진 것인가? 정보를 찾기가 쉽지 않고 막연하다. 지금까지 인터넷을 찾아보고 프로파일링을 진행해보면서 생긴 나름의 인사이트를 한번 정리해보고자 한다. 당연히 틀린 부분이 있을 수 있기 때문에 참고만 할 뿐 맹신하지 않아야 한다. 잘못되거나 추가할 내용은 생각날 때마다 갱신하는 걸로.
사실 인터넷 찾아보면 다 나오는 내용 아니냐 할 수 있는데, 딱히 틀린 말은 아니다. 다만 파편화된 정보를 한 곳에 모아서 정리하는 것도 의의가 있다고 생각한다.
GPU에 포커스
모바일과 PC의 GPU 아키텍처가 달라서 어떤 부분은 맞는 말이 되고 어떤 부분은 틀린 말이 될 수 있다. 아직까지 파악한 건 모바일이 타일 기반 렌더링을 지원한다는 건데, 요즘 PC쪽도 타일 기반으로 가는 중이라고 들어서 어찌보면 둘다 비슷할 수도 있겠다. 또 하드웨어마다 어떤건 지원이 되고 안되고 이런게 다양해서 참... 애매하다. 그래서 이 파트는 결국 결론을 '확실한 건 프로파일링을 해봐야 알 수 있다'가 될 것 같다. 그래도 지금까지 알아봤던 것을 정리하면 백지 상태에서 시작하는 것보단 도움이 많이 될 것이다.
Overdraw
항상 최적화에 대한 토픽을 얘기할 때 Fragment Shader, 픽셀 부하에 집중하는 경우가 많다. Overdraw와 Clip(Cutoff), Transparent에 대한 얘기이다. 최종 픽셀을 여러 번 덮어쓸수록 낭비가 되는 건 당연하고, 반투명과 같이 이전에 그려진 픽셀 데이터를 Alpha 값에 따라 덮어쓰는 기능은 부하가 걸린다고 생각할 수 있다. 덮어쓰게 되면 왜 무거워 지는가?
Overdraw를 무겁다고 하는 이유
1. Fragment Shader Invocation
픽셀을 여러 번 써야 하기 때문에 Shader가 Overdraw가 될 때마다 실행되어야 한다.
2. Memory BandWidth
셰이더 호출에 따라 연산에 필요한 버퍼를 읽고 쓰는 시간이 늘어난다. 또한 프레임 버퍼에 값을 쓰기 위한 부하 등 메모리 대역폭을 소비한다.
다른 것도 있겠지만 일단 크게 이 2가지라고 보면 될 것 같다. 아직 GPU의 Warp와 Bandwidth에 대해서 직관적으로 와닿지는 않지만 다음과 같이 생각하면 편할 것 같다.
메모리 할당, 제거, 읽기, 쓰기는 모두 공짜가 아니다.
Discard(AlphaTest)
Overdraw를 피하기 위해서 Discard를 쓰는건 현명한 생각인가? discard를 사용하면 마치 return을 한 것처럼 셰이더를 조기 종료 시킬 수 있을까? 일부는 맞고 일부는 틀렸다.
https://github.com/gpuweb/gpuweb/issues/361
shaders: semantics of "discard" · Issue #361 · gpuweb/gpuweb
GLSL and HLSL have long had a useful "discard" primitive. MSL has discard_fragment(). GLSL's discard maps to OpKill in SPIR-V. In D3D discard, the invocation continues execution so it can later par...
github.com
API에 따라 다른 동작을 하는 것으로 보인다. Vulkan의 경우 discard 구문이 OpKill로 바뀌는데, 이는 실제로 조기 종료를 시킬 수 있다고 위 문서에는 나와있다. 그러나 Metal 2.3에서는 프로그램이 종료되지 않고 도함수 계산을 위해 병렬로 계속 실행되지만 해당 Fragment는 만료되었다는 표시를 하고, 마지막에 쓰기 동작을 하지 않는 식으로 동작한다고도 나와있다. 이른바 "Helper Pixel"이 된다는 의미이다. 이는 이후에 설명할 2x2 쿼드 단위의 프로그램 실행과 유사한데, 아무튼 fragment shader의 연산 비용과 버퍼에 데이터를 쓰기 위한 메모리 BandWidth를 절약할 수 있는 수단으로 Transparent에 비해 가볍다고 판단할 수 있다.
모든 Warp에서 픽셀이 discard가 된다는 게 보장이 되면 중지가 되겠지만, 같은 Warp 내에서 어떤 픽셀은 discard가 되지 않는다면 여전히 코드는 실행되어야 한다. 우리가 ddx, ddy, fwidth를 통해 2X2 블록의 다른 픽셀의 데이터 값을 참조할 수 있는 이유가 이렇게 프로그램이 병렬적으로 동시에 같은 줄 코드를 실행하는 GPU의 특성 때문이다. 따라서 어떤 픽셀이 discard가 된다고 해서 Fragment Shader 프로그램이 중지될 것이라고 기대할 수는 없다.
https://developer.arm.com/documentation/102224/0200/Early-Z
Documentation – Arm Developer
developer.arm.com
https://stackoverflow.com/questions/8509051/is-discard-bad-for-program-performance-in-opengl
일반적으로 데스크탑에서 반투명 보단 Discard를 쓰는 게 성능적으로 낫다고 생각하고 있었다. 하지만 discard가 포함될 경우 현재 픽셀이 보이는지 아닌지 여부를 fragment shader를 거쳐야만 알 수 있기 때문에 하드웨어에서 제공하는 최적화 기법을 적용할 수 없다. 그 최적화 기법이란, Early Z Test와 Hidden Surface Removal이다. PC고 모바일이고 모두 적용된다.
Early Z Test(PreZ)
핵심 : Vertex Shader에서 Depth 값을 가져다 쓸 수 있음을 보장하자.
Early Z Optimization, Conservative Z Testing이라고도 불리는 기법이다.
예전에는 깊이 테스트가 Output Merger에서 수행된다고 알고 있었다.
https://stackoverflow.com/questions/17898738/early-z-test-depth-test-in-directx-11
Early Z-test / depth-test in DirectX 11
As a DirectX noob i'm trying to wrap my head around depth buffers and specifically how pixel shaders are called for obscured pixels. From what i understand, the rasterizer calls the pixel shader for
stackoverflow.com
이 스택오버플로 글에서도 알 수 있듯, DirectX11을 공부했던 나로서 EarlyZTest는 생소한 것이었다. 깊이테스트와 깊이 쓰기는 Fragment Shader 다 끝나고 Output Merger에서 수행될 것이라 생각하고 있었다.
대충 원리는 깊이 버퍼에 미리 값이 쓰여있을 경우 ZTest를 Fragment Shader 호출 전에 실시해서 Fragment Shader가 호출되지 않도록 Fragment를 날려버리는 작업이다. 따라서 Depth 값을 먼저 제공해주어야 하고, 유니티 엔진에서는 Depth Priming Mode라는 걸 가지고 Forward Pass가 실행되기 전에 미리 Depth Prepass를 실행해서 Depth Buffer를 구성하는 방식의 최적화를 할 수 있다. 하지만 Depth를 그리는 패스와 라이팅이 적용된 최종 픽셀을 그리는 2Pass가 돌기 때문에 2번의 패스가 실행되는 것과 fragment Shader가 얼마나 무거운지를 가지고 저울질을 해야한다. 근데 PBR 연산을 하게 되면 라이팅이 무거워질 수 밖에 없어서 보통은 Depth Prepass를 먼저 수행하는 게 이득이라고 생각한다.
https://community.khronos.org/t/early-z-and-discard/74748
Early z and discard
Hello everyone, I am a little confused about the conditions for early fragment rejection based on depth. I read that in most drivers early depth tests (between vs and fs) are only used if: -no alpha to sample coverage -no discard in fs -no changes to z pos
community.khronos.org
위 글에 따르면 뭐.. Interlock 연산이 구체적으로 어떻게 동작하는지는 모르지만 ZTest와 ZWrite가 InterlockMin 같은 함수로 한번에 처리되는 것 같다. 버퍼에서 값을 읽어서 현재 깊이 값과 비교해서 더 앞에 있는 값을 덮어쓰는 연산이 한번에 수행된다는 얘기다.
아무튼 이를 위해서 다른 조각이 메모리에 엑세스 할 수 없음이 보장되어야 한다는 조건이 있다고 되어 있다. 즉, Vertex Shader를 거쳐서 만들어진 Z값이 앞으로 수정되지 않는다는 보장이 있어야 Early Z Test가 가능하다는 것이다. 그래서 fragment Shader에서 Depth를 수정하는 SV_Depth 시맨틱을 사용하거나, Discard 연산 등 Depth가 앞으로 안쓰이고 버려질 수 있는 코드가 Fragment Shader에 포함될 경우, Early Z Test가 비활성화된다.
Early Z Test 비활성화 조건
- UAV에 쓰기 코드가 있는 경우 : Fragment Shader에서 UAV 버퍼에 데이터를 쓸 수도 있기 때문에 반드시 Fragment Shader의 실행을 보장해야 한다.
- Discard 구문이 존재할 경우 : Depth가 쓰여지지 않을 수도 있기 때문에 Fragment Shader의 실행을 보장해야 한다.
- SV_Depth 시멘틱을 사용하는 경우 : Depth가 다른 값으로 변경될 수 있기 때문에 Fragment Shader의 실행을 보장해야 한다.
강제로 Early Z Test 활성화하기
Shader Model 5이상을 쓸 경우 강제로 Early Z Test를 활성화하도록 셰이더를 작성할 수 있다.
earlydepthstencil - Win32 apps
셰이더가 실행되기 전에 깊이 스텐실 테스트를 강제합니다.
learn.microsoft.com
Hidden Surface Removal 과 Depth Priming Mode
Hidden Surface Removal(HSR)
Early Z Test를 하드웨어 수준에서 수행하는 기술을 HSR이라고 한다.
https://ettrends.etri.re.kr/ettrends/140/0905001809/28-2_050-057.pdf
TBDR(타일 기반 지연 렌더링)을 사용하는 모바일 GPU는 (위 PDF에서는TBDR에 대해서만 기능이 있는 것 처럼 설명되어 있는데 TBR 구조일 경우에도 동일한지는 모르겠다.) 각 타일별로 depth prepass와 유사하게 타일 내에 들어온 도형에 대한 depth를 먼저 구성하는 기술(on-chip depth buffer)이 적용된 하드웨어가 있어서 depth prepass가 수행되지 않더라도 early z test를 할 수 있다. 그래서 타일 기반 렌더링을 사용하는 모바일 GPU에서는 Early Z Test를 위해 Depth Prepass를 수행하는 것이 불필요한 패스가 될 수 있다.
Depth Priming Mode를 사용해야 할까?
유니티에서 Forward Rendering을 할 때에 한정된 얘기이지만 Depth Prepass를 통해 Depth 정보를 미리 알고 있으면 Forward Pass에서 Depth Texture를 활용한 여러 기술을 적용할 수 있다. 그래서 Forward Rendering일 경우 Depth Prepass는 필수라고 생각한다. 모바일이든 아니든 Forward Pass에서 Depth Texture를 필요로 하는 경우가 많아서 결국 2Pass로 가는 수 밖에 없다.
Dithering(AlphaTest) VS Transparent
https://www.reddit.com/r/Unity3D/comments/l6vj0x/moved_from_normal_transparency_to_dither/?rdt=41873
https://forum.unity.com/threads/general-questions-regarding-early-z.1065779/
General questions regarding Early-Z
I've been taking a second look at some of the steps on the GPU pipeline and one of the things that has been on my mind is regarding Early-Z, since it...
forum.unity.com
앞서 설명한대로 Discard가 여러 최적화를 비활성화한다면 Discard는 절대 쓰지말아야 하는 금기인걸까? 위 글에서는 타일 기반 즉시 모드 렌더러를 사용하는 GPU에서는 Hidden Surface Removal 기능이 존재하지 않기 때문에 AlphaTest 동작이 크게 문제가 되지 않는다고 되어 있다.
지금까지의 내용을 종합해보자면, Discard는 일반적으로 Transparent에 비해 가볍게 동작한다고 기대할 수 있다. 왜냐하면 Transparent는 반드시 셰이더를 return 까지 실행해야 하며, 프레임 버퍼에 데이터를 쓰기를 해야 하기 때문에 discard를 하는 것은 확실히 이득이 될 수 있다. discard 구문이 API마다 다르게 동작한다고 하지만 빠른 종료를 지원하던, Helper Pixel로 처리되든 셰이더를 끝까지 실행하고 쓰기작업을 하는 것보단 가벼울 것이기 때문이다.
하면 TBDR일 경우에는 어떤가? 위 글을 참조하였을 때, Depth Prepass를 사용한다는 전제 하에 Transparent보다 Dither가 TBDR에서도 빠르게 동작할 것이라고 기대할 수 있다. 왜냐하면 Early Z Test가 실패했을 경우 화면에 보이는 것보다 더 많은 픽셀 셰이더를 실행해야 하고, 픽셀 셰이더 이후 Output Merger에서 ZTest, ZWrite 연산을 추가로 해주어야 하는 단점이 있는데, Depth Prepass에서 Depth를 쓰는 Fragment Shader는 매우 가벼운 형태로 작성이 되기 때문에 Early Z Test를 비활성화하더라도 어느정도 감수할 수 있다. 포인트는 이후 Forward Pass 등 실제 픽셀을 그리는 패스를 실행할 때 Early Z Test를 활성화할 수 있도록 조건을 맞춰주는 것이다. Alpha Test 재질에 대해서 ZTest On, ZWrite Off를 할 경우 discard 구문이 Fragment Shader에 존재하더라도 Early Z Test를 활성화할 수 있어 TBDR에서 discard를 사용했을 때의 단점을 상쇄시킬 수 있다. 인터넷 글만 보고 판단한 것이므로 아직 신뢰가 가진 않는다. 직접 테스트를 해볼 수 있으면 좋을 것 같은데, TBDR 실기 테스트를 하고 싶어도 기기가 없으니 그냥 이론 물리학자같이 추측만 할 뿐이다. 나중에 기회가 되면 이 부분에 대해 테스트를 해보면 좋을 것 같다.
결론적으로 나는 다음과 같이 생각한다 :
- Depth Prepass를 활성화하여 discard의 단점을 상쇄한다. 이때 Depth Prepass를 실행하는 셰이더는 최대한 가볍게 동작하도록 작성해야 한다. 텍스처를 사용할 경우 텍스처가 캐시 히트가 될 수 있게 최대한 작은 크기를 사용한다. 작은 크기를 사용하면서 낮아지는 퀄리티를 SDF 등을 활용하여 커버한다. 최대한 Depth Prepass에서 사용하는 리소스는 캐시 히트율 을 높일 수 있게 최적화가 되어야 한다.
- Depth Prepass를 사용하며 Alpha Test 재질에 대해 Ztest On, Zwrite Off로 재질 세팅을 해주었다는 전제 하에, 모든 GPU에서 반투명(Transparent) 재질에 비해 Dithering을 사용하는 것이 가벼울 것이라고 기대할 수 있다. 그 이유는 반투명을 사용할 때의 Overdraw 문제(Shader 실행 및 메모리 쓰기 대역폭)를 최소화할 수 있기 때문이다. 물론 Depth Prepass가 공짜는 아니기 때문에 결국 Forward Pass보다 Depth Prepass가 더 무거워지는 상황이 온다면 Transparent를 사용하자.
Depth Prepass가 무거워지는 상황
- SetPassCall이 늘어나는 경우 : Depth Prepass를 실행하기 위한 오브젝트가 너무 많은데 아무리 최적화를 해도 답이 없는 경우(..사실 이건 최적화를 하려면 얼마든지 할 수 있을 것 같다.)
- 해상도가 높아져서 메모리 대역폭이 너무 커져버리는 경우 : Shader 실행 비용보다 Write Fragment 비용이 너무 높아져 버린 경우
- Depth Prepass 수행하는 셰이더가 무거워지는 경우 : 이펙트 셰이더의 Dissolve 기능 등 Depth Prepass가 무거워지는 요소가 있을 경우 주의해야 함
https://forum.unity.com/threads/shader-performance-questions.1362916/
Question - Shader performance questions
So its been a couple of years since I started shader programming and since I'm not really experienced in the field, I usually avoided certain features...
forum.unity.com
정정한다. discard 쓴다고 Early Z Test가 비활성화되는 게 아니었다.
Early Z Test는 거의 모든 GPU에서 discard 여부와 관계없이 수행된다. 다만 discard문은 ZWrite, 즉 Depth Buffer에 쓰기를 Early Z Test 시점에 ZWrite가 같이 되는 게 아니라, Fragment Shader 실행 이후에 Late ZWrite를 한다는 것이 차이였다.
Quad Overdraw(OverShading)
Rasterization Pipeline
먼저 알아야 할 것은 현재 게임 엔진에서 흔히 쓰이고 있는 Rasterization pipeline에 대한 이해이다.
가상의 3D 공간의 좌표인 x,y,z 위치 값을 어떻게 화면 상의 좌표로 변환할 것인가에 대해서 막연하게 Rasterizer를 통해 2D 픽셀좌표로 변환된다 라고만 알고 있었다. 하지만 Forward Rendering, Deferred Rendering, 그리고 언리얼 엔진의 나나이트라는 이름의 기술은 어떻게 보면 Rasterizer와 Fragment Shader 실행 최적화와 관계가 있다.
GPU에서 Fragment Shader는 보통 2x2 Quad 단위로 실행된다. 하드웨어 수준에서 처리되기 때문에 이 크기를 다르게 조절할 방법은 없는 걸로 알고 있다.
https://wallisc.github.io/rendering/2021/04/18/Fullscreen-Pass.html
Optimizing Triangles for a Full-screen Pass
This is my graphics blog where I’ll post about graphics programming. Probably.
wallisc.github.io
이하 아직 작성중...
'TA' 카테고리의 다른 글
(작성중) [Unity] URP환경에서 GPU Instancing Tool 만들기 (0) | 2025.01.20 |
---|---|
[Unity] Custom Shadow System 제작하기 (1) - 커스텀 그림자 제작의 필요성 (0) | 2024.12.30 |
(작성중) [Unity] Irradiance Volume 만들기 (짭 Adaptive Probe Volume) (0) | 2024.12.29 |
[Unity] Blend State 프로퍼티로 관리할 경우 Blend 옵션 비활성화 (0) | 2024.06.18 |
[Unity] 유니티의 Curve 구현에 대한 추측과 Curve 데이터 활용 (0) | 2023.12.03 |