Repository: zeux/meshoptimizer (C++)

Issue:
Should buildMeshlets align local indices to 4 bytes?
When the initial `meshopt_buildMeshlets` function was written, the only way to access the hardware mesh shading functionality was with `NV_mesh_shader` extension, which used a GLSL primitive `writePackedPrimitiveIndices4x8NV` and recommended using it to write 4 indices per thread instead of a triangle per thread. This motivated the decision to align each meshlet's local index buffer to a 4-byte boundary, as unaligned integers can't be read efficiently in GLSL. That has been the case for `buildMeshlets*` function family since they were introduced in 2018 (!!!).

Since then, we got standardized interfaces to mesh shaders in Vulkan ([EXT_mesh_shader spec](https://raw.githubusercontent.com/KhronosGroup/GLSL/refs/heads/main/extensions/ext/GLSL_EXT_mesh_shader.txt)) and D3D12 ([MeshShader spec](https://microsoft.github.io/DirectX-Specs/d3d/MeshShader.html)). Neither has a 4-byte packed write primitive and both instead ask you to write each triangle from a single thread. Of course, it's still possible to coalesce the reading of the primitive data from global memory and redistribute it through shared memory, although I don't think I've seen shaders that do that. It's not fully obvious to me if the alignment has benefits in practice, given that the common usage involves a thread reading 3 bytes with an arbitrary offset, and the entire thread group's worth of index data straddles cache line boundaries anyway.

The 4-byte alignment decision has resulted in some minor papercuts:

- The output of `buildMeshlets` can't be processed as a contiguous micro index buffer, instead you need to use `meshopt_Meshlet::triangle_offset`. This occasionally results in bugs when using the API.
- When the output buffers are allocated by the caller, the documented formula for the size of the output index buffer is `max_meshlets * max_triangles` - however, that calculation does not take alignment padding into account...
- ... which leads to the restriction that `max_triangles` must be divisible by 4, to avoid potential buffer overruns. This conflicts with NVidia guidance to use 126 as a primitive limit, instead requiring 124. This is a papercut, not an efficiency issue in practice, because 126 is an unrealistic goal to target for typical mesh topology and better defaults exist.
- Similarly, when using newer `buildMeshletsFlex`/`buildMeshletsSpatial`, `min_triangles` inherits the same restriction for the same reason, making it impossible to call the APIs in some configurations like `min_triangles=1`.

Now, I think these are mostly papercuts and don't result in efficiency loss - for example, yes you can't use `min_triangles=1`, but no hardware unit is ever going to be happy with a 1-triangle meshlet so in practice, rounding that to `min_triangles=4` works fine. But it's a little bit of a wart and the original reason for aligning mostly does not exist (although technically `NV_mesh_shader` extension is alive and well).

I'd like to reach a final conclusion on this *before* meshoptimizer hits 1.0 later this year.  Either just keep the status quo (things must be aligned to a boundary of 4), or remove the alignment restrictions *and* the alignment itself. My main concern is that there might be applications relying on the output being 4-aligned - it's not too difficult to re-align things yourself and the output of these functions isn't always used directly in renderers, but this is still a potential issue. I don't love the third potential option (remove divisibility-by-4 requirements but keep aligning the output data) as I worry it can result in rare bugs when APIs are used without enough attention to detail.

So for now I want to gather feedback on this - are you relying on meshlets being 4 byte aligned? Have you hit problems with 4 byte alignment that I describe above? Would a change like this break code you've ever written?

My default preference here is to keep compatibility and not change the de-facto guarantee that has been there for 7 years, but maybe I'm too worried about something that doesn't affect compatibility in practice, or maybe the alignment is more of a concern than I realize.


Discussion: Not using that particular corner much right now, but what if you could provide a very clear path to compatibility? 

e.g a single function call that converts from new (unaligned) to old (aligned to 4) could maybe be a one liner for people updating that are relying on it, but doesn't lock the design in the past on the main path? 

If the function signatures are different you could also make the old path automatically call that function, and the main path a new endpoint 🤔 


Yeah I considered some sort of repack function as an option. It might make relaxing the requirement more obviously good; additionally I previously got requests for aligning each *triangle* to 4b boundary for better thread access patterns - at the expense of more memory naturally - so perhaps a repack function could work for both. Thanks for the suggestion!
I've spent some time to look at a lot of different open source renderers that use buildMeshlets, results so far:

- In more cases than I expected, the meshlet index buffer is repacked to 4 bytes per triangle. I think this often happens in DX12 renderers where ByteAddressBuffers do not support loading unaligned bytes. For these of course it doesn't matter much what the meshlet alignment is, and might mean a repacking function would be helpful.
- I found one case where the code assumed that `triangle_offset` is divisible by 3, as proof that the existing alignment may confuse people. That said, it's only one bug out of 30+ sources I looked at.
- In a lot of cases, the same code that is currently used in documentation is used to truncate the index buffer; this code assumes 4 byte alignment. In all cases but one, this is also using a std::vector, and this code will behave fine, just be redundant, if the internal alignment was removed.

```
meshlet_triangles.resize(last.triangle_offset + ((last.triangle_count * 3 + 3) & ~3));
```
- In my own renderer, Niagara, there's code that mildly depends on 4 byte alignment 😅 by reinterpreting the indices as `unsigned int` and copying them to the GPU visible buffer in groups. This code was written for NV_mesh_shader originally so that's not surprising; this code will also continue working, just do unaligned `unsigned int` accesses on CPU, if this change was made.
Naturally, any change here would affect code that uses `writePackedPrimitiveIndices4x8NV`. There are a few renderers on GitHub that do so and use results of `buildMeshlets` directly. Of course it's unclear that they are going to be updated, but it's also not ideal if a meshoptimizer update would break them.

While in cross-platform applications at this point I'd expect that `EXT_mesh_shader` is used, I'm also not sure if there's still a performance benefit for using `writePackedPrimitiveIndices4x8NV` for NV. Also not sure if there's similar APIs exposed on Switch 2. Of course, for new code targeting NV hardware, it's easy to repack the indices if alignment is desirable...

One alternative option I have not considered is keeping the alignment in `buildMeshlets` but removing the restriction in `buildMeshletsFlex` & `buildMeshletsSpatial`. This fixes backwards compatibility concerns at the expense of slight differences between the APIs that may not be intuitive... (and only fixes half of the papercuts obviously) *edit* `buildMeshletsScan` also doesn't have an experimental API equivalent so that would keep aligning the meshlets under this approach making it not super compelling.

On AMD there doesn't seem to be a reason one way or the other to align indices - on RDNA3, aligning every meshlet vs not aligning them performs the same on geometry dense scenes in various culling configurations. I'd need to test NV to see what the behavior is there between aligned vs not aligned and EXT vs NV.
For `buildMeshletsSpatial`, worth noting is that the only available extension for clustered RT at the moment, `VK_NV_cluster_acceleration_structure`, does *not* require 4b alignment of index data when 8-bit indices are used.
In Metal mesh shading APIs, there's similarly no requirement to align meshlet data and no interface for packed primitive setup.

I've also compared performance on current drivers on RTX 3050 and RTX 5070 and I don't see a significant difference between using `writePackedPrimitiveIndices4x8NV` vs `gl_PrimitiveIndicesNV` when using `NV_mesh_shader` extension. So it definitely looks like the current restriction is fairly unnecessary; in specific situations where `writePackedPrimitiveIndices4x8NV` must be used, applications can easily repack the meshlet data to be aligned.

I only found one [engine](https://github.com/BoyBaykiller/IDKEngine) on GitHub that simultaneously uses `writePackedPrimitiveIndices4x8NV` and uses data returned by `buildMeshlets` as is; that said, GitHub search does not seem very reliable (e.g. today it actually can't find the aforementioned repository via query `writePackedPrimitiveIndices4x8NV`!), so I'm not confident this choice wouldn't break more existing code.
One more consideration is the reservation of output space. Currently documentation recommends reserving `max_meshlets * max_vertices` for vertex references and `max_meshlets * max_triangles * 3` for local indices. This recommendation is correct, however when using flex/spatial builders, the worst case number of meshlets may be quite high if the `min_triangles` limit is low. If alignment was removed, both buffers could instead be sized to `index_count` elements which is sufficiently large and smaller than the other limit in most cases; with alignment, local index buffer needs something like `index_count + 3 * max_meshlets` to accommodate up to 3 extra bytes per meshlet for padding... which, while correct and small, is just error-prone. Removing alignment solves this, as without it the worst case is simply `index_count` elements for both.

I think at this point I've exhausted things to check, accumulated enough reasons to remove the alignment, and gained enough confidence that the scope of any breakage here is very low. I'll make the change tomorrow.



Fix (patch):
diff --git a/README.md b/README.md
index 15ac5c449..fa14c2759 100644
--- a/README.md
+++ b/README.md
@@ -183,21 +183,21 @@ To generate meshlet data, this library provides `meshopt_buildMeshlets` algorith
 
 ```c++
 const size_t max_vertices = 64;
-const size_t max_triangles = 124;
+const size_t max_triangles = 126;
 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(max_meshlets * max_vertices);
-std::vector<unsigned char> meshlet_triangles(max_meshlets * max_triangles * 3);
+std::vector<unsigned int> meshlet_vertices(indices.size());
+std::vector<unsigned char> meshlet_triangles(indices.size());
 
 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 124 are recommended (`max_triangles` must be divisible by 4 so 124 is the value closest to official NVidia's recommended 126). `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).
+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_vertices` and `max_triangles`, such as 64 and 64, or 128 and 128. Additionally, while NVidia recommends 64/124 as a good configuration, consider using a different configuration like `max_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.
+> Note that for earlier AMD GPUs, the best configurations tend to use the same limits for `max_vertices` and `max_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 like `max_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:
 
@@ -205,7 +205,7 @@ Each resulting meshlet refers to a portion of `meshlet_vertices` and `meshlet_tr
 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 + 3) & ~3));
+meshlet_triangles.resize(last.triangle_offset + last.triangle_count * 3);
 meshlets.resize(meshlet_count);
 ```
 
@@ -274,8 +274,8 @@ 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(max_meshlets * max_vertices);
-std::vector<unsigned char> meshlet_triangles(max_meshlets * max_triangles * 3);
+std::vector<unsigned int> meshlet_vertices(indices.size());
+std::vector<unsigned char> meshlet_triangles(indices.size());
 
 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);
diff --git a/demo/main.cpp b/demo/main.cpp
index feb8ca237..18a2c9a3f 100644
--- a/demo/main.cpp
+++ b/demo/main.cpp
@@ -538,8 +538,8 @@ void simplifyClusters(const Mesh& mesh, float threshold = 0.2f)
 	// build clusters (meshlets) out of the mesh
 	size_t max_meshlets = meshopt_buildMeshletsBound(mesh.indices.size(), max_vertices, max_triangles);
 	std::vector<meshopt_Meshlet> meshlets(max_meshlets);
-	std::vector<unsigned int> meshlet_vertices(max_meshlets * max_vertices);
-	std::vector<unsigned char> meshlet_triangles(max_meshlets * max_triangles * 3);
+	std::vector<unsigned int> meshlet_vertices(mesh.indices.size());
+	std::vector<unsigned char> meshlet_triangles(mesh.indices.size());
 
 	meshlets.resize(meshopt_buildMeshlets(&meshlets[0], &meshlet_vertices[0], &meshlet_triangles[0], &mesh.indices[0], mesh.indices.size(), &mesh.vertices[0].px, mesh.vertices.size(), sizeof(Vertex), max_vertices, max_triangles, 0.f));
 
@@ -886,10 +886,9 @@ static int follow(int* parents, int index)
 
 void meshlets(const Mesh& mesh, bool scan = false, bool uniform = false, bool flex = false, bool spatial = false, bool dump = false)
 {
-	// NVidia-recommends 64/126; we round 126 down to a multiple of 4
-	// alternatively we also test uniform configuration with 64/64 which is better for AMD
+	// NVidia recommends 64/126; we also test uniform configuration with 64/64 which is better for earlier AMD GPUs
 	const size_t max_vertices = 64;
-	const size_t max_triangles = uniform ? 64 : 124;
+	const size_t max_triangles = uniform ? 64 : 126;
 	const size_t min_triangles = spatial ? 16 : (uniform ? 24 : 32); // only used in flex/spatial modes
 
 	// note: should be set to 0 unless cone culling is used at runtime!
@@ -900,8 +899,8 @@ void meshlets(const Mesh& mesh, bool scan = false, bool uniform = false, bool fl
 	double start = timestamp();
 	size_t max_meshlets = meshopt_buildMeshletsBound(mesh.indices.size(), max_vertices, min_triangles);
 	std::vector<meshopt_Meshlet> meshlets(max_meshlets);
-	std::vector<unsigned int> meshlet_vertices(max_meshlets * max_vertices);
-	std::vector<unsigned char> meshlet_triangles(max_meshlets * max_triangles * 3);
+	std::vector<unsigned int> meshlet_vertices(mesh.indices.size());
+	std::vector<unsigned char> meshlet_triangles(mesh.indices.size());
 
 	if (scan)
 		meshlets.resize(meshopt_buildMeshletsScan(&meshlets[0], &meshlet_vertices[0], &meshlet_triangles[0], &mesh.indices[0], mesh.indices.size(), mesh.vertices.size(), max_vertices, max_triangles));
@@ -922,7 +921,7 @@ void meshlets(const Mesh& mesh, bool scan = false, bool uniform = false, bool fl
 
 		// this is an example of how to trim the vertex/triangle arrays when copying data out to GPU storage
 		meshlet_vertices.resize(last.vertex_offset + last.vertex_count);
-		meshlet_triangles.resize(last.triangle_offset + ((last.triangle_count * 3 + 3) & ~3));
+		meshlet_triangles.resize(last.triangle_offset + last.triangle_count * 3);
 	}
 
 	double end = timestamp();
diff --git a/demo/nanite.cpp b/demo/nanite.cpp
index 37058e170..10e6575c2 100644
--- a/demo/nanite.cpp
+++ b/demo/nanite.cpp
@@ -129,15 +129,15 @@ static std::vector<Cluster> clusterize(const std::vector<Vertex>& vertices, cons
 
 	const size_t max_vertices = 192; // TODO: depends on kClusterSize, also may want to dial down for mesh shaders
 	const size_t max_triangles = kClusterSize;
-	const size_t min_triangles = (kClusterSize / 3) & ~3;
+	const size_t min_triangles = kClusterSize / 3;
 	const float split_factor = 2.0f;
 	const float fill_weight = 0.75f;
 
 	size_t max_meshlets = meshopt_buildMeshletsBound(indices.size(), max_vertices, min_triangles);
 
 	std::vector<meshopt_Meshlet> meshlets(max_meshlets);
-	std::vector<unsigned int> meshlet_vertices(max_meshlets * max_vertices);
-	std::vector<unsigned char> meshlet_triangles(max_meshlets * max_triangles * 3);
+	std::vector<unsigned int> meshlet_vertices(indices.size());
+	std::vector<unsigned char> meshlet_triangles(indices.size());
 
 	if (kUseSpatial)
 		meshlets.resize(meshopt_buildMeshletsSpatial(&meshlets[0], &meshlet_vertices[0], &meshlet_triangles[0], &indices[0], indices.size(), &vertices[0].px, vertices.size(), sizeof(Vertex), max_vertices, min_triangles, max_triangles, fill_weight));
diff --git a/js/meshopt_clusterizer.js b/js/meshopt_clusterizer.js
index f3d02590b..28ef037f5 100644
--- a/js/meshopt_clusterizer.js
+++ b/js/meshopt_clusterizer.js
@@ -119,7 +119,7 @@ var MeshoptClusterizer = (function () {
 		var last_triangle_count = meshlets[(count - 1) * 4 + 3];
 
 		var used_vertices = last_vertex_offset + last_vertex_count;
-		var used_triangles = last_triangle_offset + ((last_triangle_count * 3 + 3) & ~3);
+		var used_triangles = last_triangle_offset + last_triangle_count * 3;
 
 		var result = {
 			meshlets: meshlets,
@@ -250,9 +250,8 @@ var MeshoptClusterizer = (function () {
 			assert(vertex_positions instanceof Float32Array);
 			assert(vertex_positions.length % vertex_positions_stride == 0);
 			assert(vertex_positions_stride >= 3);
-			assert(max_vertices <= 256 || max_vertices > 0);
-			assert(max_triangles <= 512);
-			assert(max_triangles % 4 == 0);
+			assert(max_vertices > 0 && max_vertices <= 256);
+			assert(max_triangles >= 1 && max_triangles <= 512);
 
 			cone_weight = cone_weight || 0.0;
 
diff --git a/js/meshopt_clusterizer.module.js b/js/meshopt_clusterizer.module.js
index 5a9768051..37c4c1a94 100644
--- a/js/meshopt_clusterizer.module.js
+++ b/js/meshopt_clusterizer.module.js
@@ -119,7 +119,7 @@ var MeshoptClusterizer = (function () {
 		var last_triangle_count = meshlets[(count - 1) * 4 + 3];
 
 		var used_vertices = last_vertex_offset + last_vertex_count;
-		var used_triangles = last_triangle_offset + ((last_triangle_count * 3 + 3) & ~3);
+		var used_triangles = last_triangle_offset + last_triangle_count * 3;
 
 		var result = {
 			meshlets: meshlets,
@@ -250,9 +250,8 @@ var MeshoptClusterizer = (function () {
 			assert(vertex_positions instanceof Float32Array);
 			assert(vertex_positions.length % vertex_positions_stride == 0);
 			assert(vertex_positions_stride >= 3);
-			assert(max_vertices <= 256 || max_vertices > 0);
-			assert(max_triangles <= 512);
-			assert(max_triangles % 4 == 0);
+			assert(max_vertices > 0 && max_vertices <= 256);
+			assert(max_triangles >= 1 && max_triangles <= 512);
 
 			cone_weight = cone_weight || 0.0;
 
diff --git a/src/clusterizer.cpp b/src/clusterizer.cpp
index 583ae64d6..92c2eeb1a 100644
--- a/src/clusterizer.cpp
+++ b/src/clusterizer.cpp
@@ -335,15 +335,6 @@ static float computeTriangleCones(Cone* triangles, const unsigned int* indices,
 	return mesh_area;
 }
 
-static void finishMeshlet(meshopt_Meshlet& meshlet, unsigned char* meshlet_triangles)
-{
-	size_t offset = meshlet.triangle_offset + meshlet.triangle_count * 3;
-
-	// fill 4b padding with 0
-	while (offset & 3)
-		meshlet_triangles[offset++] = 0;
-}
-
 static bool appendMeshlet(meshopt_Meshlet& meshlet, unsigned int a, unsigned int b, unsigned int c, short* used, meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, size_t meshlet_offset, size_t max_vertices, size_t max_triangles, bool split = false)
 {
 	short& av = used[a];
@@ -361,10 +352,8 @@ static bool appendMeshlet(meshopt_Meshlet& meshlet, unsigned int a, unsigned int
 		for (size_t j = 0; j < meshlet.vertex_count; ++j)
 			used[meshlet_vertices[meshlet.vertex_offset + j]] = -1;
 
-		finishMeshlet(meshlet, meshlet_triangles);
-
 		meshlet.vertex_offset += meshlet.vertex_count;
-		meshlet.triangle_offset += (meshlet.triangle_count * 3 + 3) & ~3; // 4b padding
+		meshlet.triangle_offset += meshlet.triangle_count * 3;
 		meshlet.vertex_count = 0;
 		meshlet.triangle_count = 0;
 
@@ -1051,7 +1040,6 @@ size_t meshopt_buildMeshletsBound(size_t index_count, size_t max_vertices, size_
 	assert(index_count % 3 == 0);
 	assert(max_vertices >= 3 && max_vertices <= kMeshletMaxVertices);
 	assert(max_triangles >= 1 && max_triangles <= kMeshletMaxTriangles);
-	assert(max_triangles % 4 == 0); // ensures the caller will compute output space properly as index data is 4b aligned
 
 	(void)kMeshletMaxVertices;
 	(void)kMeshletMaxTriangles;
@@ -1076,7 +1064,6 @@ size_t meshopt_buildMeshletsFlex(meshopt_Meshlet* meshlets, unsigned int* meshle
 
 	assert(max_vertices >= 3 && max_vertices <= kMeshletMaxVertices);
 	assert(min_triangles >= 1 && min_triangles <= max_triangles && max_triangles <= kMeshletMaxTriangles);
-	assert(min_triangles % 4 == 0 && max_triangles % 4 == 0); // ensures the caller will compute output space properly as index data is 4b aligned
 
 	assert(cone_weight >= 0 && cone_weight <= 1);
 	assert(split_factor >= 0);
@@ -1251,13 +1238,10 @@ size_t meshopt_buildMeshletsFlex(meshopt_Meshlet* meshlets, unsigned int* meshle
 	}
 
 	if (meshlet.triangle_count)
-	{
-		finishMeshlet(meshlet, meshlet_triangles);
-
 		meshlets[meshlet_offset++] = meshlet;
-	}
 
 	assert(meshlet_offset <= meshopt_buildMeshletsBound(index_count, max_vertices, min_triangles));
+	assert(meshlet.triangle_offset + meshlet.triangle_count * 3 <= index_count && meshlet.vertex_offset + meshlet.vertex_count <= index_count);
 	return meshlet_offset;
 }
 
@@ -1274,7 +1258,6 @@ size_t meshopt_buildMeshletsScan(meshopt_Meshlet* meshlets, unsigned int* meshle
 
 	assert(max_vertices >= 3 && max_vertices <= kMeshletMaxVertices);
 	assert(max_triangles >= 1 && max_triangles <= kMeshletMaxTriangles);
-	assert(max_triangles % 4 == 0); // ensures the caller will compute output space properly as index data is 4b aligned
 
 	meshopt_Allocator allocator;
 
@@ -1295,13 +1278,10 @@ size_t meshopt_buildMeshletsScan(meshopt_Meshlet* meshlets, unsigned int* meshle
 	}
 
 	if (meshlet.triangle_count)
-	{
-		finishMeshlet(meshlet, meshlet_triangles);
-
 		meshlets[meshlet_offset++] = meshlet;
-	}
 
 	assert(meshlet_offset <= meshopt_buildMeshletsBound(index_count, max_vertices, max_triangles));
+	assert(meshlet.triangle_offset + meshlet.triangle_count * 3 <= index_count && meshlet.vertex_offset + meshlet.vertex_count <= index_count);
 	return meshlet_offset;
 }
 
@@ -1315,7 +1295,6 @@ size_t meshopt_buildMeshletsSpatial(struct meshopt_Meshlet* meshlets, unsigned i
 
 	assert(max_vertices >= 3 && max_vertices <= kMeshletMaxVertices);
 	assert(min_triangles >= 1 && min_triangles <= max_triangles && max_triangles <= kMeshletMaxTriangles);
-	assert(min_triangles % 4 == 0 && max_triangles % 4 == 0); // ensures the caller will compute output space properly as index data is 4b aligned
 
 	if (index_count == 0)
 		return 0;
@@ -1397,13 +1376,10 @@ size_t meshopt_buildMeshletsSpatial(struct meshopt_Meshlet* meshlets, unsigned i
 	}
 
 	if (meshlet.triangle_count)
-	{
-		finishMeshlet(meshlet, meshlet_triangles);
-
 		meshlets[meshlet_offset++] = meshlet;
-	}
 
 	assert(meshlet_offset <= meshlet_bound);
+	assert(meshlet.triangle_offset + meshlet.triangle_count * 3 <= index_count && meshlet.vertex_offset + meshlet.vertex_count <= index_count);
 	return meshlet_offset;
 }
 
diff --git a/src/meshoptimizer.h b/src/meshoptimizer.h
index d685fc1d6..5fc087a02 100644
--- a/src/meshoptimizer.h
+++ b/src/meshoptimizer.h
@@ -653,10 +653,10 @@ struct meshopt_Meshlet
  * When using buildMeshletsScan, for maximum efficiency the index buffer being converted has to be optimized for vertex cache first.
  *
  * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound
- * meshlet_vertices must contain enough space for all meshlets, worst case size is equal to max_meshlets * max_vertices
- * meshlet_triangles must contain enough space for all meshlets, worst case size is equal to max_meshlets * max_triangles * 3
+ * meshlet_vertices must contain enough space for all meshlets, worst case is index_count elements (*not* vertex_count!)
+ * meshlet_triangles must contain enough space for all meshlets, worst case is index_count elements
  * vertex_positions should have float3 position in the first 12 bytes of each vertex
- * max_vertices and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512; max_triangles must be divisible by 4)
+ * max_vertices and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512)
  * cone_weight should be set to 0 when cone culling is not used, and a value between 0 and 1 otherwise to balance between cluster size and cone culling efficiency
  */
 MESHOPTIMIZER_API size_t meshopt_buildMeshlets(struct meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t max_triangles, float cone_weight);
@@ -668,11 +668,11 @@ MESHOPTIMIZER_API size_t meshopt_buildMeshletsBound(size_t index_count, size_t m
  * Splits the mesh into a set of meshlets, similarly to meshopt_buildMeshlets, but allows to specify minimum and maximum number of triangles per meshlet.
  * Clusters between min and max triangle counts are split when the cluster size would have exceeded the expected cluster size by more than split_factor.
  *
- * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound using min_triangles (not max!)
- * meshlet_vertices must contain enough space for all meshlets, worst case size is equal to max_meshlets * max_vertices
- * meshlet_triangles must contain enough space for all meshlets, worst case size is equal to max_meshlets * max_triangles * 3
+ * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound using min_triangles (*not* max!)
+ * meshlet_vertices must contain enough space for all meshlets, worst case is index_count elements (*not* vertex_count!)
+ * meshlet_triangles must contain enough space for all meshlets, worst case is index_count elements
  * vertex_positions should have float3 position in the first 12 bytes of each vertex
- * max_vertices, min_triangles and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512; min_triangles <= max_triangles; both min_triangles and max_triangles must be divisible by 4)
+ * max_vertices, min_triangles and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512; min_triangles <= max_triangles)
  * cone_weight should be set to 0 when cone culling is not used, and a value between 0 and 1 otherwise to balance between cluster size and cone culling efficiency
  * split_factor should be set to a non-negative value; when greater than 0, clusters that have large bounds may be split unless they are under the min_triangles threshold
  */
@@ -682,11 +682,11 @@ MESHOPTIMIZER_EXPERIMENTAL size_t meshopt_buildMeshletsFlex(struct meshopt_Meshl
  * Experimental: Meshlet builder that produces clusters optimized for raytracing
  * Splits the mesh into a set of meshlets, similarly to meshopt_buildMeshlets, but optimizes cluster subdivision for raytracing and allows to specify minimum and maximum number of triangles per meshlet.
  *
- * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound using min_triangles (not max!)
- * meshlet_vertices must contain enough space for all meshlets, worst case size is equal to max_meshlets * max_vertices
- * meshlet_triangles must contain enough space for all meshlets, worst case size is equal to max_meshlets * max_triangles * 3
+ * meshlets must contain enough space for all meshlets, worst case size can be computed with meshopt_buildMeshletsBound using min_triangles (*not* max!)
+ * meshlet_vertices must contain enough space for all meshlets, worst case is index_count elements (*not* vertex_count!)
+ * meshlet_triangles must contain enough space for all meshlets, worst case is index_count elements
  * vertex_positions should have float3 position in the first 12 bytes of each vertex
- * max_vertices, min_triangles and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512; min_triangles <= max_triangles; both min_triangles and max_triangles must be divisible by 4)
+ * max_vertices, min_triangles and max_triangles must not exceed implementation limits (max_vertices <= 256, max_triangles <= 512; min_triangles <= max_triangles)
  * fill_weight allows to prioritize clusters that are closer to maximum size at some cost to SAH quality; 0.5 is a safe default
  */
 MESHOPTIMIZER_EXPERIMENTAL size_t meshopt_buildMeshletsSpatial(struct meshopt_Meshlet* meshlets, unsigned int* meshlet_vertices, unsigned char* meshlet_triangles, const unsigned int* indices, size_t index_count, const float* vertex_positions, size_t vertex_count, size_t vertex_positions_stride, size_t max_vertices, size_t min_triangles, size_t max_triangles, float fill_weight);