텍스처, 당신의 VRAM을 잡아먹는 대식가
지난번에 정점을 반으로 줄여 모델이 좀 작아졌지만, 번쩍이는 벼락처럼 날씬해지진 않았습니다 — 진짜 크기의 주인공이 여전히 그 자리에 있었기 때문입니다: 텍스처. PBR 모델에서 텍스처는 보통 크기의 80% 이상을 차지하고, VRAM에서 가장 많이 커지는 부분입니다.
이 글은 텍스처의 "VRAM 대식가" 문제를 치료합니다. 세 가지를 설명합니다: 왜 PNG/JPG가 GPU 눈에 죄가 있는지, GPU 자체의 텍스처 포맷이 어떤 모습이고 왜 그대로는 못 쓰는지, 그리고 Basis Universal + KTX2가 셋을 어떻게 이어주는지.
복습: 왜 JPG는 VRAM을 폭발시키는가
이전 글에서 공식을 하나 드렸습니다:
VRAM 사용량 = 너비 * 높이 * 4바이트(RGBA) * 1.333(mipmap 포함)
4096×4096 텍스처는, 디스크상에서 1.5MB의 JPG든 8MB의 PNG든, VRAM에서는 약 87MB가 됩니다. 이유는 하나입니다: GPU는 JPG/PNG를 이해하지 못합니다.
GPU의 텍스처 샘플러가 이해하는 건 하나뿐입니다 — UV 좌표가 주어지면, 고정 크기의 픽셀 블록에서 색을 읽어옵니다. 텍스처는 VRAM에서 "깔린 raw pixel"이어야 합니다. 그래서 브라우저는 JPG를 GPU에 업로드하기 전에 먼저 CPU에서 완전히 RGBA 픽셀로 해제한 뒤, 그 블록 전체를 VRAM에 밀어 넣어야 합니다.
이 과정에는 삼중의 문제가 있습니다:
- VRAM 폭발: 해제된 raw pixel은 거대합니다. 87MB는 과장이 아니라 공식으로 계산된 값입니다.
- 업로드 블로킹: 큰 픽셀 블록을 CPU 메모리에서 GPU VRAM으로 옮기는 건 느린 작업으로, 첫 프레임 렌더링을 막아버립니다.
- CPU 해독 비용: 큰 이미지 해독 자체가 시간이 걸리고, 모바일에서 특히 뚜렷합니다.
이전 글의 "압축 스펀지" 비유를 이어가면: PNG/JPG는 운반하기 쉽게 납작하게 눌린 스펀지입니다. GPU에 올라탄 순간, 스펀지는 물을 머금고 원래 크기로 부풉니다. 다운로드는 빨라졌지만 VRAM은 전혀 절약되지 않았습니다.
GPU 자체의 텍스처 포맷: 태생부터 VRAM에서 압축되어
GPU가 압축된 PNG를 받아들이지 않는다면, 텍스처를 VRAM 안에서도 압축 상태로 유지할 수 있을까요? 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 픽셀의 작은 블록(block) 단위로 압축 저장되고, GPU는 샘플링할 때 이 작은 블록을 필요에 따라 해독합니다. 나오는 건 단일 픽셀이 아니라 한 블록입니다. 장점은 내용에 관계없이 VRAM 사용량이 고정 비율로 줄어드는 것입니다.
비교:
| PNG/JPG(전통) | GPU 네이티브 포맷 | |
|---|---|---|
| 디스크 크기 | 작음(JPG가 특히) | 중간(블록 압축, 고정 비트레이트) |
| VRAM 사용량 | 큼(raw pixel로 해제) | 작음(블록 압축, 상주) |
| GPU 업로드 | 느림(CPU 해독 + 큰 전송) | 빠름(그대로 옮기면 됨, 해독 불필요) |
| 샘플링 속도 | 빠름(이미 raw pixel) | 빠름(하드웨어 실시간 해독) |
GPU 포맷이 완벽해 보입니다. 그럼 왜 그대로 못 쓸까요?
문제는 여기: 기기마다 인식하는 포맷이 다르다
이게 GPU 텍스처 포맷의 가장 큰 함정 — 파편화입니다.
- 데스크톱 PC는 BC1-7을 인식, ASTC는 인식 못 함
- 안드로이드 폰은 ETC2/ASTC를 인식, 대부분 BC는 인식 못 함
- iOS(A7+)는 ASTC를, 구형 기기는 PVRTC를 인식
- WebGPU/WebGL은 그 기기 뒤의 같은 하드웨어 능력에 의존
한 텍스처를 "모든 기기에서 GPU 네이티브 포맷으로 존재하게" 하려면, 플랫폼별로 따로 준비해야 합니다. 데스크톱 + 안드로이드 + iOS로 내보내는 제품이라면 같은 텍스처에 BC + ETC2/ASTC 세 버전이 필요합니다. 패키지는 3배, 공수도 3배.
더 나쁜 건, 웹에서는 사용자가 어떤 기기로 페이지를 열지 모릅니다. 모든 포맷을 미리 생성하는 건 비현실적이고, 런타임 검출은 너무 늦습니다.
Basis Universal: 한 번 인코딩, 어디서나 트랜스코드
Basis Universal(약칭 Basis)은 이 파편화를 해결하기 위해 태어났습니다. 아이디어는 한 문장입니다:
먼저 텍스처를 "중간 포맷"으로 인코딩하고, 런타임에 현재 기기의 GPU 능력에 맞춰 해당 네이티브 포맷으로 트랜스코드(transcode)합니다.
트랜스코드 흐름(개념도):
소스 텍스처(PNG/JPG)
│ 일회성 오프라인 인코딩(느림, 한 번만)
▼
Basis 중간 포맷(ETC1S 또는 UASTC)
│ KTX2 컨테이너로 패키징
▼
웹에 공개 ──┬── 데스크톱 GPU ──→ 런타임 트랜스코드 → BC1/3/7
├── 안드로이드 ───→ 런타임 트랜스코드 → ETC2
└── iOS/VR ─────→ 런타임 트랜스코드 → ASTC
핵심:
- 오프라인 인코딩은 한 번만, 컴팩트한 중간 표현을 얻습니다
- 런타임 트랜스코드는 매우 빠름(순수 연산, 수 밀리초), 게다가 블록 포맷을 변환하므로 픽셀 단위 해제가 필요 없습니다
- 트랜스코드 후 VRAM에 들어가는 건 진짜 GPU 네이티브 포맷이라, VRAM 사용량은 블록 압축 레이트로 계산되어 GPU 네이티브 포맷과 동일
Basis는 두 가지 중간 인코딩 모드를 제공합니다. 다음 글에서 풀지만, 이름만 먼저 기억하세요:
- ETC1S: 압축률 극히 높고, diffuse/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 인코딩(크로스플랫폼)일 수도, 어떤 네이티브 포맷(예: raw BC7)일 수도 있습니다. 웹에서 99%는 Basis를 넣습니다 — 우리가 원하는 건 "한 번 인코딩, 어디서나 트랜스코드"이기 때문입니다.
VRAM 실례: 4096 텍스처 비교
앞선 공식과 GPU 포맷을 겹쳐서, 4096×4096 RGBA 텍스처의 각 방안별 실제 점유를 봅시다:
| 방안 | 디스크 크기 | VRAM 사용량(mipmap 포함) | 업로드 속도 | 크로스플랫폼 |
|---|---|---|---|---|
| PNG | ~8MB | ~87MB | 느림(해독 필요) | ✅ |
| JPG | ~1.5MB | ~87MB | 느림(해독 필요) | ✅ |
| WebP | ~2MB | ~87MB | 느림(해독 필요) | ✅ |
| KTX2 (ETC1S) | ~2-3MB | ~11-14MB | 빠름 | ✅(트랜스코드) |
| KTX2 (UASTC) | ~6-8MB | ~22MB | 빠름 | ✅(트랜스코드) |
VRAM 수치 출처: GPU 블록 압축은 보통 4bpp(픽셀당 4비트) 또는 8bpp로 계산합니다. 4096×4096을 4bpp로 약 8MB, mipmap 포함 ×1.333 ≈ 11MB. UASTC는 대부분 8bpp로 트랜스코드되어 약 22MB.
중요한 건 어느 한 줄의 정확한 수치가 아니라, 이 두 가지입니다:
- 전통 포맷(PNG/JPG/WebP)은 VRAM 사용량이 거의 같습니다 — 모두 해제된 raw pixel로 87MB. 디스크가 아무리 작아도 VRAM은 절약 안 됩니다.
- KTX2는 VRAM을 1/4에서 1/8로 직격, 게다가 디스크 크기도 뒤지지 않습니다.
이것이 VR와 모바일 웹에서 KTX2를 거의 필수로 쓰는 이유입니다 — 2GB VRAM의 폰에 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 픽셀 블록 ──업로드(크고 느림)──> VRAM(87MB)
KTX2 + Basis:
KTX2 파일 ──다운로드──> CPU 메모리 ──런타임 트랜스코드(빠름)──> GPU 블록 포맷 ──업로드(작고 빠름)──> VRAM(11MB)
후자는 큰 "CPU 픽셀 단위 해독" 단계를 빼고, 업로드하는 데이터 양도 한 자릿수 작습니다. 첫 프레임이 빠르고, VRAM도 절약 — 이게 이 방안의 핵심 가치입니다.
다음 단계
이론은 끝, 다음 글은 실전입니다. toktx와 gltf-transform으로 실제 텍스처를 KTX2로 압축하고, Three.js / Babylon.js에서 로드하며, ETC1S와 UASTC를 어떻게 고르고 압축 파라미터를 어떻게 조정하는지 다룹니다.