Atlas Packing and KTX2 Basis Compression
This page packs a building’s UV islands into an atlas with a gutter that survives mipmapping, then encodes that atlas to KTX2 with Basis Universal so it stays compressed in GPU memory rather than only on the wire. The second half is the one that matters for a city: a PNG or JPEG texture is decompressed to raw RGBA on upload, so a tileset whose download looks modest can occupy several times its own size in VRAM.
Why you hit this
Texture memory, not geometry, is usually what makes a city tileset evict. A 1024×1024 RGBA texture is 4 MB resident regardless of whether it arrived as a 180 KB JPEG, and a few hundred visible buildings each carrying one is more VRAM than the geometry will ever use. KTX2 with a Basis payload transcodes to whichever block-compressed format the device supports — BC7 on desktop, ASTC or ETC2 on mobile — so the same texture occupies a quarter to a sixth of that and never round-trips through raw pixels.
The atlas this packs is produced by the baking step in texture mapping workflows.
Prerequisites
- Python 3.10+ with
xatlas>=0.0.7,trimesh>=4.0,numpy>=1.24andPillow>=10. toktxorbasisufrom the KTX-Software toolchain, plus@gltf-transform/clifor wiring the result into a glTF.- A mesh with per-face material assignments if you intend to keep materials separable, because packing merges them.
Step-by-Step
1. Pack the islands with a gutter sized for mipmapping
xatlas does the packing; the parameter that matters is the padding between islands.
import numpy as np
import trimesh
import xatlas
mesh = trimesh.load("building_block.ply", force="mesh")
atlas = xatlas.Atlas()
atlas.add_mesh(mesh.vertices, mesh.faces)
chart = xatlas.ChartOptions()
chart.max_cost = 4.0 # lower splits more, reducing distortion
pack = xatlas.PackOptions()
pack.resolution = 2048
pack.padding = 8 # texels between islands — see below
pack.bilinear = True
pack.block_align = True # align islands to 4x4 blocks for BCn/ASTC
atlas.generate(chart_options=chart, pack_options=pack)
vmap, indices, uvs = atlas[0]
print(f"{atlas.chart_count} charts, {atlas.width}x{atlas.height}, "
f"utilisation {atlas.utilization[0]:.1%}")
The gutter has to survive every mip level you intend to sample, and each level halves it. Eight texels at the base gives four at mip 1, two at mip 2 and one at mip 3 — so eight is the minimum for a texture sampled three levels down, and sixteen is safer for a full mip chain. Too small a gutter is the cause of the coloured fringes that appear on distant buildings and not on close ones, which is a distinctive and frequently misdiagnosed symptom.
2. Rebuild the mesh against the new UVs
xatlas returns a vertex remap, so the mesh has to be rebuilt rather than patched.
import numpy as np
import trimesh
new_vertices = mesh.vertices[vmap]
new_normals = mesh.vertex_normals[vmap]
packed = trimesh.Trimesh(vertices=new_vertices, faces=indices, process=False)
packed.visual = trimesh.visual.TextureVisuals(uv=uvs)
print(f"vertices {len(mesh.vertices):,} → {len(new_vertices):,} "
f"(+{100 * (len(new_vertices) / len(mesh.vertices) - 1):.1f}% from seam splits)")
The vertex count always grows, because every vertex on an island boundary has to be duplicated so it can carry two texture coordinates. A ten to twenty per cent increase is normal; forty per cent or more means max_cost is splitting charts too aggressively and is worth raising.
3. Encode to KTX2 — UASTC or ETC1S
The two Basis modes are not quality settings, they are different formats for different content.
# UASTC: high quality, larger, for colour and normal maps that matter.
toktx --t2 --uastc 3 --uastc_rdo_l 1.0 --zcmp 18 --genmipmap \
--assign_oetf srgb block_albedo.ktx2 block_albedo.png
# ETC1S: much smaller, lossier, for ambient occlusion, roughness and distant LODs.
toktx --t2 --bcmp --clevel 4 --qlevel 200 --genmipmap \
--assign_oetf linear block_ao.ktx2 block_ao.png
ktxinfo block_albedo.ktx2 | head -12
ETC1S compresses far harder and introduces visible blocking on high-frequency colour, so it suits single-channel maps and distant levels of detail. UASTC keeps facade detail intact and is roughly three to four times the file size. Both transcode to a block-compressed GPU format, which is the property that matters — the file size difference is secondary to the fact that neither ever becomes raw RGBA.
4. Wire the KTX2 into the glTF
The extension has to be declared, and it has to be declared as required for the payload to be meaningful.
npx @gltf-transform/cli uastc block.glb block_ktx2.glb \
--slots "{baseColorTexture,normalTexture}" --level 3 --rdo 1.0 --zstd 18
npx @gltf-transform/cli etc1s block_ktx2.glb block_final.glb \
--slots "{occlusionTexture,metallicRoughnessTexture}" --quality 200
npx @gltf-transform/cli inspect block_final.glb | grep -A4 "textures"
from pygltflib import GLTF2
g = GLTF2().load("block_final.glb")
print("extensionsUsed:", g.extensionsUsed)
print("extensionsRequired:", g.extensionsRequired)
assert "KHR_texture_basisu" in (g.extensionsRequired or []), \
"KTX2 payload present but the extension is not required — non-supporting clients will render untextured"
5. Check the atlas actually improved anything
Packing and encoding both have failure modes that look like success.
from PIL import Image
import os
def report(path, uvs):
size = os.path.getsize(path) / 1e6
print(f"{os.path.basename(path):<24} {size:6.2f} MB")
import numpy as np
inside = ((uvs >= 0.0) & (uvs <= 1.0)).all(axis=1).mean()
print(f"UVs inside [0,1]: {100 * inside:.2f}%")
assert inside > 0.999, "UVs escaped the atlas — packing failed or the remap was misapplied"
print(f"atlas utilisation: {atlas.utilization[0]:.1%}")
Utilisation below about 50% means the packer is wasting half the texture on empty space, usually because a few very large charts dominate. Raising max_cost splits them and typically recovers ten to twenty points, at the cost of more seams and a slightly higher vertex count.
Expected Output & Verification
A representative run on a photogrammetric block:
412 charts, 2048x2048, utilisation 71.4%
vertices 184,220 → 211,884 (+15.0% from seam splits)
UVs inside [0,1]: 100.00%
block_albedo.ktx2 3.14 MB
block_ao.ktx2 0.42 MB
extensionsRequired: ['KHR_texture_basisu']
Then confirm the two things a file inspection cannot: that the texture looks right at distance, which is where a thin gutter shows, and that VRAM fell. The second is measurable directly:
const info = viewer.scene.context._gl.getExtension('WEBGL_debug_renderer_info');
console.log('texture memory MB:', (viewer.scene.totalMemoryUsageInBytes / 1e6).toFixed(1));
A tileset that moved from JPEG to KTX2 should show texture memory falling by roughly a factor of four, and the tile eviction rate falling with it.
Common Errors
Coloured fringes on distant buildings only. The gutter is too small for the deep mip levels. Raise padding to 16 and rebuild; there is no runtime fix.
Every model renders untextured in one viewer. KHR_texture_basisu is in extensionsUsed but not extensionsRequired, so a non-supporting client loads the file, finds no fallback image, and draws it plain. Declare it required.
KTX2 files are larger than the JPEGs and VRAM did not fall. The encode produced an uncompressed KTX2 rather than a Basis payload — --t2 without --uastc or --bcmp. Check with ktxinfo that the supercompression scheme is set.
Atlas utilisation is very low. A handful of enormous charts. Raise chart.max_cost to split them, and accept the extra seams.
Frequently Asked Questions
One atlas per building or one per block?
Per block, up to about 2048 or 4096 square. One atlas per building multiplies the draw calls, which as triangle-count tuning shows is the frame cost that actually matters.
Does KTX2 slow down loading?
Transcoding costs a few milliseconds per texture on the CPU, against a JPEG decode of similar magnitude. The saving is on the GPU side, where the upload is smaller and stays smaller.
Should normal maps use UASTC or ETC1S?
UASTC. ETC1S quantises chroma aggressively, and a normal map’s channels are directions rather than colours, so the artefacts appear as visible faceting in the lighting.
One planning note before adopting this. KTX2 changes where the cost sits rather than removing it: the download grows, the GPU memory falls, and the CPU pays a transcode. For a tileset whose problem is bandwidth on a slow connection that is the wrong trade, and JPEG plus aggressive resolution reduction is better. For a city tileset whose problem is eviction — which is nearly all of them past a few hundred visible buildings — it is decisively the right one.
The second note concerns tooling. Both toktx and gltf-transform will happily produce a KTX2 file with no supercompression, which is a valid container holding uncompressed pixels and offers none of the benefit. Because the extension is declared either way and the render is identical, the only signal is the file size and ktxinfo. Check the supercompression scheme in the same step that writes the file, not in review.
Finally, keep the source PNGs. Basis is a lossy encode and the parameters will be revisited — a change of mode, a different RDO setting, a new device class in the fleet — and re-encoding from an already-encoded texture compounds the loss.
Can the atlas be rebuilt without re-baking the textures?
No. The UV layout and the texture are one artifact — repacking moves every island, so the baked pixels no longer correspond to the coordinates. Re-baking is the cost of a repack, which is a reason to settle the atlas resolution and gutter before baking rather than after.
Related Guides
- Texture Mapping Workflows for 3D Geospatial — producing the atlas this packs
- Baking Normal and AO Maps for Web Delivery — the maps that get encoded differently
- Preserving UV Seams During Mesh Decimation — protecting the islands this packing created