Skip to content

tangentspace: Improved tangent space generation#1047

Merged
zeux merged 9 commits intomasterfrom
nicet
Apr 28, 2026
Merged

tangentspace: Improved tangent space generation#1047
zeux merged 9 commits intomasterfrom
nicet

Conversation

@zeux
Copy link
Copy Markdown
Owner

@zeux zeux commented Apr 27, 2026

This change builds on top of #1046 and improves the tangent generation behavior further. A couple fixes improve consistency with MikkTSpace while improving some situations that MikkTSpace mishandles; for example, with this change tangent frame orientation is now consistent across all three triangle corners. Additionally, by default this change switches the tangent weighting to be area-sensitive. This is a departure from MikkTSpace; on real world geometry it's often the case that MikkTSpace generates broken tangents because it over-weighs tangent on thin bevels which displaces tangents away from regular triangles.

While this is almost always an improvement, and is generally benign on other parts of the mesh that don't exhibit this problem, this technically produces different tangents which may distort the lighting if the normal maps were baked from higher resolution geometry using MikkTSpace weighting. Because of this, an option meshopt_TangentCompatible can be used to use the original MikkTSpace weighting.

I've also spent some time looking into how to improve tangents on degenerate UV triangles (that are zero-area in UV space), but so far all solutions have tradeoffs and aren't clearly beneficial. An experimental option meshopt_TangentZeroFallback can be used to zero these out which can work better as it flattens the normal map but it requires renderers to handle non-orthogonal frames properly which is atypical.

A few examples of improved weighting; the left side matches MikkTSpace perfectly whereas the right side is the new default:

compatible default
image image
image image
image image
image image
image image

This contribution is sponsored by Valve.

zeux added 5 commits April 26, 2026 12:30
Previously, we unified triangle corners into groups with consistent
orientation, allowing degenerate triangles to be unified into separate
groups independently. This could lead to inconsistent tangent
orientations on triangle corners for degenerate triangles; this makes
tangent space interpolation within triangles like nonsensical.

This is similar to how MikkTSpace works too: while it assigns
orientations to degenerate triangles based on non-degenerate neighbors,
it still allows corners of these triangles that don't participate in
other groups to fall back to -1 orientation.

This change instead adopts triangle groups simlar to MikkTSpace but
ensures full consistency at all corners by propagating the groups
through vertices with degenerate-only fans. This results in a similar
number of split vertices to MikkTS but consistent orientation and more
consistent interpolation.

For performance, we only do the unification if either triangle is
degenerate; this results in degenerate islands being grouped together
with their boundary, which might be useful in the future for better
degenerate tangent propagation.
Instead of using incident angle as a weight, scale it by the product of
the two adjacent edge lengths. This deviates from MikkTSpace but
significantly improves the tangent quality around beveled areas which
are very common in practice.

This should be generally safe and produce superior quality but if the
asset normal maps were baked with the exact tangents MikkTSpace
computed, this could result in difference in shading; for users who need
exact MikkTSpace compatible weights, a new option can be used to achieve
that.
If all three points of a triangle have the same position, but have
different UVs, the determinant is non-zero but rs is zero which is
mostly reasonable. However, if the triangle is a degenerate line, we
compute an actual tangent vector and treat the triangle as
non-degenerate.

This can lead to additional splits and disagreements with MikkTSpace,
and is fundamentally flawed because there isn't a well defined tangent
on a line triangles. We now enforce consistency here by setting rw to 0,
similarly to when the UVs are degenerate but positions aren't.
By default, we use MikkTSpace fallbacks and for vertices where a valid
tangent can not be computed, we set the vector to (1,0,0). These
tangents are fundamentally invalid - they aren't orthogonal to the
normal and using them in shading may result in skewed normals.

For applications that can handle non-orthogonal frames, using zero
vector might be a reasonable alternative; it flattens the normal map
instead of rotating it arbitrarily. For applications that can give user
feedback, it may be valuable to detect the invalid frames and warn,
which is difficult for 1,0,0 fallback.
This is to avoid confusion because by default, our weighting diverges
from MikkTSpace; generating MikkTSpace tangents requires using
meshopt_TangentCompatible flag.
@zeux zeux marked this pull request as ready for review April 28, 2026 01:12
This is carefully constructed so that two triangles share a fan that
subsumes the degenerate triangle, creating a split with the third edge
along which the two vertices have duplicate tangents. This also
validates averaging behavior.
@JesseRMeyer
Copy link
Copy Markdown

For reference - Eric Lengyel mentions in FGED2 section 7.5 a maybe compatible quality addition -- to duplicate vertices on tangent frame discontinuities, set their tangents to their average if they share the same handedness and whose original tangents point in similar directions.

@zeux
Copy link
Copy Markdown
Owner Author

zeux commented Apr 28, 2026

This is described in the sample chapter https://foundationsofgameenginedev.com/FGED2-sample.pdf but I don't think it's very well motivated or really very compatible; it requires some sort of cutoff for directions which would need to be tuned. I have tested Lengyel's approach to implicit weighting (by averaging un-normalized tangents) last week and found it generally produces worse quality tangents, and the code doesn't handle tangent orientation correctly at all when UVs are mirrored so I think in general I am not keen on taking any FGED material about tangents into account.

Note that when the vertices are split on different attributes, e.g. if you have a second UV set for lightmaps that introduces its own seams, MikkTSpace, as well as my code, handles this correctly because the normals & UVs are identical at these vertices; FGED code doesn't and the note you mention would fix that (which is indeed correct and matches the spirit of building tangent frames that are independent of the mesh indexing).

zeux added 3 commits April 28, 2026 08:32
Add a section about tangent space generation, for now with minimal
example and a reference to demo/main.cpp to full splitting loop.
Add an inline example for splitting vertices based on tangents
We are now using a slightly cleaner style where data is first copied
into vertices and then splits are fixed up in a separate pass, which
matches the readme version.
@zeux zeux merged commit f404b73 into master Apr 28, 2026
13 checks passed
@zeux zeux deleted the nicet branch April 28, 2026 18:02
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