-
-
Notifications
You must be signed in to change notification settings - Fork 4k
Add gram-schmidt fast approximation tangent generation algorithm #17989
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
Open
aloucks
wants to merge
6
commits into
bevyengine:main
Choose a base branch
from
aloucks:issue-17834-compute-tangents
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+354
−48
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
761b5cb
Fix merge conflict
aloucks 6ce3091
Fixing merge conflict again
aloucks 0826c34
Remove unnecessary Vec4 to [f32; 4] conversion
aloucks 38b6d78
Fix compute_tangents doc links in Mesh::ATTRIBUTE_TANGENT
aloucks ec6ae2b
Ensure the normal is is normalized in Gram-Schmidt tangent computation
aloucks 362735b
Rename TangentCalculationStrategy to TangentAlgorithm
aloucks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
use super::{GenerateTangentsError, Mesh}; | ||
use bevy_math::{Vec2, Vec3A}; | ||
use wgpu_types::{PrimitiveTopology, VertexFormat}; | ||
|
||
struct TriangleIndexIter<'a, I>(&'a mut I); | ||
|
||
impl<'a, I> Iterator for TriangleIndexIter<'a, I> | ||
where | ||
I: Iterator<Item = usize>, | ||
{ | ||
type Item = [usize; 3]; | ||
fn next(&mut self) -> Option<[usize; 3]> { | ||
let i = &mut self.0; | ||
match (i.next(), i.next(), i.next()) { | ||
(Some(i1), Some(i2), Some(i3)) => Some([i1, i2, i3]), | ||
_ => None, | ||
} | ||
} | ||
} | ||
|
||
pub(crate) fn generate_tangents_for_mesh( | ||
mesh: &Mesh, | ||
) -> Result<Vec<[f32; 4]>, GenerateTangentsError> { | ||
let positions = mesh.attribute(Mesh::ATTRIBUTE_POSITION); | ||
let normals = mesh.attribute(Mesh::ATTRIBUTE_NORMAL); | ||
let uvs = mesh.attribute(Mesh::ATTRIBUTE_UV_0); | ||
let indices = mesh.indices(); | ||
let primitive_topology = mesh.primitive_topology(); | ||
|
||
if primitive_topology != PrimitiveTopology::TriangleList { | ||
return Err(GenerateTangentsError::UnsupportedTopology( | ||
primitive_topology, | ||
)); | ||
} | ||
|
||
match (positions, normals, uvs, indices) { | ||
(None, _, _, _) => Err(GenerateTangentsError::MissingVertexAttribute( | ||
Mesh::ATTRIBUTE_POSITION.name, | ||
)), | ||
(_, None, _, _) => Err(GenerateTangentsError::MissingVertexAttribute( | ||
Mesh::ATTRIBUTE_NORMAL.name, | ||
)), | ||
(_, _, None, _) => Err(GenerateTangentsError::MissingVertexAttribute( | ||
Mesh::ATTRIBUTE_UV_0.name, | ||
)), | ||
(_, _, _, None) => Err(GenerateTangentsError::MissingIndices), | ||
(Some(positions), Some(normals), Some(uvs), Some(indices)) => { | ||
let positions = positions.as_float3().ok_or( | ||
GenerateTangentsError::InvalidVertexAttributeFormat( | ||
Mesh::ATTRIBUTE_POSITION.name, | ||
VertexFormat::Float32x3, | ||
), | ||
)?; | ||
let normals = | ||
normals | ||
.as_float3() | ||
.ok_or(GenerateTangentsError::InvalidVertexAttributeFormat( | ||
Mesh::ATTRIBUTE_NORMAL.name, | ||
VertexFormat::Float32x3, | ||
))?; | ||
let uvs = | ||
uvs.as_float2() | ||
.ok_or(GenerateTangentsError::InvalidVertexAttributeFormat( | ||
Mesh::ATTRIBUTE_UV_0.name, | ||
VertexFormat::Float32x2, | ||
))?; | ||
let vertex_count = positions.len(); | ||
let mut tangents = vec![Vec3A::ZERO; vertex_count]; | ||
let mut bi_tangents = vec![Vec3A::ZERO; vertex_count]; | ||
|
||
for [i1, i2, i3] in TriangleIndexIter(&mut indices.iter()) { | ||
let v1 = Vec3A::from_array(positions[i1]); | ||
let v2 = Vec3A::from_array(positions[i2]); | ||
let v3 = Vec3A::from_array(positions[i3]); | ||
|
||
let w1 = Vec2::from(uvs[i1]); | ||
let w2 = Vec2::from(uvs[i2]); | ||
let w3 = Vec2::from(uvs[i3]); | ||
|
||
let delta_pos1 = v2 - v1; | ||
let delta_pos2 = v3 - v1; | ||
|
||
let delta_uv1 = w2 - w1; | ||
let delta_uv2 = w3 - w1; | ||
|
||
let determinant = delta_uv1.x * delta_uv2.y - delta_uv1.y * delta_uv2.x; | ||
|
||
// check for degenerate triangles | ||
if determinant.abs() > 1e-6 { | ||
let r = 1.0 / determinant; | ||
let tangent = (delta_pos1 * delta_uv2.y - delta_pos2 * delta_uv1.y) * r; | ||
let bi_tangent = (delta_pos2 * delta_uv1.x - delta_pos1 * delta_uv2.x) * r; | ||
|
||
tangents[i1] += tangent; | ||
tangents[i2] += tangent; | ||
tangents[i3] += tangent; | ||
|
||
bi_tangents[i1] += bi_tangent; | ||
bi_tangents[i2] += bi_tangent; | ||
bi_tangents[i3] += bi_tangent; | ||
} | ||
} | ||
|
||
let mut result_tangents = Vec::with_capacity(vertex_count); | ||
|
||
for i in 0..vertex_count { | ||
let normal = Vec3A::from_array(normals[i]); | ||
// Assume that the normal has not been pre-normalized | ||
let normal = normal.normalize_or_zero(); | ||
// Although it is not mathematically necessary to normalize the accumulated | ||
// tangent, doing so may provide better numerical stability in extreme cases. | ||
let tangent = tangents[i].normalize(); | ||
// Gram-Schmidt orthogonalization | ||
let tangent = (tangent - normal * normal.dot(tangent)).normalize(); | ||
let bi_tangent = bi_tangents[i]; | ||
let handedness = if normal.cross(tangent).dot(bi_tangent) > 0.0 { | ||
1.0 | ||
} else { | ||
-1.0 | ||
}; | ||
// Both the gram-schmidt and mikktspace algorithms seem to assume left-handedness, | ||
// so we flip the sign to correct for this. The extra multiplication here is | ||
// negligible and it's done as a separate step to better document that it's | ||
// a deviation from the general algorithm. The generated mikktspace tangents are | ||
// also post processed to flip the sign. | ||
let handedness = handedness * -1.0; | ||
result_tangents.push([tangent.x, tangent.y, tangent.z, handedness]); | ||
} | ||
|
||
Ok(result_tangents) | ||
} | ||
} | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use bevy_math::{primitives::*, Vec2, Vec3, Vec3A}; | ||
|
||
use crate::{Mesh, TangentAlgorithm}; | ||
|
||
// The tangents should be very close for simple shapes | ||
fn compare_tangents(mut mesh: Mesh) { | ||
let hq_tangents: Vec<[f32; 4]> = { | ||
mesh.remove_attribute(Mesh::ATTRIBUTE_TANGENT); | ||
mesh.compute_tangents(TangentAlgorithm::Mikktspace) | ||
.expect("compute_tangents(Mikktspace)"); | ||
mesh.attribute(Mesh::ATTRIBUTE_TANGENT) | ||
.expect("hq_tangents.attribute(tangent)") | ||
.as_float4() | ||
.expect("hq_tangents.as_float4") | ||
.to_vec() | ||
}; | ||
|
||
let fa_tangents: Vec<[f32; 4]> = { | ||
mesh.remove_attribute(Mesh::ATTRIBUTE_TANGENT); | ||
mesh.compute_tangents(TangentAlgorithm::GramSchmidt) | ||
.expect("compute_tangents(GramSchmidt)"); | ||
mesh.attribute(Mesh::ATTRIBUTE_TANGENT) | ||
.expect("fa_tangents.attribute(tangent)") | ||
.as_float4() | ||
.expect("fa_tangents.as_float4") | ||
.to_vec() | ||
}; | ||
|
||
for (hq, fa) in hq_tangents.iter().zip(fa_tangents.iter()) { | ||
assert_eq!(hq[3], fa[3], "handedness"); | ||
let hq = Vec3A::from_slice(hq); | ||
let fa = Vec3A::from_slice(fa); | ||
let angle = hq.angle_between(fa); | ||
let threshold = 15.0f32.to_radians(); | ||
assert!( | ||
angle < threshold, | ||
"tangents differ significantly: hq = {:?}, fa = {:?}, angle.to_degrees() = {}", | ||
hq, | ||
fa, | ||
angle.to_degrees() | ||
); | ||
} | ||
} | ||
|
||
#[test] | ||
fn cuboid() { | ||
compare_tangents(Mesh::from(Cuboid::new(10.0, 10.0, 10.0))); | ||
} | ||
|
||
#[test] | ||
fn capsule3d() { | ||
compare_tangents(Mesh::from(Capsule3d::new(10.0, 10.0))); | ||
} | ||
|
||
#[test] | ||
fn plane3d() { | ||
compare_tangents(Mesh::from(Plane3d::new(Vec3::Y, Vec2::splat(10.0)))); | ||
} | ||
|
||
#[test] | ||
fn cylinder() { | ||
compare_tangents(Mesh::from(Cylinder::new(10.0, 10.0))); | ||
} | ||
|
||
#[test] | ||
fn torus() { | ||
compare_tangents(Mesh::from(Torus::new(10.0, 100.0))); | ||
} | ||
|
||
#[test] | ||
fn rhombus() { | ||
compare_tangents(Mesh::from(Rhombus::new(10.0, 100.0))); | ||
} | ||
|
||
#[test] | ||
fn tetrahedron() { | ||
compare_tangents(Mesh::from(Tetrahedron::default())); | ||
} | ||
|
||
#[test] | ||
fn cone() { | ||
compare_tangents(Mesh::from(Cone::new(10.0, 100.0))); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a numerical reason to normalize here?
The next line already involves a normalization and is linear in the
tangent
variable.On the other hand, I don't see the normal getting normalised, and that would cause problems in the G-S algo.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The examples I've seen either normalize the accumulated tangent it or average it by dividing by the number of vertices that were used to accumulate it. I don't think the direction would be correct if we only normalized the final result and omitted the normalizing here.
This assumes that the vertex normals are normalized. Is that assumption false? If so, I'll need to normalize the normal here.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not familiar with the code enough to determine whether the normals are normalised, although I would expect so.
Regarding the pre-normalisation of the tangent, that part is mathematically unnecessary as the projection is linear.
where
P
is the projection operatorx -> x - x.dot(normal) * normal
, which is linear.This tells us that if
t
is not normalised by a positive factork
we have:Where the first equality comes from the linearity of P, and the second from normalisation destroying these scalar factors.
So from a purely theoretical standpoint the normalisation has no effect.
The only reason I can think of for the normalisation would be to avoid some sort of numerical error, although I cannot really think of the reasons it would be relevant here given how floating point values work.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@IQuick143
How about this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can save a few operations (a square root and some divides) by doing:
Instead of normalising the normal.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately that causes other issues when the normal is broken and has length of zero. The
cone
unit test fails. Usingnormalize_or_zero
instead ofnormalize
when normalizing the normal guards against that case.Using your suggestion above (or using
normalize
instead ofnormalize_or_zero
) results in a tangent that has allNaN
values.There's probably an argument to be made that the tangent is likely wrong in either case, but I guess this way it at least aligns with what mikktspace would have produced and doesn't contain
NaN
values.