최적화
글 59개
Addressables 의 그룹 설정이 번들 모양을 정한다
Addressables 의 그룹은 에디터 개념이고 런타임 단위는 그룹 설정이 만든 번들입니다. 묶음 방식에 따라 같은 20개 에셋이 번들 1개, 20개, 4개가 되고, 그룹끼리 공유하는 에셋은 Analyze 로 찾아 따로 빼야 한 벌이 됩니다.
AssetBundle 은 배정하지 않은 공용 에셋을 번들마다 복사한다
두 번들이 같은 머티리얼을 쓰는데 그 머티리얼을 어느 번들에도 넣지 않으면, AssetBundle 은 번들마다 따로 복사하고 둘을 열면 메모리에도 두 벌이 올라옵니다. 공용 에셋을 공용 번들에 명시하면 한 벌로 줄어듭니다.
MagicOnion 은 StreamingHub 에서만 자기 몫의 할당을 없앴다
MagicOnion 을 빼고 같은 일을 하는 gRPC 와 비교하면, StreamingHub 왕복은 할당이 같고 Unary 는 호출당 약 1.6 KB 에 페이로드 1바이트마다 1바이트를 더 씁니다. 상자, 마샬러, 풀이 그 차이를 만듭니다.
Reflex 의 비용은 조회가 아니라 스코프 생성과 IL2CPP 생성에 있다
Reflex 의 조회는 컨테이너 깊이와 상관없이 쌉니다. 그 값을 부모 크기에 비례하는 스코프 생성과 IL2CPP 의 생성마다 부르는 리플렉션에서 치르고, 락이 없어 여러 스레드의 첫 조회는 싱글턴을 여럿 만들 수 있습니다.
Zenject 의 조회 비용은 딕셔너리가 아니라 파이프라인에 있다
이미 만든 싱글턴 하나를 Zenject 로 꺼내는 데 1 µs 안팎이 듭니다. 딕셔너리 조회는 그중 100 ns 이고 나머지는 컨텍스트 대여, 조상 확정, 거리 계산, 순환 감지입니다. 조회는 프레임 밖으로 빼고 숫자는 목표 빌드에서 잽니다.
Zenject 의 주입은 리플렉션으로 읽은 타입 정보로 돈다
Zenject 는 타입마다 한 번 생성자와 [Inject] 멤버를 리플렉션으로 읽어 캐시하고, 그 정보대로 생성자, 필드, 메서드 순으로 채웁니다. 생성자가 여럿이면 인자가 가장 적은 것을 조용히 고르고, IL2CPP High 스트리핑에서는 필드가 예외 없이 비었습니다.
안 보이는 것을 안 그리는 값도 든다
앞의 여섯 편은 무엇을 판정할지 줄였습니다. 컬링은 무엇을 그릴지 줄입니다. 절두체 판정을 하나씩 할 때와 묶어서 할 때, 구로 먼저 거를 때의 손익, LOD 가 덜컹거리는 횟수를 실측으로 봅니다.
벽은 막고 문은 통과시킨다
앞의 다섯 편은 물체를 추렸습니다. 포털 컬링은 보이는 영역 자체를 잘라 나갑니다. 문을 지날 때마다 시야가 얼마나 좁아지는지, BSP(binary space partitioning)가 왜 축에 갇히지 않는지 실측으로 봅니다.
움직이는 것들에는 트리를 짓지 않는다
앞의 네 편은 구조를 지어 두고 질의를 던졌습니다. 스윕 앤 프룬(sweep and prune)은 한 축으로 줄만 세워 둡니다. 프레임마다 순서가 거의 안 바뀐다는 성질 하나로 어디까지 갈 수 있는지, 대신 무엇을 내주는지 실측으로 봅니다.
공간이 아니라 물체를 나눈다
격자도 쿼드트리도 KD 트리도 공간을 잘라 왔습니다. BVH(bounding volume hierarchy)는 물체를 둘로 갈라 각 무리를 상자로 감쌉니다. 크기가 있는 물체가 왜 여기에만 온전히 담기는지, 상자끼리 겹쳐도 괜찮은 이유를 실측으로 봅니다.
가운데가 아니라 중앙값에서 자른다
쿼드트리는 공간의 한가운데를 자르고 KD 트리(k-d tree)는 점들의 중앙값을 자릅니다. 트리 모양이 분포에서 풀려나는 대신 무엇을 내주는지, 가지치기가 실제로 몇 개를 걷어 내는지 실측으로 봅니다.
빈 곳은 큰 칸 하나로 남긴다
밀도가 고르지 않으면 칸 크기를 하나로 정할 수 없습니다. 붐비는 칸만 넷으로 쪼개는 쿼드트리(quadtree)의 동작과, 격자보다 나은 자리와 나쁜 자리를 실측으로 갈라 봅니다.
좌표를 칸으로, 칸을 배열로
균일 격자를 실제로 만들 때 막히는 자리들입니다. 음수 좌표에서 칸 번호가 어긋나는 이유, 맵 밖을 다루는 공간 해시(spatial hash), 같은 쌍을 두 번 보지 않는 법, 칸마다 리스트를 두지 않고 배열 세 개로 짓는 법.
목록은 보이는 만큼만 만든다
ListView 는 항목이 천 개여도 요소를 열몇 개만 만들고 돌려 씁니다. makeItem 과 bindItem 이 갈려 있는 이유, 높이를 아느냐가 가르는 두 가지 가상화 방식, Rebuild 와 RefreshItems 의 차이, 그리고 안 되는 것까지 정리했습니다.
한 배치에 텍스처 여덟 장까지다
UI Toolkit 의 우버 셰이더는 한 배치에 텍스처 여덟 장을 받습니다. 그 한도와 배치가 깨지는 조건, Vertex Budget, 사각 마스크와 스텐실 마스크가 갈리는 지점, 오버드로와 메모리까지 공식 문서에서 확인해 정리했습니다.
무엇이 다시 계산되는지가 네 가지로 나뉜다
uGUI 는 한 칸이 바뀌면 캔버스가 통째로 다시 계산됐습니다. UI Toolkit 은 스타일 해석·레이아웃·정점 버퍼·렌더 상태 넷으로 갈립니다. 바꾼 값마다 어디까지 번지는지, 숨기는 방법 셋의 비용, UsageHints, 바인딩의 리플렉션 비용까지 정리했습니다.
통로 안에서 줄을 당긴다
길찾기가 내놓은 계단 경로와 폴리곤 통로를 사람이 걷는 선으로 펴는 방법입니다. 보이는 점끼리 잇는 스트링 풀링(String Pulling)과, 통로 안 최단을 한 번에 뽑는 깔때기 알고리즘(Funnel Algorithm).
지도 한 장을 모두가 읽는다
유닛 수백 마리가 같은 곳으로 갈 때 각자 길을 찾는 대신, 목표에서 거꾸로 채운 지도를 한 장 만들어 모두가 읽는 방법입니다. 흐름 필드(Flow Field)의 세 겹 구조와 손익분기점, 목표가 바뀔 때 치르는 값.
칸이 아니라 구역 사이를 먼저 잇는다
맵이 커지면 한 칸씩 훑는 길찾기는 넓이만큼 헤맵니다. 맵을 구역으로 나누고 구역 사이만 미리 이어 두는 계층형 길찾기(HPA*, Hierarchical Pathfinding A*)의 구조와, 전처리·경로 품질·구역 크기의 거래.
건너뛰어도 되는 칸은 건너뛴다
균일한 격자에서는 같은 비용의 길이 수십 개라서 탐색이 그만큼 넓어집니다. 그중 하나만 보게 만드는 점프 포인트 서치(JPS, Jump Point Search)의 규칙과, 이 방법이 통하지 않는 자리.
1,000마리에서 멀쩡하던 코드가 10,000마리에서 죽는다
초를 재는 대신 입력이 커질 때 연산이 몇 번으로 늘어나는지를 셉니다. Big-O 를 읽는 법, 최악과 평균이 갈리는 자리, 그리고 기울기가 좋은 쪽이 지는 구간까지.
한 칸이 바뀌면 그 캔버스는 통째로 다시 계산된다
UI 가 무거워지는 자리는 그림의 퀄리티가 아니라 쌓아 둔 구조입니다. 캔버스 경계가 무엇까지 막아 주는지, 겹침이 어디서 묶음을 끊는지, 그리고 중첩 레이아웃 그룹과 오버드로우를 봅니다.
false sharing 은 공유한 적이 없어서 false 다
스레드마다 자기 카운터를 줬는데 병렬화가 오히려 느려집니다. 락도 없고 겹쳐 쓴 값도 없습니다. 두 카운터가 같은 64바이트 캐시 라인에 앉아 있어서 그렇습니다. false sharing 이 무엇이고 어떻게 확인하고 어떻게 고치는지를 직접 재본 숫자로 봅니다.
하나를 위해 ECS 를 지불할 이유는 없다
적 1만은 ECS 이고 플레이어는 GameObject 입니다. 무엇을 옮기고 무엇을 두는지는 개수와 이터레이션 빈도가 정합니다.
5,330만 삼각형이 118 드로우 콜로 접힌다
최적화가 잘 되면 프로파일에서 시뮬레이션이 사라집니다. 그리고 병목이 렌더로 옮겨갑니다. Entities Graphics 와 GPU Resident Drawer 는 같은 뿌리입니다.
결과가 같은 두 API 사이에서 33퍼센트가 갈린다
1만 마리가 동시에 죽을 때 DeadTag 를 AddComponent 로 붙이느냐 비트로 켜느냐. 코드는 몇 줄 차이인데 프레임 스파이크가 31.71ms 와 42.26ms 로 갈렸습니다.
1만 마리 이웃 탐색을 6.26ms 로, 그리고 기울기가 0 이 된다는 것
셀 크기를 질의 반경에 맞추면 엔티티당 검사 수가 상수가 됩니다. 19.60ms 가 6.26ms 가 됐는데, 진짜 결과는 그 배수가 아닙니다.
잡의 시그니처가 곧 쿼리다
IJobEntity 는 Execute 파라미터를 보고 쿼리를 만듭니다. 그리고 잡의 필드와 파라미터는 각각 1만 개가 공유하는 값과 1만 번 반복되는 대상입니다.
C# 을 쓰는데 결과는 네이티브다
Burst 는 IL 을 LLVM 으로 넘겨 네이티브 코드를 뽑습니다. 대신 쓸 수 있는 C# 이 부분집합으로 줄고, 그 제약이 곧 최적화의 조건입니다.
1만 개를 한 번 도는 것과 1개를 1만 번 부르는 것
적 1만 마리를 움직이는 코드가 느린 이유는 계산이 무거워서가 아니라 데이터가 흩어져 있어서입니다. ECS 는 그걸 붙여 놓는 것에서 시작합니다.
빌드 다섯 시간과 셰이더 0.5GB 는 같은 원인이다
셰이더 변형은 키워드 축의 곱으로 늘어납니다. 너무 많으면 빌드가 안 끝나고, 너무 적으면 화면이 조용히 깨집니다.
부드러움을 메모리로 살 것인가 연산으로 살 것인가
스프라이트 애니메이션은 메모리를 쓰고 스켈레탈은 CPU 를 씁니다. 캐릭터마다 다르게 고르는 게 답이고, 스켈레탈은 Burst 만 깔아도 달라집니다.
한 줄로 배터리를 절반 쓰는 방법
targetFrameRate 를 30 으로 내리면 프레임 예산이 두 배가 됩니다. 최적화 코드를 짜기 전에 이 한 줄을 먼저 재 봐야 하는 이유.
해상도를 낮춘다는 게 정확히 무엇을 낮추는 것인가
Render Scale 은 기기 해상도를 바꾸지 않습니다. 게임을 그려 넣는 캔버스의 크기를 바꿉니다. UI 는 안 따라 내려갑니다.
안 보이는 픽셀을 몇 번 칠하고 있나
2D 는 반투명이 겹겹이 쌓이는 구조라 같은 픽셀을 네다섯 번 칠합니다. 드로우 콜을 아무리 줄여도 이건 안 줄어듭니다.
2D 라이트는 배칭과 필레이트를 같이 낸다
2D 라이팅은 스프라이트를 그리기 전에 라이트 버퍼를 따로 렌더링합니다. 그래서 라이트 하나가 CPU 와 GPU 양쪽에 값을 매깁니다.
전부 올려둘 것인가 필요할 때 올릴 것인가
Late Binding, Variants, Runtime Atlas 는 전부 로딩 전략입니다. 그리고 Runtime Atlas 는 게임플레이 중에 부르면 확실히 걸립니다.
용량은 그대로인데 화질만 깎였다면
이중 압축은 원본과 아틀라스 양쪽에서 압축할 때 생깁니다. 최종 용량은 같고 품질만 두 번 깎입니다. 그리고 에러가 안 납니다.
텍스처를 합치는 것이 곧 배칭을 합치는 것
아틀라스는 메모리 최적화로 알려져 있지만 실제 효과는 배칭 쪽이 큽니다. 그리고 묶는 기준은 기능이 아니라 로딩 시점입니다.
PNG 를 넣든 PSD 를 넣든 결과는 같다
이미지 압축과 텍스처 압축은 다른 개념입니다. 그리고 모바일 빌드에 DXT 를 지정하면 런타임에 압축이 통째로 풀려서 앱이 죽습니다.
정답이 파이프라인에 따라 뒤집힌다
MaterialPropertyBlock 은 Built-in RP 의 정석이고 SRP Batcher 에서는 배치를 깹니다. 같은 코드가 한쪽에서는 답이고 한쪽에서는 문제입니다.
배칭은 다섯 가지 이유로 끊긴다
머티리얼도 텍스처도 같은데 배치가 끊깁니다. 정렬 레이어와 반투명 순서 때문입니다. 에셋을 아무리 합쳐도 이건 안 풀립니다.
합치지 않고도 빨라지는 법
SRP Batcher 는 메시를 안 합칩니다. 머티리얼 값을 GPU 에 상주시켜 두고 오프셋만 바꿉니다. 그래서 배치 조건이 머티리얼이 아니라 셰이더 변형입니다.
합치는 비용이 그리는 비용보다 클 때
다이나믹 배칭은 매 프레임 CPU 가 메시를 합칩니다. 그 부등호가 요즘 하드웨어에서 자주 뒤집혀서, URP 에서 우선순위가 내려갔습니다.
배칭이 왜 끊겼는지는 화면이 알려준다
Frame Debugger 는 배치가 끊긴 사유를 문장으로 적어 줍니다. 그 문장을 실제 원인으로 옮기는 표와, 도구마다 있는 함정 하나씩.
드로우 콜을 줄였는데 느려졌다면
드로우 콜과 SetPass Call 은 다른 것을 셉니다. 드로우 콜이 524 에서 765 로 늘었는데 오히려 빨라진 실측이 있습니다.
관리 힙은 30MB 인데 게임은 2GB 를 먹는다
텍스처와 메시는 관리 힙에 거의 없습니다. GC 를 아무리 줄여도 여기는 안 줄어듭니다.
CoreCLR 이 와도 모바일은 그대로다
유니티가 Mono 를 걷어내고 세대 GC 를 들여옵니다. 그런데 IL2CPP 는 이 변화에서 빠져 있습니다.
증분 GC 는 일을 여러 프레임에 나눈다
기본으로 켜져 있고 프레임 튐을 줄여 줍니다. 다만 없애 주지는 않고, 공짜도 아닙니다.
보헴 GC 는 객체를 옮기지 않는다
보수적이고, 비압축이고, 비세대입니다. 이 세 단어가 프레임에서 어떻게 나타나는지 하나씩 봅니다.
유니티에서 프레임이 가끔 튀는 이유
평균은 멀쩡한데 몇 초에 한 번 걸립니다. 대개 GC 인데, 그 GC 가 지금 유니티에서 갈아 끼워지는 중입니다.
GC 가 있는데도 메모리는 샌다
치우는 사람이 있어도 우리가 안 놓으면 안 치워집니다. 가장 흔한 범인은 이벤트입니다.
GC 는 한가할 때 도는 것이 아니다
자리가 모자랄 때 돕니다. 그래서 게임에서는 프레임 한가운데서 돌고, 그 순간 화면이 한 번 걸립니다.
문자열을 반복문 안에서 이으면 매번 새로 만든다
못 바꾼다는 성질의 대가는 반복 횟수만큼 붙습니다. 그런데 대부분의 자리에서는 신경 쓸 필요가 없습니다.
박싱은 값을 힙으로 보내는 일이다
object 자리에 int 를 넣으면 값이 상자에 담겨 힙으로 갑니다. 그 상자값이 얼마인지 봅니다.
전부 검사하지 않으려면 미리 나눠 둔다
적 500마리가 서로 부딪혔는지 보려면 12만 번을 비교해야 합니다. 공간을 칸으로 나눠 두면 같은 칸과 이웃 칸만 보면 됩니다.
같은 값을 인스턴스마다 복제하지 않는다
적 2000마리가 각자 들고 있는 이름과 아이콘과 기본 스탯은 전부 같은 값입니다. 하나만 두고 모두가 가리키면 됩니다.
오브젝트 풀은 언제 손해인가
풀링이 항상 이득이라는 통념을 실측으로 확인해봤고, 손해가 나는 구간이 분명히 있었다.
바뀌었다는 사실만 적어 두고 계산은 미룬다
한 프레임에 열 번 바뀌어도 화면에 나가는 것은 마지막 하나뿐입니다. 앞의 아홉 번은 계산해서 곧바로 버려집니다.