Kris

Textures: The VRAM-Hungry Gluttons You Never Saw Coming

3D CompressionTexture CompressionWebGLWebGPU

Last time, we chopped our vertex count in half and slimmed the model down—but it's still no twig. The real bulk is still sitting right there: textures. For a typical PBR model, textures account for over 80% of the file size, and they're also the part that bloats the most once they hit VRAM.

This post is all about fixing that VRAM hog. We'll cover three things: why PNG/JPG are fundamentally flawed in the eyes of a GPU, what GPU-native texture formats look like and why you can't just use them directly, and how the Basis Universal + KTX2 combo bridges the gap.

Quick Refresher: Why JPG Makes Your VRAM Explode

We dropped this formula in the last post:

VRAM Usage = Width * Height * 4 bytes (RGBA) * 1.333 (with mipmaps)

A 4096×4096 texture—whether it's a 1.5MB JPG or an 8MB PNG on disk—takes up roughly 87MB once it's in VRAM. And there's only one reason: the GPU doesn't understand JPG/PNG.

The GPU's texture sampler unit only knows one thing: given a UV coordinate, read a color from a fixed-size block of pixels. It requires textures to be stored in VRAM as "flattened raw pixels." So before the browser can upload a JPG to the GPU, it has to fully decompress it into RGBA pixels on the CPU, then shove the whole thing into VRAM.

This process has three major problems:

  1. VRAM explosion: The decompressed raw pixels take up a ton of space. 87MB isn't an exaggeration—it's what the math gives you.
  2. Upload stalls: Moving large chunks of pixels from CPU memory to GPU VRAM is slow and can block your first frame render.
  3. CPU decompression cost: Decompressing large images is time-consuming, especially on mobile.

Sticking with the "compression sponge" analogy from last time: PNG/JPG are squeezed-dry sponges, great for shipping. But the moment they hit the GPU, they soak up water and expand back to full size. Your download might be faster, but your VRAM savings are exactly zero.

GPU-Native Texture Formats: Compressed Right There in VRAM

Since GPUs won't accept pre-compressed PNGs, what if we could keep textures compressed even in VRAM? The GPU could decode individual pixel blocks on the fly during sampling, with almost no overhead.

That's exactly what GPU-native texture formats do. Here are the main families:

Format FamilyFull NamePrimary PlatformsCharacteristics
BC1-7Block CompressionDesktop (PC, Mac)The veteran; 4×4 pixel block compression per generation
ETC1/2Ericsson Texture CompressionMobile (Android/older iOS)The old mobile standard
ASTCAdaptive Scalable Texture CompressionMobile/VR (newer devices)Flexible, best quality, per-block adjustable
PVRTCPowerVROlder iOSBeing phased out in favor of ASTC

What these formats share: textures are compressed in small 4×4 pixel blocks, and the GPU decodes just that block on demand when sampling. It doesn't decode a single pixel—it decodes a block. The upside is that VRAM usage shrinks by a fixed ratio, regardless of the image content.

Here's the side-by-side:

PNG/JPG (Traditional)GPU-Native Formats
Disk SizeSmall (JPG especially)Medium (block-compressed, fixed bitrate)
VRAM UsageHuge (decompressed raw pixels)Small (block-compressed, resident)
Upload to GPUSlow (CPU decompress + large transfer)Fast (direct transfer, no decompression)
Sampling SpeedFast (already raw pixels)Fast (hardware decodes in real-time)

GPU formats sound like the perfect solution. So why can't we just use them directly?

The Catch: Different Devices Speak Different Formats

This is the biggest pain point of GPU texture formats—fragmentation.

  • Desktop PCs support BC1-7, not ASTC
  • Android phones support ETC2/ASTC, most don't support BC
  • iOS (A7 and later) supports ASTC; older models support PVRTC
  • WebGPU/WebGL ultimately rely on the same underlying hardware capabilities

If you want a texture to "exist in a GPU-native format on every device," you'd need to prepare a separate version for each platform. Shipping to desktop + Android + iOS means creating three versions of the same texture: BC + ETC2/ASTC. Your package size triples, and your workload triples.

Worse, on the web, you have no idea what device will open your page. Pre-generating every format isn't practical, and runtime detection is too slow.

Basis Universal: Encode Once, Transcode Everywhere

Basis Universal (or just "Basis") was created to solve this fragmentation problem. Its core idea in one sentence:

First encode the texture into an "intermediate format," then at runtime, transcode it into the appropriate native format based on the device's GPU capabilities.

Here's the transcoding flow (diagram):

Source Texture (PNG/JPG)
      │  One-time offline encoding (slow, done once)
      ▼
Basis Intermediate Format (ETC1S or UASTC)
      │  Packed into a KTX2 container
      ▼
Published to Web ──┬── Desktop GPU ──→ Runtime transcode → BC1/3/7
                  ├── Android ───→ Runtime transcode → ETC2
                  └── iOS/VR ────→ Runtime transcode → ASTC

Key points:

  • Offline encoding happens only once, producing a compact intermediate representation
  • Runtime transcoding is extremely fast (pure computation, a few milliseconds), and it transcodes block formats—no per-pixel decompression needed
  • After transcoding, what goes into VRAM is a true GPU-native format, so VRAM usage follows block-compression math, identical to a natively compressed texture

Basis offers two intermediate encoding modes. We'll dive deeper next post, but for now, just remember the names:

  • ETC1S: Extremely high compression ratio; great for diffuse/albedo and other color maps
  • UASTC: Higher quality; better for precision-sensitive maps like normal maps

KTX2: The Standard Container for GPU Textures

There's still an engineering problem: where does the encoded Basis data live, how is it labeled, and how does it connect to glTF? The answer is KTX2.

KTX2 (Khronos Texture 2) isn't another image format—it's a container format. Just like a .zip doesn't care whether it's holding documents or images, KTX2 is responsible for packaging GPU texture data (including Basis-encoded data) in a standard structure, along with metadata (format, mipmap levels, color space, etc.).

In glTF, KTX2 is integrated via the KHR_texture_basisu extension: the texture is no longer a PNG file but a KTX2 file containing Basis-encoded data. At load time, the engine detects device capabilities and transcodes to the appropriate BC/ETC/ASTC format.

Let's clarify the relationship between these three—don't mix them up:

NameRoleAnalogy
Basis UniversalEncoding scheme (how to compress the texture into an intermediate format)A "compression algorithm"
KTX2Container format (how to package the encoded data)A "box"
KHR_texture_basisuglTF extension (tells the engine this is a Basis texture)A "label"

A KTX2 file can contain Basis-encoded data (cross-platform) or a specific native format (like BC7 directly). On the web, 99% of the time it's Basis, because what we want is "encode once, transcode everywhere."

VRAM in Practice: A 4096 Texture Showdown

Let's stack the earlier formula against GPU formats and see the real VRAM footprint of a 4096×4096 RGBA texture across different approaches:

ApproachDisk SizeVRAM Usage (with mipmaps)Upload SpeedCross-Platform
PNG~8MB~87MBSlow (needs decompression)
JPG~1.5MB~87MBSlow (needs decompression)
WebP~2MB~87MBSlow (needs decompression)
KTX2 (ETC1S)~2-3MB~11-14MBFast✅ (transcode)
KTX2 (UASTC)~6-8MB~22MBFast✅ (transcode)

How the VRAM numbers work: GPU block compression typically runs at 4bpp (bits per pixel) or 8bpp. A 4096×4096 texture at 4bpp is about 8MB; with mipmaps, multiply by 1.333 ≈ 11MB. UASTC mostly transcodes to 8bpp, so roughly 22MB.

The point isn't the exact numbers in any single row—it's these two takeaways:

  1. Traditional formats (PNG/JPG/WebP) have nearly identical VRAM usage—they all decompress to raw pixels, so you're looking at 87MB. A smaller disk footprint doesn't save you any VRAM.
  2. KTX2 cuts VRAM usage to 1/4 or even 1/8, and the disk size is no worse either.

This is why VR and mobile web projects almost always go with KTX2—how many 87MB textures can you fit in a phone with 2GB of VRAM? At 11MB, you can fit seven.

Platform Support Matrix: Which GPUs Support What

While Basis shields us from the details, understanding the underlying mapping helps with debugging. Here's the current support landscape for native formats on mainstream devices:

Platform / DeviceBC1-7ETC2ASTCPVRTC
Desktop PC (D3D11/12, Vulkan, WebGPU)Partial (newer GPUs)
macOS (Metal)✅ (newer models)
Android (mainstream)
iOS (A8+)✅ (older models)
WebGL 2Extension-dependentPartial
WebGPU✅ (desktop)✅ (device-dependent)

Basis probes these capabilities at runtime and transcodes the same intermediate encoding into the most appropriate format. That's why Basis is nearly irreplaceable on the web—you can't know the user's device before you publish.

Upload Flow Comparison: Traditional vs. GPU Formats

Let's lock in the difference with a flow diagram.

Traditional PNG/JPG:

PNG File ──download──> CPU Memory ──CPU decompress (slow)──> RGBA Pixel Block ──upload (large, slow)──> VRAM (87MB)

KTX2 + Basis:

KTX2 File ──download──> CPU Memory ──runtime transcode (fast)──> GPU Block Format ──upload (small, fast)──> VRAM (11MB)

The latter eliminates the big "CPU per-pixel decompression" step, and the upload data volume is an order of magnitude smaller. Faster first-frame render and lower VRAM usage—that's the core value of this approach.

What's Next

Theory's done—next post, we get our hands dirty. We'll use toktx and gltf-transform to actually compress textures into KTX2, load them in Three.js / Babylon.js, and talk about how to choose between ETC1S and UASTC, plus how to tune compression parameters.

Support Us