2015년 11월 27일 금요일

Unity 서피스 셰이더에서 discard나 return을 사용할때 주의할 점

사내 프로젝트에서 요상한 문제를 겪었다.

다른 폰에서는 별다른 문제가 없는데, 유독 Galaxy S4(초기모델)에서만 픽셀이 깨지는 현상이 발견된 것이다.

처음에는 셰이더의 Spec 연산시에 음수(-)값이 나오는 것이 아닌가 하는 문제로 좁혀져서 각종 벡터연산에 saturate()를 집어넣는 방식으로 해결하려 했지만 결국 버그가 잡히지 않았다. 게다가 알파채널이 있는 경우에만 발생하는 것도 이상했다.

그러는 가운데, Galaxy S4초기 모델의 특징은 PowerVR이 사용되었다는 것에 착안하고 구글링을 해보니 유사한 현상이 Stackoverflow에 보고된 사례를 접하게 되었다.

내용을 읽어보면 PowerVR의 병렬처리 구조상 if()문 내에서 discard나 return문이 있을 경우에 원치않는 statement가 처리될 수도 있다는 것이다.

예를 들어서 우리의 경우 mask텍스처의 b채널에 alpha정보를 보관하고 있었기에 다음과 같이 처리를 하였다.


inline void surf (Input IN, inout SurfOut o)
{
   fixed4 mask = tex2D(_MaskMap, IN.uv_MainTex);
if( mask.b <= _Cutoff )
discard;

fixed4 main = tex2D(_MainTex, IN.uv_MainTex);
fixed4 n = tex2D(_BumpMap, IN.uv_MainTex);
        .
        .
        .
}

이렇게 코딩하면 오류가 발생한다는 것이다.

이 코드를 바로 잡으려면

inline void surf (Input IN, inout SurfOut o)
{
        fixed4 mask = tex2D(_MaskMap, IN.uv_MainTex);
if( mask.b <= _Cutoff )
        {
discard;
        }
        else
        {
         fixed4 main = tex2D(_MainTex, IN.uv_MainTex);
           fixed4 n = tex2D(_BumpMap, IN.uv_MainTex);
                .
                .
                .
        }
}

이렇게 해야 GPU의 병렬처리시에 오류를 막을 수 있다.

if() {}블록 다음에는 반드시 else {}블록으로 처리를 해야만 GPU가 제대로 지원한다는 것을 명심하자. 

2015년 11월 20일 금요일

[자작요리] 굴짬뽕 맛이 나는 나가사키 짬뽕 만들기

주말에 집에 혼자 있다가 냉장고를 기웃거려보니 바지락 남은 것이 있더라.

그래서, 급요리 모드로 변경해서 뚝딱 뚝딱 짬뽕을 끓여봤음

최종 완성버전의 비쥬얼


그럼 이제 조리 과정을 살펴보자 ㅋㅋㅋㅋㅋ
먼저 재료!

오늘의 주재료

오늘의 부재료
일단 바지락은 필수, 마늘, 양파, 청양고추는 my favorite vegetables.


먼저 마늘을 기름에 볶아서 마늘향을 기름에 입힌다

이제 야채를 볶으면 야채에 마늘향이 충분히 스며든다

소금간은 필요없고 청양고추와 통후추로 간단히 매운맛과 향을 추가한다

바지락 투하

쉐낏쉐낏 볶아주면 바지락 뚜껑이 열림
내가 바지락을 화나게 한건가?

물 600밀리를 붓고(설명서는 500이지만 난 재료를 추가했으니 물도 추가)

보글 보글 끓으면 스프추가

드디어 면 투척!

면은 공기와 자주 접촉할수록 탱글한 식감이 살아남

혼자 먹을 것이니 가급적 가장 크고 아름다운 그릇을 찾는다
혼자 있을때야말로 식구들의 간섭없이 최대한 우아하게 먹어보자

비쥬얼은 그럴듯하다

오~ 면발이 살아있군
게다가, 이 맛은...중국식 굴짬뽕 맛이다!
왜 굴짬뽕 맛이 나냐고 물으신다면 굴짬뽕맛이 나서....
바지락이나 굴이나 같은 어패류니까 그러려니 함

밥을 말아먹지 않을 수 음슴

결국 국물까지다 먹음
저 멀리 바지락의 조개무덤이 보임

다음엔 나가사키 홍짬뽕에 도전해 보겠음.
오늘은 마늘 기름으로 맑은 짬뽕의 깊은 국물맛을 내봤지만,
다음에는 고추기름을 만들어서 미칠듯한 매운맛을 구현해 보겠음. ㅋㅋ



2015년 8월 30일 일요일

[유니티 최적화 기법] - 셰이더 Variants

유니티 셰이더를 작성할 때 쉽게 간과하는 것이 있다.

다음은 흔한 셰이더 코드다. 빨간색 부분을 보자.
Shader "Toon/PMO_toon_mask_skin" {
    Properties {
         _Color ("Main Color", Color) = (1.0, 1.0, 1.0, 1.0)
         _UnderTex ("Under (RGB)", 2D) = "white" {}
         _MainTex ("Skin (RGB)", 2D) = "white" {}
         _RimPower ("Rim Power", Range(0.5,8.0)) = 2.0
         _RimIntesity ("Rim Intensity", float) = 2.0
         _ColorIntensity("Skin Intensity", float) = 1
         _PickColor ("Pick Color", Color) = (1, 1, 1, 1)
     }
  
     SubShader {
         Tags {"RenderTexture" = "RenderTextureMask" "IgnoreProjector"="True" "RenderType"="Opaque"}
         LOD 200
         
         CGPROGRAM
         #pragma surface surf ToonRampFace
 
         sampler2D _UnderTex;
         sampler2D _MainTex;
         half4     _Color;
         half      _RimPower;
         half4     _PickColor;
         half      _RimIntesity;
         half      _ColorIntensity;
 
         struct Input
         {
             half2 uv_MainTex : TEXCOORD0;
             half3 viewDir;
         };

         struct SurfOut
         {
             half3 Albedo;
             half3 Normal;
             half3 Emission;
             half  Specular;
             half  Alpha;
             half3 viewDir;
         };

         #pragma lighting ToonRampFace exclude_path:prepass
         inline half4 LightingToonRampFace (SurfOut s, half3 lightDir, half atten)
         {
             half4 c;
             half rim = 1.0 - saturate(dot (normalize(s.viewDir) , s.Normal));
             c.rgb = (s.Albedo * _LightColor0.rgb + _PickColor.rgb * _LightColor0.rgb * pow (rim, _RimPower) * _RimIntesity)  * atten;              
             c.a = s.Alpha;
             return c;
         }
   
         void surf (Input IN, inout SurfOut o) 
         {                      
             half4 skin = tex2D(_MainTex, IN.uv_MainTex) * _Color;
             half4 under = tex2D(_UnderTex, IN.uv_MainTex) * _Color;
             if(under.a > 0.48f)
             {
                o.Albedo = under.rgb * under.a * _ColorIntensity + (1.0f - under.a) * skin.rgb;
             }
             else
             {
                o.Albedo = skin.rgb * _ColorIntensity;
             }          
            
             o.Alpha = skin.a;
             o.viewDir = IN.viewDir;            
         }
 
         ENDCG
    }
    FallBack "Diffuse"
}
[코드-1] 흔한 Surface 셰이더 코드

보다시피 흔히 알고 있는 Surface셰이더를 사용중이며, #pragma를 사용해서 각종 컴파일 규칙을 정의해주고 있다.

이 셰이더는 컴파일될 때 과연 어떻게 될까?

유니티의 셰이더코드는 PC의 것과 매우 다르다. PC는 알파의 유무, 뼈대의 개수, 라이트의 개수, 라이맵의 유무 등에 따라서 각각의 경우마다 셰이더를 따로 작성해야 했고, 이를 감당할수 없게 되었을때 우리는 셰이더 폭발(!)이라고 불렀다. 유니티를 사용해보면 이런 부분들이 매우 편하게 되어 있어서 더 이상 셰이더 폭발을 걱정할 필요가 없을 줄 알았다.

특히 Surface셰이더를 사용하면 CgHLSL을 직접 사용한 코딩보다 훨씬 강력하게 지원되기 때문에 즐겨 사용하게 된다. 문제는 이러한 지원이 너무나 강력하다보니 원치 않는 부작용이 나타난다는 것이다.

유니티에서는 이러한 알아서 제멋대로 강력함을 최소화하기 위해서 #pragma 명령어로 다양한 제어를 할 수 있게 해놓았다. 만약 light probe를 원치않으면 novertexlights를 추가하면 된다.

#pragma surface surf ToonRampFace novertexlights

이렇게 하면 light probe 기능이 없는 셰이더 코드를 생성해주며, 당연히 Light probe코드가 제거되었기 때문에 셰이더 코드가 훨씬 가벼워진다. #pragma 명령어와 관련된 다양한 옵션은 매뉴얼(http://docs.unity3d.com/Manual/SL-SurfaceShaders.html) 을 참고하도록 하자.

오늘 말하고자 하는 것은 의외로 간과하게 되는 유니티에서의 셰이더 폭발에 관한 것이다

사실 모바일(유니티)에서도 셰이더 폭발은 발생한다. 우리가 그것을 잘 모르고 있을 뿐이다. [그림-1]을 보도록 하자.

[그림-1] 셰이더 코드의 Inspector화면

제일 하단의 Variants값이 14로 되어 있는데, 이것이 의미하는 것은 바로 옆의 [Show]버튼을 눌러보면 알 수 있다.

// Total snippets: 2
// -----------------------------------------
// Snippet #0 platforms ffffffff:

9 keyword variants:

DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_OFF
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_OFF SHADOWS_OFF
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_OFF SHADOWS_SCREEN
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_OFF VERTEXLIGHT_ON
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN VERTEXLIGHT_ON
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE VERTEXLIGHT_ON


// -----------------------------------------
// Snippet #1 platforms ffffffff:

5 keyword variants:

POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE


[코드-2] 변종 셰이더 코드의 종류

이것이 의미하는 것이 무얼까?

PC에서 셰이더를 개발해본 사람은 단박에 알 수 있을 것이다. 라이트맵이 있는 경우, 없는 경우, 그림자가 있는 경우 없는 경우, 정점조명이 있는 경우, 없는 경우 등등에 따라서 셰이더 코드를 모두 다르게 만들어야 하고, 이러한 과정 때문에 셰이더 대폭발이라는 참사가 발생하는데, 유니티에서는 사실 우리 눈에 보이지 않았을뿐 내부적으로 폭발이 발생하고 있었던 것이다너무 잘 숨겨놔서 잘 모를뿐이다
저 14라는 숫자는 다시 말해서 나는 비록 셰이더를 1개만 만들었지만, 다양한 경우에 적용될수 있는 변종이 유니티에 의해서 14개 생성되었다는 것이다. 이런 망할 ㅋㅋㅋ

그나마 모바일은 캐릭터 스키닝을 일반적으로 CPU에서 하니까 뼈대 개수에 따른 가중치별 분화는 없어서 다행이랄까? (유니티 4.5에서 GPU 스키닝을 지원하도록 컴파일해보면 몇몇 폰에서 이상 현상을 발생시킨다.)

문제는 이렇게 많은 변종 셰이더가 있을 경우 로딩에 치명적인 악영향을 끼치게 된다. 실제 스테이지를 구성하는 메시, 텍스처, 애니 데이터 로딩 시간을 모두 합친 급에 육박하는 셰이더 로딩이라는 불상사를 직접 격고 나서야 나도 이러한 문제점을 깨달았다.

방법은 의외로 간단하다. 앞서 말한 #pragma를 사용해서 필요 없는 변종들은 생성하지 않도록 지정하는 것이다다음과 같이 변경해보자

변경전: #pragma surface surf ToonRampFace
변경후: #pragma surface surf ToonRampFace nolightmap

 
[그림-2] 줄어든 Variants값

이 상태로 저장하면 [그림-2]처럼 Variants11로 줄어든 것을 목격할 수 있다. [show]버튼을 눌러서 확인해보면 [코드-3]처럼 분기되는 경우의 수가 줄어들어 있다.

// Total snippets: 2
// -----------------------------------------
// Snippet #0 platforms ffffffff:

6 keyword variants:

DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_OFF
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_OFF VERTEXLIGHT_ON
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN VERTEXLIGHT_ON
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE VERTEXLIGHT_ON


// -----------------------------------------
// Snippet #1 platforms ffffffff:

5 keyword variants:

POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE



이 외에도 줄일 수 있는 여지가 이것 저것 더 있으니 꼭 직접 찾아보기 바란다

지금 이 예의 경우에는 캐릭터 전용 셰이더기 때문에 라이트맵이 필요없었다. 따라서 #pragma를 사용해 라이트맵 지원 셰이더 생성을 막은 것이다. 이처럼 특정 용도로 제한된 셰이더(주로 캐릭터, FX, Post Process)들은 #pragma로 제한을 두어서 변종의 생성을 억제할 필요가 있다



[유니티 최적화 기법] - 텍스처 편


모든 게임이 그렇지만 모바일이라고 예외는 아니다.
결국 리소스의 대부분은 텍스처다.
과거 MMORPG를 만들어서 배포해보면 10G짜리 게임의 5G정도는 텍스처였다. 모바일도 그 상황은 크게 다르지 않다.

PC는 아주 쉬웠다. 우리는 모두 DirectX기반으로 게임을 만들었고, DX가 지원하는 포맷으로 압축하면 끝이었다. 그래서 우리는 모든 텍스처를 DXT로 압축한 것이다.
그 시절에는 DXT1~5까지중에서 뭘 쓸지 고민했다. UI는 주로 Targa를 사용했고 말이다.

모바일에서는 상황이 많이 다르다.
더 이상 DX가 절대 강자가 아니고, OpenGL에 기반하고 있기 때문이다. OpenGL자체가 Open이다보니, GPU칩 제조사가 자기 하기 나름이다. 그래서 압축 포맷도 천차만별이다.

다행인것은 모바일 GPU가 그나마 많지 않아서 총 4가지 정도로 수렴된다는 것이 위안이다.

압축방식
대표GPU
제조사
특징
ETC
Mali
ARM
- ETC1 거의 모든 Android기기가 지원
- ETC1 Alpha 지원되지 않음
ATITC
Adreno
Qualcomm
-
PVRTC
PowerVR
Imagination
- 2^n크기의 정방형 텍스처만 압축가능
- 이에 해당하지 않을 경우 강제로 2^n 크기로 텍스처 변경
- 단, 압축하지 않는 텍스처는 정방형 제한 없음
DXT
Tegra
nvidia
- PC에서 사용된 DXT방식을 그대로 사용가능
- 지원하는 장비를 만나뵙기 힘듬
[표-1] 모바일 GPU의 대표적인 압축방식

[표-1]을 보면 총 4가지 정도의 압축방식이 있는 것을 알 수 있다. 이 중에서 가장 대표적인 것은 ETC방식인데, 안드로이드(이후 AOS)라면 기본적으로 지원하는 방식이기 때문이다. 물론 여기에는 약간의 함정이 있다. 모든 AOS 단말기가 100% ETC1을 지원해야 하겠지만, 가끔 그렇지 않은 물건이 있기 때문이다. 일단, AOS라면 일단 ETC1을 지원하면 거의 대부분의 경우 해결이 된다는 것을 명심해두자.

그런데, 보다시피 ETC1은 ARM에서 제안한 압축포맷인데, 스마트폰 초창기에 많이 사용된 Mali칩에서 지원하는 포맷이다. 최근 시장의 스마트폰 단말기들은 고성능일수록 Adreno계열을 사용한다. 특히 애플은 처음부터 지금까지 꿋꿋하게 PowerVR만을 외길로 고집하고 있다. 이는 중국산 폰과 국산 폰을 살펴보면 극명하게 알 수 있는 사실인데, 중국의 저가폰(태블릿 포함)은 주로 구형 Mali 칩을 사용한다. 그러나, 국내 고급폰은 거의 대부분이 예외없이 Adreno 330(이상) 칩을 사용한다.

요즘 중국에서 만드는 $200~$300의 저가폰과 국내 고가폰의 스펙차이가 거의 없다고 말하는 사람들을 보면 받아들이기 힘들다. 물론 일반인이 봤을때는 그렇게 보이겠지. 하지만 IT전문가라는 사람들조차 그렇게 단언하는 것을 보면 좀 실망스러운 것이 사실이다.

그들이 말하는 거의 차이없다라는 수준이 어떻게 오해받는지 한번 살펴보자.

스펙의 일반적 평가기준은 CPU코어수 , 화면해상도, RAM용량, 내장 메모리용량 정도일 것이다.

문제는 픽셀 필레이트가 떨어지는 저가 GPU칩에 고해상도 2048이상 디스플레이를 달고 나오는 저가폰이 수두룩 하다는 것이다. 게다가, 내장 메모리의 Read/Write속도 역시 분명하게 차이가 난다. 이런 폰에서 게임을 돌리면 초당 30프레임은 커녕 10프레임을 넘기기도 힘들다. 게다가 게임을 로딩할때는 어마어마한 딜레이가 걸린다. 이런 상황에서 최신 중국폰에서 게임이 잘 안돌아가니, 발적화 한것 아니냐고 말하는 사람들을 보면 어이가 없을 뿐이다. 중국산 저가폰에서는 HW사양에 맞춰서 국내폰보다 더 엄청난 최적화를 해야만 원활하게 돌아갈 수 있을 것이다. 폴리곤 깍는 노인이 되는 것도 좋겠지 ㅋ

일반적인 App을 돌릴때는 문제가 안되지만, 게임은 CPU, GPU, Memory, 배터리를 모두 극한수준으로 써먹는 악독한 놈이기 때문에 저가폰과 고가폰이 큰 차이를 보이게 된다.

혹시나 노파심에서 하는 말인데, 국내 대기업제품들도 중국산 저가폰이나 하는 얄팍한 짓을 가끔씩 저지르는 경우가 있다는 것이다. [저가GPU + 고해상도 디스플레이]로 구성된 경우가 없는지 주변의 최신 폰들을 살펴보자. 최근 사내에서 다양한 폰을 테스트하며 경악한 경우가 있었기에 하는 말이다. -_-;

얘기가 잠시 샜는데, GPU마다 고유한 압축포맷이 있고, 이 포맷으로 압축하면 실제 배포되는 데이터의 크기도 줄어들고, 당연히 메모리에 올라갈때도 그 크기 그대로 올라가기 때문에 반드시 압축을 해서 배포해야만 한다.

대략적인 리소스 배포 시스템은 [그림-1]과 같다.
[그림-1] GPU에 최적화된 리소스 다운하기

AOS는 총 4가지 포맷(Adreno, Mali, PowerVR, Tegra)를 지원하는데 반해서 IOS는 PowerVR만을 지원하고 있다. 애플의 일관된 정책은 때때로 개발자에 편안함을 준다. 물론, 대부분의 경우에는 애플 덕분에 피곤한 경우가 더 많지만 말이다.

압축은 유니티의 AssetBundle을 사용하고 있다.
좀 더 정리하자면, 배포될 리소스(AssetBundle)을 빌드할때 GPU에 따른 4가지 버전을 준비해 두고, 클라이언트는 실행될때 GPU를 판독해서 자신의 폰에 최적화된 리소스를 다운받는 방식이다.

여기서 우리가 격었던 황당한 경험 2가지를 기억해보자.

1. ETC1을 지원하지 않는 AOS가 있다.
삼성 갤럭시4 초기모델은 놀랍게도 PowerVR칩을 탑재하고 나왔는데, 이 폰이 ETC1을 지원하지 않는다. 이상하게도 이 모델에서만 메모리가 급증하기에 확인해보니
PowerVR 탑재된 갤4에서 ETC1을 지원하지 않음 → Unity가 ETC1텍스처 압축을 해제함 → 압축해제된 원본 크기의 텍스처가 갤4 메모리에 로드됨

이런 상황이 벌어지고 있었던 것이다. 안드로이드라면 ETC1을 무조건 지원할 것이라 생각하고 개발했는데, 그 덕분에 이러한 상황에 맞닥뜨린 것이다. 처음에는 모든 안드로이드 폰을 ETC1으로 압축하려 하였으나, 이 사건이후로 GPU별로 압축하는 방식을 도입하였다.

2. iPhone 5와 5S는 다르다.
애셋번들을 리소스 서버로부터 요청할때 클라이언트 프로그램은 일단 단말기의 GPU를 판별하게 되는데, 이때 우리는 Unity로부터 전달받은 GPU의 시그니쳐를 사용했다. 문제는 대부분의 경우 정확힌 칩셋명이 날아오기 때문에 별다른 문제가 없었는데, iPhone 5S에서 문제가 생긴 것이다.

iPhone 5까지는 GPU명이 "PowerVR+모델번호"로 날아왔고, 우리는 "PowerVR"이라는 스트링을 기준으로 GPU를 판별한 것이다.

그런데, iPhone 5S는 놀랍게도 "Apple GPU"라는 이름으로 날아오는 것이다. 어디에도 PowerVR이라는 문자열을 찾을 수가 없었다. 이 사건 덕분에 애플제품일 경우에는 무조건 PowerVR로 리소스를 빌드하도록 코드가 수정되었다. 아마도 iPhone 5S이후부터 Metal을 지원하면서 생긴 변화가 아닌가 추측한다.





이것 외에도 다양한 리소스 최적화 기법들이 있겠지만, 일단 처음이니 이정도로 하자. 앞으로 차근차근 더 다양한 방법들에 대해서 알아보도록 하자.