Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace perturbNormal implementation with a more robust version #21299

Merged
merged 2 commits into from
Feb 18, 2021
Merged

Replace perturbNormal implementation with a more robust version #21299

merged 2 commits into from
Feb 18, 2021

Conversation

zeux
Copy link
Contributor

@zeux zeux commented Feb 18, 2021

This change switches to a slightly different formulation using a
cotangent frame described by Christian Schüler in "Normal Mapping
Without Precomputed Tangents" follow-up blog post.

This implementation is nicer as it has fewer opportunities to produce a
NaN output given a degenerate input; it contains one division and one
normalize at the end, and only division needs to be guarded against.

As a result, when the UV mapping is degenerate within a given triangle,
the resulting determinant is 0 and scale is set to 0 as well.

I believe this also handles non-uniform mapping better than the method
we used to use, as it preserves the relative length of T & B. It's worth noting
that this method is also more closely aligned with bump perturbation
(see perturbNormalArb in bumppar_pars_fragment.glsl.js).

Using Mali Offline Shader Compiler on a simple shader that samples a
normal map and converts it to object space using this function, the
resulting code is also slightly faster than before - 20.5 cycles vs 22.3
cycles. The resulting performance on a complete three.js shader is
likely to be unaffected.

Replaces #18871 (which was not merged)
Fixes #19727

This change switches to a slightly different formulation using a
cotangent frame described by Christian Schüler in "Normal Mapping
Without Precomputed Tangents" follow-up blog post.

This implementation is nicer as it has fewer opportunities to produce a
NaN output given a degenerate input; it contains one division and one
normalize at the end, and only division needs to be guarded against.

As a result, when the UV mapping is degenerate within a given triangle,
the resulting determinant is 0 and scale is set to 0 as well.

Using Mali Offline Shader Compiler on a simple shader that samples a
normal map and converts it to object space using this function, the
resulting code is also slightly faster than before - 20.5 cycles vs 22.3
cycles. The resulting performance on a complete three.js shader is
likely to be unaffected.
@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

I've tested this on some artificial examples like the one in the linked issue as well as on three.js normal map example, but any other pointers for scenes / situations to test would be appreciated as this is a rather fundamental function in the shader code :)

@donmccurdy
Copy link
Collaborator

The NormalTangentTest glTF sample would be a good one to test; unlike NormalTangentMirrorTest it does not have precomputed tangents.

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

FWIW some validation for why this change can be important - when the input model has triangles with degenerate mapping -

Before this change:

image

After this change:

image

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

@donmccurdy Thanks! This is a truly fantastic test model. I've confirmed that the model looks correct after this change from both sides on all orientations:

image

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

Profiling results: http://shader-playground.timjones.io/d229083f2340912fbf707e6f23d8bf63

highp:
Mali: old 23.3 cycles, new 21.3 cycles
PowerVR: old 50 cycles, new 50 cycles

mediump:
Mali: old 21.3 cycles, new 18.5 cycles
PowerVR: old 45 cycles, new 42 cycles

Radeon Graphics Analyzer results on shader playground sadly don't show estimated cycle counts, but the new variant has ~10% fewer instructions so that looks like a general rule of thumb wrt new variant across multiple architectures.

Copy link
Collaborator

@Mugen87 Mugen87 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome! We should definitely give this a try!

@mrdoob
Copy link
Owner

mrdoob commented Feb 18, 2021

Excellent stuff!

@mrdoob mrdoob added this to the r126 milestone Feb 18, 2021
@mrdoob mrdoob merged commit dee3528 into mrdoob:dev Feb 18, 2021
@mrdoob
Copy link
Owner

mrdoob commented Feb 18, 2021

Thanks!

@mrdoob
Copy link
Owner

mrdoob commented Feb 18, 2021

On a side note... (I didn't know about http://shader-playground.timjones.io/)

Seems like this:

float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;

produces less instructions than this:

float faceDirection = float( gl_FrontFacing ) * 2.0 - 1.0;

Do you have any recommendations?

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

@mrdoob Nice catch - I didn't look at this part since it was generic to all shaders. It does seem that on Mali, conditional select is a bit faster. On PowerVR and AMD it looks like the compiler compiles both to more or less identical code. My usual rule of thumb is that conditional selects are preferable to attempts to emulate them using multiplications since all GPUs have dedicated instructions for this that don't require branching, and multiplications can result in more instructions / cycles in some cases - but in this case it mostly appears to be a wash. Still, since there does appear to be a tiny difference on one vendor it may be worth changing this to ?:.

@WestLangley
Copy link
Collaborator

@zeux Thanks for doing this!

I've tested this on some artificial examples like the one in the linked issue as well as on three.js normal map example, but any other pointers for scenes / situations to test would be appreciated as this is a rather fundamental function in the shader code :)

Did you try a non-uniform scale test case?

Also, models having mirrored UVs must be verified (they have backwards winding order.). I think 'DamagedHelmet.gltf' has mirrored UVs.

And back-sided faces -- as opposed to double-sided ones.

And, of course, the Adreno hardware should be tested, which has been a problem for us.

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

@WestLangley I've tried a case when UV mapping was degenerate along only one axis and that also worked fine. If by "back-sided faces" you mean models with disabled culling then yeah, the NormalTangentTest covers that. I'll double check wrt mirrored UVs

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

Mirrored UVs look correct as well - I've tested using NormalTangentMirrorTest.gltf with manually patched gltf file to remove TANGENT attribute.

@mrdoob
Copy link
Owner

mrdoob commented Feb 18, 2021

@WestLangley

Also, models having mirrored UVs must be verified (they have backwards winding order.). I think 'DamagedHelmet.gltf' has mirrored UVs.

If this PR had broken DamagedHelmet.gltf the e2e tests would have caught it.

And, of course, the Adreno hardware should be tested, which has been a problem for us.

The problem with Adreno was gl_FrontFacing inside functions: #21205

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

And, of course, the Adreno hardware should be tested, which has been a problem for us.

I believe the Adreno workarounds here previously included dFdx(vec3) which I've kept as is to not hit this :) Other than that this is mostly just vector math, inversesqrt is also used in rect area light code so hopefully that works. Of course one can never be sure... I don't have a device to test this, but the bugs would be device / driver specific anyhow so we might need to just rely on the community for this.

@mrdoob
Copy link
Owner

mrdoob commented Feb 18, 2021

@zeux

Still, since there does appear to be a tiny difference on one vendor it may be worth changing this to ?:.

Will do, thanks!

Also, I think Babylon.js does the same.

@WestLangley
Copy link
Collaborator

@zeux @mrdoob OK, thanks.

I would be most interested, however, in the results of a non-uniform scale test case: before vs after.

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

@mrdoob Indeed - Babylon.js uses the exact same function which I didn't know before. This is great since it also equalizes behavior between the two engines :) The only difference there is that they handle back-facing triangles by negating the UV coordinate instead of negating T & B which is mathematically (and numerically) equivalent.

@mrdoob
Copy link
Owner

mrdoob commented Feb 18, 2021

This is great since it also equalizes behavior between the two engines :)

Yep!

The only difference there is that they handle back-facing triangles by negating the UV coordinate instead of negating T & B which is mathematically (and numerically) equivalent.

What's faster? 🤓

@zeux
Copy link
Contributor Author

zeux commented Feb 18, 2021

@mrdoob Our approach looks like it's 1 cycle faster on Mali :) Same on PowerVR. Also I guess less prone to bugs with gl_FrontFacing...

@WestLangley
Copy link
Collaborator

@zeux @mrdoob Under non-uniform scale and rendering with MeshNormalMaterial, it does appear that this PR computes different normals than before.

This is what I expected, but I am not sure if it was wrong before and now less wrong, or correct before and now wrong -- or what.

@WestLangley
Copy link
Collaborator

@zeux @mrdoob It looks like our shader math is wrong. The normal map was designed to be applied to the normal -- not to the normal after the normal matrix has been applied.

We apply the normal matrix first, in the vertex shader. In theory, we should apply the normal map first.

Under uniform scale -- the base case -- it does not matter.

It might make sense to see how other engines handle this. Maybe we just live with it.

@zeux
Copy link
Contributor Author

zeux commented Feb 20, 2021

We apply the normal matrix first, in the vertex shader. In theory, we should apply the normal map first.

Yeah that's an interesting problem. In particular, if you have a non-uniform scale along axes that are orthogonal to the vertex normal, the vertex normal stays the same! So any approach that just computes TBN from the interpolated normal in the fragment shader is doomed.

I'm not sure if there's a great way to deal with this. On some level I want to say that if an application requires perfect tangent space normal mapping it really needs to store tangents in the vertex data - indeed, canonically when one bakes tangent space normal maps from a high/low-res geometry, the bake results depend on the specific tangent space calculation (for which there's no single standard, although MikkTS is becoming a de-facto one which glTF also recommends).

We could of course deal with this by correcting the TBN matrix using the normal matrix in the fragment shader, but that's rather expensive and has additional issues with instanced geometry (where it's even more expensive since we compute the correctly scaled normal in the shader atm).

@WestLangley
Copy link
Collaborator

@zeux We are in agreement.

@WestLangley wrote:

I would be most interested, however, in the results of a non-uniform scale test case: before vs after.

It makes no sense to invest time doing that, since (as I noted above) the math in previous steps is incorrect.

But at least the issue is documented here.

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.

0 normalMap value when using a single UV value on a mesh
5 participants