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 KTX2

via 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:

CodeMeaning
0xFFno external texture has completed loading yet (JS HUD shows the wait text)
0RGBA (PNG-fallback path; BC7 unsupported or ?fmt=png / ?nobc7)
1BC7_UNORM
2BC7_SRGB
3BC1_UNORM
4BC1_SRGB
5BC3_UNORM
6BC3_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.tex0 has no transparent texels, so its S3TC sibling encodes as BC1/DXT1. The gltex-hud indicator shows BC7 (capable hardware) or S3TC BC1 / PNG depending on capability and any ?fmt=/?nobc7 override.
  • /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 reads S3TC BC3 when the S3TC path is active. The scene itself is unchanged from /gl-cutout — MASK alpha-test material, scroll-driven camera dolly, Y-rotation, and a baseColorA 1→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.