-1

The following problem originates from https://github.com/cycfi/elements/issues/144 which is me struggling to find a way in elements GUI library to invoke a callback once per frame.

So far in every library I have seen, there is some callback/explicit loop that continuously processes user input, measures time since last frame and performs the render.

In the elements library, such loop is a platform-specific implementation detail and instead, the library-using code is given access to boost::asio::io_context object to which any callable can be posted. poll is invoked inside platform-specific event loop.

I had no problems changing code from typical waterfall update(time_since_last_frame) to posting functors that do it, however this is where the real problem begins:

  • Posted functors are only invoked once. The answer from the library author is "just post again".
  • If I post again immediately from the functor, I create an endless busy loop because as soon as one functor from the poll is completed, boost asio runs the newly posted one. This completely freezes the thread that runs the GUI because of an infinite self-reposting callback loop. The answer from the library author is "post with a timer".
  • If I post with a timer, I don't fix anything:
    • If the time is too small, it runs out before the callback finishes so the newly posted callback copy is invoked again ... which brings infinite loop again.
    • If the time is too large to cause an infinite loop, but small enough to fit in multiple times within one frame, it is run multiple times per frame ... which is a waste because there is no point in calculating UI/animation/input state multiple times per frame.
    • If the time is too large, the callback is not invoked on each frame. The application renders multiple times without processing user-generated events ... which is a waste because identical state is rendered multiple times for each logic update.
    • There is no way to calculate FPS because library-using code does not even know how many frames have been rendered between posted callbacks (if any).

In other words:

  • In a typical update+input+render loop the loop runs as fast as possible, yielding as many frames as it can (or to a specified cap thanks to sleeps). If the code is slow, it's just FPS loss.
  • In elements library, if the callback is too fast it is repeated multiple times per frame because registered timer may finish multiple times within one frame. If the code is too slow, it's a "deadlock" callback loop that never gets out of asio's poll.

I do not want my code to be invoked every X time (or more-than-X because of OS-scheduler). I want my code to be invoked once per frame (preferably with the time delta argument, but I can also measure it myself from previous invokation).

Is such usage of asio in the elements library a bad design? I find the "post with a timer" solution to be an antipattern. It feels to me like fixing a deadlock between 2 threads by adding a sleep in one of them and hoping they will never collide after such change - in case of elements I'm posting a timed callback and hoping it's not too fast to waste CPU but also not to slow to cause infinite timed-callback loop. The ideal time is too hard to calculate because of so many factors that can affect it, including user actions - basically a lose-lose situation.

Extra note 1: I have tried defer instead of poll, no difference.

Extra note 2: I have already created 100+ issues/PRs for the library so it's very likely that a motivating answer will end in another PR. In other words, solutions that attempt to modify library are fine too.

Extra note 3: MCVE (here without a timer, which causes almost-infinite loop until the counter finishes, during coutning the GUI thread is frozen):

#include <elements.hpp>

using namespace cycfi::elements;

bool func()
{
        static int x = 0;
        if (++x == 10'000'000)
                return true;

        return false;
}

void post_func(view& v)
{
        if (!func())
                v.post([&v](){ post_func(v); });
}

int main(int argc, char* argv[])
{
    app _app(argc, argv);
    window _win(_app.name());
    _win.on_close = [&_app]() { _app.stop(); };

    view view_(_win);
    view_.content(box(rgba(35, 35, 37, 255)));

    view_.post([&view_](){ post_func(view_); });

    _app.run();
    return 0;
}
sehe
  • 374,641
  • 47
  • 450
  • 633
Xeverous
  • 973
  • 1
  • 12
  • 25
  • "self reposting" and "once per frame" are definitely contradictory goals. Can you perhaps clarify (perhaps by using fewer words?) – sehe Jun 15 '20 at 19:54
  • I want my code to be called once per frame, by "self-reposting" I mean a callable that when finishes, adds another copy of itself to asio's io context queue. Corrected the title. – Xeverous Jun 15 '20 at 20:00
  • That means you'll end up calling it a zillion times per frame. (Because you add one per frame, but every previously posted handler keeps reposting itself) – sehe Jun 15 '20 at 20:07
  • That's what I'm trying to avoid. I can add a timer so it is called after X time, but it has no guuarantees the invoked exactly once between frames. – Xeverous Jun 15 '20 at 20:55

1 Answers1

0

So, finally found time to look at this.

In the back-end it seems that Elements already integrates with Asio. Therefore, when you post tasks to the view with they become async tasks.

You can give them a delay, so you don't have to busy loop.

Let's do a demo

Defining A Task

Let's define a task that has fake progress and a fixed deadline for completion:

#include <utility>
#include <chrono>
using namespace std::chrono_literals;
auto now = std::chrono::high_resolution_clock::now;

struct Task {
    static constexpr auto deadline = 2.0s;
    std::chrono::high_resolution_clock::time_point _start = now();
    bool _done = false;

    void reset() { *this = {}; }

    auto elapsed() const { return now() - _start; } // fake progress
    auto done() { return std::exchange(_done, elapsed() > deadline); }
};

How To Self-Chain?

As you noticed, this is tricky. You can stoop and just type-erase your handler:

std::function<void()> cheat;
cheat = [&cheat]() {
    // do something
    cheat(); // self-chain
};

However, just to humor you, let me introduce what functional programming calls the Y combinator.

#include

template<class Fun> struct ycombi {
    Fun fun_;
    explicit ycombi(Fun fun): fun_(std::move(fun)) {}
    template<class ...Args> void operator()(Args &&...args) const {
        return fun_(*this, std::forward<Args>(args)...);
    }
};

With that, we can create a generic handler posting chainer:

auto chain = [&view_](auto f) {
    return ycombi{ [=, &view_](auto self) {
        view_.post(10ms, [=] {
            if (f())
                self();
        });
    } };
};

I opted for 10ms delay, but you don't have to. Doing no delay means "asap" which would amount to every frame, given the resources.

A Reporter Task

Let's update a progress-bar:

auto prog_bar = share(progress_bar(rbox(colors::black), rbox(pgold)));

auto make_reporter = [=, &view_](Task& t) {
    static int s_reporter_id = 1;
    return [=, id=s_reporter_id++, &t, &view_] {
        std::clog << "reporter " << id << " task at " << (t.elapsed() / 1.0ms) << "ms " << std::endl;

        prog_bar->value(t.elapsed() / Task::deadline);
        view_.refresh(*prog_bar);

        if (t.done()) {
            std::clog << "done" << std::endl;
            return false;
        }
        return true;
    };
};

Now. let's add a button to start updating the progress bar.

auto task_btn = button("Task #1");
task_btn.on_click = [=,&task1](bool) {
    if (task1.done())
        task1.reset();
    auto progress = chain(make_reporter(task1));
    progress();
};

Let's put the button and the bar in the view and run the app:

view_.content(task_btn, prog_bar);
view_.scale(8);

_app.run();

enter image description here

Full Listing

Used current Elements master (a7d1348ae81f7c)

  • File test.cpp

     #include <utility>
     #include <chrono>
     using namespace std::chrono_literals;
     auto now = std::chrono::high_resolution_clock::now;
    
     struct Task {
         static constexpr auto deadline = 2.0s;
         std::chrono::high_resolution_clock::time_point _start = now();
         bool _done = false;
    
         void reset() { *this = {}; }
    
         auto elapsed() const { return now() - _start; } // fake progress
         auto done() { return std::exchange(_done, elapsed() > deadline); }
     };
    
     #include <functional>
    
     template<class Fun> struct ycombi {
         Fun fun_;
         explicit ycombi(Fun fun): fun_(std::move(fun)) {}
         template<class ...Args> void operator()(Args &&...args) const {
             return fun_(*this, std::forward<Args>(args)...);
         }
     };
    
     #include <elements.hpp>
     #include <iostream>
    
     using namespace cycfi::elements;
    
     constexpr auto bred   = colors::red.opacity(0.4);
     constexpr auto bgreen = colors::green.level(0.7).opacity(0.4);
     constexpr auto bblue  = colors::blue.opacity(0.4);
     constexpr auto brblue = colors::royal_blue.opacity(0.4);
     constexpr auto pgold  = colors::gold.opacity(0.8);
    
     int main(int argc, char* argv[]) {
         app _app(argc, argv);
         window _win(_app.name());
         _win.on_close = [&_app]() { _app.stop(); };
    
         view view_(_win);
    
         Task task1;
    
         auto chain = [&view_](auto f) {
             return ycombi{ [=, &view_](auto self) {
                 view_.post(10ms, [=] {
                     if (f())
                         self();
                 });
             } };
         };
    
         auto prog_bar = share(progress_bar(rbox(colors::black), rbox(pgold)));
    
         auto make_reporter = [=, &view_](Task& t) {
             static int s_reporter_id = 1;
             return [=, id=s_reporter_id++, &t, &view_] {
                 std::clog << "reporter " << id << " task at " << (t.elapsed() / 1.0ms) << "ms " << std::endl;
    
                 prog_bar->value(t.elapsed() / Task::deadline);
                 view_.refresh(*prog_bar);
    
                 if (t.done()) {
                     std::clog << "done" << std::endl;
                     return false;
                 }
                 return true;
             };
         };
    
         auto task_btn = button("Task #1");
         task_btn.on_click = [=,&task1](bool) {
             if (task1.done())
                 task1.reset();
             auto progress = chain(make_reporter(task1));
             progress();
         };
    
         view_.content(task_btn, prog_bar);
         view_.scale(8);
    
         _app.run();
     }
    
sehe
  • 374,641
  • 47
  • 450
  • 633
  • Thanks for the detailed example, but this is not a valid answer. 10ms does not guuarantee anything other than being called after 10ms (or more depending on OS scheduler). *"Doing no delay means "asap" which would amount to every frame"* - this is wrong - just try it. If you set no delay, the posted handler is invoked immediately over and over because with the ycombinator the asio queue never runs out of tasks. Having no delay and reposting the function is basically an infinite loop. To save you some time - I have already found the way - it is to inherit from `view` and override `poll`. – Xeverous Jun 21 '20 at 19:08
  • I based my remark on a statement by the developer of the library : [_"view::post is async and posted tasks are handled once every 60fps in the main thread"_](https://github.com/cycfi/elements/issues/144#issuecomment-642058477). Don't shoot the messenger. Also, it's harsh to call the answer "invalid" for that. Also, don't save ME some time. You save people time bv posting your answer! – sehe Jun 21 '20 at 19:47