Skip to content

[ISOMETRIC] [BEVY] Replace MeshPickingPlugin with Rapier raycast hover detection #7866

@h0lybyte

Description

@h0lybyte

Summary

The isometric game uses a two-stage render pipeline (scene camera renders to an offscreen texture, not the window). Bevy's built-in MeshPickingPlugin doesn't work with this setup — hover events never fire.

This replaces it with a custom Rapier raycast-based hover system that casts rays from the orthographic scene camera through the cursor position.

Changes

main.rs

  • Remove MeshPickingPlugin

object_registry.rs

  • Remove per-entity .observe(on_pointer_over) / .observe(on_pointer_out) from all object spawns

scene_objects.rs

  • Remove on_pointer_over / on_pointer_out event handlers
  • Add raycast_hover_detection system using Rapier cast_ray through the orthographic camera
  • Fix update_occlusion to query IsometricCamera instead of Camera3d

Diff

diff --git a/apps/kbve/isometric/src-tauri/src/game/object_registry.rs b/apps/kbve/isometric/src-tauri/src/game/object_registry.rs
index 4c2ef8d42..0d529f564 100644
--- a/apps/kbve/isometric/src-tauri/src/game/object_registry.rs
+++ b/apps/kbve/isometric/src-tauri/src/game/object_registry.rs
@@ -11,8 +11,7 @@ use std::sync::LazyLock;
 use bevy_rapier3d::prelude::*;
 
 use super::scene_objects::{
-    AnimatedCrystal, HoverOutline, Occludable, OriginalEmissive, RotatingBox, on_pointer_out,
-    on_pointer_over,
+    AnimatedCrystal, HoverOutline, Occludable, OriginalEmissive, RotatingBox,
 };
 use super::terrain::TerrainMap;
 
@@ -339,8 +338,6 @@ fn spawn_object_entity(
                         half_extents: Vec3::splat(half),
                     },
                 ))
-                .observe(on_pointer_over)
-                .observe(on_pointer_out)
                 .id()
         }
         ObjectKind::DarkCrate => {
@@ -367,8 +364,6 @@ fn spawn_object_entity(
                         half_extents: Vec3::splat(half),
                     },
                 ))
-                .observe(on_pointer_over)
-                .observe(on_pointer_out)
                 .id()
         }
         ObjectKind::Crystal => {
@@ -393,8 +388,6 @@ fn spawn_object_entity(
                         half_extents: Vec3::splat(1.0),
                     },
                 ))
-                .observe(on_pointer_over)
-                .observe(on_pointer_out)
                 .id()
         }
         ObjectKind::Pillar => commands
@@ -417,8 +410,6 @@ fn spawn_object_entity(
                     half_extents: Vec3::new(0.4, 2.0, 0.4),
                 },
             ))
-            .observe(on_pointer_over)
-            .observe(on_pointer_out)
             .id(),
         ObjectKind::MetallicSphere => {
             let radius = 0.8;
@@ -442,8 +433,6 @@ fn spawn_object_entity(
                         half_extents: Vec3::splat(radius),
                     },
                 ))
-                .observe(on_pointer_over)
-                .observe(on_pointer_out)
                 .id()
         }
         ObjectKind::SpotLight => commands
diff --git a/apps/kbve/isometric/src-tauri/src/game/scene_objects.rs b/apps/kbve/isometric/src-tauri/src/game/scene_objects.rs
index 39c1af598..98345160a 100644
--- a/apps/kbve/isometric/src-tauri/src/game/scene_objects.rs
+++ b/apps/kbve/isometric/src-tauri/src/game/scene_objects.rs
@@ -1,11 +1,10 @@
-use bevy::picking::events::{Out, Over, Pointer};
 use bevy::prelude::*;
+use bevy::window::PrimaryWindow;
+use bevy_rapier3d::prelude::*;
 
+use super::camera::IsometricCamera;
 use super::player::Player;
 
-// Re-export EntityEvent so event_target() is available
-use bevy::ecs::event::EntityEvent;
-
 /// Marker for objects that become semi-transparent when occluding the player.
 #[derive(Component)]
 pub struct Occludable;
@@ -39,6 +38,7 @@ impl Plugin for SceneObjectsPlugin {
         app.add_systems(
             Update,
             (
+                raycast_hover_detection,
                 animate_crystal,
                 rotate_boxes,
                 update_occlusion,
@@ -49,12 +49,81 @@ impl Plugin for SceneObjectsPlugin {
     }
 }
 
-pub(crate) fn on_pointer_over(trigger: On<Pointer<Over>>, mut commands: Commands) {
-    commands.entity(trigger.event_target()).insert(Hovered);
-}
+fn raycast_hover_detection(
+    windows: Query<&Window, With<PrimaryWindow>>,
+    camera_query: Query<(&GlobalTransform, &Projection), With<IsometricCamera>>,
+    rapier_context: ReadRapierContext,
+    occludable: Query<(), With<Occludable>>,
+    current_hovered: Query<Entity, With<Hovered>>,
+    player_query: Query<Entity, With<Player>>,
+    mut commands: Commands,
+) {
+    let Ok(window) = windows.single() else { return };
+    let Ok((cam_gt, projection)) = camera_query.single() else { return };
+
+    let Some(cursor_pos) = window.cursor_position() else {
+        for entity in &current_hovered {
+            commands.entity(entity).remove::<Hovered>();
+        }
+        return;
+    };
+
+    let Projection::Orthographic(ortho) = projection else { return };
+    let viewport_height = match ortho.scaling_mode {
+        bevy::camera::ScalingMode::FixedVertical { viewport_height } => viewport_height,
+        _ => return,
+    };
+    let half_h = viewport_height / 2.0;
+    let aspect = window.width() / window.height();
+    let half_w = half_h * aspect;
+
+    let ndc_x = (cursor_pos.x / window.width()) * 2.0 - 1.0;
+    let ndc_y = 1.0 - (cursor_pos.y / window.height()) * 2.0;
+
+    let cam_tf = cam_gt.compute_transform();
+    let right = cam_tf.right().as_vec3();
+    let up = cam_tf.up().as_vec3();
+    let forward = cam_tf.forward().as_vec3();
+
+    let ray_origin = cam_tf.translation + right * (ndc_x * half_w) + up * (ndc_y * half_h);
+    let ray_dir = forward;
+
+    let mut filter = QueryFilter::new();
+    if let Ok(player_entity) = player_query.single() {
+        filter = filter.exclude_rigid_body(player_entity);
+    }
+
+    let Ok(context) = rapier_context.single() else { return };
+    let new_hovered = context
+        .cast_ray(ray_origin, ray_dir, 1000.0, false, filter)
+        .and_then(|(entity, _)| {
+            if occludable.get(entity).is_ok() {
+                Some(entity)
+            } else {
+                None
+            }
+        });
 
-pub(crate) fn on_pointer_out(trigger: On<Pointer<Out>>, mut commands: Commands) {
-    commands.entity(trigger.event_target()).remove::<Hovered>();
+    for entity in &current_hovered {
+        if Some(entity) != new_hovered {
+            commands.entity(entity).remove::<Hovered>();
+        }
+    }
+    if let Some(entity) = new_hovered {
+        if current_hovered.get(entity).is_err() {
+            commands.entity(entity).insert(Hovered);
+        }
+    }
 }
 
 fn update_occlusion(
-    camera_query: Query<&GlobalTransform, With<Camera3d>>,
+    camera_query: Query<&GlobalTransform, With<IsometricCamera>>,
     player_query: Query<&GlobalTransform, With<Player>>,
diff --git a/apps/kbve/isometric/src-tauri/src/main.rs b/apps/kbve/isometric/src-tauri/src/main.rs
index 6000c145c..52deaaefa 100644
--- a/apps/kbve/isometric/src-tauri/src/main.rs
+++ b/apps/kbve/isometric/src-tauri/src/main.rs
@@ -3,7 +3,6 @@
 
 use bevy::DefaultPlugins;
 use bevy::app::App;
-use bevy::picking::mesh_picking::MeshPickingPlugin;
 use bevy::prelude::*;
 use bevy_rapier3d::prelude::*;
 
@@ -38,9 +37,6 @@ fn main() {
     }));
 
-    // Mesh picking backend for mouse hover detection on 3D objects
-    app.add_plugins(MeshPickingPlugin);
-
     // Rapier physics engine
     app.add_plugins(RapierPhysicsPlugin::<NoUserData>::default());

Context

Found in worktree kbve-isometric-ui-test during cleanup. Needs review against current feat/isometric-rocks branch before applying.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions