Skip to content

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
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion crates/bevy_gltf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ use bevy_platform_support::collections::HashMap;
use bevy_app::prelude::*;
use bevy_asset::AssetApp;
use bevy_image::CompressedImageFormats;
use bevy_mesh::MeshVertexAttribute;
use bevy_mesh::{MeshVertexAttribute, TangentAlgorithm};
use bevy_render::renderer::RenderDevice;

/// The glTF prelude.
Expand All @@ -119,6 +119,8 @@ pub use {assets::*, label::GltfAssetLabel, loader::*};
#[derive(Default)]
pub struct GltfPlugin {
custom_vertex_attributes: HashMap<Box<str>, MeshVertexAttribute>,
/// The algorithm to use when computing tangents for meshes without them.
pub tangent_calculation_algorithm: TangentAlgorithm,
}

impl GltfPlugin {
Expand All @@ -135,6 +137,15 @@ impl GltfPlugin {
self.custom_vertex_attributes.insert(name.into(), attribute);
self
}

/// The algorithm to use when computing mesh tangents.
pub fn with_tangent_calculation_algorithm(
mut self,
tangent_algorithm: TangentAlgorithm,
) -> Self {
self.tangent_calculation_algorithm = tangent_algorithm;
self
}
}

impl Plugin for GltfPlugin {
Expand All @@ -160,6 +171,7 @@ impl Plugin for GltfPlugin {
app.register_asset_loader(GltfLoader {
supported_compressed_formats,
custom_vertex_attributes: self.custom_vertex_attributes.clone(),
tangent_calculation_algorithm: self.tangent_calculation_algorithm,
});
}
}
15 changes: 9 additions & 6 deletions crates/bevy_gltf/src/loader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use bevy_math::{Mat4, Vec3};
use bevy_mesh::{
morph::{MeshMorphWeights, MorphAttributes, MorphTargetImage, MorphWeights},
skinning::{SkinnedMesh, SkinnedMeshInverseBindposes},
Indices, Mesh, MeshVertexAttribute, PrimitiveTopology, VertexAttributeValues,
Indices, Mesh, MeshVertexAttribute, PrimitiveTopology, TangentAlgorithm, VertexAttributeValues,
};
#[cfg(feature = "pbr_transmission_textures")]
use bevy_pbr::UvChannel;
Expand Down Expand Up @@ -146,6 +146,8 @@ pub struct GltfLoader {
/// See [this section of the glTF specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#meshes-overview)
/// for additional details on custom attributes.
pub custom_vertex_attributes: HashMap<Box<str>, MeshVertexAttribute>,
/// The algorithm to use when computing mesh tangents.
pub tangent_calculation_algorithm: TangentAlgorithm,
}

/// Specifies optional settings for processing gltfs at load time. By default, all recognized contents of
Expand Down Expand Up @@ -682,16 +684,17 @@ async fn load_gltf<'a, 'b, 'c>(
&& needs_tangents(&primitive.material())
{
tracing::debug!(
"Missing vertex tangents for {}, computing them using the mikktspace algorithm. Consider using a tool such as Blender to pre-compute the tangents.", file_name
"Missing vertex tangents for {}, computing them using the {:?} algorithm. Consider using a tool such as Blender to pre-compute the tangents.",
file_name, loader.tangent_calculation_algorithm
);

let generate_tangents_span = info_span!("generate_tangents", name = file_name);
let generate_tangents_span = info_span!("compute_tangents", name = file_name);

generate_tangents_span.in_scope(|| {
if let Err(err) = mesh.generate_tangents() {
if let Err(err) = mesh.compute_tangents(loader.tangent_calculation_algorithm) {
warn!(
"Failed to generate vertex tangents using the mikktspace algorithm: {}",
err
"Failed to generate vertex tangents using {:?}: {}",
loader.tangent_calculation_algorithm, err
);
}
});
Expand Down
220 changes: 220 additions & 0 deletions crates/bevy_mesh/src/gramschmidt.rs
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();
Copy link
Contributor

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.

Copy link
Contributor Author

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.

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.

On the other hand, I don't see the normal getting normalised, and that would cause problems in the G-S algo.

This assumes that the vertex normals are normalized. Is that assumption false? If so, I'll need to normalize the normal here.

Copy link
Contributor

@IQuick143 IQuick143 Feb 27, 2025

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.

tangent = P(t).normalise()

where P is the projection operator x -> x - x.dot(normal) * normal, which is linear.

This tells us that if t is not normalised by a positive factor k we have:

P(k*t).normalise() = (k * P(t)).normalise() = P(t).normalise()

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.

Copy link
Contributor Author

@aloucks aloucks Feb 28, 2025

Choose a reason for hiding this comment

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

@IQuick143

How about this?

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();

Copy link
Contributor

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:

let tangent = (tangent - normal * (normal.dot(tangent) / normal.magnitude_squared())).normalize();

Instead of normalising the normal.

Copy link
Contributor Author

@aloucks aloucks Mar 2, 2025

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:

let tangent = (tangent - normal * (normal.dot(tangent) / normal.magnitude_squared())).normalize();

Instead of normalising the normal.

Unfortunately that causes other issues when the normal is broken and has length of zero. The cone unit test fails. Using normalize_or_zero instead of normalize when normalizing the normal guards against that case.

Using your suggestion above (or using normalize instead of normalize_or_zero) results in a tangent that has all NaN values.

thread 'gramschmidt::tests::cone' panicked at crates\bevy_mesh\src\gramschmidt.rs:172:13:
tangents differ significantly: hq = Vec3A(1.0, 2.0480125e-5, -1.684416e-8), fa = Vec3A(NaN, NaN, NaN), angle.to_degrees() = NaN

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.

// 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)));
}
}
3 changes: 2 additions & 1 deletion crates/bevy_mesh/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ extern crate alloc;
extern crate core;

mod conversions;
mod gramschmidt;
mod index;
mod mesh;
mod mikktspace;
Expand All @@ -14,7 +15,7 @@ mod vertex;
use bitflags::bitflags;
pub use index::*;
pub use mesh::*;
pub use mikktspace::*;
use mikktspace::*;
pub use primitives::*;
pub use vertex::*;
pub use wgpu_types::VertexFormat;
Expand Down
Loading