Generating UV Atlases with xatlas
This page generates a UV atlas for a mesh that has none — running xatlas to segment the surface into charts, parameterise each one and pack them into a texture, choosing the options that control seam count and distortion, sizing the atlas from a texel-density target rather than a round number, and auditing the result for distortion and wasted space.
Why you hit this
A mesh from CityGML extrusion, from a planar simplification or from a boolean operation has no texture coordinates. Before it can carry a baked texture — from projecting textures from oriented images, or a baked ambient-occlusion pass, or an analysis result as a property texture — it needs a parameterisation.
Doing it by hand in a modelling tool is not an option for 40,000 buildings. xatlas is the library the industry uses for this: it segments the mesh into charts with low distortion, parameterises each chart and packs them into a rectangle. The results depend heavily on two or three options, and the failure modes — visible seams, wasted atlas space, stretched texels — are all measurable.
Prerequisites
- Python 3.10+ with
xatlas,numpy,trimesh;Pillowfor the atlas checks. - A mesh with clean topology: no degenerate faces, no duplicate vertices. See decimating meshes with PyMeshLab for the cleaning.
- A texel-density target, in texels per metre.
Step-by-Step
1. Clean the mesh, because xatlas will not
import json
import math
from pathlib import Path
import numpy as np
import trimesh
import xatlas
def prepare_mesh(path, merge_tol=1e-5):
mesh = trimesh.load(path, process=False, force="mesh")
before = {"vertices": int(len(mesh.vertices)), "faces": int(len(mesh.faces))}
mesh.merge_vertices(merge_tex=False, merge_norm=False)
mesh.update_faces(mesh.nondegenerate_faces())
mesh.update_faces(mesh.unique_faces())
mesh.remove_unreferenced_vertices()
problems = []
if not mesh.is_winding_consistent:
problems.append("inconsistent winding — charts may be parameterised inside out")
components = mesh.split(only_watertight=False)
tiny = [c for c in components if len(c.faces) < 4]
if tiny:
problems.append(f"{len(tiny)} component(s) with fewer than 4 faces — "
f"each becomes its own chart")
return mesh, {
"before": before,
"after": {"vertices": int(len(mesh.vertices)), "faces": int(len(mesh.faces))},
"components": len(components),
"problems": problems,
"surface_area_m2": round(float(mesh.area), 2),
"is_watertight": bool(mesh.is_watertight),
}
xatlas assumes a valid mesh and produces strange charts from an invalid one. A degenerate face has no well-defined normal, so the chart segmentation puts it in its own chart; a duplicated vertex splits a chart along an edge that should be continuous, adding a seam where none is needed.
Counting components before parameterising is worth doing because each component becomes at least one chart, and a mesh with 2,000 tiny floating fragments produces 2,000 charts that consume atlas space and pack badly. Removing them first, as in the decimation cleaning pass, is the fix.
merge_tex=False matters: merging vertices that differ only in existing texture coordinates would destroy a parameterisation you might want to keep, and for a mesh with no UVs it is simply the safe default.
2. Size the atlas from a texel density
def atlas_resolution(surface_area_m2, texels_per_m=64, utilisation=0.75,
max_dim=8192, min_dim=256):
"""Work out the texture dimension needed for a target texel density."""
needed_texels = surface_area_m2 * (texels_per_m ** 2)
with_padding = needed_texels / max(utilisation, 0.01)
dim = 2 ** math.ceil(math.log2(math.sqrt(with_padding)))
clamped = min(max(dim, min_dim), max_dim)
achieved = math.sqrt(clamped ** 2 * utilisation / max(surface_area_m2, 1e-9))
return {
"surface_area_m2": round(surface_area_m2, 1),
"requested_texels_per_m": texels_per_m,
"ideal_dim": dim,
"chosen_dim": clamped,
"clamped": clamped != dim,
"achieved_texels_per_m": round(achieved, 1),
"texture_mb_rgba": round(clamped * clamped * 4 / 1e6, 2),
"gpu_mb_with_mips": round(clamped * clamped * 4 * 1.333 / 1e6, 2),
}
DENSITY_GUIDANCE = {
"city_lod2_streaming": 32,
"building_facade_viewer": 64,
"inspection_detail": 256,
"analysis_property_texture": 8,
}
for name, density in DENSITY_GUIDANCE.items():
print(name, atlas_resolution(1200.0, texels_per_m=density))
Texels per metre is the parameter to think in, and the atlas dimension follows from it. A facade viewed from across a street resolves about 2 cm per screen pixel, so 50 texels per metre is the point of diminishing returns and 64 is a comfortable choice; 256 is four times the memory for detail nobody sees.
The 0.75 utilisation assumption accounts for packing waste and is realistic — xatlas typically achieves 0.6–0.85 depending on chart shapes. Computing the achieved density after clamping is what stops a silently under-textured result: a 1,200 m² building asking for 256 texels/m needs a 16,384 atlas, gets clamped to 8,192, and actually achieves 128.
The GPU figure with mipmaps is the number that matters for a city, as covered in encoding property textures for per-texel data — a 4,096 atlas is 89 MB on the GPU as RGBA.
3. Run xatlas with the options that matter
def parameterise(mesh, resolution=4096, padding=4, texels_per_unit=0.0,
max_chart_area=0.0, max_boundary_length=0.0,
normal_deviation_weight=2.0, roundness_weight=0.01,
straightness_weight=6.0, normal_seam_weight=4.0,
texture_seam_weight=0.5, max_cost=2.0, max_iterations=1,
brute_force=False, block_align=True):
atlas = xatlas.Atlas()
atlas.add_mesh(np.asarray(mesh.vertices, dtype=np.float32),
np.asarray(mesh.faces, dtype=np.uint32))
chart_options = xatlas.ChartOptions()
chart_options.max_chart_area = max_chart_area
chart_options.max_boundary_length = max_boundary_length
chart_options.normal_deviation_weight = normal_deviation_weight
chart_options.roundness_weight = roundness_weight
chart_options.straightness_weight = straightness_weight
chart_options.normal_seam_weight = normal_seam_weight
chart_options.texture_seam_weight = texture_seam_weight
chart_options.max_cost = max_cost
chart_options.max_iterations = max_iterations
pack_options = xatlas.PackOptions()
pack_options.resolution = int(resolution)
pack_options.padding = int(padding)
pack_options.texels_per_unit = float(texels_per_unit)
pack_options.bilinear = True
pack_options.block_align = bool(block_align)
pack_options.brute_force = bool(brute_force)
pack_options.create_image = False
atlas.generate(chart_options=chart_options, pack_options=pack_options)
vmapping, indices, uvs = atlas[0]
return {
"vmapping": np.asarray(vmapping),
"indices": np.asarray(indices),
"uvs": np.asarray(uvs),
"stats": {
"charts": int(atlas.chart_count),
"atlas_count": int(atlas.atlas_count),
"width": int(atlas.width), "height": int(atlas.height),
"utilisation": round(float(atlas.utilization[0]), 4)
if atlas.atlas_count else 0.0,
"texels_per_unit": round(float(atlas.texels_per_unit), 3),
"vertices_before": int(len(mesh.vertices)),
"vertices_after": int(len(vmapping)),
"vertex_growth": round(len(vmapping) / max(len(mesh.vertices), 1), 3),
},
}
max_cost is the option with the largest effect on chart count. It caps the parameterisation distortion xatlas will tolerate before splitting a chart: at 2.0 a facade with a slight curve stays one chart with a little stretching, and at 1.05 it splits into several charts with almost none. More charts mean more seams and worse packing; higher distortion means stretched texels.
normal_seam_weight and texture_seam_weight control where xatlas prefers to put seams. Raising the normal-seam weight makes it cut along sharp creases, which is usually what you want on architecture — a seam at a building’s corner is invisible, a seam in the middle of a wall is not.
texels_per_unit is the alternative to resolution: set it and xatlas chooses the atlas size to achieve that density, which is often more useful than fixing the dimension. Setting both means the resolution wins and the density is whatever fits.
Vertex growth is expected and worth watching. A parameterisation splits vertices along every seam, so a 10,000-vertex mesh typically becomes 12,000–18,000 — and a growth factor above about 2.0 means far too many charts.
4. Apply the parameterisation back to the mesh
def apply_parameterisation(mesh, result):
vmapping = result["vmapping"]
indices = result["indices"]
uvs = result["uvs"]
new_vertices = np.asarray(mesh.vertices)[vmapping]
new_mesh = trimesh.Trimesh(vertices=new_vertices,
faces=indices.reshape(-1, 3),
process=False)
new_mesh.visual = trimesh.visual.TextureVisuals(uv=uvs)
# Face attributes survive because xatlas preserves face order.
if hasattr(mesh.visual, "face_colors") and mesh.visual.face_colors is not None:
new_mesh.visual.face_colors = mesh.visual.face_colors
area_before = float(mesh.area)
area_after = float(new_mesh.area)
return new_mesh, {
"vertices": int(len(new_mesh.vertices)),
"faces": int(len(new_mesh.faces)),
"faces_preserved": int(len(new_mesh.faces)) == int(len(mesh.faces)),
"area_before_m2": round(area_before, 3),
"area_after_m2": round(area_after, 3),
"area_change_pct": round(100.0 * (area_after - area_before)
/ max(area_before, 1e-9), 4),
"uv_range": [round(float(uvs.min()), 5), round(float(uvs.max()), 5)],
"uvs_in_unit_square": bool(uvs.min() >= -1e-6 and uvs.max() <= 1.0 + 1e-6),
}
xatlas preserves face order and face count, which is the property that lets face attributes — material assignments, per-face classifications, feature identifiers — carry through unchanged. Only the vertex array grows, and vmapping says which original vertex each new one came from, so vertex attributes carry through by indexing.
Surface area must be unchanged: parameterisation does not move vertices in 3D, only assigns them 2D coordinates. A non-zero area_change_pct means something other than xatlas modified the geometry.
UVs outside the unit square mean either multiple atlases were generated — atlas_count > 1 — or the packing overflowed, and both need handling before the mesh is textured.
5. Audit the distortion
def distortion_audit(mesh_with_uv, atlas_dim):
"""Per-triangle ratio of UV area to 3D area: how much each texel is stretched."""
verts = np.asarray(mesh_with_uv.vertices)
faces = np.asarray(mesh_with_uv.faces)
uv = np.asarray(mesh_with_uv.visual.uv)
tri3d = verts[faces]
tri2d = uv[faces] * atlas_dim
a3 = np.linalg.norm(np.cross(tri3d[:, 1] - tri3d[:, 0],
tri3d[:, 2] - tri3d[:, 0]), axis=1) / 2.0
e0 = tri2d[:, 1] - tri2d[:, 0]
e1 = tri2d[:, 2] - tri2d[:, 0]
a2 = np.abs(e0[:, 0] * e1[:, 1] - e0[:, 1] * e1[:, 0]) / 2.0
valid = (a3 > 1e-12) & (a2 > 1e-12)
texels_per_m2 = np.zeros(len(faces))
texels_per_m2[valid] = a2[valid] / a3[valid]
density = np.sqrt(np.maximum(texels_per_m2, 0.0)) # texels per metre
# Anisotropy: how differently the two UV axes are scaled.
l0 = np.linalg.norm(tri3d[:, 1] - tri3d[:, 0], axis=1)
l1 = np.linalg.norm(tri3d[:, 2] - tri3d[:, 0], axis=1)
u0 = np.linalg.norm(e0, axis=1)
u1 = np.linalg.norm(e1, axis=1)
with np.errstate(divide="ignore", invalid="ignore"):
s0 = np.where(l0 > 1e-9, u0 / l0, np.nan)
s1 = np.where(l1 > 1e-9, u1 / l1, np.nan)
anisotropy = np.where(np.isfinite(s0) & np.isfinite(s1) & (s1 > 1e-9),
np.maximum(s0 / s1, s1 / s0), np.nan)
d = density[valid]
an = anisotropy[np.isfinite(anisotropy)]
return {
"faces": int(len(faces)),
"faces_measured": int(valid.sum()),
"texels_per_m": {
"p05": round(float(np.percentile(d, 5)), 2),
"p50": round(float(np.percentile(d, 50)), 2),
"p95": round(float(np.percentile(d, 95)), 2),
"ratio_p95_p05": round(float(np.percentile(d, 95)
/ max(np.percentile(d, 5), 1e-9)), 2),
},
"anisotropy": {
"p50": round(float(np.percentile(an, 50)), 3),
"p95": round(float(np.percentile(an, 95)), 3),
"max": round(float(an.max()), 3),
"faces_over_2x": int((an > 2.0).sum()),
},
"uniform": float(np.percentile(d, 95) / max(np.percentile(d, 5), 1e-9)) < 3.0,
"verdict": "usable" if float(np.percentile(an, 95)) < 1.6
and float(np.percentile(d, 95)
/ max(np.percentile(d, 5), 1e-9)) < 3.0
else "distorted — lower max_cost to split more charts",
}
Two distortion measures matter and they are different. Density variation — the ratio between the 95th and 5th percentile texels per metre — says whether some parts of the surface get much more texture than others; a ratio above about 3 means a facade is sharp in one place and soft in another.
Anisotropy says whether individual texels are stretched: a value of 2 means a texel is twice as long in one direction as the other, which shows as directional blurring. Above 1.6 at the p95 is visible on architectural detail.
Both are fixed the same way — lower max_cost so xatlas splits charts rather than stretching them — at the cost of more seams and worse packing. The audit is what makes that trade a measurement rather than a guess.
6. Sweep the options and pick from the audit
def option_sweep(mesh, resolution=4096, max_costs=(1.05, 1.5, 2.0, 4.0),
normal_seam_weights=(1.0, 4.0)):
rows = []
for cost in max_costs:
for nsw in normal_seam_weights:
result = parameterise(mesh, resolution=resolution, max_cost=cost,
normal_seam_weight=nsw)
uvmesh, applied = apply_parameterisation(mesh, result)
audit = distortion_audit(uvmesh, resolution)
rows.append({
"max_cost": cost,
"normal_seam_weight": nsw,
"charts": result["stats"]["charts"],
"utilisation": result["stats"]["utilisation"],
"vertex_growth": result["stats"]["vertex_growth"],
"density_ratio": audit["texels_per_m"]["ratio_p95_p05"],
"anisotropy_p95": audit["anisotropy"]["p95"],
"usable": audit["verdict"] == "usable",
})
usable = [r for r in rows if r["usable"]]
best = max(usable, key=lambda r: r["utilisation"]) if usable else None
return {"sweep": rows, "recommended": best,
"reason": "highest atlas utilisation among the settings whose distortion "
"is within tolerance"}
def export_for_texturing(uvmesh, out_path, atlas_dim):
uvmesh.export(out_path)
return {"path": str(out_path),
"atlas_dim": atlas_dim,
"next_step": "bake a texture at this resolution; see the texture "
"projection guide",
"bytes": Path(out_path).stat().st_size}
Optimising for utilisation among the settings that pass the distortion check is the right objective. Utilisation is wasted memory made measurable: an atlas at 0.45 utilisation is throwing away half its texels, so a 4,096 texture is delivering the coverage of a 2,896 one.
brute_force=True on the pack options improves utilisation by a few percent at a large cost in time, and is worth enabling for a hero asset and not for 40,000 buildings.
Expected Output & Verification
{
"before": {"vertices": 8412, "faces": 16804},
"after": {"vertices": 8390, "faces": 16792},
"components": 3, "problems": [],
"surface_area_m2": 1204.82, "is_watertight": false
}
{'surface_area_m2': 1204.8, 'requested_texels_per_m': 64, 'ideal_dim': 4096,
'chosen_dim': 4096, 'clamped': false, 'achieved_texels_per_m': 102.2,
'texture_mb_rgba': 67.11, 'gpu_mb_with_mips': 89.46}
{
"charts": 68, "atlas_count": 1, "width": 4096, "height": 4096,
"utilisation": 0.7114, "texels_per_unit": 102.184,
"vertices_before": 8390, "vertices_after": 14218, "vertex_growth": 1.695
}
{
"vertices": 14218, "faces": 16792, "faces_preserved": true,
"area_before_m2": 1204.82, "area_after_m2": 1204.82, "area_change_pct": 0.0,
"uv_range": [0.00049, 0.99951], "uvs_in_unit_square": true
}
{
"faces": 16792, "faces_measured": 16792,
"texels_per_m": {"p05": 74.12, "p50": 101.84, "p95": 118.42, "ratio_p95_p05": 1.6},
"anisotropy": {"p50": 1.041, "p95": 1.182, "max": 2.84, "faces_over_2x": 12},
"uniform": true, "verdict": "usable"
}
Zero area change with faces preserved is the structural confirmation that the parameterisation did what it should: the geometry is untouched and only the vertex array grew, by 1.7× across 68 chart seams.
A 1.6 density ratio and a 1.18 p95 anisotropy is a good parameterisation. The 12 faces with anisotropy above 2 are the corners of charts and will not be noticed.
Verify the seams fall where they should, because an invisible seam and a visible one differ only in placement:
def seam_placement_audit(original_mesh, uvmesh, result, crease_angle_deg=40.0):
"""Seams on sharp creases are invisible; seams across flat surfaces are not."""
vmapping = result["vmapping"]
# A vertex split by the parameterisation appears more than once in vmapping.
counts = np.bincount(vmapping, minlength=len(original_mesh.vertices))
split_vertices = np.flatnonzero(counts > 1)
face_adjacency = original_mesh.face_adjacency
angles = np.degrees(original_mesh.face_adjacency_angles)
sharp_edges = original_mesh.face_adjacency_edges[angles >= crease_angle_deg]
flat_edges = original_mesh.face_adjacency_edges[angles < crease_angle_deg]
sharp_verts = set(np.unique(sharp_edges).tolist())
flat_verts = set(np.unique(flat_edges).tolist()) - sharp_verts
on_crease = sum(1 for v in split_vertices if int(v) in sharp_verts)
on_flat = sum(1 for v in split_vertices if int(v) in flat_verts)
return {
"split_vertices": int(len(split_vertices)),
"on_sharp_crease": on_crease,
"on_flat_surface": on_flat,
"crease_fraction": round(on_crease / max(len(split_vertices), 1), 3),
"good_placement": on_crease / max(len(split_vertices), 1) > 0.7,
"advice": "raise normal_seam_weight to pull seams onto creases"
if on_crease / max(len(split_vertices), 1) <= 0.7 else "seams are "
"mostly on creases, which is where they are least visible",
}
print(json.dumps(seam_placement_audit(mesh, uvmesh, result), indent=2))
A crease fraction above 0.7 means most seams are on edges where a texture discontinuity is expected anyway — a building’s corner, a roof ridge. Below that, seams are crossing flat walls, where a one-texel colour mismatch reads as a line, and raising normal_seam_weight is the fix.
Then verify the atlas packs without bleed at the resolution you intend to use:
def padding_check(uvmesh, atlas_dim, padding_texels=4):
"""Charts closer than the padding will bleed into each other under bilinear filtering."""
uv = np.asarray(uvmesh.visual.uv) * atlas_dim
faces = np.asarray(uvmesh.faces)
# Rasterise each triangle's bounding box into an occupancy grid at atlas resolution.
grid = np.zeros((atlas_dim, atlas_dim), dtype=np.int32)
tri = uv[faces]
lo = np.floor(tri.min(axis=1)).astype(int)
hi = np.ceil(tri.max(axis=1)).astype(int)
for (x0, y0), (x1, y1) in zip(lo, hi):
x0 = max(x0, 0); y0 = max(y0, 0)
x1 = min(x1, atlas_dim); y1 = min(y1, atlas_dim)
if x1 > x0 and y1 > y0:
grid[y0:y1, x0:x1] += 1
occupied = grid > 0
from scipy import ndimage
dilated = ndimage.binary_dilation(occupied, iterations=padding_texels)
overlap_after_dilation = int((grid > 1).sum())
return {
"atlas_dim": atlas_dim,
"occupied_texels": int(occupied.sum()),
"utilisation_measured": round(float(occupied.mean()), 4),
"texels_claimed_by_multiple_triangles": overlap_after_dilation,
"padding_texels": padding_texels,
"dilated_utilisation": round(float(dilated.mean()), 4),
"bleed_risk": float(dilated.mean()) > 0.98,
"note": "a dilated utilisation near 1.0 means charts are packed tighter than "
"the filter footprint and will bleed",
}
Measuring utilisation from the rasterised triangles rather than trusting xatlas’s figure is a useful cross-check, and the dilation test is what predicts bleed: if growing every chart by the filter’s footprint fills the atlas, neighbouring charts are within sampling distance of each other and colours will leak across seams at coarse mip levels.
Performance Notes
- xatlas runs at roughly 20,000–60,000 faces per second for chart generation plus packing. A 17,000-face building is under a second; a 2-million-face mesh is a minute.
brute_force=Trueon packing is 5–20× slower for a few percent of utilisation. Reserve it for hero assets.max_iterationsabove 1 refines charts at a proportional cost and rarely changes the audit numbers by much.- Parameterise per building, not per city tile. A whole tile in one atlas gives poor per-building texel density and makes a single building’s texture un-reusable.
- The distortion audit is vectorised NumPy and costs milliseconds; run it on every mesh.
- Cache the parameterisation. It depends only on the geometry, so a re-texture with new imagery reuses the same UVs.
Common Errors
Hundreds of charts on a simple mesh. Degenerate faces or unmerged duplicate vertices. Clean first.
UVs outside 0–1. More than one atlas was generated. Either raise the resolution or handle atlas_count > 1 by writing several textures.
Visible lines across flat walls. Seams placed badly. Raise normal_seam_weight.
Texture looks soft in places and sharp in others. Density variation. Lower max_cost so charts split instead of stretching.
Directional blurring on facades. Anisotropy. Same fix.
Colour bleeding across seams at a distance. Padding too small for the mip chain. Raise padding, or generate mips per chart.
atlas.utilization is empty. atlas.generate() was not called, or it produced no atlas because the mesh had no valid faces.
Vertex count tripled. Far too many charts; check the sweep.
Frequently Asked Questions
One atlas per building or one per tile?
Per building, for city data. A per-tile atlas makes the texel density depend on how much surface happens to be in the tile, and it prevents reusing a building’s texture when the tiling changes.
What padding should I use?
Four texels for a non-mipmapped atlas, and enough for the deepest mip level you intend to generate — which for a 4,096 atlas with a full chain is impractical, so per-chart mip generation or a dilation pass on the baked texture is the usual answer.
Can I keep an existing partial parameterisation?
xatlas will re-parameterise everything it is given. To keep existing UVs on part of a mesh, split the mesh, parameterise only the part that needs it, and merge the atlases afterwards — which is more work than re-parameterising the whole thing.
Related Guides
- Projecting Textures from Oriented Images — baking a texture into the atlas this produces
- Merging Meshes to Cut Draw Calls — remapping these UVs when meshes are combined
- Encoding Property Textures for Per-Texel Data — the other consumer of a UV atlas
Back to Texture Mapping Workflows.