Add test for invalid skinned meshes (#18763)

## Objective

Add a test that would have caught #16929 and #18712.

## Solution

The PR adds a `test_invalid_skinned_mesh` example that creates various
valid and invalid skinned meshes. This is designed to catch panics via
CI, and can be inspected visually. It also tests skinned meshes + motion
blur.


![417958065-37df8799-b068-48b8-87a4-1f144e883c9c](https://github.com/user-attachments/assets/22b2fa42-628b-48a4-9013-76d6524cecf5)

The screenshot shows all the tests, but two are currently disabled as
they cause panics. #18074 will re-enable them.

### Concerns

- The test is not currently suitable for screenshot comparison.
- I didn't add the test to CI. I'm a bit unsure if this should be part
of the PR or a follow up discussion.
- Visual inspection requires understanding why some meshes are
deliberately broken and what that looks like.
- I wasn't sure about naming conventions. I put `test` in the name so
it's not confused with a real example.

## Testing

```
cargo run --example test_invalid_skinned_mesh
```

Tested on Win10/Nvidia, across Vulkan, WebGL/Chrome, WebGPU/Chrome.
This commit is contained in:
Greeble 2025-07-07 20:28:52 +01:00 committed by GitHub
parent 831073105f
commit 0f75142560
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 241 additions and 0 deletions

View File

@ -4325,6 +4325,14 @@ description = "Demonstrates specular tints and maps"
category = "3D Rendering"
wasm = true
[[example]]
name = "test_invalid_skinned_mesh"
path = "tests/3d/test_invalid_skinned_mesh.rs"
doc-scrape-examples = true
[package.metadata.example.test_invalid_skinned_mesh]
hidden = true
[profile.wasm-release]
inherits = "release"
opt-level = "z"

View File

@ -0,0 +1,233 @@
//! Test that the renderer can handle various invalid skinned meshes
use bevy::{
core_pipeline::motion_blur::MotionBlur,
math::ops,
prelude::*,
render::{
camera::ScalingMode,
mesh::{
skinning::{SkinnedMesh, SkinnedMeshInverseBindposes},
Indices, PrimitiveTopology, VertexAttributeValues,
},
render_asset::RenderAssetUsages,
},
};
use core::f32::consts::TAU;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(AmbientLight {
brightness: 20_000.0,
..default()
})
.add_systems(Startup, (setup_environment, setup_meshes))
.add_systems(Update, update_animated_joints)
.run();
}
fn setup_environment(
mut commands: Commands,
mut mesh_assets: ResMut<Assets<Mesh>>,
mut material_assets: ResMut<Assets<StandardMaterial>>,
) {
let description = "(left to right)\n\
0: Normal skinned mesh.\n\
1: Mesh asset is missing skinning attributes.\n\
2: One joint entity is missing.\n\
3: Mesh entity is missing SkinnedMesh component.";
commands.spawn((
Text::new(description),
Node {
position_type: PositionType::Absolute,
top: Val::Px(12.0),
left: Val::Px(12.0),
..default()
},
));
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 0.0, 1.0).looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y),
Projection::Orthographic(OrthographicProjection {
scaling_mode: ScalingMode::AutoMin {
min_width: 19.0,
min_height: 6.0,
},
..OrthographicProjection::default_3d()
}),
// Add motion blur so we can check if it's working for skinned meshes.
// This also exercises the renderer's prepass path.
MotionBlur {
// Use an unrealistically large shutter angle so that motion blur is clearly visible.
shutter_angle: 3.0,
samples: 2,
},
// MSAA and MotionBlur together are not compatible on WebGL.
#[cfg(all(feature = "webgl2", target_arch = "wasm32", not(feature = "webgpu")))]
Msaa::Off,
));
// Add a directional light to make sure we exercise the renderer's shadow path.
commands.spawn((
Transform::from_xyz(1.0, 1.0, 3.0).looking_at(Vec3::ZERO, Vec3::Y),
DirectionalLight {
shadows_enabled: true,
..default()
},
));
// Add a plane behind the meshes so we can see the shadows.
commands.spawn((
Transform::from_xyz(0.0, 0.0, -1.0),
Mesh3d(mesh_assets.add(Plane3d::default().mesh().size(100.0, 100.0).normal(Dir3::Z))),
MeshMaterial3d(material_assets.add(StandardMaterial {
base_color: Color::srgb(0.05, 0.05, 0.15),
reflectance: 0.2,
..default()
})),
));
}
fn setup_meshes(
mut commands: Commands,
mut mesh_assets: ResMut<Assets<Mesh>>,
mut material_assets: ResMut<Assets<StandardMaterial>>,
mut inverse_bindposes_assets: ResMut<Assets<SkinnedMeshInverseBindposes>>,
) {
// Create a mesh with two rectangles.
let unskinned_mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
)
.with_inserted_attribute(
Mesh::ATTRIBUTE_POSITION,
vec![
[-0.3, -0.3, 0.0],
[0.3, -0.3, 0.0],
[-0.3, 0.3, 0.0],
[0.3, 0.3, 0.0],
[-0.4, 0.8, 0.0],
[0.4, 0.8, 0.0],
[-0.4, 1.8, 0.0],
[0.4, 1.8, 0.0],
],
)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 8])
.with_inserted_indices(Indices::U16(vec![0, 1, 3, 0, 3, 2, 4, 5, 7, 4, 7, 6]));
// Copy the mesh and add skinning attributes that bind each rectangle to a joint.
let skinned_mesh = unskinned_mesh
.clone()
.with_inserted_attribute(
Mesh::ATTRIBUTE_JOINT_INDEX,
VertexAttributeValues::Uint16x4(vec![
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[1, 0, 0, 0],
[1, 0, 0, 0],
[1, 0, 0, 0],
[1, 0, 0, 0],
]),
)
.with_inserted_attribute(
Mesh::ATTRIBUTE_JOINT_WEIGHT,
vec![[1.00, 0.00, 0.0, 0.0]; 8],
);
let unskinned_mesh_handle = mesh_assets.add(unskinned_mesh);
let skinned_mesh_handle = mesh_assets.add(skinned_mesh);
let inverse_bindposes_handle = inverse_bindposes_assets.add(vec![
Mat4::IDENTITY,
Mat4::from_translation(Vec3::new(0.0, -1.3, 0.0)),
]);
let mesh_material_handle = material_assets.add(StandardMaterial::default());
let background_material_handle = material_assets.add(StandardMaterial {
base_color: Color::srgb(0.05, 0.15, 0.05),
reflectance: 0.2,
..default()
});
#[derive(PartialEq)]
enum Variation {
Normal,
MissingMeshAttributes,
MissingJointEntity,
MissingSkinnedMeshComponent,
}
for (index, variation) in [
Variation::Normal,
Variation::MissingMeshAttributes,
Variation::MissingJointEntity,
Variation::MissingSkinnedMeshComponent,
]
.into_iter()
.enumerate()
{
// Skip variations that are currently broken. See https://github.com/bevyengine/bevy/issues/16929,
// https://github.com/bevyengine/bevy/pull/18074.
if (variation == Variation::MissingSkinnedMeshComponent)
|| (variation == Variation::MissingMeshAttributes)
{
continue;
}
let transform = Transform::from_xyz(((index as f32) - 1.5) * 4.5, 0.0, 0.0);
let joint_0 = commands.spawn(transform).id();
let joint_1 = commands
.spawn((ChildOf(joint_0), AnimatedJoint, Transform::IDENTITY))
.id();
if variation == Variation::MissingJointEntity {
commands.entity(joint_1).despawn();
}
let mesh_handle = match variation {
Variation::MissingMeshAttributes => &unskinned_mesh_handle,
_ => &skinned_mesh_handle,
};
let mut entity_commands = commands.spawn((
Mesh3d(mesh_handle.clone()),
MeshMaterial3d(mesh_material_handle.clone()),
transform,
));
if variation != Variation::MissingSkinnedMeshComponent {
entity_commands.insert(SkinnedMesh {
inverse_bindposes: inverse_bindposes_handle.clone(),
joints: vec![joint_0, joint_1],
});
}
// Add a square behind the mesh to distinguish it from the other meshes.
commands.spawn((
Transform::from_xyz(transform.translation.x, transform.translation.y, -0.8),
Mesh3d(mesh_assets.add(Plane3d::default().mesh().size(4.3, 4.3).normal(Dir3::Z))),
MeshMaterial3d(background_material_handle.clone()),
));
}
}
#[derive(Component)]
struct AnimatedJoint;
fn update_animated_joints(time: Res<Time>, query: Query<&mut Transform, With<AnimatedJoint>>) {
for mut transform in query {
let angle = TAU * 4.0 * ops::cos((time.elapsed_secs() / 8.0) * TAU);
let rotation = Quat::from_rotation_z(angle);
transform.rotation = rotation;
transform.translation = rotation.mul_vec3(Vec3::new(0.0, 1.3, 0.0));
}
}