Block-compressed formats store fixed-size blocks that the GPU decodes on the fly, so textures stay compressed in video memory. Each family is an optional feature: texture-compression-bc (desktop GPUs), -etc2 and -astc (mobile and Apple GPUs). This adapter reports texture-compression-bc and texture-compression-bc-sliced-3d but not ETC2 or ASTC, and asking for 'etc2-rgb8unorm' without the feature threw a TypeError. Against 32 bits per texel for rgba8unorm, BC1 (opaque color) and BC4 (one channel) take 4 bits, and BC3 (color with alpha), BC5 (normal maps), BC6H (HDR) and BC7 (high-quality color) take 8.
BC1 is simple enough to write by hand: each 4 x 4 block is two RGB565 endpoint colors and a 2-bit index per texel, 8 bytes for 16 texels. A block with endpoints cream (245, 240, 230) and BookNest blue (20, 102, 191), uploaded with writeTexture() and read with textureLoad(), decoded indices 0 to 3 as (247, 243, 231), (16, 101, 189), (170, 198, 217) and (93, 146, 203): the endpoints lost precision to 5-6-5 bits, and indices 2 and 3 are the one-third blends between them. Compressed textures cannot be render attachments, their sizes must be multiples of the block (13 x 13 failed), and bytesPerRow counts rows of blocks. In practice you ship one universal file and transcode it per GPU (KTX2 and Basis).
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="330"></canvas>
<canvas id="labels" width="600" height="330"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
const rgb565 = ([r, g, b]) => ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
const CREAM = [245, 240, 230], BLUE = [20, 102, 191];
// One BC1 block = 8 bytes for 16 texels: endpoint 0, endpoint 1 (uint16 each), then 32 bits of 2-bit indices.
function block(indexOf) { // indexOf(x, y) -> 0..3
const bytes = new Uint8Array(8), dv = new DataView(bytes.buffer);
dv.setUint16(0, rgb565(CREAM), true); // c0 > c1 selects the 4-colour mode
dv.setUint16(2, rgb565(BLUE), true);
let bits = 0;
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) bits |= indexOf(x, y) << (2 * (y * 4 + x));
dv.setUint32(4, bits >>> 0, true);
return bytes;
}
// A 16 x 16 texture: 4 x 4 blocks drawing a small blue book on cream, plus one block showing all four indices.
function blocks() {
const data = new Uint8Array(16 * 8);
for (let by = 0; by < 4; by++) for (let bx = 0; bx < 4; bx++) {
const b = block((x, y) => {
const X = bx * 4 + x, Y = by * 4 + y;
if (bx === 3 && by === 3) return x; // indices 0, 1, 2, 3 in columns
const inBook = X >= 4 && X <= 10 && Y >= 2 && Y <= 13;
const edge = inBook && (X === 4 || X === 10 || Y === 2 || Y === 13);
return inBook ? (edge ? 3 : 1) : 0; // 3 = a 1/3 blend at the edge
});
data.set(b, (by * 4 + bx) * 8);
}
return data;
}
// The same decoding in JavaScript, for GPUs without the feature.
function decodeBC1(data) {
const out = new Uint8Array(16 * 16 * 4);
const c0 = CREAM.map((c) => c), c1 = BLUE.map((c) => c);
const palette = [c0, c1, c0.map((c, k) => (2 * c + c1[k]) / 3), c0.map((c, k) => (c + 2 * c1[k]) / 3)];
for (let b = 0; b < 16; b++) {
const bits = new DataView(data.buffer).getUint32(b * 8 + 4, true);
for (let t = 0; t < 16; t++) {
const x = (b % 4) * 4 + (t % 4), y = Math.floor(b / 4) * 4 + Math.floor(t / 4);
out.set([...palette[(bits >>> (2 * t)) & 3], 255], (y * 16 + x) * 4);
}
}
return out;
}
const code = /* wgsl */ `
@group(0) @binding(0) var image: texture_2d<f32>;
@group(0) @binding(1) var smp: sampler;
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
@vertex fn vs(@builtin(vertex_index) v: u32) -> Out {
let q = vec2f(f32(v & 1), f32(v >> 1));
return Out(vec4f(q * 2 - 1, 0, 1), vec2f(q.x, 1 - q.y));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return textureSample(image, smp, in.uv); }`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const hasBC = adapter.features.has('texture-compression-bc'); // an optional feature
const device = await adapter.requestDevice({ requiredFeatures: hasBC ? ['texture-compression-bc'] : [] });
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const data = blocks();
let texture;
if (hasBC) {
texture = device.createTexture({ size: [16, 16], format: 'bc1-rgba-unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
// bytesPerRow counts rows of blocks: 4 blocks x 8 bytes.
device.queue.writeTexture({ texture }, data, { bytesPerRow: 4 * 8, rowsPerImage: 4 }, [16, 16]);
} else {
texture = device.createTexture({ size: [16, 16], format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
device.queue.writeTexture({ texture }, decodeBC1(data), { bytesPerRow: 64 }, [16, 16]);
}
const module = device.createShaderModule({ code });
const pipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] } });
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipeline);
pass.setBindGroup(0, device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: texture.createView() }, { binding: 1, resource: device.createSampler() }] }));
pass.setViewport(20, 40, 256, 256, 0, 1); // 16 x 16 texels, magnified 16x
pass.draw(4);
pass.end();
device.queue.submit([encoder.finish()]);
ink.strokeStyle = 'rgba(0,0,0,0.25)';
for (let k = 0; k <= 4; k++) { ink.strokeRect(20, 40, k * 64, 256); ink.strokeRect(20, 40, 256, k * 64); } // block grid
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText(hasBC ? 'bc1-rgba-unorm, 16 x 16 = 16 blocks x 8 bytes' : 'no texture-compression-bc here: decoded in JS', 20, 30);
ink.font = '12.5px system-ui, sans-serif';
const lines = ['Each 4 x 4 block: two RGB565 endpoints', 'and a 2-bit index per texel.',
`128 bytes here, against ${16 * 16 * 4} as rgba8unorm:`, '4 bits per texel instead of 32.', '',
'Bottom-right block shows indices 0-3:', 'cream, blue, and the two one-third', 'blends between them.', '',
`This adapter: bc ${hasBC ? 'yes' : 'no'}, etc2 ${adapter.features.has('texture-compression-etc2') ? 'yes' : 'no'}, astc ${adapter.features.has('texture-compression-astc') ? 'yes' : 'no'}`];
lines.forEach((l, i) => ink.fillText(l, 300, 60 + i * 20));
}
main();
</script>