KTX2Loader

Compressing Textures with KTX2Loader

The fox's 1024 x 1024 PNG is 27 KB on disk but 5.6 MB in GPU memory with mipmaps. KTX2 files with Basis Universal 3,114 data (Compressed Textures) stay compressed on the GPU: KTX2Loader transcodes them in a worker to the device's block format (BC7 on desktops, ASTC or ETC2 on phones). Two samples from the Three.js 30,815 repository:

Transcoding ETC1S and UASTC textures for this GPUJavaScript
import * as THREE from 'three';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
const renderer = new THREE.WebGLRenderer();
const ktx2 = new KTX2Loader().detectSupport(renderer);  // also: gltfLoader.setKTX2Loader(ktx2)
const base = 'https://cdn.jsdelivr.net/gh/mrdoob/three.js@r186/examples/textures/ktx2/';
for (const file of ['2d_etc1s.ktx2', '2d_uastc.ktx2']) {
  const texture = await ktx2.loadAsync(base + file);
  const bc7 = texture.format === THREE.RGBA_BPTC_Format;
  console.log(file, texture.isCompressedTexture, bc7 ? 'BC7 (RGBA_BPTC_Format)' : 'other');
}
Output
2d_etc1s.ktx2 true BC7 (RGBA_BPTC_Format)
2d_uastc.ktx2 true BC7 (RGBA_BPTC_Format)

Both became BC7, a quarter of RGBA8's memory. ETC1S makes the smallest files; UASTC keeps detail for normal maps. Encode with KTX-Software 1,367 's ktx create or gltf-transform etc1s and uastc, which call it (not installed here).

A cover's mipmap chain and the GPU memory it takes as RGBA8, BC7 and ETC1SHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
  .ui { padding: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="ui">Texture size <select id="size"><option>512</option><option selected>1024</option><option>2048</option><option>4096</option></select></div>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 300);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.OrthographicCamera(0, 6, 3, 0, 0, 10);   // one unit = 100 px
camera.position.z = 5;

// A cover painted on a canvas; each mip level is half the size of the last
const art = Object.assign(document.createElement('canvas'), { width: 256, height: 256 });
const g = art.getContext('2d');
g.fillStyle = '#1f5f8b';  g.fillRect(0, 0, 256, 256);
g.fillStyle = '#fff3e0';  g.font = 'bold 44px Georgia';
g.fillText('The Quiet', 20, 90);  g.fillText('Harbor', 20, 140);
for (let i = 0; i < 8; i++) { g.fillStyle = `hsl(${200 + i * 5} 50% ${50 + i * 4}%)`; g.fillRect(0, 180 + i * 10, 256, 5); }
const cover = new THREE.CanvasTexture(art);
cover.colorSpace = THREE.SRGBColorSpace;
let x = 0.15;
for (let level = 0; level < 7; level++) {
  const s = 1.3 / 2 ** level;
  const plane = new THREE.Mesh(new THREE.PlaneGeometry(s, s), new THREE.MeshBasicMaterial({ map: cover }));
  plane.position.set(x + s / 2, 2.85 - s / 2, 0);
  x += s + 0.08;
  scene.add(plane);
}

// Labels and bars drawn on one canvas texture that covers the lower part of the view
const panel = Object.assign(document.createElement('canvas'), { width: 1200, height: 300 });
const p = panel.getContext('2d');
const panelTex = new THREE.CanvasTexture(panel);
panelTex.colorSpace = THREE.SRGBColorSpace;
const panelMesh = new THREE.Mesh(new THREE.PlaneGeometry(6, 1.5), new THREE.MeshBasicMaterial({ map: panelTex }));
panelMesh.position.set(3, 0.75, 0);
scene.add(panelMesh);

// Bytes per texel on the GPU: RGBA8 4, BC7/ASTC 4×4 1, BC1/ETC1 0.5; mipmaps add one third
const formats = [['RGBA8 (a decoded PNG)', 4, '#b5452f'], ['BC7 or ASTC 4×4 (from UASTC)', 1, '#e09a10'],
                 ['BC1 or ETC1 (from ETC1S)', 0.5, '#3f7d3a']];
function update(size) {
  p.fillStyle = '#fbf8f2';  p.fillRect(0, 0, 1200, 300);
  p.font = '26px sans-serif';  p.fillStyle = '#263238';
  p.fillText(`${size}×${size} texture with mipmaps, GPU memory:`, 30, 36);
  formats.forEach(([name, bpp, color], i) => {
    const bytes = size * size * bpp * 4 / 3, w = bytes / (size * size * 16 / 3) * 700;
    p.fillStyle = color;  p.fillRect(30, 60 + i * 76, w, 50);
    p.fillStyle = '#263238';
    p.fillText(`${name}: ${(bytes / 2 ** 20).toFixed(1)} MB`, 50 + w, 96 + i * 76);
  });
  panelTex.needsUpdate = true;
  renderer.render(scene, camera);
}
const select = document.getElementById('size');
select.addEventListener('change', () => update(+select.value));
update(1024);
</script>