Skip to content

Commit 0f75142

Browse files
authored
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.
1 parent 8310731 commit 0f75142

File tree

2 files changed

+241
-0
lines changed

2 files changed

+241
-0
lines changed

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4325,6 +4325,14 @@ description = "Demonstrates specular tints and maps"
43254325
category = "3D Rendering"
43264326
wasm = true
43274327

4328+
[[example]]
4329+
name = "test_invalid_skinned_mesh"
4330+
path = "tests/3d/test_invalid_skinned_mesh.rs"
4331+
doc-scrape-examples = true
4332+
4333+
[package.metadata.example.test_invalid_skinned_mesh]
4334+
hidden = true
4335+
43284336
[profile.wasm-release]
43294337
inherits = "release"
43304338
opt-level = "z"

tests/3d/test_invalid_skinned_mesh.rs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
//! Test that the renderer can handle various invalid skinned meshes
2+
3+
use bevy::{
4+
core_pipeline::motion_blur::MotionBlur,
5+
math::ops,
6+
prelude::*,
7+
render::{
8+
camera::ScalingMode,
9+
mesh::{
10+
skinning::{SkinnedMesh, SkinnedMeshInverseBindposes},
11+
Indices, PrimitiveTopology, VertexAttributeValues,
12+
},
13+
render_asset::RenderAssetUsages,
14+
},
15+
};
16+
use core::f32::consts::TAU;
17+
18+
fn main() {
19+
App::new()
20+
.add_plugins(DefaultPlugins)
21+
.insert_resource(AmbientLight {
22+
brightness: 20_000.0,
23+
..default()
24+
})
25+
.add_systems(Startup, (setup_environment, setup_meshes))
26+
.add_systems(Update, update_animated_joints)
27+
.run();
28+
}
29+
30+
fn setup_environment(
31+
mut commands: Commands,
32+
mut mesh_assets: ResMut<Assets<Mesh>>,
33+
mut material_assets: ResMut<Assets<StandardMaterial>>,
34+
) {
35+
let description = "(left to right)\n\
36+
0: Normal skinned mesh.\n\
37+
1: Mesh asset is missing skinning attributes.\n\
38+
2: One joint entity is missing.\n\
39+
3: Mesh entity is missing SkinnedMesh component.";
40+
41+
commands.spawn((
42+
Text::new(description),
43+
Node {
44+
position_type: PositionType::Absolute,
45+
top: Val::Px(12.0),
46+
left: Val::Px(12.0),
47+
..default()
48+
},
49+
));
50+
51+
commands.spawn((
52+
Camera3d::default(),
53+
Transform::from_xyz(0.0, 0.0, 1.0).looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y),
54+
Projection::Orthographic(OrthographicProjection {
55+
scaling_mode: ScalingMode::AutoMin {
56+
min_width: 19.0,
57+
min_height: 6.0,
58+
},
59+
..OrthographicProjection::default_3d()
60+
}),
61+
// Add motion blur so we can check if it's working for skinned meshes.
62+
// This also exercises the renderer's prepass path.
63+
MotionBlur {
64+
// Use an unrealistically large shutter angle so that motion blur is clearly visible.
65+
shutter_angle: 3.0,
66+
samples: 2,
67+
},
68+
// MSAA and MotionBlur together are not compatible on WebGL.
69+
#[cfg(all(feature = "webgl2", target_arch = "wasm32", not(feature = "webgpu")))]
70+
Msaa::Off,
71+
));
72+
73+
// Add a directional light to make sure we exercise the renderer's shadow path.
74+
commands.spawn((
75+
Transform::from_xyz(1.0, 1.0, 3.0).looking_at(Vec3::ZERO, Vec3::Y),
76+
DirectionalLight {
77+
shadows_enabled: true,
78+
..default()
79+
},
80+
));
81+
82+
// Add a plane behind the meshes so we can see the shadows.
83+
commands.spawn((
84+
Transform::from_xyz(0.0, 0.0, -1.0),
85+
Mesh3d(mesh_assets.add(Plane3d::default().mesh().size(100.0, 100.0).normal(Dir3::Z))),
86+
MeshMaterial3d(material_assets.add(StandardMaterial {
87+
base_color: Color::srgb(0.05, 0.05, 0.15),
88+
reflectance: 0.2,
89+
..default()
90+
})),
91+
));
92+
}
93+
94+
fn setup_meshes(
95+
mut commands: Commands,
96+
mut mesh_assets: ResMut<Assets<Mesh>>,
97+
mut material_assets: ResMut<Assets<StandardMaterial>>,
98+
mut inverse_bindposes_assets: ResMut<Assets<SkinnedMeshInverseBindposes>>,
99+
) {
100+
// Create a mesh with two rectangles.
101+
let unskinned_mesh = Mesh::new(
102+
PrimitiveTopology::TriangleList,
103+
RenderAssetUsages::default(),
104+
)
105+
.with_inserted_attribute(
106+
Mesh::ATTRIBUTE_POSITION,
107+
vec![
108+
[-0.3, -0.3, 0.0],
109+
[0.3, -0.3, 0.0],
110+
[-0.3, 0.3, 0.0],
111+
[0.3, 0.3, 0.0],
112+
[-0.4, 0.8, 0.0],
113+
[0.4, 0.8, 0.0],
114+
[-0.4, 1.8, 0.0],
115+
[0.4, 1.8, 0.0],
116+
],
117+
)
118+
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 8])
119+
.with_inserted_indices(Indices::U16(vec![0, 1, 3, 0, 3, 2, 4, 5, 7, 4, 7, 6]));
120+
121+
// Copy the mesh and add skinning attributes that bind each rectangle to a joint.
122+
let skinned_mesh = unskinned_mesh
123+
.clone()
124+
.with_inserted_attribute(
125+
Mesh::ATTRIBUTE_JOINT_INDEX,
126+
VertexAttributeValues::Uint16x4(vec![
127+
[0, 0, 0, 0],
128+
[0, 0, 0, 0],
129+
[0, 0, 0, 0],
130+
[0, 0, 0, 0],
131+
[1, 0, 0, 0],
132+
[1, 0, 0, 0],
133+
[1, 0, 0, 0],
134+
[1, 0, 0, 0],
135+
]),
136+
)
137+
.with_inserted_attribute(
138+
Mesh::ATTRIBUTE_JOINT_WEIGHT,
139+
vec![[1.00, 0.00, 0.0, 0.0]; 8],
140+
);
141+
142+
let unskinned_mesh_handle = mesh_assets.add(unskinned_mesh);
143+
let skinned_mesh_handle = mesh_assets.add(skinned_mesh);
144+
145+
let inverse_bindposes_handle = inverse_bindposes_assets.add(vec![
146+
Mat4::IDENTITY,
147+
Mat4::from_translation(Vec3::new(0.0, -1.3, 0.0)),
148+
]);
149+
150+
let mesh_material_handle = material_assets.add(StandardMaterial::default());
151+
152+
let background_material_handle = material_assets.add(StandardMaterial {
153+
base_color: Color::srgb(0.05, 0.15, 0.05),
154+
reflectance: 0.2,
155+
..default()
156+
});
157+
158+
#[derive(PartialEq)]
159+
enum Variation {
160+
Normal,
161+
MissingMeshAttributes,
162+
MissingJointEntity,
163+
MissingSkinnedMeshComponent,
164+
}
165+
166+
for (index, variation) in [
167+
Variation::Normal,
168+
Variation::MissingMeshAttributes,
169+
Variation::MissingJointEntity,
170+
Variation::MissingSkinnedMeshComponent,
171+
]
172+
.into_iter()
173+
.enumerate()
174+
{
175+
// Skip variations that are currently broken. See https://github.com/bevyengine/bevy/issues/16929,
176+
// https://github.com/bevyengine/bevy/pull/18074.
177+
if (variation == Variation::MissingSkinnedMeshComponent)
178+
|| (variation == Variation::MissingMeshAttributes)
179+
{
180+
continue;
181+
}
182+
183+
let transform = Transform::from_xyz(((index as f32) - 1.5) * 4.5, 0.0, 0.0);
184+
185+
let joint_0 = commands.spawn(transform).id();
186+
187+
let joint_1 = commands
188+
.spawn((ChildOf(joint_0), AnimatedJoint, Transform::IDENTITY))
189+
.id();
190+
191+
if variation == Variation::MissingJointEntity {
192+
commands.entity(joint_1).despawn();
193+
}
194+
195+
let mesh_handle = match variation {
196+
Variation::MissingMeshAttributes => &unskinned_mesh_handle,
197+
_ => &skinned_mesh_handle,
198+
};
199+
200+
let mut entity_commands = commands.spawn((
201+
Mesh3d(mesh_handle.clone()),
202+
MeshMaterial3d(mesh_material_handle.clone()),
203+
transform,
204+
));
205+
206+
if variation != Variation::MissingSkinnedMeshComponent {
207+
entity_commands.insert(SkinnedMesh {
208+
inverse_bindposes: inverse_bindposes_handle.clone(),
209+
joints: vec![joint_0, joint_1],
210+
});
211+
}
212+
213+
// Add a square behind the mesh to distinguish it from the other meshes.
214+
commands.spawn((
215+
Transform::from_xyz(transform.translation.x, transform.translation.y, -0.8),
216+
Mesh3d(mesh_assets.add(Plane3d::default().mesh().size(4.3, 4.3).normal(Dir3::Z))),
217+
MeshMaterial3d(background_material_handle.clone()),
218+
));
219+
}
220+
}
221+
222+
#[derive(Component)]
223+
struct AnimatedJoint;
224+
225+
fn update_animated_joints(time: Res<Time>, query: Query<&mut Transform, With<AnimatedJoint>>) {
226+
for mut transform in query {
227+
let angle = TAU * 4.0 * ops::cos((time.elapsed_secs() / 8.0) * TAU);
228+
let rotation = Quat::from_rotation_z(angle);
229+
230+
transform.rotation = rotation;
231+
transform.translation = rotation.mul_vec3(Vec3::new(0.0, 1.3, 0.0));
232+
}
233+
}

0 commit comments

Comments
 (0)