Skip to content

Investigate a multipass (two-pass) version of egui #843

@emilk

Description

@emilk

EDITED 2021-10-30: Expanded and added proposal
EDITED 2021-11-01: Realized input must be done in the second pass

Introduction

Problem

One downside of immediate mode is the difficulty in doing layouts where one needs to know the size of a section before positioning it, but to know the size of that section one needs to call the code that creates it - but that also positions it.

Consider the menus in egui: we use a justified layout to make sure the buttons cover the full width of the menu, but how wide should the menu be? We won't know until we put in all the buttons, and we don't know how wide to make the buttons until we know the width of the menu:

| Save        |
| Preferences |
| Quit        |

How do we know to make the "Save" button wide until we know there is a "Preferences" button? We don't!

Storing the size from the previous frame is also not a solution, because if we then remove the "Preferences" button, the menu will not shrink to the smaller size, but would stay at the widest it has ever been. We see this problem today with some Window:s in egui, which will auto-expand but never auto-shrink.

Because of issues like these, egui can feel janky an unreliable today. As a user it is difficult to understand why it is possible to center some widgets, but not other ("composite" widgets, which don't know their sizes beforehand). It also produces frame-delay jank (open a new window and it will often flicker the first frame, before "finding its size").

I want egui to "just work".

Two-pass

Some immediate mode UI:s take a two-pass approach to solving this a size pass and a layout pass. In the first pass, sizes of ui:s are calculated and stored, and in the second pass the sizes are used to nicely position things. This can be extended to even more passes, but with diminishing returns.

I believe a two-pass approach could solve most of the layout issues in egui.

It will also make some widgets easier to implement: instead of having to know all the sizes of its part beforehand, it can just have it automatically calculated in the first pass, and then use it in the second pass.

Things to consider

Consistency

Both passes should ideally act exactly the same, which will be impossible to guarantee when it comes to user code. For instance, say the user code checks "Is the async download complete?" before deciding to show a widget or not. What if the download completes right in the middle of the two passes? Then the layout pass would not have the stored size of the widget.

So the layout pass need to be robust against missing sizes, falling back to some "okish" behavior (e.g. what egui currently does).

Such inconsistency will always happen with input:

Input

Inputs events should only be handled in one of the passes, otherwise we would enter text twice in a TextEdit, scroll twice as much in ScrollArea etc. Inputs must be consumed, whereas currently in egui inputs are read.

We can't handle clicks in the first pass, since we don't know where the widgets are (yet), so for consistency we should probably handle all events in the second pass.

Performance

Running the UI code twice will of course be slower, but not necessarily twice as slow. We also need to store and load sizes, which adds some overhead.

However, the first pass requires no painting, so we can skip some painting code in the widgets, and of course skip the tessellation completely.

There may also be opportunities for simplifying the layout algorithms when we know the sizes of things, potentially giving some savings, though i doubt it will be much.

I expect something like 50%-100% overhead from adding a second pass.

Compatibility

Should the two-pass approach be the only thing egui support, or should we also support the single-pass approach?

What sizes are stored?

Should we store the size of each Button? Probably not - a button already knowns its own size!


Proposal

I propose we support both one-pass and two-pass modes, as a global setting to egui (or controlled by how you call it), with one-pass being default as a start. This will allow us to experiment with two-pass on master.

Example

Example for what should work:

ui.top_down_centered(add_content: impl FnOnce(Ui) -> R) -> InnerResponse<R> {
    ui.horizontal(|ui| {});
    ui.horizontal(|ui| {});
}

How: horizontal calls ui.scope which does all the magic:

impl Ui {
    fn scope(&mut self, add_content: impl FnOnce(Ui) -> R) -> InnerResponse<R> {
        // pseudo-code:
        let id = self.advance_scope_id();
        let size: Option<Vec2> = self.ctx.memory().sizes().get_prev_pass(id);
        let rect = self.layout.place_next(size);
        let mut child = self.child(rect);
        let r = add_content(&mut child);
        self.layout.advance_after_rect(child.min_rect());
        self.ctx.memory().sizes().set(id, rect.size());
    }
}

So a "scope" is the level at which we store sizes, and every time there is a |ui| { … } closure being passed in, the size of what is generated by that closure is being stored.

Behavior

In the first "size pass", layouts will do simplest/fastest that still calculates sizes correctly.
Centering will be ignored (left-aligned), and justified is ignored (so that the parent size can grow).
Auto-sized windows will assume zero size from the start.

User-code should almost never have to care about what pass is currently running, or even if we are running in one-pass or two-pass mode. Only layout code really cares (and should be able to check with e.g. if ctx.passes.is_first_of_two { … }.

What Context does:

fn run_two_pass(&mut self, raw_input):
    clear size memory

    run size_pass:
        read drained input (no clicks, zero scroll delta, …)
        ignore sizes (since it is empty)
        do simplified layout (left-aligned, no justified, …)
        store sizes of scopes

    update input state from raw_input
    clear any painted shapes

    run layout_pass:
        read sizes from previous pass (some may be missing)
        do proper layout
        use latest input

    drain input (*)


(*) Input draining is removing events and clearing deltas.

Future work

If this works reasonably well we can start considering if two-pass should be default.

We could also consider having one-pass as the default, but allow users to opt-in to two-pass for places where it is needed, e.g.

ui.two_pass(|ui| {});

This is a lot more work to get right though, so let's wait.

Metadata

Metadata

Assignees

No one assigned

    Labels

    designSome architectual design work neededfeatureNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions