Skip to content

Allow users to fix glTF coordinate system imports #19633

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

Merged
merged 40 commits into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fd66831
Add feature to enable coordinate conversion
janhohenheim Jun 13, 2025
2e89b44
Implement vertex attribute coordinate conversion
janhohenheim Jun 13, 2025
2649145
Cleanup some conversion code
janhohenheim Jun 13, 2025
f9b6a94
Convert rotation coords
janhohenheim Jun 13, 2025
891c657
Convert inverse bind matrices
janhohenheim Jun 13, 2025
a108b50
Convert node transforms
janhohenheim Jun 13, 2025
b6ea914
Write warning
janhohenheim Jun 13, 2025
8a377b1
Improve warning
janhohenheim Jun 13, 2025
9d7eab9
Add migration notes
janhohenheim Jun 13, 2025
2e01380
Fix PR number
janhohenheim Jun 13, 2025
9ed7339
Merge branch 'main' into convert-coordinates
janhohenheim Jun 13, 2025
1cd23d1
Fix clippy lint
janhohenheim Jun 13, 2025
8aab8a9
Fix mdlint
janhohenheim Jun 13, 2025
0294578
Add feature
janhohenheim Jun 13, 2025
d116f96
Fix typo
janhohenheim Jun 13, 2025
7434e63
Remove simply
janhohenheim Jun 14, 2025
699d7ab
Improve migration
janhohenheim Jun 14, 2025
0db76a6
Don't convert coords for cameras
janhohenheim Jun 14, 2025
4aaa26b
Revert accidental changes
janhohenheim Jun 14, 2025
6d9fa62
Fix mut
janhohenheim Jun 14, 2025
1fa8c9b
Optimize conversion code
janhohenheim Jun 14, 2025
2b9a727
Revert "Optimize conversion code"
janhohenheim Jun 14, 2025
e3ce3f7
Deactivate coordinate conversion for tests
janhohenheim Jun 14, 2025
58d6aa6
Fix whitespace
janhohenheim Jun 14, 2025
518dc57
Refactor code
janhohenheim Jun 14, 2025
bcd57c5
Fix camera
janhohenheim Jun 14, 2025
14899ca
Fix incorrect transform conversion
janhohenheim Jun 14, 2025
9a9a016
Be confused
janhohenheim Jun 14, 2025
0cc2719
Reapply some stuff
janhohenheim Jun 14, 2025
e89a950
Fix camera rotation
janhohenheim Jun 14, 2025
9094b2a
Fix lints
janhohenheim Jun 14, 2025
f0cd571
Add comments
janhohenheim Jun 14, 2025
41256c6
Fix nonsense
janhohenheim Jun 14, 2025
0e5e164
Remove code duplication
janhohenheim Jun 14, 2025
34681c4
Revert migration path
janhohenheim Jun 14, 2025
7d7feaf
Add release notes
janhohenheim Jun 14, 2025
7ef4a35
Fix typo
janhohenheim Jun 14, 2025
8ee0f09
Fix lints
janhohenheim Jun 14, 2025
c2f0a60
Update convert-coordinates.md
janhohenheim Jun 15, 2025
2f375e6
Update convert-coordinates.md
janhohenheim Jun 15, 2025
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
80 changes: 80 additions & 0 deletions crates/bevy_gltf/src/convert_coordinates.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use core::f32::consts::PI;

use bevy_math::{Mat4, Quat, Vec3};
use bevy_transform::components::Transform;

pub(crate) trait ConvertCoordinates {
/// Converts the glTF coordinates to Bevy's coordinate system.
/// - glTF:
/// - forward: Z
/// - up: Y
/// - right: -X
/// - Bevy:
/// - forward: -Z
/// - up: Y
/// - right: X
///
/// See <https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units>
fn convert_coordinates(self) -> Self;
}

pub(crate) trait ConvertCameraCoordinates {
/// Like `convert_coordinates`, but uses the following for the lens rotation:
/// - forward: -Z
/// - up: Y
/// - right: X
///
/// See <https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#view-matrix>
fn convert_camera_coordinates(self) -> Self;
}

impl ConvertCoordinates for Vec3 {
fn convert_coordinates(self) -> Self {
Vec3::new(-self.x, self.y, -self.z)
}
}

impl ConvertCoordinates for [f32; 3] {
fn convert_coordinates(self) -> Self {
[-self[0], self[1], -self[2]]
}
}

impl ConvertCoordinates for [f32; 4] {
fn convert_coordinates(self) -> Self {
// Solution of q' = r q r*
[-self[0], self[1], -self[2], self[3]]
}
}

impl ConvertCoordinates for Quat {
fn convert_coordinates(self) -> Self {
// Solution of q' = r q r*
Quat::from_array([-self.x, self.y, -self.z, self.w])
}
}

impl ConvertCoordinates for Mat4 {
fn convert_coordinates(self) -> Self {
let m: Mat4 = Mat4::from_scale(Vec3::new(-1.0, 1.0, -1.0));
// Same as the original matrix
let m_inv = m;
m_inv * self * m
}
}

impl ConvertCoordinates for Transform {
fn convert_coordinates(mut self) -> Self {
self.translation = self.translation.convert_coordinates();
self.rotation = self.rotation.convert_coordinates();
self
}
}

impl ConvertCameraCoordinates for Transform {
fn convert_camera_coordinates(mut self) -> Self {
self.translation = self.translation.convert_coordinates();
self.rotate_y(PI);
self
}
}
1 change: 1 addition & 0 deletions crates/bevy_gltf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
//! You can use [`GltfAssetLabel`] to ensure you are using the correct label.

mod assets;
mod convert_coordinates;
mod label;
mod loader;
mod vertex_attributes;
Expand Down
18 changes: 15 additions & 3 deletions crates/bevy_gltf/src/loader/gltf_ext/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use itertools::Itertools;
#[cfg(feature = "bevy_animation")]
use bevy_platform::collections::{HashMap, HashSet};

use crate::GltfError;
use crate::{
convert_coordinates::{ConvertCameraCoordinates as _, ConvertCoordinates as _},
GltfError,
};

pub(crate) fn node_name(node: &Node) -> Name {
let name = node
Expand All @@ -26,8 +29,8 @@ pub(crate) fn node_name(node: &Node) -> Name {
/// on [`Node::transform()`](gltf::Node::transform) directly because it uses optimized glam types and
/// if `libm` feature of `bevy_math` crate is enabled also handles cross
/// platform determinism properly.
pub(crate) fn node_transform(node: &Node) -> Transform {
match node.transform() {
pub(crate) fn node_transform(node: &Node, convert_coordinates: bool) -> Transform {
let transform = match node.transform() {
gltf::scene::Transform::Matrix { matrix } => {
Transform::from_matrix(Mat4::from_cols_array_2d(&matrix))
}
Expand All @@ -40,6 +43,15 @@ pub(crate) fn node_transform(node: &Node) -> Transform {
rotation: bevy_math::Quat::from_array(rotation),
scale: Vec3::from(scale),
},
};
if convert_coordinates {
if node.camera().is_some() {
transform.convert_camera_coordinates()
} else {
transform.convert_coordinates()
}
} else {
transform
}
}

Expand Down
55 changes: 48 additions & 7 deletions crates/bevy_gltf/src/loader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ use self::{
texture::{texture_handle, texture_sampler, texture_transform_to_affine2},
},
};
use crate::convert_coordinates::ConvertCoordinates as _;

/// An error that occurs when loading a glTF file.
#[derive(Error, Debug)]
Expand Down Expand Up @@ -191,6 +192,16 @@ pub struct GltfLoaderSettings {
pub default_sampler: Option<ImageSamplerDescriptor>,
/// If true, the loader will ignore sampler data from gltf and use the default sampler.
pub override_sampler: bool,
/// If true, the loader will convert glTF coordinates to Bevy's coordinate system.
/// - glTF:
/// - forward: Z
/// - up: Y
/// - right: -X
/// - Bevy:
/// - forward: -Z
/// - up: Y
/// - right: X
pub convert_coordinates: bool,
}

impl Default for GltfLoaderSettings {
Expand All @@ -203,6 +214,7 @@ impl Default for GltfLoaderSettings {
include_source: false,
default_sampler: None,
override_sampler: false,
convert_coordinates: false,
}
}
}
Expand Down Expand Up @@ -303,7 +315,16 @@ async fn load_gltf<'a, 'b, 'c>(
match outputs {
ReadOutputs::Translations(tr) => {
let translation_property = animated_field!(Transform::translation);
let translations: Vec<Vec3> = tr.map(Vec3::from).collect();
let translations: Vec<Vec3> = tr
.map(Vec3::from)
.map(|verts| {
if settings.convert_coordinates {
Vec3::convert_coordinates(verts)
} else {
verts
}
})
.collect();
if keyframe_timestamps.len() == 1 {
Some(VariableCurve::new(AnimatableCurve::new(
translation_property,
Expand Down Expand Up @@ -350,8 +371,17 @@ async fn load_gltf<'a, 'b, 'c>(
}
ReadOutputs::Rotations(rots) => {
let rotation_property = animated_field!(Transform::rotation);
let rotations: Vec<Quat> =
rots.into_f32().map(Quat::from_array).collect();
let rotations: Vec<Quat> = rots
.into_f32()
.map(Quat::from_array)
.map(|quat| {
if settings.convert_coordinates {
Quat::convert_coordinates(quat)
} else {
quat
}
})
.collect();
if keyframe_timestamps.len() == 1 {
Some(VariableCurve::new(AnimatableCurve::new(
rotation_property,
Expand Down Expand Up @@ -633,6 +663,7 @@ async fn load_gltf<'a, 'b, 'c>(
accessor,
&buffer_data,
&loader.custom_vertex_attributes,
settings.convert_coordinates,
) {
Ok((attribute, values)) => mesh.insert_attribute(attribute, values),
Err(err) => warn!("{}", err),
Expand Down Expand Up @@ -752,7 +783,17 @@ async fn load_gltf<'a, 'b, 'c>(
let reader = gltf_skin.reader(|buffer| Some(&buffer_data[buffer.index()]));
let local_to_bone_bind_matrices: Vec<Mat4> = reader
.read_inverse_bind_matrices()
.map(|mats| mats.map(|mat| Mat4::from_cols_array_2d(&mat)).collect())
.map(|mats| {
mats.map(|mat| Mat4::from_cols_array_2d(&mat))
.map(|mat| {
if settings.convert_coordinates {
mat.convert_coordinates()
} else {
mat
}
})
.collect()
})
.unwrap_or_else(|| {
core::iter::repeat_n(Mat4::IDENTITY, gltf_skin.joints().len()).collect()
});
Expand Down Expand Up @@ -834,7 +875,7 @@ async fn load_gltf<'a, 'b, 'c>(
&node,
children,
mesh,
node_transform(&node),
node_transform(&node, settings.convert_coordinates),
skin,
node.extras().as_deref().map(GltfExtras::from),
);
Expand Down Expand Up @@ -1306,7 +1347,7 @@ fn load_node(
document: &Document,
) -> Result<(), GltfError> {
let mut gltf_error = None;
let transform = node_transform(gltf_node);
let transform = node_transform(gltf_node, settings.convert_coordinates);
let world_transform = *parent_transform * transform;
// according to https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#instantiation,
// if the determinant of the transform is negative we must invert the winding order of
Expand Down Expand Up @@ -1359,7 +1400,6 @@ fn load_node(
},
..OrthographicProjection::default_3d()
};

Projection::Orthographic(orthographic_projection)
}
gltf::camera::Projection::Perspective(perspective) => {
Expand All @@ -1377,6 +1417,7 @@ fn load_node(
Projection::Perspective(perspective_projection)
}
};

node.insert((
Camera3d::default(),
projection,
Expand Down
71 changes: 51 additions & 20 deletions crates/bevy_gltf/src/vertex_attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use gltf::{
};
use thiserror::Error;

use crate::convert_coordinates::ConvertCoordinates;

/// Represents whether integer data requires normalization
#[derive(Copy, Clone)]
struct Normalization(bool);
Expand Down Expand Up @@ -132,15 +134,23 @@ impl<'a> VertexAttributeIter<'a> {
}

/// Materializes values for any supported format of vertex attribute
fn into_any_values(self) -> Result<Values, AccessFailed> {
fn into_any_values(self, convert_coordinates: bool) -> Result<Values, AccessFailed> {
match self {
VertexAttributeIter::F32(it) => Ok(Values::Float32(it.collect())),
VertexAttributeIter::U32(it) => Ok(Values::Uint32(it.collect())),
VertexAttributeIter::F32x2(it) => Ok(Values::Float32x2(it.collect())),
VertexAttributeIter::U32x2(it) => Ok(Values::Uint32x2(it.collect())),
VertexAttributeIter::F32x3(it) => Ok(Values::Float32x3(it.collect())),
VertexAttributeIter::F32x3(it) => Ok(if convert_coordinates {
Values::Float32x3(it.map(ConvertCoordinates::convert_coordinates).collect())
} else {
Values::Float32x3(it.collect())
}),
VertexAttributeIter::U32x3(it) => Ok(Values::Uint32x3(it.collect())),
VertexAttributeIter::F32x4(it) => Ok(Values::Float32x4(it.collect())),
VertexAttributeIter::F32x4(it) => Ok(if convert_coordinates {
Values::Float32x4(it.map(ConvertCoordinates::convert_coordinates).collect())
} else {
Values::Float32x4(it.collect())
}),
VertexAttributeIter::U32x4(it) => Ok(Values::Uint32x4(it.collect())),
VertexAttributeIter::S16x2(it, n) => {
Ok(n.apply_either(it.collect(), Values::Snorm16x2, Values::Sint16x2))
Expand Down Expand Up @@ -188,7 +198,7 @@ impl<'a> VertexAttributeIter<'a> {
VertexAttributeIter::U16x4(it, Normalization(true)) => Ok(Values::Float32x4(
ReadColors::RgbaU16(it).into_rgba_f32().collect(),
)),
s => s.into_any_values(),
s => s.into_any_values(false),
}
}

Expand All @@ -198,7 +208,7 @@ impl<'a> VertexAttributeIter<'a> {
VertexAttributeIter::U8x4(it, Normalization(false)) => {
Ok(Values::Uint16x4(ReadJoints::U8(it).into_u16().collect()))
}
s => s.into_any_values(),
s => s.into_any_values(false),
}
}

Expand All @@ -211,7 +221,7 @@ impl<'a> VertexAttributeIter<'a> {
VertexAttributeIter::U16x4(it, Normalization(true)) => {
Ok(Values::Float32x4(ReadWeights::U16(it).into_f32().collect()))
}
s => s.into_any_values(),
s => s.into_any_values(false),
}
}

Expand All @@ -224,7 +234,7 @@ impl<'a> VertexAttributeIter<'a> {
VertexAttributeIter::U16x2(it, Normalization(true)) => Ok(Values::Float32x2(
ReadTexCoords::U16(it).into_f32().collect(),
)),
s => s.into_any_values(),
s => s.into_any_values(false),
}
}
}
Expand Down Expand Up @@ -252,28 +262,49 @@ pub(crate) fn convert_attribute(
accessor: gltf::Accessor,
buffer_data: &Vec<Vec<u8>>,
custom_vertex_attributes: &HashMap<Box<str>, MeshVertexAttribute>,
convert_coordinates: bool,
) -> Result<(MeshVertexAttribute, Values), ConvertAttributeError> {
if let Some((attribute, conversion)) = match &semantic {
gltf::Semantic::Positions => Some((Mesh::ATTRIBUTE_POSITION, ConversionMode::Any)),
gltf::Semantic::Normals => Some((Mesh::ATTRIBUTE_NORMAL, ConversionMode::Any)),
gltf::Semantic::Tangents => Some((Mesh::ATTRIBUTE_TANGENT, ConversionMode::Any)),
gltf::Semantic::Colors(0) => Some((Mesh::ATTRIBUTE_COLOR, ConversionMode::Rgba)),
gltf::Semantic::TexCoords(0) => Some((Mesh::ATTRIBUTE_UV_0, ConversionMode::TexCoord)),
gltf::Semantic::TexCoords(1) => Some((Mesh::ATTRIBUTE_UV_1, ConversionMode::TexCoord)),
gltf::Semantic::Joints(0) => {
Some((Mesh::ATTRIBUTE_JOINT_INDEX, ConversionMode::JointIndex))
if let Some((attribute, conversion, convert_coordinates)) = match &semantic {
gltf::Semantic::Positions => Some((
Mesh::ATTRIBUTE_POSITION,
ConversionMode::Any,
convert_coordinates,
)),
gltf::Semantic::Normals => Some((
Mesh::ATTRIBUTE_NORMAL,
ConversionMode::Any,
convert_coordinates,
)),
gltf::Semantic::Tangents => Some((
Mesh::ATTRIBUTE_TANGENT,
ConversionMode::Any,
convert_coordinates,
)),
gltf::Semantic::Colors(0) => Some((Mesh::ATTRIBUTE_COLOR, ConversionMode::Rgba, false)),
gltf::Semantic::TexCoords(0) => {
Some((Mesh::ATTRIBUTE_UV_0, ConversionMode::TexCoord, false))
}
gltf::Semantic::Weights(0) => {
Some((Mesh::ATTRIBUTE_JOINT_WEIGHT, ConversionMode::JointWeight))
gltf::Semantic::TexCoords(1) => {
Some((Mesh::ATTRIBUTE_UV_1, ConversionMode::TexCoord, false))
}
gltf::Semantic::Joints(0) => Some((
Mesh::ATTRIBUTE_JOINT_INDEX,
ConversionMode::JointIndex,
false,
)),
gltf::Semantic::Weights(0) => Some((
Mesh::ATTRIBUTE_JOINT_WEIGHT,
ConversionMode::JointWeight,
false,
)),
gltf::Semantic::Extras(name) => custom_vertex_attributes
.get(name.as_str())
.map(|attr| (*attr, ConversionMode::Any)),
.map(|attr| (*attr, ConversionMode::Any, false)),
_ => None,
} {
let raw_iter = VertexAttributeIter::from_accessor(accessor.clone(), buffer_data);
let converted_values = raw_iter.and_then(|iter| match conversion {
ConversionMode::Any => iter.into_any_values(),
ConversionMode::Any => iter.into_any_values(convert_coordinates),
ConversionMode::Rgba => iter.into_rgba_values(),
ConversionMode::TexCoord => iter.into_tex_coord_values(),
ConversionMode::JointIndex => iter.into_joint_index_values(),
Expand Down
Loading