需按索引化、顶点缓存优化、过度绘制优化、顶点取数优化、量化、索引过滤的顺序调用,顺序影响效果;过度绘制优化对 PowerVR、Apple 等移动端瓦片延迟渲染 GPU 可能无收益,需实测;顶点重映射基于二进制等价比较,浮点漂移会产生多余顶点,需先量化法线切线或改用 meshopt_generateVertexRemapCustom。
meshoptimizer
zeuxMIT活跃维护轻量级 C++ 网格优化库,通过顶点缓存重排、索引压缩和网格简化,让 3D 模型体积更小、GPU 渲染更快。
Mesh optimization library that makes meshes smaller and faster to render
git clone -b v1.3 https://github.com/zeux/meshoptimizer.gitREADME 未说明;库为纯 CPU 端算法,不依赖 GPU 或显存。
C/C++ 网格优化库,通过重排索引与量化顶点让三角网格更小、渲染更快
轻量级 C++ 网格优化库,通过顶点缓存重排、索引压缩和网格简化,让 3D 模型体积更小、GPU 渲染更快。
核心能力
- 生成重映射表合并冗余顶点并重建索引缓冲
- 用自适应算法重排三角形提升顶点缓存命中率
- 按阈值重排三角形以减少各方向过度绘制
- 按使用顺序重排顶点缓冲改善顶点取数局部性
- 提供顶点属性量化函数压缩存储与带宽
容易踩的坑
- v1.3最新正式版2026-09-25 · 2026-09-25 15:36 UTC
This release introduces a major new feature, voxel remeshing, in addition to normal generation, a number of simplification enhancements and several smaller improvements.
The first meshoptimizer commit was made on September 8, 2016, a little over ten years ago. Consider this a slightly belated 10-year anniversary release. A decade ago, the library started from a small collection of optimization algorithms that I happened to need at the time, and I definitely did not think it would grow into what it is today. It's now used broadly across the industry, in game engines, content pipelines and tools, and its compression formats are part of glTF. Along the way, many old algorithms had to be studied and improved upon and some new ones - invented. I'm very happy with where the library is today, and looking forward to the next ten years. Thank you to everyone who has used meshoptimizer, reported issues, contributed patches, or supported its development!
A new voxel remesher voxelizes the input mesh at a given resolution, with each voxel storing an approximation of the mesh surface; it then generates a new mesh from the grid, disregarding the original mesh topology and exact vertex positions. The result of
meshopt_remeshis a closed mesh with a uniform tessellation that can be used directly or as a base for further simplification. Features closer than a voxel may get merged, gaps smaller than a voxel may get closed, and the interior of the mesh will be filled by default. For best appearance,meshopt_RemeshSolveoption optimizes vertex placement to align with the features of the original mesh. Unlike traditional distance field based approaches, this remesher preserves thin sheets at any voxel resolution, although wires (thin one-dimensional geometry) may get erased. This remesher is still experimental and may be improved in future releases; in particular, thin features may have self-intersections and require backface culling for correct rendering.While this remesher can unlock a variety of use-cases, one possible application is generating a low-poly textured proxy that merges all source meshes, materials and textures into one; an online remesher demo that implements a remesh→simplify→unwrap→retexture pipeline (among other options) is provided as an example.
Since remeshing discards all input attributes, the output needs newly generated normals for lighting. A function to create them,
meshopt_generateNormals, is now available; it implements automatic smoothing group detection using a crease angle cutoff and supports optional post-processing that smooths the normals across the neighboring triangles. While particularly useful with remeshed meshes, it's a general purpose algorithm that can be used for any mesh, similarly tomeshopt_generateTangents.Several simplification enhancements improve the behavior of existing functions automatically or add new options to control simplification further. Permissive mode (
meshopt_SimplifyPermissive) has been improved, with a few bug fixes and a significant improvement of simplification around geometric borders; this was the final improvement that was needed to make permissive mode stable, which it now is - no further breaking changes are expected. To improve simplification behavior on double-sided geometry (which can occur in remeshed meshes),meshopt_SimplifyPreserveFoldsoption can be enabled to reduce erosion of sharp creases or edges of double-sided sheets. To improve attribute-aware simplification, both in terms of collapse ordering and the error returned by the simplifier for input geometry with significant attribute variance,meshopt_SimplifyErrorClampedoption can be - v1.22026-06-30 · 2026-06-30 14:42 UTC
查看更新说明
This release introduces a major new feature, tangent frame generation, significantly improves vertex decoding performance on Intel/AMD CPUs and also adds several smaller features and improvements. Highlights:
A new tangent frame generator can generate tangents from either indexed or unindexed triangle mesh with positions/normals/texture coordinates. The generator implements MikkTSpace algorithm, but is significantly faster to run (~6-10x faster than
mikktspace.cdepending on the input structure). Note that the generation result is a per-corner tangent and applying the tangents to the indexed mesh may require splitting source vertices; consult documentation for details. By default, the algorithm uses a modified weighting scheme that significantly improves tangent quality around beveled regions in the mesh, withmeshopt_TangentCompatibleoption provided for cases where exact compatibility withmikktspace.cis important (e.g. normal map baking workflows).Vertex decoding (
meshopt_decodeVertexBuffer) implementation for Intel/AMD CPUs has been significantly revised. The new mostly branchless SSSE3 implementation is usually ~20-45% faster than it was in previous releases, with the gains depending on CPU and the data composition; it's typical to see gains in the upper half of this range for engine-packed data. This implementation is automatically selected on compatible CPUs (SSSE3+POPCNT), with no change in data encoding, so to get this performance boost a library update is sufficient. If the code is compiled with AVX-512/AVX10 support (which is currently only selected at compile time when opted into), the decoding is an additional ~10% faster.Additionally, new index filtering functions are provided to remove degenerate/duplicate triangles based on positional identity, which can be especially helpful for raytracing performance, a function for computing optimal shared exponent for cluster positions can be used to prepare geometry for upcoming DXR2 Compressed1 format, and
clusterlod.hnow implements DAG BVH construction viaclodBuildHierarchy.The majority of the work on the core library in this release has been sponsored by Valve; thank you!
Library improvements
- New experimental function for generating tangents based on MikkTSpace algorithm,
meshopt_generateTangents - New experimental functions for filtering out degenerate and duplicate triangles based on positional identity,
meshopt_filterIndexBuffer/meshopt_filterIndexBufferMulti - New experimental function for computing optimal shared exponent for cluster positions which can be used with upcoming DXR2 Compressed1 format
meshopt_encode/decodeMeshlet*functions,meshopt_extractMeshletIndicesandmeshopt_optimizeMeshletLevelfunctions, as well asmeshopt_SimplifyVertex_Priorityandmeshopt_SimplifyRegularizeLightflags, are now stable.- Significantly improve
meshopt_decodeVertexBufferperformance on existing data for Intel/AMD CPUs (20-45% faster depending on CPU and data characteristics) - Significantly improve performance of
meshopt_partitionClusterson larger partition sizes (~2x fortarget_size64, ~200x for 1024) - Improve post-compression ratio for meshlets encoded using meshlet codec after
meshopt_optimizeMeshletLevelwith level 1+ (~0.5% gains) - Fix reduced encoding precision of small numb
- New experimental function for generating tangents based on MikkTSpace algorithm,
- v1.12026-04-02 · 2026-04-02 16:08 UTC
查看更新说明
This release introduces two new major features, meshlet compression and opacity micromaps, as well as several small features and improvements. Highlights:
A new meshlet codec can compress meshlet topology (micro-index and vertex reference data), exploiting inherent locality and designed for extremely fast CPU decompression. Each meshlet is encoded independently, allowing flexibility in structuring the runtime storage and parallelism. Meshlets can be decoded into common runtime formats (3 or 4 bytes per triangle, 2 or 4 bytes per vertex reference) for direct GPU consumption. For further size savings, the output of the encoder can be compressed using general purpose compressors such as Zstandard. Decoder is heavily optimized and can directly target write-combined memory; you can expect it to run at 7-10 GB/s on modern desktop CPUs. For applications that do streaming processing on the GPU, an example compute shader decoder is provided and can reach 150+ GB/s on RTX 5070.
Opacity micromap rasterization is now supported; a set of functions can generate hardware ready opacity micromap data from mesh UVs and a texture with an alpha channel. The resulting micromap data can be used directly in Vulkan via VK_EXT_opacity_micromap or in DirectX via DXR1.2. Each triangle is subdivided according to its UV footprint into a 4^N microtriangle grid; micro-triangles can use a 2-state (1 bit) or 4-state (2 bits) representation. 4-state representation is useful to accelerate shader invocations to confirm hit opacity via a texture lookup, whereas 2-state representation (or a forced 2-state fallback for a 4-state representation) can be used to completely eliminate shader invocations and reach maximum tracing performance. To minimize the memory overhead, the maps are reused between triangles using a per-triangle OMM index buffer with generated special indices for triangles with consistent state.
The majority of the work on the core library in this release has been sponsored by Valve; thank you!
Library improvements
- New experimental functions for meshlet encoding,
meshopt_encodeMeshlet/meshopt_encodeMeshletBound/meshopt_decodeMeshlet/meshopt_decodeMeshletRaw, can be used to compress meshlet topology - New experimental functions for opacity micromaps,
meshopt_opacityMapMeasure/meshopt_opacityMapRasterize/meshopt_opacityMapCompact/meshopt_opacityMapEntrySize, can be used to rasterize opacity micromaps from UV coordinates and a texture alpha channel - New experimental function,
meshopt_optimizeMeshletLevel, optimizes meshlet order further to achieve higher compression ratios (with level 3 recommended for good results) - New experimental function,
meshopt_extractMeshletIndices, extracts meshlet micro-index buffer and vertex references from a cluster index buffer - New experimental option for
meshopt_simplify*functions,meshopt_SimplifyRegularizeLight, enables regularization with a smaller factor to reduce impact on geometric quality - New experimental flag for
vertex_lockarray inmeshopt_simplifyWith*functions,meshopt_SimplifyVertex_Priority, allows tagging individual vertices so that they are more likely to be preserved - Improve performance of bounds computation via
meshopt_computeMeshletBound/meshopt_computeClusterBoundsby 50% on typical inputs - Remove
index_count % 3assertions from `meshopt_gener
- New experimental functions for meshlet encoding,
- v1.02025-12-08 · 2025-12-08 16:29 UTC
查看更新说明
This release focuses on improvements to clusterization, simplification and partitioning algorithms, as well as stabilization - with the exception of permissive simplification mode, all other library functionality introduced in prior releases is now considered stable.
Highlights:
Clusterization algorithms (
meshopt_buildMeshlets/meshopt_buildMeshletsFlex,meshopt_buildMeshletsSpatial) are now faster and, in the case of spatial clusterization, produce higher-quality clusters for vertex-bound meshlets. Additionally, meshlet builders no longer align local index data to 4 bytes, which simplifies memory management and removes alignment restrictions on triangle limits. When cluster partitioning is required,meshopt_partitionClustersnow produces better partitioning for disconnected clusters which improves quality of DAGs for hierarchical clusterization.Speaking of hierarchical clusterization, a new single-header library clusterlod.h is now developed and maintained alongside meshoptimizer. This library implements continuous level of detail by generating a hierarchy of clusters that are progressively grouped and simplified, similarly to Nanite. It leverages meshoptimizer algorithms for internal processing, and can either be used as-is or as a starting point for implementing a custom solution - the code is structured to be easy to understand and modify. Some relevant improvements are described in Billions of triangles in minutes blog post.
New simplification features added in the last release have seen further quality improvements as well; both
meshopt_simplifyWithUpdateand themeshopt_SimplifyPermissiveoption now generate higher-quality outputs. Permissive mode option specifically is still considered experimental, and future releases may improve or change behavior further.All other experimental library functions and options have been promoted to stable status. This means that future releases will be API compatible (code will continue compiling), ABI compatible (code can link to a shared library build of meshoptimizer which can then be updated independently) and behavior compatible (results may improve in future releases, but these improvements should not require adjustments to the calling code) as far as current 1.0 functionality is concerned.
The vertex encoding functions (
meshopt_encodeVertexBuffer/meshopt_encodeVertexBufferLevel) now encode v1 data by default. This can be overridden for applications that need to produce data in v0 format, especially if producing data forEXT_meshopt_compressionextension which only supports v0. This version only switches the encoding default; support for encoding and decoding v0/v1 will be provided in perpetuity.gltfpack now supports the new KHR_meshopt_compression extension via
-czoption (or-ce khrwhich allows specifying compression level independently;-czis equivalent to-ce khr -cc), which provides better compression compared to the existingEXT_meshopt_compressionextension. Note that loader support for this extension is not yet widely available, and by default gltfpack continues to useEXT_meshopt_compressionwhen compression is requested.Finally, JavaScript bindings have been updated to use ES modules instead of CommonJS modules, which may require adjustments to import paths. For the MeshoptDecoder module, a CommonJS variant
meshopt_decoder.cjsis still provided for compatibility (but note a different file extension, required for correct handling by some bundlers).The majority of the work on the core library in this release has been sponsored by Valve; thank you!
Upgrade notes
meshopt_encodeVertexBuffernow uses format version 1 by default, which requires meshoptimizer v0.23 or later to decode. Applications that encode data for `E
- v0.252025-08-20 · 2025-08-20 15:43 UTC
查看更新说明
This release contains many improvements to the meshoptimizer library, with a particular focus on simplification algorithms, as well as several new gltfpack features. Highlights:
New simplification function
meshopt_simplifyWithUpdateoptimizes vertex positions and attributes for higher-quality LODs, and a new permissive simplification mode enabled viameshopt_SimplifyPermissiveflag allows simplification of faceted meshes with selective preservation of attribute discontinuities. Additionally,meshopt_SimplifyRegularizeflag improves tessellation quality and appearance of deformable meshes, sloppy simplifier (meshopt_simplifySloppy) implements vertex locking support, and multiple improvements to the main simplification algorithms improve quality at the same triangle count by default.gltfpack now supports encoding WebP images via
-twoption, which enables further reduction in compressed asset sizes at some memory cost at runtime. Additionally, gltfpack exposes permissive simplification via-spoption, which can simplify meshes further while preserving appearance much better compared to aggressive simplification.The majority of the work on the core library in this release has been sponsored by Valve; thank you!
Note: gltfpack development is done in spare time; potential corporate sponsors should feel free to reach out via e-mail.
Library improvements
- New experimental function,
meshopt_simplifyWithUpdate, enhances attribute-aware simplification by computing optimal positions/attributes for the simplified mesh, allowing to increase quality of lower levels of detail at additional vertex memory cost - New experimental simplification flag,
meshopt_SimplifyPermissive, allows simplification across attribute discontinuities, with optional support for protecting individual vertices with bit flagmeshopt_SimplifyVertex_Protecton lock flags specified viavertex_lock - New experimental simplification flag,
meshopt_SimplifyRegularize, results in more regular tessellation at some cost to shape preservation, which is particularly valuable for deformable meshes - New experimental function,
meshopt_generatePositionRemap, creates a table mapping vertices to the canonical vertex with the same position (which can be used to prepare data for various algorithms like simplification and partitioning) - New experimental color filter, exposed via
meshopt_encodeFilterColor/meshopt_decodeFilterColor, can be used to further improve compression ratio on quantized RGBA streams meshopt_simplifySloppynow has an extra argument,vertex_lock, allowing to lock individual vertices to preserve mesh boundariesmeshopt_analyzeCoverage,meshopt_computeSphereBounds,meshopt_encodeVertexBufferLevel,meshopt_generateVertexRemapCustom,meshopt_partitionClustersandmeshopt_spatialClusterPointsfunctions are now stablemeshopt_simplifyPrunefunction andmeshopt_SimplifyPruneflag are now stable- Simplification now iteratively removes zero-area triangles that can arise in complex topological cases, reducing the triangle count at no quality impact
- Improve simplification restrictions around certain seam edges which can slightly reduce the overall triangle count
- Improve simplification quality of seam/border edge loops
- Improve simplification of flat (planar) regions resulting in higher quality tessellation
- Improve tracking of component error during simplification which could previously result in insufficient simplification when
meshopt_SimplifyPrunewas enabled - Improve performance of
meshopt_optimizeOverdrawby ~15% - Rework meshopt_Allocator
- New experimental function,
用户点评
登录用户的真实使用记录,可按版本和硬件筛选还没有人点评。第一个说出真实体验的人最有价值。
🐇 meshoptimizer

Purpose
When a GPU renders triangle meshes, various stages of the GPU pipeline have to process vertex and index data. The efficiency of these stages depends on the data you feed to them; this library provides algorithms to help optimize meshes for these stages, as well as algorithms to reduce the mesh complexity and storage overhead.
The library provides a C and C++ interface for all algorithms; you can use it from C/C++ or from other languages via FFI (such as P/Invoke). If you want to use this library from Rust, you should use meshopt crate. JavaScript interface for some algorithms is available through meshoptimizer.js.
Two companion projects are developed and distributed alongside the library: gltfpack, a command-line tool that automatically optimizes glTF files, and clusterlod.h, a single-header C/C++ library for continuous level of detail using clustered simplification.
Installing
meshoptimizer is hosted on GitHub; you can download the latest release using git:
git clone -b v1.3 https://github.com/zeux/meshoptimizer.git
Alternatively you can download the .zip archive from GitHub.
The library is also available as a Linux package in several distributions (ArchLinux, Debian, FreeBSD, Nix, Ubuntu), as well as a Vcpkg port (see installation instructions) and a Conan package.
gltfpack is available as a pre-built binary on Releases page or via npm package. Native binaries are recommended since they are more efficient and support texture compression.
Building
meshoptimizer is distributed as a C/C++ header (src/meshoptimizer.h) and a set of C++ source files (src/*.cpp). To include it in your project, you can use one of two options:
- Use CMake to build the library (either as a standalone project or as part of your project)
- Add source files to your project's build system
The source files are organized in such a way that you don't need to change your build-system settings, and you only need to add the source files for the algorithms you use. They should build without warnings or special compilation options on all major compilers. If you prefer amalgamated builds, you can also concatenate the source files into a single .cpp file and build that instead.
To use meshoptimizer functions, simply #include the header meshoptimizer.h; the library source is C++, but the header is C-compatible.
Core pipeline
When optimizing a mesh, to maximize rendering efficiency you should typically feed it through a set of optimizations (the order is important!):
- Indexing
- Vertex cache optimization
- (optional) Overdraw optimization
- Vertex fetch optimization
- Vertex quantization
- Index filtering
- (optional) Shadow indexing
Indexing
Most algorithms in this library assume that a mesh has a vertex buffer and an index buffer. For algorithms to work well and also for GPU to render your mesh efficiently, the vertex buffer has to have no redundant vertices; you can generate an index buffer from an unindexed vertex buffer or reindex an existing (potentially redundant) index buffer as follows:
Note: meshoptimizer generally works with 32-bit (
unsigned int) indices, however when using C++ APIs you can use any integer type for index data by using the provided template overloads. By convention, remap tables always useunsigned int.
First, generate a remap table from your existing vertex (and, optionally, index) data:
size_t index_count = face_count * 3;
size_t unindexed_vertex_count = face_count * 3;
std::vector<unsigned int> remap(unindexed_vertex_count); // temporary remap table
size_t vertex_count = meshopt_generateVertexRemap(&remap[0], NULL, index_count,
&unindexed_vertices[0], unindexed_vertex_count, sizeof(Vertex));
Note that in this case we only have an unindexed vertex buffer; when input mesh has an index buffer, it will need to be passed to meshopt_generateVertexRemap instead of NULL, along with the correct source vertex count. In either case, the remap table is generated based on binary equivalence of the input vertices, so the resulting mesh will render the same way. Binary equivalence considers all input bytes, including padding which should be zero-initialized if the vertex structure has gaps.
After generating the remap table, you can allocate space for the target vertex buffer (vertex_count elements) and index buffer (index_count elements) and generate them:
meshopt_remapIndexBuffer(indices, NULL, index_count, &remap[0]);
meshopt_remapVertexBuffer(vertices, &unindexed_vertices[0], unindexed_vertex_count, sizeof(Vertex), &remap[0]);
You can then further optimize the resulting buffers by calling the other functions on them in-place.
meshopt_generateVertexRemap uses binary equivalence of vertex data, which is generally a reasonable default; however, in some cases some attributes may have floating point drift causing extra vertices to be generated. For such cases, it may be necessary to quantize some attributes (most importantly, normals and tangents) before generating the remap, or use meshopt_generateVertexRemapCustom algorithm that allows comparing individual attributes with tolerance by providing a custom comparison function:
size_t vertex_count = meshopt_generateVertexRemapCustom(&remap[0], NULL, index_count,
&unindexed_vertices[0].px, unindexed_vertex_count, sizeof(Vertex),
[&](unsigned int lhs, unsigned int rhs) -> bool {
const Vertex& lv = unindexed_vertices[lhs];
const Vertex& rv = unindexed_vertices[rhs];
return fabsf(lv.tx - rv.tx) < 1e-3f && fabsf(lv.ty - rv.ty) < 1e-3f;
});
Vertex cache optimization
When the GPU renders the mesh, it runs the vertex shader for each vertex. Historically, GPUs used a small fixed-size post-transform cache (16-32 vertices) with different replacement policies to store the shader output and avoid redundant shader invocations. Modern GPUs still perform vertex reuse, but with substantially different mechanics: vertex invocations are batched into thread groups based on the input indices, and effective reuse depends on factors like vertex shader outputs and rasterizer throughput. To maximize the locality of reused vertex references, you have to reorder your triangles like so:
meshopt_optimizeVertexCache(indices, indices, index_count, vertex_count);
The details of vertex reuse vary between different GPU architectures, so vertex cache optimization uses an adaptive algorithm that produces a triangle sequence with good locality that works well across different GPUs. Alternatively, you can use an algorithm that optimizes specifically for fixed-size FIFO caches: meshopt_optimizeVertexCacheFifo (with a recommended cache size of 16). While it generally produces less performant results on most GPUs, it runs ~2x faster, which may benefit rapid content iteration.
Overdraw optimization
After transforming the vertices, GPU sends the triangles for rasterization which results in generating pixels that are usually first run through the depth test, and pixels that pass it get the pixel shader executed to generate the final color. As pixel shaders get more expensive, it becomes more and more important to reduce overdraw. While in general improving overdraw requires view-dependent operations, this library provides an algorithm to reorder triangles to minimize the overdraw from all directions, which you can run after vertex cache optimization like this:
meshopt_optimizeOverdraw(indices, indices, index_count, &vertices[0].x, vertex_count, sizeof(Vertex), 1.05f);
The overdraw optimizer needs to read vertex positions as a float3 from the vertex; the code snippet above assumes that the vertex stores position as float x, y, z.
When performing the overdraw optimization you have to specify a floating-point threshold parameter. The algorithm tries to maintain a balance between vertex cache efficiency and overdraw; the threshold determines how much the algorithm can compromise the vertex cache hit ratio, with 1.05 meaning that the resulting ratio should be at most 5% worse than before the optimization.
Note that depending on the renderer structure and target hardware, the optimization may or may not be beneficial; for example, mobile GPUs with tiled deferred rendering (PowerVR, Apple) would not benefit from this optimization. For vertex heavy scenes it's recommended to measure the performance impact to ensure that the reduced vertex cache efficiency is outweighed by the reduced overdraw.
Vertex fetch optimization
After the final triangle order has been established, we still can optimize the vertex buffer for memory efficiency. Before running the vertex shader GPU has to fetch the vertex attributes from the vertex buffer; the fetch is usually backed by a memory cache, and as such optimizing the data for the locality of memory access is important. You can do this by running this code:
meshopt_optimizeVertexFetch(vertices, indices, index_count, vertices, vertex_count, sizeof(Vertex));
This will reorder the vertices in the vertex buffer to try to improve the locality of reference, and rewrite the indices in place to match; if the vertex data is stored using multiple streams, you should use meshopt_optimizeVertexFetchRemap instead. This optimization has to be performed on the final index buffer since the optimal vertex order depends on the triangle order.
Note that the algorithm does not try to model cache replacement precisely and instead just orders vertices in the order of use, which generally produces results that are close to optimal.
Vertex quantization
To optimize memory bandwidth when fetching the vertex data even further, and to reduce the amount of memory required to store the mesh, it is often beneficial to quantize the vertex attributes to smaller types. While this optimization can technically run at any part of the pipeline (and sometimes doing quantization as the first step can improve indexing by merging almost identical vertices), it generally is easier to run this after all other optimizations since some of them require access to float3 positions.
Quantization is usually domain specific; it's common to quantize normals using 3 8-bit integers but you can use higher-precision quantization (for example using 10 bits per component in a 10_10_10_2 format), or a different encoding to use just 2 components. For positions and texture coordinate data the two most common storage formats are half precision floats, and 16-bit normalized integers that encode the position relative to the AABB of the mesh or the UV bounding rectangle.
The number of possible combinations here is very large but this library does provide the building blocks, specifically functions to quantize floating point values to normalized integers, as well as half-precision floats. For example, here's how you can quantize a normal using 10-10-10 SNORM encoding:
unsigned int normal =
((meshopt_quantizeSnorm(v.nx, 10) & 1023) << 20) |
((meshopt_quantizeSnorm(v.ny, 10) & 1023) << 10) |
(meshopt_quantizeSnorm(v.nz, 10) & 1023);
and here's how you can quantize a position using half precision floats:
unsigned short px = meshopt_quantizeHalf(v.x);
unsigned short py = meshopt_quantizeHalf(v.y);
unsigned short pz = meshopt_quantizeHalf(v.z);
Since quantized vertex attributes often need to remain in their compact representations for efficient transfer and storage, they are usually dequantized during vertex processing by configuring the GPU vertex input correctly to expect normalized integers or half precision floats, which often needs no or minimal changes to the shader code. When CPU dequantization is required instead, meshopt_dequantizeHalf can be used to convert half precision values back to single precision; for normalized integer formats, the dequantization just requires dividing by 2^N-1 for unorm and 2^(N-1)-1 for snorm variants. For example, manually reversing meshopt_quantizeUnorm(v, 10) can be done by dividing by 1023.
Index filtering
Some meshes may contain triangles that are processed during rendering but do not contribute to the rendered result. If any two vertices of a triangle result in the same position after vertex shader, the triangle is degenerate and will be skipped by the rasterizer. Some triangles may also be duplicates of an earlier triangle with the same post-transform positions and winding, in which case only one of the triangles will be visible depending on depth testing settings (assuming blending is disabled). In either case, such triangles require extra processing and removing them may improve rasterization or ray tracing performance; this library provides an algorithm that removes such triangles from the index buffer:
indices.resize(meshopt_filterIndexBuffer(&indices[0], &indices[0], indices.size(), &vertices[0].x, vertices.size(), sizeof(float) * 3, sizeof(Vertex)));
Note that the example above assumes only positions are relevant for transforming the vertices, but for deformable meshes skinning data may need to be added to the vertex portion used as a key; meshopt_filterIndexBufferMulti can be useful for these cases if the relevant data is not contiguous.
Filtering after quantization is convenient because quantization may increase the number of redundant triangles if triangles had similar but not identical vertex positions before quantization. However, filtering can be done at any point in the pipeline as soon as the index buffer becomes available; you could also run vertex fetch optimization after filtering, since it will naturally filter out any vertices that may become unused after redundant triangles are eliminated, potentially saving extra memory.
Shadow indexing
Many rendering pipelines require meshes to be rendered to depth-only targets, such as shadow maps or during a depth pre-pass, in addition to color/G-buffer targets. While using the same geometry data for both cases is possible, reducing the number of unique vertices for depth-only rendering can be beneficial, especially when the source geometry has many attribute seams due to faceted shading or lightmap texture seams.
To achieve this, this library provides the meshopt_generateShadowIndexBuffer algorithm, which generates a second (shadow) index buffer that can be used with the original vertex data:
std::vector<unsigned int> shadow_indices(index_count);
// note: this assumes Vertex starts with float3 positions and should be adjusted accordingly for quantized positions
meshopt_generateShadowIndexBuffer(&shadow_indices[0], indices, index_count, &vertices[0].x, vertex_count, sizeof(float) * 3, sizeof(Vertex));
Because the vertex data is shared, shadow indexing should be done after other optimizations of the vertex/index data. However, it's possible (and recommended) to optimize the resulting shadow index buffer for vertex cache:
meshopt_optimizeVertexCache(&shadow_indices[0], &shadow_indices[0], index_count, vertex_count);
In some cases, it may be beneficial to split the vertex positions into a separate buffer to maximize efficiency for depth-only rendering. Note that the example above assumes only positions are relevant for shadow rendering, but more complex materials may require adding texture coordinates (for alpha testing) or skinning data to the vertex portion used as a key. meshopt_generateShadowIndexBufferMulti can be useful for these cases if the relevant data is not contiguous.
Note that for meshes with optimal indexing and few attribute seams, the shadow index buffer will be very similar to the original index buffer, so it may not be always worth generating a separate shadow index buffer even if the rendering pipeline relies on depth-only passes.
Clusterization
While traditionally meshes have served as a unit of rendering, new approaches to rendering and raytracing are starting to use a smaller unit of work, such as clusters or meshlets. This allows more freedom in how the geometry is processed, and can lead to better performance and more efficient use of GPU hardware. This section describes algorithms designed to work with meshes as sets of clusters.
Mesh shading
Modern GPUs can deviate from the traditional rasterization model. NVidia GPUs starting from Turing, AMD GPUs starting from RDNA2, Intel GPUs starting from Arc (Xe-HPG), and Apple GPUs starting from M3/A17 Pro provide a new programmable geometry pipeline that, instead of being built around index buffers and vertex shaders, is built around mesh shaders - a new shader type that allows to provide a batch of work to the rasterizer.
Using mesh shaders in context of traditional mesh rendering provides an opportunity to use a variety of optimization techniques, starting from more efficient vertex reuse, using various forms of culling (e.g. cluster frustum or occlusion culling) and in-memory compression to maximize the utilization of GPU hardware. Beyond traditional rendering mesh shaders provide a richer programming model that can synthesize new geometry more efficiently than common alternatives such as geometry shaders. Mesh shading can be accessed via Vulkan, Direct3D 12 or Metal APIs; please refer to Introduction to Turing Mesh Shaders and Mesh Shaders and Amplification Shaders: Reinventing the Geometry Pipeline for additional information.
To use mesh shaders for conventional rendering efficiently, geometry needs to be converted into a series of meshlets; each meshlet represents a small subset of the original mesh and comes with a small set of vertices and a separate micro-index buffer that references vertices in the meshlet. This information can be directly fed to the rasterizer from the mesh shader. This library provides algorithms to create meshlet data for a mesh, and - assuming geometry is static - can compute bounding information that can be used to perform cluster culling, rejecting meshlets that are invisible on screen.
To generate meshlet data, this library provides meshopt_buildMeshlets algorithm, which tries to balance topological efficiency (by maximizing vertex reuse inside meshlets) with culling efficiency (by minimizing meshlet radius and triangle direction divergence) and produces GPU-friendly data. As an alternative (that can be useful for load-time processing), meshopt_buildMeshletsScan can create the meshlet data using a vertex cache-optimized index buffer as a starting point by greedily aggregating consecutive triangles until they go over the meshlet limits. meshopt_buildMeshlets is recommended for offline data processing even if cone culling is not used.
const size_t max_vertices = 64;
const size_t max_triangles = 126; // note: in v0.25 or prior, max_triangles needs to be divisible by 4
const float cone_weight = 0.0f;
size_t max_meshlets = meshopt_buildMeshletsBound(indices.size(), max_vertices, max_triangles);
std::vector<meshopt_Meshlet> meshlets(max_meshlets);
std::vector<unsigned int> meshlet_vertices(indices.size());
std::vector<unsigned char> meshlet_triangles(indices.size()); // note: in v0.25 or prior, use indices.size() + max_meshlets * 3
size_t meshlet_count = meshopt_buildMeshlets(meshlets.data(), meshlet_vertices.data(), meshlet_triangles.data(), indices.data(),
indices.size(), &vertices[0].x, vertices.size(), sizeof(Vertex), max_vertices, max_triangles, cone_weight);
To generate the meshlet data, max_vertices and max_triangles need to be set within limits supported by the hardware; for NVidia the values of 64 and 126 are recommended. cone_weight should be left as 0 if cluster cone culling is not used, and set to a value between 0 and 1 to balance cone culling efficiency with other forms of culling like frustum or occlusion culling (0.25 is a reasonable default).
Note that for earlier AMD GPUs, the best configurations tend to use the same limits for
max_verticesandmax_triangles, such as 64 and 64, or 128 and 128. Additionally, while NVidia recommends 64/126 as a good configuration, consider using a different configuration likemax_vertices 64, max_triangles 96, to provide more realistic limits that are achievable on real-world meshes, and to reduce the overhead on other GPUs.
Each resulting meshlet refers to a portion of meshlet_vertices and meshlet_triangles arrays; the arrays are overallocated for the worst case so it's recommended to trim them before saving them as an asset / uploading them to the GPU:
const meshopt_Meshlet& last = meshlets[meshlet_count - 1];
meshlet_vertices.resize(last.vertex_offset + last.vertex_count);
meshlet_triangles.resize(last.triangle_offset + last.triangle_count * 3);
meshlets.resize(meshlet_count);
Depending on the application, other strategies of storing the data can be useful; for example, meshlet_vertices serves as indices into the original vertex buffer but it might be worthwhile to generate a mini vertex buffer for each meshlet to remove the extra indirection when accessing vertex data, or it might be desirable to compress vertex data as vertices in each meshlet are likely to be very spatially coherent.
Some proprietary platforms have additional restrictions on the index range that can be referenced by a meshlet. Building the library with
MESHOPTIMIZER_CLUSTERIZER_INDEXLIMITdefined will ensure that generated meshlets conform to these restrictions, but the resulting meshlets can not be processed further as additional reordering may break the limits.
For optimal performance, it is recommended to further optimize each meshlet in isolation for better triangle and vertex locality by calling meshopt_optimizeMeshlet on vertex and index data like so:
meshopt_optimizeMeshlet(&meshlet_vertices[m.vertex_offset], &meshlet_triangles[m.triangle_offset], m.triangle_count, m.vertex_count);
Different applications will choose different strategies for rendering meshlets; on a GPU capable of mesh shading, meshlets can be rendered directly; for example, a basic GLSL shader for VK_EXT_mesh_shader extension could look like this (parts omitted for brevity):
layout(binding = 0) readonly buffer Meshlets { Meshlet meshlets[]; };
layout(binding = 1) readonly buffer MeshletVertices { uint meshlet_vertices[]; };
layout(binding = 2) readonly buffer MeshletTriangles { uint8_t meshlet_triangles[]; };
void main() {
Meshlet meshlet = meshlets[gl_WorkGroupID.x];
SetMeshOutputsEXT(meshlet.vertex_count, meshlet.triangle_count);
for (uint i = gl_LocalInvocationIndex; i < meshlet.vertex_count; i += gl_WorkGroupSize.x) {
uint index = meshlet_vertices[meshlet.vertex_offset + i];
gl_MeshVerticesEXT[i].gl_Position = world_view_projection * vec4(vertex_positions[index], 1);
}
for (uint i = gl_LocalInvocationIndex; i < meshlet.triangle_count; i += gl_WorkGroupSize.x) {
uint offset = meshlet.triangle_offset + i * 3;
gl_PrimitiveTriangleIndicesEXT[i] = uvec3(
meshlet_triangles[offset], meshlet_triangles[offset + 1], meshlet_triangles[offset + 2]);
}
}
Note that DirectX 12 mesh shaders cannot index raw buffers using arbitrary byte offsets. Use a typed SRV buffer (
Buffer<uint>) withDXGI_FORMAT_R8_UINTformat, repack each triangle to 32 bits to be able to use aligned 32-bit loads withByteAddressBuffer, or consider utilizing 16-bit scalar types to load a 3-byte triangle using two aligned 16-bit loads like so:Buffer.Load<uint16_t2>(triangle_offset & ~1), then extracting indices with bitwise operations based ontriangle_offset & 1.
After generating the meshlet data, it's possible to generate extra data for each meshlet that can be saved and used at runtime to perform cluster culling, where each meshlet can be discarded if it's guaranteed to be invisible. To generate the data, meshopt_computeMeshletBounds can be used:
meshopt_Bounds bounds = meshopt_computeMeshletBounds(&meshlet_vertices[m.vertex_offset], &meshlet_triangles[m.triangle_offset],
m.triangle_count, &vertices[0].x, vertices.size(), sizeof(Vertex));
The resulting bounds values can be used to perform frustum or occlusion culling using the bounding sphere, or cone culling using the cone axis/angle (which will reject the entire meshlet if all triangles are guaranteed to be back-facing from the camera point of view):
if (dot(normalize(cone_apex - camera_position), cone_axis) >= cone_cutoff) reject();
Cluster culling should ideally run at a lower frequency than mesh shading, either using amplification/task shaders, or using a separate compute dispatch.
By default, the meshlet builder tries to form complete meshlets even if that requires merging disconnected regions of the mesh into a single meshlet. In some cases, such as hierarchical level of detail, or when advanced culling is used, it may be beneficial to prioritize spatial locality of triangles in a meshlet even if that results in partially filled meshlets. To that end, meshopt_buildMeshletsFlex function can be used instead of meshopt_buildMeshlets; it provides two triangle limits, min_triangles and max_triangles, and uses an additional configuration parameter, split_factor (recommended value is 2.0), to decide whether increasing the meshlet radius is worth it to fit more triangles in the meshlet. When using this function, the worst case bound for the number of meshlets has to be computed using meshopt_buildMeshletsBound with min_triangles parameter instead of max_triangles.
Clustered raytracing
In addition to rasterization, meshlets can also be used for ray tracing. NVidia GPUs starting from Turing with recent drivers provide support for cluster acceleration structures (via VK_NV_cluster_acceleration_structure extension / NVAPI, or DXR2); instead of building a traditional BLAS, a cluster acceleration structure can be built for each meshlet and combined into a single clustered BLAS. While this currently results in reduced ray tracing performance for static geometry (for which a traditional BLAS may be more suitable), it allows updating the individual clusters without having to rebuild or refit the entire BLAS, which can be useful for mesh deformation or hierarchical level of detail.
When using meshlets for raytracing, the performance characteristics that matter differ from when rendering meshes with rasterization. For raytracing, clusters with optimal spatial division that minimize ray-triangle intersection tests are preferred, while for rasterization, clusters with maximum triangle count within vertex limits are ideal.
To generate meshlets optimized for raytracing, this library provides meshopt_buildMeshletsSpatial algorithm, which builds clusters using surface area heuristic (SAH) to produce raytracing-friendly cluster distributions:
const size_t max_vertices = 64;
const size_t min_triangles = 16;
const size_t max_triangles = 64;
const float fill_weight = 0.5f;
size_t max_meshlets = meshopt_buildMeshletsBound(indices.size(), max_vertices, min_triangles); // note: use min_triangles to compute worst case bound
std::vector<meshopt_Meshlet> meshlets(max_meshlets);
std::vector<unsigned int> meshlet_vertices(indices.size());
std::vector<unsigned char> meshlet_triangles(indices.size()); // note: in v0.25 or prior, use indices.size() + max_meshlets * 3
size_t meshlet_count = meshopt_buildMeshletsSpatial(meshlets.data(), meshlet_vertices.data(), meshlet_triangles.data(), indices.data(),
indices.size(), &vertices[0].x, vertices.size(), sizeof(Vertex), max_vertices, min_triangles, max_triangles, fill_weight);
The algorithm recursively subdivides the triangles into a BVH-like hierarchy using SAH for optimal spatial partitioning while balancing cluster size; this results in clusters that are significantly more efficient to raytrace compared to clusters generated by meshopt_buildMeshlets, but can still be used for rasterization (for example, to build visibility buffers or G-buffers).
The min_triangles and max_triangles parameters control the allowed range of triangles per cluster. For optimal raytracing performance, min_triangles should be at most max_triangles/2 (or, ideally, max_triangles/4) to give the algorithm enough freedom to produce high-quality spatial partitioning. For meshes with few seams due to normal or UV discontinuities, using max_vertices equal to max_triangles is recommended when rasterization performance is a concern; for meshes with many seams or for renderers that primarily use meshlets for ray tracing, a higher max_vertices value should be used as it ensures that more clusters can fully utilize the triangle limit.
The fill_weight parameter (typically between 0 and 1, although values higher than 1 could be used to prioritize cluster fill even more) controls the trade-off between pure SAH optimization and triangle utilization. A value of 0 will optimize purely for SAH, resulting in best raytracing performance but potentially smaller clusters. Values between 0.25 and 0.75 typically provide a good balance of SAH quality vs triangle count.
When the resulting meshlets are used to generate hardware-specific acceleration structures, using fast trace (e.g. VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_TRACE_BIT_KHR) builds results in maximum performance; if build performance is important, using meshopt_optimizeMeshlet can help improve ray tracing performance when using fast build (e.g. VK_BUILD_ACCELERATION_STRUCTURE_PREFER_FAST_BUILD_BIT_KHR), although the tracing performance will still be lower than with fast trace builds.
Point cloud clusterization
Both of the meshlet algorithms are designed to work with triangle meshes. In some cases, splitting a point cloud into fixed size clusters can be useful; the resulting point clusters could be rendered via mesh or compute shaders, or the resulting subdivision can be used to parallelize point processing while maintaining locality of points. To that end, this library provides meshopt_spatialClusterPoints algorithm:
const size_t cluster_size = 256;
std::vector<unsigned int> index(vertices.size());
meshopt_spatialClusterPoints(&index[0], &vertices[0].px, vertices.size(), sizeof(Vertex), cluster_size);
The resulting index buffer could be used to process the points directly, or reorganize the point data into flat contiguous arrays. Every consecutive chunk of cluster_size points in the index buffer refers to a single cluster, with just the last cluster containing fewer points if the total number of points is not a multiple of cluster_size. Note that the index buffer is not a remap table, so meshopt_remapVertexBuffer can't be used to flatten the point data.
Cluster partitioning
When working with clustered geometry, it can be beneficial to organize clusters into larger groups (partitions) for more efficient processing or workload distribution. This library provides an algorithm to partition clusters into groups of similar size while prioritizing locality:
const size_t partition_size = 24;
std::vector<unsigned int> cluster_partitions(cluster_count);
size_t partition_count = meshopt_partitionClusters(&cluster_partitions[0], &cluster_indices[0], total_index_count,
&cluster_index_counts[0], cluster_count, &vertices[0].x, vertex_count, sizeof(Vertex), partition_size);
The algorithm assigns each cluster to a partition, aiming for a target partition size while prioritizing topological locality (sharing vertices) and spatial locality. The resulting partitions can be used for more efficient batched processing of clusters, or for hierarchical simplification schemes similar to Nanite.
Two clusters are considered topologically adjacent if they reference the same indices. In some cases, it can be helpful to process the indices using meshopt_generateShadowIndexBuffer (or remap them manually using the remap table generated by meshopt_generatePositionRemap), which allows clusters to be considered adjacent even when boundary vertices have different indices due to attribute discontinuities.
If vertex positions are specified (not NULL), spatial locality will influence priority of merging clusters; otherwise, the algorithm will rely solely on topological connections and will not merge disconnected clusters into the same partition, which may result in smaller partitions for some inputs.
After partitioning, each element in the destination array contains the partition ID (ranging from 0 to the returned partition count minus 1) for the corresponding cluster. Note that the partitions may be both smaller and larger than the target size; given a target size, the maximum partition size returned currently is target + target / 3.
Cluster position quantization
When working with clustered geometry, it may be possible to use cluster-relative quantization for vertex positions. In particular, a common representation involves quantizing positions to lie on an integer grid with an exponent shared between clusters, to avoid quantizing shared vertices differently between clusters, and storing each position as an offset from the cluster anchor. A variant of this representation called Compressed1 position encoding is supported by DXR2. This library provides a helper function, meshopt_computePositionExponent, that can be used to compute the exponent, as well as an example (see encodeMeshletsDXR in demo/main.cpp) demonstrating the full pipeline.
First, compute the shared exponent to guarantee that any vertex in every cluster fits in a 24-bit signed integer, and additionally can be encoded as a K-bit unsigned delta from the per-cluster anchor (in the example below, K=16 which matches DXR2 limits):
const int min_exp = -13; // start with an acceptable level of precision; 2^-13 ~= 0.1mm in metric units
const int max_bits = 16; // maximum number of offset bits per axis
int exponent = min_exp;
for (size_t i = 0; i < meshlets.size(); ++i)
{
AABB meshlet_aabb = ...; // compute meshlet AABB based on vertex positions
int cexp = meshopt_computePositionExponent(meshlet_aabb.min, meshlet_aabb.max, min_exp, max_bits);
exponent = std::max(exponent, cexp);
}
float scale = ldexpf(1.f, exponent);
After this, each cluster can be encoded independently; when encoding each position, positions should be converted to the integer grid, and the anchor should be established:
int positions[256][3];
int anchor[3] = {INT_MAX, INT_MAX, INT_MAX};
for (size_t j = 0; j < meshlet.vertex_count; ++j)
{
unsigned int v = meshlet_vertices[meshlet.vertex_offset + j];
const float* p = &vertices[v].px;
for (int k = 0; k < 3; ++k)
{
positions[j][k] = int(roundf(p[k] / scale));
anchor[k] = std::min(anchor[k], positions[j][k]);
}
}
The exponent selection guarantees that as a result, for each cluster, we get 24-bit anchor components, and for each vertex position[k] - anchor[k] fits into a 16-bit unsigned integer. The positions could then be encoded using a fixed-width 16-bit encoding, with 6 bytes per vertex; however, because many clusters will require fewer bits, it's more efficient to determine the bit count per axis per cluster (by computing the number of bits per axis that is sufficient to fit position[k] - anchor[k]), and encoding the resulting deltas from the anchor into a bitstream. When using DXR2, the resulting position bits can be given directly to the cluster BVH builder by using D3D12_VERTEX_FORMAT_COMPRESSED1 format.
The same data could be directly decoded from a mesh shader in hybrid rendering pipelines, since each cluster uses a consistent number of bits per axis; this would require implementing unaligned bitstream read access in the shader. Alternatively, deltas could be stored as 16-bit integers alongside other vertex data (taking as much space as snorm16/half positions with significantly increased precision for larger meshes).
In the examples above,
max_bitsis set to 16 to match DXR2 limits. If DXR2 compatibility is not required, it may sometimes be necessary to use more bits to accommodate larger clusters. This is particularly relevant when using hierarchical cluster LOD, as all clusters at all levels of detail must share the exponent to eliminate gaps between adjacent clusters at different resolutions; Nanite uses up to 21 bits per axis for this reason.
This scheme works well together with meshlet compression described below, which can be used to encode topology (triangles) efficiently.
Mesh compression
In case storage size or transmission bandwidth is of importance, you might want to additionally compress vertex and index data. While several mesh compression libraries, like Google Draco, are available, they typically are designed to maximize the compression ratio at the cost of disturbing the vertex/index order (which makes the meshes inefficient to render on GPU) or decompression performance. They also frequently don't support custom game-ready quantized vertex formats and thus require to re-quantize the data after loading it, introducing extra quantization errors and making decoding slower.
Alternatively you can use general purpose compression libraries like zstd or Oodle to compress vertex/index data - however these compressors aren't designed to exploit redundancies in vertex/index data and as such compression rates can be unsatisfactory.
To that end, this library provides algorithms to "encode" vertex and index data. The result of the encoding is generally significantly smaller than initial data, and remains compressible with general purpose compressors - so you can either store encoded data directly (for modest compression ratios and maximum decoding performance), or further compress it with LZ4/zstd/Oodle to maximize compression ratio.
Note: this compression scheme is available as a glTF extension EXT_meshopt_compression as well as KHR_meshopt_compression.
Vertex compression
This library provides a lossless algorithm to encode/decode vertex data. To encode vertices, you need to allocate a target buffer (using the worst case bound) and call the encoding function:
std::vector<unsigned char> vbuf(meshopt_encodeVertexBufferBound(vertex_count, sizeof(Vertex)));
vbuf.resize(meshopt_encodeVertexBuffer(&vbuf[0], vbuf.size(), vertices, vertex_count, sizeof(Vertex)));
To decode the data at runtime, call the decoding function:
int res = meshopt_decodeVertexBuffer(vertices, vertex_count, sizeof(Vertex), &vbuf[0], vbuf.size());
assert(res == 0);
Note that vertex encoding assumes that vertex buffer was optimized for vertex fetch, and that vertices are quantized. Feeding unoptimized data into the encoder may result in poor compression ratios. The codec is lossless by itself - the only lossy step is quantization/reordering or filters that you may apply before encoding. Additionally, if the vertex data contains padding bytes, they should be zero-initialized to ensure that the encoder does not need to store uninitialized data.
Decoder is heavily optimized and can directly target write-combined memory; you can expect it to run at 3-6 GB/s on modern desktop CPUs. Compression ratio depends on the data; vertex data compression ratio is typically around 2-4x (compared to already quantized and optimally packed data). General purpose lossless compressors can further improve the compression ratio at some cost to decoding performance. If decoding always targets regular cached memory, compiling the library with MESHOPTIMIZER_VERTEXCODEC_ZEROCOPY define can yield additional ~10% decoding performance.
The vertex codec tries to take advantage of the inherent locality of sequential vertices and identify bit patterns that repeat in consecutive vertices. Typically, vertex cache + vertex fetch provides a reasonably local vertex traversal order; without an index buffer, it is recommended to sort vertices spatially (via meshopt_spatialSortRemap) to improve the compression ratio.
It is crucial to correctly specify the stride when encoding vertex data; however, for compression ratio it does not matter whether the vertices are interleaved or deinterleaved, as the codecs perform full byte deinterleaving internally. The stride of each stream must be a multiple of 4 bytes.
For optimal compression results, the values should be quantized to small integers. It can be valuable to use bit counts that are not multiples of 8. For example, instead of using 16 bits to represent texture coordinates, use 12-bit integers and divide by 4095 in the shader. Alternatively, using half-precision floats can often achieve good results.
For single-precision floating-point data, it's recommended to use meshopt_quantizeFloat to remove entropy from the lower bits of the mantissa; for best results, consider using 15 bits or 7 bits for extreme compression.
For normal or tangent vectors, using octahedral encoding is recommended over three components as it reduces redundancy; similarly, consider using 10-12 bits per component instead of 16.
When data is bit packed, specifying compression level 3 (via meshopt_encodeVertexBufferLevel) can improve the compression further by redistributing bits between components.
Index compression
This library also provides algorithms to encode/decode index data. To encode triangle indices, you need to allocate a target buffer (using the worst case bound) and call the encoding function:
std::vector<unsigned char> ibuf(meshopt_encodeIndexBufferBound(index_count, vertex_count));
ibuf.resize(meshopt_encodeIndexBuffer(&ibuf[0], ibuf.size(), indices, index_count));
To decode the data at runtime, call the decoding function:
int res = meshopt_decodeIndexBuffer(indices, index_count, &ibuf[0], ibuf.size());
assert(res == 0);
Note that index encoding assumes that the index buffer was optimized for vertex cache and vertex fetch. Feeding unoptimized data into the encoder will result in poor compression ratios. Codec preserves the order of triangles, however it can rotate each triangle to improve compression ratio (which means the provoking vertex may change).
Decoder is heavily optimized and can directly target write-combined memory; you can expect it to run at 3-6 GB/s on modern desktop CPUs.
The index codec targets 1 byte per triangle as a best case (6x smaller than raw 16-bit index data); on real-world meshes, it's typical to achieve 1-1.2 bytes per triangle. To reach this, the index data needs to be optimized for vertex cache and vertex fetch. Optimizations that do not disrupt triangle locality (such as overdraw) are safe to use in between.
To reduce the data size further, it's possible to use meshopt_optimizeVertexCacheStrip instead of meshopt_optimizeVertexCache when optimizing for vertex cache. This trades off some efficiency in vertex transform for smaller index (and sometimes vertex) data.
When referenced vertex indices are not sequential, the index codec will use around 2 bytes per index. This can happen when the referenced vertices are a sparse subset of the vertex buffer, such as when encoding LODs. General-purpose compression can be especially helpful in this case.
Index buffer codec only supports triangle list topology; when encoding triangle strips or line lists, use meshopt_encodeIndexSequence/meshopt_decodeIndexSequence instead. This codec typically encodes indices into ~1 byte per index, but compressing the results further with a general purpose compressor can improve the results to 1-3 bits per index.
Meshlet compression
When using mesh shading or clustered raytracing, meshlet vertex reference and triangle data can be compressed similarly to index data. This library provides a dedicated codec that exploits locality inherent in meshlet data. Unlike vertex and index buffer codecs that work on entire buffers, the meshlet codec encodes each meshlet independently; this allows applications to have more flexibility in structuring the runtime storage and adjust the decoded data during decoding. This also means that in some applications, additional data describing the meshlet (vertex/triangle count, encoded size) will need to be encoded into the meshlet stream, if it isn't already available during decoding.
To encode a meshlet, you need to allocate a target buffer (using the worst case bound) and call the encoding function with the vertex index references and micro-index buffer, as produced by meshopt_buildMeshlets:
std::vector<unsigned char> mbuf(meshopt_encodeMeshletBound(max_vertices, max_triangles));
for (const meshopt_Meshlet& m : meshlets)
{
size_t msize = meshopt_encodeMeshlet(&mbuf[0], mbuf.size(),
&meshlet_vertices[m.vertex_offset], m.vertex_count, &meshlet_triangles[m.triangle_offset], m.triangle_count);
// write m.vertex_count, m.triangle_count, msize and mbuf[0..msize-1] to the output stream
}
To decode the data at runtime, call the decoding function:
uint16_t* vertices = ...;
uint8_t* triangles = ...;
// automatically deduces `vertex_size=2` and `triangle_size=3` based on pointer types
int res = meshopt_decodeMeshlet(vertices, m.vertex_count, triangles, m.triangle_count, stream, encoded_size);
assert(res == 0);
Vertex index references can be decoded as either 16-bit or 32-bit integers; triangle data can be decoded as 3 bytes per triangle (matching meshopt_buildMeshlets output format) or as a 32-bit integer per triangle (with indices packed as a | (b << 8) | (c << 16) and top byte unused). Output buffers must have available space aligned to 4 bytes; for example, decoding a 3-triangle stream using 3 bytes per triangle needs to be able to write 12 bytes to the output triangles array.
When using the C++ API, meshopt_decodeMeshlet will automatically deduce the element sizes based on the types of vertex and triangle pointers; when using the C API, the sizes need to be specified explicitly.
Decoder is heavily optimized and can directly target write-combined memory; you can expect it to run at 7-10 GB/s on modern desktop CPUs.
Applications that do most of the streaming decompression on the GPU can also decode meshlet data on the GPU if CPU decoding is inconvenient; an example meshletdec.slang shader is provided for 32-bit output format, and can be easily adapted to other formats, including custom ones.
Note that meshlet encoding assumes that the meshlet data was optimized; meshlets should be processed using meshopt_optimizeMeshletLevel with level 1 or higher (3 recommended for improved compression) before encoding. Additionally, vertex references should have a high degree of reference locality; this can be achieved by building meshlets from meshes optimized for vertex cache/fetch, or linearizing the vertex reference data and reordering the vertex buffer using meshopt_optimizeVertexFetch. Feeding unoptimized data into the encoder will result in poor compression ratios. Codec preserves the order of triangles, however it can rotate each triangle to improve compression ratio (which means the provoking vertex may change).
Meshlets without vertex references are supported; passing NULL vertices and 0 vertex count during encoding and decoding will produce encoded meshlets with just triangle data. Note that parameters supplied during decoding must match those used during encoding; if a meshlet was encoded with vertex references, it must be decoded with the same number of vertex references.
The meshlet codec targets 5-7 bits per triangle for triangle data; when vertex references are encoded, the encoded size strongly depends on how linear the references are, but it's typical to see 9-12 bits per triangle in aggregate. To reduce the compressed size further, it's possible to compress the resulting encoded data with a general purpose compressor, which usually achieves 5-8 bits/triangle in aggregate; note that in this case general purpose compressors should be applied to a stream with many encoded meshlets at once to amortize their overhead.
Point cloud compression
The vertex encoding algorithms can be used to compress arbitrary streams of attribute data; one other use case besides triangle meshes is point cloud data. Typically point clouds come with position, color and possibly other attributes but don't have an implied point order.
To compress point clouds efficiently, it's recommended to first preprocess the points by sorting them using the spatial sort algorithm:
std::vector<unsigned int> remap(point_count);
meshopt_spatialSortRemap(&remap[0], positions, point_count, sizeof(vec3));
// for each attribute stream
meshopt_remapVertexBuffer(positions, positions, point_count, sizeof(vec3), &remap[0]);
After this the resulting arrays should be quantized (e.g. using 16-bit fixed point numbers for positions and 8-bit color components), and the result can be compressed using meshopt_encodeVertexBuffer as described in the previous section. To decompress, meshopt_decodeVertexBuffer will recover the quantized data that can be used directly or converted back to original floating-point data. The compression ratio depends on the nature of source data, for colored points it's typical to get 35-40 bits per point.
Vertex filters
To further leverage the inherent structure of some vertex data, it's possible to use filters that encode and decode the data in a lossy manner. This is similar to quantization but can be used without having to change the shader code. After decoding, the filter transformation needs to be reversed. For native game engine pipelines, it is usually more optimal to carefully prequantize and pretransform the vertex data, but sometimes (for example when serializing data in glTF format) this is not a practical option and filters are more convenient. This library provides four filters:
- Octahedral filter (
meshopt_encodeFilterOct/meshopt_decodeFilterOct) encodes quantized (snorm) normal or tangent vectors using octahedral encoding. Any number of bits between 2 and 16 can be used with 4 bytes or 8 bytes per vector. - Quaternion filter (
meshopt_encodeFilterQuat/meshopt_decodeFilterQuat) encodes quantized (snorm) quaternion vectors; this can be used to encode rotations or tangent frames. Any number of bits between 4 and 16 can be used with 8 bytes per vector. - Exponential filter (
meshopt_encodeFilterExp/meshopt_decodeFilterExp) encodes single-precision floating-point vectors; this can be used to encode arbitrary floating-point data more efficiently. In addition to an arbitrary bit count (<= 24), the filter takes a "mode" parameter that allows specifying how the exponent sharing is performed to trade off compression ratio and quality:meshopt_EncodeExpSeparatedoes not share exponents and results in the largest outputmeshopt_EncodeExpSharedVectorshares exponents between different components of the same vectormeshopt_EncodeExpSharedComponentshares exponents between the same component in different vectorsmeshopt_EncodeExpClampeddoes not share exponents but clamps the exponent range to reduce exponent entropy
- Color filter (
meshopt_encodeFilterColor/meshopt_decodeFilterColor) encodes quantized (unorm) RGBA colors using YCoCg encoding. Any number of bits between 2 and 16 can be used with 4 bytes or 8 bytes per vector.
Note that all filters are lossy and require the data to be deinterleaved with one attribute per stream; this facilitates efficient SIMD implementation of filter decoders, which decodes at 5-10 GB/s on modern desktop CPUs, allowing the overall decompression speed to be closer to that of the raw vertex codec.
Versioning and compatibility
The following guarantees on data compatibility are provided for point releases (no guarantees are given for development branch):
- Data encoded with older versions of the library can always be decoded with newer versions;
- Data encoded with newer versions of the library can be decoded with older versions, provided that encoding versions are set correctly; if binary stability of encoded data is important, use
meshopt_encodeVertexVersionandmeshopt_encodeIndexVersionto 'pin' the data versions (orversionargument ofmeshopt_encodeVertexBufferLevel).
By default, vertex data is encoded for format version 1 (compatible with meshoptimizer v0.23+), and index data is encoded for format version 1 (compatible with meshoptimizer v0.14+). When decoding the data, the decoder will automatically detect the version from the data header.
Meshlet data uses a frameless format without an embedded version header, so it's compatible across all library versions (starting with meshoptimizer v1.1). Any future change to meshlet encoding would be exposed as new functions; to adopt future improvements without breaking existing data, applications could record a format version externally, once per entire meshlet stream or file.
Simplification
All algorithms presented so far don't affect visual appearance at all, with the exception of quantization that has minimal controlled impact. However, fundamentally the most effective way to reduce the rendering or transmission cost of a mesh is to reduce the number of triangles in the mesh.
Basic simplification
This library provides a simplification algorithm, meshopt_simplify, that reduces the number of triangles in the mesh. Given a vertex and an index buffer, it generates a second index buffer that uses existing vertices in the vertex buffer. This index buffer can be used directly for rendering with the original vertex buffer (preferably after vertex cache optimization using meshopt_optimizeVertexCache), or a new compact vertex/index buffer can be generated using meshopt_optimizeVertexFetch that uses the optimal number and order of vertices.
float threshold = 0.2f;
size_t target_index_count = size_t(index_count * threshold);
float target_error = 1e-2f;
std::vector<unsigned int> lod(index_count);
float lod_error = 0.f;
lod.resize(meshopt_simplify(&lod[0], indices, index_count, &vertices[0].x, vertex_count, sizeof(Vertex),
target_index_count, target_error, /* options= */ 0, &lod_error));
Target error is an approximate measure of the deviation from the original mesh using distance normalized to [0..1] range (e.g. 1e-2f means that simplifier will try to maintain the error to be below 1% of the mesh extents). Note that the simplifier attempts to produce the requested number of indices at minimal error, but because of topological restrictions and error limit it is not guaranteed to reach the target index count and can stop earlier.
To disable the error limit, target_error can be set to FLT_MAX. This makes it more likely that the simplifier will reach the target index count, but it may produce a mesh that looks significantly different from the original, so using the resulting error to control viewing distance would be required. Conversely, setting target_index_count to 0 will simplify the input mesh as much as possible within the specified error limit; this can be useful for generating LODs that should look good at a given viewing distance.
The algorithm follows the topology of the original mesh in an attempt to preserve attribute seams, borders and overall appearance. For meshes with inconsistent topology or many seams, such as faceted meshes, it can result in simplifier getting "stuck" and not being able to simplify the mesh fully. Therefore it's critical that identical vertices are "welded" together, that is, the input vertex buffer does not contain duplicates. Additionally, it may be worthwhile to weld the vertices without taking into account vertex attributes that aren't critical and can be rebuilt later, or use "permissive" mode described below.
Alternatively, the library provides another simplification algorithm, meshopt_simplifySloppy, which doesn't follow the topology of the original mesh. This means that it doesn't preserve attribute seams or borders, but it can collapse internal details that are too small to matter because it can merge mesh features that are topologically disjoint but spatially close. In general, this algorithm produces meshes with worse geometric quality and poor attribute quality compared to meshopt_simplify.
The algorithm can also return the resulting normalized deviation that can be used to choose the correct level of detail based on screen size or solid angle; the error can be converted to object space by multiplying by the scaling factor returned by meshopt_simplifyScale. For example, given a mesh with a precomputed LOD and a prescaled error, the screen-space normalized error can be computed and used for LOD selection:
// lod_factor can be 1 or can be adjusted for more or less aggressive LOD selection
float d = max(0, distance(camera_position, mesh_center) - mesh_radius);
float e = d * (tan(camera_fovy / 2) * 2 / screen_height); // 1px in mesh space
bool lod_ok = e * lod_factor >= lod_error;
When a sequence of LOD meshes is generated that all use the original vertex buffer, care must be taken to order vertices optimally to not penalize mobile GPU architectures that are only capable of transforming a sequential vertex buffer range. It's recommended in this case to first optimize each LOD for vertex cache, then assemble all LODs in one large index buffer starting from the coarsest LOD (the one with fewest triangles), and call meshopt_optimizeVertexFetch on the final large index buffer. This will make sure that coarser LODs require a smaller vertex range and are efficient with respect to vertex fetch and transform.
Attribute-aware simplification
While meshopt_simplify is aware of attribute discontinuities by default (and infers them through the supplied index buffer) and tries to preserve them, it can be useful to provide information about attribute values. This allows the simplifier to take attribute error into account which can improve shading (by usin