Compressed Textures

Compressed and KTX2/Basis Universal Textures

A PNG is compressed only on disk: uploaded, a 1024 x 1024 RGBA texture takes 4 MB of GPU memory, 5.3 MB with mipmaps. GPU block compression keeps textures compressed in memory at 4 to 8 bits per pixel, but each GPU family reads different formats, each behind a WebGL extension and uploaded with compressedTexImage2D():

GPU texture compression families and what Chrome 154 1 exposed here
Format family WebGL extension Typical GPUs GTX 1650 SwiftShader 2,546
BC1-BC3 (S3TC/DXT) WEBGL_compressed_texture_s3tc desktop yes yes
BC4-BC5, BC6H-BC7 EXT_texture_compression_rgtc, _bptc desktop yes yes
ETC2/EAC WEBGL_compressed_texture_etc Android, some iOS no yes
ASTC WEBGL_compressed_texture_astc phones, Apple silicon no yes

The fix is one universal file converted on the device. Basis Universal 3,114 (Apache 2.0, github.com/BinomialLLC/basis_universal (https://github.com/BinomialLLC/basis_universal 3,114 ), v2.50 of August 2026) encodes to ETC1S (small) or UASTC (high quality), and its WebAssembly transcoder rewrites the data into BC7, ASTC or ETC2 as the device requires. Its container is Khronos's KTX2 (glTF's KHR_texture_basisu), made with ktx create from KTX-Software 4.4.2 1,367 (github.com/KhronosGroup/KTX-Software (https://github.com/KhronosGroup/KTX-Software 1,367 )). Three.js 30,815 and Babylon.js 89,603 wrap the transcoder in their KTX2 loaders.

A cover block-compressed to BC1 in JavaScript and uploaded with compressedTexImage2D, beside the RGBA8 originalHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font-family: monospace; }
  .names div { flex: 1; padding: 4px 2px; }
  table { max-width: 600px; width: 100%; border-collapse: collapse; font-family: monospace; }
  td { padding: 2px 6px; border-bottom: 1px solid #e3ddd0; }
  .yes { color: #3f7d3a; } .no { color: #aaa; }
</style>
<canvas id="c" width="600" height="250"></canvas>
<div class="names"><div id="left"></div><div id="right"></div></div>
<table id="exts"></table>
<script>
// The source image: 128 x 128 pixels drawn on a 2D canvas and read back.
const art = Object.assign(document.createElement('canvas'), { width: 128, height: 128 });
const g = art.getContext('2d');
const grad = g.createLinearGradient(0, 0, 0, 128);
grad.addColorStop(0, '#1f5f8b'); grad.addColorStop(1, '#2a9d8f');
g.fillStyle = grad; g.fillRect(0, 0, 128, 128);
g.fillStyle = '#e09a10'; g.beginPath(); g.arc(92, 40, 18, 0, Math.PI * 2); g.fill();
g.fillStyle = '#fff'; g.font = 'bold 17px Georgia'; g.textAlign = 'center';
g.fillText('The Quiet', 64, 92); g.fillText('Harbor', 64, 112);
const rgba = g.getImageData(0, 0, 128, 128).data;

// A tiny BC1 (DXT1) encoder: per 4x4 block, two RGB565 end colours and a 2-bit index per texel.
function encodeBC1(px, w, h) {
  const out = new Uint8Array((w / 4) * (h / 4) * 8);
  const to565 = (r, g, b) => ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
  let o = 0;
  for (let by = 0; by < h; by += 4) for (let bx = 0; bx < w; bx += 4) {
    const block = [];
    for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) { const i = ((by + y) * w + bx + x) * 4; block.push([px[i], px[i + 1], px[i + 2]]); }
    const lum = (c) => c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11;
    let hi = block[0], lo = block[0];
    for (const c of block) { if (lum(c) > lum(hi)) hi = c; if (lum(c) < lum(lo)) lo = c; }
    let c0 = to565(...hi), c1 = to565(...lo);
    const flip = c0 < c1;
    if (flip) [c0, c1] = [c1, c0];                        // c0 > c1 selects the four-colour mode
    let bits = 0;
    if (c0 !== c1) block.forEach((c, k) => {             // palette order: c0, c1, 2/3 c0 + 1/3 c1, 1/3 c0 + 2/3 c1
      const t = Math.max(0, Math.min(1, (lum(c) - lum(lo)) / Math.max(1, lum(hi) - lum(lo))));
      const index = [1, 3, 2, 0][Math.round((flip ? 1 - t : t) * 3)];
      bits |= index << (k * 2);
    });
    out.set([c0 & 255, c0 >> 8, c1 & 255, c1 >> 8, bits & 255, (bits >> 8) & 255, (bits >> 16) & 255, (bits >>> 24) & 255], o);
    o += 8;
  }
  return out;
}

const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec2 aPos; out vec2 vUV;
void main() { vUV = vec2(aPos.x, 1.0 - aPos.y); gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); }`],
  [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec2 vUV; uniform sampler2D uTex; out vec4 c; void main() { c = texture(uTex, vUV); }`]]) {
  const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
function show(texture, x) {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);   // enlarged: blocks visible
  gl.viewport(x, 5, 240, 240);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}
gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);

const plain = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, plain);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 128, 128, 0, gl.RGBA, gl.UNSIGNED_BYTE, rgba);
show(plain, 40);
document.getElementById('left').textContent = `RGBA8: ${128 * 128 * 4} bytes on the GPU`;

const s3tc = gl.getExtension('WEBGL_compressed_texture_s3tc');
if (s3tc) {
  const blocks = encodeBC1(rgba, 128, 128);
  const compressed = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, compressed);
  gl.compressedTexImage2D(gl.TEXTURE_2D, 0, s3tc.COMPRESSED_RGB_S3TC_DXT1_EXT, 128, 128, 0, blocks);
  show(compressed, 320);
  document.getElementById('right').textContent = `BC1 / DXT1: ${blocks.length} bytes (4 bits per pixel)`;
} else {
  document.getElementById('right').textContent = 'WEBGL_compressed_texture_s3tc is not exposed here';
}
// Which families this browser exposes: the reason Basis Universal transcodes on the device.
document.getElementById('exts').innerHTML = [
  ['BC1-BC3 (S3TC)', 'WEBGL_compressed_texture_s3tc'], ['BC7 (BPTC)', 'EXT_texture_compression_bptc'],
  ['ETC2 / EAC', 'WEBGL_compressed_texture_etc'], ['ASTC', 'WEBGL_compressed_texture_astc']]
  .map(([family, name]) => { const ok = !!gl.getExtension(name);
    return `<tr><td>${family}</td><td>${name}</td><td class="${ok ? 'yes' : 'no'}">${ok ? 'yes' : 'no'}</td></tr>`; }).join('');
</script>