From 96244d3274b127268a681663b749c8fdb0fdd9f1 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 21 May 2026 20:47:24 +0200 Subject: [PATCH] Support for KHR_texture_basisu in bevy-gltf --- crates/bevy_gltf/Cargo.toml | 1 + crates/bevy_gltf/src/lib.rs | 4 +- .../bevy_gltf/src/loader/gltf_ext/texture.rs | 27 +- crates/bevy_gltf/src/loader/mod.rs | 237 +++++++++++++++++- 4 files changed, 263 insertions(+), 6 deletions(-) diff --git a/crates/bevy_gltf/Cargo.toml b/crates/bevy_gltf/Cargo.toml index c9903334426f7..2a425763c2d63 100644 --- a/crates/bevy_gltf/Cargo.toml +++ b/crates/bevy_gltf/Cargo.toml @@ -50,6 +50,7 @@ gltf = { version = "1.4.0", default-features = false, features = [ "KHR_materials_unlit", "KHR_materials_emissive_strength", "KHR_texture_transform", + "allow_empty_texture", "extras", "extensions", "names", diff --git a/crates/bevy_gltf/src/lib.rs b/crates/bevy_gltf/src/lib.rs index 0255efdf51e2c..2887b2234dec1 100644 --- a/crates/bevy_gltf/src/lib.rs +++ b/crates/bevy_gltf/src/lib.rs @@ -114,14 +114,14 @@ //! | `KHR_materials_variants` | ❌ | | //! | `KHR_materials_volume` | ✅ | | //! | `KHR_mesh_quantization` | ❌ | | -//! | `KHR_texture_basisu` | ❌\* | | +//! | `KHR_texture_basisu` | ✅ | `ktx2` | //! | `KHR_texture_transform` | ✅\** | | //! | `KHR_xmp_json_ld` | ❌ | | //! | `EXT_mesh_gpu_instancing` | ❌ | | //! | `EXT_meshopt_compression` | ❌ | | //! | `EXT_texture_webp` | ❌\* | | //! -//! \*Bevy supports ktx2 and webp formats but doesn't support the extension's syntax, see [#19104](https://github.com/bevyengine/bevy/issues/19104). +//! \*Bevy supports webp formats but doesn't support the extension's syntax, see [#19104](https://github.com/bevyengine/bevy/issues/19104). //! //! \**`KHR_texture_transform` is only supported on `base_color_texture`, see [#15310](https://github.com/bevyengine/bevy/issues/15310). //! diff --git a/crates/bevy_gltf/src/loader/gltf_ext/texture.rs b/crates/bevy_gltf/src/loader/gltf_ext/texture.rs index fa62aa1fdbd90..a54683c7ea006 100644 --- a/crates/bevy_gltf/src/loader/gltf_ext/texture.rs +++ b/crates/bevy_gltf/src/loader/gltf_ext/texture.rs @@ -1,7 +1,11 @@ use bevy_image::{ImageAddressMode, ImageFilterMode, ImageSamplerDescriptor}; use bevy_math::Affine2; -use gltf::texture::{MagFilter, MinFilter, Texture, TextureTransform, WrappingMode}; +use gltf::{ + image::Image, + texture::{MagFilter, MinFilter, Texture, TextureTransform, WrappingMode}, + Document, +}; /// Extracts the texture sampler data from the glTF [`Texture`]. pub(crate) fn texture_sampler( @@ -48,6 +52,27 @@ pub(crate) fn texture_sampler( sampler } +pub(crate) fn texture_source<'a>( + texture: &Texture<'a>, + document: &'a Document, +) -> Result>, String> { + if let Some(extension) = texture.extension_value("KHR_texture_basisu") { + let source = extension + .get("source") + .and_then(|source| source.as_u64()) + .and_then(|source| usize::try_from(source).ok()) + .ok_or_else(|| extension.to_string())?; + + return document + .images() + .nth(source) + .ok_or_else(|| source.to_string()) + .map(Some); + } + + Ok(texture.source()) +} + pub(crate) fn address_mode(wrapping_mode: &WrappingMode) -> ImageAddressMode { match wrapping_mode { WrappingMode::ClampToEdge => ImageAddressMode::ClampToEdge, diff --git a/crates/bevy_gltf/src/loader/mod.rs b/crates/bevy_gltf/src/loader/mod.rs index 23526420bf7d9..0fa9b5c3500d0 100644 --- a/crates/bevy_gltf/src/loader/mod.rs +++ b/crates/bevy_gltf/src/loader/mod.rs @@ -44,7 +44,7 @@ use gltf::{ accessor::Iter, image::Source, mesh::{util::ReadIndices, Mode}, - Material, Node, Semantic, + Document, Material, Node, Semantic, }; use serde::{Deserialize, Serialize}; #[cfg(feature = "bevy_animation")] @@ -72,7 +72,7 @@ use self::{ }, mesh::{primitive_name, primitive_topology}, scene::{node_name, node_transform}, - texture::{texture_sampler, texture_transform_to_affine2}, + texture::{texture_sampler, texture_source, texture_transform_to_affine2}, }, }; use crate::convert_coordinates::GltfConvertCoordinates; @@ -92,6 +92,9 @@ pub enum GltfError { /// Invalid glTF file. #[error("invalid glTF file: {0}")] Gltf(#[from] gltf::Error), + /// Unsupported required glTF extension. + #[error("unsupported required glTF extension: {0}")] + UnsupportedRequiredExtension(String), /// Binary blob is missing. #[error("binary blob is missing")] MissingBlob, @@ -114,6 +117,17 @@ pub enum GltfError { /// The image URI was unable to be resolved with respect to the asset path. #[error("invalid image uri: {0}. asset path error={1}")] InvalidImageUri(String, ParseAssetPathError), + /// A texture did not have an image source. + #[error("texture {0} is missing an image source")] + MissingImageSource(usize), + /// A texture extension contained an invalid image source. + #[error("texture {texture} contains an invalid KHR_texture_basisu source: {value}")] + InvalidTextureBasisuSource { + /// The texture index. + texture: usize, + /// The invalid source value. + value: String, + }, /// Failed to read bytes from an asset path. #[error("failed to read bytes from an asset path: {0}")] ReadAssetBytesError(#[from] ReadAssetBytesError), @@ -248,6 +262,9 @@ impl GltfLoader { } else { gltf::Gltf::from_slice_without_validation(bytes)? }; + if settings.validate { + reject_unsupported_empty_texture_extensions(&gltf.document)?; + } // clone extensions to start with a fresh processing state let mut extensions = loader.extensions.read().await.clone(); @@ -619,6 +636,7 @@ impl GltfLoader { for texture in gltf.textures() { let image = load_image( texture.clone(), + &gltf.document, &buffer_data, &linear_textures, load_context.path(), @@ -641,11 +659,13 @@ impl GltfLoader { let textures = IoTaskPool::get().scope(|scope| { gltf.textures().for_each(|gltf_texture| { let asset_path = load_context.path().clone(); + let gltf_document = &gltf.document; let linear_textures = &linear_textures; let buffer_data = &buffer_data; scope.spawn(async move { load_image( gltf_texture, + gltf_document, buffer_data, linear_textures, &asset_path, @@ -1180,9 +1200,22 @@ impl AssetLoader for GltfLoader { } } +fn reject_unsupported_empty_texture_extensions(document: &Document) -> Result<(), GltfError> { + for extension in document.extensions_required() { + if matches!(extension, "EXT_texture_webp" | "MSFT_texture_dds") { + return Err(GltfError::UnsupportedRequiredExtension( + extension.to_string(), + )); + } + } + + Ok(()) +} + /// Loads a glTF texture as a bevy [`Image`] and returns it together with its label. async fn load_image<'a, 'b>( gltf_texture: gltf::Texture<'a>, + gltf_document: &'a Document, buffer_data: &[Vec], linear_textures: &HashSet, gltf_path: &'b AssetPath<'b>, @@ -1197,7 +1230,16 @@ async fn load_image<'a, 'b>( texture_sampler(&gltf_texture, default_sampler) }; - match gltf_texture.source().source() { + let gltf_image = texture_source(&gltf_texture, gltf_document).map_err(|source| { + GltfError::InvalidTextureBasisuSource { + texture: gltf_texture.index(), + value: source, + } + })?; + let gltf_image = + gltf_image.ok_or_else(|| GltfError::MissingImageSource(gltf_texture.index()))?; + + match gltf_image.source() { Source::View { view, mime_type } => { let start = view.offset(); let end = view.offset() + view.length(); @@ -2121,6 +2163,28 @@ mod test { use bevy_reflect::TypePath; use bevy_world_serialization::WorldSerializationPlugin; + #[derive(TypePath)] + struct FakeKtx2Loader; + + impl AssetLoader for FakeKtx2Loader { + type Asset = Image; + type Error = std::io::Error; + type Settings = ImageLoaderSettings; + + async fn load( + &self, + _reader: &mut dyn bevy_asset::io::Reader, + _settings: &Self::Settings, + _load_context: &mut LoadContext<'_>, + ) -> Result { + Ok(Image::default()) + } + + fn extensions(&self) -> &[&str] { + &["ktx2"] + } + } + fn test_app(dir: Dir) -> App { let mut app = App::new(); let reader = MemoryAssetReader { root: dir }; @@ -2691,6 +2755,173 @@ mod test { }); } + #[test] + fn reads_khr_texture_basisu_source() { + let (mut app, dir) = test_app_custom_asset_source(); + + app.init_asset::(); + + dir.insert_asset_text( + Path::new("abc.gltf"), + r#" +{ + "asset": { + "version": "2.0" + }, + "extensionsUsed": [ + "KHR_texture_basisu" + ], + "textures": [ + { + "source": 0, + "extensions": { + "KHR_texture_basisu": { + "source": 1 + } + } + } + ], + "images": [ + { + "uri": "abc.png" + }, + { + "uri": "abc.ktx2" + } + ], + "materials": [ + { + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0, + "texCoord": 0 + } + } + } + ] +} +"#, + ); + dir.insert_asset_text(Path::new("abc.png"), "png"); + dir.insert_asset_text(Path::new("abc.ktx2"), "ktx2"); + + app.init_asset::() + .register_asset_loader(FakeKtx2Loader); + + let asset_server = app.world().resource::().clone(); + let handle: Handle = asset_server.load("custom://abc.gltf"); + run_app_until(&mut app, |_world| { + asset_server + .is_loaded_with_dependencies(&handle) + .then_some(()) + }); + } + + #[test] + fn reads_required_khr_texture_basisu_source() { + let (mut app, dir) = test_app_custom_asset_source(); + + app.init_asset::(); + + dir.insert_asset_text( + Path::new("abc.gltf"), + r#" +{ + "asset": { + "version": "2.0" + }, + "extensionsUsed": [ + "KHR_texture_basisu" + ], + "extensionsRequired": [ + "KHR_texture_basisu" + ], + "textures": [ + { + "extensions": { + "KHR_texture_basisu": { + "source": 0 + } + } + } + ], + "images": [ + { + "uri": "abc.ktx2" + } + ], + "materials": [ + { + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0, + "texCoord": 0 + } + } + } + ] +} +"#, + ); + dir.insert_asset_text(Path::new("abc.ktx2"), "ktx2"); + + app.init_asset::() + .register_asset_loader(FakeKtx2Loader); + + let asset_server = app.world().resource::().clone(); + let handle: Handle = asset_server.load("custom://abc.gltf"); + run_app_until(&mut app, |_world| { + asset_server + .is_loaded_with_dependencies(&handle) + .then_some(()) + }); + } + + #[test] + fn invalid_khr_texture_basisu_source_is_an_error() { + let (mut app, dir) = test_app_custom_asset_source(); + + dir.insert_asset_text( + Path::new("abc.gltf"), + r#" +{ + "asset": { + "version": "2.0" + }, + "extensionsUsed": [ + "KHR_texture_basisu" + ], + "textures": [ + { + "extensions": { + "KHR_texture_basisu": { + "source": 0 + } + } + } + ] +} +"#, + ); + + app.init_asset::(); + + let asset_server = app.world().resource::().clone(); + let handle: Handle = asset_server.load("custom://abc.gltf"); + run_app_until(&mut app, |_| match asset_server.load_state(&handle) { + LoadState::Failed(err) => { + let err = err.to_string(); + assert!( + err.contains("invalid KHR_texture_basisu source: 0"), + "incorrect error message: {err}" + ); + Some(()) + } + LoadState::Loading => None, + state => panic!("Unexpected load state: {state:?}"), + }); + } + #[test] fn image_error_is_an_error() { let (mut app, dir) = test_app_custom_asset_source();