getTBN builds the tangent frame two ways - from the vertex tangent attribute when the mesh has one, and from screen space derivatives when it does not - and the two disagree about the direction of the frame's second axis. Anything that reads dTBN therefore renders differently depending only on whether the mesh carries tangents.
Found while adding parallax occlusion mapping (#9213), where it inverts the effect vertically, but the frame is also used by normal mapping, clearcoat normals and anisotropy.
The disagreement
For a plane primitive built with calculateTangents, the stored tangent data is self consistent and matches the geometry - measured on the mesh:
T · normalize(dP/du) = 1.000
cross(N, T) * w · normalize(dP/dv) = +1.000, so the binormal the vertex shader builds follows increasing v
The derivative branch of getTBN solves for the same two vectors, then stores the negated binormal:
dTBN = mat3(T * invmax, -B * invmax, normal);
so its second column follows decreasing v. Same mesh, same uvs, opposite convention.
What it looks like
Measurements below are from one plane, one material and one camera, with the only difference being whether the mesh has a tangent stream.
Normal mapping - mean absolute channel difference between the two paths, normal map only, no height map:
- front faces:
31.36
- back faces,
twoSidedLighting on with front face culling: 5.08
One of the two has to be wrong, and the green channel of a normal map is what decides which.
Parallax - with a uv reporting diffuse map and a constant height map, so the measured shift is the uv offset itself, at dot(N, V) = 0.574:
| mesh |
du |
dv |
| with tangents |
-0.08 |
-17.93 |
| without tangents |
-0.08 |
+21.50 |
The u component agrees; the v component is inverted. #9207 added a negation of the v component to the parallax chunk to make the effect come out the right way up, which is correct for the derivative frame and therefore wrong for the tangent attribute frame. So on any mesh with tangents - which includes most glTF content - parallax is currently inverted vertically.
A second, related problem on back faces
handleTwoSidedLighting flips only the normal column:
void handleTwoSidedLighting() {
if (!gl_FrontFacing) dTBN[2] = -dTBN[2];
}
On a back face the screen space derivatives are mirrored while the vertex normal passed into getTBN is not, so the derivative branch produces T = -dP/du and B = -dP/dv. Flipping only the normal leaves both tangents mirrored, so the frame is left handed with respect to the uvs and the parallax offset comes out negated in both components - the relief renders inside out. This is why the example in #9213 builds its room from six inward facing planes rather than a box seen from the inside; it also mirrors normal mapping on any two sided surface seen from behind.
Where
src/scene/shader-lib/glsl/chunks/lit/frag/TBN.js and the WGSL twin - the -B in the derivative branch
src/scene/shader-lib/glsl/chunks/lit/frag/litMain.js - vBinormalW = cross(vNormalW, vTangentW) * vertex_tangent.w
src/scene/shader-lib/glsl/chunks/lit/frag/twoSidedLighting.js
src/scene/geometry/geometry-utils.js - calculateTangents sets w by Lengyel's handedness test
- ordering is
getTBN then handleTwoSidedLighting then evaluateFrontend, in litForwardMain.js
Fixing it
Deciding which convention is authoritative is the first question, and it is a compatibility question rather than a technical one: whichever side changes, existing content that relies on it shifts. Normal maps are usually authored green up, which corresponds to decreasing v, suggesting the derivative branch is right and the vertex binormal should be negated - but that would flip normal mapping for every mesh with tangents, so it wants care and probably a release note.
Two further things worth handling in the same change:
- The parallax chunk currently compensates for the derivative convention with a hardcoded negation. If the frames are unified, that negation has to be revisited at the same time or parallax simply inverts the other way.
- The back face case needs the derivative branch's tangents negated as well as its normal, and only for that branch, since attribute tangents do not mirror with the viewing side.
A narrower alternative, if unifying the frames is judged too risky: leave the frames alone and make the parallax chunk pick its v sign per path, which fixes parallax without touching normal mapping. That leaves the underlying inconsistency in place.
Reproducing
Render the same plane twice, once from new PlaneGeometry({ calculateTangents: true }) and once from new PlaneGeometry(), with one StandardMaterial carrying a normal map, and compare. For the parallax case give the material a constant grey height map and parallaxMode = PARALLAX_OCCLUSION, tilt the plane about 55 degrees off head on, and compare the direction the texture shifts as heightMapFactor goes from 0 to 2.
getTBNbuilds the tangent frame two ways - from the vertex tangent attribute when the mesh has one, and from screen space derivatives when it does not - and the two disagree about the direction of the frame's second axis. Anything that readsdTBNtherefore renders differently depending only on whether the mesh carries tangents.Found while adding parallax occlusion mapping (#9213), where it inverts the effect vertically, but the frame is also used by normal mapping, clearcoat normals and anisotropy.
The disagreement
For a plane primitive built with
calculateTangents, the stored tangent data is self consistent and matches the geometry - measured on the mesh:T · normalize(dP/du)=1.000cross(N, T) * w · normalize(dP/dv)=+1.000, so the binormal the vertex shader builds follows increasing vThe derivative branch of
getTBNsolves for the same two vectors, then stores the negated binormal:so its second column follows decreasing v. Same mesh, same uvs, opposite convention.
What it looks like
Measurements below are from one plane, one material and one camera, with the only difference being whether the mesh has a tangent stream.
Normal mapping - mean absolute channel difference between the two paths, normal map only, no height map:
31.36twoSidedLightingon with front face culling:5.08One of the two has to be wrong, and the green channel of a normal map is what decides which.
Parallax - with a uv reporting diffuse map and a constant height map, so the measured shift is the uv offset itself, at
dot(N, V) = 0.574:The u component agrees; the v component is inverted. #9207 added a negation of the v component to the parallax chunk to make the effect come out the right way up, which is correct for the derivative frame and therefore wrong for the tangent attribute frame. So on any mesh with tangents - which includes most glTF content - parallax is currently inverted vertically.
A second, related problem on back faces
handleTwoSidedLightingflips only the normal column:On a back face the screen space derivatives are mirrored while the vertex normal passed into
getTBNis not, so the derivative branch producesT = -dP/duandB = -dP/dv. Flipping only the normal leaves both tangents mirrored, so the frame is left handed with respect to the uvs and the parallax offset comes out negated in both components - the relief renders inside out. This is why the example in #9213 builds its room from six inward facing planes rather than a box seen from the inside; it also mirrors normal mapping on any two sided surface seen from behind.Where
src/scene/shader-lib/glsl/chunks/lit/frag/TBN.jsand the WGSL twin - the-Bin the derivative branchsrc/scene/shader-lib/glsl/chunks/lit/frag/litMain.js-vBinormalW = cross(vNormalW, vTangentW) * vertex_tangent.wsrc/scene/shader-lib/glsl/chunks/lit/frag/twoSidedLighting.jssrc/scene/geometry/geometry-utils.js-calculateTangentssetswby Lengyel's handedness testgetTBNthenhandleTwoSidedLightingthenevaluateFrontend, inlitForwardMain.jsFixing it
Deciding which convention is authoritative is the first question, and it is a compatibility question rather than a technical one: whichever side changes, existing content that relies on it shifts. Normal maps are usually authored green up, which corresponds to decreasing v, suggesting the derivative branch is right and the vertex binormal should be negated - but that would flip normal mapping for every mesh with tangents, so it wants care and probably a release note.
Two further things worth handling in the same change:
A narrower alternative, if unifying the frames is judged too risky: leave the frames alone and make the parallax chunk pick its v sign per path, which fixes parallax without touching normal mapping. That leaves the underlying inconsistency in place.
Reproducing
Render the same plane twice, once from
new PlaneGeometry({ calculateTangents: true })and once fromnew PlaneGeometry(), with oneStandardMaterialcarrying a normal map, and compare. For the parallax case give the material a constant grey height map andparallaxMode = PARALLAX_OCCLUSION, tilt the plane about 55 degrees off head on, and compare the direction the texture shifts asheightMapFactorgoes from 0 to 2.