playcanvas / playcanvas/engine
Tangent frame v axis convention differs between the tangent attribute and derivative paths
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 16.8k
- Forks
- 2k
- Avg merge
- 4h 32m
- Merged PRs (30d)
- 222
Description
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.000cross(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,
twoSidedLightingon 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.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 test- ordering is
getTBNthenhandleTwoSidedLightingthenevaluateFrontend, inlitForwardMain.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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/scene/shader-lib/glsl/chunks/lit/frag/TBN.js, its WGSL twin, litMain.js, twoSidedLighting.js, and geometry-utils.js; trace the getTBN, handleTwoSidedLighting, and evaluateFrontend order in litForwardMain.js. Reproduce the tangent and non-tangent plane cases described above, including back faces and parallax. Done means the chosen convention is consistent across both frame paths, normal mapping and parallax agree, and back-face relief is not inverted.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- computer-graphics
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100