Skip to content

Add skinned mesh picking#23583

Open
greeble-dev wants to merge 5 commits intobevyengine:mainfrom
greeble-dev:skinned-mesh-picking
Open

Add skinned mesh picking#23583
greeble-dev wants to merge 5 commits intobevyengine:mainfrom
greeble-dev:skinned-mesh-picking

Conversation

@greeble-dev
Copy link
Copy Markdown
Contributor

@greeble-dev greeble-dev commented Mar 30, 2026

Objective

Fix #23378. As a bonus, add some useful skinning functions, and add tangents to RayMeshHit.

image

Solution

Add new functions that take the various aspects of a skinned mesh, and return a new Mesh with skinning baked into its positions, normals and tangents.

pub fn skin_mesh(
    input_mesh: &Mesh,
    mesh_from_mesh_bindpose_array: &[Mat4],
) -> Result<Mesh, SkinMeshError>

pub fn skin_mesh_entity(
    mesh_component: &Mesh3d,
    skinned_mesh_component: &SkinnedMesh,
    world_from_mesh: &GlobalTransform,
    mesh_assets: &Assets<Mesh>,
    inverse_bindposes_assets: &Assets<SkinnedMeshInverseBindposes>,
    joint_from_world_query: &Query<&GlobalTransform>,
) -> Result<Mesh, SkinMeshError>

When bevy_picking encounters a skinned mesh, it uses these functions to make a temporary mesh before ray-casting.

The PR also adds tangents to RayMeshHit:

 pub struct RayMeshHit {
     /// The point of intersection in world space.
     pub point: Vec3,
     /// The normal vector of the triangle at the point of intersection. Not guaranteed to be normalized for scaled meshes.
     pub normal: Vec3,
+    /// The tangent vector of the triangle at the point of intersection, if the mesh has tangent attributes.
+    /// Not guaranteed to be normalized for scaled meshes.
+    pub tangent: Option<Vec3>,

I wanted the tangents for testing, but they could be useful elsewhere. There will be a small per-mesh performance cost if the tangent is unwanted. That could be reduced by making the tangent (and other attributes) opt-in or opt-out via MeshRayCastSettings.

Performance

Tested on Fox.glb (25 joints, 576 vertices) with desktop Zen 4:

  • Skinning takes 25.6us, of which 16.1us is the actual vertex skinning - the rest is copying data and calculating joint transforms.
  • Ray intersection takes 6.2us.
  • Total is 31.8us, which is roughly 55ns per vertex (this is very approximate as there's also a per-joint cost).

This seems like a reasonable cost for simple cases - twenty foxes would be 0.64ms.

Picking could be optimized. Currently the whole mesh is copied and skinned, but picking only needs the position attribute for intersection tests - the normals/tangents could be calculated for just the hit triangle. Other attributes don't need to be copied or skinned at all. Fixing this is possible, at the cost of a whole custom implementation of skin_mesh for picking.

Also, the cost of the skin_vertices function is higher than what my gut feeling says it should be. Currently it's roughly 130 cycles per-vertex - I was expecting about half that. Maybe I'm wrong, or maybe there's some space for low-level optimization. One likely culprit is redundant bound checks on arrays that we know are the same size.

Testing

The PR adds a new example:

cargo run --example test_skinned_mesh_picking --features "free_camera"

And some unit tests:

cargo test -p bevy_mesh -- skin_mesh

Concerns

I've left a few comments on debatable parts. Also bevy_mesh/skinning.rs is getting a bit chonky (~1200 lines). Maybe should be refactored into a module with a few parts moved to separate files.

@greeble-dev greeble-dev added C-Bug An unexpected or incorrect behavior C-Feature A new feature, making something new possible A-Animation Make things move and change over time A-Picking Pointing at and selecting objects of all sorts labels Mar 30, 2026
@github-project-automation github-project-automation bot moved this to Needs SME Triage in Animation Mar 30, 2026
Comment on lines +286 to +293
if let Ok(mesh) = skin_mesh_entity(
mesh3d,
skinned_mesh,
transform,
&self.meshes,
&self.skinned_mesh_inverse_bindposes,
&self.joint_transforms,
) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Picking will silently skip over a skinned mesh if there's any errors. This feels like a bad idea, but I'm also cautious of adding a very spammy log. But maybe logging is the least worst option until Bevy gets a better way to handle these kinds of errors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What about warn_once!?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That might be the best compromise, yeah. I'll wait a bit to see if there's any second opinions.

Copy link
Copy Markdown
Contributor

@tbillington tbillington Mar 30, 2026

Choose a reason for hiding this comment

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

There was similar concerns when we added mesh picking in the first place, what if the mesh is malformed etc. Pretty sure think we went with just skipping malformed meshes, I think partially because you likely have bigger concerns than picking is your skinned mesh is broken, you'll likely see it render wrong etc.

Ideally there is a "validated" mesh we could test against but it's a different issue

Comment on lines +620 to +630
let mut positions = match input_mesh
.try_attribute_option(Mesh::ATTRIBUTE_POSITION.id)?
.cloned()
{
Some(VertexAttributeValues::Float32x3(values)) => Some(values),
Some(_) => Err(MeshAttributeError::UnexpectedFormat(
Mesh::ATTRIBUTE_POSITION.name,
Mesh::ATTRIBUTE_POSITION.format,
))?,
None => None,
};
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This boilerplate is repeated for normals and tangents. I did try adding a macro, similar to impl_expect_attribute, but it's kinda special case and also adds a fair amount of code. Maybe boilerplate is the least worst option for now.

Co-authored-by: atlv <email@atlasdostal.com>
Comment on lines +357 to +360
/// Returns a copy of the mesh without attributes, indices, morph targets,
/// and the data derived from them (like the AABB). Settings like
/// `primitive_topology` and `asset_usage` are preserved.
pub fn as_empty(&self) -> Mesh {
Copy link
Copy Markdown
Contributor Author

@greeble-dev greeble-dev Mar 30, 2026

Choose a reason for hiding this comment

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

I added this so that skin_mesh can replace a mesh's attributes while keeping the settings. It's maybe a bit niche for the main mesh API, but other solutions would break easily if Mesh acquires new members.

@greeble-dev greeble-dev added the S-Needs-Review Needs reviewer attention (from anyone!) to move forward label Mar 30, 2026
@tbillington
Copy link
Copy Markdown
Contributor

There is a lot of allocation to build up the skinned mesh for each test which is immediately thrown away. I think it's perfectly fine as a first solution, just wondering what we could do to alleviate it in future.

  • Allow passing in vecs that can be used for mesh positions, reused between each skinned mesh?
  • Some temporary allocator for the skinning scope that is passed down?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Animation Make things move and change over time A-Picking Pointing at and selecting objects of all sorts C-Bug An unexpected or incorrect behavior C-Feature A new feature, making something new possible S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

Mesh picking doesn't support skinned meshes

4 participants