bytestring-net / bevy_lunex

Blazingly fast path based retained layout engine for Bevy entities, built around vanilla Bevy ECS.
https://bytestring-net.github.io/bevy_lunex/
Apache License 2.0
597 stars 24 forks source link

Efficient cursor mapping #5

Closed UkoeHB closed 1 year ago

UkoeHB commented 1 year ago

The cyberpunk example has this code, where you iterate over all widgets to detect which ones are underneath the cursor. I'd like to propose a more efficient approach.

New rules: 1) Only widgets at the same level in a tree are sorted. A child widget of one branch is always sorted higher than a child widget of another branch if the first branch is sorted above the second branch. (this may already be a rule idk) 2) The bounding box of a widget always automatically expands/shrinks to encompass all child widgets.

Now, to identify which widget is under the cursor, you find the highest-sorted widget at the base of the tree that contains the cursor. Then the highest sorted child widget of that widget under the cursor, and so on until you reach the terminal widget under the cursor. If that widget does not 'interact' with the cursor state (is non-blocking [e.g. invisible], has no registered handlers for the cursor state [e.g. an invisible list widget may respond to mouse wheel events but wants clicks and hovers to fall through], and does not have any rules propagating the cursor state to parent widgets [this may be redundant if you need a handler to propagate to parents]), then backtrack to the next candidate. Continue back-tracking until you find a viable candidate to consume the cursor state/event.

Re: 'propagating cursor interactions to parent widgets' Since only the most-viable candidate (the top-most widget under the cursor) is selected for cursor interactions, if you want multiple widgets to react to a cursor (e.g. a stack of widgets that are affected by cursor hover or click), then we need a way to propagate cursor interactions to associated widgets. I think you could have 'interaction bundles' or maybe 'interaction channels' or maybe just entity relations that propagate cursor events as in bevy_event_listener.

To round off this proposal, you could register event handlers to each widget and implement an event handling algorithm to do all of the above automatically.

UkoeHB commented 1 year ago

Incidentally, this would fix a pretty serious bug in the cyperpunk example... if you go to settings then click the spot where the 'exit game' button is in the main menu, then the app will close! (I thought the game crashed lmao)

UkoeHB commented 1 year ago

After using the library for a few days I'm no longer convinced my solution in this issue is ideal. I do think it's necessary to have more efficient mapping to avoid iterating over all widgets, but it might be possible to do bespoke solutions via query filters instead of in bevy_lunex. For example, if you encounter perf issues due to too many clickable widgets (e.g. you have an inventory system with 1000 items, but only some of those widgets are visible), you can add a Visible component to visible widgets.

I implemented a couple high-level filters in my initial button-clicking implementation: OnClick callbacks and InteractionBarrier components (which are necessary to prevent clicks falling through widget surfaces to buttons behind them - e.g. for popups where you want the background widget to stay visible but not clickable).

//todo: assumes all widgets are in MainUI
pub(crate) fn apply_on_click(
    mut commands       : Commands,
    mouse_button_input : Res<Input<MouseButton>>,
    main_ui            : Query<&UiTree, With<MainUI>>,
    main_cursor        : Query<&Cursor, With<MainCursor>>,
    barrier_widgets    : Query<&Widget, With<InteractionBarrier>>,
    clickable_widgets  : Query<(&Widget, &Callback<OnClick>)>,
){
    // check if the mouse was just clicked
    if !mouse_button_input.just_pressed(MouseButton::Left) { return; }

    // prep
    let main_ui     = main_ui.get_single().unwrap();
    let main_cursor = main_cursor.get_single().unwrap();
    let cursor_position = main_cursor.position_world().as_lunex(main_ui.offset);

    // find top-most barrier widget under the mouse
    let mut depth_limit: Option<f32> = None;

    for widget in barrier_widgets.iter()
    {
        // check visibility
        let widget_branch = widget.fetch(&main_ui).unwrap();
        if !widget_branch.is_visible() { continue; }

        // check if barrier widget is above the top-most barrier widget under the mouse
        let widget_depth = widget_branch.get_depth();
        if let Some(top) = depth_limit { if top > widget_depth { continue; } }

        // check if widget is under the mouse
        if !widget.contains_position(&main_ui, &cursor_position).unwrap() { continue; }

        // save this widget as best barrier
        depth_limit = Some(widget_depth);
    }

    // give up if click is not on an interaction barrier
    let Some(mut depth_limit) = depth_limit else { return; };

    // find top-most clickable widget
    let mut top_callback: Option<&Callback<OnClick>> = None;

    for (widget, onclick_callback) in clickable_widgets.iter()
    {
        // check visibility
        let widget_branch = widget.fetch(&main_ui).unwrap();
        if !widget_branch.is_visible() { continue; }

        // check if widget is above the current depth limit
        let widget_depth = widget_branch.get_depth();
        if depth_limit > widget_depth { continue; }

        // check if widget is under the mouse
        if !widget.contains_position(&main_ui, &cursor_position).unwrap() { continue; }

        // save this widget as best candidate
        depth_limit  = widget_depth;
        top_callback = Some(onclick_callback);
    }

    // queue onclick callback for topmost visible widget under the click
    if let Some(callback) = top_callback { commands.add(callback.clone()) }
}
UkoeHB commented 1 year ago

I am pursuing a more complete solution in bevy_kot.