Skip to content

tangentspace: Implement normal generation with optional smoothing - #1072

Open
zeux wants to merge 14 commits into
masterfrom
normals
Open

tangentspace: Implement normal generation with optional smoothing#1072
zeux wants to merge 14 commits into
masterfrom
normals

Conversation

@zeux

@zeux zeux commented Aug 6, 2026

Copy link
Copy Markdown
Owner

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 smoothing to 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 toCreasedNormals on 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.

normals-comparison-final

This contribution is sponsored by Valve.

zeux added 14 commits August 5, 2026 12:09
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.
@SashaRX

SashaRX commented Aug 6, 2026

Copy link
Copy Markdown

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?

@zeux

zeux commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

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.

@SashaRX

SashaRX commented Aug 6, 2026

Copy link
Copy Markdown

You're right—the strict requirement is hard normal edge → UV seam, not the reverse. I was thinking of an optional hard-surface baking workflow where existing UV island boundaries can also constrain normal smoothing. This reduces the strong gradients the normal map must encode across large angles and makes the result more robust to low resolution, BC compression, mipmapping, and LODs. For actual hard normal edges, the UV split is also required to give both smoothing groups independent padding; otherwise filtering and mip generation may sample texels encoded for the tangent frame on the opposite side. So this would not be required for correctness, but an optional UV-seam or explicit edge-mask constraint could be useful.

image image

@zeux

zeux commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

The distortion in your test is very significant; I would say in this case I'd expect different normals to begin with - it looks like maybe the generated normals here don't take triangle area into account so the normals on the bevels are pointing much more to the side than they should be?

I guess there's a bit of a question as to whether UVs are expected to be hand-authored or not. For automatically unwrapped UVs the problem is that it's quite typical to see UV seams across a smooth surface. This happens often when using xatlas, but in general automatic UV unwrapping is difficult to get seamless. If we assume UVs are automatically generated then I worry that making these edges hard will actually introduce more visible seams. If the UVs are human authored then I am not sure if generateNormals is particularly useful anyway :) But I'll think about this more; a quick test says the baked normal map might hide this anyway.

smooth seam normals hard seam normals with rebaked normal map
image image image

@SashaRX

SashaRX commented Aug 6, 2026

Copy link
Copy Markdown

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?

@zeux

zeux commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

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 normalSeamWeight to 1000 which is a "soft" guarantee that normal seams result in UV seams: https://github.com/jpcy/xatlas/blob/master/source/xatlas/xatlas.h#L184

(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 meshopt_generateNormals.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants