Unity/TA&Development

[최적화] Shader Variants와 효율적인 사용

김성인 2024. 4. 9. 00:17

[최적화] Shader Variants와 효율적인 사용

 


목차

  1. 이글은...
  2. Shader와 Shader Variants
  3. Shader Variants는 언제 생기는가?
  4. Shader Variants를 줄여보자.
  5. 빌드 후 런타임 중 셰이더는 어떤 키워드들로 컴파일링 되었는지 확인해 보자.
  6. Shader Variants를 효율적으로 사용해 보자.
  7. Shader Graph - Shader Variant Limit
  8. Shader Variants 최적화와 테스트를 진행하며 했던 삽질
  9. 글을 마치며...
  10. 참고자료

이글은...

 

 어느 날 빌드가 너무 오래 걸려 원인을 찾아보니 Shader 컴파일링 때문이고, 다시 이것은 Shader Variants와 관련되어 있어, Shader Variants를 정확히 알며, 최적화와 연관 지어 잊지 않도록 기록을 남긴다. 

 

이 Shader 하나로 빌드 시간이 엄청나게 늘어졌다.

 


Shader와 Shader Variants

 

 Shader는 GPU에서 처리하는 프로그래밍이다. CPU보다 상대적으로 열악한 PC 파워를 사용하기 때문에, 최적화에 있어 더욱 고민을 많이 해야 하고, 특히 모바일 디바이스의 경우 더 많은 고민을 해야 한다.

 유니티에서 Shader를 간단히 살펴보면, Shader Lab이라는 언어를 이용하여 Shader 안에 Sub Shader가, 그리고 그 안에 PASS가 존재한다. 그리고 여느 프로그래밍과 마찬가지로 컴파일링을 통하여 컴퓨터가, 그리고 GPU가 이해할 수 있도록 변환된다.

 유니티에서 빌드를 진행하게 되면 내가 작성한 Shader는 Shader Variants로 컴파일링 되는데, 분명 내가 작성한 Shader 코드는 하나인데, Shader Variants는 그 수가 많게는 몇백만 개까지도 늘어나게 된다.


 Shader Variant의 수는 실질적인 Shader의 수라고 생각하면 좋겠다. 이것은 컴파일링을 진행하며 내가 작성한 하나의 Shader가 여러 가지로 나뉘게 된 것인데, #pragma multy compile 같은 키워드로 인하여 파생되는 Shader라고 생각하면 좋겠다.

 간단한 Shader라도 이 Shader Variants의 수는 한 개 이상이 존재하는데, 게임이 고도화될수록 사용하는 Shader의 종류도 늘어나게 될 것이다. 우버 셰이더 하나로 모든 애셋들을 커버한다고 하더라도, 분명 개발과 유지 보수가 쉽지 않다. 그렇기 때문에 최대한 효율적으로 분류해서 사용해야 할 것이다. 예를 들어서 캐릭터, 배경, VFX, UI 최소한 이렇게 4개는 필요할 것이다.

 다시 Shader Variants로 돌아와서 캐릭터용 우버 셰이더 하나에 많은 기능들을 넣었다고 상상해 보자. 많은 기능은 곧 셰이더가 무겁다는 것과 연결된다. 그리고 게임 내 캐릭터들은 셰이더에서 구현한 모든 기능들을 다 사용하고 있지 않을 것이다.  셰이더의 경우 CPU에서 돌리는 프로그램과 다르게 If 같은 함수에서 사용하지 않아도 될 코드들까지 모두 계산한다. 그렇기 때문에 최적화를 위하여 빌드를 진행할 때, 컴파일러는 셰이더의 사용 유무에 따라 기능을 나눠서 셰이더를 만들어 낸다고 생각하면 이해하기 쉬울 것 같다.

 그리고 이렇게 만들어진 Shader들이 Shader Variants이다.

 앞의 내용만 보면 Shader Variants는 정말 최적화에 좋은 친구이다. 그리고 여기서 이렇게 쉽고 행복하게 끝이 난다면 좋겠지만 이 Shader Variants가 많아진다면, 빌드의 속도가 느려지며, 빌드(패키지) 용량 또한 늘어난다.  동시에 메모리에 올려두어야 할 Shader Variants가 늘어남에 따라 메모리에서도 문제가 된다. 사실 메모리 쪽 용량은 앞의 빌드 속도와, 빌드 용량에 비해서 크게 문제가 되지는 않지만, 효율적으로 구성하지 못하게 되면, 게임 로딩 시간이 늘어나거나, VFX가 연출될 때 등 특정 상황에서 프레임 드롭이 일어날 수 있다.


Shader Variants는 언제 생기는가?

 

  • Shader 코드 최적화를 위하여 만들어지는 분기
    •  Shader를 작성할 때, #if defined를 사용한 적이 있을 것이다.  이것은 셰이더 프로그래밍의 특징상 if 등의 분기를 만나거나, 사용하지 않더라도 처리를 한다는 것이다.  그렇기 때문에 셰이더를 가볍게 만들기 위하여 #pragma multi_compile ~ 과 #if defined 등을 통하여 빌드 때, 하나의 Shader를 각 분기 때마다 사용 유무에 따라 최적화된 Shader 코드로 만들어 준다.

       쉽게 설명하면, A라는 셰이더 안에는 + 수식이 존재하는데, 상황에 따라서 수식을 사용할 때와 사용하지 않을 때 두 가지 상황이 존재한다고 가정해 보자.  수식을 사용하지 않더라도 셰이더는 항상 + 수식을 처리한다.  그렇기 때문에 + 수식을 처리하지 않을 때는 이 비용을 줄이기 위하여 빌드를 진행할 때, A라는 셰이더를 + 수식 사용, + 수식 미사용으로 두 개의 셰이더로 만들어 준다.

       여기까지는 문제가 없지만, 이런 식으로 다른 수식이나, 기능을 추가하여 분기를 늘리게 되면, 1개의 Shader는 2개, 4개, 8개, 16개... 이런 식으로 분기를 거칠 때마다 계속해서 늘어나게 된다!

       위 예시에서 사용된 키워드만으로도 벌써 2의 10승 만큼 Shader Variants가 만들어진다.
  • 실제 플레이 때는 어떻게 되는지 알아보자.
    • 위쪽 예시의 키워드중 #pragama multi_compile _ _SHADOWS_SOFT 키워드를 보면, 소프트 셰도우에 관련 있다는 것을 눈치챌 수 있을 것이다.
    •  이것을 위해서 유니티의 Light/Shadow 속성을 Hard Shadows, Soft Shadows로 나눠 적용해 보면, 셰이더 컴파일링 로그에서 아래와 같이 확인이 된다.
    • Hard Shadows 일 때
    • Soft Shadows 일 때
  • Shader Graph의 Boolean 키워드에서도 이렇게 작동한다.
    • 키워드 세팅은 이렇게...
    • 키워드를 활성화 하였을 때
    • 키워드를 비활성화 하였을 때

 

  • 지시문에는 세 가지 종류가 존재하는데, 각 지시문마다 용도의 차이가 존재한다.
    • multi_complie : 가능한 모든 키워드 조합에 대하여 배리언트를 만든다.  그렇기 때문에 빌드 시 배리언트가 가장 많이 생성된다.
    • shader_feature : 빌드 시 활성화하는 키워드 조합만 배리언트가 생성된다.
    • dynamic_branch : 빌드 시 배리언트가 생서되지 않는다.  동적 분기로 위 두 지시문과 다르게 사용된다.  동적 분기 말 그대로 런타임 중에 값을 변경하는, 상태 변경이 자유롭다.  그렇지만 레지스터의 공간을 낭비할 수 있다고 하며, 나는 사용해 보지 않았다.

Shader Variants를 줄여보자.

 

 불필요한 Shader Variants를 생성하는 것을 방지하는 방법이 존재하는데, 이것을 Shader Variants Stripping이라고 한다.  이것을 기본적으로 앞에서 설명한 Shader 내부의 키워드 외에도, Graphic API 등 다른 요소들에 의해서도 증가한다.  이것은 게임을 설치하는 유저의 디바이스에 대응하기 위하여(플랫폼에 따라 하드웨어가 이해할 수 있는 기계어가 다르거나, 구조가 다르기 때문에) 빌드 때 모두 준비해 놓는다.

 

  1. Graphics API
  2. Light, Fog
  3. Instancing Variants Strip
  4. URP Shader Stripping
  5. 강제로 

 

  1. 각 디바이스 별로 Grapics API를 자동이 아닌, 내가 원하는 API만 활성화하자.
    • API 별로 각각 배리언트를 생성하기 때문에 'API의 개수만큼 곱하기'로 배리언트가 생성된다.
    • Project Settings/Player/Settings~/Other Settings/Rendering/~
       
  2. Light, Fog 모드
      보통 하나의 프로젝트에는 실시간 라이팅을 이용할지, 베이크드 라이트맵 방식을 이용할지 하나로 통일되어 있을 것이다.  동시에 Fog의 경우에도 Linear나 Exponential 둘 중 하나를 선택하여 제작하고 있을 텐데, 내 프로젝트의 비쥬얼을 어떤 식으로 작업했는지에 따라 맞추면 된다. 
    • Project Settings/Graphics/Lightmap Modes
    • Project Settings/Graphics/Fog Modes
  3. Instancing Variants Strip
      위 메뉴에서 하단에 Instancing Variants가 존재하는데, 이것 역시 Strip Unused로 설정해 두면 미사용 배리언트는 빌드 때 제외하게 된다.
  4. URP Shader Stripping
      Project Settings/Graphics/URP Global Settings/Shader Stripping

 

  셰이더 그래프를 이용하여 테스트하였다.  그리고 위 작업을 진행하여 10026개의 배리언트가 77개의 배리언트로 줄어드는 것을 확인할 수 있다.


빌드 후 런타임 중 셰이더는 어떤 키워드들로 컴파일링 되었는지 확인해 보자.

 

 Log Shader Compilation 을 활성화하면 개발자 모드로 빌드 하면, 컴파일 된 셰이더와 키워드들을 모두 확인할 수 있다.

 

 이 Log는 C:\Users\사용자이름\AppData\LocalLow\프로젝트회사이름(유니티 프로젝트에 설정한 회사) 폴더에 생성되며 아래와 같은 Log를 얻을 수 있다.

 

 위와 같이 설정 후, 빌드를 진행하면 Log 텍스트를 얻을 수 있는데, 이 로그는 빌드를 진행하고, 플레이를 한번 해야 생기더라... 빌드만 진행했을 때는 생기지 않았었고, 구글링 해보니 이것은 Play log라는 명칭을 가지고 있었는데, 이름 그대로 플레이 관련된 로그인 것 같고, 플레이해야만 생긴다.  주의할 점은 빌드 후 플레이 때마다 이 로그가 갱신되는데, 백업의 개념으로 바로 직전의 로그만 Player-prev로 남기고 사라진다.

 

  Player log 외에도 콘솔 창에서 간략하게 확인하는 방법도 존재한다.

  • Shader Stripping/Shader Variant Log Level을 활성화한다.

  • 아래와 같이 콘솔창에 로그가 남는다.


 Shader Variants를 효율적으로 사용해 보자.


 유니티는 빌드 시, 컴퓨터가 이해할 수 있도록 컴파일을 통하여 Shader Variants를 준비해 놓는데, 문제는 Shader Variants를 메모리는 제한적이기 때문에 올려 둘 수 없다는 것이다.  그렇기 때문에 메모리를 효율적으로 사용하기 위하여 기본적으로 메모리에 Shader Variants를 로딩해 두지 않고, 필요할 때 메모리에 올린다.  여기서 바로 문제가 생긴다.  렌더링을 거치는 것도 시간이 걸리지만, 메모리에 올리는 것도 시간이 걸린다는 것이다.  그래서 유니티는 기본적으로 항상 메모리에 올려둘 Shader를 선택할 수 있다.


 Project Settings/Graphics/Always Included Shaders에 셰이더를 올려두면 된다.


 다음으로 Shader Variants Collections를 이용하는 것이다.  이것은 위와 비슷하지만 Shader 내부의 키워드를 선택하여 '선택적으로 Shader Variants'를 미리 준비해 두는 것이다.  이것을 '예열'이라고 표현하더라.


 흥미로운 것은 코드(Scene을 로딩하는 코드에 달 수 있었다)를 이용하여 로딩하는 Scene 각각에 이 Shader Variants를 준비할 수 있다는 것이다.


 결론적으로 Shader를 효율적으로 사용하기 위해서는 위에서 설정한 것 같이, 필요한 Shader만을 미리 로딩 해놓는 것이 관건인데, 이렇게 미리 로딩하는 이유는 런타임 중에 갑자기 변경되는 Shader의 컨디션, 변수나, Shader Variants를 코드적으로 변경했을 때, 실시간으로 컴파일링 되는 것을 막거나, 순간적으로 계산을 위하여 메모리에 올리는 시간을 없애기 위하여 준비하는 것이다.


 프로젝트를 진행하며 배경 오브젝트 뒤쪽으로 캐릭터가 이동 시 배경 오브젝트의 셰이더를 코드적으로 변경한 적이 있었는데, 처음으로 이 상황에 놓일 때마다 프래임 드랍이 일어났었다.  그리고 VFX 또한 처음 한번은 하늘색으로 표현되었었는데, 그때의 이 증상은 이와 같이 메모리에 셰이더를 미리 올려놓지 않아 생겼던 문제였다.


 물론 위와 같이 메모리에 미리 Shader들을 올려 두는 방법은 로딩 시간의 증가와 메모리를 할당하는 부분에서 손해이기 때문에, 꼭 필요한 Shader만 미리 올려 두어 로딩 시간을 줄이고, 프레임 드랍도 없애는 방법이다.

 

 지금 프로젝트에서는 상황상 실제로 적용해 보지 못했다.  그래서 아쉽다.


Shader Graph - Shader Variant Limit

 

 Shader Graph의 경우 Shader Variant Limit로 개수를 제한할 수 있다고 알고 있다.  하지만 이것을 직접 해보지는 않았다.  단지 1개로 수를 제한하니 에러가 났다.  그리고 이렇게 무작정 제한해버린다면 실제 런타임 중에 셰이더를 로드하지 못하여 의도한 대로 렌더링 되지 못하는 상황이 너무나 뻔하게 눈앞에 그려진다.

 

 정말 꼭 중요한 것은 #multi_complie 지시문과 키워드가 만나면 꼭 Shader Variants가 생긴다는 것이고, 간단하게 이 코드 한줄로 Variants는 약 2배가 된다는 것을 잊지 말자.  Shader Graph의 경우 multi_compile과 shader_feature Boolean 키워드를 사용하기 쉽게 되어있다.


 버그인지 모르겠지만, Shader Graph에서 Boolean KeyWord를 여럿 사용하면, Shader Variants의 개수가 제대로 계산이 되지 않는 것 같다.  예를 들어서 두 개의 Boolean Keyword를 사용하여 각각 shader_feature와 multi_compile로 지시문을 사용하면, Shader Variants의 개수는 2배로만 늘어난다. 세 개의 Boolean Keyword 또한 이렇게 작동한다.  하지만 세 개 모두 multi_compile로 설정하면 4배로 늘어난다... 

 그리고 이렇게 글을 작성하면서 번쩍하고 떠오른 부분이 있다. shader_feature는 사용하지 않는 분기는 컴파일 하지 않는다는 것... 그렇다 그렇기 때문에 Shader graph를 이용하여 작성한 노드(Shader)의 구조가 shader_feature 지시자를 어떻게 이용했느냐에 따라서 계산되는 거였다... 결국 버그 아님.


 Shader Variants 최적화와 테스트를 진행하며 했던 삽질

 

 나는 먼저 어떤 작업을 진행할 때, 생각을 많이 한다.  그리고 보통 몇 가지 가설을 세워 그 가설대로 테스트를 진행하는 경우가 종종 있는데, 이 글을 읽는 누군가가 나와 같은 삽질을 하지 않길 바라며 남긴다.

 

  • Unity 2022.3.6에서 작업을 진행하였다.  버전 때문인지 모르겠지만, 유니티에서 생각보다 기본적으로 Auto로 된 설정들이 잘 작동하였다.  그리고 이것은 착각이었다... PC로만 테스트할 때 이렇게 느끼는 거였다.
  • IPreprocessShaders 클래스를 이용한 스크립트는 콘솔 창을 보면 알겠지만 Editor 폴더에 넣어야 빌드 에러가 나지 않는다.
  • IPreprocessShaders 클래스를 이용하는 키워드는 빌드 후 '제거'한 거다.
  • IPreprocessShaders 클래스를 이용하여 제거한 키워드 중 플레이 로그에 남은 것은 실제로 플레이 중에 필요한 Shader Variants를 위한 키워드이기 때문에 제거가 안되었다.
  • 이것저것 해보니 배리언트가 만들어지는 시기는 '빌드'시 컴파일링을 통하여 생성되기 때문에, 에디터 상태에서 작업할 때는 배리언트의 숫자를 확인할 수 있지만 개수가 정확하게 맞지는 않은 듯하다.  Shader Inspactor에서 확인할 수 있는 배리언트 수를 맹신하지 말자.

 글을 마치며...

 

 최적화를 진행할 때, 셰이더 그래프의 경우 원하지 않는 PASS의 사용이나, 정말 사용하지 않을 Shader Variants 같은 경우, 프로그램팀에 요청해서 IPreprocessShaders 클래스 코드를 이용하여 Variants를 제거하였고(실제로 작동한지는 모르겠다.  삭제 했다는 통보를 받았다...), 더 나아가 Shader Graph로 작성한 Shader들을 코드로 변경하였다.  이렇게 Shader를 코드화하며 많은 것을 배우게 되었으며, 지금 이 순간에도 배워가는 중이다.


 상황에 따라, 필요한 Shader, Shader Variants에 한하여 미리 로딩 (예열) 하면 런타임 중에 프레임 드랍이나, 실시간으로 보여지는 VFX 등에 사용된 Shader Variants가 로딩 되기 전 렌더링 되어 이상하게 나오는 것을 방지하는 것이 게임의 퀄리티와 최적화를 동시에 잡을 수 있는 방법인 것 같다.

 

  • 가능하다면 #pragma multi_compile보다 #pragma shader_feature을 사용하자.  빌드에 포함되지 않으면 별다른 설정 없이 Shader Variants가 생기지 않는다.
  • 셰이더를 작성할 때, 하나에서 둘로 나누었을 때 어느 쪽이 유리한지 고민해 보자.
  • 셰이더를 메모리에 올리는 것도 시간적 비용이 든다.  컴퓨터는 처리하기 위해서는 항상 데이터를 메모리에 올리는 작업이 필요하다는 것을 잊지 말자.  그리고 이것을 언제 올릴지 그 타이밍이 중요하다.
  • 셰이더는 생각보다 많은 메모리를 차지하고 있다.  어쩌면 메모리에 텍스처 다음으로 많은 공간을 차지하고 있을 수 있다.

참고 자료


셰이더 배리언트 문서 : 

https://docs.unity3d.com/kr/2022.3/Manual/shader-keywords.html

https://asmaloney.com/2020/01/code/using-unitys-shadervariantcollection/

 

셰이더 키워드 문서 : 

https://docs.unity3d.com/kr/2022.3/Manual/SL-MultipleProgramVariants.html
https://docs.unity3d.com/kr/2022.3/Manual/shader-keywords.html

https://suhyeokkim.github.io/2022/05/29/shader-varaints-stripping-in-unity

 

정적, 동적 분기 문서 :

https://docs.unity3d.com/kr/2022.3/Manual/shader-branching.html#dynamic-branching

https://light11.hatenadiary.com/entry/2019/01/12/232533

 

셰이더 배리언트 컬렉션 문서 : https://docs.unity3d.com/kr/2021.1/Manual/shader-variant-collections.html

 

셰이더 스트립 관련 코드( IPreprocessShaders 클래스) : 

https://gist.github.com/yasirkula/d8fa2fb5f22aefcc7a232f6feeb91db7

// 이건 실제로 해보지 않았지만 나중에 할 요긴하게 쓰일 것 같다.

https://docs.unity3d.com/ScriptReference/Build.IPreprocessShaders.OnProcessShader.html