최근에 프로젝트를 진행하면서 심각한 성능문제를 발견했다
기존에는 게이밍 컴퓨터나 노트북으로 돌렸기에 몰랐던 부분들이다
처음 발견한 것은 PlayX4로, 시연 노트북이었던 LG 그램에서 처참한 성능을 확인하였다
진짜 문제는 안드로이드 빌드를 뽑으면서부터였다
테스팅을 위해 빌드를 뽑고 에뮬레이터랑 삼성 보급폰에서 돌렸는데 너무 느렸다
사실 느린 수준이 아니라 아니라 플레이가 불가능할 정도의 처참했다..
이걸 그대로 사용했다가는 테스트 시작도 전에 다 이탈할 판이었다
그래서 무지성으로 대가리 박은 결과 3일만에 어찌어찌 최적화를 해내었다
그리고 그 과정에서 Unity의 Light2D를 이해할 수 있었다
이번 글은 Unity Light2D와 이걸 어떻게 활용할 수 있는지를 다뤄볼까 한다
최적화의 시작은 profiling이라는 말이있다
굳이 문제가 되지 않는다면 최적화할 필요도 없기 때문이다
그래서 친구가 전에 쓰던 폰을 급하게 공수해와서 바로 돌려봤다
정말이지 무엇을 상상했던 그 이상이었다..
일부러 저성능 핸드폰도 타겟팅하기 위해 좀 오래된 폰을 빌려오긴했다
그래도 한때는 플래그쉽 이었던 Snapdragon 845에서 저 성능이 나온다는건 말이 안되었다
누가보면 3D AAA게임 만드는줄 알았을거다
한 프레임 그리는데 거의 50ms를 쓰고 있다는건 뭔가 심각하게 잘못되었다는 것이다
그리고 그 원인은 생각보다 찾기 쉬웠다
다른건 볼필요도 없이 그냥 Rendering이 문제였다
Rendering이 문제라고 손 놓고 있을수는 없기에 조금 더 살펴보았다
여러가지 문제가 있겠지만, 결론적으론 카메라가 뒤지게 많은게 문제였다
그런데 뭔가 이상하다
카메라가 많은건 알겠는데, 결과적으로 그리는건 Scene, Map, Minimap이다
저거 3개 그리는데 저렇게나 많은 시간이 필요하다고..?
Render thread로 가보면 더 가관인게 한 프레임 그리는데 44ms나 걸린덴다
만약 저게 사실이라면 현존하는 모바일 3D 게임은 거의 창조경제 수준이란거다
이걸 이해하기 위해 Gpu profile과 Deep profile을 모두 찍어보았다
결론만 놓고보자면 Light2D 관련 Draw call이 지나치게 많다는 것이 문제였다
이걸 다 보여주고 싶지만, 다시 profiling 하자니 귀찮아서 그냥 Draw call 부분만 올린다
그리고 Draw call 중 압도적으로 많은 비중이 Light2D에서 나왔다
위 사진은 한개의 카메라에서 Light2D Drawing call 소요시간을 보여준다
사실 저 한개의 카메라에서만 Light2D 관련 연산이 들어갔다면 문제가 되지 않았을 것이다
문제는 Light2D 연산이 포함된 카메라가 총 4대라는 것이다
이 말인 즉슨, Light 연산이 거의 4배로 되고 있다는 소리다
이걸 해결하려면 기존의 씬 렌더 방식을 뜯어고쳐야 할 것이다
그전에 왜 이러한 연산 중복이 나왔는지를 알아보자
문제의 원인을 파악하기 위해 이전에 사용하던 렌더 방식을 뜯어보았다
카메라 총 6대 중 맵 관련 2대를 제외하고 4대가 메인 렌더에 사용된다
이 4대중 한대는 실제 화면에 그려주는 놈이고 나머지 3대는 Layer별로 텍스쳐를 뽑아준다
그리고 뽑혀진 3개의 텍스쳐를 아래의 쉐이더 그래프를 통해 조합해서 플레이어의 시야를 구성한다
저 3개의 텍스쳐가 Layer별로 다르게 찍고 있었다면 문제가 지금만큼 심하진 않았을것이다
문제는 저 3개의 텍스쳐가 동일한 영역을 찍고 있다는 것이다
여기에 한술 더떠서 MainCamera 역시 Map을 렌더링하고 있었다
뭐 MainCamera야 화면에 보이지도 않는거 Culling하면 그만인긴한데..
그래도 아직도 연산이 2배 더 많이 수행된다는 문제가 남아있다
게이밍 컴퓨터야 실시간 3D 광원효과도 만들어내는 마당에 저정도로 문제되지 않는다
문제는 저사양 기기들에서 사실상 플레이가 불가능하게 만드는 주범으로 작용한다
그렇다면 이러한 연산이 왜 필요했을까
우선 게임에 적용된 시야 시스템을 직접 봐보자
뭔가 한가지 이상한점이 보인다
플레이어는 시야 Light에만 영향을 받는 반면, 일반 object들은 global light에도 영향을 받는다
즉, 동일한 Layer에 존재하는 object들이 서로 다른 광원효과를 받아야한다는 소리다
이걸 구현하는 방법은 여러가지가 있겠지만 이전에는 Texture을 합치는 방식으로 구현했다
MainTex * LightMapTex + ShadowTex * (1 - LightMapTex)
일반적으로 이런 경우에는 Layer를 나눠서 처리한다
하지만 이러한 방법은 Sprite Sorting의 구현 방식 때문에 불가능해보였다
object들 간에 무엇이 앞에 와야할지를 실시간으로 계산하는데 y축값을 사용한다
문제는 이들간에 Layer를 나눠버린다면, 해당 연산이 다 무너진다
왜냐하면 sorting order 보다 Layer sorting이 우선이기 때문이다
또한 큰 문제가 하나 더 있는데, TMP가 Light을 먹지 않는다는 것이다
TMP에서도 Lit2D 업데이트가 있긴한데 문제는 아직도 pre 버전이다..(3.2.0-pre10)
그래도 일말의 희망을 갖고 해당 버전을 위해 Unity 버전이랑 TMP 버전 둘다 올려서 시도해보았다
결과는..위에서 말했던 두가지 Light에 영향을 받는 문제가 그대로 나타났다
오랜시간 고민던 중 한가지 생각이 머리속을 스쳤다
그냥 적용된 2개의 Blend Style을 따로 구분할 순 없을까
다행이도(?) shader에서는 구분이 가능했다
어떻게 적용하였는지를 설명하기 전에 Light2D를 우선 이해하고 넘어가자
Unity의 Light2D는 URP 문서에서 자세하게 설명한다
Introduction to the 2D Lighting system
공부중에 한개의 그래프로 도식화된 플로우 그래프 찾아서 해당 자료도 같이 첨부한다
글 자체는 static light을 caching하는 방안인데, background로 Light2D 설명이 잘되어있다
Light textures caching: Unity 2D Renderer optimization
우선 위 그림을 토대로 Light2D 렌더 과정을 대략적으로 설명한다면 다음과 같을 것이다
위 설명을 통해 복잡도(?)가 곱이라는 확인할 수 있다
여기서 왜 카메라가 개수에 따라 성능이 급격하게 저하되었는지를 이해할 수 있다
그리고 동일한 영역을 여러대의 카메라로 그리는 것은 상당한 낭비라는 것도 알 수 있다
따라서 Light Texture를 최대한 적게 생성해서 활용하는 방법이 곧 최적화일 것이다
여기서 한발자국 더 나아가 Light Texture가 Sprite에 어떻게 영향을 미치는지도 찾아보았다
이건 Shader 코드 자체를 직접 보는게 더 이해가 빠를 것이다
따라서 가장 일반적이라할 수 있는 URP의 Sprite-Lit-Default shader를 살펴보자
Sprite-Lit-Default.shader
위는 딱히 볼 필요없어 보이고, 71번째 줄부터 보면 흥미로운 단어들이 등장한다
#if USE_SHAPE_LIGHT_TYPE_0
SHAPE_LIGHT(0)
#endif
#if USE_SHAPE_LIGHT_TYPE_1
SHAPE_LIGHT(1)
#endif
#if USE_SHAPE_LIGHT_TYPE_2
SHAPE_LIGHT(2)
#endif
#if USE_SHAPE_LIGHT_TYPE_3
SHAPE_LIGHT(3)
#endif
hightlighting이 좀 이상하게 되어있는데 저 SHAPE_LIGHT 부분이 사실 매크로다
어찌되었건 저 매크로를 따라가보면 여러가지 변수들이 선언되어 있음을 확인할 수 있다
LightingUtility.hlsl
#define SHAPE_LIGHT(index)\
TEXTURE2D(_ShapeLightTexture##index);\
SAMPLER(sampler_ShapeLightTexture##index);\
half2 _ShapeLightBlendFactors##index;\
half4 _ShapeLightMaskFilter##index;\
half4 _ShapeLightInvertedFilter##index;
Texture가 보이고 Sampler가 보인다
위에서 Light2D Texture을 생성해서 Sprite와 계산한다고 했던걸 기억하는가
여기가 바로 그 부분으로, 정확히는 각 Blend Style에 따른 Light2D Texture다
잘생각해보면 Unity는 딱 4가지 Blend Style만 지원한다
그래서 위 Shader에서도 0~3까지만 선언되어 사용되는걸 확인할 수 있다
그렇다면 저 Texture가 정확히 어떻게 연산되어 최종 결과물을 출력하는걸까
이것에 대한 해답은 Sprite-Lit-Default.shader:116 CombinedShapeLightShared에 있다
CombinedShapeLightShared.hlsl
#if USE_SHAPE_LIGHT_TYPE_0
half4 shapeLight0 = SAMPLE_TEXTURE2D(_ShapeLightTexture0, sampler_ShapeLightTexture0, lightingUV);
if (any(_ShapeLightMaskFilter0))
{
half4 processedMask = (1 - _ShapeLightInvertedFilter0) * mask + _ShapeLightInvertedFilter0 * (1 - mask);
shapeLight0 *= dot(processedMask, _ShapeLightMaskFilter0);
}
half4 shapeLight0Modulate = shapeLight0 * _ShapeLightBlendFactors0.x;
half4 shapeLight0Additive = shapeLight0 * _ShapeLightBlendFactors0.y;
#else
half4 shapeLight0Modulate = 0;
half4 shapeLight0Additive = 0;
#endif
함수 자체는 긴데 어짜피 Blend Style 0~3의 반복이라 그냥 하나만 떼왔다
식 자체도 단순하고 상당히 직관적이라 설명할 것도 없긴하다
이렇게 Blend Style 별로 Mod, Add 수치를 다 계산했다면 마지막에 합산해주면 된다
half4 finalOutput;
#if !USE_SHAPE_LIGHT_TYPE_0 && !USE_SHAPE_LIGHT_TYPE_1 && !USE_SHAPE_LIGHT_TYPE_2 && ! USE_SHAPE_LIGHT_TYPE_3
finalOutput = color;
#else
half4 finalModulate = shapeLight0Modulate + shapeLight1Modulate + shapeLight2Modulate + shapeLight3Modulate;
half4 finalAdditve = shapeLight0Additive + shapeLight1Additive + shapeLight2Additive + shapeLight3Additive;
finalOutput = _HDREmulationScale * (color * finalModulate + finalAdditve);
#endif
여기까지 본다면 이제 Unity URP 홈페이지에 개재된 그래프가 이해가 된다
목표는 한대의 카메라로 모든 것을 처리하는 것이다
이걸 위해서는 일반 object와 Player에 따라 서로 다른 shader를 적용해줘야할 것이다
일반 object야 둘다 영향 받으면 되니 그냥 Sprite-Lit-Default를 사용하면 된다
문제는 플레이어를 렌더링할 shader다
우선 변수부터 선언하고 시작하자
어차피 무조건 사용될꺼 multi_compile 선언부는 날렸다
TEXTURE2D(_ShapeLightTexture0);
SAMPLER(sampler_ShapeLightTexture0);
half2 _ShapeLightBlendFactors0;
TEXTURE2D(_ShapeLightTexture1);
SAMPLER(sampler_ShapeLightTexture1);
half2 _ShapeLightBlendFactors1;
변수를 선언했다면 다음은 실질적인 연산이 필요하다
그냥 적당히 URP 코드 복붙하고 입맛에 맡게 조금만 수정해주면 된다
원하는 결과물은 sight light의 alpha 값이 최종 sprite에 그대로 전달되는 것이다
다만 light texture는 빛이 도달하지 않는 부분에 검은색으로 칠해진다
따라서 light intensity를 계산하고 해당 값을 alpha 값에 넣어주기만 하면 된다
half4 compute_light(in light_param l)
{
half4 color = l.color;
const half2 uv = l.lightUV;
if (color.a == 0.0)
{
discard;
}
const half4 g_light = SAMPLE_TEXTURE2D(_ShapeLightTexture0, sampler_ShapeLightTexture0, uv);
const half4 s_light = SAMPLE_TEXTURE2D(_ShapeLightTexture1, sampler_ShapeLightTexture1, uv);
const half4 mod = g_light * _ShapeLightBlendFactors0.x + s_light * _ShapeLightBlendFactors1.x;
const half4 add = g_light * _ShapeLightBlendFactors0.y + s_light * _ShapeLightBlendFactors1.y;
color.a *= light_intensity(s_light, _ShapeLightBlendFactors1);
if (color.a == 0)
{
discard;
}
return clamp(color * mod + add, (half4)0, (half4)1);
}
사실 light intensity를 계산함에 있어 꼼수(?)를 사용하였다
어차피 빛이 흰색인걸 알기에 그냥 R,G,B 아무 값이나 하나 골라잡고 Blend Factor만 적용했다
테스트 결과 아무 이상 없는걸로 봐선, Light의 색상이 바뀌지 않는 이상 문제가 없을듯 하다
이렇게 작성한 코드를 필요한 Shader 맨 끝부분에 붙여주기만 하면된다
조금 더 활용한다면 아래와 같이 Blend Style 활성화 여부를 실시간으로 변경시켜줄수도 있다
변화를 주었으니 그 결과를 확인할 시간이다
우선 패치 이전에는 Batch가 대략 570이 나왔는데 반해, 패치 후 200쯤으로 감소했다
Triangle과 Vertices의 경우에는 260k에서 60k로 감소했다
![]() | ![]() |
---|
나중에 불필요한 것들 몇개 더 날리니 batch가 대충 170까지 내려왔다
다만 Triangles는 그다지 많이 감소하진 않았던걸로 기억한다
어찌되었던건 최종 결과물을 놓고 보면 거의 2~4배 감소한 효과를 보인다
그렇다면 프레임의 변화는 어떨까
위 결과물이 컴퓨터에서 측정된거라 프레임의 변화가 보이지 않는다
따라서 변화 후의 결과를 다시 모바일에서 측정해보았다
드디어 모바일에서도 쾌적한 60 프레임을 달성할 수 있었다
저것도 사실 기기제한 때문에 60인 것이라 대략 100프레임에 도달할 것으로 예측되었다
험난한 과정이었으나 어찌되었건 구형기기에서도 쾌적한 플레이 환경을 제공할 수 있게 되었다
상당히 오래된 기기에서는 여전히 잘 안돌아가겠지만..이건 단시간안에 해결될 문제는 아니라고 본다
추후에 시간이 된다면 더 최적화 해보겠으나 우선은 다른 밀린일들이 많은 관계로 여기까지만 하였다