Pure-Rust automatic UV unwrapping: generates unique, non-overlapping texture coordinates for lightmaps, texture baking and texture painting. Inspired by xatlas, with no C++ toolchain required, so it builds for wasm and cross-compiles trivially.
use uvunwrap::{generate, Density, MeshInput, Options};
let mesh = MeshInput::new(&positions, &indices);
let atlas = generate(&[mesh], &Options::default())?;
for v in &atlas.meshes[0].vertices {
// v.xref: original vertex index, v.uv: [0, 1] coordinate, v.page: atlas page
}
// Engines usually want a fixed texel density (5 texels per unit here), on a
// page that grows to fit or on as many fixed-size pages as needed:
let options = Options {
density: Density::Fixed { texels_per_unit: 5.0, page_size: None },
..Options::default()
};Density::Auto { page_size } (the default, 1024) picks the highest density
that fits one page. generate_with_progress takes a callback that receives
the current stage and progress counts and can return false to cancel, for
running large unwraps as background tasks.
Output meshes keep the input triangles in order; vertices on chart seams are
duplicated, and xref maps each one back to its input vertex.
Pass meshes as your engine stores them, with vertices split for normals and UVs: identical positions are welded, and the split edges (hard edges, UV seams) become chart boundaries. Optional per-mesh inputs:
face_materials: charts never span two materials.ignored_faces: faces that get no UV space.seams: edges that must become chart boundaries (three flags per triangle, like Blender's "Mark Seam"), without splitting vertices.polygons: for meshes triangulated from quads and n-gons, the polygon each triangle came from. A polygon's triangles always share a chart, so every polygon corner gets exactly one UV, as UV maps that store one UV per corner (e.g. Blender's) require.scale: mesh-to-world scale, so differently scaled instances share one texel density.instance_scales: one scale per instance of a mesh drawn several times. The mesh is unwrapped once (its UVs cover[0, 1]) and each instance gets its own rectangle in the atlas (OutputMesh::instances), the way engines lightmap shared meshes (e.g. Bevy'sLightmap::uv_rect). With one instance this also keeps a mesh on a single page.existing_uvs: existing UVs to repack instead of unwrapping: each UV island becomes one chart, rescaled to its surface area for uniform texel density. Mirrored islands are flipped; unusable ones are unwrapped normally.
Try it on an OBJ file (writes an OBJ with vt coordinates plus an SVG per page):
cargo run --release --example unwrap -- model.obj out/modelThe same unwrapper is on PyPI, with prebuilt wheels for Linux, macOS and Windows (Python 3.11+, no compiler needed):
pip install uvunwrapIt takes numpy arrays or any buffer, runs in a background job with progress
and cancellation, and returns one UV per triangle corner for per-corner UV
maps such as Blender's. See python/.
- Topology: welds identical positions, drops triangles too thin for f32
UVs (and ignored ones), and connects faces only across manifold,
consistently oriented edges within one material that the input doesn't
split. Split edges are ignored when most edges are split (flat shading);
edges marked in
seamsare always cut. - Segmentation: grows charts from the largest planar regions, xatlas
style: candidates are ranked by normal deviation (on crease-preserving
smoothed normals, limited by
max_chart_angle), with bonuses for filling boundary notches and penalties for crossing creases. Optionalmax_chart_areaandmax_boundary_lengthstop charts growing past a size. Small charts mostly enclosed by a neighbor are merged, and single-triangle teeth are smoothed off the boundaries. - Parameterization: planar projection for flat charts, otherwise Least
Squares Conformal Maps solved by sparse Cholesky, then as-rigid-as-possible
iterations that even out area distortion across the chart (texel density
stays uniform on curved charts; kept only when valid, and also tried on
LSCM results that fail). Results with flipped triangles, overlaps or area
distortion above
max_area_distortionare split and retried, as are charts that stay noticeably distorted, so output is always valid. Large, strongly curved charts are split without attempting a solve. - Packing: charts are rotated to their minimum-area bounding rectangle,
rasterized (conservatively, dilated by the padding) and packed by shape, so
they nest into each other's concavities and holes, with 90° rotations.
Texel density is automatic (fit one page) or fixed (one growing page, or
multiple fixed-size pages).
Packing::Fastinstead folds charts into rows and pushes each row up against the previous ones by the charts' outlines (after TABI, Gu, Vining & Sheffer 2026), then fits smaller charts into the gaps above each row and at the bottom of the page: several times faster (tens of times with thousands of charts), but it can't use holes inside charts, so atlases are less dense.max_chart_sizescales oversized charts down;block_aligngives every chart its own 4×4 texel blocks for block-compressed lightmaps.
| Feature | Default | Effect |
|---|---|---|
direct-solver |
yes | Sparse Cholesky via pure-Rust faer; about 10× faster on large charts. Without it, an iterative CGLS solver is used. |
parallel |
yes | Parameterizes charts in parallel with rayon. Disable for wasm32 without threads. |
With no default features the crate has zero dependencies.
The same input, options and features give bit-identical output on any thread
count. No platform math library calls are used, so without direct-solver
output depends only on IEEE-754 arithmetic and matches across platforms: CI
compares output hashes on Linux x86-64 and ARM64, macOS ARM64, Windows
x86-64, wasm32-wasip1 (wasmtime) and wasm32-unknown-unknown (Node). With
direct-solver, faer selects SIMD kernels at runtime, so output can differ
between CPU architectures. If you cache unwraps or bake on one machine and
ship to another, build without it. The unwrap example prints an output hash
for comparing builds.
1024×1024 page, 2 texels of padding, release build, Apple M2 Pro (10 cores),
best of 5 runs of the unwrap example. Utilization is true surface area over
atlas area.
| Model | Triangles | Charts | Single core | Multi-core | Utilization |
|---|---|---|---|---|---|
| Spot | 5.9K | 47 | 53 ms | 26 ms | 73% |
| Stanford bunny | 69K | 63 | 0.56 s | 0.20 s | 62% |
| Armadillo | 100K | 248 | 0.88 s | 0.29 s | 60% |
| Nefertiti | 100K | 459 | 1.81 s | 0.90 s | 64% |
| Igea | 269K | 84 | 2.40 s | 0.87 s | 63% |
cargo bench runs criterion benchmarks on procedural meshes, plus any of
spot, beetle, bunny, armadillo, nefertiti and igea found in
models/; scripts/fetch-models.sh downloads them from
common-3d-test-models
at a pinned commit.
MIT
