이번에 한 일

사실 서버 쪽 작업이 더 중요할텐데, 그냥 클라이언트도 여러모로 신경써서 손을 보고 있다. 정말 하나도 안 중요한 작업을 했다. 무려… UI를 건드렸다.

그래서 이번 글에서 다루는 내용들을 보면 이런 생각이 들 수도 있다.

도대체 이런걸 왜 신경쓰지?

왜냐하면요

왜냐고? 그냥 재밌으니까 했다! 내가 하고 싶은거 하는 프로젝트인데 뭐 어떻습니까! 아무튼 개발일지 시작합니다.

로그인창 조립

일단 별 거 없다. 로그인 화면 UI 리소스를 모아다가 잘 조립(?)했다. 아무래도 웹 런처 같은걸 만들기에는 무리니까 로그인창을 통해서 인증을 하는 걸로 고정할 것 같다.

도…돋움!!!

여기서부터 쓸데 없는 짓 시작. 메이플스토리할 때 특유의 저 각진, 픽셀 폰트까지도 따라하고 싶다! 한 눈에 봐도 이건 돋움체인 것 같다.

그런데 돋움체로 다람쥐라는 글자를 써보면 묘하게 다르다.

일단 메이플스토리에서 다람쥐라는 글자를 적었을 때 특징적인 부분은 이렇다.

  • : 가 합쳐져있고, 의 아랫부분이 위로 굽어있다.
  • : 가 합쳐져있다, 의 아랫부분이 위로 굽어있다.
  • : 가 분리되어있다.

다시 이 녀석을 보자면 그러한 특징을 보이지 않는다는 것을 알 수 있다.

메이플스토리 클라이언트에서 발견한 폰트 2가지가 더 있는데, 이것도 마찬가지로 다르다(위에서부터 차례대로 모리스9체, NotoSans Regular).

그러던 중 재밌는 사실을 발견했는데, 폰트 크기를 줄이면 우리가 아는 특유의 돋움 + 픽셀 느낌의 폰트가 나온다.

더 알아보니, 윈도우 기본서체들은 크기가 작아질 때 벡터 정보로 외곽선을 계산하는 것을 포기하고 내장된 비트맵 데이터로 렌더링하는 기능이 있다고 한다. 그래서 12px 내외의 작은 데이터는 흔히 말하는 ‘픽셀체 폰트’같은 느낌이 났던 것이다.

한 번에 될리가 없지

그러나 문제는 그렇게 쉽게 해결되지 않았다. 돋움체를 임포트해서 TMP로 구워봐도 원하는 느낌이 나오지 않았다. Atlas를 구울 때 Render Mode는 RASTER, Filter Mode는 Point로 해서 픽셀 폰트 같은 느낌을 최대한 살려봐도, 윈도우 GDI에서 그리는 것 같은 느낌이 안나온다.

아마 폰트를 렌더링하는 엔진이 달라서 그런 모양이다. 이리저리 한참을 더 시도해보다가, 결국 포기하고 비슷한 느낌의 모리스9 폰트를 사용하기로 했다.

그런데 모리스9 폰트는 완성형 기반인 모양이다. 입력하다가 지원하지 않는 문자가 자주 보인다. 위 이미지에서 타이핑한 것은 아이묭인데, 이 깨져보이는 것.

돋움돋움돋…움돋…?

이 시점쯤 되어서는 나도 지쳐서 슬슬 포기하려고 했다. 정말 마지막으로, 폰트 사이트 ‘눈누’에 들어가 픽셀체로 필터링해서 폰트를 구경하고 있던 찰나…

이런 폰트를 발견했다. 돋움의 비트맵 글꼴을 따와서 만든 폰트인 ‘움돋’!!!!! 그야말로 내가 원하던 폰트다!!! 세상에 이런 프로젝트를 한 사람이 있다니 정말 만수무강, 무병장수, 수복강녕, 만사형통, 수산복해, 만사여의하시길 바랍니다!!

그렇게 유니티에 임포트한 결과. 원하는 느낌 그대로 나왔다. 야호!

팝업창 구현

그리고 이제 간단한 팝업창 나타나는 부분 구현. 요거는 DOTween써서 금방 구현했다. 오히려 UI 바인딩 하는 쪽에 조금 더 공을 들였다.

오브젝트 바인딩

유니티 스크립트를 짜다보면 씬 혹은 프로젝트 상에 있는 오브젝트를 사전에 할당해두어야하는 경우가 많다. 처음에는 public 혹은 SerializeField로 두어도 수가 적으니 직접 할당하는 것이 크게 번거롭지 않지만, 프로젝트가 진행될 수록 관리가 매우 번거로워지곤한다.

대학생 때에는 이런 관리 코스트를 신경 쓸 여유가 없었지만, 이 프로젝트는 ‘내가 하고 싶은 것’을 하는 프로젝트이기 때문에 이번엔 이 부분도 개선해보았다.

public class UI_Login : UI_Base  
{  
    [BindRoot("Canvas/UICanvas/TitleBg")]  
    [Bind("IDInput")] private TMP_InputField _idInputField;  
    [Bind("PWInput")] private TMP_InputField _pwInputField;  
    [Bind("LoginBtn")] private Button _loginBtn;  
  
    public override void Init()  
    {
	    // ...
    }
    
}

이런식으로 attribute만 가져다붙이면 된다. 참고로 현재 씬은 아래와 같이 구성돼있다.

Canvas
└─ UICanvas
    └─ TitleBg
        ├─ IDInput
        ├─ PWInput
        ├─ LoginBtn
        └─ ...

이러한 구조에서, Canvas/UICanvas/TitleBg의 자식들을 바인딩하고 싶다고 가정하자. 그러면 일단 [BindRoot("Canvas/UICanvas/TitleBg)]를 통해 기본 Root를 설정한다. 그 다음에 이 Root로부터 상대적인 경로를 적어서 [Bind] attribute를 쓰면 알아서 바인딩 되는 것이다!

이 처리는 UIBase 클래스에서 알아서 해준다. UIBase를 상속받은 클래스에서 UI요소에 접근하여 리스너 이벤트를 추가하는 등의 작업을 하려면 Init함수에서 진행해야한다(UIBase.Awake에서 호출됨).

뭔가 다른데?

UI의 비율이나 약간의 배치 차이는 제외하고, ‘뭔가 다르다’는 느낌이 든 부분이 있다. 바로 블러처리다. 팝업창 뒷부분의 배경을 흐리게 처리하는 것이다.

반면, 내 팝업창은 새삼 형편없게도 블러처리가 없으니 허전하다.

…그럼 나도 블러를 넣어야지! 오랜만에 셰이더 작업을 해보자.

구현 계획

일단 UI 리소스를 보면 마스크용 텍스처가 존재한다. 아마 이 텍스처로 마스킹해서 블러를 넣는 것 같으니, 여기에 맞게 구현해보자.

UI 블러 - 실패작

일단 UI 셰이더를 바탕으로, 다른 사람이 만든 블러 셰이더를 이리저리 조합해서 빠르게 만들어보았다.

Camera Sorting Layer Texture로 블러가 적용될 레이어가 렌더링되기 직전 정보를 가져와서, 이 텍스처를 대상으로 블러 연산을 진행하기로 했다.

그리고 코드가 이리저리 나왔는데, 프래그먼트 셰이더만 살펴보자.

#define cslt _CameraSortingLayerTexture  
#define sample_lc sampler_LinearClamp

half4 frag(v2f input) : SV_Target  
{  
    float2 screenUV = input.screenPosition.xy / input.screenPosition.w;  
  
    half4 maskColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);  
    half maskVal = maskColor.a * input.color.a;  
  
    float2 texelSize = 1.0 / _ScreenParams.xy; // 픽셀당 UV  
    float2 spread = texelSize * _BlurStrength * 1.5; // 블러 강도가 높을 수록 샘플링 폭 증가  
  
    half3 blurredColor = half3(0, 0, 0);  
    const float offsets[3] = {0.0, 1.3846153846, 3.2307692308};  
    const float weights[3] = {0.2270270270, 0.3162162162, 0.0702702703};  
  
    // 중앙 샘플링  
    blurredColor += SAMPLE_TEXTURE2D(cslt, sample_lc, screenUV).rgb * weights[0];  
  
    // 가로 세로 샘플링  
    for (int i = 1; i < 3; i++)  
    {        float offset = offsets[i];  
        half3 hBlur = SAMPLE_TEXTURE2D(cslt, sample_lc, screenUV + float2(offset, 0) * spread).rgb;  
        hBlur += SAMPLE_TEXTURE2D(cslt, sample_lc, screenUV - float2(offset, 0) * spread).rgb;  
  
        half3 vBlur = SAMPLE_TEXTURE2D(cslt, sample_lc, screenUV + float2(0, offset) * spread).rgb;  
        vBlur += SAMPLE_TEXTURE2D(cslt, sample_lc, screenUV - float2(0, offset) * spread).rgb;  
  
        blurredColor += (hBlur + vBlur) * weights[i] * 0.5;  
    }  
    half3 originalColor = SAMPLE_TEXTURE2D(cslt, sample_lc, screenUV).rgb;  
    half3 finalColor = lerp(originalColor, blurredColor, maskVal);  
  
    return half4(finalColor, maskVal);  
}

가우시안 블러라고도 하는 그것이다. 블러라는게 그냥 기본적으로 주변에 있는 픽셀이랑 더해서 평균내는 건데, 여기에 가우스 함수 개념을 결합해서 가중치를 넣은 것이다.

가우스 함수는 중앙으로부터 멀어질수록 부드럽게 감소하는 특징이 있는데, $\sigma$값에 따라 모양이 조금 달라진다(이미지 내 $\mu$는 평균값인데 신경 안써도 됨). 이 함수의 모양을 따라서 가중치를 주면 픽셀이 멀어질 수록 주는 영향(가중치)이 부드럽게 줄어들게 되는 것이다.

코드에서 weightsoffsets값이 각각 가우스 함수로부터 구한 가중치와 샘플링 지점이다. 여기에는 잘 알려진 값을 가져왔는데, 이 사람이 처음 고안했다고 한다.

여기부터는 나도 새롭게 알게된 내용이다.

문득 이 값들이 어떻게 튀어나온건지 궁금해서 원문을 참고해봤다.

원문을 읽어보니, 가우시안 블러를 위해 파스칼 삼각형을 가져왔다. 파스칼 삼각형은 이항 계수를 나열한 것이므로, 행이 내려갈 수록 정규 분포에 수렴한다. 즉, 이 숫자들을 막대 그래프로 나타내면 가우스 함수의 종 모양 곡선을 나타낼 것이다. 그래서 이 파스칼 삼각형을 가우시안 블러에 활용할 수 있게 된다.

여기서 특정 행을 골라서 가중치를 설정해줘야하는데, 9탭 가우시안 블러를 위해 12번행을 골라 가운데 9개 숫자를 가중치로 활용한다. 숫자가 9개 있는 8번행을 사용하지 않는 이유는 32비트 컬러체계에서 사용하기엔 바깥쪽 계수들이 너무 작아 영향을 거의 주지 않기 때문이다.

이렇게 가중치와 오프셋을 계산한 결과가 지금 코드에 보여지는 weightsoffsets이다.

그리고 이게 구현의 결과다. 보다시피 전혀 블러 같지 않다. 그래서 구현이라고 할 수도 없다(…) 어디가서 이게 블러라고 우기면 병원에 실려갈 것 같다.

블러는 멀티 패스로 구현해야합니다

그래픽스를 잘 아는 사람이라면 제목을 보고 ‘당연한 거 아냐?’라고 생각할 것 같다. 하지만 난 몰랐다. 그냥 약간의 이론만 알았지 블러를 구현할 일이 없었으니까…

알고 보니 가우시안 블러는 멀티 패스로 그리는게 올바른 구현이었다. 지금 셰이더는 여기저기서 참고한 코드를 가져오다보니 의미도 제대로 파악하지 못한 채 사용하고 있었던 것이다!

가우시안 블러의 원리: 최종편

가우시안 블러가 가우스 함수를 사용한다는 것까지는 오케이. 알고 있었다. 그런데 내가 여기저기서 기워온 코드는 왜 십자(┼) 모양으로만 샘플링을 하는가? 즉, 컨볼루션을 하는 커널에 왜 대각선 정보는 안들어가는가? 여기에 핵심이 있었다.

2차원 가우스 함수는 1차원 가우스 함수의 곱으로 나타낼 수 있다. 그래서 x축과 y축에 대해 각각 독립적인 1차원 컨볼루션을 순차적으로 수행하면 복잡도는 낮아지고 수학적으로도 완전히 동일한 가우시안 블러가 나오게 된다.

이 성질을 이용해서 멀티패스로 가우스 함수를 구현하기 때문에, 내가 여기저기서 가져온 코드가 십자모양의 샘플링만 있던 것이었다. 그러니 나의 싱글패스 구현은 애초에 잘못된 코드다.

UI 블러 - 구현!

그렇게 셰이더를 이리저리 손봐서 만든 최종 가우시안 블러는 이런 모습이 되겠다. 렌더 피처에서 가로/세로에 대한 블러처리와 반복(iteration)처리를 진행한 다음, 마스킹을 진행한다.

즉, 스크립트 구성은 아래와 같다

  • 가로 혹은 세로 방향으로 블러를 적용하는 셰이더
  • 그 셰이더를 통해 (다운샘플링 한) 화면에 블러를 적용하는 렌더 피처
  • 그 렌더 피처가 만든 블러 텍스처를 마스킹하는 셰이더

최종 적용된 버전. 팝업창에 자연스러운 가우시안 블러가 마스킹되어서 들어가있다!

차회 예고

다음 편(?)에 로그인 처리 구현 안해오면 블로그 문 닫겠습니다.

카테고리:

업데이트:

댓글남기기