텍스처, 그 비디오 메모리 대식가
지난 글에서 버텍스를 절반으로 줄여 모델을 가볍게 만들었지만, 여전히 날씬해지진 못했습니다. 진짜 용량의 핵심은 따로 있으니까요: 바로 텍스처입니다. PBR 모델에서 텍스처는 보통 80% 이상의 용량을 차지하며, 비디오 메모리에서는 더욱 크게 부풀어 오릅니다.
이번 글에서는 이 '비디오 메모리 대식가' 문제를 집중적으로 다룹니다. 세 가지를 설명합니다: 왜 PNG/JPG가 GPU 관점에서 태생적 한계를 갖는지; GPU 전용 텍스처 포맷은 어떤 모습이고 왜 바로 사용할 수 없는지; 그리고 Basis Universal + KTX2 조합이 어떻게 이 문제를 해결하는지.
복습: 왜 JPG가 비디오 메모리를 폭발시킬까
지난 글에서 공식을 하나 소개했습니다:
비디오 메모리 사용량 = 가로 * 세로 * 4바이트(RGBA) * 1.333(mipmap 포함)
4096×4096 텍스처 하나는 디스크에서 1.5MB JPG든 8MB PNG든, 비디오 메모리에 올라가면 약 87MB가 됩니다. 이유는 단 하나입니다: GPU는 JPG/PNG를 모릅니다.
GPU의 텍스처 샘플러는 단 한 가지 일만 합니다. UV 좌표가 주어지면, 고정된 크기의 픽셀 블록에서 색상을 읽어옵니다. 텍스처가 비디오 메모리에 '펼쳐진(raw) 픽셀'로 존재해야 한다는 뜻이죠. 그래서 브라우저는 JPG를 GPU에 업로드하기 전에, CPU로 완전히 RGBA 픽셀로 디코딩한 다음 통째로 비디오 메모리에 밀어 넣어야 합니다.
이 과정에는 세 가지 문제가 있습니다:
- 비디오 메모리 폭발: 디코딩된 raw 픽셀은 엄청난 공간을 차지합니다. 87MB는 과장이 아니라 공식으로 계산된 숫자입니다.
- 업로드 병목: CPU 메모리에서 GPU 비디오 메모리로 대용량 픽셀을 옮기는 것은 느린 작업이라 첫 프레임 렌더링을 지연시킵니다.
- CPU 디코딩 비용: 큰 이미지 디코딩 자체가 시간이 걸리며, 모바일에서 특히 두드러집니다.
지난 글의 '압축 스펀지' 비유를 이어가자면: PNG/JPG는 짜서 부피를 줄인 스펀지로, 운반에는 편리합니다. 하지만 GPU에 도착하면 스펀지가 물을 빨아들여 원래 크기로 부풀어 오릅니다. 다운로드는 빨라졌지만, 비디오 메모리는 전혀 아낄 수 없습니다.
GPU 네이티브 텍스처 포맷: 비디오 메모리에서 압축된 상태 유지
GPU가 압축된 PNG를 받아들이지 못한다면, 텍스처를 비디오 메모리에서도 압축된 상태로 유지할 수는 없을까요? GPU가 샘플링 시 개별 픽셀 블록을 실시간으로 디코딩하면 되고, 오버헤드는 거의 없습니다.
이것이 바로 GPU 네이티브 텍스처 포맷이 하는 일입니다. 대표적인 패밀리는 다음과 같습니다:
| 포맷 패밀리 | 전체 이름 | 주요 플랫폼 | 특징 |
|---|---|---|---|
| BC1-7 | Block Compression | 데스크톱 (PC, Mac) | 오래된 표준, 세대별 4×4 픽셀 블록 압축 |
| ETC1/2 | Ericsson Texture Compression | 모바일 (Android/iOS 구형) | 모바일 구형 표준 |
| ASTC | Adaptive Scalable Texture Compression | 모바일/VR (신형 기기) | 유연하고 품질 우수, 블록별 조정 가능 |
| PVRTC | PowerVR | 구형 iOS | 점차 ASTC로 대체됨 |
이 포맷들의 공통점: 텍스처를 4×4 픽셀 블록 단위로 압축 저장하며, GPU는 샘플링 시 이 블록만 필요에 따라 디코딩합니다. 디코딩 결과는 단일 픽셀이 아니라 블록 단위입니다. 장점은 콘텐츠와 무관하게 비디오 메모리 사용량이 고정 비율로 줄어든다는 것입니다.
비교해 보겠습니다:
| PNG/JPG (기존) | GPU 네이티브 포맷 | |
|---|---|---|
| 디스크 크기 | 작음 (JPG 특히 작음) | 중간 (블록 압축, 고정 비트레이트) |
| 비디오 메모리 | 대용량 (raw 픽셀로 복원) | 소용량 (블록 압축 유지) |
| GPU 업로드 | 느림 (CPU 디코딩 + 대용량 전송) | 빠름 (디코딩 없이 직접 전송) |
| 샘플링 속도 | 빠름 (이미 raw 픽셀) | 빠름 (하드웨어 실시간 디코딩) |
GPU 포맷이 완벽한 해결책처럼 보입니다. 그렇다면 왜 바로 사용할 수 없을까요?
문제: 기기마다 인식하는 포맷이 다르다
이것이 GPU 텍스처 포맷의 가장 큰 함정입니다 — 파편화(Fragmentation).
- 데스크톱 PC는 BC1-7을 지원하지만 ASTC는 지원하지 않음
- Android 폰은 ETC2/ASTC를 지원하지만 대부분 BC는 지원하지 않음
- iOS (A7 이후)는 ASTC 지원, 구형 기기는 PVRTC 지원
- WebGPU/WebGL도 결국 기기의 동일한 하드웨어 능력을 사용
하나의 텍스처를 '모든 기기에서 GPU 네이티브 포맷으로' 사용하려면, 플랫폼별로 각각 준비해야 합니다. 데스크톱 + Android + iOS에 출시하려면 같은 이미지를 BC + ETC2/ASTC 세 가지 버전으로 만들어야 합니다. 패키지 용량은 세 배, 작업량도 세 배가 됩니다.
더 나쁜 것은, 웹에서는 사용자가 어떤 기기로 페이지를 여는지 전혀 알 수 없다는 점입니다. 모든 포맷을 미리 생성하는 것은 비현실적이고, 런타임 감지는 너무 늦습니다.
Basis Universal: 한 번 인코딩, 어디서든 트랜스코딩
Basis Universal (줄여서 Basis)은 바로 이 파편화 문제를 해결하기 위해 탄생했습니다. 핵심 아이디어는 한 문장으로 요약됩니다:
텍스처를 먼저 '중간 포맷'으로 인코딩하고, 런타임에 현재 기기의 GPU 능력에 맞춰 네이티브 포맷으로 트랜스코딩(transcode)합니다.
트랜스코딩 프로세스 (개념도):
소스 텍스처(PNG/JPG)
│ 일회성 오프라인 인코딩 (느림, 한 번만)
▼
Basis 중간 포맷 (ETC1S 또는 UASTC)
│ KTX2 컨테이너에 패킹
▼
웹 배포 ──┬── 데스크톱 GPU ──→ 런타임 트랜스코딩 → BC1/3/7
├── Android ───→ 런타임 트랜스코딩 → ETC2
└── iOS/VR ────→ 런타임 트랜스코딩 → ASTC
핵심 포인트:
- 오프라인 인코딩은 한 번만 수행하여 간결한 중간 표현을 얻습니다
- 런타임 트랜스코딩은 매우 빠름 (순수 계산, 수 밀리초 수준), 픽셀 단위 디코딩이 아닌 블록 포맷 변환이라 부담이 적습니다
- 트랜스코딩 후 비디오 메모리에 올라가는 것은 진짜 GPU 네이티브 포맷이며, 비디오 메모리 사용량은 블록 압축 기준으로 GPU 네이티브 포맷과 동일합니다
Basis는 두 가지 중간 인코딩 모드를 제공하며, 다음 글에서 자세히 다루겠습니다. 여기서는 이름만 기억해 두세요:
- ETC1S: 압축률이 매우 높아 디퓨즈/albedo 같은 컬러 맵에 적합
- UASTC: 품질이 더 높아 노멀 맵처럼 정밀도가 중요한 텍스처에 적합
KTX2: GPU 텍스처를 담는 표준 컨테이너
여기서 또 하나의 엔지니어링 문제가 남습니다: 인코딩된 Basis 데이터를 어디에 담고, 어떻게 표시하며, glTF와 어떻게 연결할까요? 정답은 KTX2입니다.
KTX2 (Khronos Texture 2)는 또 다른 이미지 포맷이 아니라 컨테이너 포맷입니다. .zip이 안에 문서가 있든 이미지가 있든 신경 쓰지 않는 것처럼, KTX2는 GPU 텍스처 데이터(Basis 인코딩 포함)를 표준 구조로 패키징하고 메타데이터(포맷, mipmap 레벨, 색상 공간 등)를 첨부하는 역할만 합니다.
glTF에서 KTX2는 KHR_texture_basisu 확장을 통해 연결됩니다: 텍스처가 더 이상 PNG 파일이 아니라 Basis 인코딩이 담긴 KTX2 파일이 됩니다. 로딩 시 엔진이 기기 능력을 감지하여 BC/ETC/ASTC 중 적절한 포맷으로 트랜스코딩합니다.
세 가지의 관계를 정리해 보겠습니다. 혼동하지 마세요:
| 이름 | 역할 | 비유 |
|---|---|---|
| Basis Universal | 인코딩 방식 (텍스처를 중간 포맷으로 압축) | '압축 알고리즘' |
| KTX2 | 컨테이너 포맷 (인코딩된 데이터 패키징) | '포장 상자' |
| KHR_texture_basisu | glTF 확장 (엔진에 Basis 텍스처임을 알림) | '라벨' |
하나의 KTX2 파일 내부에는 Basis 인코딩(크로스 플랫폼) 또는 특정 네이티브 포맷(예: BC7 직접)이 담길 수 있습니다. 웹에서 99%는 Basis를 담습니다. 우리가 원하는 것이 바로 '한 번 인코딩, 어디서든 트랜스코딩'이기 때문입니다.
VRAM 실전: 4096 텍스처 비교
앞서 소개한 공식과 GPU 포맷을 결합하여, 4096×4096 RGBA 텍스처 하나가 각 방식에서 실제로 차지하는 용량을 비교해 보겠습니다:
| 방식 | 디스크 크기 | 비디오 메모리 (mipmap 포함) | 업로드 속도 | 크로스 플랫폼 |
|---|---|---|---|---|
| PNG | ~8MB | ~87MB | 느림 (디코딩 필요) | ✅ |
| JPG | ~1.5MB | ~87MB | 느림 (디코딩 필요) | ✅ |
| WebP | ~2MB | ~87MB | 느림 (디코딩 필요) | ✅ |
| KTX2 (ETC1S) | ~2-3MB | ~11-14MB | 빠름 | ✅ (트랜스코딩) |
| KTX2 (UASTC) | ~6-8MB | ~22MB | 빠름 | ✅ (트랜스코딩) |
비디오 메모리 숫자 계산: GPU 블록 압축은 보통 4bpp (픽셀당 4비트) 또는 8bpp 기준입니다. 4096×4096은 4bpp에서 약 8MB, mipmap 포함 시 1.333을 곱해 약 11MB입니다. UASTC는 대부분 8bpp로 트랜스코딩되므로 약 22MB입니다.
중요한 것은 특정 행의 정확한 숫자가 아니라 다음 두 가지입니다:
- 기존 포맷 (PNG/JPG/WebP)의 비디오 메모리 사용량은 거의 동일 — 모두 디코딩된 raw 픽셀이므로 87MB입니다. 디스크가 아무리 작아도 비디오 메모리는 아낄 수 없습니다.
- KTX2는 비디오 메모리를 1/4 ~ 1/8로 직접 줄여주며, 디스크 크기도 손해 보지 않습니다.
이것이 VR과 모바일 웹에서 KTX2가 사실상 필수인 이유입니다 — 2GB 비디오 메모리 폰에 87MB 텍스처를 몇 장이나 올릴 수 있을까요? 11MB라면 7장은 올릴 수 있습니다.
플랫폼 지원 매트릭스: 어떤 GPU가 어떤 포맷을 지원할까
Basis가 세부 사항을 추상화해 주지만, 기본 매핑을 이해하면 문제 해결에 도움이 됩니다. 현재 주류 기기의 네이티브 포맷 지원 현황입니다:
| 플랫폼 / 기기 | BC1-7 | ETC2 | ASTC | PVRTC |
|---|---|---|---|---|
| 데스크톱 PC (D3D11/12, Vulkan, WebGPU) | ✅ | ❌ | 일부 (신형 GPU) | ❌ |
| macOS (Metal) | ✅ (신형) | ❌ | ✅ | ❌ |
| Android (주류) | ❌ | ✅ | ✅ | ❌ |
| iOS (A8+) | ❌ | ✅ | ✅ | ✅ (구형) |
| WebGL 2 | 확장에 따라 다름 | ✅ | 일부 | ❌ |
| WebGPU | ✅ (데스크톱) | ✅ | ✅ (기기별) | ❌ |
Basis는 런타임에 이러한 능력을 감지하여 동일한 중간 인코딩을 가장 적합한 포맷으로 변환합니다. 이것이 웹에서 Basis 레이어가 사실상 대체 불가능한 이유입니다 — 배포 전에 사용자 기기를 예측할 수 없기 때문입니다.
업로드 프로세스 비교: 기존 vs GPU 포맷
마지막으로 플로우차트로 차이를 명확히 정리하겠습니다.
기존 PNG/JPG:
PNG 파일 ──다운로드──> CPU 메모리 ──CPU 디코딩(느림)──> RGBA 픽셀 블록 ──업로드(대용량, 느림)──> 비디오 메모리(87MB)
KTX2 + Basis:
KTX2 파일 ──다운로드──> CPU 메모리 ──런타임 트랜스코딩(빠름)──> GPU 블록 포맷 ──업로드(소용량, 빠름)──> 비디오 메모리(11MB)
후자는 'CPU 픽셀 단위 디코딩'이라는 큰 단계가 없고,