Conversation
This code will be reused for normal generation, where our input is position-only; as such we need an ability to produce the remap with a simpler hasher. This change also adds (currently unused) trimmed position-only hasher. Calling buildVertexRemap with VertexHasherP should be approximately equivalent to calling meshopt_buildPositionRemap; however we can't use it directly because that would introduce a dependency between translation units.
This sets up the function scaffolding and computes face normals (without smoothing, duplicated per corner). This is a necessary first step anyway, as our algorithm will proceed to identify soft edges using the crease threshold and merge them similarly to how we merge faces into tangent groups.
Using the exact same traversal and union-find that we use for tangents, we now merge corners into groups based on incidence; for tangents, the extra criteria is that UV winding has to agree - for normals, the extra criteria is that face normals need to be aligned. Edges connecting triangles with an angle more than crease_angle are treated as hard edges. After creating the groups we proceed to accumulate the normals, weighted using the same scheme we use for tangents - incident angle times edge lengths. For triangles of a relatively uniform size this matches the canonical incident angle weighting that is most commonly used in the industry; however, it handles irregular triangulations better by weighting attached sliver geometry less than the larger neighboring triangles.
While the initial output of normal generation is decent, for meshes with non-smooth surfaces the generated normals may produce non-smooth normals which results in visible shading artifacts. We now provide an additional parameter in meshopt_generateNormals, smoothing, which can be used to run an iterative smoothing filter on the generated normals. Normals are smoothed alongside triangle edges using bilateral weighting to avoid flattening; the smoothing also preserves the previously computed corner groups, and will never smooth across creases. For simplicity, a single parameter is mapped into the floor() smoothing passes with a fixed alpha, and the last pass that uses fract() weight (if necessary). Also fix the crease cutoff comparison.
We expose it alongside generateTangents with a similar function signature; just like generateTangents, it can handle indexed or unindexed input but it always returns unindexed (per-corner) normals because they can be split alongside hard edges detected via crease angle.
meshopt_generateNormals produces cleaner normals by itself, and also supports normal smoothing which helps clean up the output even further. We now default to crease threshold of 60 degrees; 120 was used to work around some issues in three.js behavior that are not very prevalent in meshopt (three.js uses pairwise thresholding, meshopt uses the crease angle to identify hard edges and accumulates normals across soft edges).
When reading vertex data, we do not need to use remap because reading v and remap[v] should produce the same results (modulo neg zero which does not matter here). And reads through remap carry extra latency. Removing these makes tangent and normal generation ~3% faster. Also add assertions for remaining generateNormals() arguments.
Instead of normalizing before every smoothing pass, we now normalize during the delta accumulation. This is faster as we skip a pass entirely and all the relevant values are in registers anyway. Post-accumulation normalization pass thus has to run before smoothing, not after. Also clean up tangent fallback code a bit; l == 0 is cheaper to use because we've just evaluated it earlier, which skips a mostly redundant option check.
Similarly to other functions, meshopt_generateNormals can now be called with an arbitrary index type.
smoothing parameter is now optional and defaults to 0; crease angle is range checked to make it less likely that a unit mismatch occurs. Also update Wasm binary with the actual native implementation as it appears to be complete.
Similarly to meshopt_generateTangents, meshopt_generateNormals can be called on an indexed input, but it returns deindexed results. These can be incorporated back into the original mesh with lazy vertex splitting (see tangents example), but to show a different approach here we instead unindex and reindex the mesh.
Add documentation for meshopt_generateNormals and MeshoptTangents/ generateNormals for JS, and also add meshopt_generateNormals to experimental function list. The C++ documentation for now lives in the same "Tangent spaces" section; this might be a little counter-intuitive in the future (if you don't expect to find normal generation you may not find it there) but I don't want to create yet another section for this yet.
Since we already exercise smoothing code in demo/main.cpp and it might change how the edges are weighted in the future, we just test the basic non-smoothed variant here.
This mirrors the C++ test exactly, and omits smoothing value as well.
|
Hello, @zeux, some asset pipelines optionally treat UV island boundaries as hard normal seams, even when the geometric crease angle would otherwise keep the edge smooth. This keeps normal and tangent discontinuities aligned and can help avoid tangent-space normal-map baking or interpolation artifacts. Are there any plans for an optional mode that treats UV discontinuities as hard edges—perhaps by accepting UVs or an explicit seam mask—or is this expected to remain a separate preprocessing step before meshopt_generateNormals? |
|
That seems a little backwards to me; what is the example of a pipeline that works with a mesh that has UV islands and doesn't have normals that would require this? The case that I have ran into so far is the reverse: when there is no UV discontinuity but there is a normal discontinuity, you get a discontinuous tangent space but the UV is continuous, which is a problem because the texels shared by the edge between two triangles belong to two conflicting tangent spaces. |
|
You're right — I phrased it backwards. I meant that every hard normal edge should have a corresponding UV seam. Otherwise the two sides use different tangent frames while sharing the same texels, so they cannot receive independent padding, and filtering or mip generation can bleed normal data between smoothing groups. Would it be useful for meshopt_generateNormals to expose the generated hard-edge mask, so an unwrap or baking pipeline can create the required UV seams, or is that intentionally outside the scope of this API? |
|
I don't really want to add an output mask to this API but note that this is already something that is easy to unambiguously detect in the result; xatlas supports setting (I say "soft" because I think there might be cases based on my testing where this doesn't always happen, but I think it's due to further processing in xatlas that doesn't always look at normals) Perhaps a more interesting question is whether a UV chart guided normal generation is simply better overall for normal map baking workflows: I have been assuming that the flow is generate normals => unwrap UVs, but you can also unwrap UVs => generate normals, in which case normal generation can be done without crease detection, simply following the UV charts. But that then is easy to implement with a simple accumulation loop so at this point maybe this is just not the job of |





This change implements a new experimental function,
meshopt_generateNormals, which generates normals with a crease angle cutoff followed by optional smoothing.The crease angle is used to classify edges into soft/hard; we then use the same union-find method as we use in tangent generation to merge triangle corners into groups, and compute a weighted average of face normals. This is conceptually similar to how Blender computes normals, but Blender makes all non-manifold edges hard; we need them to remain soft because among other things they may be produced by remeshing, where a fin triangle connects to the outer surface and outer surface should stay smooth. We currently use the same weighting we use for tangents (angle * edge length product). This is unconventional but it works reasonably well, although we might switch to something like angle * area in the future if needed to stabilize thin triangles better - tangent weighting was motivated by compatibility with MikkTSpace whereas here the choice is arbitrary. A popular angle weighting is strictly worse on irregular geometry in terms of appearance.
Smoothing is using an iterative Laplacian filter; every iteration smooths one extra edge, so on more densely tessellated models smoothing factor may need to be increased to have the overall appearance match a lower-polygon version. Of course smoothing is quite optional, and setting
smoothingto 0 disables it. Smoothing never smooths across creases, and uses normal alignment to adjust the filter to avoid oversmoothing. I also tried to incorporate edge length into this, which would make the filter more closely match bilateral normal denoising literature, but it didn't seem to significantly affect the results, and some concerns around robustness and mesh scale invariance made me remove this for now. Smoothing behavior might change in the future, just as the triangle weighting might.The output is similar to
meshopt_generateTangents: a per-corner normal needs to be either copied to the unindexed mesh, or propagated into the indexed mesh while splitting vertices.Notably, in addition to cleaner appearance, we end up generating significantly fewer vertices than three.js's
toCreasedNormalson typical remeshed inputs, as shown in the comparison image. This is due to the fact that three.js uses a pairwise corner crease test: every corner will average all incident triangles with angle below crease threshold. This means that you get multiple different combinations per vertex which produces more vertex copies and leads to more visible seams; this algorithm outputs slightly smoother and more consistent results. Although ultimately all of these are heuristics and either algorithm has cases where it misbehaves where the other one doesn't.This contribution is sponsored by Valve.