verve.gl compressed textures (KTX2 / BC7 + S3TC)
Guide: WebGL · Advanced WebGL · live: compressed textures — BC3 alpha · custom shader materials — BC1 opaque.
Build-time pre-compressed delivery for PNG-source material textures — no runtime transcode (v0.32.0–v0.37.0). gl_asset_gen emits two KTX2 sibling files next to every externalized .tex{N}.png, and the JS bridge always requests the plain .ktx2 URL, picking the best sibling by measured GPU capability at load time.
Build pipeline
tools/gl_asset_gen.zig writes three files per externalized texture:
<stem>.tex{N}.{ext} — original compressed bytes (unchanged)
<stem>.tex{N}.bc7.ktx2 — BC7/KTX2
<stem>.tex{N}.s3tc.ktx2 — BC1/BC3 S3TC KTX2via two core/gl/tex_encode.zig entry points:
pub fn pngToKtx2(alloc, png_bytes, srgb: bool) ![]u8 // → BC7 mode-6 (core/gl/bc7.zig)
pub fn pngToKtx2S3tc(alloc, png_bytes, srgb: bool) ![]u8 // → BC1 or BC3 (core/gl/bc1.zig)Both decode the PNG to RGBA8, encode a mip chain, and pack it into a KTX2 container (core/gl/ktx2.zig). srgb comes from vmesh.Reader.texIsSrgb: true for base-color/emissive maps, false for normal/metallic-roughness/ occlusion maps.
BC1 vs BC3 selection
pngToKtx2S3tc chooses the S3TC mode with a build-time alpha scan — quoted verbatim from core/gl/tex_encode.zig:
// Alpha scan: any pixel alpha < 255 → BC3, else BC1.
var has_alpha = false;
var i: usize = 3;
while (i < img.rgba.len) : (i += 4) {
if (img.rgba[i] < 255) {
has_alpha = true;
break;
}
}
const mode: bc1.Mode = if (has_alpha) .bc3 else .bc1;BC1 (DXT1) is RGB-only at an 8:1 ratio; BC3 (DXT5) adds a separate alpha block at 4 bpp. This is why /examples/gl-material's demo.tex0 (fully opaque) compresses to BC1, while /examples/gl-compressed-textures's cutout.tex0 (has transparent texels) compresses to BC3 — same pipeline, different sibling picked automatically by the source pixels.
Format enum and wire tag
core/gl/command.zig (CompressedFormat, tag 49 = create_compressed_texture):
pub const CompressedFormat = enum(u32) {
bc7_unorm = 1,
bc7_srgb = 2,
bc1_unorm = 3,
bc1_srgb = 4,
bc3_unorm = 5,
bc3_srgb = 6,
};create_compressed_texture = 49, // {handle, w, h, format, mip_count, ptr, byte_len}
// Upload pre-compressed data from the wasm memory level table written by the JS loader.
// `ptr` → level table start: mip_count×{u32 offset, u32 length} followed by the blocks.
// `byte_len` = mip_count*8 + total_block_bytes (table + all blocks; layer-1 `len - 16`).Encoder.createCompressedTexture(handle, width, height, format, mip_count, ptr,
byte_len) encodes the 28-byte (7×u32) payload. Its doc comment predates the BC1/BC3 addition and only mentions .bc7_unorm/.bc7_srgb, but the same function is exercised by golden tests for all six CompressedFormat values (bc7_srgb, bc7_unorm, bc1_unorm, bc1_srgb, bc3_unorm, bc3_srgb byte- layout tests in command.zig) — one wire tag, one encoder, six format words. The RGBA (uncompressed) path is unaffected and keeps using createTexture/ createTextureSrgb, never tag 49.
Runtime format selection (JS bridge)
The bridge (src/bridge/verve.js) tracks capabilities in a small capset, set by whichever backend initializes first:
// bc7 — BC7/BPTC (EXT_texture_compression_bptc / texture-compression-bc)
// s3tc — BC1/BC3/DXT (WEBGL_compressed_texture_s3tc / texture-compression-bc)
const glTexCaps = { bc7: false, s3tc: false };WebGPU requests the texture-compression-bc feature, which enables all of BC1–BC7 in one shot — both bc7 and s3tc end up true together. WebGL2 probes two independent extensions: EXT_texture_compression_bptc for BC7 and WEBGL_compressed_texture_s3tc / WEBGL_compressed_texture_s3tc_srgb for BC1/BC3 — so a GPU can support one without the other. Older desktop GPUs (e.g. Intel Gen7/HD4000-class) often support S3TC but not BPTC; those receive the .s3tc.ktx2 sibling instead of falling all the way back to PNG.
Priority when no override is active: BC7 → S3TC → PNG.
?fmt= override
// 3C: Format-force param — ?fmt=bc7|s3tc|png pins gl_load's format choice (overriding
// caps, for CDP / manual testing). ?nobc7 = legacy alias for ?fmt=png.
function parseForceFmt() {
if (typeof location === "undefined") return null; // SSR guard
const m = /[?&]fmt=(bc7|s3tc|png)\b/.exec(location.search);
if (m) return m[1];
if (/[?&]nobc7\b/.test(location.search)) return "png";
return null;
}chooseTexFmt() honors a forced format only if the capset actually supports it; an unsupported forced format (e.g. ?fmt=bc7 on S3TC-only hardware) console.warns and falls through to the normal priority pick instead of erroring. ?fmt=png (or the legacy ?nobc7) always forces the uncompressed fallback regardless of capability.
Observability
A [data-ref="gltex-hud"] element, when present on the page, is updated every frame by glTexFmtHudUpdate via the chunk export glscene_tex_format():
const glTexFmtHudUpdate = (exports) => {
if (typeof exports.glscene_tex_format !== "function") return;
const hudEl = document.querySelector('[data-ref="gltex-hud"]');
if (!hudEl) return;
const fmt = exports.glscene_tex_format() >>> 0;
if (fmt === 0xFF) return; // not yet loaded — leave initial text
hudEl.textContent = fmt === 0 ? "PNG" : fmt <= 2 ? "BC7" : fmt <= 4 ? "S3TC BC1" : "S3TC BC3";
};glscene_tex_format() (client/islands/GlScene.zig) reports the format code of the last completed external texture load:
export fn glscene_tex_format() u32 {
const inst = current orelse return 0;
if (!inst.tex_format_loaded) return 0xFF;
return inst.tex_format_seen;
}The returned codes (defined by the runtime upload path in bridge/verve.js, and mapped from the KTX2 format tag by compressedFormatFromTag) are:
| Code | Meaning |
|---|---|
0xFF | no external texture has completed loading yet (JS HUD shows the wait text) |
0 | RGBA (PNG-fallback path; BC7 unsupported or ?fmt=png / ?nobc7) |
1 | BC7_UNORM |
2 | BC7_SRGB |
3 | BC1_UNORM |
4 | BC1_SRGB |
5 | BC3_UNORM |
6 | BC3_SRGB |
The bridge also logs one console.info line per textured load: verve.gl: texture <url> → BC7 (.bc7.ktx2) / → S3TC (.s3tc.ktx2) / → PNG fallback.
Demos
/examples/gl-material— BC1 opaque path.demo.tex0has no transparent texels, so its S3TC sibling encodes as BC1/DXT1. Thegltex-hudindicator showsBC7(capable hardware) orS3TC BC1/PNGdepending on capability and any?fmt=/?nobc7override./examples/gl-compressed-textures— BC3 alpha path. Reuses the alpha-test cutout scene (also shown plain at/examples/gl-cutout):cutout.tex0's alpha channel forces the S3TC sibling to BC3/DXT5, so the HUD readsS3TC BC3when the S3TC path is active. The scene itself is unchanged from/gl-cutout— MASK alpha-test material, scroll-driven camera dolly, Y-rotation, and abaseColorA1→0 dissolve — with the format HUD added.
Known limitations (v1)
- IBL cubemaps and HDR environment maps are not compressed by this pipeline.
- ETC2 and ASTC (mobile GPU formats) are not implemented; planned as a future sub-cluster alongside Basis Universal transcoding.
- Only BC1 (opaque) and BC3 (alpha) are generated from the S3TC family — no BC4/BC5/BC6H.
- Format selection is capability-priority only; there is no per-texture manual format pin outside the page-wide
?fmt=debug override.